@choiceapps/communityservices-sdk 0.1.1 → 0.2.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 +12 -0
- package/browser/coordinated-entry-event.d.mts +78 -0
- package/browser/coordinated-entry-event.mjs +175 -0
- package/browser/current-living-situation.mjs +11 -120
- package/browser/enrollment-workflow.d.mts +10 -2
- package/browser/enrollment-workflow.mjs +63 -11
- package/browser/index.d.mts +1 -0
- package/browser/index.mjs +1 -0
- package/browser/subassessment-row-recorder.mjs +154 -0
- package/dist/cjs/encoding.d.ts.map +1 -1
- package/dist/cjs/encoding.js +2 -1
- package/dist/cjs/encoding.js.map +1 -1
- package/dist/cjs/errors.d.ts +4 -1
- package/dist/cjs/errors.d.ts.map +1 -1
- package/dist/cjs/errors.js +9 -1
- package/dist/cjs/errors.js.map +1 -1
- package/dist/cjs/runtime.d.ts +5 -1
- package/dist/cjs/runtime.d.ts.map +1 -1
- package/dist/cjs/runtime.js +1 -1
- package/dist/cjs/runtime.js.map +1 -1
- package/dist/cjs/subassessments.d.ts.map +1 -1
- package/dist/cjs/subassessments.js +8 -15
- package/dist/cjs/subassessments.js.map +1 -1
- package/dist/cjs/transport.d.ts.map +1 -1
- package/dist/cjs/transport.js +15 -3
- package/dist/cjs/transport.js.map +1 -1
- package/dist/esm/encoding.d.ts.map +1 -1
- package/dist/esm/encoding.js +2 -1
- package/dist/esm/encoding.js.map +1 -1
- package/dist/esm/errors.d.ts +4 -1
- package/dist/esm/errors.d.ts.map +1 -1
- package/dist/esm/errors.js +9 -1
- package/dist/esm/errors.js.map +1 -1
- package/dist/esm/runtime.d.ts +5 -1
- package/dist/esm/runtime.d.ts.map +1 -1
- package/dist/esm/runtime.js +1 -1
- package/dist/esm/runtime.js.map +1 -1
- package/dist/esm/subassessments.d.ts.map +1 -1
- package/dist/esm/subassessments.js +8 -15
- package/dist/esm/subassessments.js.map +1 -1
- package/dist/esm/transport.d.ts.map +1 -1
- package/dist/esm/transport.js +15 -3
- package/dist/esm/transport.js.map +1 -1
- package/docs/README.md +1 -0
- package/docs/assessments.md +21 -3
- package/docs/capabilities.md +2 -0
- package/docs/configuration.md +1 -0
- package/docs/coordinated-entry-events.md +88 -0
- package/docs/current-living-situation.md +1 -1
- package/docs/dates-and-answers.md +3 -1
- package/docs/enrollments.md +21 -0
- package/docs/reference.md +5 -1
- package/docs/troubleshooting.md +25 -0
- package/docs/vendor-issues/README.md +2 -0
- package/docs/vendor-issues/date-semantics.md +2 -0
- package/docs/vendor-issues/yes-no-answers.md +35 -0
- package/examples/README.md +2 -0
- package/examples/coordinated-entry-events.mts +37 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,17 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.2.0 - 2026-11-02
|
|
4
|
+
|
|
5
|
+
- Add opt-in private HTTP error-body capture and retain the original write failure as the cause of an uncertain enrollment stage. Failed writes still require reconciliation and are never replayed automatically.
|
|
6
|
+
- Add `yesNoWrites: 'standard'` with a `writeLabel` resolver to `createEnrollmentWorkflow`. Yes/no answers then leave the mobile batch and are written through `assessments.create` with every mandatory answer, in a separate `yesno` stage. The default `mobile` mode is unchanged, and an unfinished journal never switches modes.
|
|
7
|
+
- Verify the `yesno` stage with a generic read one second after the effective instant, because a read at the instant omits yes/no answers. Standard mode supports mandatory lookups only.
|
|
8
|
+
- Read subassessment rows in the enrollment workflow at the later of now and the record's date, and in the Current Living Situation and Coordinated Entry Event recorders one second after that, so yes/no answers saved at the event instant read back. Undated generic reads omitted rows dated after the server's current time, which left a live interim's new rows unverified.
|
|
9
|
+
- Add `createCoordinatedEntryEventRecorder`, which records one journaled Coordinated Entry Event row per event key through the standard assessment endpoint, keeping provider locations and yes/no results. It checks each event's follow-up answers: placement referrals require a location, and other follow-up answers are refused where they do not apply. `recordResult` adds a referral result later by updating only the result cells of the recorded row.
|
|
10
|
+
- The Current Living Situation and Coordinated Entry Event recorders share one internal row journal and reconciliation module. Current Living Situation behavior, error reasons and journal hashes are unchanged.
|
|
11
|
+
- `subassessments.setEndDate` sends only the end-date cell instead of the whole row, so rows with provider questions, such as Coordinated Entry Events, can be end-dated, and other answers are left untouched. It finds an end-date question that the row has never answered from the parent's question list.
|
|
12
|
+
- `encodeNewSubassessmentRow` omits an unanswered provider child for mobile rows instead of rejecting the row. Answered provider children are still refused.
|
|
13
|
+
- Document the November 2, 2026 yes/no findings: mobile writes store yes/no answers empty and only insert rows, and standard writes need every mandatory answer.
|
|
14
|
+
|
|
3
15
|
## 0.1.1 - 2026-11-02
|
|
4
16
|
|
|
5
17
|
- Accept Basic, Standard, RHY, Basic Center Program Entry/Exit, PATH, Quick Call, and Transitional Living Program Entry/Exit in enrollment workflows alongside HUD and VA. Added per-type assessment display location mappings, with current provider discovery and existing journal verification.
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import type {Answers, EncodingOptions, PicklistSelector, WellSkyClient} from '../dist/esm/index.js';
|
|
2
|
+
import type {UiMutationStore} from './index.mjs';
|
|
3
|
+
/** Exact virtual fields from the target instrument's client-scoped metadata. No guessed codes. */
|
|
4
|
+
export interface CoordinatedEntryEventFields {
|
|
5
|
+
parentField: string;
|
|
6
|
+
startDate: string;
|
|
7
|
+
endDate: string;
|
|
8
|
+
eventDate: string;
|
|
9
|
+
event: string;
|
|
10
|
+
/** yes_no: Problem Solving/Diversion result, client housed or re-housed in a safe alternative. */
|
|
11
|
+
housedSafely: string;
|
|
12
|
+
/** yes_no: post-placement/follow-up case management result, enrolled in aftercare. */
|
|
13
|
+
enrolledInAftercare: string;
|
|
14
|
+
/** provider: location of the crisis or permanent housing referral. */
|
|
15
|
+
location: string;
|
|
16
|
+
/** lookup: referral result. */
|
|
17
|
+
referralResult: string;
|
|
18
|
+
/** date: date of the referral result. */
|
|
19
|
+
resultDate: string;
|
|
20
|
+
}
|
|
21
|
+
export interface CoordinatedEntryEventInput {
|
|
22
|
+
/** Unique upstream event within this client. Preserve it and the journal on retries. */
|
|
23
|
+
key: string;
|
|
24
|
+
clientId: number;
|
|
25
|
+
enrollmentId: number;
|
|
26
|
+
/** An instrument containing the CE Event subassessment and no mandatory questions. */
|
|
27
|
+
assessmentCode: string;
|
|
28
|
+
/** Date of the event as a timezone-qualified whole-second instant. */
|
|
29
|
+
eventDate: string;
|
|
30
|
+
/** The Event option. Its code selects which follow-up answers are allowed or required. */
|
|
31
|
+
event: {option: PicklistSelector};
|
|
32
|
+
/** Optional; only for a Problem Solving/Diversion event. */
|
|
33
|
+
housedSafely?: boolean;
|
|
34
|
+
/** Optional; only for a post-placement/follow-up case management referral. */
|
|
35
|
+
enrolledInAftercare?: boolean;
|
|
36
|
+
/** Provider ID. Required for, and only allowed on, ES, TH, Joint TH-RRH, RRH, PSH and Other PH referrals. */
|
|
37
|
+
location?: number;
|
|
38
|
+
/** Only on those referrals, together with resultDate. Usually known after the referral. */
|
|
39
|
+
referralResult?: {option: PicklistSelector};
|
|
40
|
+
resultDate?: string;
|
|
41
|
+
/** Other actual platform questions in the row. Cannot override the mapped fields. */
|
|
42
|
+
additionalAnswers?: Answers;
|
|
43
|
+
}
|
|
44
|
+
export interface CoordinatedEntryEventResult {
|
|
45
|
+
key: string; clientId: number; enrollmentId: number; recordsetId: number;
|
|
46
|
+
startDate: string; endDate: null; eventDate: string;
|
|
47
|
+
outcome: 'verified' | 'already-present' | 'reconciled';
|
|
48
|
+
}
|
|
49
|
+
export interface CoordinatedEntryResultInput {
|
|
50
|
+
/** The event key used with record(). The event must already be verified. */
|
|
51
|
+
key: string;
|
|
52
|
+
clientId: number;
|
|
53
|
+
assessmentCode: string;
|
|
54
|
+
referralResult: {option: PicklistSelector};
|
|
55
|
+
/** Date of the result; not before the event date. */
|
|
56
|
+
resultDate: string;
|
|
57
|
+
}
|
|
58
|
+
export interface CoordinatedEntryResultReceipt {
|
|
59
|
+
key: string; clientId: number; recordsetId: number; resultDate: string;
|
|
60
|
+
outcome: 'verified' | 'already-present' | 'reconciled';
|
|
61
|
+
}
|
|
62
|
+
export interface CoordinatedEntryEventRecorder {
|
|
63
|
+
record(input: CoordinatedEntryEventInput): Promise<CoordinatedEntryEventResult>;
|
|
64
|
+
/** Adds a referral result and date to a recorded ES, TH, Joint TH-RRH, RRH, PSH or Other PH
|
|
65
|
+
* referral. Updates only those cells through the mobile endpoint; journaled and never resent. */
|
|
66
|
+
recordResult(input: CoordinatedEntryResultInput): Promise<CoordinatedEntryResultReceipt>;
|
|
67
|
+
}
|
|
68
|
+
export class CoordinatedEntryEventConflictError extends Error {
|
|
69
|
+
readonly code: 'COORDINATED_ENTRY_EVENT_CONFLICT';
|
|
70
|
+
readonly reason: string;
|
|
71
|
+
constructor(reason: string);
|
|
72
|
+
}
|
|
73
|
+
/** REST orchestration through the standard assessment endpoint. No browser launch or credential loading. */
|
|
74
|
+
export function createCoordinatedEntryEventRecorder(options: {
|
|
75
|
+
api: WellSkyClient; store: UiMutationStore; fields: CoordinatedEntryEventFields;
|
|
76
|
+
/** Tenant-verified labels for lookup answers, including the Event. */
|
|
77
|
+
writeLabel: NonNullable<EncodingOptions['writeLabel']>;
|
|
78
|
+
}): CoordinatedEntryEventRecorder;
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
import {encodeAnswers, resolvePicklistOption, semanticHash} from '../dist/esm/index.js';
|
|
2
|
+
import {createRowRecorder, rowHelpers, checkJournal, object, positive, nonempty} from './subassessment-row-recorder.mjs';
|
|
3
|
+
|
|
4
|
+
const fail = reason => { throw new CoordinatedEntryEventConflictError(reason); };
|
|
5
|
+
export class CoordinatedEntryEventConflictError extends Error {
|
|
6
|
+
constructor(reason) { super(`Coordinated Entry Event: ${reason}`); this.name = 'CoordinatedEntryEventConflictError'; this.code = 'COORDINATED_ENTRY_EVENT_CONFLICT'; this.reason = reason; }
|
|
7
|
+
}
|
|
8
|
+
const {instant, exactFields, valueOf, childIndex} = rowHelpers(fail);
|
|
9
|
+
const roles = ['parentField', 'startDate', 'endDate', 'eventDate', 'event', 'housedSafely', 'enrolledInAftercare', 'location', 'referralResult', 'resultDate'];
|
|
10
|
+
const roleTypes = {startDate: 'date', endDate: 'date', eventDate: 'date', event: 'lookup', housedSafely: 'yes_no', enrolledInAftercare: 'yes_no', location: 'provider', referralResult: 'lookup', resultDate: 'date'};
|
|
11
|
+
// Follow-up answers each Event option allows, matching the form's dependency labels.
|
|
12
|
+
// Placement referrals require a location; results usually arrive later and stay optional.
|
|
13
|
+
const placement = {location: 'required', result: 'optional'};
|
|
14
|
+
const EVENT_RULES = Object.freeze({
|
|
15
|
+
PREVASSIST: {}, PROBSOLV: {housedSafely: 'optional'}, CRISISNEEDS: {}, HOUSINGNEEDS: {},
|
|
16
|
+
POSTPLACEFOLLOWUP: {enrolledInAftercare: 'optional'}, STREETOUTREACH: {}, HOUSINGNAVIGATION: {}, NONCONT_INELIG: {}, NONCONT_NOAVAIL: {},
|
|
17
|
+
EMERGSHELTER: placement, TRANSITHOUSING: placement, JOINTTHRRH: placement, RRH: placement, PSH: placement, OTHERPH: placement,
|
|
18
|
+
});
|
|
19
|
+
const EVENT_PREFIX = 'COORDINATEDENTRYEVENT_EVENT_';
|
|
20
|
+
|
|
21
|
+
function normalize(raw) {
|
|
22
|
+
exactFields(raw, ['key', 'clientId', 'enrollmentId', 'assessmentCode', 'eventDate', 'event', 'housedSafely', 'enrolledInAftercare', 'location', 'referralResult', 'resultDate', 'additionalAnswers']);
|
|
23
|
+
if (!nonempty(raw.key) || !positive(raw.clientId) || !positive(raw.enrollmentId) || !nonempty(raw.assessmentCode) || !object(raw.event) || raw.additionalAnswers !== undefined && !object(raw.additionalAnswers)) fail('invalid-input');
|
|
24
|
+
for (const flag of ['housedSafely', 'enrolledInAftercare']) if (raw[flag] !== undefined && typeof raw[flag] !== 'boolean') fail('invalid-input');
|
|
25
|
+
if (raw.location !== undefined && !positive(raw.location)) fail('location-provider-id-required');
|
|
26
|
+
if (raw.referralResult !== undefined && !(object(raw.referralResult) && object(raw.referralResult.option))) fail('referral-result-option-required');
|
|
27
|
+
if (raw.additionalAnswers !== undefined && Object.values(raw.additionalAnswers).some(answer => !object(answer))) fail('invalid-input');
|
|
28
|
+
return {key: raw.key, clientId: raw.clientId, enrollmentId: raw.enrollmentId, assessmentCode: raw.assessmentCode, eventDate: instant(raw.eventDate), event: structuredClone(raw.event),
|
|
29
|
+
...Object.fromEntries(['housedSafely', 'enrolledInAftercare', 'location'].filter(k => raw[k] !== undefined).map(k => [k, raw[k]])),
|
|
30
|
+
...(raw.referralResult !== undefined ? {referralResult: structuredClone(raw.referralResult)} : {}),
|
|
31
|
+
...(raw.resultDate !== undefined ? {resultDate: instant(raw.resultDate)} : {}),
|
|
32
|
+
additionalAnswers: structuredClone(raw.additionalAnswers ?? {})};
|
|
33
|
+
}
|
|
34
|
+
function ruleFor(option) {
|
|
35
|
+
const rule = option?.code?.startsWith(EVENT_PREFIX) ? EVENT_RULES[option.code.slice(EVENT_PREFIX.length)] : undefined;
|
|
36
|
+
if (!rule) fail('unsupported-event');
|
|
37
|
+
return rule;
|
|
38
|
+
}
|
|
39
|
+
function eventRule(question, selector) {
|
|
40
|
+
if (!('option' in selector)) fail('event-option-required');
|
|
41
|
+
let option;
|
|
42
|
+
try { option = resolvePicklistOption(question, selector.option); } catch { fail('unsupported-event'); }
|
|
43
|
+
return ruleFor(option);
|
|
44
|
+
}
|
|
45
|
+
function mappedChildren(definition, fields, assessmentCode, tenant) {
|
|
46
|
+
const {parent, index} = childIndex(definition, assessmentCode, fields.parentField, tenant);
|
|
47
|
+
for (const [role, type] of Object.entries(roleTypes)) if (index.get(fields[role])?.questionType !== type) fail(`${role}-field-unavailable`);
|
|
48
|
+
return {parent, index};
|
|
49
|
+
}
|
|
50
|
+
function normalizeResult(raw) {
|
|
51
|
+
exactFields(raw, ['key', 'clientId', 'assessmentCode', 'referralResult', 'resultDate']);
|
|
52
|
+
if (!nonempty(raw.key) || !positive(raw.clientId) || !nonempty(raw.assessmentCode)) fail('invalid-input');
|
|
53
|
+
if (!(object(raw.referralResult) && object(raw.referralResult.option))) fail('referral-result-option-required');
|
|
54
|
+
return {key: raw.key, clientId: raw.clientId, assessmentCode: raw.assessmentCode, referralResult: structuredClone(raw.referralResult), resultDate: instant(raw.resultDate)};
|
|
55
|
+
}
|
|
56
|
+
function compile(definition, fields, input, entryDate, tenant, writeLabel) {
|
|
57
|
+
// The standard endpoint rejects any request that omits a mandatory answer. Resending
|
|
58
|
+
// other answers would create new dated rows for them, so such instruments are refused.
|
|
59
|
+
if (definition?.questions?.some?.(q => q.mandatory === true && !['label', 'blank'].includes(q.questionType))) fail('instrument-has-mandatory-questions');
|
|
60
|
+
const {parent, index} = mappedChildren(definition, fields, input.assessmentCode, tenant);
|
|
61
|
+
if (index.get(fields.endDate).mandatory === true) fail('platform-requires-end-date');
|
|
62
|
+
if (Object.keys(input.additionalAnswers).some(field => Object.values(fields).includes(field))) fail('reserved-field-override');
|
|
63
|
+
const rule = eventRule(index.get(fields.event), input.event);
|
|
64
|
+
for (const flag of ['housedSafely', 'enrolledInAftercare']) if (input[flag] !== undefined && !rule[flag]) fail(`${flag}-not-applicable`);
|
|
65
|
+
if (rule.location === 'required' && input.location === undefined) fail('location-required');
|
|
66
|
+
if (!rule.location && input.location !== undefined) fail('location-not-applicable');
|
|
67
|
+
const hasResult = input.referralResult !== undefined, hasResultDate = input.resultDate !== undefined;
|
|
68
|
+
if ((hasResult || hasResultDate) && !rule.result) fail('referral-result-not-applicable');
|
|
69
|
+
if (hasResult !== hasResultDate) fail('referral-result-and-date-required-together');
|
|
70
|
+
if (hasResultDate && input.resultDate < input.eventDate) fail('result-before-event');
|
|
71
|
+
const answers = {...input.additionalAnswers, [fields.startDate]: {value: entryDate}, [fields.eventDate]: {value: input.eventDate}, [fields.event]: input.event,
|
|
72
|
+
...(input.housedSafely !== undefined ? {[fields.housedSafely]: {value: input.housedSafely}} : {}),
|
|
73
|
+
...(input.enrolledInAftercare !== undefined ? {[fields.enrolledInAftercare]: {value: input.enrolledInAftercare}} : {}),
|
|
74
|
+
...(input.location !== undefined ? {[fields.location]: {value: input.location}} : {}),
|
|
75
|
+
...(hasResult ? {[fields.referralResult]: input.referralResult, [fields.resultDate]: {value: input.resultDate}} : {})};
|
|
76
|
+
for (const [field, answer] of Object.entries(input.additionalAnswers)) if (index.get(field)?.questionType === 'date' && object(answer) && 'value' in answer) answers[field] = {value: instant(answer.value)};
|
|
77
|
+
// Labels are sent; the same selectors encoded as option IDs are what reads return.
|
|
78
|
+
const sent = encodeAnswers(parent.children, answers, {endpoint: 'standalone', writeLabel});
|
|
79
|
+
const stored = encodeAnswers(parent.children, answers, {endpoint: 'standalone', writeLabel: (_q, option) => String(option.id)});
|
|
80
|
+
for (const q of index.values()) {
|
|
81
|
+
if (q.mandatory === true && !sent.some(answer => answer.virt_field_name === q.fieldName && answer.answer_value !== '')) fail(`platform-required-field:${q.fieldName}`);
|
|
82
|
+
}
|
|
83
|
+
return {parent, sent, stored};
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** One new CE Event recordset per event key, written through the standard assessment endpoint,
|
|
87
|
+
* which keeps provider and yes/no answers that mobile writes drop. Follow-up answers are
|
|
88
|
+
* checked against the selected Event. */
|
|
89
|
+
export function createCoordinatedEntryEventRecorder({api, store, fields: mapping, writeLabel}) {
|
|
90
|
+
if (!store?.withLock || !store?.read || !store?.write || typeof api?.assessments?.create !== 'function') fail('api-and-store-required');
|
|
91
|
+
if (typeof writeLabel !== 'function') fail('write-label-required');
|
|
92
|
+
exactFields(mapping, roles);
|
|
93
|
+
const fields = structuredClone(mapping);
|
|
94
|
+
if (roles.some(role => !nonempty(fields[role])) || new Set(Object.values(fields)).size !== roles.length) fail('invalid-field-mapping');
|
|
95
|
+
const recorder = createRowRecorder({api, store, fail, kind: 'coordinated-entry-event', noun: 'event', dateKey: 'eventDate', parentField: fields.parentField, normalize,
|
|
96
|
+
compile(definition, input, enrollment) {
|
|
97
|
+
const {parent, sent, stored} = compile(definition, fields, input, enrollment.entryDate, api.tenant, writeLabel);
|
|
98
|
+
// Hash option IDs, not labels, so a relabelled option does not invalidate verified keys.
|
|
99
|
+
return {expected: stored, empty: [fields.endDate], intent: {fields, stored},
|
|
100
|
+
body: [{question_id: parent.questionId, question_type: 'sub_assessment', virt_field_name: fields.parentField, answer_value: '', subAssessmentQuestionAnswers: [{subAssessments: sent}]}]};
|
|
101
|
+
},
|
|
102
|
+
write: (input, body) => api.assessments.create({path: {clientId: input.clientId, assessmentCode: input.assessmentCode}, query: {dateEffective: input.eventDate}, body}),
|
|
103
|
+
});
|
|
104
|
+
/** Adds a referral result to an event this recorder verified. The standard endpoint only
|
|
105
|
+
* creates rows, so this sends just the result cells to the event's recordset through the
|
|
106
|
+
* mobile endpoint, which updates those cells and keeps the others, including the location. */
|
|
107
|
+
async function recordResult(raw) {
|
|
108
|
+
if (typeof api.assessmentsMobile?.addAnswer !== 'function') fail('api-and-store-required');
|
|
109
|
+
const input = normalizeResult(raw), scope = recorder.scopeOf(input.clientId);
|
|
110
|
+
return store.withLock(scope, async () => {
|
|
111
|
+
const entries = await store.read(scope);
|
|
112
|
+
checkJournal(entries, fail);
|
|
113
|
+
const entry = entries.find(e => e.key === input.key);
|
|
114
|
+
if (entry?.stage !== 'verified' || !positive(entry.recordsetId)) fail('event-not-verified');
|
|
115
|
+
if (entries.some(e => (e.stage === 'attempted' || e.update?.stage === 'attempted') && e.key !== input.key)) fail('unfinished-event');
|
|
116
|
+
const definition = await api.assessments.getDefinition({clientId: input.clientId, assessmentCode: input.assessmentCode, effectiveDate: input.resultDate, refresh: true});
|
|
117
|
+
const {parent, index} = mappedChildren(definition, fields, input.assessmentCode, api.tenant);
|
|
118
|
+
const cells = encodeAnswers(parent.children, {[fields.referralResult]: input.referralResult, [fields.resultDate]: {value: input.resultDate}}, {endpoint: 'mobile'});
|
|
119
|
+
const intentHash = await semanticHash({input, fields, cells});
|
|
120
|
+
if (entry.update && entry.update.intentHash !== intentHash) fail('result-intent-changed');
|
|
121
|
+
const read = () => recorder.readRows({clientId: input.clientId, assessmentCode: input.assessmentCode}, input.resultDate);
|
|
122
|
+
const result = outcome => ({key: input.key, clientId: input.clientId, recordsetId: entry.recordsetId, resultDate: input.resultDate, outcome});
|
|
123
|
+
const resultFields = [fields.referralResult, fields.resultDate];
|
|
124
|
+
const saved = row => cells.every(q => row.fields[q.virt_field_name]?.type === q.question_type && row.fields[q.virt_field_name]?.value === valueOf(q));
|
|
125
|
+
let rows = await read(), row = rows.find(row => row.id === entry.recordsetId);
|
|
126
|
+
if (!row) fail('verified-event-changed');
|
|
127
|
+
const eventOptions = index.get(fields.event).options, storedEvent = row.fields[fields.event]?.value, storedDate = row.fields[fields.eventDate]?.value;
|
|
128
|
+
if (!Array.isArray(eventOptions)) fail('event-picklist-unavailable');
|
|
129
|
+
const eventOption = eventOptions.find(o => String(o.id) === storedEvent);
|
|
130
|
+
if (!eventOption || typeof storedDate !== 'string') fail('verified-event-changed');
|
|
131
|
+
if (!ruleFor(eventOption).result) fail('referral-result-not-applicable');
|
|
132
|
+
// A result may follow the client's exit; only the event itself must fall within the enrollment.
|
|
133
|
+
if (input.resultDate < storedDate) fail('result-before-event');
|
|
134
|
+
// Only the result cells may change: earlier answers, other rows and the row count stay.
|
|
135
|
+
const reconcile = async () => {
|
|
136
|
+
const before = entry.update.before, target = before.find(r => r.id === entry.recordsetId);
|
|
137
|
+
if (rows.length !== before.length) fail('existing-event-changed');
|
|
138
|
+
for (const prior of before) {
|
|
139
|
+
const after = rows.find(r => r.id === prior.id);
|
|
140
|
+
if (!after) fail('existing-event-changed');
|
|
141
|
+
if (prior.id !== entry.recordsetId && await semanticHash(after) !== await semanticHash(prior)) fail('existing-event-changed');
|
|
142
|
+
}
|
|
143
|
+
const after = rows.find(r => r.id === entry.recordsetId);
|
|
144
|
+
for (const [field, answer] of Object.entries(target.fields)) if (!resultFields.includes(field) && await semanticHash(after.fields[field]) !== await semanticHash(answer)) fail('verified-event-changed');
|
|
145
|
+
if (!saved(after)) fail('uncertain-result-save');
|
|
146
|
+
};
|
|
147
|
+
// Drift in the event's other answers is reported by record() under the same key.
|
|
148
|
+
if (entry.update?.stage === 'verified') { if (!saved(row)) fail('verified-result-changed'); return result('already-present'); }
|
|
149
|
+
if (entry.update?.stage === 'attempted') {
|
|
150
|
+
await reconcile();
|
|
151
|
+
entry.update = {stage: 'verified', intentHash};
|
|
152
|
+
await store.write(scope, structuredClone(entries));
|
|
153
|
+
return result('reconciled');
|
|
154
|
+
}
|
|
155
|
+
if (resultFields.some(field => (row.fields[field]?.value ?? null) !== null)) {
|
|
156
|
+
if (!saved(row)) fail('result-conflict');
|
|
157
|
+
entry.update = {stage: 'verified', intentHash};
|
|
158
|
+
await store.write(scope, structuredClone(entries));
|
|
159
|
+
return result('already-present');
|
|
160
|
+
}
|
|
161
|
+
entry.update = {stage: 'attempted', intentHash, before: rows};
|
|
162
|
+
await store.write(scope, structuredClone(entries));
|
|
163
|
+
let failed = false;
|
|
164
|
+
try {
|
|
165
|
+
await api.assessmentsMobile.addAnswer({path: {clientId: input.clientId, assessmentCode: input.assessmentCode}, query: {dateEffective: input.resultDate}, body: [{question_id: parent.questionId, question_type: 'sub_assessment', virt_field_name: fields.parentField, answer_value: '', subAssessmentQuestionAnswers: [{recordset_id: entry.recordsetId, isDeleted: false, subAssessments: cells}]}]});
|
|
166
|
+
} catch { failed = true; }
|
|
167
|
+
rows = await read();
|
|
168
|
+
await reconcile();
|
|
169
|
+
entry.update = {stage: 'verified', intentHash};
|
|
170
|
+
await store.write(scope, structuredClone(entries));
|
|
171
|
+
return result(failed ? 'reconciled' : 'verified');
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
return {record: recorder.record, recordResult};
|
|
175
|
+
}
|
|
@@ -1,60 +1,19 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
1
|
+
import {encodeAnswers} from '../dist/esm/index.js';
|
|
2
|
+
import {createRowRecorder, rowHelpers, object, positive, nonempty} from './subassessment-row-recorder.mjs';
|
|
3
3
|
|
|
4
|
-
const object = value => value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
5
|
-
const positive = value => Number.isSafeInteger(value) && value > 0;
|
|
6
4
|
const fail = reason => { throw new CurrentLivingSituationConflictError(reason); };
|
|
7
5
|
export class CurrentLivingSituationConflictError extends Error {
|
|
8
6
|
constructor(reason) { super(`Current Living Situation: ${reason}`); this.name = 'CurrentLivingSituationConflictError'; this.code = 'CURRENT_LIVING_SITUATION_CONFLICT'; this.reason = reason; }
|
|
9
7
|
}
|
|
10
|
-
|
|
11
|
-
if (typeof value !== 'string') fail('invalid-date');
|
|
12
|
-
// Enrollment/assessment reads emit UTC without a suffix.
|
|
13
|
-
const text = stored ? value.replace(' ', 'T').replace(/([+-]\d\d)$/, '$1:00') + (/(?:Z|[+-]\d\d(?::\d\d)?)$/.test(value) ? '' : 'Z') : value;
|
|
14
|
-
if (!/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.000)?(?:Z|[+-]\d{2}:\d{2})$/.test(text) || !Number.isFinite(Date.parse(text)) || new Date(text.slice(0, 19) + 'Z').toISOString().slice(0, 19) !== text.slice(0, 19)) fail('invalid-date');
|
|
15
|
-
return new Date(text).toISOString();
|
|
16
|
-
}
|
|
17
|
-
function exactFields(value, allowed) {
|
|
18
|
-
if (!object(value) || Object.keys(value).some(key => !allowed.includes(key))) fail('invalid-input');
|
|
19
|
-
}
|
|
20
|
-
function nonempty(value) { return typeof value === 'string' && value.trim().length > 0 && value.length <= 200; }
|
|
8
|
+
const {instant, exactFields, childIndex} = rowHelpers(fail);
|
|
21
9
|
function normalize(raw) {
|
|
22
10
|
exactFields(raw, ['key', 'clientId', 'enrollmentId', 'assessmentCode', 'informationDate', 'livingSituation', 'endDate', 'verifiedBy', 'additionalAnswers']);
|
|
23
11
|
if (!nonempty(raw.key) || !positive(raw.clientId) || !positive(raw.enrollmentId) || !nonempty(raw.assessmentCode) || !object(raw.livingSituation) || (raw.endDate !== undefined && raw.endDate !== null && raw.endDate !== '')) fail('invalid-input');
|
|
24
12
|
if (raw.verifiedBy !== undefined && !object(raw.verifiedBy) || raw.additionalAnswers !== undefined && !object(raw.additionalAnswers)) fail('invalid-input');
|
|
25
13
|
return {key: raw.key, clientId: raw.clientId, enrollmentId: raw.enrollmentId, assessmentCode: raw.assessmentCode, informationDate: instant(raw.informationDate), livingSituation: structuredClone(raw.livingSituation), endDate: '', ...(raw.verifiedBy !== undefined ? {verifiedBy: structuredClone(raw.verifiedBy)} : {}), additionalAnswers: structuredClone(raw.additionalAnswers ?? {})};
|
|
26
14
|
}
|
|
27
|
-
async function readEnrollment(api, input) {
|
|
28
|
-
const rows = [], ids = new Set(); let total;
|
|
29
|
-
for (let page = 1; page <= 100; page++) {
|
|
30
|
-
const response = await api.enrollments.listForClient({path: {clientId: input.clientId}, query: {perPage: 100, startPage: page, total_count: true}});
|
|
31
|
-
if (!Number.isSafeInteger(response?.count) || response.count < 0 || !Array.isArray(response.values) || total !== undefined && total !== response.count) fail('enrollment-count-unavailable');
|
|
32
|
-
total = response.count;
|
|
33
|
-
for (const row of response.values) {
|
|
34
|
-
if (!object(row) || !positive(row.enrollmentId) || row.clientId !== input.clientId || ids.has(row.enrollmentId)) fail('invalid-enrollment-read');
|
|
35
|
-
ids.add(row.enrollmentId); rows.push(row);
|
|
36
|
-
}
|
|
37
|
-
if (rows.length === total) {
|
|
38
|
-
const row = rows.find(row => row.enrollmentId === input.enrollmentId);
|
|
39
|
-
if (!row || row.isActive !== true || !positive(row.enrollmentProvider)) fail('enrollment-unavailable');
|
|
40
|
-
const entryDate = instant(row.projectStartDate, true);
|
|
41
|
-
if (input.informationDate < entryDate || row.projectExitDate && input.informationDate > instant(row.projectExitDate, true)) fail('contact-outside-enrollment');
|
|
42
|
-
return {entryDate, providerId: row.enrollmentProvider};
|
|
43
|
-
}
|
|
44
|
-
if (rows.length > total || !response.values.length) fail('incomplete-enrollment-read');
|
|
45
|
-
}
|
|
46
|
-
fail('enrollment-page-limit');
|
|
47
|
-
}
|
|
48
15
|
function compile(definition, fields, input, entryDate, tenant) {
|
|
49
|
-
|
|
50
|
-
const parents = definition.questions.filter(q => q.fieldName === fields.parentField && q.questionType === 'sub_assessment');
|
|
51
|
-
if (parents.length !== 1 || !positive(parents[0].questionId) || !Array.isArray(parents[0].children)) fail('parent-unavailable');
|
|
52
|
-
const parent = parents[0], index = new Map();
|
|
53
|
-
for (const q of parent.children) {
|
|
54
|
-
if (['label', 'blank'].includes(q.questionType)) continue;
|
|
55
|
-
if (!nonempty(q.fieldName) || !positive(q.questionId) || index.has(q.fieldName)) fail('ambiguous-definition');
|
|
56
|
-
index.set(q.fieldName, q);
|
|
57
|
-
}
|
|
16
|
+
const {parent, index} = childIndex(definition, input.assessmentCode, fields.parentField, tenant);
|
|
58
17
|
for (const role of ['startDate', 'endDate', 'informationDate']) if (index.get(fields[role])?.questionType !== 'date') fail('date-field-unavailable');
|
|
59
18
|
if (index.get(fields.livingSituation)?.questionType !== 'lookup') fail('living-situation-picklist-unavailable');
|
|
60
19
|
if (index.get(fields.endDate).mandatory === true) fail('platform-requires-end-date');
|
|
@@ -75,87 +34,19 @@ function compile(definition, fields, input, entryDate, tenant) {
|
|
|
75
34
|
// Do not invent answers for omitted optional questions. Verify every answer sent.
|
|
76
35
|
return {parent, encoded};
|
|
77
36
|
}
|
|
78
|
-
function valueOf(question) {
|
|
79
|
-
if (question.answer_value === null || question.answer_value === undefined || question.answer_value === '') return null;
|
|
80
|
-
if (question.question_type === 'date') return instant(question.answer_value, true);
|
|
81
|
-
if (typeof question.answer_value !== 'string') fail('invalid-answer-read');
|
|
82
|
-
return question.answer_value;
|
|
83
|
-
}
|
|
84
|
-
async function readRows(api, input, fields) {
|
|
85
|
-
const response = await api.assessments.get({path: {clientId: input.clientId, assessmentCode: input.assessmentCode}});
|
|
86
|
-
const questions = toItems(response);
|
|
87
|
-
if (!Array.isArray(questions) || object(response) && 'count' in response && response.count !== questions.length) fail('incomplete-assessment-read');
|
|
88
|
-
const parents = questions.filter(q => q?.virt_field_name === fields.parentField && q.question_type === 'sub_assessment');
|
|
89
|
-
if (parents.length !== 1 || !Array.isArray(parents[0].sub_assessment_answer_list)) fail('rows-unavailable');
|
|
90
|
-
const rows = parents[0].sub_assessment_answer_list, ids = new Set();
|
|
91
|
-
if (rows.length > 10000) fail('row-limit');
|
|
92
|
-
return rows.map(row => {
|
|
93
|
-
if (!positive(row.recordset_id) || ids.has(row.recordset_id) || !Array.isArray(row.sub_assessment_list)) fail('ambiguous-row-read');
|
|
94
|
-
ids.add(row.recordset_id);
|
|
95
|
-
const fields = {};
|
|
96
|
-
for (const q of row.sub_assessment_list) {
|
|
97
|
-
if (['label', 'blank'].includes(q.question_type)) continue;
|
|
98
|
-
if (!nonempty(q.virt_field_name) || Object.hasOwn(fields, q.virt_field_name) || typeof q.question_type !== 'string') fail('ambiguous-answer-read');
|
|
99
|
-
Object.defineProperty(fields, q.virt_field_name, {value: {type: q.question_type, value: valueOf(q)}, enumerable: true});
|
|
100
|
-
}
|
|
101
|
-
return {id: row.recordset_id, fields};
|
|
102
|
-
});
|
|
103
|
-
}
|
|
104
|
-
const matches = (row, encoded) => encoded.every(q => row.fields[q.virt_field_name]?.type === q.question_type && row.fields[q.virt_field_name]?.value === valueOf(q));
|
|
105
37
|
|
|
106
38
|
/** One new recordset per contact key, even when two contacts have identical answers. */
|
|
107
39
|
export function createCurrentLivingSituationRecorder({api, store, fields: mapping}) {
|
|
108
|
-
const tenantScope=apiScope(api);
|
|
109
40
|
if (!store?.withLock || !store?.read || !store?.write) fail('api-and-store-required');
|
|
110
41
|
exactFields(mapping, ['parentField', 'startDate', 'endDate', 'informationDate', 'livingSituation', 'verifiedBy']);
|
|
111
42
|
const fields = structuredClone(Object.fromEntries(Object.entries(mapping).filter(([, value]) => value !== undefined)));
|
|
112
43
|
if (['parentField', 'startDate', 'endDate', 'informationDate', 'livingSituation'].some(role => !nonempty(fields[role])) || fields.verifiedBy !== undefined && !nonempty(fields.verifiedBy) || new Set(Object.values(fields)).size !== Object.keys(fields).length) fail('invalid-field-mapping');
|
|
113
|
-
return {
|
|
114
|
-
|
|
115
|
-
return store.withLock(scope, async () => {
|
|
116
|
-
const entries = await store.read(scope);
|
|
117
|
-
if (!Array.isArray(entries) || new Set(entries.map(e => e?.key)).size !== entries.length || entries.some(e => !object(e) || e.version !== 1 || !nonempty(e.key) || !['attempted', 'verified'].includes(e.stage) || typeof e.intentHash !== 'string' || !Array.isArray(e.baseline) || e.stage === 'verified' && !positive(e.recordsetId))) fail('invalid-journal');
|
|
118
|
-
let entry = entries.find(e => e.key === input.key);
|
|
119
|
-
if (entries.some(e => e.stage === 'attempted' && e.key !== input.key)) fail('unfinished-contact');
|
|
120
|
-
const enrollment = await readEnrollment(api, input);
|
|
121
|
-
const definition = await api.assessments.getDefinition({clientId: input.clientId, assessmentCode: input.assessmentCode, effectiveDate: input.informationDate, refresh: true});
|
|
44
|
+
return createRowRecorder({api, store, fail, kind: 'current-living-situation', noun: 'contact', dateKey: 'informationDate', parentField: fields.parentField, normalize,
|
|
45
|
+
compile(definition, input, enrollment) {
|
|
122
46
|
const {parent, encoded} = compile(definition, fields, input, enrollment.entryDate, api.tenant);
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
const row = rows.find(row => row.id === entry.recordsetId);
|
|
129
|
-
if (!row || !matches(row, encoded)) fail('verified-contact-changed');
|
|
130
|
-
return result(row, 'already-present');
|
|
131
|
-
}
|
|
132
|
-
const reconcile = async () => {
|
|
133
|
-
for (const before of entry.baseline) {
|
|
134
|
-
const after = rows.find(row => row.id === before.id);
|
|
135
|
-
if (!after || await semanticHash(after) !== await semanticHash(before)) fail('existing-contact-changed');
|
|
136
|
-
}
|
|
137
|
-
const added = rows.filter(row => !entry.baseline.some(before => before.id === row.id));
|
|
138
|
-
if (added.length !== 1 || !matches(added[0], encoded)) fail('uncertain-contact-save');
|
|
139
|
-
return added[0];
|
|
140
|
-
};
|
|
141
|
-
if (entry) {
|
|
142
|
-
const row = await reconcile();
|
|
143
|
-
entry.stage = 'verified'; entry.recordsetId = row.id;
|
|
144
|
-
await store.write(scope, structuredClone(entries));
|
|
145
|
-
return result(row, 'reconciled');
|
|
146
|
-
}
|
|
147
|
-
entry = {version: 1, key: input.key, intentHash, stage: 'attempted', baseline: rows};
|
|
148
|
-
entries.push(entry);
|
|
149
|
-
await store.write(scope, structuredClone(entries));
|
|
150
|
-
let failed = false;
|
|
151
|
-
try {
|
|
152
|
-
await api.assessmentsMobile.addAnswer({path: {clientId: input.clientId, assessmentCode: input.assessmentCode}, query: {dateEffective: input.informationDate}, body: [{question_id: parent.questionId, question_type: 'sub_assessment', virt_field_name: fields.parentField, answer_value: '', subAssessmentQuestionAnswers: [{recordset_id: 0, isDeleted: false, subAssessments: encoded}]}]});
|
|
153
|
-
} catch { failed = true; }
|
|
154
|
-
rows = await readRows(api, input, fields);
|
|
155
|
-
const row = await reconcile();
|
|
156
|
-
entry.stage = 'verified'; entry.recordsetId = row.id;
|
|
157
|
-
await store.write(scope, structuredClone(entries));
|
|
158
|
-
return result(row, failed ? 'reconciled' : 'verified');
|
|
159
|
-
});
|
|
160
|
-
}};
|
|
47
|
+
return {expected: encoded, empty: [], intent: {fields, encoded},
|
|
48
|
+
body: [{question_id: parent.questionId, question_type: 'sub_assessment', virt_field_name: fields.parentField, answer_value: '', subAssessmentQuestionAnswers: [{recordset_id: 0, isDeleted: false, subAssessments: encoded}]}]};
|
|
49
|
+
},
|
|
50
|
+
write: (input, body) => api.assessmentsMobile.addAnswer({path: {clientId: input.clientId, assessmentCode: input.assessmentCode}, query: {dateEffective: input.informationDate}, body}),
|
|
51
|
+
});
|
|
161
52
|
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type {DateOfBirth} from './date-of-birth.mjs';
|
|
2
|
-
import type {Answers, AssessmentDefinition, GwtEnrollmentHelpers, Provider, WellSkyClient} from '../dist/esm/index.js';
|
|
2
|
+
import type {Answers, AssessmentDefinition, EncodingOptions, GwtEnrollmentHelpers, Provider, WellSkyClient} from '../dist/esm/index.js';
|
|
3
3
|
import type {UiMutationStore,EnrollmentReader} from './index.mjs';
|
|
4
4
|
import type {ProviderAssessmentItem, ProgramDiscovery} from './program-discovery.mjs';
|
|
5
5
|
import type {HouseholdExitUi,HouseholdExitDetailInput} from './household-exit.mjs';
|
|
@@ -170,10 +170,18 @@ export interface EnrollmentWorkflowOptions {
|
|
|
170
170
|
exitUi?:HouseholdExitUi;
|
|
171
171
|
/** Required to set the later entry date for newly attached household members. */
|
|
172
172
|
memberEntryDatePort?:MemberEntryDatePort;
|
|
173
|
+
/** Default `mobile` keeps yes/no answers in the mobile scalar batch, which can store them empty.
|
|
174
|
+
* `standard` writes them through `assessments.create` with every mandatory answer, after the mobile scalars;
|
|
175
|
+
* every mandatory question must be a lookup. An unfinished journal never reconciles in the other mode. */
|
|
176
|
+
yesNoWrites?:'mobile'|'standard';
|
|
177
|
+
/** Required for `yesNoWrites: 'standard'`: tenant-verified labels for mandatory lookup answers. */
|
|
178
|
+
writeLabel?:EncodingOptions['writeLabel'];
|
|
173
179
|
}
|
|
174
180
|
export class EnrollmentWorkflowError extends Error {
|
|
175
181
|
readonly reason:string;
|
|
176
|
-
|
|
182
|
+
/** Failed durable stage, when known. The original write error is available as cause in memory. */
|
|
183
|
+
readonly step?:string;
|
|
184
|
+
constructor(reason:string,options?:ErrorOptions&{step?:string});
|
|
177
185
|
}
|
|
178
186
|
/** Tenant-configured. Retain stable keys and one durable store for all cooperating writers.
|
|
179
187
|
* A partial or uncertain write requires reconciliation; no automatic write replay occurs. */
|