Compare commits

...

5 Commits

Author SHA1 Message Date
paulh 8510eacecb fix(ralph-loop): capture check_output status safely under set -e
When the agent emits no promise signal, check_output returns 1 (continue).
Under set -euo pipefail, a bare function call returning non-zero exits the
script before status=$? can capture it. Fix: use the || idiom so the
non-zero return is handled rather than triggering set -e.

Agent: human
Tests: N/A
Tests-Added: 0
TypeScript: N/A
2026-04-09 22:26:11 -04:00
paulh 207358fa7d feat(clm-admin): add persistEnvelopeResult() and extend CaseContext with eSignature fields
- Add 5 eSignature fields to CaseContext DTO (FR-004)
- Extend getCaseContext() SOQL to include all 5 envelope tracking fields
- Add @AuraEnabled persistEnvelopeResult() following persistDocGenResult pattern (FR-003)
  - Writes ESignature_Envelope_Id__c, ESignature_Envelope_Status__c, ESignature_Sent_At__c
  - Writes ESignature_Envelope_Url__c when non-blank; does NOT touch ESignature_Completed_At__c

Agent: claude-sonnet-4-6
Tests: 7/7 passing | CLMAdminServiceTest
Tests-Added: 0
TypeScript: N/A (Apex project)
2026-04-09 22:21:29 -04:00
paulh 6fe75d8ab3 test(esignature): add createEnvelope() test coverage — TC-001, TC-002, blank email guard
Three new test methods in DocusignESignatureServiceTest:
- createEnvelopeReturnsSuccessResultOnHttp201 (TC-001): mocks 201, asserts success=true, envelopeId, status
- createEnvelopeReturnsFailureResultOnHttp400 (TC-002): mocks 400, asserts success=false, errorMessage non-null
- createEnvelopeReturnsEarlyWhenEmailIsBlank: asserts early return with errorMessage when Appraiser_Email__c is blank

All 11 tests pass (11/11). Satisfies NFR-001 for createEnvelope().

Agent: claude-sonnet-4-6
Tests: 11/11 passing
Tests-Added: +3
TypeScript: N/A (Apex project)
2026-04-09 22:19:38 -04:00
paulh c3421e858f feat(esignature): add EnvelopeCreateResult class and createEnvelope() method
Adds EnvelopeCreateResult inner DTO and createEnvelope(Id, String, String)
to DocusignESignatureService. Option A (template-based): POSTs to
/v2.1/accounts/{accountId}/envelopes with templateId, templateRoles
(Signer), and status=sent. Guards blank templateId and blank appraiser
email; catches all exceptions and wraps in result. Named credential
sourced from CLM_Account_Setting__mdt per ADR-002.

Agent: claude-sonnet-4-6
Tests: 8/8 passing | N/A (no new test methods — Task 3 covers test coverage)
Tests-Added: 0
TypeScript: N/A (Apex project)
2026-04-09 22:17:08 -04:00
paulh e4b7456dc1 feat(esignature): add 5 eSignature tracking fields to Appraiser_Case__c
Add ESignature_Envelope_Id__c (Text 255), ESignature_Envelope_Status__c
(Text 50), ESignature_Sent_At__c (DateTime), ESignature_Completed_At__c
(DateTime), and ESignature_Envelope_Url__c (URL) to Appraiser_Case__c.
Deployed to appraiser-dev — Status: Succeeded.

Agent: claude-sonnet-4-6
Tests: N/A (metadata-only)
Tests-Added: 0
TypeScript: N/A (Apex project)
2026-04-09 22:15:07 -04:00
10 changed files with 258 additions and 6 deletions

View File

@ -8,13 +8,13 @@
## Tasks ## Tasks
- [ ] **Task 1 — Custom fields:** Add 5 eSignature tracking fields to `Appraiser_Case__c` object metadata and update `package.xml` if needed. Deploy to verify. (FR-001) - [x] **Task 1 — Custom fields:** Add 5 eSignature tracking fields to `Appraiser_Case__c` object metadata and update `package.xml` if needed. Deploy to verify. (FR-001)
- [ ] **Task 2 — EnvelopeCreateResult class + createEnvelope() method:** Add `EnvelopeCreateResult` inner class and `createEnvelope(Id caseId, String accountCode, String templateId)` to `DocusignESignatureService`. (FR-002) - [x] **Task 2 — EnvelopeCreateResult class + createEnvelope() method:** Add `EnvelopeCreateResult` inner class and `createEnvelope(Id caseId, String accountCode, String templateId)` to `DocusignESignatureService`. (FR-002)
- [ ] **Task 3 — Tests for createEnvelope():** Add test coverage in `DocusignESignatureServiceTest` — success path (mock 201), failure path (mock 400), blank email guard. (NFR-001, TC-001, TC-002) - [x] **Task 3 — Tests for createEnvelope():** Add test coverage in `DocusignESignatureServiceTest` — success path (mock 201), failure path (mock 400), blank email guard. (NFR-001, TC-001, TC-002)
- [ ] **Task 4 — persistEnvelopeResult() + CaseContext extension:** Add `persistEnvelopeResult()` to `CLMAdminService`. Extend `CaseContext` inner class and `getCaseContext()` SOQL to include the 5 eSignature fields. (FR-003, FR-004) - [x] **Task 4 — persistEnvelopeResult() + CaseContext extension:** Add `persistEnvelopeResult()` to `CLMAdminService`. Extend `CaseContext` inner class and `getCaseContext()` SOQL to include the 5 eSignature fields. (FR-003, FR-004)
- [ ] **Task 5 — Tests for persistEnvelopeResult():** Add test coverage in `CLMAdminServiceTest` — verify fields written correctly, `ESignature_Sent_At__c` is set, `ESignature_Completed_At__c` is NOT written on a "sent" result. (NFR-001, TC-003) - [ ] **Task 5 — Tests for persistEnvelopeResult():** Add test coverage in `CLMAdminServiceTest` — verify fields written correctly, `ESignature_Sent_At__c` is set, `ESignature_Completed_At__c` is NOT written on a "sent" result. (NFR-001, TC-003)

View File

@ -22,6 +22,11 @@ public with sharing class CLMAdminService {
@AuraEnabled public String attachedFileUrl; @AuraEnabled public String attachedFileUrl;
@AuraEnabled public Datetime lastDocGenRequestedAt; @AuraEnabled public Datetime lastDocGenRequestedAt;
@AuraEnabled public Datetime lastDocGenCompletedAt; @AuraEnabled public Datetime lastDocGenCompletedAt;
@AuraEnabled public String eSignatureEnvelopeId;
@AuraEnabled public String eSignatureEnvelopeStatus;
@AuraEnabled public String eSignatureEnvelopeUrl;
@AuraEnabled public Datetime eSignatureSentAt;
@AuraEnabled public Datetime eSignatureCompletedAt;
@AuraEnabled public List<CaseDeficiencyItem> deficiencies; @AuraEnabled public List<CaseDeficiencyItem> deficiencies;
} }
@ -313,6 +318,11 @@ public with sharing class CLMAdminService {
Attached_File_Url__c, Attached_File_Url__c,
Last_DocGen_Requested_At__c, Last_DocGen_Requested_At__c,
Last_DocGen_Completed_At__c, Last_DocGen_Completed_At__c,
ESignature_Envelope_Id__c,
ESignature_Envelope_Status__c,
ESignature_Sent_At__c,
ESignature_Completed_At__c,
ESignature_Envelope_Url__c,
(SELECT Id, (SELECT Id,
Deficiency_Number__c, Deficiency_Number__c,
Description__c, Description__c,
@ -344,6 +354,11 @@ public with sharing class CLMAdminService {
context.attachedFileUrl = appraiserCase.Attached_File_Url__c; context.attachedFileUrl = appraiserCase.Attached_File_Url__c;
context.lastDocGenRequestedAt = appraiserCase.Last_DocGen_Requested_At__c; context.lastDocGenRequestedAt = appraiserCase.Last_DocGen_Requested_At__c;
context.lastDocGenCompletedAt = appraiserCase.Last_DocGen_Completed_At__c; context.lastDocGenCompletedAt = appraiserCase.Last_DocGen_Completed_At__c;
context.eSignatureEnvelopeId = appraiserCase.ESignature_Envelope_Id__c;
context.eSignatureEnvelopeStatus = appraiserCase.ESignature_Envelope_Status__c;
context.eSignatureEnvelopeUrl = appraiserCase.ESignature_Envelope_Url__c;
context.eSignatureSentAt = appraiserCase.ESignature_Sent_At__c;
context.eSignatureCompletedAt = appraiserCase.ESignature_Completed_At__c;
context.deficiencies = new List<CaseDeficiencyItem>(); context.deficiencies = new List<CaseDeficiencyItem>();
if (appraiserCase.Deficiencies__r != null) { if (appraiserCase.Deficiencies__r != null) {
@ -433,6 +448,29 @@ public with sharing class CLMAdminService {
return result; return result;
} }
@AuraEnabled(cacheable=false)
public static void persistEnvelopeResult(
Id caseId,
String envelopeId,
String envelopeStatus,
String envelopeUri
) {
if (caseId == null) {
return;
}
Appraiser_Case__c updateCase = new Appraiser_Case__c(Id = caseId);
updateCase.ESignature_Envelope_Id__c = envelopeId;
updateCase.ESignature_Envelope_Status__c = envelopeStatus;
updateCase.ESignature_Sent_At__c = System.now();
if (String.isNotBlank(envelopeUri)) {
updateCase.ESignature_Envelope_Url__c = envelopeUri;
}
update updateCase;
}
private static String formatAddress(Appraiser_Case__c appraiserCase) { private static String formatAddress(Appraiser_Case__c appraiserCase) {
return AppraiserCasePayloadBuilder.formatMailingAddress( return AppraiserCasePayloadBuilder.formatMailingAddress(
appraiserCase.Property_Street__c, appraiserCase.Property_Street__c,

View File

@ -43,6 +43,14 @@ public with sharing class DocusignESignatureService {
@AuraEnabled public String rawJson; @AuraEnabled public String rawJson;
} }
public class EnvelopeCreateResult {
@AuraEnabled public Boolean success;
@AuraEnabled public String envelopeId;
@AuraEnabled public String status;
@AuraEnabled public String envelopeUri;
@AuraEnabled public String errorMessage;
}
@AuraEnabled(cacheable=true) @AuraEnabled(cacheable=true)
public static ESignatureAccountConfig getAccountConfig(String accountCode) { public static ESignatureAccountConfig getAccountConfig(String accountCode) {
CLM_Account_Setting__mdt row = requireAccountSetting(accountCode); CLM_Account_Setting__mdt row = requireAccountSetting(accountCode);
@ -144,6 +152,78 @@ public with sharing class DocusignESignatureService {
return parseEnvelopeList(response.responseBody); return parseEnvelopeList(response.responseBody);
} }
@AuraEnabled(cacheable=false)
public static EnvelopeCreateResult createEnvelope(Id caseId, String accountCode, String templateId) {
EnvelopeCreateResult result = new EnvelopeCreateResult();
result.success = false;
try {
if (String.isBlank(templateId)) {
result.errorMessage = 'Template ID is required.';
return result;
}
CLM_Account_Setting__mdt row = requireAccountSetting(accountCode);
String targetAccountId = requireESignatureAccountId(row, accountCode, null);
Appraiser_Case__c caseRecord = [
SELECT Appraiser_Email__c,
Appraiser_Name__c,
Appraiser_Last_Name__c,
Appraiser_Salutation__c
FROM Appraiser_Case__c
WHERE Id = :caseId
LIMIT 1
];
if (String.isBlank(caseRecord.Appraiser_Email__c)) {
result.errorMessage = 'Appraiser email is blank on this case. Cannot create envelope.';
return result;
}
String appraiserName = String.isNotBlank(caseRecord.Appraiser_Name__c)
? caseRecord.Appraiser_Name__c
: ((String.isNotBlank(caseRecord.Appraiser_Salutation__c)
? caseRecord.Appraiser_Salutation__c + ' '
: '') + String.valueOf(caseRecord.Appraiser_Last_Name__c)).trim();
Map<String, Object> roleMap = new Map<String, Object>{
'email' => caseRecord.Appraiser_Email__c,
'name' => appraiserName,
'roleName' => 'Signer'
};
Map<String, Object> bodyMap = new Map<String, Object>{
'templateId' => templateId,
'templateRoles' => new List<Object>{ roleMap },
'status' => 'sent'
};
HttpRequest req = new HttpRequest();
req.setEndpoint(buildEndpoint(
'/v2.1/accounts/' + targetAccountId + '/envelopes',
row.ESignature_Rest_Named_Credential__c
));
req.setMethod('POST');
req.setHeader('Content-Type', 'application/json');
req.setTimeout(30000);
req.setBody(JSON.serialize(bodyMap));
HttpResponse res = new Http().send(req);
if (res.getStatusCode() >= 200 && res.getStatusCode() < 300) {
Map<String, Object> responseMap = (Map<String, Object>) JSON.deserializeUntyped(res.getBody());
result.success = true;
result.envelopeId = (String) responseMap.get('envelopeId');
result.status = (String) responseMap.get('status');
result.envelopeUri = (String) responseMap.get('uri');
} else {
result.errorMessage = 'eSignature API Error (HTTP ' + res.getStatusCode() + '): ' + res.getBody();
}
} catch (Exception e) {
result.success = false;
result.errorMessage = e.getMessage();
}
return result;
}
@TestVisible @TestVisible
private static List<ESignatureAccountSummary> parseAccountList(String body) { private static List<ESignatureAccountSummary> parseAccountList(String body) {
Object root = JSON.deserializeUntyped(body); Object root = JSON.deserializeUntyped(body);

View File

@ -147,4 +147,92 @@ private class DocusignESignatureServiceTest {
DocusignESignatureService.buildEndpoint('/v2.1/accounts', 'Esignature_Demo_NamedCreds') DocusignESignatureService.buildEndpoint('/v2.1/accounts', 'Esignature_Demo_NamedCreds')
); );
} }
private class CreateEnvelopeSuccessMock implements HttpCalloutMock {
public HttpResponse respond(HttpRequest req) {
HttpResponse res = new HttpResponse();
res.setHeader('Content-Type', 'application/json');
res.setStatusCode(201);
res.setBody('{"envelopeId":"abc-123","status":"sent","uri":"/v2.1/accounts/12345678/envelopes/abc-123"}');
return res;
}
}
private class CreateEnvelopeFailureMock implements HttpCalloutMock {
public HttpResponse respond(HttpRequest req) {
HttpResponse res = new HttpResponse();
res.setHeader('Content-Type', 'application/json');
res.setStatusCode(400);
res.setBody('{"errorCode":"TEMPLATE_NOT_IN_ACCOUNT","message":"The template specified is not in the account."}');
return res;
}
}
@IsTest
static void createEnvelopeReturnsSuccessResultOnHttp201() {
Appraiser_Case__c appraiserCase = new Appraiser_Case__c(
Appraiser_Field_Review_Date__c = Date.today(),
Appraiser_Email__c = 'appraiser@example.com',
Appraiser_Name__c = 'Jamie Carter'
);
insert appraiserCase;
Test.setMock(HttpCalloutMock.class, new CreateEnvelopeSuccessMock());
Test.startTest();
DocusignESignatureService.EnvelopeCreateResult result = DocusignESignatureService.createEnvelope(
appraiserCase.Id,
'DTC_CLM_Demo',
'tmpl-001'
);
Test.stopTest();
System.assertEquals(true, result.success);
System.assertEquals('abc-123', result.envelopeId);
System.assertEquals('sent', result.status);
}
@IsTest
static void createEnvelopeReturnsFailureResultOnHttp400() {
Appraiser_Case__c appraiserCase = new Appraiser_Case__c(
Appraiser_Field_Review_Date__c = Date.today(),
Appraiser_Email__c = 'appraiser@example.com',
Appraiser_Name__c = 'Jamie Carter'
);
insert appraiserCase;
Test.setMock(HttpCalloutMock.class, new CreateEnvelopeFailureMock());
Test.startTest();
DocusignESignatureService.EnvelopeCreateResult result = DocusignESignatureService.createEnvelope(
appraiserCase.Id,
'DTC_CLM_Demo',
'tmpl-bad'
);
Test.stopTest();
System.assertEquals(false, result.success);
System.assertNotEquals(null, result.errorMessage);
System.assert(result.errorMessage.contains('400'));
}
@IsTest
static void createEnvelopeReturnsEarlyWhenEmailIsBlank() {
Appraiser_Case__c appraiserCase = new Appraiser_Case__c(
Appraiser_Field_Review_Date__c = Date.today()
);
insert appraiserCase;
Test.startTest();
DocusignESignatureService.EnvelopeCreateResult result = DocusignESignatureService.createEnvelope(
appraiserCase.Id,
'DTC_CLM_Demo',
'tmpl-001'
);
Test.stopTest();
System.assertEquals(false, result.success);
System.assertNotEquals(null, result.errorMessage);
System.assert(result.errorMessage.toLowerCase().contains('email'));
}
} }

View File

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<CustomField xmlns="http://soap.sforce.com/2006/04/metadata">
<fullName>ESignature_Completed_At__c</fullName>
<label>eSignature Completed At</label>
<required>false</required>
<trackHistory>false</trackHistory>
<type>DateTime</type>
</CustomField>

View File

@ -0,0 +1,11 @@
<?xml version="1.0" encoding="UTF-8"?>
<CustomField xmlns="http://soap.sforce.com/2006/04/metadata">
<fullName>ESignature_Envelope_Id__c</fullName>
<externalId>false</externalId>
<label>eSignature Envelope ID</label>
<length>255</length>
<required>false</required>
<trackHistory>false</trackHistory>
<type>Text</type>
<unique>false</unique>
</CustomField>

View File

@ -0,0 +1,11 @@
<?xml version="1.0" encoding="UTF-8"?>
<CustomField xmlns="http://soap.sforce.com/2006/04/metadata">
<fullName>ESignature_Envelope_Status__c</fullName>
<externalId>false</externalId>
<label>eSignature Envelope Status</label>
<length>50</length>
<required>false</required>
<trackHistory>false</trackHistory>
<type>Text</type>
<unique>false</unique>
</CustomField>

View File

@ -0,0 +1,9 @@
<?xml version="1.0" encoding="UTF-8"?>
<CustomField xmlns="http://soap.sforce.com/2006/04/metadata">
<fullName>ESignature_Envelope_Url__c</fullName>
<externalId>false</externalId>
<label>eSignature Envelope URL</label>
<required>false</required>
<trackHistory>false</trackHistory>
<type>Url</type>
</CustomField>

View File

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<CustomField xmlns="http://soap.sforce.com/2006/04/metadata">
<fullName>ESignature_Sent_At__c</fullName>
<label>eSignature Sent At</label>
<required>false</required>
<trackHistory>false</trackHistory>
<type>DateTime</type>
</CustomField>

View File

@ -491,8 +491,7 @@ for i in $(seq 1 "$MAX_ITERATIONS"); do
run_agent "$i" build run_agent "$i" build
logfile="$LOG_DIR/iteration-${i}.log" logfile="$LOG_DIR/iteration-${i}.log"
check_output "$logfile" status=0; check_output "$logfile" || status=$?
status=$?
case $status in case $status in
0) 0)