@choiceapps/communityservices-sdk 0.1.1 → 0.3.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.
Files changed (65) hide show
  1. package/CHANGELOG.md +19 -0
  2. package/browser/client-workflows.mjs +5 -2
  3. package/browser/coordinated-entry-event.d.mts +81 -0
  4. package/browser/coordinated-entry-event.mjs +175 -0
  5. package/browser/current-living-situation.d.mts +4 -1
  6. package/browser/current-living-situation.mjs +12 -121
  7. package/browser/enrollment-workflow.d.mts +10 -2
  8. package/browser/enrollment-workflow.mjs +65 -13
  9. package/browser/index.d.mts +1 -0
  10. package/browser/index.mjs +1 -0
  11. package/browser/shelters.mjs +2 -2
  12. package/browser/subassessment-row-recorder.mjs +169 -0
  13. package/dist/cjs/encoding.d.ts +5 -0
  14. package/dist/cjs/encoding.d.ts.map +1 -1
  15. package/dist/cjs/encoding.js +12 -1
  16. package/dist/cjs/encoding.js.map +1 -1
  17. package/dist/cjs/errors.d.ts +4 -1
  18. package/dist/cjs/errors.d.ts.map +1 -1
  19. package/dist/cjs/errors.js +9 -1
  20. package/dist/cjs/errors.js.map +1 -1
  21. package/dist/cjs/runtime.d.ts +5 -1
  22. package/dist/cjs/runtime.d.ts.map +1 -1
  23. package/dist/cjs/runtime.js +1 -1
  24. package/dist/cjs/runtime.js.map +1 -1
  25. package/dist/cjs/subassessments.d.ts.map +1 -1
  26. package/dist/cjs/subassessments.js +8 -15
  27. package/dist/cjs/subassessments.js.map +1 -1
  28. package/dist/cjs/transport.d.ts.map +1 -1
  29. package/dist/cjs/transport.js +15 -3
  30. package/dist/cjs/transport.js.map +1 -1
  31. package/dist/esm/encoding.d.ts +5 -0
  32. package/dist/esm/encoding.d.ts.map +1 -1
  33. package/dist/esm/encoding.js +11 -1
  34. package/dist/esm/encoding.js.map +1 -1
  35. package/dist/esm/errors.d.ts +4 -1
  36. package/dist/esm/errors.d.ts.map +1 -1
  37. package/dist/esm/errors.js +9 -1
  38. package/dist/esm/errors.js.map +1 -1
  39. package/dist/esm/runtime.d.ts +5 -1
  40. package/dist/esm/runtime.d.ts.map +1 -1
  41. package/dist/esm/runtime.js +1 -1
  42. package/dist/esm/runtime.js.map +1 -1
  43. package/dist/esm/subassessments.d.ts.map +1 -1
  44. package/dist/esm/subassessments.js +8 -15
  45. package/dist/esm/subassessments.js.map +1 -1
  46. package/dist/esm/transport.d.ts.map +1 -1
  47. package/dist/esm/transport.js +15 -3
  48. package/dist/esm/transport.js.map +1 -1
  49. package/docs/README.md +1 -0
  50. package/docs/assessments.md +22 -3
  51. package/docs/capabilities.md +3 -1
  52. package/docs/configuration.md +1 -0
  53. package/docs/coordinated-entry-events.md +88 -0
  54. package/docs/current-living-situation.md +3 -1
  55. package/docs/dates-and-answers.md +3 -1
  56. package/docs/enrollments.md +21 -0
  57. package/docs/reference.md +6 -2
  58. package/docs/troubleshooting.md +25 -0
  59. package/docs/vendor-issues/README.md +2 -0
  60. package/docs/vendor-issues/date-semantics.md +2 -0
  61. package/docs/vendor-issues/yes-no-answers.md +43 -0
  62. package/examples/README.md +2 -0
  63. package/examples/coordinated-entry-events.mts +37 -0
  64. package/examples/current-living-situation.mts +4 -2
  65. package/package.json +1 -1
package/CHANGELOG.md CHANGED
@@ -1,5 +1,24 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.3.0 - 2026-11-03
4
+
5
+ - Compare journaled attachment and case-note intents, and shelter stay identities, by canonical hash instead of key-order-sensitive JSON, so an identical retry no longer conflicts when a store such as Postgres `jsonb` reorders keys. Existing journals remain valid.
6
+ - Add an optional `enrollmentSession` reader to `createCurrentLivingSituationRecorder` and `createCoordinatedEntryEventRecorder`. It reads the enrollment through GWT, so clients in household enrollments, whose REST enrollment reads fail with `42703`, can be recorded. Journal hashes match the REST path.
7
+ - Export `parseRestInstant`, which parses REST read timestamps such as `2026-11-20 10:00:00-07` the same way on every engine and host timezone, treating a missing offset as UTC. `Date.parse` can return NaN for that form and reads a missing offset as local time.
8
+ - Document that a mobile batch which returned HTTP 400 once saved one of its answers. The batch itself did not fail again in sandbox retests, including enrolled single-adult and household clients; the cause is unknown and the workflow is unchanged.
9
+
10
+ ## 0.2.0 - 2026-11-02
11
+
12
+ - 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.
13
+ - 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.
14
+ - 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.
15
+ - 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.
16
+ - 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.
17
+ - 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.
18
+ - `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.
19
+ - `encodeNewSubassessmentRow` omits an unanswered provider child for mobile rows instead of rejecting the row. Answered provider children are still refused.
20
+ - 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.
21
+
3
22
  ## 0.1.1 - 2026-11-02
4
23
 
5
24
  - 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.
@@ -3,9 +3,12 @@ import {createHash} from 'node:crypto';
3
3
  import {decodeMappedResponse} from './gwt-mapped.mjs';
4
4
  import {withPageLock} from './page-lock.mjs';
5
5
  import {AttachmentConflictError} from './attachment-conflict.mjs';
6
+ import {semanticHash} from '../dist/esm/index.js';
6
7
  const id=n=>{if(!Number.isSafeInteger(n)||n<=0)throw Error('Positive integer ID required');return n;};
7
8
  const hash=b=>createHash('sha256').update(b).digest('hex');
8
9
  const maxBytes=10_000_000;
10
+ // Key-order independent: stores such as Postgres jsonb do not preserve insertion order.
11
+ const sameIntent=async(a,b)=>await semanticHash(a)===await semanticHash(b);
9
12
 
10
13
  /** Browser-authenticated attachment access where the REST account cannot upload/read files.
11
14
  * Own this page exclusively. The store must persist before resolving and survive restarts. */
@@ -64,7 +67,7 @@ export function createClientWorkflows({page,store}) {
64
67
  await openClient(clientId);
65
68
  if(entityId&&!(await mapped(`SELECT g.goalId AS goalId FROM SpGoal AS g WHERE g.active = TRUE AND g.goalId = ${entityId} AND g.clientId = ${clientId}`)).length)throw Error('Goal does not belong to client');
66
69
  const records=await store.read(scope);if(!Array.isArray(records)||records.length>1)throw Error('Invalid upload journal');const previous=records[0];
67
- if(previous&&JSON.stringify(previous.intent)!==JSON.stringify(intent))throw new AttachmentConflictError('intent-changed','Attachment intent conflicts with prior attempt');
70
+ if(previous&&!(await sameIntent(previous.intent,intent)))throw new AttachmentConflictError('intent-changed','Attachment intent conflicts with prior attempt');
68
71
  const matching=async()=>{
69
72
  const matches=(await list(clientId)).filter(a=>a.entityType===entityType&&(a.entityId??null)===entityId&&a.name===file.name);
70
73
  if(matches.length>1)throw new AttachmentConflictError('duplicate-name','Duplicate attachment names');
@@ -94,7 +97,7 @@ export function createClientWorkflows({page,store}) {
94
97
  const scope=JSON.stringify(key===undefined?[base,'case-note',clientId,goalId,providerId,noteDate,hash(note)]:[base,'case-note-source',hash(key)]);
95
98
  return store.withLock(scope,async()=>{
96
99
  const records=await store.read(scope);if(!Array.isArray(records)||records.length>1)throw Error('Invalid case note journal');const previous=records[0];
97
- if(previous&&JSON.stringify(previous.intent)!==JSON.stringify(intent))throw Object.assign(Error('Case note source key conflicts with earlier intent'),{code:'CASE_NOTE_SOURCE_CONFLICT'});
100
+ if(previous&&!(await sameIntent(previous.intent,intent)))throw Object.assign(Error('Case note source key conflicts with earlier intent'),{code:'CASE_NOTE_SOURCE_CONFLICT'});
98
101
  return withPageLock(page,async()=>{
99
102
  await openClient(clientId);
100
103
  if(!(await mapped(`SELECT g.goalId AS goalId FROM SpGoal AS g WHERE g.active = TRUE AND g.goalId = ${goalId} AND g.clientId = ${clientId}`)).length)throw Error('Goal does not belong to client');
@@ -0,0 +1,81 @@
1
+ import type {Answers, EncodingOptions, PicklistSelector, WellSkyClient} from '../dist/esm/index.js';
2
+ import type {EnrollmentReader, 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
+ /** Read the enrollment through this GWT reader instead of REST, which fails for grouped
79
+ * (household) enrollments. Use the reader `openEnrollmentSession` returns. */
80
+ enrollmentSession?: EnrollmentReader;
81
+ }): 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, enrollmentSession}) {
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, enrollmentSession, 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,5 +1,5 @@
1
1
  import type {Answers, AnswerValue, WellSkyClient} from '../dist/esm/index.js';
2
- import type {UiMutationStore} from './index.mjs';
2
+ import type {EnrollmentReader, UiMutationStore} from './index.mjs';
3
3
  /** Exact virtual fields from the target program's client-scoped metadata. No guessed codes. */
4
4
  export interface CurrentLivingSituationFields {
5
5
  parentField: string;
@@ -41,4 +41,7 @@ export class CurrentLivingSituationConflictError extends Error {
41
41
  /** REST orchestration. No browser launch, credential loading, or automatic interim. */
42
42
  export function createCurrentLivingSituationRecorder(options: {
43
43
  api: WellSkyClient; store: UiMutationStore; fields: CurrentLivingSituationFields;
44
+ /** Read the enrollment through this GWT reader instead of REST, which fails for grouped
45
+ * (household) enrollments. Use the reader `openEnrollmentSession` returns. */
46
+ enrollmentSession?: EnrollmentReader;
44
47
  }): CurrentLivingSituationRecorder;
@@ -1,60 +1,19 @@
1
- import {apiScope} from './connection.mjs';
2
- import {encodeAnswers, toItems, semanticHash} from '../dist/esm/index.js';
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
- function instant(value, stored = false) {
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
- if (definition?.tenant !== tenant || definition.assessmentCode !== input.assessmentCode || !Array.isArray(definition.questions)) fail('invalid-definition');
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
- export function createCurrentLivingSituationRecorder({api, store, fields: mapping}) {
108
- const tenantScope=apiScope(api);
39
+ export function createCurrentLivingSituationRecorder({api, store, fields: mapping, enrollmentSession}) {
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 {async record(raw) {
114
- const input = normalize(raw), scope = JSON.stringify([tenantScope, 'current-living-situation', input.clientId]);
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, enrollmentSession, 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
- const intentHash = await semanticHash({input, fields, enrollment, encoded});
124
- if (entry && entry.intentHash !== intentHash) fail('intent-or-schema-changed');
125
- let rows = await readRows(api, input, fields);
126
- const result = (row, outcome) => ({key: input.key, clientId: input.clientId, enrollmentId: input.enrollmentId, recordsetId: row.id, startDate: enrollment.entryDate, endDate: null, informationDate: input.informationDate, outcome});
127
- if (entry?.stage === 'verified') {
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
- constructor(reason:string);
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. */