diff --git a/force-app/main/default/classes/AppraiserCasePayloadBuilder.cls b/force-app/main/default/classes/AppraiserCasePayloadBuilder.cls index d98e6b7..3e8fd5b 100644 --- a/force-app/main/default/classes/AppraiserCasePayloadBuilder.cls +++ b/force-app/main/default/classes/AppraiserCasePayloadBuilder.cls @@ -21,7 +21,30 @@ public class AppraiserCasePayloadBuilder { Map payload = new Map(); payload.put('AppraiserCaseNumber', appraiserCase.Name); payload.put('AppraiserFieldReviewDate', formatDate(appraiserCase.Appraiser_Field_Review_Date__c)); - payload.put('PropertyAddress', appraiserCase.Property_Address__c); + payload.put('LetterSentDate', formatDate(appraiserCase.Letter_Sent_Date__c)); + payload.put('FHACaseNumber', appraiserCase.FHA_Case_Number__c); + payload.put('AppraiserName', appraiserCase.Appraiser_Name__c); + payload.put('AppraiserSalutation', appraiserCase.Appraiser_Salutation__c); + payload.put('AppraiserLastName', appraiserCase.Appraiser_Last_Name__c); + payload.put('AppraiserEmail', appraiserCase.Appraiser_Email__c); + payload.put('AppraiserStreet', appraiserCase.Appraiser_Street__c); + payload.put('AppraiserCity', appraiserCase.Appraiser_City__c); + payload.put('AppraiserStateProvince', appraiserCase.Appraiser_State_Province__c); + payload.put('AppraiserPostalCode', appraiserCase.Appraiser_Postal_Code__c); + payload.put('AppraiserCountry', appraiserCase.Appraiser_Country__c); + payload.put('AppraiserAddress', formatMailingAddress( + appraiserCase.Appraiser_Street__c, + appraiserCase.Appraiser_City__c, + appraiserCase.Appraiser_State_Province__c, + appraiserCase.Appraiser_Postal_Code__c, + appraiserCase.Appraiser_Country__c + )); + payload.put('PropertyStreet', appraiserCase.Property_Street__c); + payload.put('PropertyCity', appraiserCase.Property_City__c); + payload.put('PropertyStateProvince', appraiserCase.Property_State_Province__c); + payload.put('PropertyPostalCode', appraiserCase.Property_Postal_Code__c); + payload.put('PropertyCountry', appraiserCase.Property_Country__c); + payload.put('PropertyAddress', formatAddress(appraiserCase)); // Transform child deficiencies into DeficiencyList array List> deficiencyList = new List>(); @@ -31,6 +54,7 @@ public class AppraiserCasePayloadBuilder { defMap.put('deficiencyNumber', deficiency.Deficiency_Number__c); defMap.put('description', deficiency.Description__c); defMap.put('resolution', deficiency.Resolution__c); + defMap.put('reference', deficiency.Reference__c); deficiencyList.add(defMap); } } @@ -60,8 +84,23 @@ public class AppraiserCasePayloadBuilder { Id, Name, Appraiser_Field_Review_Date__c, - Property_Address__c, - (SELECT Id, Deficiency_Number__c, Description__c, Resolution__c + Letter_Sent_Date__c, + FHA_Case_Number__c, + Appraiser_Name__c, + Appraiser_Salutation__c, + Appraiser_Last_Name__c, + Appraiser_Email__c, + Appraiser_Street__c, + Appraiser_City__c, + Appraiser_State_Province__c, + Appraiser_Postal_Code__c, + Appraiser_Country__c, + Property_Street__c, + Property_City__c, + Property_State_Province__c, + Property_Postal_Code__c, + Property_Country__c, + (SELECT Id, Deficiency_Number__c, Description__c, Resolution__c, Reference__c FROM Deficiencies__r ORDER BY Deficiency_Number__c ASC) FROM Appraiser_Case__c @@ -73,11 +112,60 @@ public class AppraiserCasePayloadBuilder { } /** - * @description Format date for CLM merge (YYYY-MM-DD or null). + * @description Format date for CLM merge (for example Apr 09, 2026) or null. * @param dt Date field value * @return String Formatted date or null */ private static String formatDate(Date dt) { - return dt != null ? dt.format() : null; + return dt != null + ? DateTime.newInstance(dt, Time.newInstance(0, 0, 0, 0)).format('MMM dd, yyyy') + : null; + } + + private static String formatAddress(Appraiser_Case__c appraiserCase) { + return formatMailingAddress( + appraiserCase.Property_Street__c, + appraiserCase.Property_City__c, + appraiserCase.Property_State_Province__c, + appraiserCase.Property_Postal_Code__c, + appraiserCase.Property_Country__c + ); + } + + public static String formatMailingAddress( + String street, + String city, + String stateProvince, + String postalCode, + String country + ) { + List lines = new List(); + if (String.isNotBlank(street)) { + lines.add(street.trim()); + } + + List localityParts = new List(); + if (String.isNotBlank(city)) { + localityParts.add(city.trim()); + } + if (String.isNotBlank(stateProvince)) { + localityParts.add(stateProvince.trim()); + } + + String locality = String.join(localityParts, ', '); + if (String.isNotBlank(postalCode)) { + locality = String.isNotBlank(locality) + ? locality + ' ' + postalCode.trim() + : postalCode.trim(); + } + if (String.isNotBlank(locality)) { + lines.add(locality); + } + + if (String.isNotBlank(country)) { + lines.add(country.trim()); + } + + return lines.isEmpty() ? null : String.join(lines, ', '); } } diff --git a/force-app/main/default/classes/AppraiserCasePayloadBuilderTest.cls b/force-app/main/default/classes/AppraiserCasePayloadBuilderTest.cls index 99cac08..983bbee 100644 --- a/force-app/main/default/classes/AppraiserCasePayloadBuilderTest.cls +++ b/force-app/main/default/classes/AppraiserCasePayloadBuilderTest.cls @@ -5,8 +5,23 @@ private class AppraiserCasePayloadBuilderTest { static void setupTestData() { // Create test Appraiser Case Appraiser_Case__c testCase = new Appraiser_Case__c( - Appraiser_Field_Review_Date__c = Date.parse('04/02/2026'), - Property_Address__c = '123 Main St, Denver, CO 80202' + Appraiser_Field_Review_Date__c = Date.newInstance(2026, 4, 2), + Letter_Sent_Date__c = Date.newInstance(2026, 4, 9), + FHA_Case_Number__c = '123-4567890', + Appraiser_Name__c = 'Jamie Appraiser', + Appraiser_Salutation__c = 'Ms.', + Appraiser_Last_Name__c = 'Appraiser', + Appraiser_Email__c = 'jamie.appraiser@example.com', + Appraiser_Street__c = '245 Lexington Ave', + Appraiser_City__c = 'New York', + Appraiser_State_Province__c = 'NY', + Appraiser_Postal_Code__c = '10016', + Appraiser_Country__c = 'USA', + Property_Street__c = '123 Main St', + Property_City__c = 'Denver', + Property_State_Province__c = 'CO', + Property_Postal_Code__c = '80202', + Property_Country__c = 'USA' ); insert testCase; @@ -16,13 +31,15 @@ private class AppraiserCasePayloadBuilderTest { Appraiser_Case__c = testCase.Id, Deficiency_Number__c = 1, Description__c = 'Missing comparable sale adjustment detail.', - Resolution__c = 'Added adjustment rationale and supporting calculations.' + Resolution__c = 'Added adjustment rationale and supporting calculations.', + Reference__c = 'VC-1' )); testDefs.add(new Appraiser_Case_Deficiency__c( Appraiser_Case__c = testCase.Id, Deficiency_Number__c = 2, Description__c = 'Neighborhood trend explanation insufficient.', - Resolution__c = 'Expanded market trend narrative with MLS evidence.' + Resolution__c = 'Expanded market trend narrative with MLS evidence.', + Reference__c = 'MC-2' )); insert testDefs; } @@ -36,11 +53,39 @@ private class AppraiserCasePayloadBuilderTest { Assert.isNotNull(payload, 'Payload should not be null'); Assert.isTrue(payload.containsKey('AppraiserCaseNumber'), 'Payload should contain AppraiserCaseNumber'); Assert.isTrue(payload.containsKey('AppraiserFieldReviewDate'), 'Payload should contain AppraiserFieldReviewDate'); + Assert.isTrue(payload.containsKey('LetterSentDate'), 'Payload should contain LetterSentDate'); + Assert.isTrue(payload.containsKey('FHACaseNumber'), 'Payload should contain FHACaseNumber'); + Assert.isTrue(payload.containsKey('AppraiserName'), 'Payload should contain AppraiserName'); + Assert.isTrue(payload.containsKey('AppraiserSalutation'), 'Payload should contain AppraiserSalutation'); + Assert.isTrue(payload.containsKey('AppraiserLastName'), 'Payload should contain AppraiserLastName'); + Assert.isTrue(payload.containsKey('AppraiserEmail'), 'Payload should contain AppraiserEmail'); + Assert.isTrue(payload.containsKey('AppraiserAddress'), 'Payload should contain AppraiserAddress'); Assert.isTrue(payload.containsKey('PropertyAddress'), 'Payload should contain PropertyAddress'); + Assert.isTrue(payload.containsKey('PropertyStreet'), 'Payload should contain PropertyStreet'); + Assert.isTrue(payload.containsKey('PropertyCity'), 'Payload should contain PropertyCity'); + Assert.isTrue(payload.containsKey('PropertyStateProvince'), 'Payload should contain PropertyStateProvince'); + Assert.isTrue(payload.containsKey('PropertyPostalCode'), 'Payload should contain PropertyPostalCode'); + Assert.isTrue(payload.containsKey('PropertyCountry'), 'Payload should contain PropertyCountry'); Assert.isTrue(payload.containsKey('DeficiencyList'), 'Payload should contain DeficiencyList'); + Assert.areEqual('123 Main St', (String) payload.get('PropertyStreet')); + Assert.areEqual('Denver', (String) payload.get('PropertyCity')); + Assert.areEqual('CO', (String) payload.get('PropertyStateProvince')); + Assert.areEqual('80202', (String) payload.get('PropertyPostalCode')); + Assert.areEqual('USA', (String) payload.get('PropertyCountry')); + Assert.areEqual('123-4567890', (String) payload.get('FHACaseNumber')); + Assert.areEqual('Apr 02, 2026', (String) payload.get('AppraiserFieldReviewDate')); + Assert.areEqual('Apr 09, 2026', (String) payload.get('LetterSentDate')); + Assert.areEqual('Jamie Appraiser', (String) payload.get('AppraiserName')); + Assert.areEqual('Ms.', (String) payload.get('AppraiserSalutation')); + Assert.areEqual('Appraiser', (String) payload.get('AppraiserLastName')); + Assert.areEqual('jamie.appraiser@example.com', (String) payload.get('AppraiserEmail')); + Assert.areEqual('245 Lexington Ave, New York, NY 10016, USA', (String) payload.get('AppraiserAddress')); + Assert.areEqual('123 Main St, Denver, CO 80202, USA', (String) payload.get('PropertyAddress')); List deficiencyList = (List) payload.get('DeficiencyList'); Assert.areEqual(2, deficiencyList.size(), 'DeficiencyList should contain 2 items'); + Map firstDeficiency = (Map) deficiencyList[0]; + Assert.areEqual('VC-1', (String) firstDeficiency.get('reference')); } @IsTest @@ -58,7 +103,10 @@ private class AppraiserCasePayloadBuilderTest { static void testPayloadWithNullDate() { // Create case without review date Appraiser_Case__c testCase = new Appraiser_Case__c( - Property_Address__c = '456 Oak Ave, Boulder, CO 80301' + Property_Street__c = '456 Oak Ave', + Property_City__c = 'Boulder', + Property_State_Province__c = 'CO', + Property_Postal_Code__c = '80301' ); insert testCase; @@ -66,6 +114,7 @@ private class AppraiserCasePayloadBuilderTest { Assert.isNotNull(payload, 'Payload should not be null even with null date'); Assert.isNull(payload.get('AppraiserFieldReviewDate'), 'Null date should map to null in payload'); + Assert.areEqual('456 Oak Ave, Boulder, CO 80301', (String) payload.get('PropertyAddress')); } @IsTest diff --git a/force-app/main/default/classes/CLMAdminService.cls b/force-app/main/default/classes/CLMAdminService.cls new file mode 100644 index 0000000..0dca693 --- /dev/null +++ b/force-app/main/default/classes/CLMAdminService.cls @@ -0,0 +1,744 @@ +public with sharing class CLMAdminService { + public class CaseDeficiencyItem { + @AuraEnabled public Id recordId; + @AuraEnabled public Decimal deficiencyNumber; + @AuraEnabled public String description; + @AuraEnabled public String resolution; + @AuraEnabled public String reference; + } + + public class CaseContext { + @AuraEnabled public Id caseId; + @AuraEnabled public String caseNumber; + @AuraEnabled public String propertyAddress; + @AuraEnabled public String lastDocGenStatus; + @AuraEnabled public String lastDocGenMessage; + @AuraEnabled public String lastClmAccountCode; + @AuraEnabled public String lastDocGenTaskId; + @AuraEnabled public String lastDocGenTaskUrl; + @AuraEnabled public String generatedDocumentUrl; + @AuraEnabled public String generatedDocumentId; + @AuraEnabled public String attachedFileContentDocumentId; + @AuraEnabled public String attachedFileUrl; + @AuraEnabled public Datetime lastDocGenRequestedAt; + @AuraEnabled public Datetime lastDocGenCompletedAt; + @AuraEnabled public List deficiencies; + } + + public class FileAttachmentResult { + @AuraEnabled public Boolean success; + @AuraEnabled public String message; + @AuraEnabled public String contentDocumentId; + @AuraEnabled public String fileUrl; + @AuraEnabled public String fileTitle; + } + + public class AccountSettings { + @AuraEnabled public String accountCode; + @AuraEnabled public String accountDisplayName; + @AuraEnabled public String environment; + @AuraEnabled public String clmAccountId; + @AuraEnabled public String clmApiNamedCredential; + @AuraEnabled public String clmDownloadNamedCredential; + @AuraEnabled public String eSignatureRestNamedCredential; + @AuraEnabled public String templateRootFolderHref; + @AuraEnabled public String destinationRootFolderHref; + @AuraEnabled public String defaultTemplateDocumentHref; + @AuraEnabled public String defaultDocumentNamePrefix; + @AuraEnabled public Boolean active; + } + + public class LetterSettings { + @AuraEnabled public String accountCode; + @AuraEnabled public String letterCode; + @AuraEnabled public String letterDisplayName; + @AuraEnabled public String description; + @AuraEnabled public Boolean isDefault; + @AuraEnabled public Decimal sortOrder; + @AuraEnabled public String templateRootFolderHref; + @AuraEnabled public String destinationRootFolderHref; + @AuraEnabled public String defaultTemplateDocumentHref; + @AuraEnabled public String defaultDocumentNamePrefix; + @AuraEnabled public Boolean active; + } + + public class ResourceItem { + @AuraEnabled public String name; + @AuraEnabled public String href; + @AuraEnabled public String type; + @AuraEnabled public String parentHref; + @AuraEnabled public String rawJson; + } + + public class FolderContents { + @AuraEnabled public ResourceItem folder; + @AuraEnabled public List folders; + @AuraEnabled public List documents; + } + + public class DocGenPreview { + @AuraEnabled public String accountCode; + @AuraEnabled public String accountDisplayName; + @AuraEnabled public String letterCode; + @AuraEnabled public String letterDisplayName; + @AuraEnabled public String mergeTaskEndpointUrl; + @AuraEnabled public String templateDocHref; + @AuraEnabled public String destinationFolderHref; + @AuraEnabled public String destinationDocName; + @AuraEnabled public String payloadJson; + @AuraEnabled public String dataXml; + @AuraEnabled public String requestBodyJson; + } + + @AuraEnabled(cacheable=true) + public static List listAccountSettings() { + List settings = new List(); + for (CLM_Account_Setting__mdt row : [ + SELECT DeveloperName, + Account_Code__c, + Account_Display_Name__c, + Environment_Code__c, + CLM_Account_Id__c, + CLM_Api_Named_Credential__c, + CLM_Download_Named_Credential__c, + ESignature_Rest_Named_Credential__c, + Template_Root_Folder_Href__c, + Destination_Root_Folder_Href__c, + Default_Template_Document_Href__c, + Default_Destination_Document_Name_Prefix__c, + Active__c + FROM CLM_Account_Setting__mdt + WHERE Active__c = true + ORDER BY Account_Display_Name__c ASC, DeveloperName ASC + ]) { + settings.add(toAccountSettings(row)); + } + return settings; + } + + @AuraEnabled(cacheable=true) + public static AccountSettings getAccountSettings(String accountCode) { + CLM_Account_Setting__mdt row = resolveAccountSetting(accountCode); + return row == null ? null : toAccountSettings(row); + } + + @AuraEnabled(cacheable=true) + public static List listLetterSettings(String accountCode) { + AccountSettings account = getAccountSettings(accountCode); + List letters = new List(); + if (account == null) { + return letters; + } + + for (CLM_Letter_Definition__mdt row : [ + SELECT DeveloperName, + Account_Code__c, + Letter_Code__c, + Letter_Display_Name__c, + Description__c, + Active__c, + Is_Default__c, + Sort_Order__c, + Template_Root_Folder_Href__c, + Destination_Root_Folder_Href__c, + Default_Template_Document_Href__c, + Default_Destination_Document_Name_Prefix__c + FROM CLM_Letter_Definition__mdt + WHERE Active__c = true + AND Account_Code__c = :account.accountCode + ORDER BY Is_Default__c DESC, Sort_Order__c ASC, Letter_Display_Name__c ASC, DeveloperName ASC + ]) { + letters.add(toLetterSettings(row, account)); + } + + if (letters.isEmpty()) { + letters.add(buildFallbackLetterSettings(account)); + } + return letters; + } + + @AuraEnabled(cacheable=true) + public static LetterSettings getLetterSettings(String accountCode, String letterCode) { + AccountSettings account = getAccountSettings(accountCode); + if (account == null) { + return null; + } + + String normalizedLetterCode = String.isBlank(letterCode) ? null : letterCode.trim(); + if (String.isNotBlank(normalizedLetterCode)) { + List rows = [ + SELECT DeveloperName, + Account_Code__c, + Letter_Code__c, + Letter_Display_Name__c, + Description__c, + Active__c, + Is_Default__c, + Sort_Order__c, + Template_Root_Folder_Href__c, + Destination_Root_Folder_Href__c, + Default_Template_Document_Href__c, + Default_Destination_Document_Name_Prefix__c + FROM CLM_Letter_Definition__mdt + WHERE Active__c = true + AND Account_Code__c = :account.accountCode + AND Letter_Code__c = :normalizedLetterCode + LIMIT 1 + ]; + if (!rows.isEmpty()) { + return toLetterSettings(rows[0], account); + } + } + + List letters = listLetterSettings(account.accountCode); + for (LetterSettings letter : letters) { + if (letter.isDefault) { + return letter; + } + } + return letters.isEmpty() ? buildFallbackLetterSettings(account) : letters[0]; + } + + @AuraEnabled(cacheable=false) + public static CLMDocGenCallout.CLMDocGenResponse generateDocument( + Id appraiserCaseId, + String templateDocHref, + String destinationFolderHref, + String destinationDocName, + String accountCode + ) { + CLM_Account_Setting__mdt account = requireAccountSetting(accountCode); + CLMDocGenCallout.CLMDocGenResponse response = CLMDocGenCallout.generateDocument( + (String) appraiserCaseId, + templateDocHref, + destinationFolderHref, + destinationDocName, + account.Environment_Code__c, + account.CLM_Account_Id__c, + account.CLM_Api_Named_Credential__c + ); + persistDocGenResult(appraiserCaseId, templateDocHref, destinationFolderHref, response, false, accountCode); + return response; + } + + @AuraEnabled(cacheable=false) + public static CLMDocGenCallout.CLMDocGenResponse getTaskStatus(Id appraiserCaseId, String taskId, String accountCode) { + CLM_Account_Setting__mdt account = requireAccountSetting(accountCode); + CLMDocGenCallout.CLMDocGenResponse response = CLMDocGenCallout.getTaskStatus( + taskId, + account.Environment_Code__c, + account.CLM_Account_Id__c, + account.CLM_Api_Named_Credential__c + ); + persistDocGenResult(appraiserCaseId, null, null, response, true, accountCode); + return response; + } + + @AuraEnabled(cacheable=false) + public static String probeResource(String resourceOrHref, String accountCode) { + CLM_Account_Setting__mdt account = requireAccountSetting(accountCode); + return performGet(resourceOrHref, account).getBody(); + } + + @AuraEnabled(cacheable=false) + public static DocGenPreview getDocGenPreview(Id appraiserCaseId, String accountCode, String letterCode) { + if (appraiserCaseId == null) { + throw new AuraHandledException('appraiserCaseId is required'); + } + + AccountSettings account = getAccountSettings(accountCode); + if (account == null) { + throw new AuraHandledException('No active CLM account setting was found for ' + accountCode + '.'); + } + + LetterSettings letter = getLetterSettings(account.accountCode, letterCode); + List previewRows = [ + SELECT Id, Name + FROM Appraiser_Case__c + WHERE Id = :appraiserCaseId + LIMIT 1 + ]; + if (previewRows.isEmpty()) { + throw new AuraHandledException('Appraiser Case not found: ' + appraiserCaseId); + } + Appraiser_Case__c appraiserCase = previewRows[0]; + + String prefix = letter != null && String.isNotBlank(letter.defaultDocumentNamePrefix) + ? letter.defaultDocumentNamePrefix + : account.defaultDocumentNamePrefix; + + DocGenPreview preview = new DocGenPreview(); + preview.accountCode = account.accountCode; + preview.accountDisplayName = account.accountDisplayName; + preview.letterCode = letter != null ? letter.letterCode : 'APPRAISER_REVIEW'; + preview.letterDisplayName = letter != null ? letter.letterDisplayName : 'Appraiser Review Letter'; + preview.templateDocHref = letter != null ? letter.defaultTemplateDocumentHref : account.defaultTemplateDocumentHref; + preview.destinationFolderHref = letter != null ? letter.destinationRootFolderHref : account.destinationRootFolderHref; + preview.destinationDocName = buildDefaultDocumentName(prefix, appraiserCase.Name); + preview.mergeTaskEndpointUrl = CLMDocGenCallout.buildDocumentXmlMergeTasksUrl( + preview.templateDocHref, + preview.destinationFolderHref, + account.environment, + account.clmAccountId + ); + preview.payloadJson = JSON.serializePretty(AppraiserCasePayloadBuilder.buildPayload((String) appraiserCaseId)); + preview.dataXml = CLMDocGenCallout.prettyPrintXml(CLMDocGenCallout.buildDataXmlForCase((String) appraiserCaseId)); + preview.requestBodyJson = CLMDocGenCallout.buildRequestBodyJson( + (String) appraiserCaseId, + preview.templateDocHref, + preview.destinationFolderHref, + preview.destinationDocName + ); + return preview; + } + + @AuraEnabled(cacheable=false) + public static CaseContext getCaseContext(Id appraiserCaseId) { + List contextRows = [ + SELECT Id, + Name, + Property_Street__c, + Property_City__c, + Property_State_Province__c, + Property_Postal_Code__c, + Property_Country__c, + Last_DocGen_Status__c, + Last_DocGen_Message__c, + Last_CLM_Account_Code__c, + Last_DocGen_Task_Id__c, + Last_DocGen_Task_Url__c, + Generated_Document_Url__c, + Generated_Document_Id__c, + Attached_File_Content_Document_Id__c, + Attached_File_Url__c, + Last_DocGen_Requested_At__c, + Last_DocGen_Completed_At__c, + (SELECT Id, + Deficiency_Number__c, + Description__c, + Resolution__c, + Reference__c + FROM Deficiencies__r + ORDER BY Deficiency_Number__c ASC, CreatedDate ASC) + FROM Appraiser_Case__c + WHERE Id = :appraiserCaseId + LIMIT 1 + ]; + if (contextRows.isEmpty()) { + throw new AuraHandledException('Appraiser Case not found: ' + appraiserCaseId); + } + Appraiser_Case__c appraiserCase = contextRows[0]; + + CaseContext context = new CaseContext(); + context.caseId = appraiserCase.Id; + context.caseNumber = appraiserCase.Name; + context.propertyAddress = formatAddress(appraiserCase); + context.lastDocGenStatus = appraiserCase.Last_DocGen_Status__c; + context.lastDocGenMessage = appraiserCase.Last_DocGen_Message__c; + context.lastClmAccountCode = appraiserCase.Last_CLM_Account_Code__c; + context.lastDocGenTaskId = appraiserCase.Last_DocGen_Task_Id__c; + context.lastDocGenTaskUrl = appraiserCase.Last_DocGen_Task_Url__c; + context.generatedDocumentUrl = appraiserCase.Generated_Document_Url__c; + context.generatedDocumentId = appraiserCase.Generated_Document_Id__c; + context.attachedFileContentDocumentId = appraiserCase.Attached_File_Content_Document_Id__c; + context.attachedFileUrl = appraiserCase.Attached_File_Url__c; + context.lastDocGenRequestedAt = appraiserCase.Last_DocGen_Requested_At__c; + context.lastDocGenCompletedAt = appraiserCase.Last_DocGen_Completed_At__c; + context.deficiencies = new List(); + + if (appraiserCase.Deficiencies__r != null) { + for (Appraiser_Case_Deficiency__c deficiency : appraiserCase.Deficiencies__r) { + CaseDeficiencyItem item = new CaseDeficiencyItem(); + item.recordId = deficiency.Id; + item.deficiencyNumber = deficiency.Deficiency_Number__c; + item.description = deficiency.Description__c; + item.resolution = deficiency.Resolution__c; + item.reference = deficiency.Reference__c; + context.deficiencies.add(item); + } + } + + return context; + } + + @AuraEnabled(cacheable=false) + public static FileAttachmentResult attachGeneratedDocumentToCase(Id appraiserCaseId, String accountCode) { + if (appraiserCaseId == null) { + throw new AuraHandledException('appraiserCaseId is required'); + } + CLM_Account_Setting__mdt account = requireAccountSetting(accountCode); + + Appraiser_Case__c appraiserCase = [ + SELECT Id, + Name, + Generated_Document_Url__c, + Generated_Document_Id__c + FROM Appraiser_Case__c + WHERE Id = :appraiserCaseId + LIMIT 1 + ]; + + if (String.isBlank(appraiserCase.Generated_Document_Url__c)) { + throw new AuraHandledException('No generated document is available to attach yet.'); + } + + CLMDocGenCallout.DownloadedDocument downloaded = CLMDocGenCallout.downloadDocument( + appraiserCase.Generated_Document_Url__c, + account.Environment_Code__c, + account.CLM_Account_Id__c, + account.CLM_Download_Named_Credential__c + ); + String fileName = String.isNotBlank(downloaded.fileName) + ? downloaded.fileName + : 'Generated_' + appraiserCase.Name + '.docx'; + String title = fileName.contains('.') + ? fileName.substringBeforeLast('.') + : fileName; + + ContentVersion version = new ContentVersion( + Title = title, + PathOnClient = '/' + fileName, + VersionData = downloaded.body + ); + insert version; + + version = [ + SELECT Id, ContentDocumentId + FROM ContentVersion + WHERE Id = :version.Id + LIMIT 1 + ]; + + insert new ContentDocumentLink( + ContentDocumentId = version.ContentDocumentId, + LinkedEntityId = appraiserCase.Id, + ShareType = 'V', + Visibility = 'AllUsers' + ); + + String fileUrl = '/lightning/r/ContentDocument/' + version.ContentDocumentId + '/view'; + update new Appraiser_Case__c( + Id = appraiserCase.Id, + Last_CLM_Account_Code__c = accountCode, + Attached_File_Content_Document_Id__c = version.ContentDocumentId, + Attached_File_Url__c = fileUrl + ); + + FileAttachmentResult result = new FileAttachmentResult(); + result.success = true; + result.message = 'Generated document attached to the case.'; + result.contentDocumentId = version.ContentDocumentId; + result.fileUrl = fileUrl; + result.fileTitle = title; + return result; + } + + private static String formatAddress(Appraiser_Case__c appraiserCase) { + return AppraiserCasePayloadBuilder.formatMailingAddress( + appraiserCase.Property_Street__c, + appraiserCase.Property_City__c, + appraiserCase.Property_State_Province__c, + appraiserCase.Property_Postal_Code__c, + appraiserCase.Property_Country__c + ); + } + + @AuraEnabled(cacheable=false) + public static FolderContents getFolderContents(String folderHref, String accountCode) { + if (String.isBlank(folderHref)) { + throw new IllegalArgumentException('folderHref is required'); + } + CLM_Account_Setting__mdt account = requireAccountSetting(accountCode); + + FolderContents contents = new FolderContents(); + contents.folder = parseSingleResource(performGet(folderHref, account).getBody(), 'Folder'); + contents.folders = parseResourceList(performGet(folderHref + '/folders', account).getBody(), 'Folder', folderHref); + contents.documents = parseResourceList(performGet(folderHref + '/documents', account).getBody(), 'Document', folderHref); + return contents; + } + + private static HttpResponse performGet(String resourceOrHref, CLM_Account_Setting__mdt account) { + HttpRequest req = new HttpRequest(); + req.setEndpoint(CLMDocGenCallout.buildEndpointForResource( + resourceOrHref, + account.CLM_Account_Id__c, + account.CLM_Api_Named_Credential__c + )); + req.setMethod('GET'); + req.setTimeout(CLMDocGenCallout.HTTP_TIMEOUT); + + HttpResponse res = new Http().send(req); + Integer statusCode = res.getStatusCode(); + if (statusCode < 200 || statusCode >= 300) { + throw new AuraHandledException('CLM API Error (HTTP ' + statusCode + '): ' + res.getBody()); + } + + return res; + } + + @TestVisible + private static ResourceItem parseSingleResource(String body, String defaultType) { + Object root = JSON.deserializeUntyped(body); + if (!(root instanceof Map)) { + return null; + } + return parseResource((Map) root, defaultType, null); + } + + @TestVisible + private static List parseResourceList(String body, String defaultType, String parentHref) { + Object root = JSON.deserializeUntyped(body); + List records = unwrapList(root); + List items = new List(); + + for (Object record : records) { + if (record instanceof Map) { + items.add(parseResource((Map) record, defaultType, parentHref)); + } + } + + return items; + } + + private static List unwrapList(Object root) { + if (root instanceof List) { + return (List) root; + } + + if (root instanceof Map) { + Map payload = (Map) root; + for (String key : new List{ 'Results', 'Items', 'Documents', 'Folders' }) { + Object value = payload.get(key); + if (value instanceof List) { + return (List) value; + } + } + + if (payload.size() == 1) { + for (Object value : payload.values()) { + if (value instanceof List) { + return (List) value; + } + } + } + } + + return new List(); + } + + private static ResourceItem parseResource(Map source, String defaultType, String parentHref) { + ResourceItem item = new ResourceItem(); + item.name = firstString(source, new List{ 'Name', 'DisplayName', 'Title', 'Label' }); + item.href = firstString(source, new List{ 'Href', 'Uri', 'Location' }); + item.type = firstString(source, new List{ 'Type', 'ObjectType', 'ItemType' }); + item.parentHref = extractParentHref(source, parentHref); + item.rawJson = JSON.serialize(source); + + if (String.isBlank(item.type)) { + item.type = defaultType; + } + + if (String.isBlank(item.name) && String.isNotBlank(item.href)) { + item.name = item.href.substringAfterLast('/'); + } + + return item; + } + + private static String extractParentHref(Map source, String fallbackValue) { + Object parentValue = source.get('Parent'); + if (parentValue instanceof Map) { + String href = firstString((Map) parentValue, new List{ 'Href', 'Uri', 'Location' }); + if (String.isNotBlank(href)) { + return href; + } + } + return fallbackValue; + } + + private static String firstString(Map source, List keys) { + for (String key : keys) { + Object value = source.get(key); + if (value != null) { + String textValue = String.valueOf(value); + if (String.isNotBlank(textValue)) { + return textValue; + } + } + } + return null; + } + + private static void persistDocGenResult( + Id appraiserCaseId, + String templateDocHref, + String destinationFolderHref, + CLMDocGenCallout.CLMDocGenResponse response, + Boolean isStatusRefresh, + String accountCode + ) { + if (appraiserCaseId == null || response == null) { + return; + } + + Appraiser_Case__c updateCase = new Appraiser_Case__c(Id = appraiserCaseId); + updateCase.Last_CLM_Account_Code__c = accountCode; + updateCase.Last_DocGen_Status__c = String.isNotBlank(response.taskStatus) ? response.taskStatus : (response.success ? 'Submitted' : 'Failed'); + updateCase.Last_DocGen_Message__c = response.message; + updateCase.Last_DocGen_Task_Id__c = response.documentId; + updateCase.Last_DocGen_Task_Url__c = response.documentUrl; + + if (!isStatusRefresh) { + updateCase.Last_DocGen_Requested_At__c = System.now(); + updateCase.Last_Template_Document_Href__c = templateDocHref; + updateCase.Last_Destination_Folder_Href__c = destinationFolderHref; + } + + if (String.isNotBlank(response.generatedDocumentUrl)) { + updateCase.Generated_Document_Url__c = response.generatedDocumentUrl; + updateCase.Generated_Document_Id__c = response.generatedDocumentId; + } + + if (response.success && String.valueOf(response.taskStatus).toLowerCase() == 'completed') { + updateCase.Last_DocGen_Completed_At__c = System.now(); + } + + update updateCase; + } + + private static CLM_Account_Setting__mdt requireAccountSetting(String accountCode) { + CLM_Account_Setting__mdt row = resolveAccountSetting(accountCode); + if (row == null) { + throw new AuraHandledException('No active CLM account setting was found for ' + accountCode + '.'); + } + return row; + } + + private static CLM_Account_Setting__mdt resolveAccountSetting(String accountCode) { + String normalizedCode = String.isBlank(accountCode) ? null : accountCode.trim(); + List rows; + if (String.isNotBlank(normalizedCode)) { + rows = [ + SELECT DeveloperName, + Account_Code__c, + Account_Display_Name__c, + Environment_Code__c, + CLM_Account_Id__c, + CLM_Api_Named_Credential__c, + CLM_Download_Named_Credential__c, + ESignature_Rest_Named_Credential__c, + Template_Root_Folder_Href__c, + Destination_Root_Folder_Href__c, + Default_Template_Document_Href__c, + Default_Destination_Document_Name_Prefix__c, + Active__c + FROM CLM_Account_Setting__mdt + WHERE Active__c = true + AND DeveloperName = :normalizedCode + LIMIT 1 + ]; + if (rows.isEmpty()) { + rows = [ + SELECT DeveloperName, + Account_Code__c, + Account_Display_Name__c, + Environment_Code__c, + CLM_Account_Id__c, + CLM_Api_Named_Credential__c, + CLM_Download_Named_Credential__c, + ESignature_Rest_Named_Credential__c, + Template_Root_Folder_Href__c, + Destination_Root_Folder_Href__c, + Default_Template_Document_Href__c, + Default_Destination_Document_Name_Prefix__c, + Active__c + FROM CLM_Account_Setting__mdt + WHERE Active__c = true + AND Account_Code__c = :normalizedCode + LIMIT 1 + ]; + } + } else { + rows = [ + SELECT DeveloperName, + Account_Code__c, + Account_Display_Name__c, + Environment_Code__c, + CLM_Account_Id__c, + CLM_Api_Named_Credential__c, + CLM_Download_Named_Credential__c, + ESignature_Rest_Named_Credential__c, + Template_Root_Folder_Href__c, + Destination_Root_Folder_Href__c, + Default_Template_Document_Href__c, + Default_Destination_Document_Name_Prefix__c, + Active__c + FROM CLM_Account_Setting__mdt + WHERE Active__c = true + ORDER BY Account_Display_Name__c ASC, DeveloperName ASC + LIMIT 1 + ]; + } + return rows.isEmpty() ? null : rows[0]; + } + + private static AccountSettings toAccountSettings(CLM_Account_Setting__mdt row) { + AccountSettings settings = new AccountSettings(); + settings.accountCode = String.isNotBlank(row.Account_Code__c) ? row.Account_Code__c : row.DeveloperName; + settings.accountDisplayName = String.isNotBlank(row.Account_Display_Name__c) ? row.Account_Display_Name__c : row.DeveloperName; + settings.environment = row.Environment_Code__c; + settings.clmAccountId = row.CLM_Account_Id__c; + settings.clmApiNamedCredential = row.CLM_Api_Named_Credential__c; + settings.clmDownloadNamedCredential = row.CLM_Download_Named_Credential__c; + settings.eSignatureRestNamedCredential = row.ESignature_Rest_Named_Credential__c; + settings.templateRootFolderHref = row.Template_Root_Folder_Href__c; + settings.destinationRootFolderHref = row.Destination_Root_Folder_Href__c; + settings.defaultTemplateDocumentHref = row.Default_Template_Document_Href__c; + settings.defaultDocumentNamePrefix = row.Default_Destination_Document_Name_Prefix__c; + settings.active = row.Active__c; + return settings; + } + + private static LetterSettings toLetterSettings(CLM_Letter_Definition__mdt row, AccountSettings account) { + LetterSettings settings = new LetterSettings(); + settings.accountCode = String.isNotBlank(row.Account_Code__c) ? row.Account_Code__c : account.accountCode; + settings.letterCode = String.isNotBlank(row.Letter_Code__c) ? row.Letter_Code__c : 'APPRAISER_REVIEW'; + settings.letterDisplayName = String.isNotBlank(row.Letter_Display_Name__c) ? row.Letter_Display_Name__c : row.DeveloperName; + settings.description = row.Description__c; + settings.isDefault = row.Is_Default__c; + settings.sortOrder = row.Sort_Order__c; + settings.templateRootFolderHref = firstNonBlankValue(row.Template_Root_Folder_Href__c, account.templateRootFolderHref); + settings.destinationRootFolderHref = firstNonBlankValue(row.Destination_Root_Folder_Href__c, account.destinationRootFolderHref); + settings.defaultTemplateDocumentHref = firstNonBlankValue(row.Default_Template_Document_Href__c, account.defaultTemplateDocumentHref); + settings.defaultDocumentNamePrefix = firstNonBlankValue(row.Default_Destination_Document_Name_Prefix__c, account.defaultDocumentNamePrefix); + settings.active = row.Active__c; + return settings; + } + + private static LetterSettings buildFallbackLetterSettings(AccountSettings account) { + LetterSettings settings = new LetterSettings(); + settings.accountCode = account.accountCode; + settings.letterCode = 'APPRAISER_REVIEW'; + settings.letterDisplayName = 'Appraiser Review Letter'; + settings.description = 'Fallback current appraiser letter flow.'; + settings.isDefault = true; + settings.sortOrder = 10; + settings.templateRootFolderHref = account.templateRootFolderHref; + settings.destinationRootFolderHref = account.destinationRootFolderHref; + settings.defaultTemplateDocumentHref = account.defaultTemplateDocumentHref; + settings.defaultDocumentNamePrefix = account.defaultDocumentNamePrefix; + settings.active = true; + return settings; + } + + private static String firstNonBlankValue(String preferredValue, String fallbackValue) { + return String.isNotBlank(preferredValue) ? preferredValue : fallbackValue; + } + + private static String buildDefaultDocumentName(String prefix, String caseNumber) { + String normalizedPrefix = String.isNotBlank(prefix) ? prefix : 'Review'; + return String.isNotBlank(caseNumber) + ? normalizedPrefix + '_' + caseNumber + '.docx' + : normalizedPrefix + '.docx'; + } +} diff --git a/force-app/main/default/classes/CLMAdminService.cls-meta.xml b/force-app/main/default/classes/CLMAdminService.cls-meta.xml new file mode 100644 index 0000000..5f399c3 --- /dev/null +++ b/force-app/main/default/classes/CLMAdminService.cls-meta.xml @@ -0,0 +1,5 @@ + + + 63.0 + Active + diff --git a/force-app/main/default/classes/CLMAdminServiceTest.cls b/force-app/main/default/classes/CLMAdminServiceTest.cls new file mode 100644 index 0000000..420e9b3 --- /dev/null +++ b/force-app/main/default/classes/CLMAdminServiceTest.cls @@ -0,0 +1,286 @@ +@IsTest +private class CLMAdminServiceTest { + private class FolderBrowseMock implements HttpCalloutMock { + public HttpResponse respond(HttpRequest req) { + HttpResponse res = new HttpResponse(); + res.setHeader('Content-Type', 'application/json'); + + if (req.getMethod() == 'POST' && req.getEndpoint().contains('/documentxmlmergetasks')) { + res.setStatusCode(200); + res.setBody('{"Href":"https://apiuatna11.springcm.com/v2/bccae332-c7db-4892-ab85-257df0f70fea/documentxmlmergetasks/TASK-555","Status":"Queued","Result":{"Href":"https://apiuatna11.springcm.com/v2/bccae332-c7db-4892-ab85-257df0f70fea/documents/generated-555"}}'); + return res; + } + + if (req.getEndpoint().endsWith('/folders/root-folder')) { + res.setStatusCode(200); + res.setBody('{"Name":"Templates Root","Href":"https://uatna11.springcm.com/v2/2371cf36-eb8a-43fe-9f28-b5bbe7644397/folders/root-folder","Type":"Folder","Parent":{"Href":"https://uatna11.springcm.com/v2/2371cf36-eb8a-43fe-9f28-b5bbe7644397/folders/root-parent"}}'); + return res; + } + + if (req.getEndpoint().endsWith('/folders/root-folder/folders')) { + res.setStatusCode(200); + res.setBody('{"Results":[{"Name":"Residential","Href":"https://uatna11.springcm.com/v2/2371cf36-eb8a-43fe-9f28-b5bbe7644397/folders/residential","Parent":{"Href":"https://uatna11.springcm.com/v2/2371cf36-eb8a-43fe-9f28-b5bbe7644397/folders/root-folder"}}]}'); + return res; + } + + if (req.getEndpoint().endsWith('/folders/root-folder/documents')) { + res.setStatusCode(200); + res.setBody('{"Results":[{"Name":"Appraiser Review Letter Template","Href":"https://uatna11.springcm.com/v2/2371cf36-eb8a-43fe-9f28-b5bbe7644397/documents/template-1"}]}'); + return res; + } + + if (req.getMethod() == 'GET' && req.getEndpoint().contains('/documentxmlmergetasks')) { + res.setStatusCode(200); + res.setBody('{"Href":"https://apiuatna11.springcm.com/v2/bccae332-c7db-4892-ab85-257df0f70fea/documentxmlmergetasks/TASK-555","Status":"Completed","Result":{"Href":"https://apiuatna11.springcm.com/v2/bccae332-c7db-4892-ab85-257df0f70fea/documents/generated-555"}}'); + return res; + } + + if (req.getMethod() == 'GET' && req.getEndpoint().contains('/documents/generated-555')) { + res.setStatusCode(200); + res.setHeader('Content-Type', 'application/vnd.openxmlformats-officedocument.wordprocessingml.document'); + res.setHeader('Content-Disposition', 'attachment; filename="Review_AC-000001.docx"'); + res.setBodyAsBlob(Blob.valueOf('docx-binary')); + return res; + } + + res.setStatusCode(404); + res.setBody('{"Message":"Not Found"}'); + return res; + } + } + + @IsTest + static void parsesFolderContents() { + Test.setMock(HttpCalloutMock.class, new FolderBrowseMock()); + + Test.startTest(); + CLMAdminService.FolderContents contents = CLMAdminService.getFolderContents( + 'https://uatna11.springcm.com/v2/2371cf36-eb8a-43fe-9f28-b5bbe7644397/folders/root-folder', + 'DTC_CLM_Demo' + ); + String body = CLMAdminService.probeResource('/documentxmlmergetasks/TASK-555', 'DTC_IAM_Enterprise'); + Test.stopTest(); + + System.assertEquals('Templates Root', contents.folder.name); + System.assertEquals('https://uatna11.springcm.com/v2/2371cf36-eb8a-43fe-9f28-b5bbe7644397/folders/root-parent', contents.folder.parentHref); + System.assertEquals(1, contents.folders.size()); + System.assertEquals('Residential', contents.folders[0].name); + System.assertEquals(1, contents.documents.size()); + System.assertEquals('Appraiser Review Letter Template', contents.documents[0].name); + System.assert(body.contains('Completed')); + } + + @IsTest + static void returnsAccountSettingsFromMetadata() { + Test.startTest(); + List accounts = CLMAdminService.listAccountSettings(); + CLMAdminService.AccountSettings settings = CLMAdminService.getAccountSettings('DTC_CLM_Demo'); + Test.stopTest(); + + Set accountCodes = new Set(); + for (CLMAdminService.AccountSettings account : accounts) { + accountCodes.add(account.accountCode); + } + System.assert(accountCodes.contains('DTC_CLM_Demo')); + System.assert(accountCodes.contains('DTC_IAM_Enterprise')); + System.assert(accountCodes.contains('DTC_HUD_Demo')); + System.assertNotEquals(null, settings); + System.assertEquals('DTC_CLM_Demo', settings.accountCode); + System.assertEquals('DTC CLM Demo', settings.accountDisplayName); + System.assertEquals('UAT', settings.environment); + System.assert(settings.destinationRootFolderHref.contains('/folders/')); + System.assert(settings.defaultTemplateDocumentHref.contains('/documents/')); + System.assertEquals('Review', settings.defaultDocumentNamePrefix); + } + + @IsTest + static void returnsLetterSettingsFromMetadata() { + Test.startTest(); + List letters = CLMAdminService.listLetterSettings('DTC_CLM_Demo'); + CLMAdminService.LetterSettings settings = CLMAdminService.getLetterSettings('DTC_CLM_Demo', 'APPRAISER_REVIEW'); + Test.stopTest(); + + System.assert(!letters.isEmpty()); + System.assertEquals('APPRAISER_REVIEW', settings.letterCode); + System.assertEquals('Appraiser Review Letter', settings.letterDisplayName); + System.assertEquals(true, settings.isDefault); + System.assertEquals('Review', settings.defaultDocumentNamePrefix); + } + + @IsTest + static void buildsDocGenPreviewForSelectedLetter() { + Appraiser_Case__c appraiserCase = new Appraiser_Case__c( + Appraiser_Field_Review_Date__c = Date.today(), + Letter_Sent_Date__c = Date.newInstance(2026, 4, 9), + FHA_Case_Number__c = '123-4567890', + Appraiser_Name__c = 'Jamie Carter', + Appraiser_Last_Name__c = 'Carter', + Appraiser_Street__c = '12 Park Ave', + Appraiser_City__c = 'New York', + Appraiser_State_Province__c = 'NY', + Appraiser_Postal_Code__c = '10016', + Appraiser_Country__c = 'USA', + Property_Street__c = '245 Lexington Ave', + Property_City__c = 'New York', + Property_State_Province__c = 'NY', + Property_Postal_Code__c = '10016', + Property_Country__c = 'USA' + ); + insert appraiserCase; + + insert new Appraiser_Case_Deficiency__c( + Appraiser_Case__c = appraiserCase.Id, + Deficiency_Number__c = 1, + Description__c = 'Test deficiency', + Resolution__c = 'Test resolution', + Reference__c = 'VC-1' + ); + + Test.startTest(); + CLMAdminService.DocGenPreview preview = CLMAdminService.getDocGenPreview( + appraiserCase.Id, + 'DTC_CLM_Demo', + 'NOD_LETTER' + ); + Test.stopTest(); + + System.assertEquals('DTC_CLM_Demo', preview.accountCode); + System.assertEquals('NOD_LETTER', preview.letterCode); + System.assertEquals('NOD Letter', preview.letterDisplayName); + Appraiser_Case__c refreshedCase = [ + SELECT Name + FROM Appraiser_Case__c + WHERE Id = :appraiserCase.Id + LIMIT 1 + ]; + System.assertEquals('NOD_' + refreshedCase.Name + '.docx', preview.destinationDocName); + System.assert(preview.mergeTaskEndpointUrl.endsWith('/documentxmlmergetasks')); + System.assert(preview.payloadJson.contains('FHACaseNumber')); + System.assert(preview.dataXml.contains('\n <')); + System.assert(preview.dataXml.contains('VC-1')); + System.assert(preview.requestBodyJson.contains('"DestinationDocumentName"')); + } + + @IsTest + static void persistsCaseTrackingOnGenerate() { + Appraiser_Case__c appraiserCase = new Appraiser_Case__c( + Appraiser_Field_Review_Date__c = Date.today(), + Property_Street__c = '123 Main St', + Property_City__c = 'Denver', + Property_State_Province__c = 'CO', + Property_Postal_Code__c = '80202' + ); + insert appraiserCase; + + insert new Appraiser_Case_Deficiency__c( + Appraiser_Case__c = appraiserCase.Id, + Deficiency_Number__c = 1, + Description__c = 'Test deficiency', + Resolution__c = 'Test resolution', + Reference__c = 'VC-1' + ); + + Test.setMock(HttpCalloutMock.class, new FolderBrowseMock()); + + Test.startTest(); + CLMDocGenCallout.CLMDocGenResponse generateResponse = CLMAdminService.generateDocument( + appraiserCase.Id, + 'https://apiuatna11.springcm.com/v2/bccae332-c7db-4892-ab85-257df0f70fea/documents/template-1', + 'https://apiuatna11.springcm.com/v2/bccae332-c7db-4892-ab85-257df0f70fea/folders/root-folder', + 'Review_AC-000001.docx', + 'DTC_CLM_Demo' + ); + CLMAdminService.CaseContext context = CLMAdminService.getCaseContext(appraiserCase.Id); + Test.stopTest(); + + System.assertEquals(true, generateResponse.success); + System.assertEquals('Queued', context.lastDocGenStatus); + System.assertEquals('DTC_CLM_Demo', context.lastClmAccountCode); + System.assertEquals('TASK-555', context.lastDocGenTaskId); + System.assert(context.lastDocGenTaskUrl.contains('/documentxmlmergetasks/TASK-555')); + System.assert(context.generatedDocumentUrl.contains('/documents/generated-555')); + System.assertNotEquals(null, context.lastDocGenRequestedAt); + System.assertEquals(null, context.lastDocGenCompletedAt); + System.assertEquals(1, context.deficiencies.size()); + System.assertEquals('123 Main St, Denver, CO 80202', context.propertyAddress); + System.assertEquals('VC-1', context.deficiencies[0].reference); + } + + @IsTest + static void persistsCaseTrackingOnStatusRefresh() { + Appraiser_Case__c appraiserCase = new Appraiser_Case__c( + Appraiser_Field_Review_Date__c = Date.today(), + Property_Street__c = '123 Main St', + Property_City__c = 'Denver', + Property_State_Province__c = 'CO', + Property_Postal_Code__c = '80202', + Last_DocGen_Task_Id__c = 'TASK-555' + ); + insert appraiserCase; + + insert new Appraiser_Case_Deficiency__c( + Appraiser_Case__c = appraiserCase.Id, + Deficiency_Number__c = 1, + Description__c = 'Test deficiency', + Resolution__c = 'Test resolution', + Reference__c = 'VC-1' + ); + + Test.setMock(HttpCalloutMock.class, new FolderBrowseMock()); + + Test.startTest(); + CLMDocGenCallout.CLMDocGenResponse statusResponse = CLMAdminService.getTaskStatus(appraiserCase.Id, 'TASK-555', 'DTC_CLM_Demo'); + CLMAdminService.CaseContext context = CLMAdminService.getCaseContext(appraiserCase.Id); + Test.stopTest(); + + System.assertEquals(true, statusResponse.success); + System.assertEquals('Completed', context.lastDocGenStatus); + System.assertEquals('DTC_CLM_Demo', context.lastClmAccountCode); + System.assertEquals('TASK-555', context.lastDocGenTaskId); + System.assert(context.lastDocGenTaskUrl.contains('/documentxmlmergetasks/TASK-555')); + System.assert(context.generatedDocumentUrl.contains('/documents/generated-555')); + System.assertNotEquals(null, context.lastDocGenCompletedAt); + System.assertEquals(1, context.deficiencies.size()); + } + + @IsTest + static void attachesGeneratedDocumentToCaseAsSalesforceFile() { + Appraiser_Case__c appraiserCase = new Appraiser_Case__c( + Appraiser_Field_Review_Date__c = Date.today(), + Property_Street__c = '245 Lexington Ave', + Property_City__c = 'New York', + Property_State_Province__c = 'NY', + Property_Postal_Code__c = '10016', + Generated_Document_Url__c = 'https://apiuatna11.springcm.com/v2/bccae332-c7db-4892-ab85-257df0f70fea/documents/generated-555', + Generated_Document_Id__c = 'generated-555' + ); + insert appraiserCase; + + Test.setMock(HttpCalloutMock.class, new FolderBrowseMock()); + + Test.startTest(); + CLMAdminService.FileAttachmentResult result = CLMAdminService.attachGeneratedDocumentToCase(appraiserCase.Id, 'DTC_CLM_Demo'); + Test.stopTest(); + + Appraiser_Case__c refreshedCase = [ + SELECT Last_CLM_Account_Code__c, Attached_File_Content_Document_Id__c, Attached_File_Url__c + FROM Appraiser_Case__c + WHERE Id = :appraiserCase.Id + LIMIT 1 + ]; + List links = [ + SELECT ContentDocumentId, LinkedEntityId + FROM ContentDocumentLink + WHERE LinkedEntityId = :appraiserCase.Id + ]; + + System.assertEquals(true, result.success); + System.assertNotEquals(null, result.contentDocumentId); + System.assert(result.fileUrl.contains('/lightning/r/ContentDocument/')); + System.assertEquals('DTC_CLM_Demo', refreshedCase.Last_CLM_Account_Code__c); + System.assertEquals(result.contentDocumentId, refreshedCase.Attached_File_Content_Document_Id__c); + System.assertEquals(result.fileUrl, refreshedCase.Attached_File_Url__c); + System.assertEquals(1, links.size()); + System.assertEquals(result.contentDocumentId, links[0].ContentDocumentId); + } +} diff --git a/force-app/main/default/classes/CLMAdminServiceTest.cls-meta.xml b/force-app/main/default/classes/CLMAdminServiceTest.cls-meta.xml new file mode 100644 index 0000000..5f399c3 --- /dev/null +++ b/force-app/main/default/classes/CLMAdminServiceTest.cls-meta.xml @@ -0,0 +1,5 @@ + + + 63.0 + Active + diff --git a/force-app/main/default/classes/CLMDocGenCallout.cls b/force-app/main/default/classes/CLMDocGenCallout.cls index 6c93912..fe19c5b 100644 --- a/force-app/main/default/classes/CLMDocGenCallout.cls +++ b/force-app/main/default/classes/CLMDocGenCallout.cls @@ -1,18 +1,95 @@ public class CLMDocGenCallout { // S1 demo environment - private static final String CLM_ACCOUNT_ID_S1 = '2371cf36-eb8a-43fe-9f28-b5bbe7644397'; - private static final String CLM_BASE_S1 = 'callout:CLMNamedCred/v2/' + CLM_ACCOUNT_ID_S1; + 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_BASE_UAT = 'callout:CLMuatNamedCreds/v2/' + CLM_ACCOUNT_ID_UAT; + private static final String CLM_NAMED_CRED_UAT = 'callout:CLMuatNamedCreds'; + private static final String CLM_DOWNLOAD_NAMED_CRED_UAT = 'callout:CLMuatDownload'; - private static final Integer HTTP_TIMEOUT = 30000; + public static final Integer HTTP_TIMEOUT = 30000; - /** Resolve the correct CLM base URL. env: 'UAT' or 'S1'. */ - private static String clmBase(String env) { - return env == 'S1' ? CLM_BASE_S1 : CLM_BASE_UAT; + /** 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. */ @@ -40,21 +117,42 @@ public class CLMDocGenCallout { String destinationDocName, String env ) { - Map casePayload = AppraiserCasePayloadBuilder.buildPayload(caseId); + return generateDocument( + caseId, + templateDocHref, + destinationFolderHref, + destinationDocName, + env, + defaultAccountId(env), + clmNamedCredential(env).substringAfter('callout:') + ); + } - Map requestBody = new Map{ - 'TemplateDocument' => new Map{ - 'Href' => templateDocHref - }, - 'DataXML' => buildDataXml(casePayload), - 'DestinationDocumentName' => destinationDocName, - 'DestinationFolder' => new Map{ - 'Href' => destinationFolderHref - } - }; + 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(clmBase(env) + '/documentxmlmergetasks'); + req.setEndpoint('callout:' + apiNamedCredential + '/v2/' + accountId + '/documentxmlmergetasks'); req.setMethod('POST'); req.setHeader('Content-Type', 'application/json'); req.setTimeout(HTTP_TIMEOUT); @@ -69,15 +167,59 @@ public class CLMDocGenCallout { } } - /** Poll the status of a submitted merge task by its GUID. */ + 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(clmBase(env) + '/documentxmlmergetasks/' + taskId); + req.setEndpoint(buildEndpointForResource('/documentxmlmergetasks/' + taskId, configuredAccountId, apiNamedCredential)); req.setMethod('GET'); req.setTimeout(HTTP_TIMEOUT); try { @@ -96,13 +238,41 @@ public class CLMDocGenCallout { public static String probe(String resource, String env) { HttpRequest req = new HttpRequest(); - req.setEndpoint(clmBase(env) + '/' + resource); + 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 . @@ -115,7 +285,7 @@ public class CLMDocGenCallout { // Emit flat fields first for (String key : payload.keySet()) { if (key == 'DeficiencyList') continue; - xml += '<' + key + '>' + escapeXml(String.valueOf(payload.get(key))) + ''; + xml += '<' + key + '>' + escapeXml(safeValue(payload.get(key))) + ''; } // Emit DeficiencyList as a nested list so templates can iterate dynamically @@ -125,9 +295,10 @@ public class CLMDocGenCallout { for (Integer i = 0; i < deficiencies.size(); i++) { Map d = (Map) deficiencies[i]; xml += ''; - xml += '' + escapeXml(String.valueOf(d.get('deficiencyNumber'))) + ''; - xml += '' + escapeXml(String.valueOf(d.get('description'))) + ''; - xml += '' + escapeXml(String.valueOf(d.get('resolution'))) + ''; + 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 += ''; @@ -138,6 +309,93 @@ public class CLMDocGenCallout { 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('&', '&') @@ -152,26 +410,160 @@ public class CLMDocGenCallout { String body = res.getBody(); if (statusCode >= 200 && statusCode < 300) { Map m = (Map) JSON.deserializeUntyped(body); - String href = (String) m.get('Href'); - String status = (String) m.get('Status'); + String href = firstString(m, new List{ 'Href', 'Uri', 'Location' }); + String status = firstString(m, new List{ 'Status', 'State' }); String taskId = href != null ? href.substringAfterLast('/') : null; - return new CLMDocGenResponse(true, 'Task status: ' + status, href, taskId); + 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); + 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 = success; - this.message = message; + 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.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; + } } diff --git a/force-app/main/default/classes/CLMDocGenCalloutTest.cls b/force-app/main/default/classes/CLMDocGenCalloutTest.cls new file mode 100644 index 0000000..6fb2a89 --- /dev/null +++ b/force-app/main/default/classes/CLMDocGenCalloutTest.cls @@ -0,0 +1,155 @@ +@IsTest +private class CLMDocGenCalloutTest { + private class CLMCalloutMock implements HttpCalloutMock { + public HttpResponse respond(HttpRequest req) { + HttpResponse res = new HttpResponse(); + res.setHeader('Content-Type', 'application/json'); + + if (req.getMethod() == 'POST' && req.getEndpoint().contains('/documentxmlmergetasks')) { + System.assert(req.getBody().contains('TemplateDocument')); + System.assert(req.getBody().contains('DestinationFolder')); + System.assert(req.getBody().contains('DeficiencyList')); + System.assert(req.getBody().contains('&')); + res.setStatusCode(200); + res.setBody('{"Href":"https://uatna11.springcm.com/v2/2371cf36-eb8a-43fe-9f28-b5bbe7644397/documentxmlmergetasks/TASK-123","Status":"Queued"}'); + return res; + } + + if (req.getMethod() == 'GET' && req.getEndpoint().contains('/documentxmlmergetasks/TASK-123')) { + res.setStatusCode(200); + res.setBody('{"Href":"https://uatna11.springcm.com/v2/2371cf36-eb8a-43fe-9f28-b5bbe7644397/documentxmlmergetasks/TASK-123","Status":"Completed"}'); + return res; + } + + if (req.getMethod() == 'GET' && req.getEndpoint().contains('/documents/template-guid')) { + res.setStatusCode(200); + res.setHeader('Content-Type', 'application/pdf'); + res.setHeader('Content-Disposition', 'attachment; filename="GeneratedReview.pdf"'); + res.setBodyAsBlob(Blob.valueOf('pdf-bytes')); + return res; + } + + res.setStatusCode(404); + res.setBody('{"Message":"Not Found"}'); + return res; + } + } + + @IsTest + static void generatesDocumentAndPollsTaskStatus() { + Appraiser_Case__c appraiserCase = new Appraiser_Case__c( + Appraiser_Field_Review_Date__c = Date.newInstance(2026, 4, 2), + Letter_Sent_Date__c = Date.newInstance(2026, 4, 9), + FHA_Case_Number__c = '123-4567890', + Appraiser_Name__c = 'Jamie Appraiser', + Appraiser_Last_Name__c = 'Appraiser', + Property_Street__c = '123 Main & Main ', + Property_City__c = 'Denver', + Property_State_Province__c = 'CO', + Property_Postal_Code__c = '80202' + ); + insert appraiserCase; + + insert new Appraiser_Case_Deficiency__c( + Appraiser_Case__c = appraiserCase.Id, + Deficiency_Number__c = 1, + Description__c = 'Missing comparable sale adjustment detail.', + Resolution__c = 'Added supporting calculations & notes.', + Reference__c = 'VC-1' + ); + + Test.setMock(HttpCalloutMock.class, new CLMCalloutMock()); + + Test.startTest(); + CLMDocGenCallout.CLMDocGenResponse response = CLMDocGenCallout.generateDocument( + appraiserCase.Id, + 'https://uatna11.springcm.com/v2/2371cf36-eb8a-43fe-9f28-b5bbe7644397/documents/template-guid', + 'https://uatna11.springcm.com/v2/2371cf36-eb8a-43fe-9f28-b5bbe7644397/folders/folder-guid', + 'Review_AC-00001.docx', + 'UAT' + ); + CLMDocGenCallout.CLMDocGenResponse taskStatus = CLMDocGenCallout.getTaskStatus('TASK-123', 'S1'); + Test.stopTest(); + + System.assertEquals(true, response.success); + System.assertEquals('TASK-123', response.documentId); + System.assert(response.documentUrl.contains('/documentxmlmergetasks/TASK-123')); + System.assertEquals(true, taskStatus.success); + System.assertEquals('Task status: Completed', taskStatus.message); + } + + @IsTest + static void normalizesResourcePathsAndExtractsAccountIds() { + System.assertEquals( + '2371cf36-eb8a-43fe-9f28-b5bbe7644397', + CLMDocGenCallout.extractAccountId('https://uatna11.springcm.com/v2/2371cf36-eb8a-43fe-9f28-b5bbe7644397/folders/folder-guid') + ); + System.assertEquals( + '/v2/2371cf36-eb8a-43fe-9f28-b5bbe7644397/folders/folder-guid', + CLMDocGenCallout.normalizeResourcePath( + 'https://uatna11.springcm.com/v2/2371cf36-eb8a-43fe-9f28-b5bbe7644397/folders/folder-guid', + 'UAT' + ) + ); + System.assertEquals( + '/v2/' + CLMDocGenCallout.defaultAccountId('UAT') + '/folders/folder-guid', + CLMDocGenCallout.normalizeResourcePath('/folders/folder-guid', 'UAT') + ); + } + + @IsTest + static void downloadsGeneratedDocumentThroughDownloadCredential() { + Test.setMock(HttpCalloutMock.class, new CLMCalloutMock()); + + Test.startTest(); + CLMDocGenCallout.DownloadedDocument downloaded = CLMDocGenCallout.downloadDocument( + 'https://apiuatna11.springcm.com/v2/bccae332-c7db-4892-ab85-257df0f70fea/documents/template-guid', + 'UAT' + ); + Test.stopTest(); + + System.assertEquals('GeneratedReview.pdf', downloaded.fileName); + System.assertEquals('application/pdf', downloaded.contentType); + System.assertNotEquals(null, downloaded.body); + } + + @IsTest + static void buildsPreviewXmlAndRequestBody() { + Appraiser_Case__c appraiserCase = new Appraiser_Case__c( + Appraiser_Field_Review_Date__c = Date.newInstance(2026, 4, 2), + Letter_Sent_Date__c = Date.newInstance(2026, 4, 9), + FHA_Case_Number__c = '123-4567890', + Appraiser_Name__c = 'Jamie Appraiser', + Appraiser_Last_Name__c = 'Appraiser', + Property_Street__c = '245 Lexington Ave', + Property_City__c = 'New York', + Property_State_Province__c = 'NY', + Property_Postal_Code__c = '10016', + Property_Country__c = 'USA' + ); + insert appraiserCase; + + insert new Appraiser_Case_Deficiency__c( + Appraiser_Case__c = appraiserCase.Id, + Deficiency_Number__c = 1, + Description__c = 'Missing comparable sale adjustment detail.', + Resolution__c = 'Added supporting calculations.', + Reference__c = 'VC-1' + ); + + String dataXml = CLMDocGenCallout.buildDataXmlForCase(appraiserCase.Id); + String requestBodyJson = CLMDocGenCallout.buildRequestBodyJson( + appraiserCase.Id, + 'https://apiuatna11.springcm.com/v2/bccae332-c7db-4892-ab85-257df0f70fea/documents/template-guid', + 'https://apiuatna11.springcm.com/v2/bccae332-c7db-4892-ab85-257df0f70fea/folders/folder-guid', + 'Review_AC-000002.docx' + ); + + System.assert(dataXml.contains('')); + System.assert(dataXml.contains('123-4567890')); + System.assert(dataXml.contains('VC-1')); + System.assert(requestBodyJson.contains('"DataXML"')); + System.assert(requestBodyJson.contains('Review_AC-000002.docx')); + System.assert(requestBodyJson.contains('template-guid')); + } +} diff --git a/force-app/main/default/classes/CLMDocGenCalloutTest.cls-meta.xml b/force-app/main/default/classes/CLMDocGenCalloutTest.cls-meta.xml new file mode 100644 index 0000000..5f399c3 --- /dev/null +++ b/force-app/main/default/classes/CLMDocGenCalloutTest.cls-meta.xml @@ -0,0 +1,5 @@ + + + 63.0 + Active + diff --git a/force-app/main/default/classes/DocusignESignatureService.cls b/force-app/main/default/classes/DocusignESignatureService.cls new file mode 100644 index 0000000..0d14898 --- /dev/null +++ b/force-app/main/default/classes/DocusignESignatureService.cls @@ -0,0 +1,339 @@ +public with sharing class DocusignESignatureService { + public class ESignatureAccountConfig { + @AuraEnabled public String accountCode; + @AuraEnabled public String accountDisplayName; + @AuraEnabled public String environment; + @AuraEnabled public String eSignatureAuthNamedCredential; + @AuraEnabled public String eSignatureRestNamedCredential; + @AuraEnabled public String eSignatureAccountId; + } + + public class ApiResponse { + @AuraEnabled public Boolean success; + @AuraEnabled public Integer statusCode; + @AuraEnabled public String message; + @AuraEnabled public String requestPath; + @AuraEnabled public String responseBody; + } + + public class ESignatureAccountSummary { + @AuraEnabled public String accountId; + @AuraEnabled public String accountName; + @AuraEnabled public String baseUri; + @AuraEnabled public Boolean isDefault; + @AuraEnabled public String rawJson; + } + + public class TemplateSummary { + @AuraEnabled public String templateId; + @AuraEnabled public String name; + @AuraEnabled public String description; + @AuraEnabled public String shared; + @AuraEnabled public String lastModified; + @AuraEnabled public String rawJson; + } + + public class EnvelopeSummary { + @AuraEnabled public String envelopeId; + @AuraEnabled public String emailSubject; + @AuraEnabled public String status; + @AuraEnabled public String createdDateTime; + @AuraEnabled public String sentDateTime; + @AuraEnabled public String completedDateTime; + @AuraEnabled public String rawJson; + } + + @AuraEnabled(cacheable=true) + public static ESignatureAccountConfig getAccountConfig(String accountCode) { + CLM_Account_Setting__mdt row = requireAccountSetting(accountCode); + ESignatureAccountConfig config = new ESignatureAccountConfig(); + config.accountCode = String.isNotBlank(row.Account_Code__c) ? row.Account_Code__c : row.DeveloperName; + config.accountDisplayName = String.isNotBlank(row.Account_Display_Name__c) ? row.Account_Display_Name__c : row.DeveloperName; + config.environment = row.Environment_Code__c; + config.eSignatureAuthNamedCredential = row.ESignature_Auth_Named_Credential__c; + config.eSignatureRestNamedCredential = row.ESignature_Rest_Named_Credential__c; + config.eSignatureAccountId = row.ESignature_Account_Id__c; + return config; + } + + @AuraEnabled(cacheable=false) + public static ApiResponse probe(String accountCode, String relativePath) { + CLM_Account_Setting__mdt row = requireAccountSetting(accountCode); + String normalizedPath = normalizePath(relativePath); + + HttpRequest req = new HttpRequest(); + req.setEndpoint(buildEndpoint(normalizedPath, row.ESignature_Rest_Named_Credential__c)); + req.setMethod('GET'); + req.setTimeout(30000); + + HttpResponse res = new Http().send(req); + ApiResponse response = new ApiResponse(); + response.success = res.getStatusCode() >= 200 && res.getStatusCode() < 300; + response.statusCode = res.getStatusCode(); + response.message = response.success ? 'eSignature request succeeded.' : 'eSignature request failed.'; + response.requestPath = normalizedPath; + response.responseBody = res.getBody(); + return response; + } + + @AuraEnabled(cacheable=false) + public static List listAccounts(String accountCode) { + ApiResponse response = getLoginInformation(accountCode); + if (!response.success) { + throw new AuraHandledException('eSignature API Error (HTTP ' + response.statusCode + '): ' + response.responseBody); + } + return parseAccountList(response.responseBody); + } + + @AuraEnabled(cacheable=false) + public static ApiResponse getLoginInformation(String accountCode) { + return probe(accountCode, '/v2.1/login_information'); + } + + @AuraEnabled(cacheable=false) + public static ApiResponse getUserInfo(String accountCode) { + CLM_Account_Setting__mdt row = requireAccountSetting(accountCode); + + HttpRequest req = new HttpRequest(); + req.setEndpoint(buildEndpoint('/oauth/userinfo', authNamedCredential(row))); + req.setMethod('GET'); + req.setTimeout(30000); + + HttpResponse res = new Http().send(req); + ApiResponse response = new ApiResponse(); + response.success = res.getStatusCode() >= 200 && res.getStatusCode() < 300; + response.statusCode = res.getStatusCode(); + response.message = response.success ? 'eSignature user info request succeeded.' : 'eSignature user info request failed.'; + response.requestPath = '/oauth/userinfo'; + response.responseBody = res.getBody(); + return response; + } + + @AuraEnabled(cacheable=false) + public static ApiResponse getAccountInformation(String accountCode, String eSignatureAccountId) { + CLM_Account_Setting__mdt row = requireAccountSetting(accountCode); + String targetAccountId = requireESignatureAccountId(row, accountCode, eSignatureAccountId); + return probe(accountCode, '/v2.1/accounts/' + targetAccountId); + } + + @AuraEnabled(cacheable=false) + public static List listTemplates(String accountCode) { + CLM_Account_Setting__mdt row = requireAccountSetting(accountCode); + String targetAccountId = requireESignatureAccountId(row, accountCode, null); + ApiResponse response = probe(accountCode, '/v2.1/accounts/' + targetAccountId + '/templates'); + if (!response.success) { + throw new AuraHandledException('eSignature API Error (HTTP ' + response.statusCode + '): ' + response.responseBody); + } + return parseTemplateList(response.responseBody); + } + + @AuraEnabled(cacheable=false) + public static List listEnvelopes(String accountCode, String fromDate) { + CLM_Account_Setting__mdt row = requireAccountSetting(accountCode); + String targetAccountId = requireESignatureAccountId(row, accountCode, null); + String normalizedFromDate = String.isBlank(fromDate) + ? DateTime.newInstanceGMT(Date.today().addDays(-30), Time.newInstance(0, 0, 0, 0)).formatGMT('yyyy-MM-dd') + : fromDate.trim(); + ApiResponse response = probe( + accountCode, + '/v2.1/accounts/' + targetAccountId + '/envelopes?from_date=' + EncodingUtil.urlEncode(normalizedFromDate, 'UTF-8') + ); + if (!response.success) { + throw new AuraHandledException('eSignature API Error (HTTP ' + response.statusCode + '): ' + response.responseBody); + } + return parseEnvelopeList(response.responseBody); + } + + @TestVisible + private static List parseAccountList(String body) { + Object root = JSON.deserializeUntyped(body); + List records = new List(); + if (root instanceof List) { + records = (List) root; + } else if (root instanceof Map) { + Object loginAccounts = ((Map) root).get('loginAccounts'); + if (loginAccounts instanceof List) { + records = (List) loginAccounts; + } + Object accounts = ((Map) root).get('accounts'); + if (records.isEmpty() && accounts instanceof List) { + records = (List) accounts; + } + } + + List summaries = new List(); + for (Object record : records) { + if (!(record instanceof Map)) { + continue; + } + Map row = (Map) record; + ESignatureAccountSummary summary = new ESignatureAccountSummary(); + summary.accountId = firstString(row, new List{ 'accountId', 'account_id' }); + summary.accountName = firstString(row, new List{ 'accountName', 'account_name', 'name' }); + summary.baseUri = firstString(row, new List{ 'baseUri', 'base_uri', 'baseUrl', 'base_url' }); + summary.isDefault = parseBoolean(row.get('isDefault')); + summary.rawJson = JSON.serialize(row); + summaries.add(summary); + } + return summaries; + } + + @TestVisible + private static List parseTemplateList(String body) { + List records = extractCollection(body, new List{ 'envelopeTemplates', 'templates' }); + List summaries = new List(); + for (Object record : records) { + if (!(record instanceof Map)) { + continue; + } + Map row = (Map) record; + TemplateSummary summary = new TemplateSummary(); + summary.templateId = firstString(row, new List{ 'templateId', 'template_id' }); + summary.name = firstString(row, new List{ 'name' }); + summary.description = firstString(row, new List{ 'description' }); + summary.shared = firstString(row, new List{ 'shared' }); + summary.lastModified = firstString(row, new List{ 'lastModified', 'last_modified', 'lastModifiedDateTime' }); + summary.rawJson = JSON.serialize(row); + summaries.add(summary); + } + return summaries; + } + + @TestVisible + private static List parseEnvelopeList(String body) { + List records = extractCollection(body, new List{ 'envelopes' }); + List summaries = new List(); + for (Object record : records) { + if (!(record instanceof Map)) { + continue; + } + Map row = (Map) record; + EnvelopeSummary summary = new EnvelopeSummary(); + summary.envelopeId = firstString(row, new List{ 'envelopeId', 'envelope_id' }); + summary.emailSubject = firstString(row, new List{ 'emailSubject', 'email_subject' }); + summary.status = firstString(row, new List{ 'status' }); + summary.createdDateTime = firstString(row, new List{ 'createdDateTime', 'created_datetime' }); + summary.sentDateTime = firstString(row, new List{ 'sentDateTime', 'sent_datetime' }); + summary.completedDateTime = firstString(row, new List{ 'completedDateTime', 'completed_datetime' }); + summary.rawJson = JSON.serialize(row); + summaries.add(summary); + } + return summaries; + } + + @TestVisible + private static String buildEndpoint(String relativePath, String namedCredential) { + if (String.isBlank(namedCredential)) { + throw new AuraHandledException('No eSignature named credential is configured for this account.'); + } + return 'callout:' + namedCredential + normalizePath(relativePath); + } + + private static String normalizePath(String relativePath) { + if (String.isBlank(relativePath)) { + throw new AuraHandledException('A relative path is required.'); + } + return relativePath.startsWith('/') ? relativePath : '/' + relativePath; + } + + private static CLM_Account_Setting__mdt requireAccountSetting(String accountCode) { + String normalizedCode = String.isBlank(accountCode) ? null : accountCode.trim(); + List rows = new List(); + if (String.isNotBlank(normalizedCode)) { + rows = [ + SELECT DeveloperName, + Account_Code__c, + Account_Display_Name__c, + Environment_Code__c, + ESignature_Auth_Named_Credential__c, + ESignature_Rest_Named_Credential__c, + ESignature_Account_Id__c, + Active__c + FROM CLM_Account_Setting__mdt + WHERE Active__c = true + AND DeveloperName = :normalizedCode + LIMIT 1 + ]; + if (rows.isEmpty()) { + rows = [ + SELECT DeveloperName, + Account_Code__c, + Account_Display_Name__c, + Environment_Code__c, + ESignature_Auth_Named_Credential__c, + ESignature_Rest_Named_Credential__c, + ESignature_Account_Id__c, + Active__c + FROM CLM_Account_Setting__mdt + WHERE Active__c = true + AND Account_Code__c = :normalizedCode + LIMIT 1 + ]; + } + } + + if (rows.isEmpty()) { + throw new AuraHandledException('No active CLM account setting was found for ' + accountCode + '.'); + } + + CLM_Account_Setting__mdt row = rows[0]; + if (String.isBlank(row.ESignature_Rest_Named_Credential__c)) { + throw new AuraHandledException('No eSignature named credential is configured for ' + accountCode + '.'); + } + return row; + } + + private static String authNamedCredential(CLM_Account_Setting__mdt row) { + return String.isNotBlank(row.ESignature_Auth_Named_Credential__c) + ? row.ESignature_Auth_Named_Credential__c + : row.ESignature_Rest_Named_Credential__c; + } + + private static String requireESignatureAccountId(CLM_Account_Setting__mdt row, String accountCode, String eSignatureAccountId) { + String targetAccountId = String.isNotBlank(eSignatureAccountId) ? eSignatureAccountId : row.ESignature_Account_Id__c; + if (String.isBlank(targetAccountId)) { + throw new AuraHandledException('No eSignature account id is configured for ' + accountCode + '.'); + } + return targetAccountId; + } + + private static List extractCollection(String body, List keys) { + Object root = JSON.deserializeUntyped(body); + if (root instanceof List) { + return (List) root; + } + if (root instanceof Map) { + Map source = (Map) root; + for (String key : keys) { + Object records = source.get(key); + if (records instanceof List) { + return (List) records; + } + } + } + return new List(); + } + + private static String firstString(Map source, List keys) { + for (String key : keys) { + Object value = source.get(key); + if (value != null) { + String text = String.valueOf(value); + if (String.isNotBlank(text)) { + return text; + } + } + } + return null; + } + + private static Boolean parseBoolean(Object value) { + if (value == null) { + return false; + } + if (value instanceof Boolean) { + return (Boolean) value; + } + return String.valueOf(value).toLowerCase() == 'true'; + } +} diff --git a/force-app/main/default/classes/DocusignESignatureService.cls-meta.xml b/force-app/main/default/classes/DocusignESignatureService.cls-meta.xml new file mode 100644 index 0000000..5f399c3 --- /dev/null +++ b/force-app/main/default/classes/DocusignESignatureService.cls-meta.xml @@ -0,0 +1,5 @@ + + + 63.0 + Active + diff --git a/force-app/main/default/classes/DocusignESignatureServiceTest.cls b/force-app/main/default/classes/DocusignESignatureServiceTest.cls new file mode 100644 index 0000000..3fcd8e0 --- /dev/null +++ b/force-app/main/default/classes/DocusignESignatureServiceTest.cls @@ -0,0 +1,150 @@ +@IsTest +private class DocusignESignatureServiceTest { + private class ESignatureMock implements HttpCalloutMock { + public HttpResponse respond(HttpRequest req) { + HttpResponse res = new HttpResponse(); + res.setHeader('Content-Type', 'application/json'); + + if (req.getMethod() == 'GET' && req.getEndpoint().contains('/oauth/userinfo')) { + res.setStatusCode(200); + res.setBody('{"sub":"d9aab149-ff54-408c-a748-baa4b56e2fcd","accounts":[{"account_id":"12345678","account_name":"Demo eSignature Account","base_uri":"https://demo.docusign.net","is_default":true}]}'); + return res; + } + + if (req.getMethod() == 'GET' && req.getEndpoint().contains('/templates')) { + res.setStatusCode(200); + res.setBody('{"envelopeTemplates":[{"templateId":"tmpl-001","name":"Review Letter","description":"Appraiser review letter template","shared":"true","lastModified":"2026-04-08T12:00:00Z"},{"templateId":"tmpl-002","name":"Alternate Review Letter","description":"Alternate version","shared":"false","lastModified":"2026-04-07T12:00:00Z"}]}'); + return res; + } + + if (req.getMethod() == 'GET' && req.getEndpoint().contains('/envelopes?from_date=')) { + res.setStatusCode(200); + res.setBody('{"envelopes":[{"envelopeId":"env-001","emailSubject":"Appraiser Review","status":"completed","createdDateTime":"2026-04-01T10:00:00Z","sentDateTime":"2026-04-01T10:05:00Z","completedDateTime":"2026-04-01T10:15:00Z"},{"envelopeId":"env-002","emailSubject":"Second Review","status":"sent","createdDateTime":"2026-04-02T09:00:00Z","sentDateTime":"2026-04-02T09:05:00Z"}]}'); + return res; + } + + if (req.getMethod() == 'GET' && req.getEndpoint().contains('/v2.1/accounts/12345678')) { + res.setStatusCode(200); + res.setBody('{"accountId":"12345678","accountName":"Demo eSignature Account","status":"active"}'); + return res; + } + + if (req.getMethod() == 'GET' && req.getEndpoint().contains('/v2.1/login_information')) { + res.setStatusCode(200); + res.setBody('{"loginAccounts":[{"accountId":"12345678","name":"Demo eSignature Account","baseUrl":"https://demo.docusign.net/restapi/v2.1/accounts/12345678","isDefault":"true"},{"accountId":"87654321","name":"Secondary Demo Account","baseUrl":"https://demo.docusign.net/restapi/v2.1/accounts/87654321","isDefault":"false"}]}'); + return res; + } + + res.setStatusCode(404); + res.setBody('{"message":"Not Found"}'); + return res; + } + } + + @IsTest + static void returnsConfiguredAccountInfo() { + Test.startTest(); + DocusignESignatureService.ESignatureAccountConfig config = DocusignESignatureService.getAccountConfig('DTC_CLM_Demo'); + Test.stopTest(); + + System.assertEquals('DTC_CLM_Demo', config.accountCode); + System.assertEquals('DTC CLM Demo', config.accountDisplayName); + System.assertEquals('AcctDemo_NamedCreds', config.eSignatureAuthNamedCredential); + System.assertEquals('Esignature_Demo_NamedCreds', config.eSignatureRestNamedCredential); + } + + @IsTest + static void listsAccountsFromEsignatureApi() { + Test.setMock(HttpCalloutMock.class, new ESignatureMock()); + + Test.startTest(); + List accounts = DocusignESignatureService.listAccounts('DTC_CLM_Demo'); + Test.stopTest(); + + System.assertEquals(2, accounts.size()); + System.assertEquals('12345678', accounts[0].accountId); + System.assertEquals('Demo eSignature Account', accounts[0].accountName); + System.assertEquals('https://demo.docusign.net/restapi/v2.1/accounts/12345678', accounts[0].baseUri); + System.assertEquals(true, accounts[0].isDefault); + } + + @IsTest + static void getsLoginInformationUsingWorkingEndpoint() { + Test.setMock(HttpCalloutMock.class, new ESignatureMock()); + + Test.startTest(); + DocusignESignatureService.ApiResponse response = DocusignESignatureService.getLoginInformation('DTC_CLM_Demo'); + Test.stopTest(); + + System.assertEquals(true, response.success); + System.assertEquals(200, response.statusCode); + System.assert(response.responseBody.contains('loginAccounts')); + } + + @IsTest + static void getsUserInfoUsingAuthNamedCredential() { + Test.setMock(HttpCalloutMock.class, new ESignatureMock()); + + Test.startTest(); + DocusignESignatureService.ApiResponse response = DocusignESignatureService.getUserInfo('DTC_CLM_Demo'); + Test.stopTest(); + + System.assertEquals(true, response.success); + System.assertEquals(200, response.statusCode); + System.assertEquals('/oauth/userinfo', response.requestPath); + System.assert(response.responseBody.contains('"accounts"')); + } + + @IsTest + static void getsAccountInformationUsingConfiguredAccountId() { + Test.setMock(HttpCalloutMock.class, new ESignatureMock()); + + Test.startTest(); + DocusignESignatureService.ApiResponse response = DocusignESignatureService.getAccountInformation( + 'DTC_CLM_Demo', + '12345678' + ); + Test.stopTest(); + + System.assertEquals(true, response.success); + System.assertEquals(200, response.statusCode); + System.assert(response.responseBody.contains('Demo eSignature Account')); + } + + @IsTest + static void listsTemplatesUsingConfiguredAccountId() { + Test.setMock(HttpCalloutMock.class, new ESignatureMock()); + + Test.startTest(); + List templates = DocusignESignatureService.listTemplates('DTC_CLM_Demo'); + Test.stopTest(); + + System.assertEquals(2, templates.size()); + System.assertEquals('tmpl-001', templates[0].templateId); + System.assertEquals('Review Letter', templates[0].name); + } + + @IsTest + static void listsRecentEnvelopesUsingConfiguredAccountId() { + Test.setMock(HttpCalloutMock.class, new ESignatureMock()); + + Test.startTest(); + List envelopes = DocusignESignatureService.listEnvelopes( + 'DTC_CLM_Demo', + '2026-04-01' + ); + Test.stopTest(); + + System.assertEquals(2, envelopes.size()); + System.assertEquals('env-001', envelopes[0].envelopeId); + System.assertEquals('completed', envelopes[0].status); + } + + @IsTest + static void buildsEndpointWithNamedCredential() { + System.assertEquals( + 'callout:Esignature_Demo_NamedCreds/v2.1/accounts', + DocusignESignatureService.buildEndpoint('/v2.1/accounts', 'Esignature_Demo_NamedCreds') + ); + } +} diff --git a/force-app/main/default/classes/DocusignESignatureServiceTest.cls-meta.xml b/force-app/main/default/classes/DocusignESignatureServiceTest.cls-meta.xml new file mode 100644 index 0000000..5f399c3 --- /dev/null +++ b/force-app/main/default/classes/DocusignESignatureServiceTest.cls-meta.xml @@ -0,0 +1,5 @@ + + + 63.0 + Active +