public class CLMDocGenCallout { // S1 demo environment private static final String CLM_ACCOUNT_ID_S1 = '2371cf36-eb8a-43fe-9f28-b5bbe7644397'; private static final String CLM_NAMED_CRED_S1 = 'callout:CLMs1NamedCreds'; private static final String CLM_DOWNLOAD_NAMED_CRED_S1 = 'callout:CLMs1Download'; // UAT demo environment private static final String CLM_ACCOUNT_ID_UAT = 'bccae332-c7db-4892-ab85-257df0f70fea'; private static final String CLM_NAMED_CRED_UAT = 'callout:CLMuatNamedCreds'; private static final String CLM_DOWNLOAD_NAMED_CRED_UAT = 'callout:CLMuatDownload'; public static final Integer HTTP_TIMEOUT = 30000; /** Resolve the correct CLM Named Credential base. env: 'UAT' or 'S1'. */ private static String clmNamedCredential(String env) { return env == 'S1' ? CLM_NAMED_CRED_S1 : CLM_NAMED_CRED_UAT; } private static String clmDownloadNamedCredential(String env) { return env == 'S1' ? CLM_DOWNLOAD_NAMED_CRED_S1 : CLM_DOWNLOAD_NAMED_CRED_UAT; } @TestVisible static String defaultAccountId(String env) { return env == 'S1' ? CLM_ACCOUNT_ID_S1 : CLM_ACCOUNT_ID_UAT; } @TestVisible static String extractAccountId(String resourceOrHref) { if (String.isBlank(resourceOrHref)) { return null; } Integer v2Index = resourceOrHref.indexOf('/v2/'); if (v2Index < 0) { return null; } String afterV2 = resourceOrHref.substring(v2Index + 4); List parts = afterV2.split('/'); return parts.isEmpty() ? null : parts[0]; } @TestVisible static String normalizeResourcePath(String resourceOrHref, String env) { return normalizeResourcePathWithAccountId(resourceOrHref, defaultAccountId(env)); } @TestVisible static String normalizeResourcePathWithAccountId(String resourceOrHref, String fallbackAccountId) { if (String.isBlank(resourceOrHref)) { throw new IllegalArgumentException('resourceOrHref is required'); } if (resourceOrHref.startsWith('http://') || resourceOrHref.startsWith('https://')) { Integer pathStart = resourceOrHref.indexOf('/', resourceOrHref.indexOf('//') + 2); if (pathStart < 0) { throw new IllegalArgumentException('Unable to determine resource path from href: ' + resourceOrHref); } return resourceOrHref.substring(pathStart); } if (resourceOrHref.startsWith('/v2/')) { return resourceOrHref; } if (resourceOrHref.startsWith('v2/')) { return '/' + resourceOrHref; } if (resourceOrHref.startsWith('/')) { return '/v2/' + fallbackAccountId + resourceOrHref; } return '/v2/' + fallbackAccountId + '/' + resourceOrHref; } public static String buildEndpointForResource(String resourceOrHref, String env) { return clmNamedCredential(env) + normalizeResourcePath(resourceOrHref, env); } public static String buildEndpointForResource(String resourceOrHref, String accountId, String apiNamedCredential) { return 'callout:' + apiNamedCredential + normalizeResourcePathWithAccountId(resourceOrHref, accountId); } public static String buildDownloadEndpointForResource(String resourceOrHref, String env) { return clmDownloadNamedCredential(env) + normalizeResourcePath(resourceOrHref, env); } public static String buildDownloadEndpointForResource(String resourceOrHref, String accountId, String downloadNamedCredential) { return 'callout:' + downloadNamedCredential + normalizeResourcePathWithAccountId(resourceOrHref, accountId); } /** Defaults to UAT environment. */ public static CLMDocGenResponse generateDocument( String caseId, String templateDocHref, String destinationFolderHref, String destinationDocName ) { return generateDocument(caseId, templateDocHref, destinationFolderHref, destinationDocName, 'UAT'); } /** * Generate a merged document via CLM documentxmlmergetasks (no user interaction). * @param caseId Appraiser_Case__c record Id * @param templateDocHref Full CLM Href URL of the template .docx document * @param destinationFolderHref Full CLM Href URL of the destination folder * @param destinationDocName Filename for the generated document, e.g. "Review_AC-00001.docx" * @param env 'UAT' (apiuatna11.springcm.com) or 'S1' (api.s1.us.clm.demo.docusign.net) */ public static CLMDocGenResponse generateDocument( String caseId, String templateDocHref, String destinationFolderHref, String destinationDocName, String env ) { return generateDocument( caseId, templateDocHref, destinationFolderHref, destinationDocName, env, defaultAccountId(env), clmNamedCredential(env).substringAfter('callout:') ); } public static CLMDocGenResponse generateDocument( String caseId, String templateDocHref, String destinationFolderHref, String destinationDocName, String env, String configuredAccountId, String apiNamedCredential ) { Map casePayload = AppraiserCasePayloadBuilder.buildPayload(caseId); String accountId = firstNonBlank( extractAccountId(templateDocHref), extractAccountId(destinationFolderHref), configuredAccountId, defaultAccountId(env) ); Map requestBody = buildRequestBodyMap( casePayload, templateDocHref, destinationFolderHref, destinationDocName ); HttpRequest req = new HttpRequest(); req.setEndpoint('callout:' + apiNamedCredential + '/v2/' + accountId + '/documentxmlmergetasks'); req.setMethod('POST'); req.setHeader('Content-Type', 'application/json'); req.setTimeout(HTTP_TIMEOUT); req.setBody(JSON.serialize(requestBody)); try { Http http = new Http(); HttpResponse res = http.send(req); return parseTaskResponse(res); } catch (Exception ex) { return new CLMDocGenResponse(false, 'HTTP Callout Error: ' + ex.getMessage(), null, null); } } public static String buildDataXmlForCase(String caseId) { return buildDataXml(AppraiserCasePayloadBuilder.buildPayload(caseId)); } public static String buildRequestBodyJson( String caseId, String templateDocHref, String destinationFolderHref, String destinationDocName ) { return JSON.serializePretty( buildRequestBodyMap( AppraiserCasePayloadBuilder.buildPayload(caseId), templateDocHref, destinationFolderHref, destinationDocName ) ); } public static String buildDocumentXmlMergeTasksUrl( String templateDocHref, String destinationFolderHref, String env, String configuredAccountId ) { String accountId = firstNonBlank( extractAccountId(templateDocHref), extractAccountId(destinationFolderHref), configuredAccountId, defaultAccountId(env) ); String baseUrl = firstNonBlank( extractBaseUrl(templateDocHref), extractBaseUrl(destinationFolderHref), null, defaultBaseUrl(env) ); return baseUrl + '/v2/' + accountId + '/documentxmlmergetasks'; } /** Poll the status of a submitted merge task by its GUID (defaults to UAT). */ public static CLMDocGenResponse getTaskStatus(String taskId) { return getTaskStatus(taskId, 'UAT'); } public static CLMDocGenResponse getTaskStatus(String taskId, String env) { return getTaskStatus(taskId, env, defaultAccountId(env), clmNamedCredential(env).substringAfter('callout:')); } public static CLMDocGenResponse getTaskStatus(String taskId, String env, String configuredAccountId, String apiNamedCredential) { HttpRequest req = new HttpRequest(); req.setEndpoint(buildEndpointForResource('/documentxmlmergetasks/' + taskId, configuredAccountId, apiNamedCredential)); req.setMethod('GET'); req.setTimeout(HTTP_TIMEOUT); try { Http http = new Http(); HttpResponse res = http.send(req); return parseTaskResponse(res); } catch (Exception ex) { return new CLMDocGenResponse(false, 'HTTP Callout Error: ' + ex.getMessage(), null, null); } } /** Probe any CLM resource path for debugging. env: 'UAT' or 'S1'. */ public static String probe(String resource) { return probe(resource, 'UAT'); } public static String probe(String resource, String env) { HttpRequest req = new HttpRequest(); req.setEndpoint(buildEndpointForResource(resource, env)); req.setMethod('GET'); req.setTimeout(HTTP_TIMEOUT); HttpResponse res = new Http().send(req); return 'HTTP ' + res.getStatusCode() + ': ' + res.getBody(); } public static DownloadedDocument downloadDocument(String resourceOrHref, String env) { return downloadDocument( resourceOrHref, env, defaultAccountId(env), clmDownloadNamedCredential(env).substringAfter('callout:') ); } public static DownloadedDocument downloadDocument(String resourceOrHref, String env, String configuredAccountId, String downloadNamedCredential) { HttpRequest req = new HttpRequest(); req.setEndpoint(buildDownloadEndpointForResource(resourceOrHref, configuredAccountId, downloadNamedCredential)); req.setMethod('GET'); req.setTimeout(HTTP_TIMEOUT); HttpResponse res = new Http().send(req); Integer statusCode = res.getStatusCode(); if (statusCode < 200 || statusCode >= 300) { throw new CalloutException('CLM download error (HTTP ' + statusCode + '): ' + res.getBody()); } DownloadedDocument document = new DownloadedDocument(); document.body = res.getBodyAsBlob(); document.contentType = res.getHeader('Content-Type'); document.fileName = extractFileName(res, resourceOrHref, document.contentType); return document; } /** * Build the DataXML string from the case payload. * Flat fields become direct child elements of . * DeficiencyList items expand into numbered elements: * Deficiency_1_Number, Deficiency_1_Description, Deficiency_1_Resolution, etc. */ private static String buildDataXml(Map payload) { String xml = ''; // Emit flat fields first for (String key : payload.keySet()) { if (key == 'DeficiencyList') continue; xml += '<' + key + '>' + escapeXml(safeValue(payload.get(key))) + ''; } // Emit DeficiencyList as a nested list so templates can iterate dynamically List deficiencies = (List) payload.get('DeficiencyList'); if (deficiencies != null && !deficiencies.isEmpty()) { xml += ''; for (Integer i = 0; i < deficiencies.size(); i++) { Map d = (Map) deficiencies[i]; xml += ''; xml += '' + escapeXml(safeValue(d.get('deficiencyNumber'))) + ''; xml += '' + escapeXml(safeValue(d.get('description'))) + ''; xml += '' + escapeXml(safeValue(d.get('resolution'))) + ''; xml += '' + escapeXml(safeValue(d.get('reference'))) + ''; xml += ''; } xml += ''; xml += '' + deficiencies.size() + ''; } xml += ''; return xml; } public static String prettyPrintXml(String xml) { if (String.isBlank(xml)) { return xml; } String normalized = xml .replace('><', '>\n<') .replace('\r\n', '\n') .replace('\r', '\n'); List lines = normalized.split('\n'); List formatted = new List(); Integer indent = 0; for (String rawLine : lines) { String line = rawLine == null ? '' : rawLine.trim(); if (line == '') { continue; } Boolean isClosing = line.startsWith('') || (line.contains(' 0); if (isClosing && indent > 0) { indent--; } formatted.add(repeatIndent(indent) + line); if (!isClosing && !isSelfClosing && !isDeclaration) { indent++; } } return String.join(formatted, '\n'); } private static Map buildRequestBodyMap( Map casePayload, String templateDocHref, String destinationFolderHref, String destinationDocName ) { return new Map{ 'TemplateDocument' => new Map{ 'Href' => templateDocHref }, 'DataXML' => buildDataXml(casePayload), 'DestinationDocumentName' => destinationDocName, 'DestinationFolder' => new Map{ 'Href' => destinationFolderHref } }; } private static String extractBaseUrl(String resourceOrHref) { if (String.isBlank(resourceOrHref)) { return null; } if (!(resourceOrHref.startsWith('http://') || resourceOrHref.startsWith('https://'))) { return null; } Integer schemeIndex = resourceOrHref.indexOf('//'); Integer pathStart = resourceOrHref.indexOf('/', schemeIndex + 2); return pathStart > 0 ? resourceOrHref.substring(0, pathStart) : resourceOrHref; } private static String defaultBaseUrl(String env) { return env == 'S1' ? 'https://api.s1.us.clm.demo.docusign.net' : 'https://apiuatna11.springcm.com'; } private static String repeatIndent(Integer indent) { String value = ''; for (Integer i = 0; i < indent; i++) { value += ' '; } return value; } private static String safeValue(Object val) { return val != null ? String.valueOf(val) : ''; } private static String escapeXml(String s) { if (s == null) return ''; return s.replace('&', '&') .replace('<', '<') .replace('>', '>') .replace('"', '"') .replace('\'', '''); } private static CLMDocGenResponse parseTaskResponse(HttpResponse res) { Integer statusCode = res.getStatusCode(); String body = res.getBody(); if (statusCode >= 200 && statusCode < 300) { Map m = (Map) JSON.deserializeUntyped(body); String href = firstString(m, new List{ 'Href', 'Uri', 'Location' }); String status = firstString(m, new List{ 'Status', 'State' }); String taskId = href != null ? href.substringAfterLast('/') : null; String generatedDocumentUrl = findFirstDocumentHref(m); String generatedDocumentId = generatedDocumentUrl != null ? generatedDocumentUrl.substringAfterLast('/') : null; String message = 'Task status: ' + (String.isNotBlank(status) ? status : 'Unknown'); return new CLMDocGenResponse(true, message, href, taskId, status, generatedDocumentUrl, generatedDocumentId, body); } else { return new CLMDocGenResponse(false, 'CLM API Error (HTTP ' + statusCode + '): ' + body, null, null, null, null, null, body); } } private static String firstNonBlank(String firstValue, String secondValue, String thirdValue, String fallbackValue) { if (String.isNotBlank(firstValue)) { return firstValue; } if (String.isNotBlank(secondValue)) { return secondValue; } if (String.isNotBlank(thirdValue)) { return thirdValue; } return fallbackValue; } private static String firstString(Map source, List keys) { for (String key : keys) { Object value = source.get(key); if (value != null) { String asString = String.valueOf(value); if (String.isNotBlank(asString)) { return asString; } } } return null; } private static String findFirstDocumentHref(Object node) { if (node instanceof Map) { Map mapNode = (Map) node; String href = firstString(mapNode, new List{ 'Href', 'Uri', 'Location' }); if (String.isNotBlank(href) && href.contains('/documents/') && !href.contains('/documentxmlmergetasks/')) { return href; } for (Object value : mapNode.values()) { String nestedHref = findFirstDocumentHref(value); if (String.isNotBlank(nestedHref)) { return nestedHref; } } } else if (node instanceof List) { for (Object item : (List) node) { String nestedHref = findFirstDocumentHref(item); if (String.isNotBlank(nestedHref)) { return nestedHref; } } } return null; } private static String extractFileName(HttpResponse res, String resourceOrHref, String contentType) { String disposition = res.getHeader('Content-Disposition'); if (String.isNotBlank(disposition)) { Integer marker = disposition.toLowerCase().indexOf('filename='); if (marker >= 0) { String candidate = disposition.substring(marker + 9).trim(); if (candidate.startsWith('"') && candidate.endsWith('"') && candidate.length() >= 2) { candidate = candidate.substring(1, candidate.length() - 1); } if (String.isNotBlank(candidate)) { return candidate; } } } String baseName = String.isNotBlank(resourceOrHref) ? resourceOrHref.substringAfterLast('/') : 'generated-document'; String extension = inferExtension(contentType); if (String.isNotBlank(extension) && !baseName.toLowerCase().endsWith(extension)) { return baseName + extension; } return baseName; } private static String inferExtension(String contentType) { if (String.isBlank(contentType)) { return '.docx'; } String normalizedType = contentType.toLowerCase(); if (normalizedType.contains('pdf')) { return '.pdf'; } if (normalizedType.contains('wordprocessingml') || normalizedType.contains('officedocument')) { return '.docx'; } if (normalizedType.contains('msword')) { return '.doc'; } if (normalizedType.contains('json')) { return '.json'; } return ''; } public class CLMDocGenResponse { @AuraEnabled public Boolean success; @AuraEnabled public String message; @AuraEnabled public String documentUrl; @AuraEnabled public String documentId; @AuraEnabled public String taskStatus; @AuraEnabled public String generatedDocumentUrl; @AuraEnabled public String generatedDocumentId; @AuraEnabled public String taskDetailsJson; public CLMDocGenResponse(Boolean success, String message, String documentUrl, String documentId) { this(success, message, documentUrl, documentId, null, null, null, null); } public CLMDocGenResponse( Boolean success, String message, String documentUrl, String documentId, String taskStatus, String generatedDocumentUrl, String generatedDocumentId, String taskDetailsJson ) { this.success = success; this.message = message; this.documentUrl = documentUrl; this.documentId = documentId; this.taskStatus = taskStatus; this.generatedDocumentUrl = generatedDocumentUrl; this.generatedDocumentId = generatedDocumentId; this.taskDetailsJson = taskDetailsJson; } } public class DownloadedDocument { public Blob body; public String fileName; public String contentType; } }