@choiceapps/communityservices-sdk 0.4.1 → 0.6.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 -1
- package/browser/enrollment-workflow.d.mts +66 -4
- package/browser/enrollment-workflow.mjs +137 -19
- package/browser/entry-correction.mjs +205 -0
- package/browser/index.d.mts +15 -2
- package/browser/index.mjs +2 -2
- package/browser/playwright-session.mjs +30 -8
- package/docs/assessments.md +7 -1
- package/docs/browser-session.md +1 -1
- package/docs/capabilities.md +14 -4
- package/docs/enrollments.md +79 -1
- package/docs/reference.md +2 -2
- package/docs/troubleshooting.md +3 -0
- package/examples/README.md +2 -0
- package/examples/entry-correction-workflow.mts +22 -0
- package/examples/existing-enrollment-exits.mts +30 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,21 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.6.0 - 2026-11-10
|
|
4
|
+
|
|
5
|
+
- 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.
|
|
6
|
+
- Surface correction HTTP status and preserve allowlisted kind/status across retries without journaling response bodies. Retain write and read causes when reconciliation also fails.
|
|
7
|
+
- Add explicit `resolveEntryCorrection` with independent confirmation and two unchanged-state checks. Preserve attempted history, refuse partial/verified commits, and require a new key for corrected intent after rejection. Never reset a sent request automatically from a 4xx response.
|
|
8
|
+
- Clarify shared answers across assessment instruments, the public GWT enrollment catalog, and adoption for interims and scalar corrections. HUD label resolution and household-ID discovery still require authoritative metadata.
|
|
9
|
+
- Correct password recovery documentation: WellSky permits immediately resetting back to the previous password.
|
|
10
|
+
|
|
11
|
+
## 0.5.0 - 2026-11-10
|
|
12
|
+
|
|
13
|
+
- Use a configured GWT enrollment catalog for creation and member preflight. Prior household enrollments, including exited entries, no longer force the known-broken REST grouped list. Failed or incomplete reads never imply absence.
|
|
14
|
+
- Add `EnrollmentReadError` to distinguish a preflight non-commit from an unavailable read after a write attempt.
|
|
15
|
+
- Add `correctEntry` for explicit scalar answers/clears and in-place edits to rows created by the original entry workflow. Preserve row IDs, other values and same-key recovery; original entry retries respect verified corrections.
|
|
16
|
+
- Add read-only `adoptEnrollment` and `adoptHouseholdEnrollment` operations so manually entered enrollments can use the journaled exit workflows.
|
|
17
|
+
- Record integrator-reported November 10, 2026 sandbox acceptance of 0.4.1 individual and household exits. The newly added catalog, correction and adoption paths have synthetic coverage; they have not been independently exercised against a live tenant.
|
|
18
|
+
|
|
3
19
|
## 0.4.1 - 2026-11-10
|
|
4
20
|
|
|
5
21
|
- Pin GitHub verification and publication jobs to Ubuntu 24.04. The Ubuntu 26.04 runner selected by `ubuntu-latest` cannot install Chromium with the pinned Playwright version, which blocked public npm publication of 0.4.0. SDK behavior is unchanged.
|
|
@@ -7,7 +23,7 @@
|
|
|
7
23
|
## 0.4.0 - 2026-11-10
|
|
8
24
|
|
|
9
25
|
- Add `exitEnrollment` for standalone program enrollments, with durable save journaling, authoritative exit verification, conditional destination/reason details and per-person exit assessments. Linked household participants continue to use `exitHousehold`, including whole-household and selected-member exits.
|
|
10
|
-
- Support a standalone exit form without a household checkbox tree only when fresh enrollment data confirms exactly one participant.
|
|
26
|
+
- Support a standalone exit form without a household checkbox tree only when fresh enrollment data confirms exactly one participant. Initial acceptance covered synthetic regressions. Subsequent integrator-reported sandbox acceptance is recorded under capabilities on November 10, 2026.
|
|
11
27
|
|
|
12
28
|
## 0.3.0 - 2026-11-03
|
|
13
29
|
|
|
@@ -67,6 +67,46 @@ export interface ExitEnrollmentInput extends HouseholdExitDetailInput {
|
|
|
67
67
|
reasonForLeaving:string;
|
|
68
68
|
assessment:EnrollmentAssessmentPlan;
|
|
69
69
|
}
|
|
70
|
+
export interface EntryRowCorrection {
|
|
71
|
+
/** Original subassessment row key from createEnrollment, never a new row key. */
|
|
72
|
+
key:string;parentField:string;answers?:Answers;clear?:string[];
|
|
73
|
+
}
|
|
74
|
+
export interface EntryCorrectionInput {
|
|
75
|
+
key:string;enrollmentKey:string;clientId:number;
|
|
76
|
+
answers?:Answers;
|
|
77
|
+
/** Explicit clears; an omitted field stays unchanged. Clear is distinct from No. */
|
|
78
|
+
clear?:string[];
|
|
79
|
+
subassessments?:EntryRowCorrection[];
|
|
80
|
+
}
|
|
81
|
+
export interface EntryCorrectionResult extends EnrollmentWorkflowResult {enrollmentKey:string;}
|
|
82
|
+
export interface EntryCorrectionFailure {
|
|
83
|
+
status?:number;
|
|
84
|
+
kind?:'http'|'auth'|'timeout'|'network'|'aborted'|'protocol'|'validation'|'throttled'|'cooldown';
|
|
85
|
+
}
|
|
86
|
+
export interface EntryCorrectionResolution {
|
|
87
|
+
kind:'not-applied';evidenceReference:string;resolvedAt:string;
|
|
88
|
+
}
|
|
89
|
+
export interface EntryCorrectionResolutionEvidence {
|
|
90
|
+
key:string;enrollmentKey:string;clientId:number;enrollmentId:number;effectiveDate:string;matchesBefore:true;
|
|
91
|
+
steps:Array<{name:string;state:'attempted'|'verified';failure?:EntryCorrectionFailure}>;
|
|
92
|
+
}
|
|
93
|
+
export interface ResolveEntryCorrectionInput {
|
|
94
|
+
key:string;enrollmentKey:string;clientId:number;
|
|
95
|
+
/** Opaque reference to independent non-application evidence, not a response body. */
|
|
96
|
+
evidenceReference:string;
|
|
97
|
+
/** Read-only confirmation using independent server/administrative evidence.
|
|
98
|
+
* A 4xx response plus matchesBefore alone is not sufficient. */
|
|
99
|
+
confirmNotApplied:(evidence:EntryCorrectionResolutionEvidence)=>boolean|Promise<boolean>;
|
|
100
|
+
}
|
|
101
|
+
export interface ResolveEntryCorrectionResult {
|
|
102
|
+
key:string;enrollmentKey:string;clientId:number;outcome:'rejected'|'already-rejected';resolution:EntryCorrectionResolution;
|
|
103
|
+
}
|
|
104
|
+
export interface AdoptEnrollmentInput {key:string;clientId:number;enrollmentId:number;}
|
|
105
|
+
export interface AdoptHouseholdEnrollmentInput {
|
|
106
|
+
key:string;householdId:number;headClientId:number;
|
|
107
|
+
/** Complete linked cohort, including already exited participants. IDs are explicit, not inferred. */
|
|
108
|
+
members:Array<{clientId:number;enrollmentId:number}>;
|
|
109
|
+
}
|
|
70
110
|
export interface EnrollmentWorkflowResult {
|
|
71
111
|
key:string;
|
|
72
112
|
outcome:'verified'|'already-present';
|
|
@@ -86,15 +126,16 @@ export interface ExitEnrollmentResult extends EnrollmentWorkflowResult {projectE
|
|
|
86
126
|
export interface ExitWorkflowDiscovery extends EnrollmentWorkflowDiscovery {exit:{assessment:ProviderAssessmentItem;definition:AssessmentDefinition};}
|
|
87
127
|
export interface EnrollmentWorkflowStatus {
|
|
88
128
|
key:string;
|
|
89
|
-
kind:'entry'|'interim'|'exit';
|
|
90
|
-
stage:'pending'|'verified';
|
|
129
|
+
kind:'entry'|'interim'|'exit'|'correction';
|
|
130
|
+
stage:'pending'|'verified'|'rejected';
|
|
91
131
|
assessmentCode:string;
|
|
92
132
|
enrollmentId?:number;
|
|
93
133
|
reviewId?:number;
|
|
94
134
|
projectExitDate?:string;
|
|
95
135
|
exitSubgroupId?:string|null;
|
|
96
136
|
recordsetIds:Record<string,number>;
|
|
97
|
-
steps:{name:string;state:'attempted'|'verified'}[];
|
|
137
|
+
steps:{name:string;state:'attempted'|'verified';failure?:EntryCorrectionFailure}[];
|
|
138
|
+
resolution?:EntryCorrectionResolution;
|
|
98
139
|
}
|
|
99
140
|
export interface EnrollmentWorkflow {
|
|
100
141
|
discover(input:EnrollmentDiscoveryInput):Promise<EnrollmentWorkflowDiscovery>;
|
|
@@ -103,6 +144,14 @@ export interface EnrollmentWorkflow {
|
|
|
103
144
|
createInterim(input:InterimWorkflowInput):Promise<InterimWorkflowResult>;
|
|
104
145
|
/** Standalone participant only. Linked household participants use exitHousehold. */
|
|
105
146
|
exitEnrollment(input:ExitEnrollmentInput):Promise<ExitEnrollmentResult>;
|
|
147
|
+
/** Patch an open entry at its original timestamp, before any review or exit. */
|
|
148
|
+
correctEntry(input:EntryCorrectionInput):Promise<EntryCorrectionResult>;
|
|
149
|
+
/** Verify unchanged state and independently confirmed rejection; keeps the attempt history. */
|
|
150
|
+
resolveEntryCorrection(input:ResolveEntryCorrectionInput):Promise<ResolveEntryCorrectionResult>;
|
|
151
|
+
/** Verify an existing standalone entry and journal it without an HMIS write. */
|
|
152
|
+
adoptEnrollment(input:AdoptEnrollmentInput):Promise<EnrollmentWorkflowResult>;
|
|
153
|
+
/** Verify all existing linked entries, original head and dates; no HMIS writes. */
|
|
154
|
+
adoptHouseholdEnrollment(input:AdoptHouseholdEnrollmentInput):Promise<HouseholdEnrollmentResult>;
|
|
106
155
|
/** Reads the local journal only. A verified journal is not fresh server verification. */
|
|
107
156
|
getStatus(input:{clientId:number;key:string}):Promise<EnrollmentWorkflowStatus|null>;
|
|
108
157
|
createHouseholdEnrollment(input:HouseholdEnrollmentInput):Promise<HouseholdEnrollmentResult>;
|
|
@@ -193,9 +242,22 @@ export interface EnrollmentWorkflowOptions {
|
|
|
193
242
|
}
|
|
194
243
|
export class EnrollmentWorkflowError extends Error {
|
|
195
244
|
readonly reason:string;
|
|
245
|
+
readonly code?:string;
|
|
246
|
+
readonly fields?:string[];
|
|
247
|
+
readonly status?:number;
|
|
196
248
|
/** Failed durable stage, when known. The original write error is available as cause in memory. */
|
|
197
249
|
readonly step?:string;
|
|
198
|
-
constructor(reason:string,options?:ErrorOptions&{step?:string});
|
|
250
|
+
constructor(reason:string,options?:ErrorOptions&{step?:string;code?:string;fields?:string[];status?:number});
|
|
251
|
+
}
|
|
252
|
+
/** An unavailable list is never evidence that no enrollment exists. */
|
|
253
|
+
export class EnrollmentReadError extends EnrollmentWorkflowError {
|
|
254
|
+
readonly code:'ENROLLMENT_READ_FAILED';
|
|
255
|
+
readonly clientId:number;
|
|
256
|
+
readonly source:'rest'|'gwt-rpc';
|
|
257
|
+
readonly phase:'preflight'|'reconciliation';
|
|
258
|
+
/** false only when this workflow has not attempted a server write. */
|
|
259
|
+
readonly committed:false|'unknown';
|
|
260
|
+
constructor(options:{clientId:number;source:'rest'|'gwt-rpc';writeAttempted:boolean;cause?:unknown});
|
|
199
261
|
}
|
|
200
262
|
/** Tenant-configured. Retain stable keys and one durable store for all cooperating writers.
|
|
201
263
|
* A partial or uncertain write requires reconciliation; no automatic write replay occurs. */
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import {apiScope} from './connection.mjs';
|
|
2
2
|
import {validateBirthDate} from './date-of-birth.mjs';
|
|
3
3
|
import {expectedExitDetails} from './exit-details.mjs';
|
|
4
|
+
import {createEntryCorrector,correctionFailureDetails} from './entry-correction.mjs';
|
|
4
5
|
import {createHash} from 'node:crypto';
|
|
5
6
|
import {readProgramCatalog,encodeAnswers,encodeNewSubassessmentRow,toItems,parseRestInstant} from '../dist/esm/index.js';
|
|
6
7
|
|
|
@@ -10,7 +11,21 @@ const entryExitTypes=['HUD','Basic','Standard','RHY','VA','Basic Center Program
|
|
|
10
11
|
const canonical=v=>JSON.stringify(v,(_,x)=>object(x)?Object.fromEntries(Object.keys(x).sort().map(k=>[k,x[k]])):x);
|
|
11
12
|
const hash=v=>createHash('sha256').update(canonical(v)).digest('hex');
|
|
12
13
|
const fail=message=>{throw new EnrollmentWorkflowError(message);};
|
|
13
|
-
export class EnrollmentWorkflowError extends Error {
|
|
14
|
+
export class EnrollmentWorkflowError extends Error {
|
|
15
|
+
constructor(reason,options){
|
|
16
|
+
super(`Enrollment workflow: ${reason}`,options);this.name='EnrollmentWorkflowError';this.reason=reason;
|
|
17
|
+
if(options?.step!==undefined)this.step=options.step;
|
|
18
|
+
if(options?.code!==undefined)this.code=options.code;
|
|
19
|
+
if(options?.fields!==undefined)this.fields=[...options.fields];
|
|
20
|
+
const status=options?.status??options?.cause?.status;
|
|
21
|
+
if(Number.isInteger(status)&&status>=100&&status<=599)this.status=status;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
export class EnrollmentReadError extends EnrollmentWorkflowError {
|
|
25
|
+
constructor({clientId,source,writeAttempted,cause}){
|
|
26
|
+
super(cause instanceof EnrollmentWorkflowError?cause.reason:'enrollment list unavailable',{step:'enrollment-read',cause});this.name='EnrollmentReadError';this.code='ENROLLMENT_READ_FAILED';this.clientId=clientId;this.source=source;this.phase=writeAttempted?'reconciliation':'preflight';this.committed=writeAttempted?'unknown':false;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
14
29
|
function date(value){
|
|
15
30
|
if(typeof value!=='string'||!/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.000)?(?:Z|[+-]\d{2}:\d{2})$/.test(value)||!Number.isFinite(Date.parse(value))||new Date(value.slice(0,19)+'Z').toISOString().slice(0,19)!==value.slice(0,19))fail('timezone-qualified whole-second date required');
|
|
16
31
|
return new Date(value).toISOString();
|
|
@@ -78,14 +93,19 @@ function compile(definition,input,{yesNoWrites,writeLabel}={}){
|
|
|
78
93
|
}
|
|
79
94
|
return {scalar:scalar.filter(q=>q.virt_field_name!=='SVPPROFDOB'),yesNo,mandatory,dateOfBirth:birthDate?.answer_value,rows:compiledRows,ends:compiledEnds,carry,excluded};
|
|
80
95
|
}
|
|
81
|
-
async function enrollments(api,clientId){
|
|
96
|
+
async function enrollments(api,clientId,{enrollmentSession,writeAttempted=false}={}){
|
|
97
|
+
const gwt=typeof enrollmentSession?.listEnrollments==='function';
|
|
98
|
+
try{
|
|
82
99
|
const all=[],ids=new Set();let count;
|
|
83
100
|
for(let page=1;page<=100;page++){
|
|
84
|
-
let result;
|
|
101
|
+
let result;
|
|
102
|
+
if(gwt){result=await enrollmentSession.listEnrollments({clientId});if(result?.source!=='gwt-rpc'||result.tenant!==api.tenant||result.clientId!==clientId)fail('enrollment list context mismatch');}
|
|
103
|
+
else try{result=await api.enrollments.listForClient({path:{clientId},query:{perPage:100,startPage:page,total_count:true}});}catch(e){if(page===1&&e.status===404)return [];throw e;}
|
|
85
104
|
if(!Number.isSafeInteger(result?.count)||result.count<0||!Array.isArray(result.values)||count!==undefined&&count!==result.count)fail('enrollment count unavailable or changed');count=result.count;
|
|
86
|
-
for(const row of result.values){if(!positive(row.enrollmentId)||row.clientId!==clientId||!positive(row.enrollmentProvider)||typeof row.type!=='string'||!row.type||!Number.isFinite(restDate(row.projectStartDate))||typeof row.isActive!=='boolean'||ids.has(row.enrollmentId))fail('invalid enrollment list');ids.add(row.enrollmentId);all.push(row);}
|
|
87
|
-
if(all.length===count)return all;if(all.length>count||!result.values.length)fail('incomplete enrollment list');
|
|
105
|
+
for(const row of result.values){if(!object(row)||!positive(row.enrollmentId)||row.clientId!==clientId||!positive(row.enrollmentProvider)||typeof row.type!=='string'||!row.type||!Number.isFinite(restDate(row.projectStartDate))||typeof row.isActive!=='boolean'||ids.has(row.enrollmentId)||(gwt||row.projectExitDate!==undefined)&&row.projectExitDate!==null&&!Number.isFinite(restDate(row.projectExitDate)))fail('invalid enrollment list');ids.add(row.enrollmentId);all.push(row);}
|
|
106
|
+
if(all.length===count)return all;if(gwt||all.length>count||!result.values.length)fail('incomplete enrollment list');
|
|
88
107
|
}fail('enrollment list exceeds limit');
|
|
108
|
+
}catch(cause){throw new EnrollmentReadError({clientId,source:gwt?'gwt-rpc':'rest',writeAttempted,cause});}
|
|
89
109
|
}
|
|
90
110
|
|
|
91
111
|
/** Explicit orchestration for an existing client. All writers must share the durable store.
|
|
@@ -118,6 +138,7 @@ export function createEnrollmentWorkflow({api,discovery,helpers,store,enrollment
|
|
|
118
138
|
};
|
|
119
139
|
const scope=clientId=>JSON.stringify([tenantScope,'enrollment-workflow',clientId]);
|
|
120
140
|
const reservationScope=clientId=>JSON.stringify([tenantScope,'household-workflow-reservation',clientId]);
|
|
141
|
+
const {correctEntry,resolveEntryCorrection}=createEntryCorrector({api,store,enrollmentSession,writeLabel,discover,scope,reservationScope,makeError:(reason,options)=>new EnrollmentWorkflowError(reason,options)});
|
|
121
142
|
const run=async(kind,raw,internal={})=>{
|
|
122
143
|
const standaloneExit=kind==='exit'&&internal.standaloneExit===true;
|
|
123
144
|
fields(raw,kind==='entry'?['key','clientId','providerId','type','entryDate','assessment']:['key','enrollmentKey','clientId',kind==='exit'?'exitDate':'reviewDate','assessment',...(standaloneExit?['destination','reasonForLeaving','destinationDetails','reasonForLeavingOther']:[])]);
|
|
@@ -133,7 +154,7 @@ export function createEnrollmentWorkflow({api,discovery,helpers,store,enrollment
|
|
|
133
154
|
const reservations=await store.withLock(reservationScope(input.clientId),()=>store.read(reservationScope(input.clientId)));
|
|
134
155
|
if(!Array.isArray(reservations)||reservations.some(r=>r.stage!=='verified'&&r.owner!==internal.owner))fail('unfinished household workflow must be reconciled first');
|
|
135
156
|
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');
|
|
136
|
-
if(records.some(r=>r.stage!=='verified'&&r.key!==input.key))fail('another unfinished workflow must be reconciled first');
|
|
157
|
+
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');
|
|
137
158
|
const matches=records.filter(r=>r.key===input.key);if(matches.length>1)fail('duplicate journal keys');let record=matches[0];
|
|
138
159
|
const intentHash=hash({kind,input,...(internal.owner?{household:internal.owner}:{})});if(record&&record.intentHash!==intentHash)fail('key conflicts with previous intent');
|
|
139
160
|
// Changing the yes/no endpoint after an attempt would be an endpoint switch, not reconciliation.
|
|
@@ -206,9 +227,11 @@ export function createEnrollmentWorkflow({api,discovery,helpers,store,enrollment
|
|
|
206
227
|
const readSaved=async()=>questions(await api.assessments.get({path:{clientId:input.clientId,assessmentCode:code},query:{dateEffective:new Date(Date.parse(at)+1000).toISOString()}}));
|
|
207
228
|
const initial=await read();
|
|
208
229
|
if(expectedHead===input.clientId&&headQuestion)checkHeadAnswer(proposedHead?.answer_value??initial.find(q=>q.virt_field_name==='HUD_RELATIONTOHOH')?.answer_value);
|
|
209
|
-
for(const field of plan.carry.filter(field=>field!=='SVPPROFDOB')){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');}
|
|
230
|
+
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');}
|
|
210
231
|
// Resend mandatory answers with the standard yes/no write. A blank write must not hide a recorded value.
|
|
211
232
|
const mandatory=plan.mandatory.map(m=>{
|
|
233
|
+
const corrected=kind==='entry'?record?.correctedScalars?.[m.virt_field_name]:undefined;
|
|
234
|
+
if(corrected)return {...m,expected:corrected.answer_value};
|
|
212
235
|
const recorded=initial.find(q=>q.virt_field_name===m.virt_field_name)?.answer_value;
|
|
213
236
|
if(m.carry){
|
|
214
237
|
const question=definition.questions.find(q=>q.fieldName===m.virt_field_name),options=question.options.filter(o=>String(o.id)===String(recorded));
|
|
@@ -222,6 +245,10 @@ export function createEnrollmentWorkflow({api,discovery,helpers,store,enrollment
|
|
|
222
245
|
});
|
|
223
246
|
// Validate closure targets before creating enrollment/review or submitting any answers.
|
|
224
247
|
const current=await readCurrent();
|
|
248
|
+
if(kind==='entry'&&record?.correctedScalars){
|
|
249
|
+
const saved=await readSaved();
|
|
250
|
+
for(const q of Object.values(record.correctedScalars)){const actual=saved.find(r=>r.virt_field_name===q.virt_field_name);if(!actual||!sameAnswer(q,actual.answer_value)||q.answer_value!==''&&restDate(actual.date_effective)!==restDate(at))fail('verified entry correction changed');}
|
|
251
|
+
}
|
|
225
252
|
const currentHead=current.find(q=>q.virt_field_name==='HUD_RELATIONTOHOH');
|
|
226
253
|
// A fresh entry may establish a different relationship from a prior enrollment.
|
|
227
254
|
if(kind!=='entry'&¤tHead?.answer_value!==null&¤tHead?.answer_value!==undefined&¤tHead.answer_value!=='')checkHeadAnswer(currentHead.answer_value);
|
|
@@ -259,9 +286,11 @@ export function createEnrollmentWorkflow({api,discovery,helpers,store,enrollment
|
|
|
259
286
|
};
|
|
260
287
|
const findEnrollment=async()=>{
|
|
261
288
|
const knownId=record.enrollmentId??record.steps.enrollment?.value??entry?.enrollmentId;
|
|
262
|
-
if(internal.findEnrollment)return internal.findEnrollment({knownId,context});
|
|
289
|
+
if(internal.findEnrollment)return internal.findEnrollment({knownId,context,writeAttempted:Object.keys(record.steps).length>0});
|
|
263
290
|
if(standaloneExit){await inspectStandaloneExit();return {found:true,value:entry.enrollmentId};}
|
|
264
|
-
const list=await enrollments(api,input.clientId),
|
|
291
|
+
const list=await enrollments(api,input.clientId,{enrollmentSession,writeAttempted:Object.keys(record.steps).length>0}),matching=list.filter(e=>e.enrollmentProvider===context.providerId&&e.type===context.type&&restDate(e.projectStartDate)===restDate(context.entryDate)&&e.isActive!==false);
|
|
292
|
+
const open=matching.filter(e=>e.projectExitDate==null);if(open.length>1)fail('duplicate matching enrollments');
|
|
293
|
+
const found=knownId?matching.filter(e=>e.enrollmentId===knownId):open;
|
|
265
294
|
if(found.length>1)fail('duplicate matching enrollments');
|
|
266
295
|
const id=record.enrollmentId??record.steps.enrollment?.value??entry?.enrollmentId;if(id&&(!found[0]||found[0].enrollmentId!==id))fail('saved enrollment missing or changed');
|
|
267
296
|
return {found:found.length===1,value:found[0]?.enrollmentId};
|
|
@@ -300,12 +329,12 @@ export function createEnrollmentWorkflow({api,discovery,helpers,store,enrollment
|
|
|
300
329
|
},()=>api.subassessments.setEndDate({clientId:input.clientId,assessmentCode:code,effectiveDate:at,parentField:end.parentField,recordsetId:end.recordsetId,endDateField:end.endDateField,endDate:end.endDate}));
|
|
301
330
|
}
|
|
302
331
|
if(plan.scalar.length)await step('scalars',async()=>{
|
|
303
|
-
const source=await read();return {found:plan.scalar.every(
|
|
332
|
+
const source=kind==='entry'&&record.correctedScalars?await readSaved():await read();return {found:plan.scalar.every(original=>{const q=kind==='entry'?record.correctedScalars?.[original.virt_field_name]??original:original,r=source.find(r=>r.virt_field_name===q.virt_field_name);return r&&sameAnswer(q,r.answer_value)&&(q.answer_value===''||restDate(r.date_effective)===restDate(at));}),value:plan.scalar.map(q=>q.virt_field_name)};
|
|
304
333
|
},()=>api.assessmentsMobile.addAnswer({path:{clientId:input.clientId,assessmentCode:code},query:{dateEffective:at},body:plan.scalar}));
|
|
305
334
|
// Standard writes update existing dated rows and change only the answers sent.
|
|
306
335
|
if(plan.yesNo.length){const body=[...plan.yesNo,...mandatory.map(({expected,...q})=>q)],expected=[...plan.yesNo,...mandatory.map(({expected,...q})=>({...q,answer_value:expected}))];
|
|
307
336
|
await step('yesno',async()=>{
|
|
308
|
-
const source=await readSaved();return {found:expected.every(
|
|
337
|
+
const source=await readSaved();return {found:expected.every(original=>{const q=kind==='entry'?record.correctedScalars?.[original.virt_field_name]??original:original,r=source.find(r=>r.virt_field_name===q.virt_field_name);return r&&sameAnswer(q,r.answer_value)&&(q.answer_value===''||restDate(r.date_effective)===restDate(at));}),value:plan.yesNo.map(q=>q.virt_field_name)};
|
|
309
338
|
},()=>api.assessments.create({path:{clientId:input.clientId,assessmentCode:code},query:{dateEffective:at},body}));
|
|
310
339
|
}
|
|
311
340
|
// Group rows into one creation request. Partial success blocks resubmission until all rows reconcile.
|
|
@@ -314,8 +343,8 @@ export function createEnrollmentWorkflow({api,discovery,helpers,store,enrollment
|
|
|
314
343
|
for(const row of plan.rows){
|
|
315
344
|
const parent=source.find(q=>q.virt_field_name===row.parentField);if(!Array.isArray(parent?.sub_assessment_answer_list))fail('subassessment read missing');
|
|
316
345
|
const known=record.steps.rows?.value?.[row.key];
|
|
317
|
-
const expected=row.encoded.map(
|
|
318
|
-
const candidates=parent.sub_assessment_answer_list.filter(r=>(!known||r.recordset_id===known)&&expected.every(q=>{const fields=r.sub_assessment_list?.filter(c=>c.virt_field_name===q.virt_field_name)??[];return fields.length===1&&fields[0].question_type===q.question_type&&sameAnswer(q,fields[0].answer_value);}));
|
|
346
|
+
const expected=row.encoded.map(original=>{const q=kind==='entry'?record.correctedRows?.[row.parentField]?.[known]?.[original.virt_field_name]??original:original;const overrides=records.flatMap(r=>(r.ends??[]).filter(e=>e.recordsetId===known&&r.steps['end:'+e.recordsetId]?.state==='verified'&&e.endDateField===q.virt_field_name));if(overrides.length>1)fail('multiple end-date overrides');return overrides.length?{...q,answer_value:overrides[0].endDate}:q;});
|
|
347
|
+
const candidates=parent.sub_assessment_answer_list.filter(r=>(!known||r.recordset_id===known)&&expected.every(q=>{const fields=r.sub_assessment_list?.filter(c=>c.virt_field_name===q.virt_field_name)??[];return fields.length===0&&q.answer_value===''&&record.correctedRows?.[row.parentField]?.[known]?.[q.virt_field_name]||fields.length===1&&fields[0].question_type===q.question_type&&sameAnswer(q,fields[0].answer_value);}));
|
|
319
348
|
if(candidates.length>1)fail('duplicate matching subassessment rows');if(candidates.length){if(!positive(candidates[0].recordset_id)||matchedIds.has(candidates[0].recordset_id))fail('invalid or reused subassessment ID');matchedIds.add(candidates[0].recordset_id);matched[row.key]=candidates[0].recordset_id;}else missing.push(row);
|
|
320
349
|
}
|
|
321
350
|
return {found:missing.length===0,value:matched,missing};
|
|
@@ -343,6 +372,82 @@ export function createEnrollmentWorkflow({api,discovery,helpers,store,enrollment
|
|
|
343
372
|
const householdScope=headClientId=>JSON.stringify([tenantScope,'household-enrollment-workflow',headClientId]);
|
|
344
373
|
const memberKey=(key,clientId)=>'household:'+hash({key,clientId});
|
|
345
374
|
const withClients=(ids,work,index=0)=>index===ids.length?work():store.withLock(scope(ids[index]),()=>withClients(ids,work,index+1));
|
|
375
|
+
const adopt=async(household,raw)=>{
|
|
376
|
+
fields(raw,household?['key','householdId','headClientId','members']:['key','clientId','enrollmentId']);
|
|
377
|
+
const input=structuredClone(raw);key(input.key);
|
|
378
|
+
if(typeof enrollmentSession?.readEnrollment!=='function')fail('authoritative enrollment session required for adoption');
|
|
379
|
+
const targets=household?input.members:[{clientId:input.clientId,enrollmentId:input.enrollmentId}];
|
|
380
|
+
if(!Array.isArray(targets)||!targets.length)fail('explicit enrollment targets required');
|
|
381
|
+
for(const target of targets){fields(target,['clientId','enrollmentId']);if(!positive(target.clientId)||!positive(target.enrollmentId))fail('explicit enrollment targets required');}
|
|
382
|
+
targets.sort((a,b)=>a.clientId-b.clientId);const ids=targets.map(t=>t.clientId);
|
|
383
|
+
if(new Set(ids).size!==ids.length||new Set(targets.map(t=>t.enrollmentId)).size!==targets.length)fail('duplicate adoption targets');
|
|
384
|
+
if(household&&(!positive(input.householdId)||!positive(input.headClientId)||!ids.includes(input.headClientId)||typeof api.households?.get!=='function'))fail('household, original head and complete enrollment cohort required');
|
|
385
|
+
const owner=hash({adoption:true,input}),intentHash=hash(input);
|
|
386
|
+
return withClients(ids,async()=>{
|
|
387
|
+
const locked=household?work=>store.withLock(householdScope(input.headClientId),work):work=>work();
|
|
388
|
+
return locked(async()=>{
|
|
389
|
+
const groups=household?await store.read(householdScope(input.headClientId)):[];
|
|
390
|
+
if(!Array.isArray(groups)||groups.some(r=>r.stage!=='verified'&&r.key!==input.key))fail('another unfinished household workflow must be reconciled first');
|
|
391
|
+
const groupMatches=groups.filter(r=>r.key===input.key);if(groupMatches.length>1)fail('duplicate household journal keys');let group=groupMatches[0];
|
|
392
|
+
if(group&&(!group.adopted||group.intentHash!==intentHash))fail('adoption key conflicts with previous intent');
|
|
393
|
+
const journals=new Map(),prior=new Map();
|
|
394
|
+
for(const target of targets){
|
|
395
|
+
const reservations=await store.withLock(reservationScope(target.clientId),()=>store.read(reservationScope(target.clientId)));
|
|
396
|
+
if(!Array.isArray(reservations)||reservations.some(r=>r.stage!=='verified'&&r.owner!==owner))fail('unfinished household workflow must be reconciled first');
|
|
397
|
+
const records=await store.read(scope(target.clientId)),member=household?memberKey(input.key,target.clientId):input.key;
|
|
398
|
+
if(!Array.isArray(records)||records.some(r=>r.version!==1||!object(r.steps)))fail('invalid workflow journal');
|
|
399
|
+
if(records.some(r=>r.stage!=='verified'&&!(r.kind==='correction'&&r.stage==='rejected')&&r.key!==member))fail('another unfinished workflow must be reconciled first');
|
|
400
|
+
if(records.some(r=>r.kind==='entry'&&r.enrollmentId===target.enrollmentId&&r.key!==member))fail('enrollment already journaled; use its existing entry key');
|
|
401
|
+
const matches=records.filter(r=>r.key===member);if(matches.length>1)fail('duplicate journal keys');
|
|
402
|
+
if(matches[0]&&(!matches[0].adopted||matches[0].intentHash!==intentHash))fail('adoption key conflicts with previous intent');
|
|
403
|
+
journals.set(target.clientId,records);prior.set(target.clientId,matches[0]);
|
|
404
|
+
}
|
|
405
|
+
const snapshots=[];
|
|
406
|
+
for(const target of targets){
|
|
407
|
+
const s=await enrollmentSession.readEnrollment(target);
|
|
408
|
+
if(s?.source!=='gwt-rpc'||s.tenant!==api.tenant||s.clientId!==target.clientId||s.enrollmentId!==target.enrollmentId||!positive(s.providerId)||!entryExitTypes.includes(s.enrollmentType)||!Number.isFinite(restDate(s.projectStartDate)))fail('adoption enrollment context mismatch');
|
|
409
|
+
if(!Array.isArray(s.members)||canonical([...s.members].sort((a,b)=>a.clientId-b.clientId))!==canonical(targets))fail(household?'adoption requires the complete linked enrollment cohort':'linked household enrollment requires adoptHouseholdEnrollment');
|
|
410
|
+
const exits=s.exits?.filter(e=>e.clientId===target.clientId&&e.enrollmentId===target.enrollmentId);
|
|
411
|
+
if(exits?.length!==1||exits[0].projectExitDate!==null&&!Number.isFinite(restDate(exits[0].projectExitDate)))fail('authoritative adoption exit date required');
|
|
412
|
+
const start=restDate(s.projectStartDate),end=exits[0].projectExitDate===null?Infinity:restDate(exits[0].projectExitDate);if(end<start)fail('adoption exit precedes entry');
|
|
413
|
+
snapshots.push({target,s,start,end});
|
|
414
|
+
}
|
|
415
|
+
const head=snapshots.find(r=>r.target.clientId===(household?input.headClientId:input.clientId));
|
|
416
|
+
if(snapshots.some(r=>r.s.providerId!==head.s.providerId||r.s.enrollmentType!==head.s.enrollmentType||r.start<head.start||r.end>head.end))fail('invalid enrollment data: adopted members require matching program and head coverage');
|
|
417
|
+
if(household){
|
|
418
|
+
const found=toItems(await api.households.get({path:{householdId:input.householdId}}));
|
|
419
|
+
if(found.length!==1||found[0].householdId!==input.householdId||found[0].isActive===false||!Array.isArray(found[0].clients))fail('active household required for adoption');
|
|
420
|
+
const members=found[0].clients,heads=members.filter(m=>m.headofhouseholdStatus&&m.isActive!==false&&!m.dateLeft);
|
|
421
|
+
if(heads.length!==1||heads[0].clientId!==input.headClientId)fail('adoption household head mismatch');
|
|
422
|
+
for(const r of snapshots){const membersForClient=members.filter(m=>m.clientId===r.target.clientId);if(membersForClient.length!==1||membersForClient[0].isActive===false||membersForClient[0].dateLeft||!Number.isFinite(restDate(membersForClient[0].dateJoined))||restDate(membersForClient[0].dateJoined)>r.start||!Array.isArray(r.s.householdClientIds)||ids.some(id=>!r.s.householdClientIds.includes(id)))fail('adoption household membership mismatch');}
|
|
423
|
+
}
|
|
424
|
+
const prepared=[];
|
|
425
|
+
for(const r of snapshots){
|
|
426
|
+
const context={clientId:r.target.clientId,providerId:r.s.providerId,type:r.s.enrollmentType,entryDate:new Date(r.start).toISOString()};
|
|
427
|
+
const discovered=await discover(context),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}});
|
|
428
|
+
const headQuestion=discovered.entry.definition.questions.find(q=>q.fieldName==='HUD_RELATIONTOHOH');
|
|
429
|
+
if(headQuestion){
|
|
430
|
+
const self=headQuestion.options.filter(o=>o.code==='RELATIONHOH_SELF'),answers=questions(await api.assessments.get({path:{clientId:r.target.clientId,assessmentCode:discovered.entry.assessment.code},query:{dateEffective:context.entryDate}}));
|
|
431
|
+
const answer=answers.find(q=>q.virt_field_name==='HUD_RELATIONTOHOH')?.answer_value;
|
|
432
|
+
if(self.length!==1||answer===null||answer===undefined||answer===''||(String(answer)===String(self[0].id))!==(r.target.clientId===head.target.clientId))fail('adoption requires the original enrollment head relationship');
|
|
433
|
+
}
|
|
434
|
+
const existing=prior.get(r.target.clientId);
|
|
435
|
+
if(existing&&(existing.enrollmentId!==r.target.enrollmentId||canonical(existing.context)!==canonical(context)||existing.schemaHash!==schemaHash))fail('adopted enrollment identity or configuration changed');
|
|
436
|
+
prepared.push(existing??{version:1,key:household?memberKey(input.key,r.target.clientId):input.key,kind:'entry',adopted:true,intentHash,schemaHash,context,enrollmentId:r.target.enrollmentId,assessmentCode:discovered.entry.assessment.code,stage:'verified',steps:{enrollment:{state:'verified',value:r.target.enrollmentId}},rows:{},ends:[]});
|
|
437
|
+
}
|
|
438
|
+
const wasVerified=household?group?.stage==='verified':prior.get(input.clientId)?.stage==='verified';
|
|
439
|
+
const results=prepared.map(r=>({key:r.key,outcome:wasVerified?'already-present':'verified',clientId:r.context.clientId,enrollmentId:r.enrollmentId,assessmentCode:r.assessmentCode,effectiveDate:r.context.entryDate,recordsetIds:structuredClone(r.rows)}));
|
|
440
|
+
if(household){
|
|
441
|
+
if(!group){group={version:1,key:input.key,kind:'entry',adopted:true,intentHash,context:{householdId:input.householdId,headClientId:input.headClientId,providerId:head.s.providerId,type:head.s.enrollmentType,entryDate:new Date(head.start).toISOString()},clientIds:ids,stage:'pending',members:{},attached:true};groups.push(group);}
|
|
442
|
+
await store.write(householdScope(input.headClientId),structuredClone(groups));
|
|
443
|
+
for(const clientId of ids)await store.withLock(reservationScope(clientId),()=>store.write(reservationScope(clientId),[{owner,stage:'pending'}]));
|
|
444
|
+
}
|
|
445
|
+
for(const r of prepared){if(!prior.get(r.context.clientId))journals.get(r.context.clientId).push(r);await store.write(scope(r.context.clientId),structuredClone(journals.get(r.context.clientId)));}
|
|
446
|
+
if(household){group.members=Object.fromEntries(results.map(r=>[r.clientId,r]));group.stage='verified';await store.write(householdScope(input.headClientId),structuredClone(groups));for(const clientId of ids)await store.withLock(reservationScope(clientId),()=>store.write(reservationScope(clientId),[{owner,stage:'verified'}]));return {key:input.key,outcome:wasVerified?'already-present':'verified',householdId:input.householdId,headClientId:input.headClientId,members:results};}
|
|
447
|
+
return results[0];
|
|
448
|
+
});
|
|
449
|
+
});
|
|
450
|
+
};
|
|
346
451
|
const householdRun=async(kind,raw)=>{
|
|
347
452
|
if(typeof enrollmentSession?.readEnrollment!=='function'||typeof helpers.addHouseholdMembers!=='function'||typeof api.households?.get!=='function')fail('household API and authoritative enrollment session required');
|
|
348
453
|
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']:[])]);
|
|
@@ -374,7 +479,7 @@ export function createEnrollmentWorkflow({api,discovery,helpers,store,enrollment
|
|
|
374
479
|
const base=entry??record;
|
|
375
480
|
const catalog={};
|
|
376
481
|
for(const group of [base,...records.filter(r=>r.kind==='addition'&&r.stage==='verified'&&r.enrollmentKey===(entry?.key??input.key))].filter(Boolean)){
|
|
377
|
-
for(const clientId of group.clientIds){if(catalog[clientId])fail('duplicate household member enrollment');catalog[clientId]={...group.members[clientId],entryDate:group.context.entryDate,entryKey:group.key};}
|
|
482
|
+
for(const clientId of group.clientIds){if(catalog[clientId])fail('duplicate household member enrollment');catalog[clientId]={...group.members[clientId],entryDate:group.members[clientId]?.effectiveDate??group.context.entryDate,entryKey:group.key};}
|
|
378
483
|
}
|
|
379
484
|
if(kind==='addition'){
|
|
380
485
|
if(typeof memberEntryDatePort?.validateMemberEntryDate!=='function'||typeof memberEntryDatePort?.saveMemberEntryDate!=='function')fail('member entry date port required');
|
|
@@ -434,11 +539,21 @@ export function createEnrollmentWorkflow({api,discovery,helpers,store,enrollment
|
|
|
434
539
|
if(!record){record={version:1,key:input.key,kind,intentHash,context,...(input.enrollmentKey?{enrollmentKey:input.enrollmentKey}:{}),clientIds:ids,stage:'pending',members:{},...(kind==='exit'?{exitSchemas:preflight}:kind==='addition'?{entrySchemas:preflight}:{})};records.push(record);}
|
|
435
540
|
const persist=()=>store.write(householdScope(input.headClientId),structuredClone(records));await persist();
|
|
436
541
|
for(const clientId of cohortIds)await store.withLock(reservationScope(clientId),()=>store.write(reservationScope(clientId),[{owner,stage:'pending'}]));
|
|
437
|
-
const
|
|
542
|
+
const writeAttempted=async()=>{
|
|
543
|
+
if(Object.keys(record.members).length>0||record.attachmentAttempted===true)return true;
|
|
544
|
+
// The head's creation can commit before its result reaches the household journal.
|
|
545
|
+
// Its per-client attempt marker remains authoritative on the next household retry.
|
|
546
|
+
for(const clientId of ids){
|
|
547
|
+
const entries=await store.read(scope(clientId));if(!Array.isArray(entries))fail('invalid workflow journal');
|
|
548
|
+
if(entries.some(r=>r.key===memberKey(input.key,clientId)&&r.steps&&Object.keys(r.steps).length>0))return true;
|
|
549
|
+
}
|
|
550
|
+
return false;
|
|
551
|
+
};
|
|
552
|
+
const findMember=clientId=>async({knownId,writeAttempted:memberWriteAttempted=false})=>{
|
|
438
553
|
const expected=record.members[clientId]?.enrollmentId??catalog[clientId]?.enrollmentId??knownId;
|
|
439
554
|
if(expected){if(knownId&&knownId!==expected)fail('household enrollment identity changed');await readTarget({clientId,enrollmentId:expected},{partial:kind==='entry'&&clientId===input.headClientId&&!record.attached});return {found:true,value:expected};}
|
|
440
555
|
if(clientId!==input.headClientId)fail('member enrollment has not been attached');
|
|
441
|
-
const found=(await enrollments(api,clientId)).filter(e=>e.enrollmentProvider===context.providerId&&e.type===context.type&&e.isActive&&restDate(e.projectStartDate)===restDate(context.entryDate));
|
|
556
|
+
const found=(await enrollments(api,clientId,{enrollmentSession,writeAttempted:memberWriteAttempted||await writeAttempted()})).filter(e=>e.enrollmentProvider===context.providerId&&e.type===context.type&&e.isActive&&e.projectExitDate==null&&restDate(e.projectStartDate)===restDate(context.entryDate));
|
|
442
557
|
if(found.length>1)fail('duplicate matching enrollments');
|
|
443
558
|
if(found[0])await readTarget({clientId,enrollmentId:found[0].enrollmentId},{partial:!record.attached});
|
|
444
559
|
return {found:found.length===1,value:found[0]?.enrollmentId};
|
|
@@ -462,7 +577,7 @@ export function createEnrollmentWorkflow({api,discovery,helpers,store,enrollment
|
|
|
462
577
|
// Before attachment, an independently matching member enrollment is ambiguous.
|
|
463
578
|
// Once any attachment was attempted, only the GWT journal can reconcile it.
|
|
464
579
|
if(!record.attachmentAttempted)for(const clientId of ids.filter(id=>id!==input.headClientId)){
|
|
465
|
-
const existing=await enrollments(api,clientId);if(existing.some(e=>e.enrollmentProvider===context.providerId&&e.type===context.type&&e.isActive&&restDate(e.projectStartDate)===restDate(context.entryDate)))fail('member already has an independent matching enrollment');
|
|
580
|
+
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');
|
|
466
581
|
}
|
|
467
582
|
const head=input.members.find(m=>m.clientId===input.headClientId);
|
|
468
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();
|
|
@@ -483,7 +598,7 @@ export function createEnrollmentWorkflow({api,discovery,helpers,store,enrollment
|
|
|
483
598
|
if(!record.anchor)fail('open household enrollment required to add a member');await persist();
|
|
484
599
|
}
|
|
485
600
|
if(!record.attachmentAttempted)for(const clientId of ids){
|
|
486
|
-
const existing=await enrollments(api,clientId);if(existing.some(e=>e.enrollmentProvider===context.providerId&&e.type===context.type&&e.isActive))fail('additional member already has an independent enrollment');
|
|
601
|
+
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');
|
|
487
602
|
}
|
|
488
603
|
record.attachmentAttempted=true;await persist();
|
|
489
604
|
const attached=await helpers.addHouseholdMembers({...record.anchor,memberClientIds:ids});
|
|
@@ -583,6 +698,9 @@ export function createEnrollmentWorkflow({api,discovery,helpers,store,enrollment
|
|
|
583
698
|
}));
|
|
584
699
|
};
|
|
585
700
|
return {
|
|
701
|
+
correctEntry,
|
|
702
|
+
resolveEntryCorrection,
|
|
703
|
+
adoptEnrollment:input=>adopt(false,input),adoptHouseholdEnrollment:input=>adopt(true,input),
|
|
586
704
|
addHouseholdMembers:input=>householdRun('addition',input),
|
|
587
705
|
createHouseholdEnrollment:input=>householdRun('entry',input),createHouseholdInterim:input=>householdRun('interim',input),exitHousehold:input=>householdRun('exit',input),
|
|
588
706
|
discoverExit:input=>discover(input,true),
|
|
@@ -602,7 +720,7 @@ export function createEnrollmentWorkflow({api,discovery,helpers,store,enrollment
|
|
|
602
720
|
const records=await store.read(scope(input.clientId));if(!Array.isArray(records))fail('invalid workflow journal');
|
|
603
721
|
const matches=records.filter(r=>r.key===input.key);if(matches.length>1)fail('duplicate journal keys');const r=matches[0];if(!r)return null;
|
|
604
722
|
if(r.version!==1||!object(r.steps))fail('invalid workflow journal');
|
|
605
|
-
return {key:r.key,kind:r.kind,stage:r.stage,assessmentCode:r.assessmentCode,...(r.enrollmentId?{enrollmentId:r.enrollmentId}:{}),...(r.reviewId?{reviewId:r.reviewId}:{}),...(r.projectExitDate?{projectExitDate:r.projectExitDate,exitSubgroupId:r.exitSubgroupId}:{}),recordsetIds:structuredClone(r.rows),steps:Object.entries(r.steps).map(([name,step])=>({name,state:step.state}))};
|
|
723
|
+
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)}:{})}))};
|
|
606
724
|
});
|
|
607
725
|
},
|
|
608
726
|
};
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
import {createHash} from 'node:crypto';
|
|
2
|
+
import {encodeAnswers,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
|
+
const scalarTypes=new Set(['lookup','yes_no','date','int','money','textbox','textarea']);
|
|
9
|
+
export function correctionFailureDetails(error){
|
|
10
|
+
const failure={};
|
|
11
|
+
if(Number.isInteger(error?.status)&&error.status>=100&&error.status<=599)failure.status=error.status;
|
|
12
|
+
if(['http','auth','timeout','network','aborted','protocol','validation','throttled','cooldown'].includes(error?.kind))failure.kind=error.kind;
|
|
13
|
+
return failure;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** Internal correction writer. The workflow supplies its existing client locks and journal scopes. */
|
|
17
|
+
export function createEntryCorrector({api,store,enrollmentSession,writeLabel,discover,scope,reservationScope,makeError}){
|
|
18
|
+
const fail=reason=>{throw makeError(reason);};
|
|
19
|
+
const fields=(v,allowed)=>{if(!object(v)||Object.keys(v).some(k=>!allowed.includes(k)))fail('unknown or invalid correction fields');};
|
|
20
|
+
const key=v=>{if(typeof v!=='string'||!v.trim()||v.length>200)fail('stable correction key required');};
|
|
21
|
+
const value=(type,v)=>{
|
|
22
|
+
if(v===null||v===undefined||v==='')return null;
|
|
23
|
+
if(type==='date'){const n=parseRestInstant(v);if(!Number.isFinite(n))fail('invalid stored correction date');return n;}
|
|
24
|
+
if(type==='int'||type==='money'){const n=Number(v);if(!Number.isFinite(n))fail('invalid stored correction number');return n;}
|
|
25
|
+
return String(v);
|
|
26
|
+
};
|
|
27
|
+
const encode=(questions,answers,clear,endpoint)=>{
|
|
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
|
+
const index=new Map(questions.map(q=>[q.fieldName,q]));
|
|
30
|
+
const unsupportedClears=clear.filter(field=>index.get(field)?.questionType==='yes_no');
|
|
31
|
+
if(unsupportedClears.length)throw makeError('yes/no answers cannot be cleared',{code:'UNSUPPORTED_YES_NO_CLEAR',fields:unsupportedClears});
|
|
32
|
+
for(const field of [...Object.keys(answers),...clear]){
|
|
33
|
+
const q=index.get(field);
|
|
34
|
+
if(!q||!scalarTypes.has(q.questionType)||endpoint==='mobile'&&q.questionType==='yes_no')fail('unsupported correction field type');
|
|
35
|
+
if(['SVPPROFDOB','HUD_RELATIONTOHOH'].includes(field))fail('DOB and household head corrections require their dedicated workflows');
|
|
36
|
+
}
|
|
37
|
+
const body=encodeAnswers(questions,answers,{endpoint,writeLabel});
|
|
38
|
+
if(body.length!==Object.keys(answers).length)fail('correction answer could not be encoded');
|
|
39
|
+
for(const q of body)if(q.question_type==='date'&&q.answer_value){const day=q.answer_value.slice(0,10);if(!Number.isFinite(parseRestInstant(q.answer_value))||!/^\d{4}-\d{2}-\d{2}$/.test(day)||new Date(day+'T00:00:00Z').toISOString().slice(0,10)!==day)fail('invalid correction date');}
|
|
40
|
+
for(const field of clear){const q=index.get(field);body.push({question_id:q.questionId,question_type:q.questionType,virt_field_name:field,answer_value:''});}
|
|
41
|
+
return body;
|
|
42
|
+
};
|
|
43
|
+
const expected=(questions,body,answers)=>body.map(q=>{
|
|
44
|
+
if(q.question_type!=='lookup'||q.answer_value==='')return q;
|
|
45
|
+
const encoded=encodeAnswers(questions,{[q.virt_field_name]:answers[q.virt_field_name]},{endpoint:'mobile'});
|
|
46
|
+
if(encoded.length!==1)fail('correction lookup could not be resolved');return encoded[0];
|
|
47
|
+
});
|
|
48
|
+
const readQuestions=async(clientId,assessmentCode,at)=>{
|
|
49
|
+
const rows=toItems(await api.assessments.get({path:{clientId,assessmentCode},query:{dateEffective:at}}));
|
|
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
|
+
return rows;
|
|
52
|
+
};
|
|
53
|
+
const run=async(mode,raw)=>{
|
|
54
|
+
const resolving=mode==='resolve';
|
|
55
|
+
fields(raw,resolving?['key','enrollmentKey','clientId','evidenceReference','confirmNotApplied']:['key','enrollmentKey','clientId','answers','clear','subassessments']);
|
|
56
|
+
const {confirmNotApplied,...serializable}=raw;
|
|
57
|
+
const input=structuredClone(serializable);key(input.key);key(input.enrollmentKey);
|
|
58
|
+
if(resolving){key(input.evidenceReference);if(typeof confirmNotApplied!=='function')fail('independent non-application confirmation required');}
|
|
59
|
+
if(!positive(input.clientId)||!Array.isArray(input.subassessments??[]))fail('invalid correction client or rows');
|
|
60
|
+
if(typeof api.assessments?.create!=='function'||typeof enrollmentSession?.readEnrollment!=='function')fail('standard assessment writes and authoritative enrollment reader required for corrections');
|
|
61
|
+
return store.withLock(scope(input.clientId),async()=>{
|
|
62
|
+
const reservations=await store.withLock(reservationScope(input.clientId),()=>store.read(reservationScope(input.clientId)));
|
|
63
|
+
if(!Array.isArray(reservations)||reservations.some(r=>r.stage!=='verified'))fail('unfinished household workflow must be reconciled first');
|
|
64
|
+
const records=await store.read(scope(input.clientId));
|
|
65
|
+
if(!Array.isArray(records)||records.some(r=>!object(r)||r.version!==1||!object(r.steps)))fail('invalid workflow journal');
|
|
66
|
+
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
|
+
const entries=records.filter(r=>r.kind==='entry'&&r.key===input.enrollmentKey&&r.stage==='verified');
|
|
68
|
+
if(entries.length!==1)fail('verified enrollment workflow required');const entry=entries[0];
|
|
69
|
+
const matches=records.filter(r=>r.key===input.key);if(matches.length>1)fail('duplicate correction journal keys');let record=matches[0];
|
|
70
|
+
const intentHash=hash(input);
|
|
71
|
+
if(resolving){
|
|
72
|
+
if(!record||record.kind!=='correction'||record.enrollmentKey!==input.enrollmentKey)fail('matching correction journal required');
|
|
73
|
+
if(record.stage==='verified')fail('verified correction cannot be rejected');
|
|
74
|
+
if(record.stage==='rejected'){
|
|
75
|
+
if(record.resolution?.evidenceReference!==input.evidenceReference)fail('resolution evidence reference changed');
|
|
76
|
+
return {key:input.key,enrollmentKey:input.enrollmentKey,clientId:input.clientId,outcome:'already-rejected',resolution:structuredClone(record.resolution)};
|
|
77
|
+
}
|
|
78
|
+
}else{
|
|
79
|
+
if(record?.kind==='correction'&&record.stage==='rejected')throw makeError('correction was resolved as not applied; use a new key for a corrected intent',{code:'ENTRY_CORRECTION_REJECTED'});
|
|
80
|
+
if(record&&(record.kind!=='correction'||record.intentHash!==intentHash))fail('correction key conflicts with previous intent');
|
|
81
|
+
}
|
|
82
|
+
if(!record&&records.some(r=>r.enrollmentId===entry.enrollmentId&&!['entry','correction'].includes(r.kind)))fail('entry correction must precede interim or exit workflows');
|
|
83
|
+
const {providerId,type,entryDate}=entry.context,at=new Date(parseRestInstant(entryDate)).toISOString(),readAt=new Date(parseRestInstant(at)+1000).toISOString();
|
|
84
|
+
const target={clientId:input.clientId,enrollmentId:entry.enrollmentId};
|
|
85
|
+
const verifyEnrollment=async()=>{
|
|
86
|
+
const s=await enrollmentSession.readEnrollment(target);
|
|
87
|
+
if(s?.source!=='gwt-rpc'||s.tenant!==api.tenant||s.clientId!==target.clientId||s.enrollmentId!==target.enrollmentId||s.providerId!==providerId||s.enrollmentType!==type||parseRestInstant(s.projectStartDate)!==parseRestInstant(at)||!Array.isArray(s.members)||s.members.filter(m=>m.clientId===target.clientId&&m.enrollmentId===target.enrollmentId).length!==1)fail('entry correction enrollment context mismatch');
|
|
88
|
+
const exits=s.exits?.filter(e=>e.clientId===target.clientId&&e.enrollmentId===target.enrollmentId);
|
|
89
|
+
if(exits?.length!==1||exits[0].projectExitDate!==null||!Array.isArray(s.reviews)||s.reviews.length)fail('entry correction requires an open enrollment without reviews');
|
|
90
|
+
};
|
|
91
|
+
await verifyEnrollment();
|
|
92
|
+
const discovered=await discover({clientId:input.clientId,providerId,type,entryDate:at});
|
|
93
|
+
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
|
+
if(schemaHash!==entry.schemaHash||record&&schemaHash!==record.schemaHash)fail('program assessment configuration changed');
|
|
95
|
+
const definition=discovered.entry.definition,code=definition.assessmentCode;
|
|
96
|
+
const snapshot=async()=>{
|
|
97
|
+
const raw=await readQuestions(input.clientId,code,readAt),current=await readQuestions(input.clientId,code,new Date(Math.max(Date.now(),parseRestInstant(readAt))).toISOString());
|
|
98
|
+
const scalars={},rows={};
|
|
99
|
+
for(const q of definition.questions){
|
|
100
|
+
if(['blank','label'].includes(q.questionType))continue;
|
|
101
|
+
if(q.questionType!=='sub_assessment'){
|
|
102
|
+
const actual=raw.find(r=>r.virt_field_name===q.fieldName);if(!actual)fail('correction scalar read missing');
|
|
103
|
+
const date=actual.date_effective?parseRestInstant(actual.date_effective):null;if(date!==null&&!Number.isFinite(date))fail('invalid correction effective date');
|
|
104
|
+
scalars[q.fieldName]={type:q.questionType,value:value(q.questionType,actual.answer_value),date};
|
|
105
|
+
continue;
|
|
106
|
+
}
|
|
107
|
+
const parent=current.find(r=>r.virt_field_name===q.fieldName);if(!parent||!Array.isArray(parent.sub_assessment_answer_list??[]))fail('correction subassessment read missing');
|
|
108
|
+
rows[q.fieldName]={};
|
|
109
|
+
for(const row of parent.sub_assessment_answer_list??[]){
|
|
110
|
+
if(!positive(row.recordset_id)||Object.hasOwn(rows[q.fieldName],row.recordset_id)||!Array.isArray(row.sub_assessment_list))fail('ambiguous correction row');
|
|
111
|
+
const cells={};
|
|
112
|
+
for(const child of q.children){
|
|
113
|
+
if(['label','blank'].includes(child.questionType))continue;
|
|
114
|
+
const found=row.sub_assessment_list.filter(c=>c.virt_field_name===child.fieldName);if(found.length>1||found[0]&&found[0].question_type!==child.questionType)fail('ambiguous correction row cell');
|
|
115
|
+
cells[child.fieldName]={type:child.questionType,value:value(child.questionType,found[0]?.answer_value)};
|
|
116
|
+
}
|
|
117
|
+
if(row.sub_assessment_list.some(c=>!q.children.some(child=>child.fieldName===c.virt_field_name)))fail('correction row metadata incomplete');
|
|
118
|
+
rows[q.fieldName][row.recordset_id]=cells;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
return {raw,scalars,rows};
|
|
122
|
+
};
|
|
123
|
+
const observed=await snapshot();
|
|
124
|
+
if(resolving){
|
|
125
|
+
if(record.stage!=='pending'||!Object.values(record.steps).some(s=>s.state==='attempted'))fail('attempted correction required for resolution');
|
|
126
|
+
const unchanged=state=>canonical({scalars:state.scalars,rows:state.rows})===canonical(record.before);
|
|
127
|
+
if(!unchanged(observed))fail('correction state differs from its pre-write snapshot; non-application cannot be resolved');
|
|
128
|
+
const evidence={clientId:input.clientId,enrollmentId:entry.enrollmentId,enrollmentKey:input.enrollmentKey,key:input.key,effectiveDate:at,matchesBefore:true,steps:Object.entries(record.steps).map(([name,s])=>({name,state:s.state,...(s.failure?{failure:correctionFailureDetails(s.failure)}:{})}))};
|
|
129
|
+
if(await confirmNotApplied(evidence)!==true)fail('independent non-application confirmation required');
|
|
130
|
+
await verifyEnrollment();if(!unchanged(await snapshot()))fail('correction state changed during resolution');
|
|
131
|
+
record.resolution={kind:'not-applied',evidenceReference:input.evidenceReference,resolvedAt:new Date().toISOString()};record.stage='rejected';
|
|
132
|
+
await store.write(scope(input.clientId),structuredClone(records));
|
|
133
|
+
return {key:input.key,enrollmentKey:input.enrollmentKey,clientId:input.clientId,outcome:'rejected',resolution:structuredClone(record.resolution)};
|
|
134
|
+
}
|
|
135
|
+
const readOverrides=state=>{
|
|
136
|
+
for(const q of Object.values(entry.correctedScalars??{})){
|
|
137
|
+
const actual=state.scalars[q.virt_field_name];if(!actual||actual.value!==value(q.question_type,q.answer_value)||actual.value!==null&&actual.date!==parseRestInstant(at))fail('verified entry correction changed');
|
|
138
|
+
}
|
|
139
|
+
for(const [parent,group]of Object.entries(entry.correctedRows??{}))for(const [id,cells]of Object.entries(group))for(const q of Object.values(cells))if(state.rows[parent]?.[id]?.[q.virt_field_name]?.value!==value(q.question_type,q.answer_value))fail('verified row correction changed');
|
|
140
|
+
};
|
|
141
|
+
const result=outcome=>({key:input.key,enrollmentKey:input.enrollmentKey,outcome,clientId:input.clientId,enrollmentId:entry.enrollmentId,assessmentCode:code,effectiveDate:at,recordsetIds:structuredClone(entry.rows)});
|
|
142
|
+
if(record?.stage==='verified'){readOverrides(observed);return result('already-present');}
|
|
143
|
+
if(!record){
|
|
144
|
+
readOverrides(observed);
|
|
145
|
+
const answers=input.answers??{},clear=input.clear??[];
|
|
146
|
+
const scalarBody=encode(definition.questions,answers,clear,'standalone'),scalarExpected=expected(definition.questions,scalarBody,answers);
|
|
147
|
+
// The standard endpoint requires every mandatory scalar, even if this patch leaves it unchanged.
|
|
148
|
+
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
|
+
if(q.fieldName==='SVPPROFDOB'||!scalarTypes.has(q.questionType))fail('unsupported mandatory correction field');
|
|
150
|
+
const prior=observed.raw.find(r=>r.virt_field_name===q.fieldName);
|
|
151
|
+
if(prior?.answer_value===null||prior?.answer_value===undefined||prior?.answer_value==='')fail('mandatory correction field requires an explicit answer or clear');
|
|
152
|
+
let body;
|
|
153
|
+
if(q.questionType==='lookup'){
|
|
154
|
+
const options=q.options.filter(o=>String(o.id)===String(prior.answer_value)),label=options.length===1?writeLabel?.(q,options[0]):undefined;
|
|
155
|
+
if(!label)fail('mandatory correction lookup label unavailable');
|
|
156
|
+
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});
|
|
158
|
+
if(body.length!==1)fail('mandatory correction field could not be encoded');scalarBody.push(body[0]);
|
|
159
|
+
scalarExpected.push({...body[0],answer_value:prior.answer_value});
|
|
160
|
+
}
|
|
161
|
+
const rowPlans=[],ids=new Set();
|
|
162
|
+
for(const patch of input.subassessments??[]){
|
|
163
|
+
fields(patch,['key','parentField','answers','clear']);key(patch.key);
|
|
164
|
+
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
|
+
rowPlans.push({id,parentField:parent.fieldName,questionId:parent.questionId,cells});
|
|
168
|
+
}
|
|
169
|
+
if(!scalarBody.length&&!rowPlans.length)fail('empty entry correction');
|
|
170
|
+
record={version:1,key:input.key,kind:'correction',enrollmentKey:input.enrollmentKey,intentHash,schemaHash,context:structuredClone(entry.context),enrollmentId:entry.enrollmentId,assessmentCode:code,stage:'pending',steps:{},rows:structuredClone(entry.rows),before:{scalars:observed.scalars,rows:observed.rows},scalarBody,scalarExpected,rowPlans};records.push(record);
|
|
171
|
+
}
|
|
172
|
+
const persist=()=>store.write(scope(input.clientId),structuredClone(records));await persist();
|
|
173
|
+
const inspect=async(candidate,complete=false)=>{
|
|
174
|
+
const state=await snapshot(),want=structuredClone(record.before);
|
|
175
|
+
if(complete||candidate==='scalars'||record.steps.scalars?.state==='verified')for(const q of record.scalarExpected)want.scalars[q.virt_field_name]={type:q.question_type,value:value(q.question_type,q.answer_value),date:parseRestInstant(at)};
|
|
176
|
+
for(const plan of record.rowPlans)if(complete||candidate==='row:'+plan.id||record.steps['row:'+plan.id]?.state==='verified')for(const q of plan.cells)want.rows[plan.parentField][plan.id][q.virt_field_name]={type:q.question_type,value:value(q.question_type,q.answer_value)};
|
|
177
|
+
const scalarsMatch=Object.keys(state.scalars).length===Object.keys(want.scalars).length&&Object.entries(want.scalars).every(([field,e])=>{const a=state.scalars[field];return a&&a.type===e.type&&a.value===e.value&&(e.value===null||a.date===e.date);});
|
|
178
|
+
return scalarsMatch&&canonical(state.rows)===canonical(want.rows);
|
|
179
|
+
};
|
|
180
|
+
const step=async(name,submit)=>{
|
|
181
|
+
const uncertain=cause=>makeError('uncertain entry correction requires reconciliation',{step:name,code:'ENTRY_CORRECTION_UNCERTAIN',status:record.steps[name]?.failure?.status,...(cause===undefined?{}:{cause})});
|
|
182
|
+
let found;try{found=await inspect(name);}catch(readError){if(record.steps[name])throw uncertain(readError);throw readError;}
|
|
183
|
+
if(found){record.steps[name]={state:'verified'};await persist();return;}
|
|
184
|
+
if(record.steps[name])throw uncertain();
|
|
185
|
+
if(!await inspect(null))fail('entry changed since correction preflight');
|
|
186
|
+
await verifyEnrollment();record.steps[name]={state:'attempted'};await persist();let cause;
|
|
187
|
+
try{await submit();}catch(e){
|
|
188
|
+
cause=e;const failure=correctionFailureDetails(e);
|
|
189
|
+
if(Object.keys(failure).length){record.steps[name].failure=failure;await persist();}
|
|
190
|
+
}
|
|
191
|
+
let saved;
|
|
192
|
+
try{saved=await inspect(name);}catch(readError){throw uncertain(cause===undefined?readError:new AggregateError([cause,readError],'Correction write and reconciliation read failed'));}
|
|
193
|
+
if(!saved)throw uncertain(cause);
|
|
194
|
+
record.steps[name]={state:'verified'};await persist();
|
|
195
|
+
};
|
|
196
|
+
if(record.scalarBody.length)await step('scalars',()=>api.assessments.create({path:{clientId:input.clientId,assessmentCode:code},query:{dateEffective:at},body:record.scalarBody}));
|
|
197
|
+
for(const plan of record.rowPlans)await step('row:'+plan.id,()=>api.assessmentsMobile.addAnswer({path:{clientId:input.clientId,assessmentCode:code},query:{dateEffective:at},body:[{question_id:plan.questionId,question_type:'sub_assessment',virt_field_name:plan.parentField,answer_value:'',subAssessmentQuestionAnswers:[{recordset_id:plan.id,isDeleted:false,subAssessments:plan.cells}]}]}));
|
|
198
|
+
if(!await inspect(null,true))fail('entry correction did not verify');await verifyEnrollment();
|
|
199
|
+
entry.correctedScalars??={};for(const q of record.scalarExpected)entry.correctedScalars[q.virt_field_name]=q;
|
|
200
|
+
entry.correctedRows??={};for(const plan of record.rowPlans){entry.correctedRows[plan.parentField]??={};entry.correctedRows[plan.parentField][plan.id]??={};for(const q of plan.cells)entry.correctedRows[plan.parentField][plan.id][q.virt_field_name]=q;}
|
|
201
|
+
record.stage='verified';await persist();return result('verified');
|
|
202
|
+
});
|
|
203
|
+
};
|
|
204
|
+
return {correctEntry:raw=>run('correct',raw),resolveEntryCorrection:raw=>run('resolve',raw)};
|
|
205
|
+
}
|
package/browser/index.d.mts
CHANGED
|
@@ -18,14 +18,27 @@ export class LoginError extends Error {
|
|
|
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
20
|
export interface PlaywrightEnrollmentSnapshot extends GwtEnrollmentSnapshot {providerId:number;enrollmentType:string;exits:readonly GwtEnrollmentExit[];}
|
|
21
|
+
export interface EnrollmentListRow {
|
|
22
|
+
enrollmentId:number;clientId:number;enrollmentProvider:number;type:string;
|
|
23
|
+
projectStartDate:string;projectExitDate:string|null;isActive:boolean;
|
|
24
|
+
}
|
|
25
|
+
export interface EnrollmentListSnapshot {
|
|
26
|
+
source:'gwt-rpc';tenant:string;clientId:number;count:number;values:EnrollmentListRow[];
|
|
27
|
+
}
|
|
21
28
|
export interface EnrollmentReader {
|
|
22
29
|
readonly connection?:UiConnection;
|
|
23
30
|
readEnrollment(target:{clientId:number;enrollmentId:number}):Promise<PlaywrightEnrollmentSnapshot>;
|
|
31
|
+
/** Built-in sessions supply this complete, counted list, including exited entries.
|
|
32
|
+
* Legacy custom readers without it retain REST list behavior. */
|
|
33
|
+
listEnrollments?(target:{clientId:number}):Promise<EnrollmentListSnapshot>;
|
|
34
|
+
}
|
|
35
|
+
export interface EnrollmentCatalogReader extends EnrollmentReader {
|
|
36
|
+
listEnrollments(target:{clientId:number}):Promise<EnrollmentListSnapshot>;
|
|
24
37
|
}
|
|
25
38
|
export function openUiSession(options:LoginOptions):Promise<UiSession>;
|
|
26
39
|
/** The read-only enrollment session shares the page lock with mutation helpers. */
|
|
27
|
-
export function openEnrollmentSession(options:LoginOptions&{store:GwtMutationStore & UiMutationStore}):Promise<UiSession&{helpers:GwtEnrollmentHelpers;enrollmentSession:
|
|
28
|
-
export function createPlaywrightGwtSession(options:PlaywrightGwtOptions):Omit<GwtEnrollmentSession,'readEnrollment'>&
|
|
40
|
+
export function openEnrollmentSession(options:LoginOptions&{store:GwtMutationStore & UiMutationStore}):Promise<UiSession&{helpers:GwtEnrollmentHelpers;enrollmentSession:EnrollmentCatalogReader;workflows:ClientWorkflows}>;
|
|
41
|
+
export function createPlaywrightGwtSession(options:PlaywrightGwtOptions):Omit<GwtEnrollmentSession,'readEnrollment'>&EnrollmentCatalogReader;
|
|
29
42
|
export function createPlaywrightEnrollmentHelpers(options:PlaywrightGwtOptions&{store:GwtMutationStore}):GwtEnrollmentHelpers;
|
|
30
43
|
|
|
31
44
|
/** Durable JSON journal. Use a separate namespace from enrollment intents. */
|
package/browser/index.mjs
CHANGED
|
@@ -10,7 +10,7 @@ export async function openEnrollmentSession({store,...loginOptions}) {
|
|
|
10
10
|
try {
|
|
11
11
|
const helpers=createPlaywrightEnrollmentHelpers({page:session.page,store,...session.connection});
|
|
12
12
|
const reader=createPlaywrightGwtSession({page:session.page,...session.connection});
|
|
13
|
-
const enrollmentSession={connection:session.connection,readEnrollment:target=>withPageLock(session.page,()=>reader.readEnrollment(target))};
|
|
13
|
+
const enrollmentSession={connection:session.connection,readEnrollment:target=>withPageLock(session.page,()=>reader.readEnrollment(target)),listEnrollments:target=>withPageLock(session.page,()=>reader.listEnrollments(target))};
|
|
14
14
|
return Object.assign(session,{helpers,enrollmentSession,workflows:createClientWorkflows({page:session.page,store})});
|
|
15
15
|
}catch(error){await session.close();throw error;}
|
|
16
16
|
}
|
|
@@ -21,7 +21,7 @@ export {createServiceUi} from './service-ui.mjs';
|
|
|
21
21
|
export {createServiceRecorder} from './service-recording.mjs';
|
|
22
22
|
export {getSsvfServiceRules} from './ssvf-rules.mjs';
|
|
23
23
|
export {createProgramDiscovery} from './program-discovery.mjs';
|
|
24
|
-
export {createEnrollmentWorkflow,EnrollmentWorkflowError} from './enrollment-workflow.mjs';
|
|
24
|
+
export {createEnrollmentWorkflow,EnrollmentWorkflowError,EnrollmentReadError} from './enrollment-workflow.mjs';
|
|
25
25
|
export {createMemberEntryDatePort} from './member-entry-date.mjs';
|
|
26
26
|
export {createHouseholdExitUi} from './household-exit.mjs';
|
|
27
27
|
|
|
@@ -47,22 +47,25 @@ export function createPlaywrightGwtSession({page,tenant,moduleBaseUrl}) {
|
|
|
47
47
|
}
|
|
48
48
|
throw Error('Could not close enrollment dialogs');
|
|
49
49
|
};
|
|
50
|
-
const
|
|
51
|
-
id(
|
|
52
|
-
const clientHeading=page.getByText(new RegExp(`^Client - \\(${
|
|
50
|
+
const openClient=async(clientId)=>{
|
|
51
|
+
id(clientId);await guard();await closeDialogs();
|
|
52
|
+
const clientHeading=page.getByText(new RegExp(`^Client - \\(${clientId}\\)`));
|
|
53
53
|
if(!await clientHeading.isVisible()){
|
|
54
|
-
const switcher=page.locator('select:visible').filter({has:page.locator(`option[value="${
|
|
54
|
+
const switcher=page.locator('select:visible').filter({has:page.locator(`option[value="${clientId}"]`)});
|
|
55
55
|
if(await switcher.count()===1&&await control(page,'ClientMainView.householdSubmitButton').isVisible()){
|
|
56
|
-
await switcher.selectOption(String(
|
|
56
|
+
await switcher.selectOption(String(clientId));await control(page,'ClientMainView.householdSubmitButton').click();
|
|
57
57
|
}else{
|
|
58
58
|
// A hash alone is not proof that GWT switched the displayed client.
|
|
59
|
-
if(new URL(page.url()).hash===`#loadClient;clientId=${
|
|
60
|
-
await page.goto(moduleBaseUrl+`index.html#loadClient;clientId=${
|
|
59
|
+
if(new URL(page.url()).hash===`#loadClient;clientId=${clientId}`)throw Error('Client navigation did not load the requested client');
|
|
60
|
+
await page.goto(moduleBaseUrl+`index.html#loadClient;clientId=${clientId}`);
|
|
61
61
|
}
|
|
62
62
|
await clientHeading.waitFor();await ready();
|
|
63
63
|
}
|
|
64
64
|
await page.getByRole('tab',{name:'Summary',exact:true}).click();await ready();
|
|
65
65
|
await page.getByRole('tab',{name:'Entry / Exit',exact:true}).click();await ready();
|
|
66
|
+
};
|
|
67
|
+
const openTarget=async(target)=>{
|
|
68
|
+
id(target.enrollmentId);await openClient(target.clientId);
|
|
66
69
|
const edit=control(page,`ClientEntryExitTab.editEntry-imageLink_${target.enrollmentId}`);
|
|
67
70
|
await edit.waitFor();
|
|
68
71
|
const response=page.waitForResponse(r=>r.url()===endpoint&&r.request().postData()?.includes('|getEntryExit|')&&r.request().postData()?.endsWith(`|${target.enrollmentId}|`));
|
|
@@ -97,6 +100,25 @@ export function createPlaywrightGwtSession({page,tenant,moduleBaseUrl}) {
|
|
|
97
100
|
if(ids.some(n=>!Number.isSafeInteger(n)||n<=0))throw Error('Invalid household tree');
|
|
98
101
|
await closeDialogs();return [...new Set([target.clientId,...ids])].sort((a,b)=>a-b);
|
|
99
102
|
};
|
|
103
|
+
const listEnrollments=async({clientId})=>{
|
|
104
|
+
id(clientId);await guard();wire=undefined;let captured;
|
|
105
|
+
const listener=request=>{
|
|
106
|
+
if(request.url()===moduleBaseUrl+'service/MetaQueryService'&&request.postData()?.startsWith('7|0|')){
|
|
107
|
+
const policy=request.postData().split('|')[4];
|
|
108
|
+
captured=request.allHeaders().then(headers=>({policy,headers:Object.fromEntries(['content-type','x-gwt-module-base','x-gwt-permutation'].map(k=>[k,headers[k]]))}));
|
|
109
|
+
}
|
|
110
|
+
};
|
|
111
|
+
page.on('request',listener);
|
|
112
|
+
try{await openClient(clientId);wire=await captured;}finally{page.off('request',listener);}
|
|
113
|
+
if(!wire?.policy||Object.values(wire.headers).some(v=>!v))throw Error('Fresh enrollment list context unavailable');
|
|
114
|
+
const rows=await mapped(`SELECT e.entryExitId AS enrollmentId, e.clientId AS clientId, e.entryDate AS projectStartDate, e.exitDate AS projectExitDate, e.providerId AS enrollmentProvider, e.typeEntryExit.name AS type FROM SpEntryExit AS e WHERE e.active = TRUE AND e.client.active = TRUE AND e.clientId = ${clientId} ORDER BY e.entryExitId`);
|
|
115
|
+
const ids=new Set();
|
|
116
|
+
for(const row of rows){
|
|
117
|
+
if(!Number.isSafeInteger(row.enrollmentId)||row.enrollmentId<=0||row.clientId!==clientId||!Number.isSafeInteger(row.enrollmentProvider)||row.enrollmentProvider<=0||typeof row.type!=='string'||!row.type.trim()||typeof row.projectStartDate!=='string'||!Number.isFinite(Date.parse(row.projectStartDate))||row.projectExitDate!==null&&(typeof row.projectExitDate!=='string'||!Number.isFinite(Date.parse(row.projectExitDate)))||ids.has(row.enrollmentId))throw Error('Invalid enrollment list');
|
|
118
|
+
ids.add(row.enrollmentId);
|
|
119
|
+
}
|
|
120
|
+
return {source:'gwt-rpc',tenant,clientId,count:rows.length,values:rows.map(row=>({...row,isActive:true}))};
|
|
121
|
+
};
|
|
100
122
|
const readEnrollment=async target=>{
|
|
101
123
|
const model=await openTarget(target),householdClientIds=await householdIds(target);
|
|
102
124
|
const entries=await mapped(`SELECT e.entryExitId AS enrollmentId, e.clientId AS clientId, e.entryDate AS projectStartDate, e.groupId AS groupId, e.providerId AS providerId, e.typeEntryExit.name AS enrollmentType FROM SpEntryExit AS e WHERE e.active = TRUE AND e.entryExitId = ${id(target.enrollmentId)} AND e.clientId = ${id(target.clientId)}`);
|
|
@@ -190,7 +212,7 @@ export function createPlaywrightGwtSession({page,tenant,moduleBaseUrl}) {
|
|
|
190
212
|
try{await save.click();const result=await Promise.race([captured,new Promise(resolve=>{timer=setTimeout(()=>resolve(null),15000);})]);if(!result)throw Error('Expected mutation was not captured');pending=digest(JSON.stringify({url:result.url,headers:result.headers,body:result.body}));return result;}
|
|
191
213
|
finally{clearTimeout(timer);await page.unroute('**/service/**',handler);await ready();await closeDialogs();}
|
|
192
214
|
};
|
|
193
|
-
return {connection:resolveUiConnection({tenant,moduleBaseUrl}),readEnrollment,prepare,async send(request){
|
|
215
|
+
return {connection:resolveUiConnection({tenant,moduleBaseUrl}),readEnrollment,listEnrollments,prepare,async send(request){
|
|
194
216
|
checkGeneration();const fingerprint=digest(JSON.stringify(request));if(!pending||fingerprint!==pending)throw Error('Only the newly prepared request can be sent once');pending=undefined;return post(request);
|
|
195
217
|
}};
|
|
196
218
|
}
|
package/docs/assessments.md
CHANGED
|
@@ -29,7 +29,7 @@ The default metadata cache TTL is 60 seconds with 100 entries per client. Config
|
|
|
29
29
|
|
|
30
30
|
WellSky requires a client context and known assessment code for these reads. There is no documented global assessment-list or standalone picklist-list endpoint. Mobile option metadata has no effective-date filter, so definitions explicitly identify its options as current even when the generic assessment read is dated. Metadata also does not provide a complete questionnaire branching engine.
|
|
31
31
|
|
|
32
|
-
Use the raw `assessments.get`, `assessmentsMobile.getAnswers`, and `enrollments.getAssessment` methods for recorded answers. Use `encodeAnswers` and `encodeNewSubassessmentRow` for endpoint-specific encoding. Mobile lookups use option IDs; standalone/enrollment lookups require an explicit `writeLabel` resolver with tenant-verified labels. Raw option names are not assumed to be valid write labels. Provider answers are refused for mobile writes because that endpoint can silently discard them. Answer-state code mappings and multi-value serialization are caller-supplied; a refusal is never converted to `No`.
|
|
32
|
+
Use the raw `assessments.get`, `assessmentsMobile.getAnswers`, and `enrollments.getAssessment` methods for recorded answers. Use `encodeAnswers` and `encodeNewSubassessmentRow` for endpoint-specific encoding. Mobile lookups use option IDs; standalone/enrollment lookups require an explicit `writeLabel` resolver with tenant-verified labels. Raw option names are not assumed to be valid write labels. Some HUD-marked choices require `(HUD)` in the full write label even when metadata `name` omits it. Do not infer this from trailing whitespace or append the suffix to every option. Continue supplying tenant-verified labels until a label resolver has authoritative metadata; recorded `answer_value_text` is useful evidence for the corresponding option. Provider answers are refused for mobile writes because that endpoint can silently discard them. Answer-state code mappings and multi-value serialization are caller-supplied; a refusal is never converted to `No`.
|
|
33
33
|
|
|
34
34
|
## Yes/no answer acceptance
|
|
35
35
|
|
|
@@ -48,6 +48,12 @@ Reproduced on November 2, 2026 in a sandbox tenant with synthetic clients:
|
|
|
48
48
|
|
|
49
49
|
The enrollment workflow writes yes/no answers through the mobile batch by default. That keeps their stage pending because the saved values never verify. Construct it with `yesNoWrites: 'standard'` and a `writeLabel` resolver to send them through `assessments.create` instead; see [the enrollment workflow](enrollments.md#yesno-answers). It never treats an empty yes/no answer as `No`, skips a requested field, or switches endpoints for a stage that was already attempted. For a minimal reproduction, [enable private error diagnostics](troubleshooting.md#inspect-an-upstream-error-privately) and retain the question's current metadata.
|
|
50
50
|
|
|
51
|
+
## Shared questions across assessment instruments
|
|
52
|
+
|
|
53
|
+
An entry assessment and a standalone form can reference the same stored question. Changing `assessmentCode` does not make a second answer at the same client/question/effective instant available for a mobile insert. The integrator reported this for `HPTARGETINGSCORETOTAL` on an entry and form 1002: when the stored value differed, the mobile batch was rejected. Compare question identifiers and effective dates, not only form names.
|
|
54
|
+
|
|
55
|
+
Read the existing answer before planning a mobile scalar insert. If it already exists at that instant with another value, use a supported standard correction/update path after resolving the original operation; do not move its timestamp to evade the collision. Raw `assessmentsMobile.addAnswer` remains a raw write and performs no implicit read or cross-form collision check. Never switch endpoints to retry an uncertain batch.
|
|
56
|
+
|
|
51
57
|
## Race and ethnicity combinations
|
|
52
58
|
|
|
53
59
|
`encodeAnswers` recognizes the standard `HUD_RACEETHNICITY` field. It resolves single-category and multiple-category answers to the tenant's `RACE_ETH_COMBO_*` option IDs. A single White answer uses `RACE_ETH_COMBO_WHI`, even when the caller supplies `RACE_ETH_WHI`. Atomic option IDs can read back with a label while appearing blank in the UI.
|
package/docs/browser-session.md
CHANGED
|
@@ -97,7 +97,7 @@ On the expired-login form, the helper appends the character to the original `HMI
|
|
|
97
97
|
|
|
98
98
|
Use one shared, durable recovery store for every caller of the same UI account. Multi-host deployments must override the default with shared durable storage and locking. Keep using an existing custom recovery store if it has a pending recovery; changing stores does not reconcile it. The per-account lock covers the entire recovery. Its journal contains stages and the append character, never either password. The original must be 8–49 characters when a reset is needed so the temporary password fits the observed 50-character limit. A 50-character password can still log in normally. Keep the original credential and append character unchanged while a recovery is pending. The SDK sanitizes login errors; do not enable Playwright tracing, request-body capture, or debug logging containing credentials.
|
|
99
99
|
|
|
100
|
-
A saved temporary-password attempt can resume with one authentication check of that temporary password. A saved restoration attempt can resume with one authentication check of the original. Neither uncertain password write is repeated. An interrupted or failed authentication check leaves a blocking journal stage and raises `RECOVERY_UNCERTAIN`; reconcile the account and journal before proceeding. A process crash may also leave the file store's exclusive lock. Do not delete journals or automatically expire locks to force retries.
|
|
100
|
+
A saved temporary-password attempt can resume with one authentication check of that temporary password. A saved restoration attempt can resume with one authentication check of the original. Neither uncertain password write is repeated. An interrupted or failed authentication check leaves a blocking journal stage and raises `RECOVERY_UNCERTAIN`; reconcile the account and journal before proceeding. A process crash may also leave the file store's exclusive lock. Do not delete journals or automatically expire locks to force retries. WellSky permits immediately resetting back to the previous password. Password history does not prevent the restore-original step.
|
|
101
101
|
|
|
102
102
|
Both UI actions use GWT-RPC: the shipped expired-login handler calls `AuthenticationService.changePasswordHeartbeat`, while the user-settings restoration was observed calling `UserService.updateUserPassword` with HTTP 200 / GWT OK. The former uses the limited session established by the expired login. The helper currently drives the live forms to generate fresh requests. A standalone RPC-only password adapter has not been implemented or tested; captured password payloads must not be stored or replayed. See [sandbox findings](capabilities.md).
|
|
103
103
|
|
package/docs/capabilities.md
CHANGED
|
@@ -21,7 +21,7 @@ The generated client covers the pinned V2/V3 Swagger operations. The following f
|
|
|
21
21
|
| Later child or member | `addHouseholdMembers` | Must already belong to household; own later entry date, assessment, and DOB; existing dates preserved |
|
|
22
22
|
| Interims | Workflow `createInterim`, `createHouseholdInterim` or low-level GWT `createInterimUpdate` | Workflow populates answers; low-level helper creates only review/participant links. Selected household members supported |
|
|
23
23
|
| Household exit | `exitHousehold` with exit UI port | Selected members, staggered dates, subsidy/Other details, per-member exit assessments; all-member metadata uses one GWT save, assessments follow separately |
|
|
24
|
-
| Individual enrollment exit | `exitEnrollment` with enrollment reader and exit UI port | Standalone participant, verified entry journal, exit details and assessment, same-key reconciliation;
|
|
24
|
+
| Individual enrollment exit | `exitEnrollment` with enrollment reader and exit UI port | Standalone participant, verified entry journal, exit details and assessment, same-key reconciliation; integrator-reported sandbox acceptance November 10, 2026 |
|
|
25
25
|
| Head of household | Enrollment workflow guards | Missing or mid-enrollment replacement head rejected. Head cannot exit before remaining members. Retroactive replacement is not implemented |
|
|
26
26
|
| Verification records at entry | `readEnrollmentVerifications` | Reads generic assessment and filters intervals; complete-enrollment entry arrays can omit valid UI rows |
|
|
27
27
|
| Existing verification end dates | `api.subassessments.setEndDate` | Preserves row ID and other values, verifies result; no blind resend or delete/recreate |
|
|
@@ -39,12 +39,12 @@ The generated client covers the pinned V2/V3 Swagger operations. The following f
|
|
|
39
39
|
| Client Notes, goals, action steps | `createCasePlans` plus raw REST methods | Journaled create/update and supported-field verification; helper supplies clientId on goal updates; unreadable follow-up fields excluded |
|
|
40
40
|
| Goal case notes | `session.workflows.createCaseNote` | Optional source key rejects changed intent and verifies saved note ID; legacy unkeyed matching remains; single client only. REST goal response does not expose these notes |
|
|
41
41
|
| Multi-client/multi-case notes | No packaged bulk helper | Do not infer note participants from unrelated `households` nested in a goal response |
|
|
42
|
-
| Expired UI password | Headless login, enabled by default | Temporary suffix then restore original, durable recovery stages, fresh-login verification;
|
|
42
|
+
| Expired UI password | Headless login, enabled by default | Temporary suffix then restore original, durable recovery stages, fresh-login verification; WellSky permits immediate restoration of the previous password |
|
|
43
43
|
| Session expiration | Managed session plus durable workflow journals | Reauthenticate and report interruption; caller retries original intent for reconciliation, not automatic callback replay |
|
|
44
44
|
|
|
45
45
|
## Known server gaps
|
|
46
46
|
|
|
47
|
-
Grouped REST enrollment reads can return `42703: column v.entry_date_effective does not exist` after both UI and RPC saves. This is not specific to replay. A broken read is not an absent enrollment. The browser enrollment reader uses current UI/GWT evidence. The enrollment workflow
|
|
47
|
+
Grouped REST enrollment reads can return `42703: column v.entry_date_effective does not exist` after both UI and RPC saves. This is not specific to replay. A broken read is not an absent enrollment. The browser enrollment reader uses current UI/GWT evidence. The configured enrollment workflow now uses its counted GWT catalog for creation preflight as well as targeted GWT reads. The Current Living Situation and Coordinated Entry Event recorders use targeted GWT reads when given `enrollmentSession`. Legacy custom readers without `listEnrollments` retain REST list behavior.
|
|
48
48
|
|
|
49
49
|
The October 24 [V2/V3 comparison](vendor-issues/enrollment-effective-date-column.md) reproduced the missing-column error for all 16 tested grouped complete enrollments in each version. Individual-only client lists succeeded, and individual pages within a mixed client also succeeded. Switching to V2 does not fix the issue. One individual complete read had a separate `Non-static method requires a target.` error.
|
|
50
50
|
|
|
@@ -69,10 +69,20 @@ These observations explain the current contracts. They are dated acceptance resu
|
|
|
69
69
|
| Household exits and head coverage, October 23, 2026 | An invalid early head exit was blocked before writes. Valid staggered exits and an all-member exit preserved dates, original heads, and selected subgroups; retries made no extra record writes. | Earlier server-accepted orphaned groups were invalid under the current SDK contract. See [head validation feedback](vendor-issues/household-head-validation.md) and [enrollments](enrollments.md). |
|
|
70
70
|
| Attachment identity and content, October 24, 2026 | Profile, Case Plans, and goal files retained their IDs across fresh helpers, and downloaded bytes matched the originals. | Does not establish anonymous access or sharing permissions. REST authentication remained a separate failure. See [files and notes](files-and-notes.md). |
|
|
71
71
|
| SSVF conditional forms, October 23, 2026 | Form inspection checked conditional branches and required-date validation without saving; four REST-supported dependencies were then saved and read back, with zero duplicate writes on same-key retries. | This is a dated rules snapshot, not exhaustive financial assistance or funding-allocation acceptance. See [SSVF rules](../SSVF.md) and [services](services.md). |
|
|
72
|
-
| Expired-password recovery, October 23, 2026 | An expired account completed temporary-password recovery, restored its original password, and authenticated in a fresh context. |
|
|
72
|
+
| Expired-password recovery, October 23, 2026 | An expired account completed temporary-password recovery, restored its original password, and authenticated in a fresh context. | WellSky permits immediate restoration of the previous password; retain the durable recovery journal for interrupted operations. See [browser sessions](browser-session.md). |
|
|
73
73
|
|
|
74
74
|
Independent needs and AIRS/service-type discovery use UI/GWT helpers. See [independent needs](independent-needs.md) for the creation, counted catalog, and retry contracts. The pinned Swagger exposes need reads but no independent-need creation.
|
|
75
75
|
|
|
76
76
|
The [separate complete-enrollment target error](vendor-issues/enrollment-complete-target-error.md) was isolated to missing DOB information in controlled sandbox fixtures. Adding only `SVPPROFDOB`, or only `SVPPROFDOBTYPE` with `DOBTYPE_DONTKNOW` or `DOBTYPE_REFUSED`, changed HTTP 400 to success in V2 and V3. The quality-only fixtures retained empty DOB values, confirmed through REST and the UI. Never invent a DOB or recreate an enrollment to recover a read.
|
|
77
77
|
|
|
78
78
|
Current Living Situation contact recording is available through `createCurrentLivingSituationRecorder`. It preserves distinct contacts with program-entry starts and optional recording-program verification. See the [contact guide](current-living-situation.md) for metadata mapping, date contracts, blocked server reads, and acceptance limits.
|
|
79
|
+
|
|
80
|
+
## Integrator-reported acceptance, November 10, 2026
|
|
81
|
+
|
|
82
|
+
The integrator reported these results using SDK 0.4.1 in the training tenant. They are reported live evidence, not a fresh SDK-team rerun. Client and enrollment identifiers are omitted from this public package.
|
|
83
|
+
|
|
84
|
+
- A standalone enrollment exited on November 9 and the person was re-enrolled. Repeating the same keys produced no additional writes.
|
|
85
|
+
- Two household cohorts exited successfully using plans from `discoverExit`, carrying forward fields with values and marking the rest not applicable.
|
|
86
|
+
- After those household exits, both re-enrollment attempts failed on the pre-create REST list with HTTP 400 from the grouped-enrollment `42703` gap. This confirms that an exited household enrollment can still break that REST list.
|
|
87
|
+
|
|
88
|
+
The GWT catalog correction has regression coverage for individual creation, household heads and member preflight, failed-read classification and re-enrollment. Same-date entry corrections and adoption of manual enrollments have synthetic coverage. These newer paths have not been independently verified live.
|
package/docs/enrollments.md
CHANGED
|
@@ -128,8 +128,62 @@ In this mode the mobile batch carries no yes/no answers. After it verifies, a se
|
|
|
128
128
|
|
|
129
129
|
Every mandatory label is resolved before the first write. The `yesno` stage verifies with a generic read one second after the effective instant, because a read at the instant omits yes/no answers, and requires each answer and its effective timestamp to match. The mode is recorded in the journal. An unfinished workflow started in one mode fails in the other rather than switching endpoints, including journals left pending by earlier mobile yes/no attempts; those require separate reconciliation. Yes/no children of new subassessment rows still use the mobile row write, which drops them, so leave them unanswered in workflow rows. Record Coordinated Entry Events, which need yes/no and provider answers, with the [event recorder](coordinated-entry-events.md).
|
|
130
130
|
|
|
131
|
+
## Correct an existing entry assessment
|
|
132
|
+
|
|
133
|
+
`correctEntry` patches an entry at its original effective timestamp. Supply the verified `createEnrollment` key as `enrollmentKey` and a new stable `key` for each correction. Configure `enrollmentSession` and a `writeLabel` resolver for tenant-verified lookup labels. A correction requires an open enrollment with no interim reviews or exit workflows.
|
|
134
|
+
|
|
135
|
+
```ts
|
|
136
|
+
const corrected = await workflow.correctEntry({
|
|
137
|
+
key: sourceCorrectionId,
|
|
138
|
+
enrollmentKey: sourceEnrollmentId,
|
|
139
|
+
clientId,
|
|
140
|
+
answers: changedScalarAnswers,
|
|
141
|
+
clear: scalarFieldsNoLongerAsked,
|
|
142
|
+
subassessments: [{
|
|
143
|
+
key: originalEntryRowKey,
|
|
144
|
+
parentField,
|
|
145
|
+
answers: changedRowAnswers,
|
|
146
|
+
clear: rowFieldsToClear,
|
|
147
|
+
}],
|
|
148
|
+
});
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
`answers` uses the ordinary answer and option selectors. `clear` explicitly removes a recorded value; an omitted field stays unchanged. Yes/no fields cannot appear in `clear`: the SDK throws `UNSUPPORTED_YES_NO_CLEAR`, with the affected `fields`, before any correction write or new journal record. Use `{value: false}` to answer `No`; it does not mean blank. No alternative yes/no blanking route has been verified. Do not use `notApplicable` to clear an old answer. Every row key must identify an existing row returned by the original entry operation. The correction never creates a second row, deletes a row, or changes the enrollment date.
|
|
152
|
+
|
|
153
|
+
Scalars use the standard assessment endpoint, which supports same-date updates. The batch includes every mandatory scalar, preserving its current value unless the patch explicitly answers or clears it. Missing mandatory values require an explicit answer, or an explicit clear for a supported non-yes/no type; lookup values require verified write labels. Readback checks one second after the original instant so stored yes/no values are visible.
|
|
154
|
+
|
|
155
|
+
Row edits send only the changed cells to the existing recordset through the mobile endpoint. Lookup, text, numeric and date cells are supported; provider and yes/no row cells are refused before any write. Untouched cells, all other rows and row IDs must remain unchanged. DOB and household-head changes require their dedicated workflows and cannot be supplied as corrections here. An unmodified mandatory DOB is also refused because the standard batch cannot safely resend it through the DOB helper.
|
|
156
|
+
|
|
157
|
+
All changes are validated before the first write. Scalar and row batches have separate journal stages, so a partial correction is not rolled back. Retry the same intent and key to reconcile; an uncertain batch is never resent automatically. `getStatus({clientId, key})` reports correction progress. Verified corrections update the original entry's verification expectations, so retrying `createEnrollment` under its old key does not restore the old path. A later correction uses a new correction key. See the [typed example](../examples/entry-correction-workflow.mts).
|
|
158
|
+
|
|
159
|
+
## Resolve a rejected correction
|
|
160
|
+
|
|
161
|
+
An attempted correction stays pending until its outcome is established. A 4xx response or an unchanged read alone does not prove non-application, so the SDK does not automatically reset or resend it. New failures expose `code: 'ENTRY_CORRECTION_UNCERTAIN'`, `step`, and an available HTTP `status`. The first failure retains its original `cause`; a failed reconciliation read retains both errors in an `AggregateError`. Only allowlisted failure kind/status are persisted and returned by `getStatus`. Response bodies, messages, credentials and stacks are not written to the journal. Older journals cannot recover diagnostics they never saved.
|
|
162
|
+
|
|
163
|
+
If independent server or administrative evidence establishes that an attempted correction was not applied, call:
|
|
164
|
+
|
|
165
|
+
```ts
|
|
166
|
+
await workflow.resolveEntryCorrection({
|
|
167
|
+
key: failedCorrectionKey,
|
|
168
|
+
enrollmentKey,
|
|
169
|
+
clientId,
|
|
170
|
+
evidenceReference: independentReviewReference,
|
|
171
|
+
confirmNotApplied: async evidence => rejectionReview.confirmNotApplied(evidence),
|
|
172
|
+
});
|
|
173
|
+
```
|
|
174
|
+
|
|
175
|
+
`rejectionReview` is application-owned. Its callback must be read-only and confirm non-application from independent evidence; do not implement it as `() => true` or infer its answer only from HTTP status and `matchesBefore`. The callback receives identities, effective date and sanitized step diagnostics. Do not call the same client's workflow methods from the callback: the client journal lock remains held.
|
|
176
|
+
|
|
177
|
+
The SDK verifies the complete saved pre-write state before and after confirmation and rechecks enrollment context. A partial application, changed state or unavailable read prevents resolution. Successful resolution marks the correction `rejected` while retaining its original intent, attempted steps, diagnostics and evidence reference. It makes no SDK record writes to WellSky. Repeating the same resolution returns `already-rejected`; submitting that correction key again throws `ENTRY_CORRECTION_REJECTED`. Use a new correction key for the corrected intent only after resolution. A verified successful correction cannot be rejected.
|
|
178
|
+
|
|
179
|
+
This also supports pending 0.5.0 journals, including those without failure metadata, when the required independent evidence exists. A new validation failure that occurs before journaling needs no resolution.
|
|
180
|
+
|
|
131
181
|
## Household enrollment and interim assessments
|
|
132
182
|
|
|
183
|
+
Enrollment creation and member preflight use the complete GWT list when the configured `enrollmentSession` supplies `listEnrollments`. The built-in sessions supply it. This avoids the REST `42703` failure for clients with existing household enrollments, including exited enrollments and enrollments in other programs. Reads verify the client, tenant, row identities and count; a failed GWT read never falls back to REST or means an empty list. The current GWT reader refuses lists above 1,000 records. Legacy custom readers without this method retain the counted REST path.
|
|
184
|
+
|
|
185
|
+
An unreadable list throws `EnrollmentReadError` with `code: 'ENROLLMENT_READ_FAILED'`, `source`, `clientId`, and the original error as `cause`. `phase: 'preflight'` and `committed: false` mean this workflow has not attempted a server write. After a write attempt, the error reports `phase: 'reconciliation'` and `committed: 'unknown'`. Retain the existing key and journal in both cases. A lost write followed by a failed read is never a non-commit.
|
|
186
|
+
|
|
133
187
|
The same workflow exposes `createHouseholdEnrollment` and `createHouseholdInterim`. Supply `enrollmentSession: session.enrollmentSession` when constructing it. Initial household enrollment requires an existing household and at least two existing clients, including its head. Later interims and exits accept a nonempty subset of enrolled members. Establish household membership separately before enrollment.
|
|
134
188
|
|
|
135
189
|
```ts
|
|
@@ -164,6 +218,22 @@ Client locks coordinate overlapping household and single-client operations. Dura
|
|
|
164
218
|
|
|
165
219
|
See the typed [household example](../examples/household-enrollment-workflow.mts) and [live household verification](capabilities.md). The integration uses the configured tenant and uses an isolated authenticated page.
|
|
166
220
|
|
|
221
|
+
## Public enrollment reads
|
|
222
|
+
|
|
223
|
+
The managed session exposes a GWT-backed catalog independently of the workflow:
|
|
224
|
+
|
|
225
|
+
```ts
|
|
226
|
+
const {values: enrollments} =
|
|
227
|
+
await session.enrollmentSession.listEnrollments({clientId});
|
|
228
|
+
|
|
229
|
+
const detail = await session.enrollmentSession.readEnrollment({
|
|
230
|
+
clientId: headClientId, enrollmentId: headEnrollmentId,
|
|
231
|
+
});
|
|
232
|
+
// detail.members contains the complete linked clientId/enrollmentId pairs.
|
|
233
|
+
```
|
|
234
|
+
|
|
235
|
+
Use the session returned by `openEnrollmentSession`. Lists include open and exited entries, validate complete counts, and refuse more than 1,000 records. The detail read currently exposes `householdClientIds`, not `householdId`; these client IDs and the enrollment's group are not interchangeable with a household ID. Household adoption still requires an explicitly verified household ID.
|
|
236
|
+
|
|
167
237
|
## Individual enrollment exits
|
|
168
238
|
|
|
169
239
|
Use `exitEnrollment` to end a standalone person's program enrollment. It requires a verified `createEnrollment` entry in the same durable store. Supply `enrollmentSession: session.enrollmentSession` and `exitUi: createHouseholdExitUi({page: session.page})` when constructing the workflow. The same exit UI port serves individual and household forms.
|
|
@@ -186,7 +256,15 @@ The workflow verifies the exact enrollment and sole participant through fresh GW
|
|
|
186
256
|
|
|
187
257
|
Retain the same key, intent and store after failures. A lost save response triggers readback, never an automatic resend. Use `getStatus({clientId, key})` to inspect local progress. Exit metadata and assessment writes are separate stages, so a saved exit can remain pending assessment verification. `endDates` closes only the explicitly selected verification rows; enrollment exit does not automatically end every verification record.
|
|
188
258
|
|
|
189
|
-
For a person in a linked household enrollment, use `exitHousehold` with that person as the only selected member. `exitEnrollment` rejects linked groups to preserve household head coverage. Neither method ends household membership. See the [typed individual exit example](../examples/individual-exit-workflow.mts).
|
|
259
|
+
For a person in a linked household enrollment, use `exitHousehold` with that person as the only selected member. `exitEnrollment` rejects linked groups to preserve household head coverage. Neither method ends household membership. See the [typed individual exit example](../examples/individual-exit-workflow.mts). The integrator reported successful standalone exit and same-key no-write retry acceptance on November 10, 2026; see [reported sandbox acceptance](capabilities.md#integrator-reported-acceptance-november-10-2026).
|
|
260
|
+
|
|
261
|
+
## Adopt an enrollment entered manually in WellSky
|
|
262
|
+
|
|
263
|
+
Use `adoptEnrollment({key, clientId, enrollmentId})` for a standalone enrollment. For linked household enrollments, use `adoptHouseholdEnrollment({key, householdId, headClientId, members})`, where each member supplies both `clientId` and `enrollmentId`. Supply the complete linked cohort, including members already exited, and the original enrollment head. Adoption verifies authoritative GWT identities, program/type, entry dates, current household membership, head relationships when present, and head coverage across all enrollment intervals.
|
|
264
|
+
|
|
265
|
+
Adoption writes only to the local durable journal. It does not create, attach, exit or modify an HMIS record. Later entrants retain their own original entry dates. An enrollment already tracked under another entry key is rejected; use its existing key. Retry an interrupted adoption with the same inputs and store.
|
|
266
|
+
|
|
267
|
+
After standalone adoption, its key can be used as `enrollmentKey` for `createInterim`, scalar-only `correctEntry`, or `exitEnrollment`. After household adoption, the group key works with `createHouseholdInterim` and `exitHousehold`; use the returned member's `key` for that person's scalar-only `correctEntry`. Correction still requires an open entry without reviews or exits. Do not use an all-not-applicable `createEnrollment` call merely to link an existing entry: it can create an enrollment when no match exists. Exit assessment plans and normal household head rules still apply. Adoption records existing enrollment identities, not historical subassessment row-key mappings, so its returned `recordsetIds` is empty. It cannot identify rows for `correctEntry` by a new invented key. See [manual-entry exit examples](../examples/existing-enrollment-exits.mts).
|
|
190
268
|
|
|
191
269
|
## Household exits
|
|
192
270
|
|
package/docs/reference.md
CHANGED
|
@@ -25,7 +25,7 @@ Runtime exports at the root: `AmbiguousCommitError`, `AssessmentMetadata`, `Auth
|
|
|
25
25
|
|
|
26
26
|
Import from `@choiceapps/communityservices-sdk/browser` in server-side ESM. These helpers use the tenant supplied by your application. Browser interfaces require `playwright-core`; some orchestration helpers use REST plus injected UI ports rather than directly launching a browser.
|
|
27
27
|
|
|
28
|
-
Runtime exports: `AttachmentConflictError`, `CoordinatedEntryEventConflictError`, `EnrollmentWorkflowError`, `LoginError`, `SessionInterruptedError`, `createFileGwtMutationStore`, `createPlaywrightEnrollmentHelpers`, `createPlaywrightGwtSession`, `createCasePlanUi`, `createCasePlans`, `createClientCreator`, `createClientProfile`, `createClientProfileUi`, `createClientWorkflows`, `createCoordinatedEntryEventRecorder`, `createDateOfBirth`, `createDateOfBirthUi`, `createEnrollmentWorkflow`, `createHouseholdExitUi`, `createHouseholdUi`, `createHouseholds`, `createMemberEntryDatePort`, `createProgramDiscovery`, `createServiceRecorder`, `createServiceUi`, `getHouseholdOptions`, `getSsvfServiceRules`, `openEnrollmentSession`, `openUiSession`, `validateBirthDate`, `resolveUiConnection`.
|
|
28
|
+
Runtime exports: `AttachmentConflictError`, `CoordinatedEntryEventConflictError`, `EnrollmentWorkflowError`, `EnrollmentReadError`, `LoginError`, `SessionInterruptedError`, `createFileGwtMutationStore`, `createPlaywrightEnrollmentHelpers`, `createPlaywrightGwtSession`, `createCasePlanUi`, `createCasePlans`, `createClientCreator`, `createClientProfile`, `createClientProfileUi`, `createClientWorkflows`, `createCoordinatedEntryEventRecorder`, `createDateOfBirth`, `createDateOfBirthUi`, `createEnrollmentWorkflow`, `createHouseholdExitUi`, `createHouseholdUi`, `createHouseholds`, `createMemberEntryDatePort`, `createProgramDiscovery`, `createServiceRecorder`, `createServiceUi`, `getHouseholdOptions`, `getSsvfServiceRules`, `openEnrollmentSession`, `openUiSession`, `validateBirthDate`, `resolveUiConnection`.
|
|
29
29
|
|
|
30
30
|
| Task | Contract | Methods or behavior |
|
|
31
31
|
| --- | --- | --- |
|
|
@@ -36,7 +36,7 @@ Runtime exports: `AttachmentConflictError`, `CoordinatedEntryEventConflictError`
|
|
|
36
36
|
| Profile fields and Sex | [Client profile](../browser/client-profile.d.mts) | `read`, `update` |
|
|
37
37
|
| Calendar DOB | [Birth dates](../browser/date-of-birth.d.mts) | `read`, `validate`, `verify`, `update` |
|
|
38
38
|
| Provider assessment defaults | [Discovery](../browser/program-discovery.d.mts) | `listAssessments`, `getProviderAssessments` |
|
|
39
|
-
| Enrollment and household lifecycle | [Workflow](../browser/enrollment-workflow.d.mts) | Discovery, entry, member additions, interim, `exitEnrollment`, `exitHousehold`, journal status; `EntryExitType` and per-type `assessmentLocations` in [enrollments](enrollments.md) |
|
|
39
|
+
| Enrollment and household lifecycle | [Workflow](../browser/enrollment-workflow.d.mts) | Discovery, entry, `correctEntry`, `resolveEntryCorrection`, `adoptEnrollment`, `adoptHouseholdEnrollment`, member additions, interim, `exitEnrollment`, `exitHousehold`, journal status; `EntryExitType` and per-type `assessmentLocations` in [enrollments](enrollments.md) |
|
|
40
40
|
| Entry-date and exit UI ports | [Member entry](../browser/member-entry-date.d.mts), [exit](../browser/household-exit.d.mts) | Fresh reads and targeted completion |
|
|
41
41
|
| Services and recovery diagnostics | [Service recording](../browser/service-recording.d.mts) | `record`, `recordBulk`, Funding Sources, `RecoveryDiagnostic` |
|
|
42
42
|
| SSVF conditional rules | [SSVF types](../browser/ssvf-rules.d.mts) | [Field rules](../SSVF.md) and dated option snapshots |
|
package/docs/troubleshooting.md
CHANGED
|
@@ -28,6 +28,9 @@ Successful adoption of an existing record also needs its journal. Retain it even
|
|
|
28
28
|
| `DefinitionDriftError`, `UnknownPicklistValueError` | Metadata or lookup assumption changed | Refresh/review definitions and application mapping. Do not guess a numeric option |
|
|
29
29
|
| `AmbiguousCommitError` | Mutation outcome not established | Keep journal pending; re-read through the workflow's authoritative path |
|
|
30
30
|
| `GwtEnrollmentConflictError` | Duplicate/partial/conflicting enrollment or review | Inspect membership and review instant. Do not replay the captured request |
|
|
31
|
+
| `UNSUPPORTED_YES_NO_CLEAR` | A correction attempted to blank a yes/no field | Fix the plan before any write; `No` and blank are different states |
|
|
32
|
+
| `ENTRY_CORRECTION_UNCERTAIN` | An attempted correction is not verified | Inspect `status`, `cause`, and journal step diagnostics; use [explicit resolution](enrollments.md#resolve-a-rejected-correction) only with independent non-application evidence |
|
|
33
|
+
| `ENTRY_CORRECTION_REJECTED` | The old correction key was resolved as not applied | Keep its audit record and use a new key for corrected intent |
|
|
31
34
|
| `EnrollmentWorkflowError` | Invalid plan, missing head, schema drift, conflict, or unresolved stage | Inspect the specific reason and local status; fix application inputs only before reservation, otherwise reconcile original intent |
|
|
32
35
|
| `SessionInterruptedError`, `SESSION_INTERRUPTED` | Managed page operation ended around session expiry | Check `reauthenticated`; retry the original workflow call, never its prepared RPC |
|
|
33
36
|
| `LoginError`, `PASSWORD_EXPIRED` | Expiration detected with recovery disabled | Use the documented recovery option or resolve the password outside the helper |
|
package/examples/README.md
CHANGED
|
@@ -15,6 +15,8 @@ Every file here ships with the package and imports the public `@choiceapps/commu
|
|
|
15
15
|
| [program-discovery.ts](program-discovery.ts) | Program and VA-entry assessment discovery | Exported function; authenticated page required |
|
|
16
16
|
| [enrollment-workflow.mts](enrollment-workflow.mts) | Single entry and interim assessment plans | Exported function; caller supplies plans and dependencies |
|
|
17
17
|
| [individual-exit-workflow.mts](individual-exit-workflow.mts) | Standalone program enrollment exit and assessment | Verified entry key, enrollment reader, exit UI port and durable store required |
|
|
18
|
+
| [entry-correction-workflow.mts](entry-correction-workflow.mts) | Same-date scalar clears and existing row edits | Original entry/row keys, verified lookup labels and durable store required |
|
|
19
|
+
| [existing-enrollment-exits.mts](existing-enrollment-exits.mts) | Adopt manual individual or household entries, then exit | Explicit enrollment identities and original household head required |
|
|
18
20
|
| [household-enrollment-workflow.mts](household-enrollment-workflow.mts) | Per-member enrollment and review | Existing household and per-member answer plans required |
|
|
19
21
|
| [household-lifecycle-workflow.mts](household-lifecycle-workflow.mts) | Selected exits and a later child entry | Household membership precedes program attachment |
|
|
20
22
|
| [household-exit-workflow.mts](household-exit-workflow.mts) | Household exit with destination details | Explicit exit dates and per-member plans required |
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import {
|
|
2
|
+
createEnrollmentWorkflow,
|
|
3
|
+
type EnrollmentWorkflowOptions,
|
|
4
|
+
type EntryCorrectionInput,
|
|
5
|
+
type ResolveEntryCorrectionInput,
|
|
6
|
+
} from '@choiceapps/communityservices-sdk/browser';
|
|
7
|
+
|
|
8
|
+
/** The application supplies explicit answers, clears and original row keys.
|
|
9
|
+
* Dependencies include enrollmentSession and a verified writeLabel resolver.
|
|
10
|
+
* Reuse the original entry store; input.key identifies this correction, while
|
|
11
|
+
* input.enrollmentKey identifies the existing createEnrollment operation. */
|
|
12
|
+
export async function correctOriginalEntry(dependencies:EnrollmentWorkflowOptions,input:EntryCorrectionInput){
|
|
13
|
+
const workflow=createEnrollmentWorkflow(dependencies);
|
|
14
|
+
return workflow.correctEntry(input);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** The confirmation callback must use independent server or administrative evidence.
|
|
18
|
+
* The SDK also checks the complete pre-write state before and after that callback.
|
|
19
|
+
* Retain the rejected key; submit a corrected request under a new key afterward. */
|
|
20
|
+
export async function resolveRejectedCorrection(dependencies:EnrollmentWorkflowOptions,input:ResolveEntryCorrectionInput){
|
|
21
|
+
return createEnrollmentWorkflow(dependencies).resolveEntryCorrection(input);
|
|
22
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import {
|
|
2
|
+
createEnrollmentWorkflow,
|
|
3
|
+
type EnrollmentWorkflowOptions,
|
|
4
|
+
type AdoptEnrollmentInput,
|
|
5
|
+
type AdoptHouseholdEnrollmentInput,
|
|
6
|
+
type ExitEnrollmentInput,
|
|
7
|
+
type HouseholdExitInput,
|
|
8
|
+
} from '@choiceapps/communityservices-sdk/browser';
|
|
9
|
+
|
|
10
|
+
/** Adoption reads WellSky and records its identities locally; it creates no enrollment. */
|
|
11
|
+
export async function exitManualEnrollment(
|
|
12
|
+
dependencies:EnrollmentWorkflowOptions,
|
|
13
|
+
existing:AdoptEnrollmentInput,
|
|
14
|
+
exit:Omit<ExitEnrollmentInput,'enrollmentKey'|'clientId'>,
|
|
15
|
+
){
|
|
16
|
+
const workflow=createEnrollmentWorkflow(dependencies);
|
|
17
|
+
await workflow.adoptEnrollment(existing);
|
|
18
|
+
return workflow.exitEnrollment({...exit,enrollmentKey:existing.key,clientId:existing.clientId});
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** Adopt the complete linked cohort, then exit only the members selected in exit.members. */
|
|
22
|
+
export async function exitManualHousehold(
|
|
23
|
+
dependencies:EnrollmentWorkflowOptions,
|
|
24
|
+
existing:AdoptHouseholdEnrollmentInput,
|
|
25
|
+
exit:Omit<HouseholdExitInput,'enrollmentKey'|'headClientId'>,
|
|
26
|
+
){
|
|
27
|
+
const workflow=createEnrollmentWorkflow(dependencies);
|
|
28
|
+
await workflow.adoptHouseholdEnrollment(existing);
|
|
29
|
+
return workflow.exitHousehold({...exit,enrollmentKey:existing.key,headClientId:existing.headClientId});
|
|
30
|
+
}
|
package/package.json
CHANGED