@choiceapps/communityservices-sdk 0.5.0 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +18 -0
- package/browser/enrollment-workflow.d.mts +54 -4
- package/browser/enrollment-workflow.mjs +89 -33
- package/browser/entry-correction.mjs +53 -10
- package/browser/entry-row-registration.mjs +63 -0
- package/browser/household-enrollment-discovery.mjs +49 -0
- package/browser/index.d.mts +3 -1
- package/browser/playwright-session.mjs +4 -3
- package/browser/workflow-error-codes.mjs +198 -0
- package/dist/cjs/mobile-preflight.d.ts +19 -0
- package/dist/cjs/mobile-preflight.d.ts.map +1 -0
- package/dist/cjs/mobile-preflight.js +75 -0
- package/dist/cjs/mobile-preflight.js.map +1 -0
- package/dist/cjs/v2.d.ts +2 -0
- package/dist/cjs/v2.d.ts.map +1 -1
- package/dist/cjs/v2.js +2 -0
- package/dist/cjs/v2.js.map +1 -1
- package/dist/cjs/v3.d.ts +2 -0
- package/dist/cjs/v3.d.ts.map +1 -1
- package/dist/cjs/v3.js +2 -0
- package/dist/cjs/v3.js.map +1 -1
- package/dist/cjs/write-labels.d.ts +13 -0
- package/dist/cjs/write-labels.d.ts.map +1 -0
- package/dist/cjs/write-labels.js +68 -0
- package/dist/cjs/write-labels.js.map +1 -0
- package/dist/esm/mobile-preflight.d.ts +19 -0
- package/dist/esm/mobile-preflight.d.ts.map +1 -0
- package/dist/esm/mobile-preflight.js +70 -0
- package/dist/esm/mobile-preflight.js.map +1 -0
- package/dist/esm/v2.d.ts +2 -0
- package/dist/esm/v2.d.ts.map +1 -1
- package/dist/esm/v2.js +2 -0
- package/dist/esm/v2.js.map +1 -1
- package/dist/esm/v3.d.ts +2 -0
- package/dist/esm/v3.d.ts.map +1 -1
- package/dist/esm/v3.js +2 -0
- package/dist/esm/v3.js.map +1 -1
- package/dist/esm/write-labels.d.ts +13 -0
- package/dist/esm/write-labels.d.ts.map +1 -0
- package/dist/esm/write-labels.js +65 -0
- package/dist/esm/write-labels.js.map +1 -0
- package/docs/assessments.md +38 -1
- package/docs/browser-session.md +1 -1
- package/docs/capabilities.md +15 -2
- package/docs/enrollments.md +75 -3
- package/docs/reference.md +3 -2
- package/docs/troubleshooting.md +20 -0
- package/examples/README.md +2 -0
- package/examples/checked-assessment-writes.mts +14 -0
- package/examples/enrollment-discovery-and-correction.mts +19 -0
- package/examples/entry-correction-workflow.mts +8 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,23 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.7.0 - 2026-11-10
|
|
4
|
+
|
|
5
|
+
- Add `createReadBackWriteLabelResolver` for exact observed lookup labels, including HUD suffixes. Unobserved options remain unresolved; no suffix is inferred from metadata.
|
|
6
|
+
- Expose `groupId` and `grouped` on GWT enrollment catalog rows and details without per-row reads.
|
|
7
|
+
- Add `discoverHouseholdEnrollment` and adoption from a head's client/enrollment IDs, requiring a unique verified household membership match.
|
|
8
|
+
- Add `registerEntryRows` to bind existing recordsets to adopted entry keys for in-place corrections.
|
|
9
|
+
- Add stable workflow error codes and per-operation `committed` markers, preserving uncertain prior writes and original causes.
|
|
10
|
+
- Add `writeMobileAnswersChecked` and enrollment-workflow scalar collision preflight for questions shared across instruments.
|
|
11
|
+
- Document missing diagnostics in 0.5.0 journals, the unsupported yes/no clear route, and integrator-reported 0.6.0 acceptance separately from local tests.
|
|
12
|
+
|
|
13
|
+
## 0.6.0 - 2026-11-10
|
|
14
|
+
|
|
15
|
+
- Reject yes/no fields in `correctEntry.clear` before any correction write or new journal entry. Correct synthetic fixtures that incorrectly accepted blank yes/no values. Setting `No` remains supported.
|
|
16
|
+
- Surface correction HTTP status and preserve allowlisted kind/status across retries without journaling response bodies. Retain write and read causes when reconciliation also fails.
|
|
17
|
+
- Add explicit `resolveEntryCorrection` with independent confirmation and two unchanged-state checks. Preserve attempted history, refuse partial/verified commits, and require a new key for corrected intent after rejection. Never reset a sent request automatically from a 4xx response.
|
|
18
|
+
- Clarify shared answers across assessment instruments, the public GWT enrollment catalog, and adoption for interims and scalar corrections. HUD label resolution and household-ID discovery still require authoritative metadata.
|
|
19
|
+
- Correct password recovery documentation: WellSky permits immediately resetting back to the previous password.
|
|
20
|
+
|
|
3
21
|
## 0.5.0 - 2026-11-10
|
|
4
22
|
|
|
5
23
|
- Use a configured GWT enrollment catalog for creation and member preflight. Prior household enrollments, including exited entries, no longer force the known-broken REST grouped list. Failed or incomplete reads never imply absence.
|
|
@@ -79,12 +79,47 @@ export interface EntryCorrectionInput {
|
|
|
79
79
|
subassessments?:EntryRowCorrection[];
|
|
80
80
|
}
|
|
81
81
|
export interface EntryCorrectionResult extends EnrollmentWorkflowResult {enrollmentKey:string;}
|
|
82
|
+
export interface EntryCorrectionFailure {
|
|
83
|
+
status?:number;
|
|
84
|
+
kind?:'http'|'auth'|'timeout'|'network'|'aborted'|'protocol'|'validation'|'throttled'|'cooldown';
|
|
85
|
+
}
|
|
86
|
+
export interface EntryCorrectionResolution {
|
|
87
|
+
kind:'not-applied';evidenceReference:string;resolvedAt:string;
|
|
88
|
+
}
|
|
89
|
+
export interface EntryCorrectionResolutionEvidence {
|
|
90
|
+
key:string;enrollmentKey:string;clientId:number;enrollmentId:number;effectiveDate:string;matchesBefore:true;
|
|
91
|
+
steps:Array<{name:string;state:'attempted'|'verified';failure?:EntryCorrectionFailure}>;
|
|
92
|
+
}
|
|
93
|
+
export interface ResolveEntryCorrectionInput {
|
|
94
|
+
key:string;enrollmentKey:string;clientId:number;
|
|
95
|
+
/** Opaque reference to independent non-application evidence, not a response body. */
|
|
96
|
+
evidenceReference:string;
|
|
97
|
+
/** Read-only confirmation using independent server/administrative evidence.
|
|
98
|
+
* A 4xx response plus matchesBefore alone is not sufficient. */
|
|
99
|
+
confirmNotApplied:(evidence:EntryCorrectionResolutionEvidence)=>boolean|Promise<boolean>;
|
|
100
|
+
}
|
|
101
|
+
export interface ResolveEntryCorrectionResult {
|
|
102
|
+
key:string;enrollmentKey:string;clientId:number;outcome:'rejected'|'already-rejected';resolution:EntryCorrectionResolution;
|
|
103
|
+
}
|
|
82
104
|
export interface AdoptEnrollmentInput {key:string;clientId:number;enrollmentId:number;}
|
|
83
105
|
export interface AdoptHouseholdEnrollmentInput {
|
|
84
106
|
key:string;householdId:number;headClientId:number;
|
|
85
107
|
/** Complete linked cohort, including already exited participants. IDs are explicit, not inferred. */
|
|
86
108
|
members:Array<{clientId:number;enrollmentId:number}>;
|
|
87
109
|
}
|
|
110
|
+
export interface HouseholdEnrollmentDiscoveryInput {headClientId:number;headEnrollmentId:number;}
|
|
111
|
+
export interface HouseholdEnrollmentDiscoveryResult extends HouseholdEnrollmentDiscoveryInput {
|
|
112
|
+
householdId:number;householdSource:'verified-membership';providerId:number;type:string;
|
|
113
|
+
members:Array<{clientId:number;enrollmentId:number}>;
|
|
114
|
+
}
|
|
115
|
+
export interface AdoptHouseholdEnrollmentFromHeadInput extends HouseholdEnrollmentDiscoveryInput {key:string;}
|
|
116
|
+
export interface RegisterEntryRowsInput {
|
|
117
|
+
key:string;enrollmentKey:string;clientId:number;
|
|
118
|
+
rows:Array<{key:string;parentField:string;recordsetId:number}>;
|
|
119
|
+
}
|
|
120
|
+
export interface RegisterEntryRowsResult {
|
|
121
|
+
key:string;enrollmentKey:string;clientId:number;enrollmentId:number;outcome:'verified'|'already-present';recordsetIds:Record<string,number>;
|
|
122
|
+
}
|
|
88
123
|
export interface EnrollmentWorkflowResult {
|
|
89
124
|
key:string;
|
|
90
125
|
outcome:'verified'|'already-present';
|
|
@@ -105,14 +140,15 @@ export interface ExitWorkflowDiscovery extends EnrollmentWorkflowDiscovery {exit
|
|
|
105
140
|
export interface EnrollmentWorkflowStatus {
|
|
106
141
|
key:string;
|
|
107
142
|
kind:'entry'|'interim'|'exit'|'correction';
|
|
108
|
-
stage:'pending'|'verified';
|
|
143
|
+
stage:'pending'|'verified'|'rejected';
|
|
109
144
|
assessmentCode:string;
|
|
110
145
|
enrollmentId?:number;
|
|
111
146
|
reviewId?:number;
|
|
112
147
|
projectExitDate?:string;
|
|
113
148
|
exitSubgroupId?:string|null;
|
|
114
149
|
recordsetIds:Record<string,number>;
|
|
115
|
-
steps:{name:string;state:'attempted'|'verified'}[];
|
|
150
|
+
steps:{name:string;state:'attempted'|'verified';failure?:EntryCorrectionFailure}[];
|
|
151
|
+
resolution?:EntryCorrectionResolution;
|
|
116
152
|
}
|
|
117
153
|
export interface EnrollmentWorkflow {
|
|
118
154
|
discover(input:EnrollmentDiscoveryInput):Promise<EnrollmentWorkflowDiscovery>;
|
|
@@ -123,10 +159,16 @@ export interface EnrollmentWorkflow {
|
|
|
123
159
|
exitEnrollment(input:ExitEnrollmentInput):Promise<ExitEnrollmentResult>;
|
|
124
160
|
/** Patch an open entry at its original timestamp, before any review or exit. */
|
|
125
161
|
correctEntry(input:EntryCorrectionInput):Promise<EntryCorrectionResult>;
|
|
162
|
+
/** Bind explicit existing row IDs to stable keys without writing HMIS records. */
|
|
163
|
+
registerEntryRows(input:RegisterEntryRowsInput):Promise<RegisterEntryRowsResult>;
|
|
164
|
+
/** Resolve a unique current household satisfying full-cohort membership/head/date checks. */
|
|
165
|
+
discoverHouseholdEnrollment(input:HouseholdEnrollmentDiscoveryInput):Promise<HouseholdEnrollmentDiscoveryResult>;
|
|
166
|
+
/** Verify unchanged state and independently confirmed rejection; keeps the attempt history. */
|
|
167
|
+
resolveEntryCorrection(input:ResolveEntryCorrectionInput):Promise<ResolveEntryCorrectionResult>;
|
|
126
168
|
/** Verify an existing standalone entry and journal it without an HMIS write. */
|
|
127
169
|
adoptEnrollment(input:AdoptEnrollmentInput):Promise<EnrollmentWorkflowResult>;
|
|
128
170
|
/** Verify all existing linked entries, original head and dates; no HMIS writes. */
|
|
129
|
-
adoptHouseholdEnrollment(input:AdoptHouseholdEnrollmentInput):Promise<HouseholdEnrollmentResult>;
|
|
171
|
+
adoptHouseholdEnrollment(input:AdoptHouseholdEnrollmentInput|AdoptHouseholdEnrollmentFromHeadInput):Promise<HouseholdEnrollmentResult>;
|
|
130
172
|
/** Reads the local journal only. A verified journal is not fresh server verification. */
|
|
131
173
|
getStatus(input:{clientId:number;key:string}):Promise<EnrollmentWorkflowStatus|null>;
|
|
132
174
|
createHouseholdEnrollment(input:HouseholdEnrollmentInput):Promise<HouseholdEnrollmentResult>;
|
|
@@ -217,9 +259,17 @@ export interface EnrollmentWorkflowOptions {
|
|
|
217
259
|
}
|
|
218
260
|
export class EnrollmentWorkflowError extends Error {
|
|
219
261
|
readonly reason:string;
|
|
262
|
+
readonly code:string;
|
|
263
|
+
readonly committed:false|'unknown';
|
|
264
|
+
readonly operation?:string;
|
|
265
|
+
/** Resolution-call failure does not establish the original correction's outcome. */
|
|
266
|
+
readonly originalCommitted?:'unknown';
|
|
267
|
+
readonly reauthenticated?:boolean;
|
|
268
|
+
readonly fields?:string[];
|
|
269
|
+
readonly status?:number;
|
|
220
270
|
/** Failed durable stage, when known. The original write error is available as cause in memory. */
|
|
221
271
|
readonly step?:string;
|
|
222
|
-
constructor(reason:string,options?:ErrorOptions&{step?:string});
|
|
272
|
+
constructor(reason:string,options?:ErrorOptions&{step?:string;code?:string;fields?:string[];status?:number;committed?:false|'unknown'});
|
|
223
273
|
}
|
|
224
274
|
/** An unavailable list is never evidence that no enrollment exists. */
|
|
225
275
|
export class EnrollmentReadError extends EnrollmentWorkflowError {
|
|
@@ -1,7 +1,10 @@
|
|
|
1
1
|
import {apiScope} from './connection.mjs';
|
|
2
2
|
import {validateBirthDate} from './date-of-birth.mjs';
|
|
3
3
|
import {expectedExitDetails} from './exit-details.mjs';
|
|
4
|
-
import {createEntryCorrector} from './entry-correction.mjs';
|
|
4
|
+
import {createEntryCorrector,correctionFailureDetails} from './entry-correction.mjs';
|
|
5
|
+
import {createEntryRowRegistrar} from './entry-row-registration.mjs';
|
|
6
|
+
import {createHouseholdEnrollmentDiscovery} from './household-enrollment-discovery.mjs';
|
|
7
|
+
import {workflowErrorCode} from './workflow-error-codes.mjs';
|
|
5
8
|
import {createHash} from 'node:crypto';
|
|
6
9
|
import {readProgramCatalog,encodeAnswers,encodeNewSubassessmentRow,toItems,parseRestInstant} from '../dist/esm/index.js';
|
|
7
10
|
|
|
@@ -10,8 +13,18 @@ const positive=v=>Number.isSafeInteger(v)&&v>0;
|
|
|
10
13
|
const entryExitTypes=['HUD','Basic','Standard','RHY','VA','Basic Center Program Entry/Exit','PATH','Quick Call','Transitional Living Program Entry/Exit'];
|
|
11
14
|
const canonical=v=>JSON.stringify(v,(_,x)=>object(x)?Object.fromEntries(Object.keys(x).sort().map(k=>[k,x[k]])):x);
|
|
12
15
|
const hash=v=>createHash('sha256').update(canonical(v)).digest('hex');
|
|
13
|
-
const fail=message=>{throw new EnrollmentWorkflowError(message);};
|
|
14
|
-
export class EnrollmentWorkflowError extends Error {
|
|
16
|
+
const fail=(message,options)=>{throw new EnrollmentWorkflowError(message,{committed:false,...options});};
|
|
17
|
+
export class EnrollmentWorkflowError extends Error {
|
|
18
|
+
constructor(reason,options){
|
|
19
|
+
super(`Enrollment workflow: ${reason}`,options);this.name='EnrollmentWorkflowError';this.reason=reason;
|
|
20
|
+
if(options?.step!==undefined)this.step=options.step;
|
|
21
|
+
this.code=options?.code??workflowErrorCode(reason);
|
|
22
|
+
this.committed=options?.committed??'unknown';
|
|
23
|
+
if(options?.fields!==undefined)this.fields=[...options.fields];
|
|
24
|
+
const status=options?.status??options?.cause?.status;
|
|
25
|
+
if(Number.isInteger(status)&&status>=100&&status<=599)this.status=status;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
15
28
|
export class EnrollmentReadError extends EnrollmentWorkflowError {
|
|
16
29
|
constructor({clientId,source,writeAttempted,cause}){
|
|
17
30
|
super(cause instanceof EnrollmentWorkflowError?cause.reason:'enrollment list unavailable',{step:'enrollment-read',cause});this.name='EnrollmentReadError';this.code='ENROLLMENT_READ_FAILED';this.clientId=clientId;this.source=source;this.phase=writeAttempted?'reconciliation':'preflight';this.committed=writeAttempted?'unknown':false;
|
|
@@ -59,7 +72,7 @@ function compile(definition,input,{yesNoWrites,writeLabel}={}){
|
|
|
59
72
|
const covered=new Set([...Object.keys(answers),...Object.keys(excluded),...carry,...compiledRows.map(r=>r.parentField),...compiledEnds.map(r=>r.parentField)]);
|
|
60
73
|
for(const q of definition.questions){
|
|
61
74
|
if(['blank','label'].includes(q.questionType))continue;
|
|
62
|
-
if(!covered.has(q.fieldName))fail(`uncovered assessment field ${q.fieldName}
|
|
75
|
+
if(!covered.has(q.fieldName))fail(`uncovered assessment field ${q.fieldName}`,{code:'ASSESSMENT_FIELD_UNCOVERED',fields:[q.fieldName??String(q.questionId)]});
|
|
63
76
|
if((q.fieldName in excluded||carry.includes(q.fieldName))&&(q.fieldName in answers||compiledRows.some(r=>r.parentField===q.fieldName)||compiledEnds.some(r=>r.parentField===q.fieldName)))fail('conflicting field coverage');
|
|
64
77
|
if(q.fieldName in excluded&&carry.includes(q.fieldName))fail('conflicting field coverage');
|
|
65
78
|
}
|
|
@@ -72,12 +85,12 @@ function compile(definition,input,{yesNoWrites,writeLabel}={}){
|
|
|
72
85
|
if(yesNo.length)for(const q of definition.questions){
|
|
73
86
|
if(!q.mandatory||['blank','label','sub_assessment'].includes(q.questionType)||yesNo.some(y=>y.virt_field_name===q.fieldName))continue;
|
|
74
87
|
// Only mandatory lookups were verified live; this also keeps DOB inside its own helper.
|
|
75
|
-
if(q.questionType!=='lookup')fail(`standard yes/no writes support mandatory lookups only; ${q.fieldName} is ${q.questionType}
|
|
88
|
+
if(q.questionType!=='lookup')fail(`standard yes/no writes support mandatory lookups only; ${q.fieldName} is ${q.questionType}`,{code:'UNSUPPORTED_MANDATORY_FIELD_TYPE',fields:[q.fieldName??String(q.questionId)]});
|
|
76
89
|
const base={question_id:q.questionId,question_type:q.questionType,virt_field_name:q.fieldName};
|
|
77
90
|
if(q.fieldName in answers){
|
|
78
91
|
const [label]=encodeAnswers(definition.questions,{[q.fieldName]:answers[q.fieldName]},{endpoint:'standalone',writeLabel});
|
|
79
92
|
const [stored]=encodeAnswers(definition.questions,{[q.fieldName]:answers[q.fieldName]},{endpoint:'mobile'});
|
|
80
|
-
if(!label||!stored)fail(`mandatory answer ${q.fieldName} has no standard encoding
|
|
93
|
+
if(!label||!stored)fail(`mandatory answer ${q.fieldName} has no standard encoding`,{code:'ANSWER_ENCODING_UNAVAILABLE',fields:[q.fieldName??String(q.questionId)]});
|
|
81
94
|
mandatory.push({...base,answer_value:label.answer_value,expected:stored.answer_value});
|
|
82
95
|
}else if(q.fieldName in excluded)mandatory.push({...base,answer_value:'',expected:''});
|
|
83
96
|
else if(carry.includes(q.fieldName))mandatory.push({...base,carry:true});
|
|
@@ -102,7 +115,18 @@ async function enrollments(api,clientId,{enrollmentSession,writeAttempted=false}
|
|
|
102
115
|
/** Explicit orchestration for an existing client. All writers must share the durable store.
|
|
103
116
|
* No automatic retries of uncertain REST writes. This is not a cross-system transaction. */
|
|
104
117
|
export function createEnrollmentWorkflow({api,discovery,helpers,store,enrollmentSession,exitUi,memberEntryDatePort,birthDates,assessmentLocations={},yesNoWrites='mobile',writeLabel}){
|
|
105
|
-
|
|
118
|
+
let tenantScope;try{tenantScope=apiScope(api, discovery, helpers, enrollmentSession, exitUi, writeLabel);}catch(cause){throw new EnrollmentWorkflowError(cause instanceof Error?cause.message:'invalid workflow configuration',{code:'WORKFLOW_CONFIGURATION_INVALID',committed:false,cause});}
|
|
119
|
+
const execute=async(operation,action)=>{
|
|
120
|
+
const state={attempted:false};
|
|
121
|
+
try{return await action(state);}catch(cause){
|
|
122
|
+
const error=cause instanceof EnrollmentWorkflowError?cause:new EnrollmentWorkflowError(cause instanceof Error?cause.message:'upstream workflow operation failed',{code:cause?.code==='SESSION_INTERRUPTED'?'SESSION_INTERRUPTED':cause?.kind==='validation'?'WORKFLOW_VALIDATION_FAILED':'WORKFLOW_UPSTREAM_FAILURE',...(Array.isArray(cause?.fields)&&cause.fields.every(f=>typeof f==='string')?{fields:cause.fields}:{}),cause});
|
|
123
|
+
if(cause?.code==='SESSION_INTERRUPTED')error.reauthenticated=cause.reauthenticated;
|
|
124
|
+
error.operation=operation;
|
|
125
|
+
error.committed=state.attempted||cause instanceof EnrollmentReadError&&cause.committed==='unknown'||cause?.code==='ENTRY_CORRECTION_UNCERTAIN'?'unknown':false;
|
|
126
|
+
if(operation==='resolveEntryCorrection')error.originalCommitted='unknown';
|
|
127
|
+
throw error;
|
|
128
|
+
}
|
|
129
|
+
};
|
|
106
130
|
if(typeof discovery?.getProviderAssessments!=='function'||typeof helpers?.createInterimUpdate!=='function'||!store)fail('REST API, discovery, enrollment helpers and durable store required');
|
|
107
131
|
if(!['mobile','standard'].includes(yesNoWrites)||yesNoWrites==='standard'&&(typeof writeLabel!=='function'||typeof api.assessments?.create!=='function'))fail('yesNoWrites must be mobile, or standard with writeLabel and assessments.create');
|
|
108
132
|
if(!object(assessmentLocations))fail('assessment locations must be a map by Entry/Exit type');
|
|
@@ -114,7 +138,7 @@ export function createEnrollmentWorkflow({api,discovery,helpers,store,enrollment
|
|
|
114
138
|
const locationsByType={HUD:{entry:'showHudEntry',interim:'showHudInterim',exit:'showHudExit'},VA:{entry:'showVaEntry',interim:'showVaInterim',exit:'showVaExit'},...structuredClone(assessmentLocations)};
|
|
115
139
|
const discover=async(input,includeExit=false)=>{
|
|
116
140
|
fields(input,['clientId','providerId','type','entryDate']);if(!positive(input.clientId)||!positive(input.providerId)||!entryExitTypes.includes(input.type))fail('client, provider and supported Entry/Exit type required');const at=date(input.entryDate);
|
|
117
|
-
const locations=locationsByType[input.type];if(!locations||includeExit&&!locations.exit)fail(`assessment locations required for ${input.type}${includeExit?' including exit':''}
|
|
141
|
+
const locations=locationsByType[input.type];if(!locations||includeExit&&!locations.exit)fail(`assessment locations required for ${input.type}${includeExit?' including exit':''}`,{code:'ASSESSMENT_LOCATIONS_REQUIRED'});
|
|
118
142
|
const client=toItems(await api.clients.get({path:{clientId:input.clientId}}));if(client.length!==1||client[0].clientId!==input.clientId||client[0].isActive===false)fail('active client required');
|
|
119
143
|
const catalog=await readProgramCatalog(api);const providers=catalog.providers.filter(p=>p.providerId===input.providerId);if(providers.length!==1)fail('active operational program required');
|
|
120
144
|
const configuration=await discovery.getProviderAssessments({providerId:input.providerId});if(configuration.providerId!==input.providerId)fail('provider discovery mismatch');
|
|
@@ -129,8 +153,11 @@ export function createEnrollmentWorkflow({api,discovery,helpers,store,enrollment
|
|
|
129
153
|
};
|
|
130
154
|
const scope=clientId=>JSON.stringify([tenantScope,'enrollment-workflow',clientId]);
|
|
131
155
|
const reservationScope=clientId=>JSON.stringify([tenantScope,'household-workflow-reservation',clientId]);
|
|
132
|
-
const correctEntry=createEntryCorrector({api,store,enrollmentSession,writeLabel,discover,scope,reservationScope,makeError:(reason,options)=>new EnrollmentWorkflowError(reason,options)});
|
|
156
|
+
const {correctEntry,resolveEntryCorrection}=createEntryCorrector({api,store,enrollmentSession,writeLabel,discover,scope,reservationScope,makeError:(reason,options)=>new EnrollmentWorkflowError(reason,options)});
|
|
157
|
+
const registerEntryRows=createEntryRowRegistrar({api,store,enrollmentSession,discover,scope,reservationScope,makeError:(reason,options)=>new EnrollmentWorkflowError(reason,options)});
|
|
158
|
+
const discoverHouseholdEnrollment=createHouseholdEnrollmentDiscovery({api,enrollmentSession,makeError:(reason,options)=>new EnrollmentWorkflowError(reason,options)});
|
|
133
159
|
const run=async(kind,raw,internal={})=>{
|
|
160
|
+
const operation=internal.operation??{attempted:false};
|
|
134
161
|
const standaloneExit=kind==='exit'&&internal.standaloneExit===true;
|
|
135
162
|
fields(raw,kind==='entry'?['key','clientId','providerId','type','entryDate','assessment']:['key','enrollmentKey','clientId',kind==='exit'?'exitDate':'reviewDate','assessment',...(standaloneExit?['destination','reasonForLeaving','destinationDetails','reasonForLeavingOther']:[])]);
|
|
136
163
|
const input=structuredClone(raw);key(input.key);if(!positive(input.clientId))fail('client ID required');
|
|
@@ -144,8 +171,10 @@ export function createEnrollmentWorkflow({api,discovery,helpers,store,enrollment
|
|
|
144
171
|
return locked(scope(input.clientId),async()=>{
|
|
145
172
|
const reservations=await store.withLock(reservationScope(input.clientId),()=>store.read(reservationScope(input.clientId)));
|
|
146
173
|
if(!Array.isArray(reservations)||reservations.some(r=>r.stage!=='verified'&&r.owner!==internal.owner))fail('unfinished household workflow must be reconciled first');
|
|
174
|
+
const inheritedAttempt=operation.attempted;operation.attempted=true;
|
|
147
175
|
const records=await store.read(scope(input.clientId));if(!Array.isArray(records)||records.some(r=>!object(r)||r.version!==1||!object(r.steps)))fail('invalid workflow journal');
|
|
148
|
-
|
|
176
|
+
operation.attempted=inheritedAttempt||records.some(r=>r.key===input.key&&Object.keys(r.steps).length>0);
|
|
177
|
+
if(records.some(r=>r.stage!=='verified'&&!(r.kind==='correction'&&r.stage==='rejected')&&r.key!==input.key))fail('another unfinished workflow must be reconciled first');
|
|
149
178
|
const matches=records.filter(r=>r.key===input.key);if(matches.length>1)fail('duplicate journal keys');let record=matches[0];
|
|
150
179
|
const intentHash=hash({kind,input,...(internal.owner?{household:internal.owner}:{})});if(record&&record.intentHash!==intentHash)fail('key conflicts with previous intent');
|
|
151
180
|
// Changing the yes/no endpoint after an attempt would be an endpoint switch, not reconciliation.
|
|
@@ -217,6 +246,17 @@ export function createEnrollmentWorkflow({api,discovery,helpers,store,enrollment
|
|
|
217
246
|
// that instant; one second later returns them. Only the yes/no step needs this.
|
|
218
247
|
const readSaved=async()=>questions(await api.assessments.get({path:{clientId:input.clientId,assessmentCode:code},query:{dateEffective:new Date(Date.parse(at)+1000).toISOString()}}));
|
|
219
248
|
const initial=await read();
|
|
249
|
+
if(!record&&plan.scalar.length){
|
|
250
|
+
const saved=await readSaved();
|
|
251
|
+
const existing=plan.scalar.map(q=>({q,row:saved.find(r=>r.virt_field_name===q.virt_field_name)}));
|
|
252
|
+
const complete=existing.every(({q,row})=>row&&sameAnswer(q,row.answer_value)&&restDate(row.date_effective)===restDate(at));
|
|
253
|
+
if(!complete){
|
|
254
|
+
const unavailable=existing.filter(({q,row})=>!row||row.question_type!==q.question_type||row.date_effective!=null&&(!Number.isFinite(restDate(row.date_effective))||restDate(row.date_effective)>restDate(at))||row.date_effective==null&&row.answer_value!=null&&row.answer_value!=='').map(({q})=>q.virt_field_name);
|
|
255
|
+
if(unavailable.length)fail('mobile scalar preflight unavailable',{code:'MOBILE_PREFLIGHT_UNAVAILABLE',fields:unavailable});
|
|
256
|
+
const collisions=existing.filter(({row})=>restDate(row.date_effective)===restDate(at)).map(({q})=>q.virt_field_name);
|
|
257
|
+
if(collisions.length)fail('mobile scalar answer already exists at the effective date',{code:'MOBILE_ANSWER_EXISTS',fields:collisions});
|
|
258
|
+
}
|
|
259
|
+
}
|
|
220
260
|
if(expectedHead===input.clientId&&headQuestion)checkHeadAnswer(proposedHead?.answer_value??initial.find(q=>q.virt_field_name==='HUD_RELATIONTOHOH')?.answer_value);
|
|
221
261
|
for(const field of plan.carry.filter(field=>field!=='SVPPROFDOB')){if(kind==='entry'&&record?.correctedScalars?.[field])continue;const q=initial.find(q=>q.virt_field_name===field);if(!q||(q.question_type==='sub_assessment'?!q.sub_assessment_answer_list?.length:!q.answer_value))fail('carry-forward field is empty');}
|
|
222
262
|
// Resend mandatory answers with the standard yes/no write. A blank write must not hide a recorded value.
|
|
@@ -227,11 +267,11 @@ export function createEnrollmentWorkflow({api,discovery,helpers,store,enrollment
|
|
|
227
267
|
if(m.carry){
|
|
228
268
|
const question=definition.questions.find(q=>q.fieldName===m.virt_field_name),options=question.options.filter(o=>String(o.id)===String(recorded));
|
|
229
269
|
const label=options.length===1?writeLabel(question,options[0]):undefined;
|
|
230
|
-
if(!label)fail(`carried mandatory answer ${m.virt_field_name} has no verified write label
|
|
270
|
+
if(!label)fail(`carried mandatory answer ${m.virt_field_name} has no verified write label`,{code:'LOOKUP_WRITE_LABEL_UNAVAILABLE',fields:[m.virt_field_name]});
|
|
231
271
|
if(m.virt_field_name==='HUD_RELATIONTOHOH')checkHeadAnswer(recorded);
|
|
232
272
|
return {question_id:m.question_id,question_type:m.question_type,virt_field_name:m.virt_field_name,answer_value:label,expected:String(recorded)};
|
|
233
273
|
}
|
|
234
|
-
if(m.expected===''&&recorded!==null&&recorded!==undefined&&recorded!=='')fail(`not-applicable mandatory field ${m.virt_field_name} has a recorded value; answer it explicitly
|
|
274
|
+
if(m.expected===''&&recorded!==null&&recorded!==undefined&&recorded!=='')fail(`not-applicable mandatory field ${m.virt_field_name} has a recorded value; answer it explicitly`,{code:'MANDATORY_CLEAR_CONFLICT',fields:[m.virt_field_name]});
|
|
235
275
|
return m;
|
|
236
276
|
});
|
|
237
277
|
// Validate closure targets before creating enrollment/review or submitting any answers.
|
|
@@ -265,6 +305,7 @@ export function createEnrollmentWorkflow({api,discovery,helpers,store,enrollment
|
|
|
265
305
|
if(previous)throw new EnrollmentWorkflowError(previous.state==='verified'?'verified step changed':'uncertain write requires reconciliation',{step:name});
|
|
266
306
|
record.steps[name]={state:'attempted'};await persist();
|
|
267
307
|
let writeError;
|
|
308
|
+
operation.attempted=true;
|
|
268
309
|
try{await submit(result);}catch(error){writeError=error;}
|
|
269
310
|
// A write response alone never establishes success or absence. Preserve its
|
|
270
311
|
// diagnostic cause in memory while reconciliation remains authoritative.
|
|
@@ -290,6 +331,7 @@ export function createEnrollmentWorkflow({api,discovery,helpers,store,enrollment
|
|
|
290
331
|
else {const found=await findEnrollment();if(!found.found)fail('saved enrollment missing');record.enrollmentId=entry.enrollmentId;}
|
|
291
332
|
await persist();
|
|
292
333
|
if(kind==='interim'){
|
|
334
|
+
if(!internal.readReview)operation.attempted=true;
|
|
293
335
|
// The GWT helper has its own journal and authoritative reads; it owns uncertain-write reconciliation.
|
|
294
336
|
const review=internal.readReview?await internal.readReview({clientId:input.clientId,enrollmentId:record.enrollmentId}):await helpers.createInterimUpdate({clientId:input.clientId,enrollmentId:record.enrollmentId,clientIds:[input.clientId],reviewDate:at});
|
|
295
337
|
const matches=review.snapshot.reviews.filter(r=>r.type==='Update'&&restDate(r.reviewDate)===restDate(at)&&r.clientIds.includes(input.clientId));if(matches.length!==1||record.reviewId&&record.reviewId!==matches[0].reviewId)fail('interim did not verify');record.reviewId=matches[0].reviewId;await persist();
|
|
@@ -352,7 +394,7 @@ export function createEnrollmentWorkflow({api,discovery,helpers,store,enrollment
|
|
|
352
394
|
const known=record.steps.birthDate.value;
|
|
353
395
|
result=await birthDates.verify({...dobContext,dateOfBirth:known.dateOfBirth,effectiveDate:known.effectiveDate,allowLater:true});
|
|
354
396
|
}else if(plan.dateOfBirth!==undefined){
|
|
355
|
-
result=await birthDates.update({...dobContext,key:'enrollment-dob:'+hash({key:input.key,kind,clientId:input.clientId}),dateOfBirth:plan.dateOfBirth,effectiveDate:at,...(kind==='entry'?{entry:{enrollmentId:record.enrollmentId,assessmentName:discovered.entry.assessment.name}}:{})});
|
|
397
|
+
operation.attempted=true;result=await birthDates.update({...dobContext,key:'enrollment-dob:'+hash({key:input.key,kind,clientId:input.clientId}),dateOfBirth:plan.dateOfBirth,effectiveDate:at,...(kind==='entry'?{entry:{enrollmentId:record.enrollmentId,assessmentName:discovered.entry.assessment.name}}:{})});
|
|
356
398
|
}else result=await birthDates.verify({...dobContext,dateOfBirth:carriedBirthDate.dateOfBirth,effectiveDate:carriedBirthDate.effectiveDate,allowLater:true});
|
|
357
399
|
record.steps.birthDate={state:'verified',value:{clientId:input.clientId,dateOfBirth:result.dateOfBirth,effectiveDate:result.effectiveDate}};await persist();
|
|
358
400
|
}
|
|
@@ -364,6 +406,11 @@ export function createEnrollmentWorkflow({api,discovery,helpers,store,enrollment
|
|
|
364
406
|
const memberKey=(key,clientId)=>'household:'+hash({key,clientId});
|
|
365
407
|
const withClients=(ids,work,index=0)=>index===ids.length?work():store.withLock(scope(ids[index]),()=>withClients(ids,work,index+1));
|
|
366
408
|
const adopt=async(household,raw)=>{
|
|
409
|
+
if(household&&raw&&Object.hasOwn(raw,'headEnrollmentId')){
|
|
410
|
+
fields(raw,['key','headClientId','headEnrollmentId']);key(raw.key);
|
|
411
|
+
const found=await discoverHouseholdEnrollment({headClientId:raw.headClientId,headEnrollmentId:raw.headEnrollmentId});
|
|
412
|
+
raw={key:raw.key,headClientId:found.headClientId,householdId:found.householdId,members:found.members};
|
|
413
|
+
}
|
|
367
414
|
fields(raw,household?['key','householdId','headClientId','members']:['key','clientId','enrollmentId']);
|
|
368
415
|
const input=structuredClone(raw);key(input.key);
|
|
369
416
|
if(typeof enrollmentSession?.readEnrollment!=='function')fail('authoritative enrollment session required for adoption');
|
|
@@ -387,7 +434,7 @@ export function createEnrollmentWorkflow({api,discovery,helpers,store,enrollment
|
|
|
387
434
|
if(!Array.isArray(reservations)||reservations.some(r=>r.stage!=='verified'&&r.owner!==owner))fail('unfinished household workflow must be reconciled first');
|
|
388
435
|
const records=await store.read(scope(target.clientId)),member=household?memberKey(input.key,target.clientId):input.key;
|
|
389
436
|
if(!Array.isArray(records)||records.some(r=>r.version!==1||!object(r.steps)))fail('invalid workflow journal');
|
|
390
|
-
if(records.some(r=>r.stage!=='verified'&&r.key!==member))fail('another unfinished workflow must be reconciled first');
|
|
437
|
+
if(records.some(r=>r.stage!=='verified'&&!(r.kind==='correction'&&r.stage==='rejected')&&r.key!==member))fail('another unfinished workflow must be reconciled first');
|
|
391
438
|
if(records.some(r=>r.kind==='entry'&&r.enrollmentId===target.enrollmentId&&r.key!==member))fail('enrollment already journaled; use its existing entry key');
|
|
392
439
|
const matches=records.filter(r=>r.key===member);if(matches.length>1)fail('duplicate journal keys');
|
|
393
440
|
if(matches[0]&&(!matches[0].adopted||matches[0].intentHash!==intentHash))fail('adoption key conflicts with previous intent');
|
|
@@ -439,7 +486,7 @@ export function createEnrollmentWorkflow({api,discovery,helpers,store,enrollment
|
|
|
439
486
|
});
|
|
440
487
|
});
|
|
441
488
|
};
|
|
442
|
-
const householdRun=async(kind,raw)=>{
|
|
489
|
+
const householdRun=async(kind,raw,operation={attempted:false})=>{
|
|
443
490
|
if(typeof enrollmentSession?.readEnrollment!=='function'||typeof helpers.addHouseholdMembers!=='function'||typeof api.households?.get!=='function')fail('household API and authoritative enrollment session required');
|
|
444
491
|
fields(raw,kind==='addition'?['key','enrollmentKey','headClientId','entryDate','members']:kind==='entry'?['key','householdId','headClientId','providerId','type','entryDate','members']:['key','enrollmentKey','headClientId',kind==='exit'?'exitDate':'reviewDate','members',...(kind==='exit'?['destination','reasonForLeaving','destinationDetails','reasonForLeavingOther']:[])]);
|
|
445
492
|
const input=structuredClone(raw);key(input.key);
|
|
@@ -454,8 +501,10 @@ export function createEnrollmentWorkflow({api,discovery,helpers,store,enrollment
|
|
|
454
501
|
else{key(input.enrollmentKey);if(kind==='addition')input.entryDate=date(input.entryDate);else if(kind==='exit')input.exitDate=date(input.exitDate);else input.reviewDate=date(input.reviewDate);}
|
|
455
502
|
// Read the cohort under its journal lock, then acquire all client locks in order.
|
|
456
503
|
// Recheck under both locks so concurrent membership growth cannot escape protection.
|
|
504
|
+
operation.attempted=true;
|
|
457
505
|
const prior=await store.withLock(householdScope(input.headClientId),()=>store.read(householdScope(input.headClientId)));
|
|
458
506
|
if(!Array.isArray(prior))fail('invalid household journal');
|
|
507
|
+
operation.attempted=prior.some(r=>r.key===input.key);
|
|
459
508
|
const lockedIds=[...new Set([input.headClientId,...ids,...prior.flatMap(r=>r.clientIds??[])])].sort((a,b)=>a-b);
|
|
460
509
|
return withClients(lockedIds,()=>store.withLock(householdScope(input.headClientId),async()=>{
|
|
461
510
|
const records=await store.read(householdScope(input.headClientId));
|
|
@@ -523,7 +572,7 @@ export function createEnrollmentWorkflow({api,discovery,helpers,store,enrollment
|
|
|
523
572
|
const toMember=m=>['entry','addition'].includes(kind)?{key:memberKey(input.key,m.clientId),clientId:m.clientId,providerId:context.providerId,type:context.type,entryDate:context.entryDate,assessment:m.assessment}:{key:memberKey(input.key,m.clientId),enrollmentKey:memberKey(catalog[m.clientId].entryKey,m.clientId),clientId:m.clientId,...(kind==='exit'?{exitDate:input.exitDate}:{reviewDate:input.reviewDate}),assessment:m.assessment};
|
|
524
573
|
// Validate every member before any record mutation. The same client locks cover
|
|
525
574
|
// preflight and execution, including overlaps with the single-client workflow.
|
|
526
|
-
const preflight=[];for(const member of input.members)preflight.push(await run(kind==='addition'?'entry':kind,toMember(member),{locked:true,owner,headClientId:input.headClientId,preflight:true,...(kind==='addition'?{pinSchema:true}:{})}));
|
|
575
|
+
const preflight=[];for(const member of input.members)preflight.push(await run(kind==='addition'?'entry':kind,toMember(member),{locked:true,owner,operation,headClientId:input.headClientId,preflight:true,...(kind==='addition'?{pinSchema:true}:{})}));
|
|
527
576
|
if(kind==='addition'&&record?.entrySchemas&&canonical(record.entrySchemas)!==canonical(preflight))fail('additional member assessment configuration changed');
|
|
528
577
|
if(kind==='exit'&&record?.exitSchemas&&canonical(record.exitSchemas)!==canonical(preflight))fail('exit assessment configuration changed');
|
|
529
578
|
const wasVerified=record?.stage==='verified';
|
|
@@ -571,14 +620,14 @@ export function createEnrollmentWorkflow({api,discovery,helpers,store,enrollment
|
|
|
571
620
|
const existing=await enrollments(api,clientId,{enrollmentSession,writeAttempted:await writeAttempted()});if(existing.some(e=>e.enrollmentProvider===context.providerId&&e.type===context.type&&e.isActive&&e.projectExitDate==null&&restDate(e.projectStartDate)===restDate(context.entryDate)))fail('member already has an independent matching enrollment');
|
|
572
621
|
}
|
|
573
622
|
const head=input.members.find(m=>m.clientId===input.headClientId);
|
|
574
|
-
const result=await run('entry',toMember(head),{locked:true,owner,headClientId:input.headClientId,findEnrollment:findMember(head.clientId)});record.members[head.clientId]=result;await persist();
|
|
623
|
+
const result=await run('entry',toMember(head),{locked:true,owner,operation,headClientId:input.headClientId,findEnrollment:findMember(head.clientId)});record.members[head.clientId]=result;await persist();
|
|
575
624
|
// Reject unexpected members before the attachment helper is allowed to write.
|
|
576
625
|
await readTarget({clientId:head.clientId,enrollmentId:result.enrollmentId},{partial:!record.attached});
|
|
577
626
|
record.attachmentAttempted=true;await persist();
|
|
578
|
-
const attached=await helpers.addHouseholdMembers({clientId:head.clientId,enrollmentId:result.enrollmentId,memberClientIds:ids.filter(id=>id!==head.clientId)});
|
|
627
|
+
operation.attempted=true;const attached=await helpers.addHouseholdMembers({clientId:head.clientId,enrollmentId:result.enrollmentId,memberClientIds:ids.filter(id=>id!==head.clientId)});
|
|
579
628
|
const snapshot=validateSnapshot(attached.snapshot,{clientId:head.clientId,enrollmentId:result.enrollmentId});
|
|
580
629
|
for(const m of snapshot.members)record.members[m.clientId]??={clientId:m.clientId,enrollmentId:m.enrollmentId};record.attached=true;await persist();
|
|
581
|
-
for(const member of input.members.filter(m=>m.clientId!==head.clientId)){record.members[member.clientId]=await run('entry',toMember(member),{locked:true,owner,headClientId:input.headClientId,findEnrollment:findMember(member.clientId)});await persist();}
|
|
630
|
+
for(const member of input.members.filter(m=>m.clientId!==head.clientId)){record.members[member.clientId]=await run('entry',toMember(member),{locked:true,owner,operation,headClientId:input.headClientId,findEnrollment:findMember(member.clientId)});await persist();}
|
|
582
631
|
}else if(kind==='addition'){
|
|
583
632
|
if(!record.anchor){
|
|
584
633
|
for(const clientId of Object.keys(catalog).map(Number).sort((a,b)=>a-b)){
|
|
@@ -592,7 +641,7 @@ export function createEnrollmentWorkflow({api,discovery,helpers,store,enrollment
|
|
|
592
641
|
const existing=await enrollments(api,clientId,{enrollmentSession,writeAttempted:await writeAttempted()});if(existing.some(e=>e.enrollmentProvider===context.providerId&&e.type===context.type&&e.isActive&&e.projectExitDate==null))fail('additional member already has an independent enrollment');
|
|
593
642
|
}
|
|
594
643
|
record.attachmentAttempted=true;await persist();
|
|
595
|
-
const attached=await helpers.addHouseholdMembers({...record.anchor,memberClientIds:ids});
|
|
644
|
+
operation.attempted=true;const attached=await helpers.addHouseholdMembers({...record.anchor,memberClientIds:ids});
|
|
596
645
|
validateSnapshot(attached.snapshot,record.anchor);
|
|
597
646
|
for(const clientId of ids){const found=attached.snapshot.members.find(m=>m.clientId===clientId);if(record.members[clientId]?.enrollmentId&&record.members[clientId].enrollmentId!==found.enrollmentId)fail('added enrollment identity changed');record.members[clientId]??={clientId,enrollmentId:found.enrollmentId};}
|
|
598
647
|
record.attached=true;await persist();await preserve();
|
|
@@ -610,13 +659,13 @@ export function createEnrollmentWorkflow({api,discovery,helpers,store,enrollment
|
|
|
610
659
|
const change={...target,providerId:context.providerId,type:context.type,entryDate:input.entryDate,cohort};
|
|
611
660
|
await memberEntryDatePort.validateMemberEntryDate(change);
|
|
612
661
|
record.entryDates[clientId]='attempted';record.entryDateInputs[clientId]=change;await persist();
|
|
613
|
-
try{await memberEntryDatePort.saveMemberEntryDate(change);}catch{/* Resolve only through readback; never replay the date update. */}
|
|
662
|
+
operation.attempted=true;try{await memberEntryDatePort.saveMemberEntryDate(change);}catch{/* Resolve only through readback; never replay the date update. */}
|
|
614
663
|
}
|
|
615
664
|
if(record.entryDates[clientId]==='attempted'&&record.entryDateInputs[clientId])await memberEntryDatePort.validateMemberEntryDate(record.entryDateInputs[clientId]);
|
|
616
665
|
const fresh=await readTarget(target);if(!wasVerified&&fresh.exits?.filter(e=>e.clientId===clientId&&e.enrollmentId===target.enrollmentId&&e.projectExitDate===null).length!==1)fail('new member must remain enrolled');
|
|
617
666
|
await preserve();record.entryDates[clientId]='verified';await persist();
|
|
618
667
|
}
|
|
619
|
-
for(const member of input.members){record.members[member.clientId]=await run('entry',toMember(member),{locked:true,owner,headClientId:input.headClientId,findEnrollment:findMember(member.clientId)});await persist();}
|
|
668
|
+
for(const member of input.members){record.members[member.clientId]=await run('entry',toMember(member),{locked:true,owner,operation,headClientId:input.headClientId,findEnrollment:findMember(member.clientId)});await persist();}
|
|
620
669
|
}else if(kind==='interim'){
|
|
621
670
|
// Verify all linked enrollments before making the single household review call.
|
|
622
671
|
const existingReviews=[];
|
|
@@ -631,7 +680,7 @@ export function createEnrollmentWorkflow({api,discovery,helpers,store,enrollment
|
|
|
631
680
|
if(existingReviews.some(Boolean)&&existingReviews.some(r=>!r))fail('partial household review requires reconciliation');
|
|
632
681
|
if(existingReviews[0]&&new Set(existingReviews.map(r=>r.reviewId)).size!==ids.length)fail('duplicate household review IDs');
|
|
633
682
|
const target={clientId:ids[0],enrollmentId:catalog[ids[0]].enrollmentId};
|
|
634
|
-
const review=await helpers.createInterimUpdate({...target,clientIds:ids,reviewDate:input.reviewDate});validateSnapshot(review.snapshot,target);
|
|
683
|
+
operation.attempted=true;const review=await helpers.createInterimUpdate({...target,clientIds:ids,reviewDate:input.reviewDate});validateSnapshot(review.snapshot,target);
|
|
635
684
|
const readReview=async target=>{
|
|
636
685
|
const snapshot=await readTarget(target),reviews=snapshot.reviews.filter(r=>r.type==='Update'&&restDate(r.reviewDate)===restDate(input.reviewDate));
|
|
637
686
|
if(reviews.length!==1||!positive(reviews[0].reviewId)||canonical([...reviews[0].clientIds].sort((a,b)=>a-b))!==canonical(ids))fail('household review membership mismatch');
|
|
@@ -641,7 +690,7 @@ export function createEnrollmentWorkflow({api,discovery,helpers,store,enrollment
|
|
|
641
690
|
// Pin every member's distinct review ID before writing any member's answers.
|
|
642
691
|
for(const clientId of ids){const enrollmentId=catalog[clientId].enrollmentId,{snapshot}=await readReview({clientId,enrollmentId});record.members[clientId]??={clientId,enrollmentId};record.members[clientId].reviewId=snapshot.reviews.find(r=>r.type==='Update'&&restDate(r.reviewDate)===restDate(input.reviewDate)).reviewId;await persist();}
|
|
643
692
|
if(new Set(ids.map(id=>record.members[id].reviewId)).size!==ids.length)fail('duplicate household review IDs');
|
|
644
|
-
for(const member of input.members){record.members[member.clientId]=await run('interim',toMember(member),{locked:true,owner,headClientId:input.headClientId,findEnrollment:findMember(member.clientId),readReview});await persist();}
|
|
693
|
+
for(const member of input.members){record.members[member.clientId]=await run('interim',toMember(member),{locked:true,owner,operation,headClientId:input.headClientId,findEnrollment:findMember(member.clientId),readReview});await persist();}
|
|
645
694
|
}else if(kind==='exit'){
|
|
646
695
|
const target={clientId:ids[0],enrollmentId:catalog[ids[0]].enrollmentId};
|
|
647
696
|
const normalizeLabel=value=>typeof value==='string'?value.replace(/\s*\(HUD\)\s*$/,'').trim():value;
|
|
@@ -672,7 +721,7 @@ export function createEnrollmentWorkflow({api,discovery,helpers,store,enrollment
|
|
|
672
721
|
const exitInput={...target,clientIds:ids,exitDate:input.exitDate,destination:input.destination,reasonForLeaving:input.reasonForLeaving,...(input.destinationDetails?{destinationDetails:input.destinationDetails}:{}),...(input.reasonForLeavingOther?{reasonForLeavingOther:input.reasonForLeavingOther}:{})};
|
|
673
722
|
await exitUi.validateExit(exitInput);
|
|
674
723
|
record.exitStep='attempted';await persist();
|
|
675
|
-
try{await exitUi.saveExit(exitInput);}catch{/* Only authoritative readback establishes success. Never replay an uncertain save. */}
|
|
724
|
+
operation.attempted=true;try{await exitUi.saveExit(exitInput);}catch{/* Only authoritative readback establishes success. Never replay an uncertain save. */}
|
|
676
725
|
observed=await inspect();if(!observed.found)fail('uncertain household exit requires reconciliation');
|
|
677
726
|
}
|
|
678
727
|
await validateHeadCoverage();await preserve();
|
|
@@ -680,7 +729,7 @@ export function createEnrollmentWorkflow({api,discovery,helpers,store,enrollment
|
|
|
680
729
|
const readExit=async target=>{
|
|
681
730
|
const result=await inspect();if(!result.found)fail('verified exit missing');return result.exits.find(e=>e.clientId===target.clientId&&e.enrollmentId===target.enrollmentId);
|
|
682
731
|
};
|
|
683
|
-
for(const member of input.members){record.members[member.clientId]=await run('exit',toMember(member),{locked:true,owner,headClientId:input.headClientId,findEnrollment:findMember(member.clientId),readExit});await persist();}
|
|
732
|
+
for(const member of input.members){record.members[member.clientId]=await run('exit',toMember(member),{locked:true,owner,operation,headClientId:input.headClientId,findEnrollment:findMember(member.clientId),readExit});await persist();}
|
|
684
733
|
}
|
|
685
734
|
if(kind==='exit'||kind==='addition')await preserve();
|
|
686
735
|
await validateHeadCoverage();
|
|
@@ -689,12 +738,16 @@ export function createEnrollmentWorkflow({api,discovery,helpers,store,enrollment
|
|
|
689
738
|
}));
|
|
690
739
|
};
|
|
691
740
|
return {
|
|
692
|
-
correctEntry,
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
741
|
+
correctEntry:input=>execute('correctEntry',operation=>correctEntry(input,operation)),
|
|
742
|
+
registerEntryRows:input=>execute('registerEntryRows',()=>registerEntryRows(input)),
|
|
743
|
+
discoverHouseholdEnrollment:input=>execute('discoverHouseholdEnrollment',()=>discoverHouseholdEnrollment(input)),
|
|
744
|
+
resolveEntryCorrection:input=>execute('resolveEntryCorrection',()=>resolveEntryCorrection(input)),
|
|
745
|
+
adoptEnrollment:input=>execute('adoptEnrollment',()=>adopt(false,input)),adoptHouseholdEnrollment:input=>execute('adoptHouseholdEnrollment',()=>adopt(true,input)),
|
|
746
|
+
addHouseholdMembers:input=>execute('addHouseholdMembers',operation=>householdRun('addition',input,operation)),
|
|
747
|
+
createHouseholdEnrollment:input=>execute('createHouseholdEnrollment',operation=>householdRun('entry',input,operation)),createHouseholdInterim:input=>execute('createHouseholdInterim',operation=>householdRun('interim',input,operation)),exitHousehold:input=>execute('exitHousehold',operation=>householdRun('exit',input,operation)),
|
|
748
|
+
discoverExit:input=>execute('discoverExit',()=>discover(input,true)),
|
|
697
749
|
async getHouseholdStatus(input){
|
|
750
|
+
return execute('getHouseholdStatus',async()=>{
|
|
698
751
|
fields(input,['headClientId','key']);if(!positive(input.headClientId))fail('head client ID required');key(input.key);
|
|
699
752
|
return store.withLock(householdScope(input.headClientId),async()=>{
|
|
700
753
|
const records=await store.read(householdScope(input.headClientId));if(!Array.isArray(records))fail('invalid household journal');
|
|
@@ -702,15 +755,18 @@ export function createEnrollmentWorkflow({api,discovery,helpers,store,enrollment
|
|
|
702
755
|
if(record.version!==1||!object(record.members))fail('invalid household journal');
|
|
703
756
|
return {key:record.key,kind:record.kind,stage:record.stage,householdId:record.context.householdId,headClientId:input.headClientId,members:record.clientIds.map(clientId=>({clientId,key:memberKey(input.key,clientId),...structuredClone(record.members[clientId]??{})}))};
|
|
704
757
|
});
|
|
758
|
+
});
|
|
705
759
|
},
|
|
706
|
-
discover,createEnrollment:input=>run('entry',input),createInterim:input=>run('interim',input),exitEnrollment:input=>run('exit',input,{standaloneExit:true}),
|
|
760
|
+
discover:input=>execute('discover',()=>discover(input)),createEnrollment:input=>execute('createEnrollment',operation=>run('entry',input,{operation})),createInterim:input=>execute('createInterim',operation=>run('interim',input,{operation})),exitEnrollment:input=>execute('exitEnrollment',operation=>run('exit',input,{standaloneExit:true,operation})),
|
|
707
761
|
async getStatus(input){
|
|
762
|
+
return execute('getStatus',async()=>{
|
|
708
763
|
fields(input,['clientId','key']);if(!positive(input.clientId))fail('client ID required');key(input.key);
|
|
709
764
|
return store.withLock(scope(input.clientId),async()=>{
|
|
710
765
|
const records=await store.read(scope(input.clientId));if(!Array.isArray(records))fail('invalid workflow journal');
|
|
711
766
|
const matches=records.filter(r=>r.key===input.key);if(matches.length>1)fail('duplicate journal keys');const r=matches[0];if(!r)return null;
|
|
712
767
|
if(r.version!==1||!object(r.steps))fail('invalid workflow journal');
|
|
713
|
-
return {key:r.key,kind:r.kind,stage:r.stage,assessmentCode:r.assessmentCode,...(r.enrollmentId?{enrollmentId:r.enrollmentId}:{}),...(r.reviewId?{reviewId:r.reviewId}:{}),...(r.projectExitDate?{projectExitDate:r.projectExitDate,exitSubgroupId:r.exitSubgroupId}:{}),recordsetIds:structuredClone(r.rows),steps:Object.entries(r.steps).map(([name,step])=>({name,state:step.state}))};
|
|
768
|
+
return {key:r.key,kind:r.kind,stage:r.stage,assessmentCode:r.assessmentCode,...(r.enrollmentId?{enrollmentId:r.enrollmentId}:{}),...(r.reviewId?{reviewId:r.reviewId}:{}),...(r.projectExitDate?{projectExitDate:r.projectExitDate,exitSubgroupId:r.exitSubgroupId}:{}),...(r.resolution?{resolution:structuredClone(r.resolution)}:{}),recordsetIds:structuredClone(r.rows),steps:Object.entries(r.steps).map(([name,step])=>({name,state:step.state,...(step.failure?{failure:correctionFailureDetails(step.failure)}:{})}))};
|
|
769
|
+
});
|
|
714
770
|
});
|
|
715
771
|
},
|
|
716
772
|
};
|
|
@@ -6,6 +6,12 @@ const canonical=v=>JSON.stringify(v,(_,x)=>object(x)?Object.fromEntries(Object.k
|
|
|
6
6
|
const hash=v=>createHash('sha256').update(canonical(v)).digest('hex');
|
|
7
7
|
const positive=v=>Number.isSafeInteger(v)&&v>0;
|
|
8
8
|
const scalarTypes=new Set(['lookup','yes_no','date','int','money','textbox','textarea']);
|
|
9
|
+
export function correctionFailureDetails(error){
|
|
10
|
+
const failure={};
|
|
11
|
+
if(Number.isInteger(error?.status)&&error.status>=100&&error.status<=599)failure.status=error.status;
|
|
12
|
+
if(['http','auth','timeout','network','aborted','protocol','validation','throttled','cooldown'].includes(error?.kind))failure.kind=error.kind;
|
|
13
|
+
return failure;
|
|
14
|
+
}
|
|
9
15
|
|
|
10
16
|
/** Internal correction writer. The workflow supplies its existing client locks and journal scopes. */
|
|
11
17
|
export function createEntryCorrector({api,store,enrollmentSession,writeLabel,discover,scope,reservationScope,makeError}){
|
|
@@ -21,6 +27,8 @@ export function createEntryCorrector({api,store,enrollmentSession,writeLabel,dis
|
|
|
21
27
|
const encode=(questions,answers,clear,endpoint)=>{
|
|
22
28
|
if(!object(answers)||!Array.isArray(clear)||new Set(clear).size!==clear.length||clear.some(f=>typeof f!=='string'||Object.hasOwn(answers,f)))fail('invalid or conflicting correction answers and clears');
|
|
23
29
|
const index=new Map(questions.map(q=>[q.fieldName,q]));
|
|
30
|
+
const unsupportedClears=clear.filter(field=>index.get(field)?.questionType==='yes_no');
|
|
31
|
+
if(unsupportedClears.length)throw makeError('yes/no answers cannot be cleared',{code:'UNSUPPORTED_YES_NO_CLEAR',fields:unsupportedClears});
|
|
24
32
|
for(const field of [...Object.keys(answers),...clear]){
|
|
25
33
|
const q=index.get(field);
|
|
26
34
|
if(!q||!scalarTypes.has(q.questionType)||endpoint==='mobile'&&q.questionType==='yes_no')fail('unsupported correction field type');
|
|
@@ -42,21 +50,37 @@ export function createEntryCorrector({api,store,enrollmentSession,writeLabel,dis
|
|
|
42
50
|
if(rows.some(q=>!object(q)||typeof q.virt_field_name!=='string')||new Set(rows.map(q=>q.virt_field_name)).size!==rows.length)fail('ambiguous correction assessment read');
|
|
43
51
|
return rows;
|
|
44
52
|
};
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
53
|
+
const run=async(mode,raw,operation={attempted:false})=>{
|
|
54
|
+
const resolving=mode==='resolve';
|
|
55
|
+
fields(raw,resolving?['key','enrollmentKey','clientId','evidenceReference','confirmNotApplied']:['key','enrollmentKey','clientId','answers','clear','subassessments']);
|
|
56
|
+
const {confirmNotApplied,...serializable}=raw;
|
|
57
|
+
const input=structuredClone(serializable);key(input.key);key(input.enrollmentKey);
|
|
58
|
+
if(resolving){key(input.evidenceReference);if(typeof confirmNotApplied!=='function')fail('independent non-application confirmation required');}
|
|
48
59
|
if(!positive(input.clientId)||!Array.isArray(input.subassessments??[]))fail('invalid correction client or rows');
|
|
49
60
|
if(typeof api.assessments?.create!=='function'||typeof enrollmentSession?.readEnrollment!=='function')fail('standard assessment writes and authoritative enrollment reader required for corrections');
|
|
50
61
|
return store.withLock(scope(input.clientId),async()=>{
|
|
51
62
|
const reservations=await store.withLock(reservationScope(input.clientId),()=>store.read(reservationScope(input.clientId)));
|
|
52
63
|
if(!Array.isArray(reservations)||reservations.some(r=>r.stage!=='verified'))fail('unfinished household workflow must be reconciled first');
|
|
64
|
+
if(!resolving)operation.attempted=true;
|
|
53
65
|
const records=await store.read(scope(input.clientId));
|
|
54
66
|
if(!Array.isArray(records)||records.some(r=>!object(r)||r.version!==1||!object(r.steps)))fail('invalid workflow journal');
|
|
55
|
-
if(records.some(r=>r.
|
|
67
|
+
if(!resolving)operation.attempted=records.some(r=>r.key===input.key&&Object.keys(r.steps).length>0);
|
|
68
|
+
if(records.some(r=>r.stage!=='verified'&&!(r.kind==='correction'&&r.stage==='rejected')&&r.key!==input.key))fail('another unfinished workflow must be reconciled first');
|
|
56
69
|
const entries=records.filter(r=>r.kind==='entry'&&r.key===input.enrollmentKey&&r.stage==='verified');
|
|
57
70
|
if(entries.length!==1)fail('verified enrollment workflow required');const entry=entries[0];
|
|
58
71
|
const matches=records.filter(r=>r.key===input.key);if(matches.length>1)fail('duplicate correction journal keys');let record=matches[0];
|
|
59
|
-
const intentHash=hash(input);
|
|
72
|
+
const intentHash=hash(input);
|
|
73
|
+
if(resolving){
|
|
74
|
+
if(!record||record.kind!=='correction'||record.enrollmentKey!==input.enrollmentKey)fail('matching correction journal required');
|
|
75
|
+
if(record.stage==='verified')fail('verified correction cannot be rejected');
|
|
76
|
+
if(record.stage==='rejected'){
|
|
77
|
+
if(record.resolution?.evidenceReference!==input.evidenceReference)fail('resolution evidence reference changed');
|
|
78
|
+
return {key:input.key,enrollmentKey:input.enrollmentKey,clientId:input.clientId,outcome:'already-rejected',resolution:structuredClone(record.resolution)};
|
|
79
|
+
}
|
|
80
|
+
}else{
|
|
81
|
+
if(record?.kind==='correction'&&record.stage==='rejected')throw makeError('correction was resolved as not applied; use a new key for a corrected intent',{code:'ENTRY_CORRECTION_REJECTED'});
|
|
82
|
+
if(record&&(record.kind!=='correction'||record.intentHash!==intentHash))fail('correction key conflicts with previous intent');
|
|
83
|
+
}
|
|
60
84
|
if(!record&&records.some(r=>r.enrollmentId===entry.enrollmentId&&!['entry','correction'].includes(r.kind)))fail('entry correction must precede interim or exit workflows');
|
|
61
85
|
const {providerId,type,entryDate}=entry.context,at=new Date(parseRestInstant(entryDate)).toISOString(),readAt=new Date(parseRestInstant(at)+1000).toISOString();
|
|
62
86
|
const target={clientId:input.clientId,enrollmentId:entry.enrollmentId};
|
|
@@ -99,6 +123,17 @@ export function createEntryCorrector({api,store,enrollmentSession,writeLabel,dis
|
|
|
99
123
|
return {raw,scalars,rows};
|
|
100
124
|
};
|
|
101
125
|
const observed=await snapshot();
|
|
126
|
+
if(resolving){
|
|
127
|
+
if(record.stage!=='pending'||!Object.values(record.steps).some(s=>s.state==='attempted'))fail('attempted correction required for resolution');
|
|
128
|
+
const unchanged=state=>canonical({scalars:state.scalars,rows:state.rows})===canonical(record.before);
|
|
129
|
+
if(!unchanged(observed))fail('correction state differs from its pre-write snapshot; non-application cannot be resolved');
|
|
130
|
+
const evidence={clientId:input.clientId,enrollmentId:entry.enrollmentId,enrollmentKey:input.enrollmentKey,key:input.key,effectiveDate:at,matchesBefore:true,steps:Object.entries(record.steps).map(([name,s])=>({name,state:s.state,...(s.failure?{failure:correctionFailureDetails(s.failure)}:{})}))};
|
|
131
|
+
if(await confirmNotApplied(evidence)!==true)fail('independent non-application confirmation required');
|
|
132
|
+
await verifyEnrollment();if(!unchanged(await snapshot()))fail('correction state changed during resolution');
|
|
133
|
+
record.resolution={kind:'not-applied',evidenceReference:input.evidenceReference,resolvedAt:new Date().toISOString()};record.stage='rejected';
|
|
134
|
+
await store.write(scope(input.clientId),structuredClone(records));
|
|
135
|
+
return {key:input.key,enrollmentKey:input.enrollmentKey,clientId:input.clientId,outcome:'rejected',resolution:structuredClone(record.resolution)};
|
|
136
|
+
}
|
|
102
137
|
const readOverrides=state=>{
|
|
103
138
|
for(const q of Object.values(entry.correctedScalars??{})){
|
|
104
139
|
const actual=state.scalars[q.virt_field_name];if(!actual||actual.value!==value(q.question_type,q.answer_value)||actual.value!==null&&actual.date!==parseRestInstant(at))fail('verified entry correction changed');
|
|
@@ -129,7 +164,7 @@ export function createEntryCorrector({api,store,enrollmentSession,writeLabel,dis
|
|
|
129
164
|
for(const patch of input.subassessments??[]){
|
|
130
165
|
fields(patch,['key','parentField','answers','clear']);key(patch.key);
|
|
131
166
|
const id=entry.rows[patch.key],parent=definition.questions.find(q=>q.fieldName===patch.parentField&&q.questionType==='sub_assessment');
|
|
132
|
-
if(!positive(id)||ids.has(id)||!parent||!observed.rows[parent.fieldName]?.[id])fail('correction requires an existing entry row key and matching parent');ids.add(id);
|
|
167
|
+
if(!positive(id)||ids.has(id)||!parent||!observed.rows[parent.fieldName]?.[id]||entry.rowParents?.[patch.key]&&entry.rowParents[patch.key]!==patch.parentField)fail('correction requires an existing entry row key and matching parent');ids.add(id);
|
|
133
168
|
const cells=encode(parent.children,patch.answers??{},patch.clear??[],'mobile');if(!cells.length)fail('empty row correction');
|
|
134
169
|
rowPlans.push({id,parentField:parent.fieldName,questionId:parent.questionId,cells});
|
|
135
170
|
}
|
|
@@ -145,12 +180,19 @@ export function createEntryCorrector({api,store,enrollmentSession,writeLabel,dis
|
|
|
145
180
|
return scalarsMatch&&canonical(state.rows)===canonical(want.rows);
|
|
146
181
|
};
|
|
147
182
|
const step=async(name,submit)=>{
|
|
148
|
-
|
|
149
|
-
if(record.steps[name])throw
|
|
183
|
+
const uncertain=cause=>makeError('uncertain entry correction requires reconciliation',{step:name,code:'ENTRY_CORRECTION_UNCERTAIN',status:record.steps[name]?.failure?.status,...(cause===undefined?{}:{cause})});
|
|
184
|
+
let found;try{found=await inspect(name);}catch(readError){if(record.steps[name])throw uncertain(readError);throw readError;}
|
|
185
|
+
if(found){record.steps[name]={state:'verified'};await persist();return;}
|
|
186
|
+
if(record.steps[name])throw uncertain();
|
|
150
187
|
if(!await inspect(null))fail('entry changed since correction preflight');
|
|
151
188
|
await verifyEnrollment();record.steps[name]={state:'attempted'};await persist();let cause;
|
|
152
|
-
try{await submit();}catch(e){
|
|
153
|
-
|
|
189
|
+
operation.attempted=true;try{await submit();}catch(e){
|
|
190
|
+
cause=e;const failure=correctionFailureDetails(e);
|
|
191
|
+
if(Object.keys(failure).length){record.steps[name].failure=failure;await persist();}
|
|
192
|
+
}
|
|
193
|
+
let saved;
|
|
194
|
+
try{saved=await inspect(name);}catch(readError){throw uncertain(cause===undefined?readError:new AggregateError([cause,readError],'Correction write and reconciliation read failed'));}
|
|
195
|
+
if(!saved)throw uncertain(cause);
|
|
154
196
|
record.steps[name]={state:'verified'};await persist();
|
|
155
197
|
};
|
|
156
198
|
if(record.scalarBody.length)await step('scalars',()=>api.assessments.create({path:{clientId:input.clientId,assessmentCode:code},query:{dateEffective:at},body:record.scalarBody}));
|
|
@@ -161,4 +203,5 @@ export function createEntryCorrector({api,store,enrollmentSession,writeLabel,dis
|
|
|
161
203
|
record.stage='verified';await persist();return result('verified');
|
|
162
204
|
});
|
|
163
205
|
};
|
|
206
|
+
return {correctEntry:(raw,operation)=>run('correct',raw,operation),resolveEntryCorrection:raw=>run('resolve',raw)};
|
|
164
207
|
}
|