@choiceapps/communityservices-sdk 0.6.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 +10 -0
- package/browser/enrollment-workflow.d.mts +25 -3
- package/browser/enrollment-workflow.mjs +75 -29
- package/browser/entry-correction.mjs +6 -4
- 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 +31 -0
- package/docs/capabilities.md +13 -0
- package/docs/enrollments.md +38 -4
- package/docs/reference.md +3 -2
- package/docs/troubleshooting.md +17 -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/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,15 @@
|
|
|
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
|
+
|
|
3
13
|
## 0.6.0 - 2026-11-10
|
|
4
14
|
|
|
5
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.
|
|
@@ -107,6 +107,19 @@ export interface AdoptHouseholdEnrollmentInput {
|
|
|
107
107
|
/** Complete linked cohort, including already exited participants. IDs are explicit, not inferred. */
|
|
108
108
|
members:Array<{clientId:number;enrollmentId:number}>;
|
|
109
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
|
+
}
|
|
110
123
|
export interface EnrollmentWorkflowResult {
|
|
111
124
|
key:string;
|
|
112
125
|
outcome:'verified'|'already-present';
|
|
@@ -146,12 +159,16 @@ export interface EnrollmentWorkflow {
|
|
|
146
159
|
exitEnrollment(input:ExitEnrollmentInput):Promise<ExitEnrollmentResult>;
|
|
147
160
|
/** Patch an open entry at its original timestamp, before any review or exit. */
|
|
148
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>;
|
|
149
166
|
/** Verify unchanged state and independently confirmed rejection; keeps the attempt history. */
|
|
150
167
|
resolveEntryCorrection(input:ResolveEntryCorrectionInput):Promise<ResolveEntryCorrectionResult>;
|
|
151
168
|
/** Verify an existing standalone entry and journal it without an HMIS write. */
|
|
152
169
|
adoptEnrollment(input:AdoptEnrollmentInput):Promise<EnrollmentWorkflowResult>;
|
|
153
170
|
/** Verify all existing linked entries, original head and dates; no HMIS writes. */
|
|
154
|
-
adoptHouseholdEnrollment(input:AdoptHouseholdEnrollmentInput):Promise<HouseholdEnrollmentResult>;
|
|
171
|
+
adoptHouseholdEnrollment(input:AdoptHouseholdEnrollmentInput|AdoptHouseholdEnrollmentFromHeadInput):Promise<HouseholdEnrollmentResult>;
|
|
155
172
|
/** Reads the local journal only. A verified journal is not fresh server verification. */
|
|
156
173
|
getStatus(input:{clientId:number;key:string}):Promise<EnrollmentWorkflowStatus|null>;
|
|
157
174
|
createHouseholdEnrollment(input:HouseholdEnrollmentInput):Promise<HouseholdEnrollmentResult>;
|
|
@@ -242,12 +259,17 @@ export interface EnrollmentWorkflowOptions {
|
|
|
242
259
|
}
|
|
243
260
|
export class EnrollmentWorkflowError extends Error {
|
|
244
261
|
readonly reason:string;
|
|
245
|
-
readonly code
|
|
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;
|
|
246
268
|
readonly fields?:string[];
|
|
247
269
|
readonly status?:number;
|
|
248
270
|
/** Failed durable stage, when known. The original write error is available as cause in memory. */
|
|
249
271
|
readonly step?:string;
|
|
250
|
-
constructor(reason:string,options?:ErrorOptions&{step?:string;code?:string;fields?:string[];status?:number});
|
|
272
|
+
constructor(reason:string,options?:ErrorOptions&{step?:string;code?:string;fields?:string[];status?:number;committed?:false|'unknown'});
|
|
251
273
|
}
|
|
252
274
|
/** An unavailable list is never evidence that no enrollment exists. */
|
|
253
275
|
export class EnrollmentReadError extends EnrollmentWorkflowError {
|
|
@@ -2,6 +2,9 @@ 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
9
|
import {readProgramCatalog,encodeAnswers,encodeNewSubassessmentRow,toItems,parseRestInstant} from '../dist/esm/index.js';
|
|
7
10
|
|
|
@@ -10,12 +13,13 @@ 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,7 +115,18 @@ 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
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');
|
|
117
132
|
if(!object(assessmentLocations))fail('assessment locations must be a map by Entry/Exit type');
|
|
@@ -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');
|
|
@@ -139,7 +154,10 @@ export function createEnrollmentWorkflow({api,discovery,helpers,store,enrollment
|
|
|
139
154
|
const scope=clientId=>JSON.stringify([tenantScope,'enrollment-workflow',clientId]);
|
|
140
155
|
const reservationScope=clientId=>JSON.stringify([tenantScope,'household-workflow-reservation',clientId]);
|
|
141
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)});
|
|
142
159
|
const run=async(kind,raw,internal={})=>{
|
|
160
|
+
const operation=internal.operation??{attempted:false};
|
|
143
161
|
const standaloneExit=kind==='exit'&&internal.standaloneExit===true;
|
|
144
162
|
fields(raw,kind==='entry'?['key','clientId','providerId','type','entryDate','assessment']:['key','enrollmentKey','clientId',kind==='exit'?'exitDate':'reviewDate','assessment',...(standaloneExit?['destination','reasonForLeaving','destinationDetails','reasonForLeavingOther']:[])]);
|
|
145
163
|
const input=structuredClone(raw);key(input.key);if(!positive(input.clientId))fail('client ID required');
|
|
@@ -153,7 +171,9 @@ export function createEnrollmentWorkflow({api,discovery,helpers,store,enrollment
|
|
|
153
171
|
return locked(scope(input.clientId),async()=>{
|
|
154
172
|
const reservations=await store.withLock(reservationScope(input.clientId),()=>store.read(reservationScope(input.clientId)));
|
|
155
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;
|
|
156
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');
|
|
176
|
+
operation.attempted=inheritedAttempt||records.some(r=>r.key===input.key&&Object.keys(r.steps).length>0);
|
|
157
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');
|
|
158
178
|
const matches=records.filter(r=>r.key===input.key);if(matches.length>1)fail('duplicate journal keys');let record=matches[0];
|
|
159
179
|
const intentHash=hash({kind,input,...(internal.owner?{household:internal.owner}:{})});if(record&&record.intentHash!==intentHash)fail('key conflicts with previous intent');
|
|
@@ -226,6 +246,17 @@ export function createEnrollmentWorkflow({api,discovery,helpers,store,enrollment
|
|
|
226
246
|
// that instant; one second later returns them. Only the yes/no step needs this.
|
|
227
247
|
const readSaved=async()=>questions(await api.assessments.get({path:{clientId:input.clientId,assessmentCode:code},query:{dateEffective:new Date(Date.parse(at)+1000).toISOString()}}));
|
|
228
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
|
+
}
|
|
229
260
|
if(expectedHead===input.clientId&&headQuestion)checkHeadAnswer(proposedHead?.answer_value??initial.find(q=>q.virt_field_name==='HUD_RELATIONTOHOH')?.answer_value);
|
|
230
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');}
|
|
231
262
|
// Resend mandatory answers with the standard yes/no write. A blank write must not hide a recorded value.
|
|
@@ -236,11 +267,11 @@ export function createEnrollmentWorkflow({api,discovery,helpers,store,enrollment
|
|
|
236
267
|
if(m.carry){
|
|
237
268
|
const question=definition.questions.find(q=>q.fieldName===m.virt_field_name),options=question.options.filter(o=>String(o.id)===String(recorded));
|
|
238
269
|
const label=options.length===1?writeLabel(question,options[0]):undefined;
|
|
239
|
-
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]});
|
|
240
271
|
if(m.virt_field_name==='HUD_RELATIONTOHOH')checkHeadAnswer(recorded);
|
|
241
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)};
|
|
242
273
|
}
|
|
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
|
|
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]});
|
|
244
275
|
return m;
|
|
245
276
|
});
|
|
246
277
|
// Validate closure targets before creating enrollment/review or submitting any answers.
|
|
@@ -274,6 +305,7 @@ export function createEnrollmentWorkflow({api,discovery,helpers,store,enrollment
|
|
|
274
305
|
if(previous)throw new EnrollmentWorkflowError(previous.state==='verified'?'verified step changed':'uncertain write requires reconciliation',{step:name});
|
|
275
306
|
record.steps[name]={state:'attempted'};await persist();
|
|
276
307
|
let writeError;
|
|
308
|
+
operation.attempted=true;
|
|
277
309
|
try{await submit(result);}catch(error){writeError=error;}
|
|
278
310
|
// A write response alone never establishes success or absence. Preserve its
|
|
279
311
|
// diagnostic cause in memory while reconciliation remains authoritative.
|
|
@@ -299,6 +331,7 @@ export function createEnrollmentWorkflow({api,discovery,helpers,store,enrollment
|
|
|
299
331
|
else {const found=await findEnrollment();if(!found.found)fail('saved enrollment missing');record.enrollmentId=entry.enrollmentId;}
|
|
300
332
|
await persist();
|
|
301
333
|
if(kind==='interim'){
|
|
334
|
+
if(!internal.readReview)operation.attempted=true;
|
|
302
335
|
// The GWT helper has its own journal and authoritative reads; it owns uncertain-write reconciliation.
|
|
303
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});
|
|
304
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();
|
|
@@ -361,7 +394,7 @@ export function createEnrollmentWorkflow({api,discovery,helpers,store,enrollment
|
|
|
361
394
|
const known=record.steps.birthDate.value;
|
|
362
395
|
result=await birthDates.verify({...dobContext,dateOfBirth:known.dateOfBirth,effectiveDate:known.effectiveDate,allowLater:true});
|
|
363
396
|
}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}}:{})});
|
|
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}}:{})});
|
|
365
398
|
}else result=await birthDates.verify({...dobContext,dateOfBirth:carriedBirthDate.dateOfBirth,effectiveDate:carriedBirthDate.effectiveDate,allowLater:true});
|
|
366
399
|
record.steps.birthDate={state:'verified',value:{clientId:input.clientId,dateOfBirth:result.dateOfBirth,effectiveDate:result.effectiveDate}};await persist();
|
|
367
400
|
}
|
|
@@ -373,6 +406,11 @@ export function createEnrollmentWorkflow({api,discovery,helpers,store,enrollment
|
|
|
373
406
|
const memberKey=(key,clientId)=>'household:'+hash({key,clientId});
|
|
374
407
|
const withClients=(ids,work,index=0)=>index===ids.length?work():store.withLock(scope(ids[index]),()=>withClients(ids,work,index+1));
|
|
375
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
|
+
}
|
|
376
414
|
fields(raw,household?['key','householdId','headClientId','members']:['key','clientId','enrollmentId']);
|
|
377
415
|
const input=structuredClone(raw);key(input.key);
|
|
378
416
|
if(typeof enrollmentSession?.readEnrollment!=='function')fail('authoritative enrollment session required for adoption');
|
|
@@ -448,7 +486,7 @@ export function createEnrollmentWorkflow({api,discovery,helpers,store,enrollment
|
|
|
448
486
|
});
|
|
449
487
|
});
|
|
450
488
|
};
|
|
451
|
-
const householdRun=async(kind,raw)=>{
|
|
489
|
+
const householdRun=async(kind,raw,operation={attempted:false})=>{
|
|
452
490
|
if(typeof enrollmentSession?.readEnrollment!=='function'||typeof helpers.addHouseholdMembers!=='function'||typeof api.households?.get!=='function')fail('household API and authoritative enrollment session required');
|
|
453
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']:[])]);
|
|
454
492
|
const input=structuredClone(raw);key(input.key);
|
|
@@ -463,8 +501,10 @@ export function createEnrollmentWorkflow({api,discovery,helpers,store,enrollment
|
|
|
463
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);}
|
|
464
502
|
// Read the cohort under its journal lock, then acquire all client locks in order.
|
|
465
503
|
// Recheck under both locks so concurrent membership growth cannot escape protection.
|
|
504
|
+
operation.attempted=true;
|
|
466
505
|
const prior=await store.withLock(householdScope(input.headClientId),()=>store.read(householdScope(input.headClientId)));
|
|
467
506
|
if(!Array.isArray(prior))fail('invalid household journal');
|
|
507
|
+
operation.attempted=prior.some(r=>r.key===input.key);
|
|
468
508
|
const lockedIds=[...new Set([input.headClientId,...ids,...prior.flatMap(r=>r.clientIds??[])])].sort((a,b)=>a-b);
|
|
469
509
|
return withClients(lockedIds,()=>store.withLock(householdScope(input.headClientId),async()=>{
|
|
470
510
|
const records=await store.read(householdScope(input.headClientId));
|
|
@@ -532,7 +572,7 @@ export function createEnrollmentWorkflow({api,discovery,helpers,store,enrollment
|
|
|
532
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};
|
|
533
573
|
// Validate every member before any record mutation. The same client locks cover
|
|
534
574
|
// 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}:{})}));
|
|
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}:{})}));
|
|
536
576
|
if(kind==='addition'&&record?.entrySchemas&&canonical(record.entrySchemas)!==canonical(preflight))fail('additional member assessment configuration changed');
|
|
537
577
|
if(kind==='exit'&&record?.exitSchemas&&canonical(record.exitSchemas)!==canonical(preflight))fail('exit assessment configuration changed');
|
|
538
578
|
const wasVerified=record?.stage==='verified';
|
|
@@ -580,14 +620,14 @@ export function createEnrollmentWorkflow({api,discovery,helpers,store,enrollment
|
|
|
580
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');
|
|
581
621
|
}
|
|
582
622
|
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();
|
|
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();
|
|
584
624
|
// Reject unexpected members before the attachment helper is allowed to write.
|
|
585
625
|
await readTarget({clientId:head.clientId,enrollmentId:result.enrollmentId},{partial:!record.attached});
|
|
586
626
|
record.attachmentAttempted=true;await persist();
|
|
587
|
-
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)});
|
|
588
628
|
const snapshot=validateSnapshot(attached.snapshot,{clientId:head.clientId,enrollmentId:result.enrollmentId});
|
|
589
629
|
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();}
|
|
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();}
|
|
591
631
|
}else if(kind==='addition'){
|
|
592
632
|
if(!record.anchor){
|
|
593
633
|
for(const clientId of Object.keys(catalog).map(Number).sort((a,b)=>a-b)){
|
|
@@ -601,7 +641,7 @@ export function createEnrollmentWorkflow({api,discovery,helpers,store,enrollment
|
|
|
601
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');
|
|
602
642
|
}
|
|
603
643
|
record.attachmentAttempted=true;await persist();
|
|
604
|
-
const attached=await helpers.addHouseholdMembers({...record.anchor,memberClientIds:ids});
|
|
644
|
+
operation.attempted=true;const attached=await helpers.addHouseholdMembers({...record.anchor,memberClientIds:ids});
|
|
605
645
|
validateSnapshot(attached.snapshot,record.anchor);
|
|
606
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};}
|
|
607
647
|
record.attached=true;await persist();await preserve();
|
|
@@ -619,13 +659,13 @@ export function createEnrollmentWorkflow({api,discovery,helpers,store,enrollment
|
|
|
619
659
|
const change={...target,providerId:context.providerId,type:context.type,entryDate:input.entryDate,cohort};
|
|
620
660
|
await memberEntryDatePort.validateMemberEntryDate(change);
|
|
621
661
|
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. */}
|
|
662
|
+
operation.attempted=true;try{await memberEntryDatePort.saveMemberEntryDate(change);}catch{/* Resolve only through readback; never replay the date update. */}
|
|
623
663
|
}
|
|
624
664
|
if(record.entryDates[clientId]==='attempted'&&record.entryDateInputs[clientId])await memberEntryDatePort.validateMemberEntryDate(record.entryDateInputs[clientId]);
|
|
625
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');
|
|
626
666
|
await preserve();record.entryDates[clientId]='verified';await persist();
|
|
627
667
|
}
|
|
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();}
|
|
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();}
|
|
629
669
|
}else if(kind==='interim'){
|
|
630
670
|
// Verify all linked enrollments before making the single household review call.
|
|
631
671
|
const existingReviews=[];
|
|
@@ -640,7 +680,7 @@ export function createEnrollmentWorkflow({api,discovery,helpers,store,enrollment
|
|
|
640
680
|
if(existingReviews.some(Boolean)&&existingReviews.some(r=>!r))fail('partial household review requires reconciliation');
|
|
641
681
|
if(existingReviews[0]&&new Set(existingReviews.map(r=>r.reviewId)).size!==ids.length)fail('duplicate household review IDs');
|
|
642
682
|
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);
|
|
683
|
+
operation.attempted=true;const review=await helpers.createInterimUpdate({...target,clientIds:ids,reviewDate:input.reviewDate});validateSnapshot(review.snapshot,target);
|
|
644
684
|
const readReview=async target=>{
|
|
645
685
|
const snapshot=await readTarget(target),reviews=snapshot.reviews.filter(r=>r.type==='Update'&&restDate(r.reviewDate)===restDate(input.reviewDate));
|
|
646
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');
|
|
@@ -650,7 +690,7 @@ export function createEnrollmentWorkflow({api,discovery,helpers,store,enrollment
|
|
|
650
690
|
// Pin every member's distinct review ID before writing any member's answers.
|
|
651
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();}
|
|
652
692
|
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();}
|
|
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();}
|
|
654
694
|
}else if(kind==='exit'){
|
|
655
695
|
const target={clientId:ids[0],enrollmentId:catalog[ids[0]].enrollmentId};
|
|
656
696
|
const normalizeLabel=value=>typeof value==='string'?value.replace(/\s*\(HUD\)\s*$/,'').trim():value;
|
|
@@ -681,7 +721,7 @@ export function createEnrollmentWorkflow({api,discovery,helpers,store,enrollment
|
|
|
681
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}:{})};
|
|
682
722
|
await exitUi.validateExit(exitInput);
|
|
683
723
|
record.exitStep='attempted';await persist();
|
|
684
|
-
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. */}
|
|
685
725
|
observed=await inspect();if(!observed.found)fail('uncertain household exit requires reconciliation');
|
|
686
726
|
}
|
|
687
727
|
await validateHeadCoverage();await preserve();
|
|
@@ -689,7 +729,7 @@ export function createEnrollmentWorkflow({api,discovery,helpers,store,enrollment
|
|
|
689
729
|
const readExit=async target=>{
|
|
690
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);
|
|
691
731
|
};
|
|
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();}
|
|
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();}
|
|
693
733
|
}
|
|
694
734
|
if(kind==='exit'||kind==='addition')await preserve();
|
|
695
735
|
await validateHeadCoverage();
|
|
@@ -698,13 +738,16 @@ export function createEnrollmentWorkflow({api,discovery,helpers,store,enrollment
|
|
|
698
738
|
}));
|
|
699
739
|
};
|
|
700
740
|
return {
|
|
701
|
-
correctEntry,
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
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)),
|
|
707
749
|
async getHouseholdStatus(input){
|
|
750
|
+
return execute('getHouseholdStatus',async()=>{
|
|
708
751
|
fields(input,['headClientId','key']);if(!positive(input.headClientId))fail('head client ID required');key(input.key);
|
|
709
752
|
return store.withLock(householdScope(input.headClientId),async()=>{
|
|
710
753
|
const records=await store.read(householdScope(input.headClientId));if(!Array.isArray(records))fail('invalid household journal');
|
|
@@ -712,9 +755,11 @@ export function createEnrollmentWorkflow({api,discovery,helpers,store,enrollment
|
|
|
712
755
|
if(record.version!==1||!object(record.members))fail('invalid household journal');
|
|
713
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]??{})}))};
|
|
714
757
|
});
|
|
758
|
+
});
|
|
715
759
|
},
|
|
716
|
-
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})),
|
|
717
761
|
async getStatus(input){
|
|
762
|
+
return execute('getStatus',async()=>{
|
|
718
763
|
fields(input,['clientId','key']);if(!positive(input.clientId))fail('client ID required');key(input.key);
|
|
719
764
|
return store.withLock(scope(input.clientId),async()=>{
|
|
720
765
|
const records=await store.read(scope(input.clientId));if(!Array.isArray(records))fail('invalid workflow journal');
|
|
@@ -722,6 +767,7 @@ export function createEnrollmentWorkflow({api,discovery,helpers,store,enrollment
|
|
|
722
767
|
if(r.version!==1||!object(r.steps))fail('invalid workflow journal');
|
|
723
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)}:{})}))};
|
|
724
769
|
});
|
|
770
|
+
});
|
|
725
771
|
},
|
|
726
772
|
};
|
|
727
773
|
}
|
|
@@ -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];
|
|
@@ -162,7 +164,7 @@ 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);
|
|
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);
|
|
166
168
|
const cells=encode(parent.children,patch.answers??{},patch.clear??[],'mobile');if(!cells.length)fail('empty row correction');
|
|
167
169
|
rowPlans.push({id,parentField:parent.fieldName,questionId:parent.questionId,cells});
|
|
168
170
|
}
|
|
@@ -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
|
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import {createHash} from 'node:crypto';
|
|
2
|
+
import {parseRestInstant,toItems} from '../dist/esm/index.js';
|
|
3
|
+
|
|
4
|
+
const object=v=>v!==null&&typeof v==='object'&&!Array.isArray(v);
|
|
5
|
+
const canonical=v=>JSON.stringify(v,(_,x)=>object(x)?Object.fromEntries(Object.keys(x).sort().map(k=>[k,x[k]])):x);
|
|
6
|
+
const hash=v=>createHash('sha256').update(canonical(v)).digest('hex');
|
|
7
|
+
const positive=v=>Number.isSafeInteger(v)&&v>0;
|
|
8
|
+
|
|
9
|
+
/** Registers caller-selected existing identities locally; never writes an assessment. */
|
|
10
|
+
export function createEntryRowRegistrar({api,store,enrollmentSession,discover,scope,reservationScope,makeError}){
|
|
11
|
+
const fail=(code,reason)=>{throw makeError(reason,{code,committed:false});};
|
|
12
|
+
const key=v=>typeof v==='string'&&!!v.trim()&&v.length<=200;
|
|
13
|
+
return async raw=>{
|
|
14
|
+
if(!object(raw)||Object.keys(raw).some(k=>!['key','enrollmentKey','clientId','rows'].includes(k))||!key(raw.key)||!key(raw.enrollmentKey)||!positive(raw.clientId)||!Array.isArray(raw.rows)||!raw.rows.length)fail('ROW_REGISTRATION_INPUT_INVALID','valid registration key, entry key, client and explicit rows required');
|
|
15
|
+
if(typeof enrollmentSession?.readEnrollment!=='function')fail('ROW_REGISTRATION_READER_REQUIRED','authoritative enrollment reader required');
|
|
16
|
+
const input=structuredClone(raw);input.rows.sort((a,b)=>String(a?.key).localeCompare(String(b?.key)));
|
|
17
|
+
const keys=new Set(),ids=new Set();
|
|
18
|
+
for(const row of input.rows){
|
|
19
|
+
if(!object(row)||Object.keys(row).some(k=>!['key','parentField','recordsetId'].includes(k))||!key(row.key)||!key(row.parentField)||!positive(row.recordsetId)||keys.has(row.key)||ids.has(row.recordsetId))fail('ROW_REGISTRATION_INPUT_INVALID','unique row keys, parents and positive recordset IDs required');
|
|
20
|
+
keys.add(row.key);ids.add(row.recordsetId);
|
|
21
|
+
}
|
|
22
|
+
return store.withLock(scope(input.clientId),async()=>{
|
|
23
|
+
const reservations=await store.withLock(reservationScope(input.clientId),()=>store.read(reservationScope(input.clientId)));
|
|
24
|
+
if(!Array.isArray(reservations)||reservations.some(r=>r.stage!=='verified'))fail('WORKFLOW_PENDING','unfinished household workflow must be reconciled first');
|
|
25
|
+
const records=await store.read(scope(input.clientId));
|
|
26
|
+
if(!Array.isArray(records)||records.some(r=>r.version!==1||!object(r.steps)))fail('WORKFLOW_JOURNAL_INVALID','invalid workflow journal');
|
|
27
|
+
if(records.some(r=>r.stage!=='verified'&&!(r.kind==='correction'&&r.stage==='rejected')))fail('WORKFLOW_PENDING','unfinished workflow must be reconciled first');
|
|
28
|
+
const entries=records.filter(r=>r.key===input.enrollmentKey&&r.kind==='entry'&&r.stage==='verified');
|
|
29
|
+
if(entries.length!==1)fail('ROW_REGISTRATION_ENTRY_REQUIRED','verified enrollment workflow required');const entry=entries[0];
|
|
30
|
+
const prior=Object.hasOwn(entry.rowRegistrations??{},input.key)?entry.rowRegistrations[input.key]:undefined,intentHash=hash(input);
|
|
31
|
+
if(prior&&prior.intentHash!==intentHash)fail('ROW_REGISTRATION_INTENT_CONFLICT','registration key conflicts with previous intent');
|
|
32
|
+
const target={clientId:input.clientId,enrollmentId:entry.enrollmentId},at=entry.context.entryDate;
|
|
33
|
+
const verifyEnrollment=async()=>{
|
|
34
|
+
const s=await enrollmentSession.readEnrollment(target);
|
|
35
|
+
if(s?.source!=='gwt-rpc'||s.tenant!==api.tenant||s.clientId!==target.clientId||s.enrollmentId!==target.enrollmentId||s.providerId!==entry.context.providerId||s.enrollmentType!==entry.context.type||parseRestInstant(s.projectStartDate)!==parseRestInstant(at))fail('ROW_REGISTRATION_CONTEXT_MISMATCH','enrollment identity changed during row registration');
|
|
36
|
+
};
|
|
37
|
+
await verifyEnrollment();
|
|
38
|
+
const discovered=await discover({...entry.context}),definition=discovered.entry.definition;
|
|
39
|
+
const schemaHash=hash({providerId:discovered.provider.providerId,entry:{assessment:discovered.entry.assessment,questions:definition.questions},interim:{assessment:discovered.interim.assessment,questions:discovered.interim.definition.questions}});
|
|
40
|
+
if(schemaHash!==entry.schemaHash)fail('WORKFLOW_SCHEMA_CHANGED','program assessment configuration changed');
|
|
41
|
+
const rowMap=entry.rows??{},parents=entry.rowParents??{};
|
|
42
|
+
for(const row of input.rows){
|
|
43
|
+
if(!definition.questions.some(q=>q.fieldName===row.parentField&&q.questionType==='sub_assessment'))fail('ROW_REGISTRATION_PARENT_INVALID','subassessment parent not in entry definition');
|
|
44
|
+
if(Object.hasOwn(rowMap,row.key)&&(rowMap[row.key]!==row.recordsetId||!Object.hasOwn(parents,row.key))||Object.entries(rowMap).some(([key,id])=>key!==row.key&&id===row.recordsetId)||Object.hasOwn(parents,row.key)&&parents[row.key]!==row.parentField)fail('ROW_REGISTRATION_BINDING_CONFLICT','row key or recordset is already bound differently');
|
|
45
|
+
}
|
|
46
|
+
const verifyRows=async effectiveDate=>{
|
|
47
|
+
const response=toItems(await api.assessments.get({path:{clientId:input.clientId,assessmentCode:entry.assessmentCode},query:{dateEffective:effectiveDate}}));
|
|
48
|
+
for(const row of input.rows){
|
|
49
|
+
const parent=response.filter(p=>p?.virt_field_name===row.parentField&&p.question_type==='sub_assessment');
|
|
50
|
+
if(parent.length!==1||!Array.isArray(parent[0].sub_assessment_answer_list))fail('ROW_REGISTRATION_READ_UNAVAILABLE','complete row registration read required');
|
|
51
|
+
const matches=parent[0].sub_assessment_answer_list.filter(r=>r?.recordset_id===row.recordsetId);
|
|
52
|
+
if(matches.length!==1||!Array.isArray(matches[0].sub_assessment_list))fail('ROW_REGISTRATION_ROW_MISSING','selected row is missing or ambiguous at the entry date or current read');
|
|
53
|
+
}
|
|
54
|
+
};
|
|
55
|
+
await verifyRows(at);await verifyRows(new Date(Math.max(Date.now(),parseRestInstant(at)+1000)).toISOString());await verifyEnrollment();
|
|
56
|
+
entry.rows=Object.fromEntries([...Object.entries(rowMap),...input.rows.map(r=>[r.key,r.recordsetId])]);
|
|
57
|
+
entry.rowParents=Object.fromEntries([...Object.entries(parents),...input.rows.map(r=>[r.key,r.parentField])]);
|
|
58
|
+
entry.rowRegistrations=Object.fromEntries([...Object.entries(entry.rowRegistrations??{}),[input.key,{intentHash,rows:input.rows}]]);
|
|
59
|
+
await store.write(scope(input.clientId),structuredClone(records));
|
|
60
|
+
return {key:input.key,enrollmentKey:input.enrollmentKey,clientId:input.clientId,enrollmentId:entry.enrollmentId,outcome:prior?'already-present':'verified',recordsetIds:structuredClone(entry.rows)};
|
|
61
|
+
});
|
|
62
|
+
};
|
|
63
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import {parseRestInstant,toItems} from '../dist/esm/index.js';
|
|
2
|
+
|
|
3
|
+
const positive=v=>Number.isSafeInteger(v)&&v>0;
|
|
4
|
+
const pairs=members=>JSON.stringify(members.map(({clientId,enrollmentId})=>({clientId,enrollmentId})).sort((a,b)=>a.clientId-b.clientId));
|
|
5
|
+
|
|
6
|
+
/** Resolves only a unique current household satisfying the adoption membership contract. */
|
|
7
|
+
export function createHouseholdEnrollmentDiscovery({api,enrollmentSession,makeError}){
|
|
8
|
+
const fail=(code,reason)=>{throw makeError(reason,{code,committed:false});};
|
|
9
|
+
return async input=>{
|
|
10
|
+
if(!input||Object.keys(input).some(k=>!['headClientId','headEnrollmentId'].includes(k))||!positive(input.headClientId)||!positive(input.headEnrollmentId))fail('HOUSEHOLD_DISCOVERY_INPUT_INVALID','head client and enrollment IDs required');
|
|
11
|
+
if(typeof enrollmentSession?.readEnrollment!=='function'||typeof api.households?.listForClient!=='function'||typeof api.households?.get!=='function')fail('HOUSEHOLD_DISCOVERY_READERS_REQUIRED','GWT detail and complete household readers required');
|
|
12
|
+
const target={clientId:input.headClientId,enrollmentId:input.headEnrollmentId};
|
|
13
|
+
const anchor=await enrollmentSession.readEnrollment(target);
|
|
14
|
+
const valid=(s,t)=>s?.source==='gwt-rpc'&&s.tenant===api.tenant&&s.clientId===t.clientId&&s.enrollmentId===t.enrollmentId&&positive(s.providerId)&&typeof s.enrollmentType==='string'&&Number.isFinite(parseRestInstant(s.projectStartDate));
|
|
15
|
+
if(!valid(anchor,target)||!Array.isArray(anchor.members)||!anchor.members.length||anchor.members.some(m=>!positive(m?.clientId)||!positive(m?.enrollmentId))||new Set(anchor.members.map(m=>m.clientId)).size!==anchor.members.length||new Set(anchor.members.map(m=>m.enrollmentId)).size!==anchor.members.length||!anchor.members.some(m=>m.clientId===target.clientId&&m.enrollmentId===target.enrollmentId))fail('HOUSEHOLD_DISCOVERY_DETAIL_INVALID','complete linked enrollment cohort required');
|
|
16
|
+
if(anchor.members.length===1&&anchor.grouped!==true)fail('ENROLLMENT_NOT_GROUPED','standalone enrollment uses adoptEnrollment');
|
|
17
|
+
if(anchor.grouped===false&&anchor.members.length>1)fail('HOUSEHOLD_DISCOVERY_DETAIL_INVALID','group flag conflicts with linked cohort');
|
|
18
|
+
const members=anchor.members.map(({clientId,enrollmentId})=>({clientId,enrollmentId})).sort((a,b)=>a.clientId-b.clientId),starts=new Map();
|
|
19
|
+
for(const member of members){
|
|
20
|
+
const s=member.clientId===target.clientId?anchor:await enrollmentSession.readEnrollment(member);
|
|
21
|
+
if(!valid(s,member)||s.providerId!==anchor.providerId||s.enrollmentType!==anchor.enrollmentType||!Array.isArray(s.members)||pairs(s.members)!==pairs(members))fail('HOUSEHOLD_DISCOVERY_COHORT_CHANGED','linked enrollment cohort changed during discovery');
|
|
22
|
+
starts.set(member.clientId,parseRestInstant(s.projectStartDate));
|
|
23
|
+
}
|
|
24
|
+
const candidates=[],seen=new Set();let total;
|
|
25
|
+
for(let startPage=1;startPage<=100;startPage++){
|
|
26
|
+
const result=await api.households.listForClient({path:{clientId:input.headClientId},query:{perPage:100,startPage,total_count:true}});
|
|
27
|
+
if(!Number.isSafeInteger(result?.count)||result.count<0||!Array.isArray(result.values)||total!==undefined&&total!==result.count)fail('HOUSEHOLD_DISCOVERY_LIST_INCOMPLETE','household list count unavailable or changed');total=result.count;
|
|
28
|
+
for(const h of result.values){
|
|
29
|
+
if(!positive(h?.householdId)||seen.has(h.householdId)||!Array.isArray(h.clients)||h.clients.some(m=>!positive(m?.clientId))||new Set(h.clients.map(m=>m.clientId)).size!==h.clients.length||!h.clients.some(m=>m.clientId===input.headClientId))fail('HOUSEHOLD_DISCOVERY_LIST_INCOMPLETE','invalid household list');
|
|
30
|
+
seen.add(h.householdId);candidates.push(h);
|
|
31
|
+
}
|
|
32
|
+
if(seen.size===total)break;
|
|
33
|
+
if(seen.size>total||!result.values.length||startPage===100)fail('HOUSEHOLD_DISCOVERY_LIST_INCOMPLETE','incomplete household list');
|
|
34
|
+
}
|
|
35
|
+
const matches=h=>{
|
|
36
|
+
if(h.isActive!==true||!Array.isArray(h.clients))return false;
|
|
37
|
+
const heads=h.clients.filter(m=>m.headofhouseholdStatus&&m.isActive!==false&&!m.dateLeft);
|
|
38
|
+
return heads.length===1&&heads[0].clientId===input.headClientId&&members.every(m=>{
|
|
39
|
+
const found=h.clients.filter(c=>c.clientId===m.clientId);
|
|
40
|
+
return found.length===1&&found[0].isActive!==false&&!found[0].dateLeft&&Number.isFinite(parseRestInstant(found[0].dateJoined))&&parseRestInstant(found[0].dateJoined)<=starts.get(m.clientId);
|
|
41
|
+
});
|
|
42
|
+
};
|
|
43
|
+
const found=candidates.filter(matches);
|
|
44
|
+
if(found.length!==1)fail(found.length?'HOUSEHOLD_DISCOVERY_AMBIGUOUS':'HOUSEHOLD_DISCOVERY_NOT_FOUND','a unique current household matching the full cohort, head and dates is required');
|
|
45
|
+
const fresh=toItems(await api.households.get({path:{householdId:found[0].householdId}}));
|
|
46
|
+
if(fresh.length!==1||fresh[0].householdId!==found[0].householdId||!matches(fresh[0]))fail('HOUSEHOLD_DISCOVERY_COHORT_CHANGED','household membership changed during discovery');
|
|
47
|
+
return {headClientId:input.headClientId,headEnrollmentId:input.headEnrollmentId,householdId:fresh[0].householdId,householdSource:'verified-membership',providerId:anchor.providerId,type:anchor.enrollmentType,members};
|
|
48
|
+
};
|
|
49
|
+
}
|
package/browser/index.d.mts
CHANGED
|
@@ -17,10 +17,12 @@ export class LoginError extends Error {
|
|
|
17
17
|
}
|
|
18
18
|
export interface UiSession {readonly connection:UiConnection;browser: Browser;context: BrowserContext;page: Page;passwordRecovery:'restored-original'|'not-needed';ensureAuthenticated():Promise<void>;reauthenticate():Promise<void>;close():Promise<void>;}
|
|
19
19
|
export interface PlaywrightGwtOptions {page:Page;tenant:string;moduleBaseUrl:string;}
|
|
20
|
-
export interface PlaywrightEnrollmentSnapshot extends GwtEnrollmentSnapshot {providerId:number;enrollmentType:string;exits:readonly GwtEnrollmentExit[];}
|
|
20
|
+
export interface PlaywrightEnrollmentSnapshot extends GwtEnrollmentSnapshot {providerId:number;enrollmentType:string;exits:readonly GwtEnrollmentExit[];groupId?:number|string|null;grouped?:boolean;}
|
|
21
21
|
export interface EnrollmentListRow {
|
|
22
22
|
enrollmentId:number;clientId:number;enrollmentProvider:number;type:string;
|
|
23
23
|
projectStartDate:string;projectExitDate:string|null;isActive:boolean;
|
|
24
|
+
/** An enrollment group pointer, not a household identifier or member count. */
|
|
25
|
+
groupId:number|string|null;grouped:boolean;
|
|
24
26
|
}
|
|
25
27
|
export interface EnrollmentListSnapshot {
|
|
26
28
|
source:'gwt-rpc';tenant:string;clientId:number;count:number;values:EnrollmentListRow[];
|