@choiceapps/communityservices-sdk 0.6.0 → 0.8.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 +17 -0
- package/browser/enrollment-workflow.d.mts +29 -5
- package/browser/enrollment-workflow.mjs +88 -34
- package/browser/entry-correction.mjs +13 -11
- package/browser/entry-row-registration.mjs +63 -0
- package/browser/household-enrollment-discovery.mjs +49 -0
- package/browser/index.d.mts +7 -2
- package/browser/index.mjs +1 -1
- package/browser/playwright-session.mjs +24 -8
- 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/picklist-labels.d.ts +22 -0
- package/dist/cjs/picklist-labels.d.ts.map +1 -0
- package/dist/cjs/picklist-labels.js +35 -0
- package/dist/cjs/picklist-labels.js.map +1 -0
- package/dist/cjs/v2.d.ts +4 -0
- package/dist/cjs/v2.d.ts.map +1 -1
- package/dist/cjs/v2.js +5 -0
- package/dist/cjs/v2.js.map +1 -1
- package/dist/cjs/v3.d.ts +4 -0
- package/dist/cjs/v3.d.ts.map +1 -1
- package/dist/cjs/v3.js +5 -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/picklist-labels.d.ts +22 -0
- package/dist/esm/picklist-labels.d.ts.map +1 -0
- package/dist/esm/picklist-labels.js +32 -0
- package/dist/esm/picklist-labels.js.map +1 -0
- package/dist/esm/v2.d.ts +4 -0
- package/dist/esm/v2.d.ts.map +1 -1
- package/dist/esm/v2.js +3 -0
- package/dist/esm/v2.js.map +1 -1
- package/dist/esm/v3.d.ts +4 -0
- package/dist/esm/v3.d.ts.map +1 -1
- package/dist/esm/v3.js +3 -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 +50 -1
- package/docs/capabilities.md +19 -0
- package/docs/enrollments.md +41 -5
- package/docs/reference.md +3 -2
- package/docs/troubleshooting.md +17 -0
- package/docs/vendor-issues/lookup-write-labels.md +13 -0
- package/examples/README.md +3 -0
- package/examples/catalog-write-labels.mts +15 -0
- package/examples/checked-assessment-writes.mts +14 -0
- package/examples/enrollment-discovery-and-correction.mts +19 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,22 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.8.0 - 2026-11-11
|
|
4
|
+
|
|
5
|
+
- Resolve full lookup write labels without existing answers using `enrollmentSession.readPicklistOptions` and `createCatalogWriteLabelResolver`.
|
|
6
|
+
- Discover labels automatically in enrollment workflows when the configured session supports catalog reads and no explicit `writeLabel` override is supplied, including corrections and mandatory carry-forward.
|
|
7
|
+
- Match option ID, picklist ID and code; preserve full names including HUD suffixes, and reject incomplete or mismatched catalogs before writes. Read explicit option IDs in bounded batches.
|
|
8
|
+
- Read-only training verification matched 1,288 distinct catalog options to Coordinated Entry metadata from three synthetic clients, including the shared enhanced yes/no list and living-situation options. Live profile dropdowns matched the DOB-quality catalog labels. No new assessment writes were performed.
|
|
9
|
+
|
|
10
|
+
## 0.7.0 - 2026-11-10
|
|
11
|
+
|
|
12
|
+
- Add `createReadBackWriteLabelResolver` for exact observed lookup labels, including HUD suffixes. Unobserved options remain unresolved; no suffix is inferred from metadata.
|
|
13
|
+
- Expose `groupId` and `grouped` on GWT enrollment catalog rows and details without per-row reads.
|
|
14
|
+
- Add `discoverHouseholdEnrollment` and adoption from a head's client/enrollment IDs, requiring a unique verified household membership match.
|
|
15
|
+
- Add `registerEntryRows` to bind existing recordsets to adopted entry keys for in-place corrections.
|
|
16
|
+
- Add stable workflow error codes and per-operation `committed` markers, preserving uncertain prior writes and original causes.
|
|
17
|
+
- Add `writeMobileAnswersChecked` and enrollment-workflow scalar collision preflight for questions shared across instruments.
|
|
18
|
+
- 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.
|
|
19
|
+
|
|
3
20
|
## 0.6.0 - 2026-11-10
|
|
4
21
|
|
|
5
22
|
- 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.
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type {DateOfBirth} from './date-of-birth.mjs';
|
|
2
|
-
import type {Answers, AssessmentDefinition, EncodingOptions, GwtEnrollmentHelpers, Provider, WellSkyClient} from '../dist/esm/index.js';
|
|
2
|
+
import type {PicklistCatalogSnapshot, Answers, AssessmentDefinition, EncodingOptions, GwtEnrollmentHelpers, Provider, WellSkyClient} from '../dist/esm/index.js';
|
|
3
3
|
import type {UiMutationStore,EnrollmentReader} from './index.mjs';
|
|
4
4
|
import type {ProviderAssessmentItem, ProgramDiscovery} from './program-discovery.mjs';
|
|
5
5
|
import type {HouseholdExitUi,HouseholdExitDetailInput} from './household-exit.mjs';
|
|
@@ -41,6 +41,8 @@ export interface EnrollmentDiscoveryInput {
|
|
|
41
41
|
entryDate:string;
|
|
42
42
|
}
|
|
43
43
|
export interface EnrollmentWorkflowDiscovery {
|
|
44
|
+
/** Current exact label catalog when automatic browser discovery is available. */
|
|
45
|
+
picklistOptions?:PicklistCatalogSnapshot;
|
|
44
46
|
provider:Provider;
|
|
45
47
|
entry:{assessment:ProviderAssessmentItem;definition:AssessmentDefinition};
|
|
46
48
|
interim:{assessment:ProviderAssessmentItem;definition:AssessmentDefinition};
|
|
@@ -107,6 +109,19 @@ export interface AdoptHouseholdEnrollmentInput {
|
|
|
107
109
|
/** Complete linked cohort, including already exited participants. IDs are explicit, not inferred. */
|
|
108
110
|
members:Array<{clientId:number;enrollmentId:number}>;
|
|
109
111
|
}
|
|
112
|
+
export interface HouseholdEnrollmentDiscoveryInput {headClientId:number;headEnrollmentId:number;}
|
|
113
|
+
export interface HouseholdEnrollmentDiscoveryResult extends HouseholdEnrollmentDiscoveryInput {
|
|
114
|
+
householdId:number;householdSource:'verified-membership';providerId:number;type:string;
|
|
115
|
+
members:Array<{clientId:number;enrollmentId:number}>;
|
|
116
|
+
}
|
|
117
|
+
export interface AdoptHouseholdEnrollmentFromHeadInput extends HouseholdEnrollmentDiscoveryInput {key:string;}
|
|
118
|
+
export interface RegisterEntryRowsInput {
|
|
119
|
+
key:string;enrollmentKey:string;clientId:number;
|
|
120
|
+
rows:Array<{key:string;parentField:string;recordsetId:number}>;
|
|
121
|
+
}
|
|
122
|
+
export interface RegisterEntryRowsResult {
|
|
123
|
+
key:string;enrollmentKey:string;clientId:number;enrollmentId:number;outcome:'verified'|'already-present';recordsetIds:Record<string,number>;
|
|
124
|
+
}
|
|
110
125
|
export interface EnrollmentWorkflowResult {
|
|
111
126
|
key:string;
|
|
112
127
|
outcome:'verified'|'already-present';
|
|
@@ -146,12 +161,16 @@ export interface EnrollmentWorkflow {
|
|
|
146
161
|
exitEnrollment(input:ExitEnrollmentInput):Promise<ExitEnrollmentResult>;
|
|
147
162
|
/** Patch an open entry at its original timestamp, before any review or exit. */
|
|
148
163
|
correctEntry(input:EntryCorrectionInput):Promise<EntryCorrectionResult>;
|
|
164
|
+
/** Bind explicit existing row IDs to stable keys without writing HMIS records. */
|
|
165
|
+
registerEntryRows(input:RegisterEntryRowsInput):Promise<RegisterEntryRowsResult>;
|
|
166
|
+
/** Resolve a unique current household satisfying full-cohort membership/head/date checks. */
|
|
167
|
+
discoverHouseholdEnrollment(input:HouseholdEnrollmentDiscoveryInput):Promise<HouseholdEnrollmentDiscoveryResult>;
|
|
149
168
|
/** Verify unchanged state and independently confirmed rejection; keeps the attempt history. */
|
|
150
169
|
resolveEntryCorrection(input:ResolveEntryCorrectionInput):Promise<ResolveEntryCorrectionResult>;
|
|
151
170
|
/** Verify an existing standalone entry and journal it without an HMIS write. */
|
|
152
171
|
adoptEnrollment(input:AdoptEnrollmentInput):Promise<EnrollmentWorkflowResult>;
|
|
153
172
|
/** Verify all existing linked entries, original head and dates; no HMIS writes. */
|
|
154
|
-
adoptHouseholdEnrollment(input:AdoptHouseholdEnrollmentInput):Promise<HouseholdEnrollmentResult>;
|
|
173
|
+
adoptHouseholdEnrollment(input:AdoptHouseholdEnrollmentInput|AdoptHouseholdEnrollmentFromHeadInput):Promise<HouseholdEnrollmentResult>;
|
|
155
174
|
/** Reads the local journal only. A verified journal is not fresh server verification. */
|
|
156
175
|
getStatus(input:{clientId:number;key:string}):Promise<EnrollmentWorkflowStatus|null>;
|
|
157
176
|
createHouseholdEnrollment(input:HouseholdEnrollmentInput):Promise<HouseholdEnrollmentResult>;
|
|
@@ -237,17 +256,22 @@ export interface EnrollmentWorkflowOptions {
|
|
|
237
256
|
* `standard` writes them through `assessments.create` with every mandatory answer, after the mobile scalars;
|
|
238
257
|
* every mandatory question must be a lookup. An unfinished journal never reconciles in the other mode. */
|
|
239
258
|
yesNoWrites?:'mobile'|'standard';
|
|
240
|
-
/**
|
|
259
|
+
/** Explicit tenant-verified override. When omitted, a configured readPicklistOptions reader supplies exact catalog labels automatically. */
|
|
241
260
|
writeLabel?:EncodingOptions['writeLabel'];
|
|
242
261
|
}
|
|
243
262
|
export class EnrollmentWorkflowError extends Error {
|
|
244
263
|
readonly reason:string;
|
|
245
|
-
readonly code
|
|
264
|
+
readonly code:string;
|
|
265
|
+
readonly committed:false|'unknown';
|
|
266
|
+
readonly operation?:string;
|
|
267
|
+
/** Resolution-call failure does not establish the original correction's outcome. */
|
|
268
|
+
readonly originalCommitted?:'unknown';
|
|
269
|
+
readonly reauthenticated?:boolean;
|
|
246
270
|
readonly fields?:string[];
|
|
247
271
|
readonly status?:number;
|
|
248
272
|
/** Failed durable stage, when known. The original write error is available as cause in memory. */
|
|
249
273
|
readonly step?:string;
|
|
250
|
-
constructor(reason:string,options?:ErrorOptions&{step?:string;code?:string;fields?:string[];status?:number});
|
|
274
|
+
constructor(reason:string,options?:ErrorOptions&{step?:string;code?:string;fields?:string[];status?:number;committed?:false|'unknown'});
|
|
251
275
|
}
|
|
252
276
|
/** An unavailable list is never evidence that no enrollment exists. */
|
|
253
277
|
export class EnrollmentReadError extends EnrollmentWorkflowError {
|
|
@@ -2,20 +2,24 @@ import {apiScope} from './connection.mjs';
|
|
|
2
2
|
import {validateBirthDate} from './date-of-birth.mjs';
|
|
3
3
|
import {expectedExitDetails} from './exit-details.mjs';
|
|
4
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
|
-
import {readProgramCatalog,encodeAnswers,encodeNewSubassessmentRow,toItems,parseRestInstant} from '../dist/esm/index.js';
|
|
9
|
+
import {readProgramCatalog,flattenQuestions,createCatalogWriteLabelResolver,encodeAnswers,encodeNewSubassessmentRow,toItems,parseRestInstant} from '../dist/esm/index.js';
|
|
7
10
|
|
|
8
11
|
const object=v=>v!==null&&typeof v==='object'&&!Array.isArray(v);
|
|
9
12
|
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);};
|
|
16
|
+
const fail=(message,options)=>{throw new EnrollmentWorkflowError(message,{committed:false,...options});};
|
|
14
17
|
export class EnrollmentWorkflowError extends Error {
|
|
15
18
|
constructor(reason,options){
|
|
16
19
|
super(`Enrollment workflow: ${reason}`,options);this.name='EnrollmentWorkflowError';this.reason=reason;
|
|
17
20
|
if(options?.step!==undefined)this.step=options.step;
|
|
18
|
-
|
|
21
|
+
this.code=options?.code??workflowErrorCode(reason);
|
|
22
|
+
this.committed=options?.committed??'unknown';
|
|
19
23
|
if(options?.fields!==undefined)this.fields=[...options.fields];
|
|
20
24
|
const status=options?.status??options?.cause?.status;
|
|
21
25
|
if(Number.isInteger(status)&&status>=100&&status<=599)this.status=status;
|
|
@@ -68,7 +72,7 @@ function compile(definition,input,{yesNoWrites,writeLabel}={}){
|
|
|
68
72
|
const covered=new Set([...Object.keys(answers),...Object.keys(excluded),...carry,...compiledRows.map(r=>r.parentField),...compiledEnds.map(r=>r.parentField)]);
|
|
69
73
|
for(const q of definition.questions){
|
|
70
74
|
if(['blank','label'].includes(q.questionType))continue;
|
|
71
|
-
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)]});
|
|
72
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');
|
|
73
77
|
if(q.fieldName in excluded&&carry.includes(q.fieldName))fail('conflicting field coverage');
|
|
74
78
|
}
|
|
@@ -81,12 +85,12 @@ function compile(definition,input,{yesNoWrites,writeLabel}={}){
|
|
|
81
85
|
if(yesNo.length)for(const q of definition.questions){
|
|
82
86
|
if(!q.mandatory||['blank','label','sub_assessment'].includes(q.questionType)||yesNo.some(y=>y.virt_field_name===q.fieldName))continue;
|
|
83
87
|
// Only mandatory lookups were verified live; this also keeps DOB inside its own helper.
|
|
84
|
-
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)]});
|
|
85
89
|
const base={question_id:q.questionId,question_type:q.questionType,virt_field_name:q.fieldName};
|
|
86
90
|
if(q.fieldName in answers){
|
|
87
91
|
const [label]=encodeAnswers(definition.questions,{[q.fieldName]:answers[q.fieldName]},{endpoint:'standalone',writeLabel});
|
|
88
92
|
const [stored]=encodeAnswers(definition.questions,{[q.fieldName]:answers[q.fieldName]},{endpoint:'mobile'});
|
|
89
|
-
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)]});
|
|
90
94
|
mandatory.push({...base,answer_value:label.answer_value,expected:stored.answer_value});
|
|
91
95
|
}else if(q.fieldName in excluded)mandatory.push({...base,answer_value:'',expected:''});
|
|
92
96
|
else if(carry.includes(q.fieldName))mandatory.push({...base,carry:true});
|
|
@@ -111,9 +115,20 @@ async function enrollments(api,clientId,{enrollmentSession,writeAttempted=false}
|
|
|
111
115
|
/** Explicit orchestration for an existing client. All writers must share the durable store.
|
|
112
116
|
* No automatic retries of uncertain REST writes. This is not a cross-system transaction. */
|
|
113
117
|
export function createEnrollmentWorkflow({api,discovery,helpers,store,enrollmentSession,exitUi,memberEntryDatePort,birthDates,assessmentLocations={},yesNoWrites='mobile',writeLabel}){
|
|
114
|
-
|
|
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
|
+
};
|
|
115
130
|
if(typeof discovery?.getProviderAssessments!=='function'||typeof helpers?.createInterimUpdate!=='function'||!store)fail('REST API, discovery, enrollment helpers and durable store required');
|
|
116
|
-
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');
|
|
131
|
+
if(!['mobile','standard'].includes(yesNoWrites)||yesNoWrites==='standard'&&(typeof writeLabel!=='function'&&typeof enrollmentSession?.readPicklistOptions!=='function'||typeof api.assessments?.create!=='function'))fail('yesNoWrites must be mobile, or standard with writeLabel and assessments.create');
|
|
117
132
|
if(!object(assessmentLocations))fail('assessment locations must be a map by Entry/Exit type');
|
|
118
133
|
for(const [type,locations]of Object.entries(assessmentLocations)){
|
|
119
134
|
if(!entryExitTypes.includes(type))fail('unsupported Entry/Exit type in assessment locations');
|
|
@@ -123,7 +138,7 @@ export function createEnrollmentWorkflow({api,discovery,helpers,store,enrollment
|
|
|
123
138
|
const locationsByType={HUD:{entry:'showHudEntry',interim:'showHudInterim',exit:'showHudExit'},VA:{entry:'showVaEntry',interim:'showVaInterim',exit:'showVaExit'},...structuredClone(assessmentLocations)};
|
|
124
139
|
const discover=async(input,includeExit=false)=>{
|
|
125
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);
|
|
126
|
-
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'});
|
|
127
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');
|
|
128
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');
|
|
129
144
|
const configuration=await discovery.getProviderAssessments({providerId:input.providerId});if(configuration.providerId!==input.providerId)fail('provider discovery mismatch');
|
|
@@ -134,12 +149,22 @@ export function createEnrollmentWorkflow({api,discovery,helpers,store,enrollment
|
|
|
134
149
|
if(definition.tenant!==api.tenant||definition.assessmentCode!==assessment.code||definition.unresolvedPicklists.length)fail('assessment metadata incomplete');
|
|
135
150
|
resolved[phase]={assessment,definition};
|
|
136
151
|
}
|
|
137
|
-
|
|
152
|
+
let picklistOptions;
|
|
153
|
+
if(writeLabel===undefined&&typeof enrollmentSession?.readPicklistOptions==='function'){
|
|
154
|
+
const optionIds=[...new Set(Object.values(resolved).flatMap(({definition})=>flattenQuestions(definition.questions).filter(({question})=>question.questionType==='lookup').flatMap(({question})=>question.options.map(o=>o.id))))];
|
|
155
|
+
picklistOptions=await enrollmentSession.readPicklistOptions({clientId:input.clientId,optionIds});
|
|
156
|
+
if(picklistOptions?.clientId!==input.clientId)fail('picklist catalog client mismatch',{code:'PICKLIST_CATALOG_CONTEXT_MISMATCH'});
|
|
157
|
+
for(const {definition}of Object.values(resolved))createCatalogWriteLabelResolver(definition,picklistOptions);
|
|
158
|
+
}
|
|
159
|
+
return {provider:providers[0],...resolved,...(picklistOptions?{picklistOptions}:{})};
|
|
138
160
|
};
|
|
139
161
|
const scope=clientId=>JSON.stringify([tenantScope,'enrollment-workflow',clientId]);
|
|
140
162
|
const reservationScope=clientId=>JSON.stringify([tenantScope,'household-workflow-reservation',clientId]);
|
|
141
163
|
const {correctEntry,resolveEntryCorrection}=createEntryCorrector({api,store,enrollmentSession,writeLabel,discover,scope,reservationScope,makeError:(reason,options)=>new EnrollmentWorkflowError(reason,options)});
|
|
164
|
+
const registerEntryRows=createEntryRowRegistrar({api,store,enrollmentSession,discover,scope,reservationScope,makeError:(reason,options)=>new EnrollmentWorkflowError(reason,options)});
|
|
165
|
+
const discoverHouseholdEnrollment=createHouseholdEnrollmentDiscovery({api,enrollmentSession,makeError:(reason,options)=>new EnrollmentWorkflowError(reason,options)});
|
|
142
166
|
const run=async(kind,raw,internal={})=>{
|
|
167
|
+
const operation=internal.operation??{attempted:false};
|
|
143
168
|
const standaloneExit=kind==='exit'&&internal.standaloneExit===true;
|
|
144
169
|
fields(raw,kind==='entry'?['key','clientId','providerId','type','entryDate','assessment']:['key','enrollmentKey','clientId',kind==='exit'?'exitDate':'reviewDate','assessment',...(standaloneExit?['destination','reasonForLeaving','destinationDetails','reasonForLeavingOther']:[])]);
|
|
145
170
|
const input=structuredClone(raw);key(input.key);if(!positive(input.clientId))fail('client ID required');
|
|
@@ -153,7 +178,9 @@ export function createEnrollmentWorkflow({api,discovery,helpers,store,enrollment
|
|
|
153
178
|
return locked(scope(input.clientId),async()=>{
|
|
154
179
|
const reservations=await store.withLock(reservationScope(input.clientId),()=>store.read(reservationScope(input.clientId)));
|
|
155
180
|
if(!Array.isArray(reservations)||reservations.some(r=>r.stage!=='verified'&&r.owner!==internal.owner))fail('unfinished household workflow must be reconciled first');
|
|
181
|
+
const inheritedAttempt=operation.attempted;operation.attempted=true;
|
|
156
182
|
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');
|
|
183
|
+
operation.attempted=inheritedAttempt||records.some(r=>r.key===input.key&&Object.keys(r.steps).length>0);
|
|
157
184
|
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');
|
|
158
185
|
const matches=records.filter(r=>r.key===input.key);if(matches.length>1)fail('duplicate journal keys');let record=matches[0];
|
|
159
186
|
const intentHash=hash({kind,input,...(internal.owner?{household:internal.owner}:{})});if(record&&record.intentHash!==intentHash)fail('key conflicts with previous intent');
|
|
@@ -190,7 +217,8 @@ export function createEnrollmentWorkflow({api,discovery,helpers,store,enrollment
|
|
|
190
217
|
const definition=discovered[kind].definition,code=definition.assessmentCode,at=kind==='entry'?input.entryDate:(input.exitDate??input.reviewDate);
|
|
191
218
|
const exitSchemaHash=kind==='exit'?hash({assessment:discovered.exit.assessment,questions:definition.questions}):undefined;
|
|
192
219
|
if(record&&kind==='exit'&&record.exitSchemaHash!==exitSchemaHash)fail('exit assessment configuration changed');
|
|
193
|
-
const
|
|
220
|
+
const operationWriteLabel=writeLabel??(discovered.picklistOptions?createCatalogWriteLabelResolver(definition,discovered.picklistOptions):undefined);
|
|
221
|
+
const plan=compile(definition,input.assessment,{yesNoWrites,writeLabel:operationWriteLabel});
|
|
194
222
|
const carriesBirthDate=plan.carry.includes('SVPPROFDOB'),hasBirthDate=plan.dateOfBirth!==undefined||carriesBirthDate;
|
|
195
223
|
let carriedBirthDate;
|
|
196
224
|
if(hasBirthDate){
|
|
@@ -226,6 +254,17 @@ export function createEnrollmentWorkflow({api,discovery,helpers,store,enrollment
|
|
|
226
254
|
// that instant; one second later returns them. Only the yes/no step needs this.
|
|
227
255
|
const readSaved=async()=>questions(await api.assessments.get({path:{clientId:input.clientId,assessmentCode:code},query:{dateEffective:new Date(Date.parse(at)+1000).toISOString()}}));
|
|
228
256
|
const initial=await read();
|
|
257
|
+
if(!record&&plan.scalar.length){
|
|
258
|
+
const saved=await readSaved();
|
|
259
|
+
const existing=plan.scalar.map(q=>({q,row:saved.find(r=>r.virt_field_name===q.virt_field_name)}));
|
|
260
|
+
const complete=existing.every(({q,row})=>row&&sameAnswer(q,row.answer_value)&&restDate(row.date_effective)===restDate(at));
|
|
261
|
+
if(!complete){
|
|
262
|
+
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);
|
|
263
|
+
if(unavailable.length)fail('mobile scalar preflight unavailable',{code:'MOBILE_PREFLIGHT_UNAVAILABLE',fields:unavailable});
|
|
264
|
+
const collisions=existing.filter(({row})=>restDate(row.date_effective)===restDate(at)).map(({q})=>q.virt_field_name);
|
|
265
|
+
if(collisions.length)fail('mobile scalar answer already exists at the effective date',{code:'MOBILE_ANSWER_EXISTS',fields:collisions});
|
|
266
|
+
}
|
|
267
|
+
}
|
|
229
268
|
if(expectedHead===input.clientId&&headQuestion)checkHeadAnswer(proposedHead?.answer_value??initial.find(q=>q.virt_field_name==='HUD_RELATIONTOHOH')?.answer_value);
|
|
230
269
|
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');}
|
|
231
270
|
// Resend mandatory answers with the standard yes/no write. A blank write must not hide a recorded value.
|
|
@@ -235,12 +274,12 @@ export function createEnrollmentWorkflow({api,discovery,helpers,store,enrollment
|
|
|
235
274
|
const recorded=initial.find(q=>q.virt_field_name===m.virt_field_name)?.answer_value;
|
|
236
275
|
if(m.carry){
|
|
237
276
|
const question=definition.questions.find(q=>q.fieldName===m.virt_field_name),options=question.options.filter(o=>String(o.id)===String(recorded));
|
|
238
|
-
const label=options.length===1?
|
|
239
|
-
if(!label)fail(`carried mandatory answer ${m.virt_field_name} has no verified write label
|
|
277
|
+
const label=options.length===1?operationWriteLabel?.(question,options[0]):undefined;
|
|
278
|
+
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]});
|
|
240
279
|
if(m.virt_field_name==='HUD_RELATIONTOHOH')checkHeadAnswer(recorded);
|
|
241
280
|
return {question_id:m.question_id,question_type:m.question_type,virt_field_name:m.virt_field_name,answer_value:label,expected:String(recorded)};
|
|
242
281
|
}
|
|
243
|
-
if(m.expected===''&&recorded!==null&&recorded!==undefined&&recorded!=='')fail(`not-applicable mandatory field ${m.virt_field_name} has a recorded value; answer it explicitly
|
|
282
|
+
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]});
|
|
244
283
|
return m;
|
|
245
284
|
});
|
|
246
285
|
// Validate closure targets before creating enrollment/review or submitting any answers.
|
|
@@ -274,6 +313,7 @@ export function createEnrollmentWorkflow({api,discovery,helpers,store,enrollment
|
|
|
274
313
|
if(previous)throw new EnrollmentWorkflowError(previous.state==='verified'?'verified step changed':'uncertain write requires reconciliation',{step:name});
|
|
275
314
|
record.steps[name]={state:'attempted'};await persist();
|
|
276
315
|
let writeError;
|
|
316
|
+
operation.attempted=true;
|
|
277
317
|
try{await submit(result);}catch(error){writeError=error;}
|
|
278
318
|
// A write response alone never establishes success or absence. Preserve its
|
|
279
319
|
// diagnostic cause in memory while reconciliation remains authoritative.
|
|
@@ -299,6 +339,7 @@ export function createEnrollmentWorkflow({api,discovery,helpers,store,enrollment
|
|
|
299
339
|
else {const found=await findEnrollment();if(!found.found)fail('saved enrollment missing');record.enrollmentId=entry.enrollmentId;}
|
|
300
340
|
await persist();
|
|
301
341
|
if(kind==='interim'){
|
|
342
|
+
if(!internal.readReview)operation.attempted=true;
|
|
302
343
|
// The GWT helper has its own journal and authoritative reads; it owns uncertain-write reconciliation.
|
|
303
344
|
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});
|
|
304
345
|
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();
|
|
@@ -361,7 +402,7 @@ export function createEnrollmentWorkflow({api,discovery,helpers,store,enrollment
|
|
|
361
402
|
const known=record.steps.birthDate.value;
|
|
362
403
|
result=await birthDates.verify({...dobContext,dateOfBirth:known.dateOfBirth,effectiveDate:known.effectiveDate,allowLater:true});
|
|
363
404
|
}else if(plan.dateOfBirth!==undefined){
|
|
364
|
-
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}}:{})});
|
|
405
|
+
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}}:{})});
|
|
365
406
|
}else result=await birthDates.verify({...dobContext,dateOfBirth:carriedBirthDate.dateOfBirth,effectiveDate:carriedBirthDate.effectiveDate,allowLater:true});
|
|
366
407
|
record.steps.birthDate={state:'verified',value:{clientId:input.clientId,dateOfBirth:result.dateOfBirth,effectiveDate:result.effectiveDate}};await persist();
|
|
367
408
|
}
|
|
@@ -373,6 +414,11 @@ export function createEnrollmentWorkflow({api,discovery,helpers,store,enrollment
|
|
|
373
414
|
const memberKey=(key,clientId)=>'household:'+hash({key,clientId});
|
|
374
415
|
const withClients=(ids,work,index=0)=>index===ids.length?work():store.withLock(scope(ids[index]),()=>withClients(ids,work,index+1));
|
|
375
416
|
const adopt=async(household,raw)=>{
|
|
417
|
+
if(household&&raw&&Object.hasOwn(raw,'headEnrollmentId')){
|
|
418
|
+
fields(raw,['key','headClientId','headEnrollmentId']);key(raw.key);
|
|
419
|
+
const found=await discoverHouseholdEnrollment({headClientId:raw.headClientId,headEnrollmentId:raw.headEnrollmentId});
|
|
420
|
+
raw={key:raw.key,headClientId:found.headClientId,householdId:found.householdId,members:found.members};
|
|
421
|
+
}
|
|
376
422
|
fields(raw,household?['key','householdId','headClientId','members']:['key','clientId','enrollmentId']);
|
|
377
423
|
const input=structuredClone(raw);key(input.key);
|
|
378
424
|
if(typeof enrollmentSession?.readEnrollment!=='function')fail('authoritative enrollment session required for adoption');
|
|
@@ -448,7 +494,7 @@ export function createEnrollmentWorkflow({api,discovery,helpers,store,enrollment
|
|
|
448
494
|
});
|
|
449
495
|
});
|
|
450
496
|
};
|
|
451
|
-
const householdRun=async(kind,raw)=>{
|
|
497
|
+
const householdRun=async(kind,raw,operation={attempted:false})=>{
|
|
452
498
|
if(typeof enrollmentSession?.readEnrollment!=='function'||typeof helpers.addHouseholdMembers!=='function'||typeof api.households?.get!=='function')fail('household API and authoritative enrollment session required');
|
|
453
499
|
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']:[])]);
|
|
454
500
|
const input=structuredClone(raw);key(input.key);
|
|
@@ -463,8 +509,10 @@ export function createEnrollmentWorkflow({api,discovery,helpers,store,enrollment
|
|
|
463
509
|
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);}
|
|
464
510
|
// Read the cohort under its journal lock, then acquire all client locks in order.
|
|
465
511
|
// Recheck under both locks so concurrent membership growth cannot escape protection.
|
|
512
|
+
operation.attempted=true;
|
|
466
513
|
const prior=await store.withLock(householdScope(input.headClientId),()=>store.read(householdScope(input.headClientId)));
|
|
467
514
|
if(!Array.isArray(prior))fail('invalid household journal');
|
|
515
|
+
operation.attempted=prior.some(r=>r.key===input.key);
|
|
468
516
|
const lockedIds=[...new Set([input.headClientId,...ids,...prior.flatMap(r=>r.clientIds??[])])].sort((a,b)=>a-b);
|
|
469
517
|
return withClients(lockedIds,()=>store.withLock(householdScope(input.headClientId),async()=>{
|
|
470
518
|
const records=await store.read(householdScope(input.headClientId));
|
|
@@ -532,7 +580,7 @@ export function createEnrollmentWorkflow({api,discovery,helpers,store,enrollment
|
|
|
532
580
|
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};
|
|
533
581
|
// Validate every member before any record mutation. The same client locks cover
|
|
534
582
|
// preflight and execution, including overlaps with the single-client workflow.
|
|
535
|
-
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}:{})}));
|
|
583
|
+
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}:{})}));
|
|
536
584
|
if(kind==='addition'&&record?.entrySchemas&&canonical(record.entrySchemas)!==canonical(preflight))fail('additional member assessment configuration changed');
|
|
537
585
|
if(kind==='exit'&&record?.exitSchemas&&canonical(record.exitSchemas)!==canonical(preflight))fail('exit assessment configuration changed');
|
|
538
586
|
const wasVerified=record?.stage==='verified';
|
|
@@ -580,14 +628,14 @@ export function createEnrollmentWorkflow({api,discovery,helpers,store,enrollment
|
|
|
580
628
|
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');
|
|
581
629
|
}
|
|
582
630
|
const head=input.members.find(m=>m.clientId===input.headClientId);
|
|
583
|
-
const result=await run('entry',toMember(head),{locked:true,owner,headClientId:input.headClientId,findEnrollment:findMember(head.clientId)});record.members[head.clientId]=result;await persist();
|
|
631
|
+
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();
|
|
584
632
|
// Reject unexpected members before the attachment helper is allowed to write.
|
|
585
633
|
await readTarget({clientId:head.clientId,enrollmentId:result.enrollmentId},{partial:!record.attached});
|
|
586
634
|
record.attachmentAttempted=true;await persist();
|
|
587
|
-
const attached=await helpers.addHouseholdMembers({clientId:head.clientId,enrollmentId:result.enrollmentId,memberClientIds:ids.filter(id=>id!==head.clientId)});
|
|
635
|
+
operation.attempted=true;const attached=await helpers.addHouseholdMembers({clientId:head.clientId,enrollmentId:result.enrollmentId,memberClientIds:ids.filter(id=>id!==head.clientId)});
|
|
588
636
|
const snapshot=validateSnapshot(attached.snapshot,{clientId:head.clientId,enrollmentId:result.enrollmentId});
|
|
589
637
|
for(const m of snapshot.members)record.members[m.clientId]??={clientId:m.clientId,enrollmentId:m.enrollmentId};record.attached=true;await persist();
|
|
590
|
-
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();}
|
|
638
|
+
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();}
|
|
591
639
|
}else if(kind==='addition'){
|
|
592
640
|
if(!record.anchor){
|
|
593
641
|
for(const clientId of Object.keys(catalog).map(Number).sort((a,b)=>a-b)){
|
|
@@ -601,7 +649,7 @@ export function createEnrollmentWorkflow({api,discovery,helpers,store,enrollment
|
|
|
601
649
|
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');
|
|
602
650
|
}
|
|
603
651
|
record.attachmentAttempted=true;await persist();
|
|
604
|
-
const attached=await helpers.addHouseholdMembers({...record.anchor,memberClientIds:ids});
|
|
652
|
+
operation.attempted=true;const attached=await helpers.addHouseholdMembers({...record.anchor,memberClientIds:ids});
|
|
605
653
|
validateSnapshot(attached.snapshot,record.anchor);
|
|
606
654
|
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};}
|
|
607
655
|
record.attached=true;await persist();await preserve();
|
|
@@ -619,13 +667,13 @@ export function createEnrollmentWorkflow({api,discovery,helpers,store,enrollment
|
|
|
619
667
|
const change={...target,providerId:context.providerId,type:context.type,entryDate:input.entryDate,cohort};
|
|
620
668
|
await memberEntryDatePort.validateMemberEntryDate(change);
|
|
621
669
|
record.entryDates[clientId]='attempted';record.entryDateInputs[clientId]=change;await persist();
|
|
622
|
-
try{await memberEntryDatePort.saveMemberEntryDate(change);}catch{/* Resolve only through readback; never replay the date update. */}
|
|
670
|
+
operation.attempted=true;try{await memberEntryDatePort.saveMemberEntryDate(change);}catch{/* Resolve only through readback; never replay the date update. */}
|
|
623
671
|
}
|
|
624
672
|
if(record.entryDates[clientId]==='attempted'&&record.entryDateInputs[clientId])await memberEntryDatePort.validateMemberEntryDate(record.entryDateInputs[clientId]);
|
|
625
673
|
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');
|
|
626
674
|
await preserve();record.entryDates[clientId]='verified';await persist();
|
|
627
675
|
}
|
|
628
|
-
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();}
|
|
676
|
+
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();}
|
|
629
677
|
}else if(kind==='interim'){
|
|
630
678
|
// Verify all linked enrollments before making the single household review call.
|
|
631
679
|
const existingReviews=[];
|
|
@@ -640,7 +688,7 @@ export function createEnrollmentWorkflow({api,discovery,helpers,store,enrollment
|
|
|
640
688
|
if(existingReviews.some(Boolean)&&existingReviews.some(r=>!r))fail('partial household review requires reconciliation');
|
|
641
689
|
if(existingReviews[0]&&new Set(existingReviews.map(r=>r.reviewId)).size!==ids.length)fail('duplicate household review IDs');
|
|
642
690
|
const target={clientId:ids[0],enrollmentId:catalog[ids[0]].enrollmentId};
|
|
643
|
-
const review=await helpers.createInterimUpdate({...target,clientIds:ids,reviewDate:input.reviewDate});validateSnapshot(review.snapshot,target);
|
|
691
|
+
operation.attempted=true;const review=await helpers.createInterimUpdate({...target,clientIds:ids,reviewDate:input.reviewDate});validateSnapshot(review.snapshot,target);
|
|
644
692
|
const readReview=async target=>{
|
|
645
693
|
const snapshot=await readTarget(target),reviews=snapshot.reviews.filter(r=>r.type==='Update'&&restDate(r.reviewDate)===restDate(input.reviewDate));
|
|
646
694
|
if(reviews.length!==1||!positive(reviews[0].reviewId)||canonical([...reviews[0].clientIds].sort((a,b)=>a-b))!==canonical(ids))fail('household review membership mismatch');
|
|
@@ -650,7 +698,7 @@ export function createEnrollmentWorkflow({api,discovery,helpers,store,enrollment
|
|
|
650
698
|
// Pin every member's distinct review ID before writing any member's answers.
|
|
651
699
|
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();}
|
|
652
700
|
if(new Set(ids.map(id=>record.members[id].reviewId)).size!==ids.length)fail('duplicate household review IDs');
|
|
653
|
-
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();}
|
|
701
|
+
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();}
|
|
654
702
|
}else if(kind==='exit'){
|
|
655
703
|
const target={clientId:ids[0],enrollmentId:catalog[ids[0]].enrollmentId};
|
|
656
704
|
const normalizeLabel=value=>typeof value==='string'?value.replace(/\s*\(HUD\)\s*$/,'').trim():value;
|
|
@@ -681,7 +729,7 @@ export function createEnrollmentWorkflow({api,discovery,helpers,store,enrollment
|
|
|
681
729
|
const exitInput={...target,clientIds:ids,exitDate:input.exitDate,destination:input.destination,reasonForLeaving:input.reasonForLeaving,...(input.destinationDetails?{destinationDetails:input.destinationDetails}:{}),...(input.reasonForLeavingOther?{reasonForLeavingOther:input.reasonForLeavingOther}:{})};
|
|
682
730
|
await exitUi.validateExit(exitInput);
|
|
683
731
|
record.exitStep='attempted';await persist();
|
|
684
|
-
try{await exitUi.saveExit(exitInput);}catch{/* Only authoritative readback establishes success. Never replay an uncertain save. */}
|
|
732
|
+
operation.attempted=true;try{await exitUi.saveExit(exitInput);}catch{/* Only authoritative readback establishes success. Never replay an uncertain save. */}
|
|
685
733
|
observed=await inspect();if(!observed.found)fail('uncertain household exit requires reconciliation');
|
|
686
734
|
}
|
|
687
735
|
await validateHeadCoverage();await preserve();
|
|
@@ -689,7 +737,7 @@ export function createEnrollmentWorkflow({api,discovery,helpers,store,enrollment
|
|
|
689
737
|
const readExit=async target=>{
|
|
690
738
|
const result=await inspect();if(!result.found)fail('verified exit missing');return result.exits.find(e=>e.clientId===target.clientId&&e.enrollmentId===target.enrollmentId);
|
|
691
739
|
};
|
|
692
|
-
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();}
|
|
740
|
+
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();}
|
|
693
741
|
}
|
|
694
742
|
if(kind==='exit'||kind==='addition')await preserve();
|
|
695
743
|
await validateHeadCoverage();
|
|
@@ -698,13 +746,16 @@ export function createEnrollmentWorkflow({api,discovery,helpers,store,enrollment
|
|
|
698
746
|
}));
|
|
699
747
|
};
|
|
700
748
|
return {
|
|
701
|
-
correctEntry,
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
749
|
+
correctEntry:input=>execute('correctEntry',operation=>correctEntry(input,operation)),
|
|
750
|
+
registerEntryRows:input=>execute('registerEntryRows',()=>registerEntryRows(input)),
|
|
751
|
+
discoverHouseholdEnrollment:input=>execute('discoverHouseholdEnrollment',()=>discoverHouseholdEnrollment(input)),
|
|
752
|
+
resolveEntryCorrection:input=>execute('resolveEntryCorrection',()=>resolveEntryCorrection(input)),
|
|
753
|
+
adoptEnrollment:input=>execute('adoptEnrollment',()=>adopt(false,input)),adoptHouseholdEnrollment:input=>execute('adoptHouseholdEnrollment',()=>adopt(true,input)),
|
|
754
|
+
addHouseholdMembers:input=>execute('addHouseholdMembers',operation=>householdRun('addition',input,operation)),
|
|
755
|
+
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)),
|
|
756
|
+
discoverExit:input=>execute('discoverExit',()=>discover(input,true)),
|
|
707
757
|
async getHouseholdStatus(input){
|
|
758
|
+
return execute('getHouseholdStatus',async()=>{
|
|
708
759
|
fields(input,['headClientId','key']);if(!positive(input.headClientId))fail('head client ID required');key(input.key);
|
|
709
760
|
return store.withLock(householdScope(input.headClientId),async()=>{
|
|
710
761
|
const records=await store.read(householdScope(input.headClientId));if(!Array.isArray(records))fail('invalid household journal');
|
|
@@ -712,9 +763,11 @@ export function createEnrollmentWorkflow({api,discovery,helpers,store,enrollment
|
|
|
712
763
|
if(record.version!==1||!object(record.members))fail('invalid household journal');
|
|
713
764
|
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]??{})}))};
|
|
714
765
|
});
|
|
766
|
+
});
|
|
715
767
|
},
|
|
716
|
-
discover,createEnrollment:input=>run('entry',input),createInterim:input=>run('interim',input),exitEnrollment:input=>run('exit',input,{standaloneExit:true}),
|
|
768
|
+
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})),
|
|
717
769
|
async getStatus(input){
|
|
770
|
+
return execute('getStatus',async()=>{
|
|
718
771
|
fields(input,['clientId','key']);if(!positive(input.clientId))fail('client ID required');key(input.key);
|
|
719
772
|
return store.withLock(scope(input.clientId),async()=>{
|
|
720
773
|
const records=await store.read(scope(input.clientId));if(!Array.isArray(records))fail('invalid workflow journal');
|
|
@@ -722,6 +775,7 @@ export function createEnrollmentWorkflow({api,discovery,helpers,store,enrollment
|
|
|
722
775
|
if(r.version!==1||!object(r.steps))fail('invalid workflow journal');
|
|
723
776
|
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)}:{})}))};
|
|
724
777
|
});
|
|
778
|
+
});
|
|
725
779
|
},
|
|
726
780
|
};
|
|
727
781
|
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import {createHash} from 'node:crypto';
|
|
2
|
-
import {encodeAnswers,parseRestInstant,toItems} from '../dist/esm/index.js';
|
|
2
|
+
import {createCatalogWriteLabelResolver,encodeAnswers,parseRestInstant,toItems} from '../dist/esm/index.js';
|
|
3
3
|
|
|
4
4
|
const object=v=>v!==null&&typeof v==='object'&&!Array.isArray(v);
|
|
5
5
|
const canonical=v=>JSON.stringify(v,(_,x)=>object(x)?Object.fromEntries(Object.keys(x).sort().map(k=>[k,x[k]])):x);
|
|
@@ -24,7 +24,7 @@ export function createEntryCorrector({api,store,enrollmentSession,writeLabel,dis
|
|
|
24
24
|
if(type==='int'||type==='money'){const n=Number(v);if(!Number.isFinite(n))fail('invalid stored correction number');return n;}
|
|
25
25
|
return String(v);
|
|
26
26
|
};
|
|
27
|
-
const encode=(questions,answers,clear,endpoint)=>{
|
|
27
|
+
const encode=(questions,answers,clear,endpoint,writeLabel)=>{
|
|
28
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');
|
|
29
29
|
const index=new Map(questions.map(q=>[q.fieldName,q]));
|
|
30
30
|
const unsupportedClears=clear.filter(field=>index.get(field)?.questionType==='yes_no');
|
|
@@ -50,7 +50,7 @@ export function createEntryCorrector({api,store,enrollmentSession,writeLabel,dis
|
|
|
50
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');
|
|
51
51
|
return rows;
|
|
52
52
|
};
|
|
53
|
-
const run=async(mode,raw)=>{
|
|
53
|
+
const run=async(mode,raw,operation={attempted:false})=>{
|
|
54
54
|
const resolving=mode==='resolve';
|
|
55
55
|
fields(raw,resolving?['key','enrollmentKey','clientId','evidenceReference','confirmNotApplied']:['key','enrollmentKey','clientId','answers','clear','subassessments']);
|
|
56
56
|
const {confirmNotApplied,...serializable}=raw;
|
|
@@ -61,8 +61,10 @@ export function createEntryCorrector({api,store,enrollmentSession,writeLabel,dis
|
|
|
61
61
|
return store.withLock(scope(input.clientId),async()=>{
|
|
62
62
|
const reservations=await store.withLock(reservationScope(input.clientId),()=>store.read(reservationScope(input.clientId)));
|
|
63
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;
|
|
64
65
|
const records=await store.read(scope(input.clientId));
|
|
65
66
|
if(!Array.isArray(records)||records.some(r=>!object(r)||r.version!==1||!object(r.steps)))fail('invalid workflow journal');
|
|
67
|
+
if(!resolving)operation.attempted=records.some(r=>r.key===input.key&&Object.keys(r.steps).length>0);
|
|
66
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');
|
|
67
69
|
const entries=records.filter(r=>r.kind==='entry'&&r.key===input.enrollmentKey&&r.stage==='verified');
|
|
68
70
|
if(entries.length!==1)fail('verified enrollment workflow required');const entry=entries[0];
|
|
@@ -92,7 +94,7 @@ export function createEntryCorrector({api,store,enrollmentSession,writeLabel,dis
|
|
|
92
94
|
const discovered=await discover({clientId:input.clientId,providerId,type,entryDate:at});
|
|
93
95
|
const schemaHash=hash({providerId:discovered.provider.providerId,entry:{assessment:discovered.entry.assessment,questions:discovered.entry.definition.questions},interim:{assessment:discovered.interim.assessment,questions:discovered.interim.definition.questions}});
|
|
94
96
|
if(schemaHash!==entry.schemaHash||record&&schemaHash!==record.schemaHash)fail('program assessment configuration changed');
|
|
95
|
-
const definition=discovered.entry.definition,code=definition.assessmentCode;
|
|
97
|
+
const definition=discovered.entry.definition,operationWriteLabel=writeLabel??(discovered.picklistOptions?createCatalogWriteLabelResolver(discovered.entry.definition,discovered.picklistOptions):undefined),code=definition.assessmentCode;
|
|
96
98
|
const snapshot=async()=>{
|
|
97
99
|
const raw=await readQuestions(input.clientId,code,readAt),current=await readQuestions(input.clientId,code,new Date(Math.max(Date.now(),parseRestInstant(readAt))).toISOString());
|
|
98
100
|
const scalars={},rows={};
|
|
@@ -143,7 +145,7 @@ export function createEntryCorrector({api,store,enrollmentSession,writeLabel,dis
|
|
|
143
145
|
if(!record){
|
|
144
146
|
readOverrides(observed);
|
|
145
147
|
const answers=input.answers??{},clear=input.clear??[];
|
|
146
|
-
const scalarBody=encode(definition.questions,answers,clear,'standalone'),scalarExpected=expected(definition.questions,scalarBody,answers);
|
|
148
|
+
const scalarBody=encode(definition.questions,answers,clear,'standalone',operationWriteLabel),scalarExpected=expected(definition.questions,scalarBody,answers);
|
|
147
149
|
// The standard endpoint requires every mandatory scalar, even if this patch leaves it unchanged.
|
|
148
150
|
if(scalarBody.length)for(const q of definition.questions.filter(q=>q.mandatory&&!['label','blank','sub_assessment'].includes(q.questionType)&&!scalarBody.some(c=>c.virt_field_name===q.fieldName))){
|
|
149
151
|
if(q.fieldName==='SVPPROFDOB'||!scalarTypes.has(q.questionType))fail('unsupported mandatory correction field');
|
|
@@ -151,10 +153,10 @@ export function createEntryCorrector({api,store,enrollmentSession,writeLabel,dis
|
|
|
151
153
|
if(prior?.answer_value===null||prior?.answer_value===undefined||prior?.answer_value==='')fail('mandatory correction field requires an explicit answer or clear');
|
|
152
154
|
let body;
|
|
153
155
|
if(q.questionType==='lookup'){
|
|
154
|
-
const options=q.options.filter(o=>String(o.id)===String(prior.answer_value)),label=options.length===1?
|
|
156
|
+
const options=q.options.filter(o=>String(o.id)===String(prior.answer_value)),label=options.length===1?operationWriteLabel?.(q,options[0]):undefined;
|
|
155
157
|
if(!label)fail('mandatory correction lookup label unavailable');
|
|
156
158
|
body=[{question_id:q.questionId,question_type:q.questionType,virt_field_name:q.fieldName,answer_value:label}];
|
|
157
|
-
}else body=encodeAnswers([q],{[q.fieldName]:{value:prior.answer_value}},{endpoint:'standalone',writeLabel});
|
|
159
|
+
}else body=encodeAnswers([q],{[q.fieldName]:{value:prior.answer_value}},{endpoint:'standalone',writeLabel:operationWriteLabel});
|
|
158
160
|
if(body.length!==1)fail('mandatory correction field could not be encoded');scalarBody.push(body[0]);
|
|
159
161
|
scalarExpected.push({...body[0],answer_value:prior.answer_value});
|
|
160
162
|
}
|
|
@@ -162,8 +164,8 @@ export function createEntryCorrector({api,store,enrollmentSession,writeLabel,dis
|
|
|
162
164
|
for(const patch of input.subassessments??[]){
|
|
163
165
|
fields(patch,['key','parentField','answers','clear']);key(patch.key);
|
|
164
166
|
const id=entry.rows[patch.key],parent=definition.questions.find(q=>q.fieldName===patch.parentField&&q.questionType==='sub_assessment');
|
|
165
|
-
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);
|
|
166
|
-
const cells=encode(parent.children,patch.answers??{},patch.clear??[],'mobile');if(!cells.length)fail('empty row correction');
|
|
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);
|
|
168
|
+
const cells=encode(parent.children,patch.answers??{},patch.clear??[],'mobile',operationWriteLabel);if(!cells.length)fail('empty row correction');
|
|
167
169
|
rowPlans.push({id,parentField:parent.fieldName,questionId:parent.questionId,cells});
|
|
168
170
|
}
|
|
169
171
|
if(!scalarBody.length&&!rowPlans.length)fail('empty entry correction');
|
|
@@ -184,7 +186,7 @@ export function createEntryCorrector({api,store,enrollmentSession,writeLabel,dis
|
|
|
184
186
|
if(record.steps[name])throw uncertain();
|
|
185
187
|
if(!await inspect(null))fail('entry changed since correction preflight');
|
|
186
188
|
await verifyEnrollment();record.steps[name]={state:'attempted'};await persist();let cause;
|
|
187
|
-
try{await submit();}catch(e){
|
|
189
|
+
operation.attempted=true;try{await submit();}catch(e){
|
|
188
190
|
cause=e;const failure=correctionFailureDetails(e);
|
|
189
191
|
if(Object.keys(failure).length){record.steps[name].failure=failure;await persist();}
|
|
190
192
|
}
|
|
@@ -201,5 +203,5 @@ export function createEntryCorrector({api,store,enrollmentSession,writeLabel,dis
|
|
|
201
203
|
record.stage='verified';await persist();return result('verified');
|
|
202
204
|
});
|
|
203
205
|
};
|
|
204
|
-
return {correctEntry:raw=>run('correct',raw),resolveEntryCorrection:raw=>run('resolve',raw)};
|
|
206
|
+
return {correctEntry:(raw,operation)=>run('correct',raw,operation),resolveEntryCorrection:raw=>run('resolve',raw)};
|
|
205
207
|
}
|