Compare commits
12 Commits
b00a930e8d
...
03a8f48e8d
| Author | SHA1 | Date |
|---|---|---|
|
|
03a8f48e8d | |
|
|
5ae3da3c1e | |
|
|
fed796e6cc | |
|
|
978051bf49 | |
|
|
7023b2e040 | |
|
|
4211648e2a | |
|
|
4b1edd4d27 | |
|
|
ace2518349 | |
|
|
19852fd4bb | |
|
|
f56b5374e4 | |
|
|
7df62e06ca | |
|
|
565b851462 |
|
|
@ -122,6 +122,40 @@ sf apex run test --class-names DocusignEnvelopeRequestHandlerTest --method-names
|
|||
|
||||
---
|
||||
|
||||
**Run tests for a specific class only:**
|
||||
|
||||
Bash/WSL:
|
||||
```bash
|
||||
# Test the main invocable class
|
||||
sf apex run test --class-names DocusignCompositeEnvelopeBuilderTest --wait 10 --result-format human --code-coverage --target-org dev-org
|
||||
|
||||
# Test the request handler
|
||||
sf apex run test --class-names DocusignEnvelopeRequestHandlerTest --wait 10 --result-format human --code-coverage --target-org dev-org
|
||||
|
||||
# Test the API service
|
||||
sf apex run test --class-names DocusignAPIServiceTest --wait 10 --result-format human --code-coverage --target-org dev-org
|
||||
|
||||
# Test the credentials helper
|
||||
sf apex run test --class-names DocusignCredentialsTest --wait 10 --result-format human --code-coverage --target-org dev-org
|
||||
```
|
||||
|
||||
PowerShell:
|
||||
```powershell
|
||||
# Test the main invocable class
|
||||
sf apex run test --class-names DocusignCompositeEnvelopeBuilderTest --wait 10 --result-format human --code-coverage --target-org dev-org
|
||||
|
||||
# Test the request handler
|
||||
sf apex run test --class-names DocusignEnvelopeRequestHandlerTest --wait 10 --result-format human --code-coverage --target-org dev-org
|
||||
|
||||
# Test the API service
|
||||
sf apex run test --class-names DocusignAPIServiceTest --wait 10 --result-format human --code-coverage --target-org dev-org
|
||||
|
||||
# Test the credentials helper
|
||||
sf apex run test --class-names DocusignCredentialsTest --wait 10 --result-format human --code-coverage --target-org dev-org
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
**Run ALL tests with JSON output (for parsing):**
|
||||
|
||||
Bash/WSL:
|
||||
|
|
@ -184,6 +218,75 @@ Apex Code Coverage: 92%
|
|||
|
||||
---
|
||||
|
||||
## Updating Template IDs After Sandbox Refresh
|
||||
|
||||
After a sandbox refresh, Docusign template IDs may become stale and need updating with the correct demo/sandbox template IDs.
|
||||
|
||||
### Find Your Demo Template IDs
|
||||
|
||||
1. Log into your Docusign demo account: **https://demo.docusign.net**
|
||||
2. Go to **Templates**
|
||||
3. Click each template — the GUID is in the URL or under **Template ID**
|
||||
4. Copy the template IDs you need
|
||||
|
||||
### Option 1: Developer Console (Quickest)
|
||||
|
||||
1. Open **Developer Console** (gear icon → Developer Console)
|
||||
2. Click **Debug** → **Open Execute Anonymous Window**
|
||||
|
||||
**Step 1: View current template IDs:**
|
||||
```apex
|
||||
List<dfsle__EnvelopeConfiguration__c> configs = [
|
||||
SELECT Id, Name, dfsle__DocuSignId__c
|
||||
FROM dfsle__EnvelopeConfiguration__c
|
||||
ORDER BY Name
|
||||
];
|
||||
for (dfsle__EnvelopeConfiguration__c c : configs) {
|
||||
System.debug(c.Name + ' → ' + c.dfsle__DocuSignId__c);
|
||||
}
|
||||
```
|
||||
|
||||
Check the **Logs** tab at the bottom for the output.
|
||||
|
||||
**Step 2: Update with new template IDs:**
|
||||
```apex
|
||||
// Replace template names and IDs with YOUR values
|
||||
Map<String, String> updates = new Map<String, String>{
|
||||
'Template Name 1' => 'xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx',
|
||||
'Template Name 2' => 'xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx'
|
||||
};
|
||||
|
||||
List<dfsle__EnvelopeConfiguration__c> configs = [
|
||||
SELECT Id, Name, dfsle__DocuSignId__c
|
||||
FROM dfsle__EnvelopeConfiguration__c
|
||||
WHERE Name IN :updates.keySet()
|
||||
];
|
||||
|
||||
for (dfsle__EnvelopeConfiguration__c c : configs) {
|
||||
c.dfsle__DocuSignId__c = updates.get(c.Name);
|
||||
}
|
||||
update configs;
|
||||
System.debug('Updated ' + configs.size() + ' templates');
|
||||
```
|
||||
|
||||
### Option 2: Query Editor (View Only)
|
||||
|
||||
In Developer Console → **Query Editor** tab:
|
||||
```sql
|
||||
SELECT Id, Name, dfsle__DocuSignId__c, Envelope_Template_Language__c
|
||||
FROM dfsle__EnvelopeConfiguration__c
|
||||
ORDER BY Name
|
||||
```
|
||||
|
||||
### Option 3: Salesforce UI (Point and Click)
|
||||
|
||||
1. Use **App Launcher** → search for **Docusign Envelope Templates**
|
||||
2. Open each record → **Edit**
|
||||
3. Update the **DocuSign template ID** field
|
||||
4. **Save**
|
||||
|
||||
---
|
||||
|
||||
## Post-Deployment Setup
|
||||
|
||||
After successful deployment, configure your Salesforce org:
|
||||
|
|
|
|||
|
|
@ -1,271 +0,0 @@
|
|||
/**
|
||||
* @description Service class for Docusign REST API interactions
|
||||
* @author Paul Huliganga
|
||||
* @date 2026-02-23
|
||||
*/
|
||||
public with sharing class DocusignAPIService {
|
||||
|
||||
private static final Integer TIMEOUT_MS = 120000; // 120 seconds
|
||||
private static final Integer MAX_RETRIES = 2;
|
||||
|
||||
/**
|
||||
* @description Creates a composite envelope in Docusign
|
||||
* @param envelopeJSON JSON string containing envelope definition
|
||||
* @param creds Docusign credentials object
|
||||
* @return Envelope ID (GUID)
|
||||
* @throws CalloutException if API call fails
|
||||
*/
|
||||
public static String createCompositeEnvelope(
|
||||
String envelopeJSON,
|
||||
DocusignCredentials creds
|
||||
) {
|
||||
Long startTime = System.currentTimeMillis();
|
||||
|
||||
// Build HTTP request
|
||||
HttpRequest req = buildCreateEnvelopeRequest(envelopeJSON, creds);
|
||||
|
||||
// Make callout with retry logic
|
||||
HttpResponse res = executeWithRetry(req, MAX_RETRIES);
|
||||
|
||||
Long duration = System.currentTimeMillis() - startTime;
|
||||
|
||||
// Log request/response (sanitized)
|
||||
logAPICall(req, res, duration);
|
||||
|
||||
// Parse response
|
||||
if (res.getStatusCode() == 201) {
|
||||
return parseEnvelopeId(res);
|
||||
} else {
|
||||
handleAPIError(res);
|
||||
return null; // Won't reach here, handleAPIError throws
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @description Builds HTTP request for creating envelope
|
||||
* @param envelopeJSON JSON body
|
||||
* @param creds Credentials
|
||||
* @return Configured HttpRequest
|
||||
*/
|
||||
@TestVisible
|
||||
private static HttpRequest buildCreateEnvelopeRequest(
|
||||
String envelopeJSON,
|
||||
DocusignCredentials creds
|
||||
) {
|
||||
HttpRequest req = new HttpRequest();
|
||||
|
||||
// Use Named Credential if available, otherwise build URL manually
|
||||
String endpoint = buildEnvelopeEndpoint(creds);
|
||||
req.setEndpoint(endpoint);
|
||||
|
||||
req.setMethod('POST');
|
||||
req.setHeader('Authorization', 'Bearer ' + creds.getAccessToken());
|
||||
req.setHeader('Content-Type', 'application/json');
|
||||
req.setHeader('Accept', 'application/json');
|
||||
req.setBody(envelopeJSON);
|
||||
req.setTimeout(TIMEOUT_MS);
|
||||
|
||||
return req;
|
||||
}
|
||||
|
||||
/**
|
||||
* @description Builds envelope endpoint URL
|
||||
* @param creds Docusign credentials
|
||||
* @return Full endpoint URL
|
||||
*/
|
||||
private static String buildEnvelopeEndpoint(DocusignCredentials creds) {
|
||||
// Check if using Named Credential (starts with 'callout:')
|
||||
if (creds.getBaseUrl().startsWith('callout:')) {
|
||||
return creds.getBaseUrl() + '/accounts/' + creds.getAccountId() + '/envelopes';
|
||||
} else {
|
||||
return creds.getBaseUrl() + '/restapi/v2.1/accounts/' + creds.getAccountId() + '/envelopes';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @description Executes HTTP request with retry logic
|
||||
* @param req HTTP request
|
||||
* @param maxRetries Maximum number of retry attempts
|
||||
* @return HTTP response
|
||||
* @throws CalloutException if all retries fail
|
||||
*/
|
||||
@TestVisible
|
||||
private static HttpResponse executeWithRetry(HttpRequest req, Integer maxRetries) {
|
||||
Http http = new Http();
|
||||
HttpResponse res;
|
||||
Integer attempt = 0;
|
||||
|
||||
while (attempt <= maxRetries) {
|
||||
attempt++;
|
||||
|
||||
try {
|
||||
res = http.send(req);
|
||||
|
||||
// Success or non-retryable error
|
||||
if (res.getStatusCode() == 201 || !isRetryableError(res.getStatusCode())) {
|
||||
return res;
|
||||
}
|
||||
|
||||
// Rate limit - wait before retry
|
||||
if (res.getStatusCode() == 429 && attempt <= maxRetries) {
|
||||
Integer waitMs = calculateBackoff(attempt);
|
||||
System.debug('Rate limited, waiting ' + waitMs + 'ms before retry ' + attempt);
|
||||
// Note: Apex doesn't have Thread.sleep, but in production this would be handled by Platform Events
|
||||
// For now, just retry immediately
|
||||
}
|
||||
|
||||
} catch (System.CalloutException e) {
|
||||
// Timeout or connection error
|
||||
if (attempt > maxRetries) {
|
||||
throw new CalloutException('Docusign API callout failed after ' + maxRetries + ' retries: ' + e.getMessage());
|
||||
}
|
||||
System.debug('Callout failed (attempt ' + attempt + '): ' + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
// All retries exhausted
|
||||
throw new CalloutException('Docusign API callout failed after ' + maxRetries + ' retries. Last status: ' + res.getStatusCode());
|
||||
}
|
||||
|
||||
/**
|
||||
* @description Determines if an HTTP status code is retryable
|
||||
* @param statusCode HTTP status code
|
||||
* @return True if error is transient and should be retried
|
||||
*/
|
||||
private static Boolean isRetryableError(Integer statusCode) {
|
||||
// 401 (token expired), 429 (rate limit), 500-599 (server errors)
|
||||
return statusCode == 401 || statusCode == 429 || (statusCode >= 500 && statusCode < 600);
|
||||
}
|
||||
|
||||
/**
|
||||
* @description Calculates exponential backoff delay
|
||||
* @param attempt Retry attempt number
|
||||
* @return Delay in milliseconds
|
||||
*/
|
||||
private static Integer calculateBackoff(Integer attempt) {
|
||||
// Exponential backoff: 2^attempt * 1000ms (1s, 2s, 4s, ...)
|
||||
return (Integer) Math.pow(2, attempt) * 1000;
|
||||
}
|
||||
|
||||
/**
|
||||
* @description Parses envelope ID from successful response
|
||||
* @param res HTTP response
|
||||
* @return Envelope ID (GUID)
|
||||
*/
|
||||
@TestVisible
|
||||
private static String parseEnvelopeId(HttpResponse res) {
|
||||
try {
|
||||
Map<String, Object> responseMap = (Map<String, Object>) JSON.deserializeUntyped(res.getBody());
|
||||
String envelopeId = (String) responseMap.get('envelopeId');
|
||||
|
||||
if (String.isBlank(envelopeId)) {
|
||||
throw new CalloutException('Envelope ID not found in Docusign response');
|
||||
}
|
||||
|
||||
return envelopeId;
|
||||
} catch (Exception e) {
|
||||
throw new CalloutException('Failed to parse Docusign response: ' + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @description Handles API error responses
|
||||
* @param res HTTP response with error
|
||||
* @throws CalloutException with detailed error message
|
||||
*/
|
||||
@TestVisible
|
||||
private static void handleAPIError(HttpResponse res) {
|
||||
String errorMessage = 'Docusign API error [' + res.getStatusCode() + ']';
|
||||
|
||||
try {
|
||||
Map<String, Object> errorBody = (Map<String, Object>) JSON.deserializeUntyped(res.getBody());
|
||||
|
||||
String errorCode = (String) errorBody.get('errorCode');
|
||||
String message = (String) errorBody.get('message');
|
||||
|
||||
if (String.isNotBlank(errorCode)) {
|
||||
errorMessage += ' ' + errorCode;
|
||||
}
|
||||
if (String.isNotBlank(message)) {
|
||||
errorMessage += ': ' + message;
|
||||
}
|
||||
} catch (Exception e) {
|
||||
// Could not parse error body, use raw response
|
||||
errorMessage += ': ' + res.getBody();
|
||||
}
|
||||
|
||||
// Map common errors to user-friendly messages
|
||||
errorMessage = enhanceErrorMessage(res.getStatusCode(), errorMessage);
|
||||
|
||||
throw new CalloutException(errorMessage);
|
||||
}
|
||||
|
||||
/**
|
||||
* @description Enhances error messages with user-friendly guidance
|
||||
* @param statusCode HTTP status code
|
||||
* @param originalMessage Original error message
|
||||
* @return Enhanced error message
|
||||
*/
|
||||
private static String enhanceErrorMessage(Integer statusCode, String originalMessage) {
|
||||
switch on statusCode {
|
||||
when 400 {
|
||||
return originalMessage + ' - Check template IDs and request parameters.';
|
||||
}
|
||||
when 401 {
|
||||
return originalMessage + ' - Authentication failed. Check API credentials and access token.';
|
||||
}
|
||||
when 403 {
|
||||
return originalMessage + ' - User lacks permission to create envelopes.';
|
||||
}
|
||||
when 404 {
|
||||
return originalMessage + ' - One or more templates not found in Docusign account.';
|
||||
}
|
||||
when 429 {
|
||||
return originalMessage + ' - API rate limit exceeded. Please try again later.';
|
||||
}
|
||||
when 500, 503 {
|
||||
return originalMessage + ' - Docusign server error. Please try again.';
|
||||
}
|
||||
when else {
|
||||
return originalMessage;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @description Logs API call details (sanitized, no credentials)
|
||||
* @param req HTTP request
|
||||
* @param res HTTP response
|
||||
* @param durationMs Duration in milliseconds
|
||||
*/
|
||||
private static void logAPICall(HttpRequest req, HttpResponse res, Long durationMs) {
|
||||
System.debug(LoggingLevel.INFO, '=== Docusign API Call ===');
|
||||
System.debug(LoggingLevel.INFO, 'Method: ' + req.getMethod());
|
||||
System.debug(LoggingLevel.INFO, 'Endpoint: ' + sanitizeEndpoint(req.getEndpoint()));
|
||||
System.debug(LoggingLevel.INFO, 'Request Body Length: ' + req.getBody().length() + ' bytes');
|
||||
System.debug(LoggingLevel.INFO, 'Response Status: ' + res.getStatusCode() + ' ' + res.getStatus());
|
||||
System.debug(LoggingLevel.INFO, 'Response Body Length: ' + res.getBody().length() + ' bytes');
|
||||
System.debug(LoggingLevel.INFO, 'Duration: ' + durationMs + 'ms');
|
||||
|
||||
// Only log full bodies in DEBUG mode (not in production)
|
||||
if (Test.isRunningTest()) {
|
||||
System.debug(LoggingLevel.FINEST, 'Request Body: ' + req.getBody());
|
||||
System.debug(LoggingLevel.FINEST, 'Response Body: ' + res.getBody());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @description Sanitizes endpoint URL for logging (removes account ID)
|
||||
* @param endpoint Full endpoint URL
|
||||
* @return Sanitized URL
|
||||
*/
|
||||
private static String sanitizeEndpoint(String endpoint) {
|
||||
// Replace account ID with placeholder for security
|
||||
return endpoint.replaceAll('/accounts/[a-f0-9\\-]+/', '/accounts/{accountId}/');
|
||||
}
|
||||
|
||||
/**
|
||||
* @description Custom exception for API errors
|
||||
*/
|
||||
public class CalloutException extends Exception {}
|
||||
}
|
||||
|
|
@ -1,323 +0,0 @@
|
|||
/**
|
||||
* @description Test class for DocusignAPIService
|
||||
* @author Paul Huliganga
|
||||
* @date 2026-02-23
|
||||
*/
|
||||
@isTest
|
||||
private class DocusignAPIServiceTest {
|
||||
|
||||
/**
|
||||
* @description Mock HTTP callout
|
||||
*/
|
||||
private class DocusignMock implements HttpCalloutMock {
|
||||
private Integer statusCode;
|
||||
private String responseBody;
|
||||
private Integer callCount = 0;
|
||||
|
||||
public DocusignMock(Integer statusCode, String responseBody) {
|
||||
this.statusCode = statusCode;
|
||||
this.responseBody = responseBody;
|
||||
}
|
||||
|
||||
public HTTPResponse respond(HTTPRequest req) {
|
||||
callCount++;
|
||||
HttpResponse res = new HttpResponse();
|
||||
res.setStatusCode(this.statusCode);
|
||||
res.setBody(this.responseBody);
|
||||
res.setStatus(this.statusCode == 201 ? 'Created' : 'Error');
|
||||
return res;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @description Mock that fails first time, succeeds second (for retry testing)
|
||||
*/
|
||||
private class RetryMock implements HttpCalloutMock {
|
||||
private Integer callCount = 0;
|
||||
|
||||
public HTTPResponse respond(HTTPRequest req) {
|
||||
callCount++;
|
||||
HttpResponse res = new HttpResponse();
|
||||
|
||||
if (callCount == 1) {
|
||||
// First call fails with 500
|
||||
res.setStatusCode(500);
|
||||
res.setBody('{"errorCode":"INTERNAL_ERROR","message":"Server error"}');
|
||||
res.setStatus('Internal Server Error');
|
||||
} else {
|
||||
// Second call succeeds
|
||||
res.setStatusCode(201);
|
||||
res.setBody('{"envelopeId":"envelope-retry-success","status":"sent"}');
|
||||
res.setStatus('Created');
|
||||
}
|
||||
|
||||
return res;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @description Test successful envelope creation
|
||||
*/
|
||||
@isTest
|
||||
static void testSuccessfulEnvelopeCreation() {
|
||||
// Arrange
|
||||
String envelopeId = 'envelope-test-12345';
|
||||
String mockResponse = '{"envelopeId":"' + envelopeId + '","status":"sent"}';
|
||||
Test.setMock(HttpCalloutMock.class, new DocusignMock(201, mockResponse));
|
||||
|
||||
DocusignCredentials.setTestCredentials('test-account-id', 'callout:DocusignAPI', 'test-token');
|
||||
DocusignCredentials creds = DocusignCredentials.getInstance();
|
||||
|
||||
String envelopeJSON = '{"status":"sent","compositeTemplates":[]}';
|
||||
|
||||
// Act
|
||||
Test.startTest();
|
||||
String resultEnvelopeId = DocusignAPIService.createCompositeEnvelope(envelopeJSON, creds);
|
||||
Test.stopTest();
|
||||
|
||||
// Assert
|
||||
System.assertEquals(envelopeId, resultEnvelopeId, 'Should return envelope ID');
|
||||
}
|
||||
|
||||
/**
|
||||
* @description Test parseEnvelopeId method
|
||||
*/
|
||||
@isTest
|
||||
static void testParseEnvelopeId() {
|
||||
// Arrange
|
||||
HttpResponse res = new HttpResponse();
|
||||
res.setStatusCode(201);
|
||||
res.setBody('{"envelopeId":"parsed-envelope-123","uri":"/envelopes/parsed-envelope-123"}');
|
||||
|
||||
// Act
|
||||
Test.startTest();
|
||||
String envelopeId = DocusignAPIService.parseEnvelopeId(res);
|
||||
Test.stopTest();
|
||||
|
||||
// Assert
|
||||
System.assertEquals('parsed-envelope-123', envelopeId, 'Should parse envelope ID correctly');
|
||||
}
|
||||
|
||||
/**
|
||||
* @description Test parseEnvelopeId with missing ID
|
||||
*/
|
||||
@isTest
|
||||
static void testParseEnvelopeIdMissing() {
|
||||
// Arrange
|
||||
HttpResponse res = new HttpResponse();
|
||||
res.setStatusCode(201);
|
||||
res.setBody('{"status":"sent"}'); // No envelopeId field
|
||||
|
||||
// Act & Assert
|
||||
Test.startTest();
|
||||
try {
|
||||
DocusignAPIService.parseEnvelopeId(res);
|
||||
System.assert(false, 'Should have thrown exception');
|
||||
} catch (DocusignAPIService.CalloutException e) {
|
||||
System.assert(e.getMessage().contains('Envelope ID not found'), 'Should mention missing ID');
|
||||
}
|
||||
Test.stopTest();
|
||||
}
|
||||
|
||||
/**
|
||||
* @description Test handleAPIError for 400 Bad Request
|
||||
*/
|
||||
@isTest
|
||||
static void testHandleAPIError400() {
|
||||
// Arrange
|
||||
HttpResponse res = new HttpResponse();
|
||||
res.setStatusCode(400);
|
||||
res.setBody('{"errorCode":"INVALID_REQUEST_PARAMETER","message":"Invalid template ID"}');
|
||||
|
||||
// Act & Assert
|
||||
Test.startTest();
|
||||
try {
|
||||
DocusignAPIService.handleAPIError(res);
|
||||
System.assert(false, 'Should have thrown exception');
|
||||
} catch (DocusignAPIService.CalloutException e) {
|
||||
System.assert(e.getMessage().contains('400'), 'Should include status code');
|
||||
System.assert(e.getMessage().contains('Invalid template'), 'Should include error message');
|
||||
System.assert(e.getMessage().contains('template IDs'), 'Should include guidance');
|
||||
}
|
||||
Test.stopTest();
|
||||
}
|
||||
|
||||
/**
|
||||
* @description Test handleAPIError for 401 Unauthorized
|
||||
*/
|
||||
@isTest
|
||||
static void testHandleAPIError401() {
|
||||
// Arrange
|
||||
HttpResponse res = new HttpResponse();
|
||||
res.setStatusCode(401);
|
||||
res.setBody('{"errorCode":"USER_AUTHENTICATION_FAILED","message":"Invalid access token"}');
|
||||
|
||||
// Act & Assert
|
||||
Test.startTest();
|
||||
try {
|
||||
DocusignAPIService.handleAPIError(res);
|
||||
System.assert(false, 'Should have thrown exception');
|
||||
} catch (DocusignAPIService.CalloutException e) {
|
||||
System.assert(e.getMessage().contains('401'), 'Should include status code');
|
||||
System.assert(e.getMessage().contains('Authentication'), 'Should mention authentication');
|
||||
}
|
||||
Test.stopTest();
|
||||
}
|
||||
|
||||
/**
|
||||
* @description Test handleAPIError for 403 Forbidden
|
||||
*/
|
||||
@isTest
|
||||
static void testHandleAPIError403() {
|
||||
// Arrange
|
||||
HttpResponse res = new HttpResponse();
|
||||
res.setStatusCode(403);
|
||||
res.setBody('{"errorCode":"USER_LACKS_PERMISSIONS","message":"User cannot send envelopes"}');
|
||||
|
||||
// Act & Assert
|
||||
Test.startTest();
|
||||
try {
|
||||
DocusignAPIService.handleAPIError(res);
|
||||
System.assert(false, 'Should have thrown exception');
|
||||
} catch (DocusignAPIService.CalloutException e) {
|
||||
System.assert(e.getMessage().contains('403'), 'Should include status code');
|
||||
System.assert(e.getMessage().contains('permission'), 'Should mention permission');
|
||||
}
|
||||
Test.stopTest();
|
||||
}
|
||||
|
||||
/**
|
||||
* @description Test handleAPIError for 404 Not Found
|
||||
*/
|
||||
@isTest
|
||||
static void testHandleAPIError404() {
|
||||
// Arrange
|
||||
HttpResponse res = new HttpResponse();
|
||||
res.setStatusCode(404);
|
||||
res.setBody('{"errorCode":"RESOURCE_NOT_FOUND","message":"Template not found"}');
|
||||
|
||||
// Act & Assert
|
||||
Test.startTest();
|
||||
try {
|
||||
DocusignAPIService.handleAPIError(res);
|
||||
System.assert(false, 'Should have thrown exception');
|
||||
} catch (DocusignAPIService.CalloutException e) {
|
||||
System.assert(e.getMessage().contains('404'), 'Should include status code');
|
||||
System.assert(e.getMessage().contains('not found'), 'Should mention template not found');
|
||||
}
|
||||
Test.stopTest();
|
||||
}
|
||||
|
||||
/**
|
||||
* @description Test handleAPIError for 429 Rate Limit
|
||||
*/
|
||||
@isTest
|
||||
static void testHandleAPIError429() {
|
||||
// Arrange
|
||||
HttpResponse res = new HttpResponse();
|
||||
res.setStatusCode(429);
|
||||
res.setBody('{"errorCode":"HOURLY_APIINVOCATION_LIMIT_EXCEEDED","message":"Rate limit exceeded"}');
|
||||
|
||||
// Act & Assert
|
||||
Test.startTest();
|
||||
try {
|
||||
DocusignAPIService.handleAPIError(res);
|
||||
System.assert(false, 'Should have thrown exception');
|
||||
} catch (DocusignAPIService.CalloutException e) {
|
||||
System.assert(e.getMessage().contains('429'), 'Should include status code');
|
||||
System.assert(e.getMessage().contains('rate limit'), 'Should mention rate limit');
|
||||
}
|
||||
Test.stopTest();
|
||||
}
|
||||
|
||||
/**
|
||||
* @description Test handleAPIError for 500 Server Error
|
||||
*/
|
||||
@isTest
|
||||
static void testHandleAPIError500() {
|
||||
// Arrange
|
||||
HttpResponse res = new HttpResponse();
|
||||
res.setStatusCode(500);
|
||||
res.setBody('{"errorCode":"INTERNAL_ERROR","message":"Server error"}');
|
||||
|
||||
// Act & Assert
|
||||
Test.startTest();
|
||||
try {
|
||||
DocusignAPIService.handleAPIError(res);
|
||||
System.assert(false, 'Should have thrown exception');
|
||||
} catch (DocusignAPIService.CalloutException e) {
|
||||
System.assert(e.getMessage().contains('500'), 'Should include status code');
|
||||
System.assert(e.getMessage().contains('server error'), 'Should mention server error');
|
||||
}
|
||||
Test.stopTest();
|
||||
}
|
||||
|
||||
/**
|
||||
* @description Test retry logic with transient failure
|
||||
*/
|
||||
@isTest
|
||||
static void testRetryLogic() {
|
||||
// Arrange
|
||||
Test.setMock(HttpCalloutMock.class, new RetryMock());
|
||||
|
||||
DocusignCredentials.setTestCredentials('test-account-id', 'callout:DocusignAPI', 'test-token');
|
||||
DocusignCredentials creds = DocusignCredentials.getInstance();
|
||||
|
||||
String envelopeJSON = '{"status":"sent","compositeTemplates":[]}';
|
||||
|
||||
// Act
|
||||
Test.startTest();
|
||||
String envelopeId = DocusignAPIService.createCompositeEnvelope(envelopeJSON, creds);
|
||||
Test.stopTest();
|
||||
|
||||
// Assert
|
||||
System.assertEquals('envelope-retry-success', envelopeId, 'Should succeed after retry');
|
||||
}
|
||||
|
||||
/**
|
||||
* @description Test buildCreateEnvelopeRequest
|
||||
*/
|
||||
@isTest
|
||||
static void testBuildCreateEnvelopeRequest() {
|
||||
// Arrange
|
||||
DocusignCredentials.setTestCredentials('test-account-id', 'callout:DocusignAPI', 'test-token');
|
||||
DocusignCredentials creds = DocusignCredentials.getInstance();
|
||||
|
||||
String envelopeJSON = '{"status":"sent"}';
|
||||
|
||||
// Act
|
||||
Test.startTest();
|
||||
HttpRequest req = DocusignAPIService.buildCreateEnvelopeRequest(envelopeJSON, creds);
|
||||
Test.stopTest();
|
||||
|
||||
// Assert
|
||||
System.assertEquals('POST', req.getMethod(), 'Should use POST method');
|
||||
System.assert(req.getEndpoint().contains('/envelopes'), 'Should have envelopes endpoint');
|
||||
System.assert(req.getHeader('Authorization').contains('Bearer'), 'Should have Bearer token');
|
||||
System.assertEquals('application/json', req.getHeader('Content-Type'), 'Should set JSON content type');
|
||||
System.assertEquals(envelopeJSON, req.getBody(), 'Should set body');
|
||||
}
|
||||
|
||||
/**
|
||||
* @description Test executeWithRetry with non-retryable error
|
||||
*/
|
||||
@isTest
|
||||
static void testExecuteWithRetryNonRetryable() {
|
||||
// Arrange
|
||||
String mockResponse = '{"errorCode":"INVALID_REQUEST_PARAMETER","message":"Bad request"}';
|
||||
Test.setMock(HttpCalloutMock.class, new DocusignMock(400, mockResponse));
|
||||
|
||||
DocusignCredentials.setTestCredentials('test-account-id', 'callout:DocusignAPI', 'test-token');
|
||||
DocusignCredentials creds = DocusignCredentials.getInstance();
|
||||
|
||||
HttpRequest req = DocusignAPIService.buildCreateEnvelopeRequest('{}', creds);
|
||||
|
||||
// Act
|
||||
Test.startTest();
|
||||
HttpResponse res = DocusignAPIService.executeWithRetry(req, 2);
|
||||
Test.stopTest();
|
||||
|
||||
// Assert
|
||||
System.assertEquals(400, res.getStatusCode(), 'Should return 400 without retry');
|
||||
}
|
||||
}
|
||||
|
|
@ -1,5 +0,0 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<ApexClass xmlns="http://soap.sforce.com/2006/04/metadata">
|
||||
<apiVersion>61.0</apiVersion>
|
||||
<status>Active</status>
|
||||
</ApexClass>
|
||||
|
|
@ -1,70 +1,151 @@
|
|||
/**
|
||||
* @description Main invocable class for combining multiple Docusign templates into a single envelope
|
||||
* @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-23
|
||||
* @date 2026-02-25
|
||||
*/
|
||||
global with sharing class DocusignCompositeEnvelopeBuilder {
|
||||
|
||||
/**
|
||||
* @description Invocable method called from Salesforce Screen Flow
|
||||
* @param requests List of request objects containing template IDs and metadata
|
||||
* @return List of result objects with envelope ID and status
|
||||
*/
|
||||
// ============================================================
|
||||
// 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'
|
||||
description='Combines multiple Docusign templates into a single envelope using dfsle Apex Toolkit'
|
||||
category='Docusign'
|
||||
)
|
||||
public static List<Result> sendCompositeEnvelope(List<Request> requests) {
|
||||
List<Result> results = new List<Result>();
|
||||
public static List<DocusignEnvelopeResult> sendCompositeEnvelope(List<DocusignEnvelopeRequest> requests) {
|
||||
List<DocusignEnvelopeResult> results = new List<DocusignEnvelopeResult>();
|
||||
|
||||
// Process first request (Flow only sends one)
|
||||
if (requests == null || requests.isEmpty()) {
|
||||
return buildErrorResult('No request provided');
|
||||
}
|
||||
|
||||
Request req = requests[0];
|
||||
Result result = new Result();
|
||||
DocusignEnvelopeRequest req = requests[0];
|
||||
DocusignEnvelopeResult result = new DocusignEnvelopeResult();
|
||||
|
||||
try {
|
||||
// Validate request using handler
|
||||
// Validate request
|
||||
DocusignEnvelopeRequestHandler.validateRequest(req);
|
||||
|
||||
// Build envelope JSON using handler
|
||||
String envelopeJSON = DocusignEnvelopeRequestHandler.buildEnvelopeJSON(req);
|
||||
|
||||
// Get Docusign credentials
|
||||
DocusignCredentials creds = DocusignCredentials.getInstance();
|
||||
|
||||
// Call Docusign API
|
||||
String envelopeId = DocusignAPIService.createCompositeEnvelope(
|
||||
envelopeJSON,
|
||||
creds
|
||||
// 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)
|
||||
// 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>();
|
||||
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;
|
||||
}
|
||||
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 envelope subject to combined template names and body to concatenated template email messages
|
||||
// Query for EmailMessage__c
|
||||
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 :sortedTemplateIds
|
||||
]) {
|
||||
if (String.isNotBlank(config.dfsle__EmailMessage__c)) {
|
||||
templateBodies.put(config.dfsle__DocuSignId__c, config.dfsle__EmailMessage__c);
|
||||
}
|
||||
}
|
||||
List<String> bodyParts = new List<String>();
|
||||
for (String templateId : sortedTemplateIds) {
|
||||
if (templateBodies.containsKey(templateId)) {
|
||||
bodyParts.add(templateBodies.get(templateId));
|
||||
}
|
||||
}
|
||||
String envelopeSubject = combinedName;
|
||||
String envelopeBody = bodyParts.isEmpty() ? '' : String.join(bodyParts, '\n\n');
|
||||
myEnvelope = myEnvelope.withEmail(envelopeSubject, envelopeBody);
|
||||
|
||||
// Send the envelope
|
||||
myEnvelope = dfsle.EnvelopeService.sendEnvelope(myEnvelope, true);
|
||||
|
||||
// Success
|
||||
result.envelopeId = envelopeId;
|
||||
result.envelopeId = String.valueOf(myEnvelope.docuSignId);
|
||||
result.success = true;
|
||||
result.errorMessage = null;
|
||||
|
||||
// Log success
|
||||
logAPICall(req.templateIds.size(), envelopeId, 'Success', null);
|
||||
logResult(sortedTemplateIds.size(), result.envelopeId, 'Success (' + String.join(docNames, ', ') + ')', null);
|
||||
|
||||
} catch (Exception e) {
|
||||
// Error handling
|
||||
result.success = false;
|
||||
result.errorMessage = e.getMessage();
|
||||
result.envelopeId = null;
|
||||
|
||||
// Log error
|
||||
logAPICall(
|
||||
logResult(
|
||||
req.templateIds != null ? req.templateIds.size() : 0,
|
||||
null,
|
||||
'Error',
|
||||
null, 'Error',
|
||||
e.getMessage() + '\n' + e.getStackTraceString()
|
||||
);
|
||||
|
||||
// Re-throw if critical (governor limits)
|
||||
if (e instanceof System.LimitException) {
|
||||
throw e;
|
||||
}
|
||||
|
|
@ -75,96 +156,123 @@ global with sharing class DocusignCompositeEnvelopeBuilder {
|
|||
}
|
||||
|
||||
/**
|
||||
* @description Logs API call to debug log (future: custom object)
|
||||
* @param templateCount Number of templates in envelope
|
||||
* @param envelopeId Docusign envelope ID
|
||||
* @param status Success or Error
|
||||
* @param errorMessage Error message if applicable
|
||||
* @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 void logAPICall(
|
||||
Integer templateCount,
|
||||
String envelopeId,
|
||||
String status,
|
||||
String errorMessage
|
||||
) {
|
||||
System.debug(LoggingLevel.INFO, '=== Docusign Composite Envelope API Call ===');
|
||||
System.debug(LoggingLevel.INFO, 'Timestamp: ' + System.now());
|
||||
System.debug(LoggingLevel.INFO, 'Template Count: ' + templateCount);
|
||||
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);
|
||||
}
|
||||
|
||||
// Future enhancement: Insert into Docusign_API_Log__c custom object
|
||||
}
|
||||
|
||||
/**
|
||||
* @description Helper to build error result
|
||||
* @param errorMessage Error message
|
||||
* @return List containing single error result
|
||||
* @description Strips language suffixes like " - English" or " - Spanish" from template names
|
||||
* @param name Template name
|
||||
* @return Cleaned template name
|
||||
*/
|
||||
private static List<Result> buildErrorResult(String errorMessage) {
|
||||
Result result = new Result();
|
||||
@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<Result>{ result };
|
||||
}
|
||||
|
||||
/**
|
||||
* @description Input parameters for invocable method (from Screen Flow)
|
||||
*/
|
||||
global class Request {
|
||||
@InvocableVariable(
|
||||
label='Template IDs'
|
||||
description='List of Docusign template IDs to combine'
|
||||
required=true
|
||||
)
|
||||
public List<String> templateIds;
|
||||
|
||||
@InvocableVariable(
|
||||
label='Salesforce Record ID'
|
||||
description='ID of the Salesforce record to attach documents to'
|
||||
required=true
|
||||
)
|
||||
public String recordId;
|
||||
|
||||
@InvocableVariable(
|
||||
label='Language'
|
||||
description='Language code (en or es)'
|
||||
required=false
|
||||
)
|
||||
public String language;
|
||||
|
||||
@InvocableVariable(
|
||||
label='Email Subject'
|
||||
description='Subject line for envelope email'
|
||||
required=false
|
||||
)
|
||||
public String emailSubject;
|
||||
}
|
||||
|
||||
/**
|
||||
* @description Output parameters for invocable method (to Screen Flow)
|
||||
*/
|
||||
global class Result {
|
||||
@InvocableVariable(
|
||||
label='Envelope ID'
|
||||
description='Docusign envelope ID'
|
||||
)
|
||||
public String envelopeId;
|
||||
|
||||
@InvocableVariable(
|
||||
label='Success'
|
||||
description='True if envelope was created successfully'
|
||||
)
|
||||
public Boolean success;
|
||||
|
||||
@InvocableVariable(
|
||||
label='Error Message'
|
||||
description='Error message if envelope creation failed'
|
||||
)
|
||||
public String errorMessage;
|
||||
return new List<DocusignEnvelopeResult>{ result };
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,281 +1,266 @@
|
|||
/**
|
||||
* @description Test class for DocusignCompositeEnvelopeBuilder
|
||||
* @description Test class for DocusignCompositeEnvelopeBuilder (dfsle Apex Toolkit)
|
||||
* @author Paul Huliganga
|
||||
* @date 2026-02-23
|
||||
* @date 2026-02-25
|
||||
*/
|
||||
@isTest
|
||||
private class DocusignCompositeEnvelopeBuilderTest {
|
||||
|
||||
/**
|
||||
* @description Mock HTTP callout for Docusign API
|
||||
*/
|
||||
private class DocusignMock implements HttpCalloutMock {
|
||||
private Integer statusCode;
|
||||
private String responseBody;
|
||||
|
||||
public DocusignMock(Integer statusCode, String responseBody) {
|
||||
this.statusCode = statusCode;
|
||||
this.responseBody = responseBody;
|
||||
}
|
||||
|
||||
public HTTPResponse respond(HTTPRequest req) {
|
||||
HttpResponse res = new HttpResponse();
|
||||
res.setStatusCode(this.statusCode);
|
||||
res.setBody(this.responseBody);
|
||||
res.setStatus(this.statusCode == 201 ? 'Created' : 'Error');
|
||||
return res;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @description Setup test data
|
||||
*/
|
||||
@testSetup
|
||||
static void setup() {
|
||||
// Create Custom Setting for credentials
|
||||
Docusign_Configuration__c config = new Docusign_Configuration__c();
|
||||
config.Account_Id__c = 'test-account-id-12345';
|
||||
config.Base_URL__c = 'callout:DocusignAPI';
|
||||
insert config;
|
||||
}
|
||||
|
||||
/**
|
||||
* @description Test successful envelope creation with 3 templates
|
||||
*/
|
||||
@isTest
|
||||
static void testSuccessfulEnvelopeCreation() {
|
||||
static void testSuccessfulCompositeEnvelope() {
|
||||
// Arrange
|
||||
String envelopeId = 'envelope-12345-abcde';
|
||||
String mockResponse = '{"envelopeId":"' + envelopeId + '","status":"sent"}';
|
||||
Test.setMock(HttpCalloutMock.class, new DocusignMock(201, mockResponse));
|
||||
dfsle.TestUtils.setMock(new dfsle.ESignatureAPIMock());
|
||||
|
||||
DocusignCredentials.setTestCredentials('test-account-id', 'callout:DocusignAPI', 'test-token');
|
||||
|
||||
DocusignCompositeEnvelopeBuilder.Request req = new DocusignCompositeEnvelopeBuilder.Request();
|
||||
req.templateIds = new List<String>{'template-1', 'template-2', 'template-3'};
|
||||
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 = 'Test Envelope';
|
||||
req.emailSubject = 'Please sign these forms';
|
||||
|
||||
// Act
|
||||
Test.startTest();
|
||||
List<DocusignCompositeEnvelopeBuilder.Result> results =
|
||||
DocusignCompositeEnvelopeBuilder.sendCompositeEnvelope(new List<DocusignCompositeEnvelopeBuilder.Request>{req});
|
||||
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.assertEquals(envelopeId, results[0].envelopeId, 'Should return envelope ID');
|
||||
System.assertEquals(null, results[0].errorMessage, 'Should have no error message');
|
||||
System.assertNotEquals(null, results[0].envelopeId, 'Should have envelope ID');
|
||||
System.assertEquals(null, results[0].errorMessage, 'Should have no error');
|
||||
}
|
||||
|
||||
/**
|
||||
* @description Test with single template (minimum)
|
||||
*/
|
||||
@isTest
|
||||
static void testSingleTemplate() {
|
||||
// Arrange
|
||||
String mockResponse = '{"envelopeId":"envelope-single","status":"sent"}';
|
||||
Test.setMock(HttpCalloutMock.class, new DocusignMock(201, mockResponse));
|
||||
dfsle.TestUtils.setMock(new dfsle.ESignatureAPIMock());
|
||||
|
||||
DocusignCredentials.setTestCredentials('test-account-id', 'callout:DocusignAPI', 'test-token');
|
||||
|
||||
DocusignCompositeEnvelopeBuilder.Request req = new DocusignCompositeEnvelopeBuilder.Request();
|
||||
req.templateIds = new List<String>{'template-1'};
|
||||
DocusignEnvelopeRequest req = new DocusignEnvelopeRequest();
|
||||
req.templateIds = new List<String>{'01234567-abcd-ef01-2345-6789abcdef01'};
|
||||
req.recordId = '001000000ABC123';
|
||||
|
||||
// Act
|
||||
Test.startTest();
|
||||
List<DocusignCompositeEnvelopeBuilder.Result> results =
|
||||
DocusignCompositeEnvelopeBuilder.sendCompositeEnvelope(new List<DocusignCompositeEnvelopeBuilder.Request>{req});
|
||||
List<DocusignEnvelopeResult> results =
|
||||
DocusignCompositeEnvelopeBuilder.sendCompositeEnvelope(
|
||||
new List<DocusignEnvelopeRequest>{req}
|
||||
);
|
||||
Test.stopTest();
|
||||
|
||||
// Assert
|
||||
System.assertEquals(true, results[0].success, 'Should be successful with 1 template');
|
||||
System.assertEquals(true, results[0].success, 'Should succeed with 1 template');
|
||||
}
|
||||
|
||||
/**
|
||||
* @description Test with 14 templates (maximum)
|
||||
*/
|
||||
@isTest
|
||||
static void testMaximumTemplates() {
|
||||
// Arrange
|
||||
String mockResponse = '{"envelopeId":"envelope-max","status":"sent"}';
|
||||
Test.setMock(HttpCalloutMock.class, new DocusignMock(201, mockResponse));
|
||||
|
||||
DocusignCredentials.setTestCredentials('test-account-id', 'callout:DocusignAPI', 'test-token');
|
||||
dfsle.TestUtils.setMock(new dfsle.ESignatureAPIMock());
|
||||
|
||||
List<String> templateIds = new List<String>();
|
||||
for (Integer i = 1; i <= 14; i++) {
|
||||
templateIds.add('template-' + i);
|
||||
templateIds.add('01234567-abcd-ef01-2345-6789abcdef' + String.valueOf(i).leftPad(2, '0'));
|
||||
}
|
||||
|
||||
DocusignCompositeEnvelopeBuilder.Request req = new DocusignCompositeEnvelopeBuilder.Request();
|
||||
DocusignEnvelopeRequest req = new DocusignEnvelopeRequest();
|
||||
req.templateIds = templateIds;
|
||||
req.recordId = '001000000ABC123';
|
||||
|
||||
// Act
|
||||
Test.startTest();
|
||||
List<DocusignCompositeEnvelopeBuilder.Result> results =
|
||||
DocusignCompositeEnvelopeBuilder.sendCompositeEnvelope(new List<DocusignCompositeEnvelopeBuilder.Request>{req});
|
||||
List<DocusignEnvelopeResult> results =
|
||||
DocusignCompositeEnvelopeBuilder.sendCompositeEnvelope(
|
||||
new List<DocusignEnvelopeRequest>{req}
|
||||
);
|
||||
Test.stopTest();
|
||||
|
||||
// Assert
|
||||
System.assertEquals(true, results[0].success, 'Should be successful with 14 templates');
|
||||
System.assertEquals(true, results[0].success, 'Should succeed with 14 templates');
|
||||
}
|
||||
|
||||
/**
|
||||
* @description Test with duplicate template IDs (should be deduplicated)
|
||||
*/
|
||||
@isTest
|
||||
static void testDuplicateTemplates() {
|
||||
static void testDuplicateTemplatesDeduped() {
|
||||
// Arrange
|
||||
String mockResponse = '{"envelopeId":"envelope-dedup","status":"sent"}';
|
||||
Test.setMock(HttpCalloutMock.class, new DocusignMock(201, mockResponse));
|
||||
dfsle.TestUtils.setMock(new dfsle.ESignatureAPIMock());
|
||||
|
||||
DocusignCredentials.setTestCredentials('test-account-id', 'callout:DocusignAPI', 'test-token');
|
||||
|
||||
DocusignCompositeEnvelopeBuilder.Request req = new DocusignCompositeEnvelopeBuilder.Request();
|
||||
req.templateIds = new List<String>{'template-1', 'template-2', 'template-1'}; // Duplicate
|
||||
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<DocusignCompositeEnvelopeBuilder.Result> results =
|
||||
DocusignCompositeEnvelopeBuilder.sendCompositeEnvelope(new List<DocusignCompositeEnvelopeBuilder.Request>{req});
|
||||
List<DocusignEnvelopeResult> results =
|
||||
DocusignCompositeEnvelopeBuilder.sendCompositeEnvelope(
|
||||
new List<DocusignEnvelopeRequest>{req}
|
||||
);
|
||||
Test.stopTest();
|
||||
|
||||
// Assert
|
||||
System.assertEquals(true, results[0].success, 'Should handle duplicates');
|
||||
}
|
||||
|
||||
// Custom fields test removed - not supported in InvocableVariable (Phase 2 enhancement)
|
||||
@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');
|
||||
}
|
||||
|
||||
/**
|
||||
* @description Test validation failure - no template IDs
|
||||
*/
|
||||
@isTest
|
||||
static void testValidationNoTemplates() {
|
||||
// Arrange
|
||||
DocusignCredentials.setTestCredentials('test-account-id', 'callout:DocusignAPI', 'test-token');
|
||||
|
||||
DocusignCompositeEnvelopeBuilder.Request req = new DocusignCompositeEnvelopeBuilder.Request();
|
||||
req.templateIds = new List<String>(); // Empty
|
||||
DocusignEnvelopeRequest req = new DocusignEnvelopeRequest();
|
||||
req.templateIds = new List<String>();
|
||||
req.recordId = '001000000ABC123';
|
||||
|
||||
// Act
|
||||
Test.startTest();
|
||||
List<DocusignCompositeEnvelopeBuilder.Result> results =
|
||||
DocusignCompositeEnvelopeBuilder.sendCompositeEnvelope(new List<DocusignCompositeEnvelopeBuilder.Request>{req});
|
||||
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'), 'Error should mention templates');
|
||||
System.assert(results[0].errorMessage.containsIgnoreCase('template'), 'Should mention templates');
|
||||
}
|
||||
|
||||
/**
|
||||
* @description Test validation failure - too many templates
|
||||
*/
|
||||
@isTest
|
||||
static void testValidationTooManyTemplates() {
|
||||
// Arrange
|
||||
DocusignCredentials.setTestCredentials('test-account-id', 'callout:DocusignAPI', 'test-token');
|
||||
|
||||
List<String> templateIds = new List<String>();
|
||||
for (Integer i = 1; i <= 15; i++) {
|
||||
templateIds.add('template-' + i);
|
||||
templateIds.add('01234567-abcd-ef01-2345-6789abcdef' + String.valueOf(i).leftPad(2, '0'));
|
||||
}
|
||||
|
||||
DocusignCompositeEnvelopeBuilder.Request req = new DocusignCompositeEnvelopeBuilder.Request();
|
||||
req.templateIds = templateIds; // 15 templates (> max of 14)
|
||||
DocusignEnvelopeRequest req = new DocusignEnvelopeRequest();
|
||||
req.templateIds = templateIds;
|
||||
req.recordId = '001000000ABC123';
|
||||
|
||||
// Act
|
||||
Test.startTest();
|
||||
List<DocusignCompositeEnvelopeBuilder.Result> results =
|
||||
DocusignCompositeEnvelopeBuilder.sendCompositeEnvelope(new List<DocusignCompositeEnvelopeBuilder.Request>{req});
|
||||
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'), 'Error should mention limit');
|
||||
System.assert(results[0].errorMessage.contains('Maximum 14'), 'Should mention limit');
|
||||
}
|
||||
|
||||
/**
|
||||
* @description Test validation failure - no record ID
|
||||
*/
|
||||
@isTest
|
||||
static void testValidationNoRecordId() {
|
||||
// Arrange
|
||||
DocusignCredentials.setTestCredentials('test-account-id', 'callout:DocusignAPI', 'test-token');
|
||||
|
||||
DocusignCompositeEnvelopeBuilder.Request req = new DocusignCompositeEnvelopeBuilder.Request();
|
||||
req.templateIds = new List<String>{'template-1'};
|
||||
req.recordId = ''; // Blank
|
||||
DocusignEnvelopeRequest req = new DocusignEnvelopeRequest();
|
||||
req.templateIds = new List<String>{'01234567-abcd-ef01-2345-6789abcdef01'};
|
||||
req.recordId = '';
|
||||
|
||||
// Act
|
||||
Test.startTest();
|
||||
List<DocusignCompositeEnvelopeBuilder.Result> results =
|
||||
DocusignCompositeEnvelopeBuilder.sendCompositeEnvelope(new List<DocusignCompositeEnvelopeBuilder.Request>{req});
|
||||
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'), 'Error should mention record ID');
|
||||
System.assert(results[0].errorMessage.contains('record ID'), 'Should mention record ID');
|
||||
}
|
||||
|
||||
/**
|
||||
* @description Test API error - 400 Bad Request
|
||||
*/
|
||||
@isTest
|
||||
static void testAPIError400() {
|
||||
static void testValidationBlankTemplateId() {
|
||||
// Arrange
|
||||
String mockResponse = '{"errorCode":"INVALID_REQUEST_PARAMETER","message":"Invalid template ID"}';
|
||||
Test.setMock(HttpCalloutMock.class, new DocusignMock(400, mockResponse));
|
||||
|
||||
DocusignCredentials.setTestCredentials('test-account-id', 'callout:DocusignAPI', 'test-token');
|
||||
|
||||
DocusignCompositeEnvelopeBuilder.Request req = new DocusignCompositeEnvelopeBuilder.Request();
|
||||
req.templateIds = new List<String>{'invalid-template'};
|
||||
DocusignEnvelopeRequest req = new DocusignEnvelopeRequest();
|
||||
req.templateIds = new List<String>{'01234567-abcd-ef01-2345-6789abcdef01', ''};
|
||||
req.recordId = '001000000ABC123';
|
||||
|
||||
// Act
|
||||
Test.startTest();
|
||||
List<DocusignCompositeEnvelopeBuilder.Result> results =
|
||||
DocusignCompositeEnvelopeBuilder.sendCompositeEnvelope(new List<DocusignCompositeEnvelopeBuilder.Request>{req});
|
||||
List<DocusignEnvelopeResult> results =
|
||||
DocusignCompositeEnvelopeBuilder.sendCompositeEnvelope(
|
||||
new List<DocusignEnvelopeRequest>{req}
|
||||
);
|
||||
Test.stopTest();
|
||||
|
||||
// Assert
|
||||
System.assertEquals(false, results[0].success, 'Should fail with API error');
|
||||
System.assert(results[0].errorMessage.contains('400'), 'Error should include status code');
|
||||
System.assertEquals(false, results[0].success, 'Should fail validation');
|
||||
System.assert(results[0].errorMessage.contains('blank'), 'Should mention blank');
|
||||
}
|
||||
|
||||
/**
|
||||
* @description Test API error - 401 Unauthorized
|
||||
*/
|
||||
@isTest
|
||||
static void testAPIError401() {
|
||||
static void testWithEmailSubject() {
|
||||
// Arrange
|
||||
String mockResponse = '{"errorCode":"USER_AUTHENTICATION_FAILED","message":"Invalid token"}';
|
||||
Test.setMock(HttpCalloutMock.class, new DocusignMock(401, mockResponse));
|
||||
dfsle.TestUtils.setMock(new dfsle.ESignatureAPIMock());
|
||||
|
||||
DocusignCredentials.setTestCredentials('test-account-id', 'callout:DocusignAPI', 'invalid-token');
|
||||
|
||||
DocusignCompositeEnvelopeBuilder.Request req = new DocusignCompositeEnvelopeBuilder.Request();
|
||||
req.templateIds = new List<String>{'template-1'};
|
||||
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<DocusignCompositeEnvelopeBuilder.Result> results =
|
||||
DocusignCompositeEnvelopeBuilder.sendCompositeEnvelope(new List<DocusignCompositeEnvelopeBuilder.Request>{req});
|
||||
List<DocusignEnvelopeResult> results =
|
||||
DocusignCompositeEnvelopeBuilder.sendCompositeEnvelope(
|
||||
new List<DocusignEnvelopeRequest>{req}
|
||||
);
|
||||
Test.stopTest();
|
||||
|
||||
// Assert
|
||||
System.assertEquals(false, results[0].success, 'Should fail with auth error');
|
||||
System.assertNotEquals(null, results[0].errorMessage, 'Should have error message');
|
||||
System.assert(String.isNotBlank(results[0].errorMessage), 'Error message should not be blank');
|
||||
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');
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,187 +0,0 @@
|
|||
/**
|
||||
* @description Manages Docusign API credentials and access tokens
|
||||
* @author Paul Huliganga
|
||||
* @date 2026-02-23
|
||||
*/
|
||||
public with sharing class DocusignCredentials {
|
||||
|
||||
private String baseUrl;
|
||||
private String accountId;
|
||||
private String accessToken;
|
||||
private DateTime tokenExpiry;
|
||||
|
||||
// Singleton instance
|
||||
private static DocusignCredentials instance;
|
||||
|
||||
// Flag to skip loadCredentials for setTestCredentials
|
||||
private static Boolean skipLoadForNextInstance = false;
|
||||
|
||||
/**
|
||||
* @description Private constructor (singleton pattern)
|
||||
*/
|
||||
private DocusignCredentials() {
|
||||
// Only load credentials if not explicitly skipped (for setTestCredentials)
|
||||
if (!skipLoadForNextInstance) {
|
||||
loadCredentials();
|
||||
}
|
||||
skipLoadForNextInstance = false; // Reset flag
|
||||
}
|
||||
|
||||
/**
|
||||
* @description Gets singleton instance of credentials
|
||||
* @return DocusignCredentials instance
|
||||
*/
|
||||
public static DocusignCredentials getInstance() {
|
||||
if (instance == null) {
|
||||
instance = new DocusignCredentials();
|
||||
}
|
||||
|
||||
// Refresh token if expired
|
||||
if (instance.isTokenExpired()) {
|
||||
instance.refreshAccessToken();
|
||||
}
|
||||
|
||||
return instance;
|
||||
}
|
||||
|
||||
/**
|
||||
* @description Loads credentials from Named Credential or Custom Settings
|
||||
*/
|
||||
private void loadCredentials() {
|
||||
// Option 1: Using Named Credential (preferred)
|
||||
// When using Named Credential, the platform handles authentication automatically
|
||||
// We just need the account ID and base URL
|
||||
|
||||
try {
|
||||
// Try to load from Custom Settings first
|
||||
Docusign_Configuration__c config = Docusign_Configuration__c.getOrgDefaults();
|
||||
|
||||
if (config != null && String.isNotBlank(config.Account_Id__c)) {
|
||||
this.accountId = config.Account_Id__c;
|
||||
this.baseUrl = String.isNotBlank(config.Base_URL__c)
|
||||
? config.Base_URL__c
|
||||
: 'callout:DocusignAPI'; // Default to Named Credential
|
||||
|
||||
// If using Named Credential, token is managed by platform
|
||||
if (this.baseUrl.startsWith('callout:')) {
|
||||
this.accessToken = 'MANAGED_BY_NAMED_CREDENTIAL';
|
||||
this.tokenExpiry = DateTime.now().addHours(1);
|
||||
} else {
|
||||
// Manual token management would go here
|
||||
// For now, throw exception - must configure Named Credential
|
||||
throw new CredentialException('Manual token management not implemented. Please use Named Credential.');
|
||||
}
|
||||
} else {
|
||||
throw new CredentialException('Docusign credentials not configured. Please set up Custom Settings: Docusign_Configuration__c');
|
||||
}
|
||||
} catch (Exception e) {
|
||||
throw new CredentialException('Failed to load Docusign credentials: ' + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @description Checks if access token is expired
|
||||
* @return True if token is expired or about to expire (within 5 minutes)
|
||||
*/
|
||||
private Boolean isTokenExpired() {
|
||||
if (this.tokenExpiry == null) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Refresh 5 minutes before expiry to avoid edge cases
|
||||
DateTime threshold = DateTime.now().addMinutes(5);
|
||||
return this.tokenExpiry < threshold;
|
||||
}
|
||||
|
||||
/**
|
||||
* @description Refreshes access token (JWT or OAuth2)
|
||||
* Note: In production with Named Credential, this is handled automatically by Salesforce
|
||||
*/
|
||||
private void refreshAccessToken() {
|
||||
// If using Named Credential, no manual refresh needed
|
||||
if (this.baseUrl.startsWith('callout:')) {
|
||||
this.tokenExpiry = DateTime.now().addHours(1);
|
||||
return;
|
||||
}
|
||||
|
||||
// Manual JWT token refresh would go here
|
||||
// This is a placeholder for future enhancement
|
||||
throw new CredentialException('Manual token refresh not implemented. Use Named Credential for automatic token management.');
|
||||
}
|
||||
|
||||
/**
|
||||
* @description Gets access token
|
||||
* @return Access token string
|
||||
*/
|
||||
public String getAccessToken() {
|
||||
if (isTokenExpired()) {
|
||||
refreshAccessToken();
|
||||
}
|
||||
return this.accessToken;
|
||||
}
|
||||
|
||||
/**
|
||||
* @description Gets Docusign account ID
|
||||
* @return Account ID (GUID)
|
||||
*/
|
||||
public String getAccountId() {
|
||||
return this.accountId;
|
||||
}
|
||||
|
||||
/**
|
||||
* @description Gets base URL for Docusign API
|
||||
* @return Base URL (either Named Credential callout or full URL)
|
||||
*/
|
||||
public String getBaseUrl() {
|
||||
return this.baseUrl;
|
||||
}
|
||||
|
||||
/**
|
||||
* @description Validates that credentials are properly configured
|
||||
* @return True if valid
|
||||
* @throws CredentialException if invalid
|
||||
*/
|
||||
public Boolean validate() {
|
||||
if (String.isBlank(this.accountId)) {
|
||||
throw new CredentialException('Docusign Account ID is not configured');
|
||||
}
|
||||
|
||||
if (String.isBlank(this.baseUrl)) {
|
||||
throw new CredentialException('Docusign Base URL is not configured');
|
||||
}
|
||||
|
||||
if (String.isBlank(this.accessToken)) {
|
||||
throw new CredentialException('Docusign Access Token is not available');
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* @description Resets singleton instance (for testing)
|
||||
*/
|
||||
@TestVisible
|
||||
private static void resetInstance() {
|
||||
instance = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @description Sets credentials manually (for testing)
|
||||
*/
|
||||
@TestVisible
|
||||
private static void setTestCredentials(String testAccountId, String testBaseUrl, String testAccessToken) {
|
||||
// Set flag to skip loadCredentials for this instance
|
||||
skipLoadForNextInstance = true;
|
||||
instance = new DocusignCredentials();
|
||||
// Override with specific test values
|
||||
instance.accountId = testAccountId;
|
||||
instance.baseUrl = testBaseUrl;
|
||||
instance.accessToken = testAccessToken;
|
||||
instance.tokenExpiry = DateTime.now().addHours(1);
|
||||
}
|
||||
|
||||
/**
|
||||
* @description Custom exception for credential errors
|
||||
*/
|
||||
public class CredentialException extends Exception {}
|
||||
}
|
||||
|
|
@ -1,258 +0,0 @@
|
|||
/**
|
||||
* @description Test class for DocusignCredentials
|
||||
* @author Paul Huliganga
|
||||
* @date 2026-02-23
|
||||
*/
|
||||
@isTest
|
||||
private class DocusignCredentialsTest {
|
||||
|
||||
/**
|
||||
* @description Setup test data
|
||||
*/
|
||||
@testSetup
|
||||
static void setup() {
|
||||
// Create Custom Setting for credentials
|
||||
Docusign_Configuration__c config = new Docusign_Configuration__c();
|
||||
config.Account_Id__c = 'test-account-id-12345';
|
||||
config.Base_URL__c = 'callout:DocusignAPI';
|
||||
insert config;
|
||||
}
|
||||
|
||||
/**
|
||||
* @description Test singleton pattern
|
||||
*/
|
||||
@isTest
|
||||
static void testGetInstance() {
|
||||
// Act
|
||||
Test.startTest();
|
||||
DocusignCredentials creds1 = DocusignCredentials.getInstance();
|
||||
DocusignCredentials creds2 = DocusignCredentials.getInstance();
|
||||
Test.stopTest();
|
||||
|
||||
// Assert
|
||||
System.assertEquals(creds1, creds2, 'Should return same instance (singleton)');
|
||||
}
|
||||
|
||||
/**
|
||||
* @description Test loading credentials from Custom Settings
|
||||
*/
|
||||
@isTest
|
||||
static void testLoadCredentialsFromCustomSettings() {
|
||||
// Reset instance to force fresh load from Custom Settings
|
||||
DocusignCredentials.resetInstance();
|
||||
|
||||
// Act
|
||||
Test.startTest();
|
||||
DocusignCredentials creds = DocusignCredentials.getInstance();
|
||||
Test.stopTest();
|
||||
|
||||
// Assert
|
||||
System.assertEquals('test-account-id-12345', creds.getAccountId(), 'Should load account ID');
|
||||
System.assertEquals('callout:DocusignAPI', creds.getBaseUrl(), 'Should load base URL');
|
||||
System.assertNotEquals(null, creds.getAccessToken(), 'Should have access token');
|
||||
}
|
||||
|
||||
/**
|
||||
* @description Test getAccessToken method
|
||||
*/
|
||||
@isTest
|
||||
static void testGetAccessToken() {
|
||||
// Arrange
|
||||
DocusignCredentials.resetInstance();
|
||||
|
||||
// Act
|
||||
Test.startTest();
|
||||
DocusignCredentials creds = DocusignCredentials.getInstance();
|
||||
String token = creds.getAccessToken();
|
||||
Test.stopTest();
|
||||
|
||||
// Assert
|
||||
System.assertNotEquals(null, token, 'Should return access token');
|
||||
}
|
||||
|
||||
/**
|
||||
* @description Test getAccountId method
|
||||
*/
|
||||
@isTest
|
||||
static void testGetAccountId() {
|
||||
// Reset instance to force fresh load from Custom Settings
|
||||
DocusignCredentials.resetInstance();
|
||||
|
||||
// Act
|
||||
Test.startTest();
|
||||
DocusignCredentials creds = DocusignCredentials.getInstance();
|
||||
String accountId = creds.getAccountId();
|
||||
Test.stopTest();
|
||||
|
||||
// Assert
|
||||
System.assertEquals('test-account-id-12345', accountId, 'Should return account ID');
|
||||
}
|
||||
|
||||
/**
|
||||
* @description Test getBaseUrl method
|
||||
*/
|
||||
@isTest
|
||||
static void testGetBaseUrl() {
|
||||
// Act
|
||||
Test.startTest();
|
||||
DocusignCredentials creds = DocusignCredentials.getInstance();
|
||||
String baseUrl = creds.getBaseUrl();
|
||||
Test.stopTest();
|
||||
|
||||
// Assert
|
||||
System.assertEquals('callout:DocusignAPI', baseUrl, 'Should return base URL');
|
||||
}
|
||||
|
||||
/**
|
||||
* @description Test validate method with valid credentials
|
||||
*/
|
||||
@isTest
|
||||
static void testValidateSuccess() {
|
||||
// Act
|
||||
Test.startTest();
|
||||
DocusignCredentials creds = DocusignCredentials.getInstance();
|
||||
Boolean isValid = creds.validate();
|
||||
Test.stopTest();
|
||||
|
||||
// Assert
|
||||
System.assertEquals(true, isValid, 'Should validate successfully');
|
||||
}
|
||||
|
||||
/**
|
||||
* @description Test validate method with missing account ID
|
||||
*/
|
||||
@isTest
|
||||
static void testValidateMissingAccountId() {
|
||||
// Arrange
|
||||
DocusignCredentials.resetInstance();
|
||||
DocusignCredentials.setTestCredentials('', 'callout:DocusignAPI', 'test-token');
|
||||
|
||||
// Act & Assert
|
||||
Test.startTest();
|
||||
try {
|
||||
DocusignCredentials creds = DocusignCredentials.getInstance();
|
||||
creds.validate();
|
||||
System.assert(false, 'Should have thrown exception');
|
||||
} catch (DocusignCredentials.CredentialException e) {
|
||||
System.assert(e.getMessage().contains('Account ID'), 'Should mention Account ID');
|
||||
}
|
||||
Test.stopTest();
|
||||
}
|
||||
|
||||
/**
|
||||
* @description Test validate method with missing base URL
|
||||
*/
|
||||
@isTest
|
||||
static void testValidateMissingBaseUrl() {
|
||||
// Arrange
|
||||
DocusignCredentials.resetInstance();
|
||||
DocusignCredentials.setTestCredentials('test-account-id', '', 'test-token');
|
||||
|
||||
// Act & Assert
|
||||
Test.startTest();
|
||||
try {
|
||||
DocusignCredentials creds = DocusignCredentials.getInstance();
|
||||
creds.validate();
|
||||
System.assert(false, 'Should have thrown exception');
|
||||
} catch (DocusignCredentials.CredentialException e) {
|
||||
System.assert(e.getMessage().contains('Base URL'), 'Should mention Base URL');
|
||||
}
|
||||
Test.stopTest();
|
||||
}
|
||||
|
||||
/**
|
||||
* @description Test validate method with missing access token
|
||||
*/
|
||||
@isTest
|
||||
static void testValidateMissingAccessToken() {
|
||||
// Arrange
|
||||
DocusignCredentials.resetInstance();
|
||||
DocusignCredentials.setTestCredentials('test-account-id', 'callout:DocusignAPI', '');
|
||||
|
||||
// Act & Assert
|
||||
Test.startTest();
|
||||
try {
|
||||
DocusignCredentials creds = DocusignCredentials.getInstance();
|
||||
creds.validate();
|
||||
System.assert(false, 'Should have thrown exception');
|
||||
} catch (DocusignCredentials.CredentialException e) {
|
||||
System.assert(e.getMessage().contains('Access Token'), 'Should mention Access Token');
|
||||
}
|
||||
Test.stopTest();
|
||||
}
|
||||
|
||||
/**
|
||||
* @description Test setTestCredentials method
|
||||
*/
|
||||
@isTest
|
||||
static void testSetTestCredentials() {
|
||||
// Arrange
|
||||
DocusignCredentials.resetInstance();
|
||||
|
||||
// Act
|
||||
Test.startTest();
|
||||
DocusignCredentials.setTestCredentials('test-acc-123', 'https://test.docusign.net', 'test-token-xyz');
|
||||
DocusignCredentials creds = DocusignCredentials.getInstance();
|
||||
Test.stopTest();
|
||||
|
||||
// Assert
|
||||
System.assertEquals('test-acc-123', creds.getAccountId(), 'Should set test account ID');
|
||||
System.assertEquals('https://test.docusign.net', creds.getBaseUrl(), 'Should set test base URL');
|
||||
System.assertEquals('test-token-xyz', creds.getAccessToken(), 'Should set test access token');
|
||||
}
|
||||
|
||||
/**
|
||||
* @description Test resetInstance method
|
||||
*/
|
||||
@isTest
|
||||
static void testResetInstance() {
|
||||
// Arrange
|
||||
DocusignCredentials creds1 = DocusignCredentials.getInstance();
|
||||
|
||||
// Act
|
||||
Test.startTest();
|
||||
DocusignCredentials.resetInstance();
|
||||
DocusignCredentials creds2 = DocusignCredentials.getInstance();
|
||||
Test.stopTest();
|
||||
|
||||
// Assert
|
||||
System.assertNotEquals(creds1, creds2, 'Should create new instance after reset');
|
||||
}
|
||||
|
||||
/**
|
||||
* @description Test error when Custom Settings not configured
|
||||
*/
|
||||
@isTest
|
||||
static void testMissingCustomSettings() {
|
||||
// Arrange
|
||||
// Reset instance first
|
||||
DocusignCredentials.resetInstance();
|
||||
// Delete the test setup data
|
||||
delete [SELECT Id FROM Docusign_Configuration__c];
|
||||
|
||||
// Act & Assert
|
||||
Test.startTest();
|
||||
try {
|
||||
DocusignCredentials creds = DocusignCredentials.getInstance();
|
||||
System.assert(false, 'Should have thrown exception');
|
||||
} catch (DocusignCredentials.CredentialException e) {
|
||||
System.assert(e.getMessage().containsIgnoreCase('not configured'), 'Should mention not configured');
|
||||
}
|
||||
Test.stopTest();
|
||||
}
|
||||
|
||||
/**
|
||||
* @description Test Named Credential URL format
|
||||
*/
|
||||
@isTest
|
||||
static void testNamedCredentialFormat() {
|
||||
// Act
|
||||
Test.startTest();
|
||||
DocusignCredentials creds = DocusignCredentials.getInstance();
|
||||
String baseUrl = creds.getBaseUrl();
|
||||
Test.stopTest();
|
||||
|
||||
// Assert
|
||||
System.assert(baseUrl.startsWith('callout:'), 'Named Credential URL should start with callout:');
|
||||
}
|
||||
}
|
||||
|
|
@ -1,5 +0,0 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<ApexClass xmlns="http://soap.sforce.com/2006/04/metadata">
|
||||
<apiVersion>61.0</apiVersion>
|
||||
<status>Active</status>
|
||||
</ApexClass>
|
||||
|
|
@ -0,0 +1,35 @@
|
|||
/**
|
||||
* @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;
|
||||
}
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<ApexClass xmlns="http://soap.sforce.com/2006/04/metadata">
|
||||
<apiVersion>61.0</apiVersion>
|
||||
<apiVersion>60.0</apiVersion>
|
||||
<status>Active</status>
|
||||
</ApexClass>
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
/**
|
||||
* @description Handles request validation and envelope JSON building for Docusign composite envelopes
|
||||
* @description Validates request parameters for Docusign composite envelopes
|
||||
* @author Paul Huliganga
|
||||
* @date 2026-02-25
|
||||
*/
|
||||
|
|
@ -10,7 +10,7 @@ global with sharing class DocusignEnvelopeRequestHandler {
|
|||
* @param req Request object to validate
|
||||
* @throws IllegalArgumentException if validation fails
|
||||
*/
|
||||
public static void validateRequest(DocusignCompositeEnvelopeBuilder.Request req) {
|
||||
public static void validateRequest(DocusignEnvelopeRequest req) {
|
||||
if (req.templateIds == null || req.templateIds.isEmpty()) {
|
||||
throw new IllegalArgumentException('At least one template ID is required');
|
||||
}
|
||||
|
|
@ -23,151 +23,10 @@ global with sharing class DocusignEnvelopeRequestHandler {
|
|||
throw new IllegalArgumentException('Salesforce record ID is required');
|
||||
}
|
||||
|
||||
// Check for null template IDs
|
||||
for (String templateId : req.templateIds) {
|
||||
if (String.isBlank(templateId)) {
|
||||
throw new IllegalArgumentException('Template ID cannot be blank');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @description Builds composite envelope JSON for Docusign API from request
|
||||
* @param req Request object containing parameters
|
||||
* @return JSON string for Docusign API request
|
||||
*/
|
||||
public static String buildEnvelopeJSON(DocusignCompositeEnvelopeBuilder.Request req) {
|
||||
// Remove duplicates and sort alphabetically
|
||||
List<String> sortedTemplateIds = sortTemplatesAlphabetically(
|
||||
new Set<String>(req.templateIds)
|
||||
);
|
||||
|
||||
// Build composite envelope JSON
|
||||
return buildCompositeEnvelopeJSON(
|
||||
sortedTemplateIds,
|
||||
req.recordId,
|
||||
req.language,
|
||||
req.emailSubject,
|
||||
null // customFields not supported in InvocableVariable (Phase 2 enhancement)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @description Removes duplicates and sorts template IDs alphabetically
|
||||
* @param templateIdSet Set of template IDs
|
||||
* @return Sorted list of unique template IDs
|
||||
*/
|
||||
private static List<String> sortTemplatesAlphabetically(Set<String> templateIdSet) {
|
||||
List<String> sortedList = new List<String>(templateIdSet);
|
||||
sortedList.sort();
|
||||
return sortedList;
|
||||
}
|
||||
|
||||
/**
|
||||
* @description Builds composite envelope JSON for Docusign API
|
||||
* @param templateIds List of template IDs to combine
|
||||
* @param recordId Salesforce record ID for custom fields
|
||||
* @param language Language code (en/es)
|
||||
* @param emailSubject Email subject line
|
||||
* @param customFields Map of custom field name/value pairs
|
||||
* @return JSON string for Docusign API request
|
||||
*/
|
||||
private static String buildCompositeEnvelopeJSON(
|
||||
List<String> templateIds,
|
||||
String recordId,
|
||||
String language,
|
||||
String emailSubject,
|
||||
Map<String, String> customFields
|
||||
) {
|
||||
// Build composite templates array
|
||||
List<Object> compositeTemplates = new List<Object>();
|
||||
|
||||
Integer sequence = 1;
|
||||
for (String templateId : templateIds) {
|
||||
Map<String, Object> compositeTemplate = new Map<String, Object>{
|
||||
'compositeTemplateId' => String.valueOf(sequence),
|
||||
'serverTemplates' => new List<Object>{
|
||||
new Map<String, Object>{
|
||||
'sequence' => String.valueOf(sequence),
|
||||
'templateId' => templateId
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Add custom fields if this is the first template
|
||||
if (sequence == 1 && (String.isNotBlank(recordId) || customFields != null)) {
|
||||
compositeTemplate.put('inlineTemplates', buildInlineTemplates(recordId, language, customFields));
|
||||
}
|
||||
|
||||
compositeTemplates.add(compositeTemplate);
|
||||
sequence++;
|
||||
}
|
||||
|
||||
// Build envelope object
|
||||
Map<String, Object> envelope = new Map<String, Object>{
|
||||
'status' => 'sent',
|
||||
'emailSubject' => String.isNotBlank(emailSubject)
|
||||
? emailSubject
|
||||
: 'Please review and sign these forms',
|
||||
'compositeTemplates' => compositeTemplates
|
||||
};
|
||||
|
||||
return JSON.serialize(envelope);
|
||||
}
|
||||
|
||||
/**
|
||||
* @description Builds inline templates for custom fields
|
||||
* @param recordId Salesforce record ID
|
||||
* @param language Language code
|
||||
* @param customFields Additional custom fields
|
||||
* @return List of inline template objects
|
||||
*/
|
||||
private static List<Object> buildInlineTemplates(
|
||||
String recordId,
|
||||
String language,
|
||||
Map<String, String> customFields
|
||||
) {
|
||||
List<Object> textCustomFields = new List<Object>();
|
||||
|
||||
// Add Salesforce record ID
|
||||
if (String.isNotBlank(recordId)) {
|
||||
textCustomFields.add(new Map<String, Object>{
|
||||
'name' => 'SalesforceRecordId',
|
||||
'value' => recordId,
|
||||
'show' => 'false',
|
||||
'required' => 'false'
|
||||
});
|
||||
}
|
||||
|
||||
// Add language
|
||||
if (String.isNotBlank(language)) {
|
||||
textCustomFields.add(new Map<String, Object>{
|
||||
'name' => 'Language',
|
||||
'value' => language,
|
||||
'show' => 'false',
|
||||
'required' => 'false'
|
||||
});
|
||||
}
|
||||
|
||||
// Add additional custom fields
|
||||
if (customFields != null && !customFields.isEmpty()) {
|
||||
for (String fieldName : customFields.keySet()) {
|
||||
textCustomFields.add(new Map<String, Object>{
|
||||
'name' => fieldName,
|
||||
'value' => customFields.get(fieldName),
|
||||
'show' => 'false',
|
||||
'required' => 'false'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return new List<Object>{
|
||||
new Map<String, Object>{
|
||||
'sequence' => '1',
|
||||
'customFields' => new Map<String, Object>{
|
||||
'textCustomFields' => textCustomFields
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,173 +8,73 @@ public class DocusignEnvelopeRequestHandlerTest {
|
|||
|
||||
@isTest
|
||||
static void testValidateRequest_Success() {
|
||||
// Setup
|
||||
DocusignCompositeEnvelopeBuilder.Request req = new DocusignCompositeEnvelopeBuilder.Request();
|
||||
DocusignEnvelopeRequest req = new DocusignEnvelopeRequest();
|
||||
req.templateIds = new List<String>{ 'template1', 'template2' };
|
||||
req.recordId = '001xx000003DHf';
|
||||
req.language = 'en';
|
||||
req.emailSubject = 'Test Subject';
|
||||
|
||||
// Test
|
||||
Test.startTest();
|
||||
DocusignEnvelopeRequestHandler.validateRequest(req);
|
||||
Test.stopTest();
|
||||
|
||||
// Verify - no exception thrown
|
||||
Assert.isTrue(true, 'Validation should pass');
|
||||
}
|
||||
|
||||
@isTest
|
||||
static void testValidateRequest_NoTemplateIds() {
|
||||
// Setup
|
||||
DocusignCompositeEnvelopeBuilder.Request req = new DocusignCompositeEnvelopeBuilder.Request();
|
||||
DocusignEnvelopeRequest req = new DocusignEnvelopeRequest();
|
||||
req.templateIds = new List<String>();
|
||||
req.recordId = '001xx000003DHf';
|
||||
|
||||
// Test & Verify
|
||||
Test.startTest();
|
||||
try {
|
||||
DocusignEnvelopeRequestHandler.validateRequest(req);
|
||||
Assert.fail('Should throw IllegalArgumentException');
|
||||
} catch (IllegalArgumentException e) {
|
||||
Assert.isTrue(e.getMessage().contains('At least one template ID'), 'Correct error message');
|
||||
Assert.isTrue(e.getMessage().contains('At least one template ID'), 'Correct error');
|
||||
}
|
||||
Test.stopTest();
|
||||
}
|
||||
|
||||
@isTest
|
||||
static void testValidateRequest_TooManyTemplates() {
|
||||
// Setup
|
||||
DocusignCompositeEnvelopeBuilder.Request req = new DocusignCompositeEnvelopeBuilder.Request();
|
||||
DocusignEnvelopeRequest req = new DocusignEnvelopeRequest();
|
||||
req.templateIds = new List<String>();
|
||||
for (Integer i = 0; i < 15; i++) {
|
||||
req.templateIds.add('template' + i);
|
||||
}
|
||||
req.recordId = '001xx000003DHf';
|
||||
|
||||
// Test & Verify
|
||||
Test.startTest();
|
||||
try {
|
||||
DocusignEnvelopeRequestHandler.validateRequest(req);
|
||||
Assert.fail('Should throw IllegalArgumentException');
|
||||
} catch (IllegalArgumentException e) {
|
||||
Assert.isTrue(e.getMessage().contains('Maximum 14 templates'), 'Correct error message');
|
||||
Assert.isTrue(e.getMessage().contains('Maximum 14 templates'), 'Correct error');
|
||||
}
|
||||
Test.stopTest();
|
||||
}
|
||||
|
||||
@isTest
|
||||
static void testValidateRequest_NoRecordId() {
|
||||
// Setup
|
||||
DocusignCompositeEnvelopeBuilder.Request req = new DocusignCompositeEnvelopeBuilder.Request();
|
||||
DocusignEnvelopeRequest req = new DocusignEnvelopeRequest();
|
||||
req.templateIds = new List<String>{ 'template1' };
|
||||
req.recordId = '';
|
||||
|
||||
// Test & Verify
|
||||
Test.startTest();
|
||||
try {
|
||||
DocusignEnvelopeRequestHandler.validateRequest(req);
|
||||
Assert.fail('Should throw IllegalArgumentException');
|
||||
} catch (IllegalArgumentException e) {
|
||||
Assert.isTrue(e.getMessage().contains('Salesforce record ID'), 'Correct error message');
|
||||
Assert.isTrue(e.getMessage().contains('Salesforce record ID'), 'Correct error');
|
||||
}
|
||||
Test.stopTest();
|
||||
}
|
||||
|
||||
@isTest
|
||||
static void testValidateRequest_BlankTemplateId() {
|
||||
// Setup
|
||||
DocusignCompositeEnvelopeBuilder.Request req = new DocusignCompositeEnvelopeBuilder.Request();
|
||||
DocusignEnvelopeRequest req = new DocusignEnvelopeRequest();
|
||||
req.templateIds = new List<String>{ 'template1', '', 'template3' };
|
||||
req.recordId = '001xx000003DHf';
|
||||
|
||||
// Test & Verify
|
||||
Test.startTest();
|
||||
try {
|
||||
DocusignEnvelopeRequestHandler.validateRequest(req);
|
||||
Assert.fail('Should throw IllegalArgumentException');
|
||||
} catch (IllegalArgumentException e) {
|
||||
Assert.isTrue(e.getMessage().contains('Template ID cannot be blank'), 'Correct error message');
|
||||
}
|
||||
Test.stopTest();
|
||||
}
|
||||
|
||||
@isTest
|
||||
static void testBuildEnvelopeJSON_Success() {
|
||||
// Setup
|
||||
DocusignCompositeEnvelopeBuilder.Request req = new DocusignCompositeEnvelopeBuilder.Request();
|
||||
req.templateIds = new List<String>{ 'template2', 'template1', 'template2' }; // duplicates and unsorted
|
||||
req.recordId = '001xx000003DHf';
|
||||
req.language = 'es';
|
||||
req.emailSubject = 'Custom Subject';
|
||||
|
||||
// Test
|
||||
Test.startTest();
|
||||
String json = DocusignEnvelopeRequestHandler.buildEnvelopeJSON(req);
|
||||
Test.stopTest();
|
||||
|
||||
// Verify
|
||||
Assert.isNotNull(json, 'JSON should be generated');
|
||||
Assert.isTrue(json.contains('sent'), 'Should contain sent status');
|
||||
Assert.isTrue(json.contains('Custom Subject'), 'Should contain custom subject');
|
||||
Assert.isTrue(json.contains('compositeTemplates'), 'Should contain compositeTemplates');
|
||||
}
|
||||
|
||||
@isTest
|
||||
static void testBuildEnvelopeJSON_DefaultSubject() {
|
||||
// Setup
|
||||
DocusignCompositeEnvelopeBuilder.Request req = new DocusignCompositeEnvelopeBuilder.Request();
|
||||
req.templateIds = new List<String>{ 'template1' };
|
||||
req.recordId = '001xx000003DHf';
|
||||
req.emailSubject = null;
|
||||
|
||||
// Test
|
||||
Test.startTest();
|
||||
String json = DocusignEnvelopeRequestHandler.buildEnvelopeJSON(req);
|
||||
Test.stopTest();
|
||||
|
||||
// Verify
|
||||
Assert.isTrue(json.contains('Please review and sign these forms'), 'Should use default subject');
|
||||
}
|
||||
|
||||
@isTest
|
||||
static void testBuildEnvelopeJSON_TemplatesAreSorted() {
|
||||
// Setup
|
||||
DocusignCompositeEnvelopeBuilder.Request req = new DocusignCompositeEnvelopeBuilder.Request();
|
||||
req.templateIds = new List<String>{ 'template3', 'template1', 'template2' };
|
||||
req.recordId = '001xx000003DHf';
|
||||
|
||||
// Test
|
||||
Test.startTest();
|
||||
String json = DocusignEnvelopeRequestHandler.buildEnvelopeJSON(req);
|
||||
Test.stopTest();
|
||||
|
||||
// Verify - templates should be in sorted order (template1, template2, template3)
|
||||
Integer idx1 = json.indexOf('template1');
|
||||
Integer idx2 = json.indexOf('template2');
|
||||
Integer idx3 = json.indexOf('template3');
|
||||
Assert.isTrue(idx1 < idx2 && idx2 < idx3, 'Templates should be sorted alphabetically');
|
||||
}
|
||||
|
||||
@isTest
|
||||
static void testBuildEnvelopeJSON_DuplicatesRemoved() {
|
||||
// Setup
|
||||
DocusignCompositeEnvelopeBuilder.Request req = new DocusignCompositeEnvelopeBuilder.Request();
|
||||
req.templateIds = new List<String>{ 'template1', 'template1', 'template2' };
|
||||
req.recordId = '001xx000003DHf';
|
||||
|
||||
// Test
|
||||
Test.startTest();
|
||||
String json = DocusignEnvelopeRequestHandler.buildEnvelopeJSON(req);
|
||||
Test.stopTest();
|
||||
|
||||
// Verify - should only have 2 compositeTemplate entries
|
||||
Integer count = 0;
|
||||
Integer pos = 0;
|
||||
while ((pos = json.indexOf('compositeTemplateId', pos)) != -1) {
|
||||
count++;
|
||||
pos++;
|
||||
}
|
||||
Assert.areEqual(2, count, 'Should have 2 unique templates after deduplication');
|
||||
Assert.isTrue(e.getMessage().contains('Template ID cannot be blank'), 'Correct error');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<ApexClass xmlns="http://soap.sforce.com/2006/04/metadata">
|
||||
<apiVersion>61.0</apiVersion>
|
||||
<apiVersion>60.0</apiVersion>
|
||||
<status>Active</status>
|
||||
</ApexClass>
|
||||
|
|
@ -0,0 +1,460 @@
|
|||
<?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>50</locationX>
|
||||
<locationY>1000</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>
|
||||
<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>50</locationX>
|
||||
<locationY>890</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>
|
||||
<decisions>
|
||||
<name>Check_Envelope_Result</name>
|
||||
<label>Check Envelope Result</label>
|
||||
<locationX>50</locationX>
|
||||
<locationY>1108</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>182</locationX>
|
||||
<locationY>674</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>Build_Template_ID_Collection</targetReference>
|
||||
</connector>
|
||||
<label>Is Row Selected?</label>
|
||||
</rules>
|
||||
</decisions>
|
||||
<decisions>
|
||||
<name>Is_Language_Selected</name>
|
||||
<label>Is Language Selected?</label>
|
||||
<locationX>380</locationX>
|
||||
<locationY>242</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>
|
||||
<environments>Default</environments>
|
||||
<interviewLabel>Docusign Envelope Templates V3 {!$Flow.CurrentDateTime}</interviewLabel>
|
||||
<label>Docusign Envelope Templates V3</label>
|
||||
<loops>
|
||||
<name>Build_Template_ID_Collection</name>
|
||||
<label>Build Template ID Collection</label>
|
||||
<locationX>50</locationX>
|
||||
<locationY>782</locationY>
|
||||
<collectionReference>data.selectedRows</collectionReference>
|
||||
<iterationOrder>Asc</iterationOrder>
|
||||
<nextValueConnector>
|
||||
<targetReference>Add_Template_ID</targetReference>
|
||||
</nextValueConnector>
|
||||
<noMoreValuesConnector>
|
||||
<targetReference>Send_Composite_Envelope</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>182</locationX>
|
||||
<locationY>458</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>
|
||||
<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_Records</name>
|
||||
<label>Get Records</label>
|
||||
<locationX>380</locationX>
|
||||
<locationY>134</locationY>
|
||||
<assignNullValuesIfNoRecordsFound>false</assignNullValuesIfNoRecordsFound>
|
||||
<connector>
|
||||
<targetReference>Is_Language_Selected</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>
|
||||
<storeOutputAutomatically>true</storeOutputAutomatically>
|
||||
</recordLookups>
|
||||
<screens>
|
||||
<name>Envelope_template_records</name>
|
||||
<label>Envelope template records</label>
|
||||
<locationX>182</locationX>
|
||||
<locationY>566</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>[{"apiName":"Name","guid":"column-6d57","editable":false,"hasCustomHeaderLabel":true,"customHeaderLabel":"Envelope Template Name","wrapText":true,"order":0,"label":"Name","type":"text"}]</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>1216</locationY>
|
||||
<allowBack>true</allowBack>
|
||||
<allowFinish>true</allowFinish>
|
||||
<allowPause>false</allowPause>
|
||||
<backButtonLabel>Back</backButtonLabel>
|
||||
<fields>
|
||||
<name>ErrorDisplayMessage</name>
|
||||
<fieldText><p><span style="font-size: 16px; color: rgb(255, 0, 0);">❌ Failed to send composite envelope.</span></p><p><br></p><p><strong>Error:</strong> {!envelopeErrorMessage}</p><p><br></p><p>Please try again or contact your administrator.</p></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>578</locationX>
|
||||
<locationY>350</locationY>
|
||||
<allowBack>false</allowBack>
|
||||
<allowFinish>true</allowFinish>
|
||||
<allowPause>false</allowPause>
|
||||
<fields>
|
||||
<name>LanguageNotSelected</name>
|
||||
<fieldText><p>The <strong>DocuSign Envelope Language</strong> is not populated on the record. Please add the language first and then proceed.</p></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>182</locationX>
|
||||
<locationY>350</locationY>
|
||||
<allowBack>false</allowBack>
|
||||
<allowFinish>true</allowFinish>
|
||||
<allowPause>false</allowPause>
|
||||
<connector>
|
||||
<targetReference>DocuSign_Envelope_Templates</targetReference>
|
||||
</connector>
|
||||
<fields>
|
||||
<name>LangWarningText</name>
|
||||
<fieldText><p>The current selected language is <strong>{!Get_Records.Docusign_Envelope_Language__c}. </strong>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 <strong>DocuSign Envelope Language</strong>.</p></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>314</locationX>
|
||||
<locationY>782</locationY>
|
||||
<allowBack>true</allowBack>
|
||||
<allowFinish>true</allowFinish>
|
||||
<allowPause>false</allowPause>
|
||||
<backButtonLabel>Back</backButtonLabel>
|
||||
<fields>
|
||||
<name>ErrorMessage</name>
|
||||
<fieldText><p><strong style="background-color: rgb(255, 255, 255); color: rgb(68, 68, 68);"><em>You have not selected any of the forms. Please go back and select the form first and then proceed.</em></strong></p></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>Success_Screen</name>
|
||||
<label>Success Screen</label>
|
||||
<locationX>50</locationX>
|
||||
<locationY>1216</locationY>
|
||||
<allowBack>false</allowBack>
|
||||
<allowFinish>true</allowFinish>
|
||||
<allowPause>false</allowPause>
|
||||
<fields>
|
||||
<name>SuccessMessage</name>
|
||||
<fieldText><p><span style="font-size: 16px; color: rgb(0, 128, 0);">✅ Composite envelope sent successfully!</span></p><p><br></p><p><strong>Envelope ID:</strong> {!envelopeId}</p><p><strong>Templates combined:</strong> All selected templates were merged into a single envelope.</p></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>254</locationX>
|
||||
<locationY>0</locationY>
|
||||
<connector>
|
||||
<targetReference>Get_Records</targetReference>
|
||||
</connector>
|
||||
</start>
|
||||
<status>Draft</status>
|
||||
<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>recordId</name>
|
||||
<dataType>String</dataType>
|
||||
<isCollection>false</isCollection>
|
||||
<isInput>true</isInput>
|
||||
<isOutput>false</isOutput>
|
||||
</variables>
|
||||
</Flow>
|
||||
Loading…
Reference in New Issue