@choiceapps/communityservices-sdk 0.1.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 (294) hide show
  1. package/AGENTS.md +43 -0
  2. package/API.md +86 -0
  3. package/CHANGELOG.md +12 -0
  4. package/LICENSE +201 -0
  5. package/NOTICE +11 -0
  6. package/README.md +100 -0
  7. package/SSVF.md +131 -0
  8. package/browser/attachment-conflict.mjs +6 -0
  9. package/browser/case-plans-ui.mjs +23 -0
  10. package/browser/case-plans.d.mts +20 -0
  11. package/browser/case-plans.mjs +89 -0
  12. package/browser/client-creation.d.mts +16 -0
  13. package/browser/client-creation.mjs +60 -0
  14. package/browser/client-profile-ui.mjs +59 -0
  15. package/browser/client-profile.d.mts +24 -0
  16. package/browser/client-profile.mjs +94 -0
  17. package/browser/client-workflows.mjs +135 -0
  18. package/browser/connection.d.mts +4 -0
  19. package/browser/connection.mjs +41 -0
  20. package/browser/current-living-situation.d.mts +44 -0
  21. package/browser/current-living-situation.mjs +161 -0
  22. package/browser/date-of-birth-ui.mjs +68 -0
  23. package/browser/date-of-birth.d.mts +28 -0
  24. package/browser/date-of-birth.mjs +112 -0
  25. package/browser/enrollment-workflow.d.mts +174 -0
  26. package/browser/enrollment-workflow.mjs +518 -0
  27. package/browser/exit-details.mjs +21 -0
  28. package/browser/file-store.mjs +32 -0
  29. package/browser/gwt-mapped.mjs +46 -0
  30. package/browser/household-exit.d.mts +34 -0
  31. package/browser/household-exit.mjs +102 -0
  32. package/browser/households-ui.mjs +20 -0
  33. package/browser/households.d.mts +22 -0
  34. package/browser/households.mjs +115 -0
  35. package/browser/index.d.mts +78 -0
  36. package/browser/index.mjs +46 -0
  37. package/browser/member-entry-date.d.mts +30 -0
  38. package/browser/member-entry-date.mjs +71 -0
  39. package/browser/need-recording.d.mts +37 -0
  40. package/browser/need-recording.mjs +68 -0
  41. package/browser/need-ui.mjs +138 -0
  42. package/browser/page-lock.mjs +11 -0
  43. package/browser/password-recovery.mjs +49 -0
  44. package/browser/playwright-session.mjs +196 -0
  45. package/browser/program-discovery.d.mts +22 -0
  46. package/browser/program-discovery.mjs +80 -0
  47. package/browser/recovery-diagnostics.mjs +9 -0
  48. package/browser/service-recording.d.mts +45 -0
  49. package/browser/service-recording.mjs +148 -0
  50. package/browser/service-ui.mjs +160 -0
  51. package/browser/session-authentication.mjs +40 -0
  52. package/browser/shelter-inventory.d.mts +103 -0
  53. package/browser/shelter-inventory.mjs +273 -0
  54. package/browser/shelter-ui.mjs +382 -0
  55. package/browser/shelters.d.mts +139 -0
  56. package/browser/shelters.mjs +425 -0
  57. package/browser/ssvf-rules.d.mts +21 -0
  58. package/browser/ssvf-rules.mjs +145 -0
  59. package/browser/ui-login.mjs +135 -0
  60. package/dist/cjs/bulk.d.ts +33 -0
  61. package/dist/cjs/bulk.d.ts.map +1 -0
  62. package/dist/cjs/bulk.js +42 -0
  63. package/dist/cjs/bulk.js.map +1 -0
  64. package/dist/cjs/control.d.ts +35 -0
  65. package/dist/cjs/control.d.ts.map +1 -0
  66. package/dist/cjs/control.js +141 -0
  67. package/dist/cjs/control.js.map +1 -0
  68. package/dist/cjs/effective-date.d.ts +3 -0
  69. package/dist/cjs/effective-date.d.ts.map +1 -0
  70. package/dist/cjs/effective-date.js +27 -0
  71. package/dist/cjs/effective-date.js.map +1 -0
  72. package/dist/cjs/encoding.d.ts +32 -0
  73. package/dist/cjs/encoding.d.ts.map +1 -0
  74. package/dist/cjs/encoding.js +121 -0
  75. package/dist/cjs/encoding.js.map +1 -0
  76. package/dist/cjs/enrollment-verifications.d.ts +39 -0
  77. package/dist/cjs/enrollment-verifications.d.ts.map +1 -0
  78. package/dist/cjs/enrollment-verifications.js +96 -0
  79. package/dist/cjs/enrollment-verifications.js.map +1 -0
  80. package/dist/cjs/errors.d.ts +33 -0
  81. package/dist/cjs/errors.d.ts.map +1 -0
  82. package/dist/cjs/errors.js +45 -0
  83. package/dist/cjs/errors.js.map +1 -0
  84. package/dist/cjs/generated/v2-models.d.ts +1482 -0
  85. package/dist/cjs/generated/v2-models.d.ts.map +1 -0
  86. package/dist/cjs/generated/v2-models.js +3 -0
  87. package/dist/cjs/generated/v2-models.js.map +1 -0
  88. package/dist/cjs/generated/v2.d.ts +1149 -0
  89. package/dist/cjs/generated/v2.d.ts.map +1 -0
  90. package/dist/cjs/generated/v2.js +252 -0
  91. package/dist/cjs/generated/v2.js.map +1 -0
  92. package/dist/cjs/generated/v3-models.d.ts +1420 -0
  93. package/dist/cjs/generated/v3-models.d.ts.map +1 -0
  94. package/dist/cjs/generated/v3-models.js +3 -0
  95. package/dist/cjs/generated/v3-models.js.map +1 -0
  96. package/dist/cjs/generated/v3.d.ts +1149 -0
  97. package/dist/cjs/generated/v3.d.ts.map +1 -0
  98. package/dist/cjs/generated/v3.js +252 -0
  99. package/dist/cjs/generated/v3.js.map +1 -0
  100. package/dist/cjs/gwt-enrollments.d.ts +131 -0
  101. package/dist/cjs/gwt-enrollments.d.ts.map +1 -0
  102. package/dist/cjs/gwt-enrollments.js +280 -0
  103. package/dist/cjs/gwt-enrollments.js.map +1 -0
  104. package/dist/cjs/index.d.ts +3 -0
  105. package/dist/cjs/index.d.ts.map +1 -0
  106. package/dist/cjs/index.js +19 -0
  107. package/dist/cjs/index.js.map +1 -0
  108. package/dist/cjs/metadata.d.ts +100 -0
  109. package/dist/cjs/metadata.d.ts.map +1 -0
  110. package/dist/cjs/metadata.js +165 -0
  111. package/dist/cjs/metadata.js.map +1 -0
  112. package/dist/cjs/package.json +1 -0
  113. package/dist/cjs/program-discovery.d.ts +28 -0
  114. package/dist/cjs/program-discovery.d.ts.map +1 -0
  115. package/dist/cjs/program-discovery.js +58 -0
  116. package/dist/cjs/program-discovery.js.map +1 -0
  117. package/dist/cjs/race.d.ts +4 -0
  118. package/dist/cjs/race.d.ts.map +1 -0
  119. package/dist/cjs/race.js +40 -0
  120. package/dist/cjs/race.js.map +1 -0
  121. package/dist/cjs/runtime.d.ts +108 -0
  122. package/dist/cjs/runtime.d.ts.map +1 -0
  123. package/dist/cjs/runtime.js +38 -0
  124. package/dist/cjs/runtime.js.map +1 -0
  125. package/dist/cjs/subassessments.d.ts +29 -0
  126. package/dist/cjs/subassessments.d.ts.map +1 -0
  127. package/dist/cjs/subassessments.js +107 -0
  128. package/dist/cjs/subassessments.js.map +1 -0
  129. package/dist/cjs/testing.d.ts +45 -0
  130. package/dist/cjs/testing.d.ts.map +1 -0
  131. package/dist/cjs/testing.js +66 -0
  132. package/dist/cjs/testing.js.map +1 -0
  133. package/dist/cjs/transport.d.ts +19 -0
  134. package/dist/cjs/transport.d.ts.map +1 -0
  135. package/dist/cjs/transport.js +373 -0
  136. package/dist/cjs/transport.js.map +1 -0
  137. package/dist/cjs/v2.d.ts +13 -0
  138. package/dist/cjs/v2.d.ts.map +1 -0
  139. package/dist/cjs/v2.js +29 -0
  140. package/dist/cjs/v2.js.map +1 -0
  141. package/dist/cjs/v3.d.ts +13 -0
  142. package/dist/cjs/v3.d.ts.map +1 -0
  143. package/dist/cjs/v3.js +29 -0
  144. package/dist/cjs/v3.js.map +1 -0
  145. package/dist/cjs/validation.d.ts +33 -0
  146. package/dist/cjs/validation.d.ts.map +1 -0
  147. package/dist/cjs/validation.js +77 -0
  148. package/dist/cjs/validation.js.map +1 -0
  149. package/dist/cjs/verification.d.ts +82 -0
  150. package/dist/cjs/verification.d.ts.map +1 -0
  151. package/dist/cjs/verification.js +122 -0
  152. package/dist/cjs/verification.js.map +1 -0
  153. package/dist/esm/bulk.d.ts +33 -0
  154. package/dist/esm/bulk.d.ts.map +1 -0
  155. package/dist/esm/bulk.js +39 -0
  156. package/dist/esm/bulk.js.map +1 -0
  157. package/dist/esm/control.d.ts +35 -0
  158. package/dist/esm/control.d.ts.map +1 -0
  159. package/dist/esm/control.js +132 -0
  160. package/dist/esm/control.js.map +1 -0
  161. package/dist/esm/effective-date.d.ts +3 -0
  162. package/dist/esm/effective-date.d.ts.map +1 -0
  163. package/dist/esm/effective-date.js +24 -0
  164. package/dist/esm/effective-date.js.map +1 -0
  165. package/dist/esm/encoding.d.ts +32 -0
  166. package/dist/esm/encoding.d.ts.map +1 -0
  167. package/dist/esm/encoding.js +117 -0
  168. package/dist/esm/encoding.js.map +1 -0
  169. package/dist/esm/enrollment-verifications.d.ts +39 -0
  170. package/dist/esm/enrollment-verifications.d.ts.map +1 -0
  171. package/dist/esm/enrollment-verifications.js +93 -0
  172. package/dist/esm/enrollment-verifications.js.map +1 -0
  173. package/dist/esm/errors.d.ts +33 -0
  174. package/dist/esm/errors.d.ts.map +1 -0
  175. package/dist/esm/errors.js +33 -0
  176. package/dist/esm/errors.js.map +1 -0
  177. package/dist/esm/generated/v2-models.d.ts +1482 -0
  178. package/dist/esm/generated/v2-models.d.ts.map +1 -0
  179. package/dist/esm/generated/v2-models.js +2 -0
  180. package/dist/esm/generated/v2-models.js.map +1 -0
  181. package/dist/esm/generated/v2.d.ts +1149 -0
  182. package/dist/esm/generated/v2.d.ts.map +1 -0
  183. package/dist/esm/generated/v2.js +247 -0
  184. package/dist/esm/generated/v2.js.map +1 -0
  185. package/dist/esm/generated/v3-models.d.ts +1420 -0
  186. package/dist/esm/generated/v3-models.d.ts.map +1 -0
  187. package/dist/esm/generated/v3-models.js +2 -0
  188. package/dist/esm/generated/v3-models.js.map +1 -0
  189. package/dist/esm/generated/v3.d.ts +1149 -0
  190. package/dist/esm/generated/v3.d.ts.map +1 -0
  191. package/dist/esm/generated/v3.js +247 -0
  192. package/dist/esm/generated/v3.js.map +1 -0
  193. package/dist/esm/gwt-enrollments.d.ts +131 -0
  194. package/dist/esm/gwt-enrollments.d.ts.map +1 -0
  195. package/dist/esm/gwt-enrollments.js +274 -0
  196. package/dist/esm/gwt-enrollments.js.map +1 -0
  197. package/dist/esm/index.d.ts +3 -0
  198. package/dist/esm/index.d.ts.map +1 -0
  199. package/dist/esm/index.js +3 -0
  200. package/dist/esm/index.js.map +1 -0
  201. package/dist/esm/metadata.d.ts +100 -0
  202. package/dist/esm/metadata.d.ts.map +1 -0
  203. package/dist/esm/metadata.js +157 -0
  204. package/dist/esm/metadata.js.map +1 -0
  205. package/dist/esm/program-discovery.d.ts +28 -0
  206. package/dist/esm/program-discovery.d.ts.map +1 -0
  207. package/dist/esm/program-discovery.js +55 -0
  208. package/dist/esm/program-discovery.js.map +1 -0
  209. package/dist/esm/race.d.ts +4 -0
  210. package/dist/esm/race.d.ts.map +1 -0
  211. package/dist/esm/race.js +37 -0
  212. package/dist/esm/race.js.map +1 -0
  213. package/dist/esm/runtime.d.ts +108 -0
  214. package/dist/esm/runtime.d.ts.map +1 -0
  215. package/dist/esm/runtime.js +33 -0
  216. package/dist/esm/runtime.js.map +1 -0
  217. package/dist/esm/subassessments.d.ts +29 -0
  218. package/dist/esm/subassessments.d.ts.map +1 -0
  219. package/dist/esm/subassessments.js +103 -0
  220. package/dist/esm/subassessments.js.map +1 -0
  221. package/dist/esm/testing.d.ts +45 -0
  222. package/dist/esm/testing.d.ts.map +1 -0
  223. package/dist/esm/testing.js +60 -0
  224. package/dist/esm/testing.js.map +1 -0
  225. package/dist/esm/transport.d.ts +19 -0
  226. package/dist/esm/transport.d.ts.map +1 -0
  227. package/dist/esm/transport.js +369 -0
  228. package/dist/esm/transport.js.map +1 -0
  229. package/dist/esm/v2.d.ts +13 -0
  230. package/dist/esm/v2.d.ts.map +1 -0
  231. package/dist/esm/v2.js +13 -0
  232. package/dist/esm/v2.js.map +1 -0
  233. package/dist/esm/v3.d.ts +13 -0
  234. package/dist/esm/v3.d.ts.map +1 -0
  235. package/dist/esm/v3.js +13 -0
  236. package/dist/esm/v3.js.map +1 -0
  237. package/dist/esm/validation.d.ts +33 -0
  238. package/dist/esm/validation.d.ts.map +1 -0
  239. package/dist/esm/validation.js +72 -0
  240. package/dist/esm/validation.js.map +1 -0
  241. package/dist/esm/verification.d.ts +82 -0
  242. package/dist/esm/verification.d.ts.map +1 -0
  243. package/dist/esm/verification.js +117 -0
  244. package/dist/esm/verification.js.map +1 -0
  245. package/docs/README.md +56 -0
  246. package/docs/assessments.md +103 -0
  247. package/docs/browser-session.md +129 -0
  248. package/docs/capabilities.md +75 -0
  249. package/docs/configuration.md +81 -0
  250. package/docs/current-living-situation.md +56 -0
  251. package/docs/dates-and-answers.md +40 -0
  252. package/docs/development.md +81 -0
  253. package/docs/durable-records.md +156 -0
  254. package/docs/enrollments.md +217 -0
  255. package/docs/files-and-notes.md +39 -0
  256. package/docs/getting-started.md +56 -0
  257. package/docs/independent-needs.md +77 -0
  258. package/docs/integration-methods.md +60 -0
  259. package/docs/profiles.md +68 -0
  260. package/docs/reference.md +64 -0
  261. package/docs/rest-client.md +97 -0
  262. package/docs/services.md +66 -0
  263. package/docs/shelter-inventory.md +64 -0
  264. package/docs/shelters.md +75 -0
  265. package/docs/troubleshooting.md +61 -0
  266. package/docs/using-the-sdk.md +203 -0
  267. package/docs/vendor-issues/README.md +46 -0
  268. package/docs/vendor-issues/api-coverage.md +27 -0
  269. package/docs/vendor-issues/attachment-authentication.md +40 -0
  270. package/docs/vendor-issues/date-semantics.md +51 -0
  271. package/docs/vendor-issues/empty-service-list-404.md +51 -0
  272. package/docs/vendor-issues/enrollment-complete-target-error.md +178 -0
  273. package/docs/vendor-issues/enrollment-effective-date-column.md +112 -0
  274. package/docs/vendor-issues/household-head-validation.md +26 -0
  275. package/docs/vendor-issues/write-contracts.md +51 -0
  276. package/examples/README.md +41 -0
  277. package/examples/current-living-situation.mts +27 -0
  278. package/examples/durable-records.mts +78 -0
  279. package/examples/enrollment-workflow.mts +46 -0
  280. package/examples/gwt-enrollment-session.ts +17 -0
  281. package/examples/gwt-file-store.mjs +2 -0
  282. package/examples/household-enrollment-workflow.mts +43 -0
  283. package/examples/household-exit-workflow.mts +35 -0
  284. package/examples/household-lifecycle-workflow.mts +34 -0
  285. package/examples/independent-needs.mts +30 -0
  286. package/examples/program-discovery.ts +19 -0
  287. package/examples/quickstart.mjs +13 -0
  288. package/examples/shelter-inventory.mts +30 -0
  289. package/examples/shelters.mts +33 -0
  290. package/examples/usage.ts +23 -0
  291. package/examples/verification-snapshot.ts +9 -0
  292. package/llms.txt +26 -0
  293. package/package.json +103 -0
  294. package/specs/provenance.json +17 -0
@@ -0,0 +1,44 @@
1
+ import type {Answers, AnswerValue, WellSkyClient} from '../dist/esm/index.js';
2
+ import type {UiMutationStore} from './index.mjs';
3
+ /** Exact virtual fields from the target program's client-scoped metadata. No guessed codes. */
4
+ export interface CurrentLivingSituationFields {
5
+ parentField: string;
6
+ startDate: string;
7
+ endDate: string;
8
+ informationDate: string;
9
+ livingSituation: string;
10
+ verifiedBy?: string;
11
+ }
12
+ export interface CurrentLivingSituationInput {
13
+ /** Unique upstream contact event within this client. Preserve it and the journal on retries. */
14
+ key: string;
15
+ clientId: number;
16
+ enrollmentId: number;
17
+ assessmentCode: string;
18
+ /** Contact date as a timezone-qualified whole-second instant, in the program's calendar. */
19
+ informationDate: string;
20
+ livingSituation: AnswerValue;
21
+ /** Open by default. Recording another contact does not close the previous contact row. */
22
+ endDate?: null | '';
23
+ /** Optional. A recording program may be supplied using the field's verified text or lookup representation. */
24
+ verifiedBy?: AnswerValue;
25
+ /** Only actual additional platform questions. Cannot override the mapped fields. */
26
+ additionalAnswers?: Answers;
27
+ }
28
+ export interface CurrentLivingSituationResult {
29
+ key: string; clientId: number; enrollmentId: number; recordsetId: number;
30
+ startDate: string; endDate: null; informationDate: string;
31
+ outcome: 'verified' | 'already-present' | 'reconciled';
32
+ }
33
+ export interface CurrentLivingSituationRecorder {
34
+ record(input: CurrentLivingSituationInput): Promise<CurrentLivingSituationResult>;
35
+ }
36
+ export class CurrentLivingSituationConflictError extends Error {
37
+ readonly code: 'CURRENT_LIVING_SITUATION_CONFLICT';
38
+ readonly reason: string;
39
+ constructor(reason: string);
40
+ }
41
+ /** REST orchestration. No browser launch, credential loading, or automatic interim. */
42
+ export function createCurrentLivingSituationRecorder(options: {
43
+ api: WellSkyClient; store: UiMutationStore; fields: CurrentLivingSituationFields;
44
+ }): CurrentLivingSituationRecorder;
@@ -0,0 +1,161 @@
1
+ import {apiScope} from './connection.mjs';
2
+ import {encodeAnswers, toItems, semanticHash} from '../dist/esm/index.js';
3
+
4
+ const object = value => value !== null && typeof value === 'object' && !Array.isArray(value);
5
+ const positive = value => Number.isSafeInteger(value) && value > 0;
6
+ const fail = reason => { throw new CurrentLivingSituationConflictError(reason); };
7
+ export class CurrentLivingSituationConflictError extends Error {
8
+ constructor(reason) { super(`Current Living Situation: ${reason}`); this.name = 'CurrentLivingSituationConflictError'; this.code = 'CURRENT_LIVING_SITUATION_CONFLICT'; this.reason = reason; }
9
+ }
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; }
21
+ function normalize(raw) {
22
+ exactFields(raw, ['key', 'clientId', 'enrollmentId', 'assessmentCode', 'informationDate', 'livingSituation', 'endDate', 'verifiedBy', 'additionalAnswers']);
23
+ 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
+ if (raw.verifiedBy !== undefined && !object(raw.verifiedBy) || raw.additionalAnswers !== undefined && !object(raw.additionalAnswers)) fail('invalid-input');
25
+ 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
+ }
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
+ 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
+ }
58
+ for (const role of ['startDate', 'endDate', 'informationDate']) if (index.get(fields[role])?.questionType !== 'date') fail('date-field-unavailable');
59
+ if (index.get(fields.livingSituation)?.questionType !== 'lookup') fail('living-situation-picklist-unavailable');
60
+ if (index.get(fields.endDate).mandatory === true) fail('platform-requires-end-date');
61
+ const reserved = Object.values(fields);
62
+ if (Object.keys(input.additionalAnswers).some(field => reserved.includes(field))) fail('reserved-field-override');
63
+ const answers = {...input.additionalAnswers, [fields.startDate]: {value: entryDate}, [fields.endDate]: {value: ''}, [fields.informationDate]: {value: input.informationDate}, [fields.livingSituation]: input.livingSituation};
64
+ if (input.verifiedBy !== undefined) {
65
+ const question = index.get(fields.verifiedBy);
66
+ if (!question || !['textbox', 'textarea', 'lookup'].includes(question.questionType)) fail('verified-by-representation-unsupported');
67
+ if (['textbox', 'textarea'].includes(question.questionType) && (!('value' in input.verifiedBy) || typeof input.verifiedBy.value !== 'string' || !input.verifiedBy.value.trim())) fail('verified-by-text-required');
68
+ answers[fields.verifiedBy] = input.verifiedBy;
69
+ }
70
+ const encoded = encodeAnswers(parent.children, answers, {endpoint: 'mobile'});
71
+ if (!encoded.some(q => q.virt_field_name === fields.livingSituation && q.answer_value)) fail('living-situation-required');
72
+ for (const q of index.values()) {
73
+ if (q.mandatory === true && !encoded.some(answer => answer.virt_field_name === q.fieldName && answer.answer_value !== '')) fail(`platform-required-field:${q.fieldName}`);
74
+ }
75
+ // Do not invent answers for omitted optional questions. Verify every answer sent.
76
+ return {parent, encoded};
77
+ }
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
+
106
+ /** 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);
109
+ if (!store?.withLock || !store?.read || !store?.write) fail('api-and-store-required');
110
+ exactFields(mapping, ['parentField', 'startDate', 'endDate', 'informationDate', 'livingSituation', 'verifiedBy']);
111
+ const fields = structuredClone(Object.fromEntries(Object.entries(mapping).filter(([, value]) => value !== undefined)));
112
+ 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});
122
+ 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
+ }};
161
+ }
@@ -0,0 +1,68 @@
1
+ import {pageConnection} from './connection.mjs';
2
+ import {withPageLock} from './page-lock.mjs';
3
+ import {validateBirthDate} from './date-of-birth.mjs';
4
+
5
+ /** UI verification plus correction of DOB at an existing enrollment's entry date. */
6
+ export function createDateOfBirthUi({page}){
7
+ const connection=pageConnection(page),base=connection.moduleBaseUrl;
8
+ const ready=()=>page.waitForFunction(()=>!document.body.innerText.includes('Processing...')&&!document.body.innerText.includes('Loading...'),null,{timeout:30000});
9
+ const control=id=>page.locator(`[id="${id}"]:visible`);
10
+ const navigate=async clientId=>{
11
+ if(!Number.isSafeInteger(clientId)||clientId<1||new URL(page.url()).origin+new URL(page.url()).pathname!==base+'index.html')throw Error('Client and configured application page required');
12
+ await ready();if(await page.locator('[role=dialog]:visible').count())throw Error('Close dialogs before DOB operation');
13
+ if(/Client - \(\d+\)/.test(await page.locator('body').innerText())){
14
+ await page.getByRole('tab',{name:'Client Information',exact:true}).click();await page.getByRole('tab',{name:'Summary',exact:true}).click();await ready();
15
+ await page.getByRole('button',{name:'Exit',exact:true}).click();await page.waitForURL(u=>u.hash==='#clientPointClientMain');await ready();
16
+ }
17
+ await page.goto(base+'index.html#loadClient;clientId='+clientId);
18
+ await page.waitForFunction(clientId=>document.body.innerText.includes(`Client - (${clientId})`)&&!document.body.innerText.includes('Processing...')&&!document.body.innerText.includes('Loading...'),clientId,{timeout:30000});
19
+ await page.getByRole('tab',{name:'Summary',exact:true}).click();await ready();
20
+ };
21
+ const validateRestDate=async dateOfBirth=>{
22
+ validateBirthDate(dateOfBirth);
23
+ const rendered=await page.evaluate(value=>{const d=new Date(value+'T07:00:00Z');return [d.getFullYear(),String(d.getMonth()+1).padStart(2,'0'),String(d.getDate()).padStart(2,'0')].join('-');},dateOfBirth);
24
+ if(rendered!==dateOfBirth)throw Error('Browser timezone cannot display the REST DOB encoding on the intended date');
25
+ };
26
+ const read=clientId=>withPageLock(page,async()=>{
27
+ await navigate(clientId);
28
+ const text=await page.getByText('Date of Birth',{exact:true}).evaluate(e=>e.closest('td').nextElementSibling.innerText.trim());
29
+ if(!text)return null;
30
+ const m=/^(\d{2})\/(\d{2})\/(\d{4})(?: \(Age \d+\))?$/.exec(text);
31
+ if(!m)throw Error('Unexpected Summary DOB format');return validateBirthDate(`${m[3]}-${m[1]}-${m[2]}`);
32
+ });
33
+ const correctAtEntry=input=>withPageLock(page,async()=>{
34
+ const {clientId,entry,dateOfBirth,effectiveDate,expectedDateOfBirth,beforeSave}=input;
35
+ validateBirthDate(dateOfBirth);
36
+ if(!entry||!Number.isSafeInteger(entry.enrollmentId)||entry.enrollmentId<1||typeof entry.assessmentName!=='string'||!entry.assessmentName.trim()||typeof beforeSave!=='function')throw Error('DOB entry context and journal callback required');
37
+ await navigate(clientId);await page.getByRole('tab',{name:'Entry / Exit',exact:true}).click();await ready();
38
+ await control(`ClientEntryExitTab.editEntry-imageLink_${entry.enrollmentId}`).click();
39
+ await control('EntryDataPopup.cancel-button').waitFor();await ready();
40
+ const heading=await page.locator('[role=dialog]:visible').innerText();
41
+ if(!heading.includes(`Edit Project Start Data - (${clientId})`))throw Error('Entry client mismatch');
42
+ const parts={};for(const part of ['Month','Day','Year','Hour','Minute','Second','AM/PM'])parts[part]=await page.getByLabel('Project Start Date '+part,{exact:true}).inputValue();
43
+ const sameInstant=await page.evaluate(({parts,effectiveDate})=>{
44
+ const date=new Date(effectiveDate),hour=date.getHours();
45
+ return Number(parts.Year)===date.getFullYear()&&Number(parts.Month)===date.getMonth()+1&&Number(parts.Day)===date.getDate()&&Number(parts.Hour)===(hour%12||12)&&Number(parts.Minute)===date.getMinutes()&&Number(parts.Second)===date.getSeconds()&&parts['AM/PM']===(hour<12?'AM':'PM');
46
+ },{parts,effectiveDate});
47
+ if(!sameInstant){await control('EntryDataPopup.cancel-button').click();throw Error('DOB correction requires the exact existing entry effective date');}
48
+ await control('EntryDataPopup.saveAndContinue-button').click();await control('EntryDataPopup.cancel-button').waitFor({state:'hidden'});await ready();
49
+ const dialog=page.getByRole('dialog',{name:'Entry/Exit Data',exact:true});
50
+ await dialog.getByRole('button',{name:entry.assessmentName,exact:true}).click();
51
+ const month=page.getByLabel('Date of Birth Month',{exact:true}),day=page.getByLabel('Date of Birth Day',{exact:true}),year=page.getByLabel('Date of Birth Year',{exact:true});
52
+ await month.waitFor();await ready();
53
+ if(!new RegExp(`check_circle\\s*\\(${clientId}\\)`).test(await dialog.innerText()))throw Error('Selected assessment client mismatch');
54
+ const current=validateBirthDate(`${await year.inputValue()}-${(await month.inputValue()).padStart(2,'0')}-${(await day.inputValue()).padStart(2,'0')}`);
55
+ if(current!==expectedDateOfBirth)throw Error('DOB changed before UI correction');
56
+ const controls=()=>dialog.locator('input,select,textarea').evaluateAll(es=>es.filter(e=>e.getClientRects().length&&!/^Date of Birth (Month|Day|Year)$/.test(e.getAttribute('aria-label')||'')).map(e=>({id:e.id,label:e.getAttribute('aria-label'),value:e.value,checked:e.checked,selected:e.tagName==='SELECT'?[...e.selectedOptions].map(o=>o.value):null})));
57
+ const before=JSON.stringify(await controls());
58
+ // Blur each component and wait for GWT's dependent refresh before editing the next.
59
+ for(const [field,value] of [[day,'01'],[year,dateOfBirth.slice(0,4)],[month,dateOfBirth.slice(5,7)],[day,dateOfBirth.slice(8,10)]]){await field.fill(value);await field.press('Tab');await ready();}
60
+ const edited=`${await year.inputValue()}-${(await month.inputValue()).padStart(2,'0')}-${(await day.inputValue()).padStart(2,'0')}`;
61
+ if(edited!==dateOfBirth)throw Error('DOB widget did not retain the requested calendar date');
62
+ if(JSON.stringify(await controls())!==before)throw Error('Other assessment controls changed');
63
+ await beforeSave();await dialog.getByRole('button',{name:'Save',exact:true}).click();await ready();
64
+ if(await page.getByRole('dialog',{name:'Validation Warning',exact:true}).isVisible())throw Error('Assessment validation blocked DOB save; complete required answers before reconciling');
65
+ await control('EntryExitAssessment.btnExit-button').click();await dialog.waitFor({state:'hidden'});await ready();
66
+ });
67
+ return {connection,read,correctAtEntry,validateRestDate};
68
+ }
@@ -0,0 +1,28 @@
1
+ import type {Page} from 'playwright-core';
2
+ import type {WellSkyClient} from '../dist/esm/index.js';
3
+ import type {UiMutationStore} from './index.mjs';
4
+ export interface BirthDateEntry {enrollmentId:number;assessmentName:string;}
5
+ export interface BirthDateUpdate {
6
+ clientId:number;key:string;assessmentCode:string;
7
+ /** Strict calendar date, not a timestamp. */dateOfBirth:string;
8
+ /** Explicit instant. Never moved to avoid an existing-answer conflict. */effectiveDate:string;
9
+ /** Required to correct a DOB at the same effective timestamp as this enrollment's entry. */entry?:BirthDateEntry;
10
+ }
11
+ export interface DateOfBirthUi {
12
+ readonly connection?:import("./connection.mjs").UiConnection;
13
+ validateRestDate(dateOfBirth:string):Promise<void>;
14
+ read(clientId:number):Promise<string|null>;
15
+ correctAtEntry(input:BirthDateUpdate&{expectedDateOfBirth:string|null;beforeSave:()=>Promise<void>}):Promise<void>;
16
+ }
17
+ export interface BirthDateSnapshot {clientId:number;dateOfBirth:string|null;effectiveDate:string|null;}
18
+ export interface DateOfBirth {
19
+ read(input:{clientId:number;assessmentCode:string}):Promise<BirthDateSnapshot>;
20
+ /** Read-only preflight; allowEntryCorrection reserves no write and creates no enrollment. */
21
+ validate(input:{clientId:number;assessmentCode:string;dateOfBirth:string;effectiveDate:string;allowEntryCorrection?:boolean}):Promise<BirthDateSnapshot>;
22
+ /** allowLater permits the same calendar value at a newer effective timestamp. */
23
+ verify(input:{clientId:number;assessmentCode:string;dateOfBirth:string;effectiveDate:string;allowLater?:boolean}):Promise<BirthDateSnapshot&{dateOfBirth:string;effectiveDate:string}>;
24
+ update(input:BirthDateUpdate):Promise<{clientId:number;dateOfBirth:string;effectiveDate:string;method:'rest'|'ui';outcome:'verified'|'already-present'}>;
25
+ }
26
+ export function validateBirthDate(value:unknown):string;
27
+ export function createDateOfBirth(options:{api:WellSkyClient;ui:DateOfBirthUi;store:UiMutationStore}):DateOfBirth;
28
+ export function createDateOfBirthUi(options:{page:Page}):DateOfBirthUi;
@@ -0,0 +1,112 @@
1
+ import {apiScope} from './connection.mjs';
2
+ import {createHash} from 'node:crypto';
3
+ import {toItems} from '../dist/esm/index.js';
4
+
5
+ const coreKeys=['firstName','middleName','lastName','suffix','search_alias','socSecNo','nameDataQuality','ssnDataQuality','veteranStatus'];
6
+ const protectedKeys=[...coreKeys,'svpprofdobtype','svpprofgender','hud_raceethnicity','hud_raceethnicitydetail','phone','email'];
7
+ const hash=value=>createHash('sha256').update(JSON.stringify(value,(_,v)=>v&&typeof v==='object'&&!Array.isArray(v)?Object.fromEntries(Object.entries(v).sort(([a],[b])=>a.localeCompare(b))):v)).digest('hex');
8
+ const pick=(value,keys)=>Object.fromEntries(keys.map(k=>[k,value[k]??'']));
9
+ const fail=message=>{throw Error(`Date of birth: ${message}`);};
10
+ export function validateBirthDate(value){
11
+ if(typeof value!=='string'||!/^\d{4}-\d{2}-\d{2}$/.test(value)||value<'0001-01-01'||!Number.isFinite(Date.parse(value+'T00:00:00Z'))||new Date(value+'T00:00:00Z').toISOString().slice(0,10)!==value)fail('valid YYYY-MM-DD calendar date required');
12
+ return value;
13
+ }
14
+ const instant=value=>{
15
+ 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 effective date required');
16
+ return new Date(value).toISOString();
17
+ };
18
+ const storedDate=value=>value==null||value===''?null:validateBirthDate(value.replaceAll('/','-'));
19
+ const answerDate=value=>{
20
+ if(typeof value!=='string'||!/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}-07$/.test(value))fail('unexpected DOB answer format or timezone');
21
+ return validateBirthDate(value.slice(0,10));
22
+ };
23
+ const effective=value=>{if(typeof value!=='string'||!/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}$/.test(value))fail('unexpected answer effective date');return instant(value+'Z');};
24
+
25
+ /** Calendar-date writer. New effective timestamps use REST;
26
+ * collisions use the existing entry assessment UI without moving the timestamp. */
27
+ export function createDateOfBirth({api,ui,store}){
28
+ const tenantScope=apiScope(api, ui);
29
+ if(!ui?.read||!ui?.correctAtEntry||!ui?.validateRestDate||!store?.withLock||!store?.read||!store?.write)fail('REST API, DOB UI and durable store required');
30
+ const read=async input=>{
31
+ const rows=toItems(await api.clients.get({path:{clientId:input.clientId}}));
32
+ if(rows.length!==1||rows[0].clientId!==input.clientId||rows[0].isActive===false)fail('active client readback unavailable');
33
+ const client=rows[0],answers=toItems(await api.assessmentsMobile.getAnswers({path:{clientId:input.clientId,assessmentCode:input.assessmentCode}}));
34
+ const dob=answers.filter(q=>q.virt_field_name==='SVPPROFDOB');
35
+ if(dob.length!==1||dob[0].question_type!=='date')fail('assessment must expose one DOB date question');
36
+ const row=dob[0],dateOfBirth=storedDate(client.svpprofdob),hasValue=row.answer_value!==null&&row.answer_value!==undefined&&row.answer_value!=='',hasAnswer=row.date_effective!==null&&row.date_effective!==undefined&&row.date_effective!=='';
37
+ // WellSky's mobile projection omits the value for UI-created date answers.
38
+ // Require matching current client + fresh UI values; use the answer only for its effective date.
39
+ if(hasValue&&(!hasAnswer||answerDate(row.answer_value)!==dateOfBirth)||!hasAnswer&&dateOfBirth!==null)fail('client and assessment DOB disagree');
40
+ const uiDate=await ui.read(input.clientId);
41
+ if(uiDate!==dateOfBirth)fail('client and UI DOB disagree');
42
+ // Values and effective dates of every other exposed answer must survive the write.
43
+ const protectedAnswers=answers.filter(q=>q.virt_field_name!=='SVPPROFDOB').map(q=>({field:q.virt_field_name,value:q.answer_value??null,text:q.answer_value_text??null,effective:q.date_effective??null,rows:q.sub_assessment_answer_list??null})).sort((a,b)=>String(a.field).localeCompare(String(b.field)));
44
+ return {client,dateOfBirth,effectiveDate:hasAnswer?effective(row.date_effective):null,protectedHash:hash({client:pick(client,protectedKeys),answers:protectedAnswers})};
45
+ };
46
+ const update=async raw=>{
47
+ if(!raw||Object.keys(raw).some(k=>!['clientId','key','assessmentCode','dateOfBirth','effectiveDate','entry'].includes(k)))fail('unknown update field');
48
+ if(!Number.isSafeInteger(raw.clientId)||raw.clientId<1||typeof raw.key!=='string'||!raw.key.trim()||raw.key.length>200||typeof raw.assessmentCode!=='string'||!/^[A-Za-z0-9_]+$/.test(raw.assessmentCode))fail('client, stable key and assessment code required');
49
+ const input={...structuredClone(raw),dateOfBirth:validateBirthDate(raw.dateOfBirth),effectiveDate:instant(raw.effectiveDate)};
50
+ if(input.dateOfBirth>input.effectiveDate.slice(0,10))fail('birth date follows effective date');
51
+ if(input.entry&&(!Number.isSafeInteger(input.entry.enrollmentId)||input.entry.enrollmentId<1||typeof input.entry.assessmentName!=='string'||!input.entry.assessmentName.trim()||Object.keys(input.entry).some(k=>!['enrollmentId','assessmentName'].includes(k))))fail('entry enrollment and assessment name required');
52
+ const scope=JSON.stringify([tenantScope,'client-profile',input.clientId]),keyHash=hash('dob:'+input.key),intentHash=hash(input);
53
+ return store.withLock(scope,async()=>{
54
+ const records=await store.read(scope);if(!Array.isArray(records))fail('invalid journal');
55
+ let record=records.find(r=>r.keyHash===keyHash);
56
+ if(records.filter(r=>r.keyHash===keyHash).length>1||record&&record.intentHash!==intentHash)fail('key conflicts with earlier intent');
57
+ if(records.some(r=>r.keyHash!==keyHash&&r.stage!=='verified'))fail('another profile update is unresolved');
58
+ if(record&&(record.version!==1||record.operation!=='dob'||!['planned','attempted','verified'].includes(record.stage)))fail('invalid journal stage');
59
+ const wasVerified=record?.stage==='verified';
60
+ const persist=async stage=>{record={...record,version:1,operation:'dob',keyHash,intentHash,stage};const index=records.findIndex(r=>r.keyHash===keyHash);if(index<0)records.push(record);else records[index]=record;await store.write(scope,structuredClone(records));};
61
+ let current=await read(input);
62
+ const matches=()=>current.dateOfBirth===input.dateOfBirth&&current.effectiveDate===input.effectiveDate;
63
+ if(!record){
64
+ if(current.effectiveDate&&Date.parse(input.effectiveDate)<Date.parse(current.effectiveDate))fail('historical correction before latest DOB requires a separate history workflow');
65
+ const method=current.effectiveDate===input.effectiveDate?'ui':'rest';
66
+ if(!matches()&&method==='rest')await ui.validateRestDate(input.dateOfBirth);
67
+ if(!matches()&&method==='ui'&&!input.entry)fail('effective timestamp already exists; provide its entry assessment context for UI correction');
68
+ record={method,protectedHash:current.protectedHash,beforeHash:hash({dob:current.dateOfBirth,effective:current.effectiveDate})};await persist('planned');
69
+ }
70
+ if(current.protectedHash!==record.protectedHash)fail('unrequested profile or assessment data changed');
71
+ if(!matches()){
72
+ if(record.stage!=='planned')fail('write unresolved or later value changed; refusing replay');
73
+ if(hash({dob:current.dateOfBirth,effective:current.effectiveDate})!==record.beforeHash)fail('DOB changed before write');
74
+ if(record.method==='rest'){
75
+ await ui.validateRestDate(input.dateOfBirth);
76
+ // The training server converts UTC to fixed -07 for DOB values, in winter and summer.
77
+ // Keep this workaround scoped to this helper; never shift enrollment/effective dates.
78
+ await persist('attempted');
79
+ let error;try{await api.clients.update({path:{client_id:input.clientId},body:{...pick(current.client,coreKeys),svpprofdob:input.dateOfBirth+'T07:00:00Z',dateEffective:input.effectiveDate}});}catch(e){error=e;}
80
+ current=await read(input);
81
+ if(!matches()||current.protectedHash!==record.protectedHash)throw error??Error('Date of birth: REST verification failed; journal blocks replay');
82
+ }else{
83
+ await ui.correctAtEntry({...input,expectedDateOfBirth:current.dateOfBirth,beforeSave:()=>persist('attempted')});
84
+ current=await read(input);
85
+ }
86
+ }
87
+ if(!matches()||current.protectedHash!==record.protectedHash)fail('verification failed; journal blocks replay');
88
+ await persist('verified');return {clientId:input.clientId,dateOfBirth:current.dateOfBirth,effectiveDate:current.effectiveDate,method:record.method,outcome:wasVerified?'already-present':'verified'};
89
+ });
90
+ };
91
+ const inspect=async input=>{
92
+ if(!input||Object.keys(input).some(k=>!['clientId','assessmentCode'].includes(k))||!Number.isSafeInteger(input.clientId)||input.clientId<1||typeof input.assessmentCode!=='string'||!/^[A-Za-z0-9_]+$/.test(input.assessmentCode))fail('client and assessment code required');
93
+ const current=await read(input);return {clientId:input.clientId,dateOfBirth:current.dateOfBirth,effectiveDate:current.effectiveDate};
94
+ };
95
+ const validate=async input=>{
96
+ if(!input||Object.keys(input).some(k=>!['clientId','assessmentCode','dateOfBirth','effectiveDate','allowEntryCorrection'].includes(k))||input.allowEntryCorrection!==undefined&&typeof input.allowEntryCorrection!=='boolean')fail('invalid DOB validation input');
97
+ const wanted=validateBirthDate(input.dateOfBirth),at=instant(input.effectiveDate);
98
+ if(wanted>at.slice(0,10))fail('birth date follows effective date');
99
+ const current=await inspect({clientId:input.clientId,assessmentCode:input.assessmentCode});
100
+ if(current.effectiveDate&&Date.parse(current.effectiveDate)>Date.parse(at))fail('historical correction before latest DOB requires a separate history workflow');
101
+ if(current.effectiveDate===at){if(current.dateOfBirth!==wanted&&!input.allowEntryCorrection)fail('existing effective timestamp requires matching entry context');}
102
+ else await ui.validateRestDate(wanted);
103
+ return current;
104
+ };
105
+ const verify=async input=>{
106
+ if(!input||Object.keys(input).some(k=>!['clientId','assessmentCode','dateOfBirth','effectiveDate','allowLater'].includes(k))||input.allowLater!==undefined&&typeof input.allowLater!=='boolean')fail('invalid DOB verification input');
107
+ const wanted=validateBirthDate(input.dateOfBirth),at=instant(input.effectiveDate),current=await inspect({clientId:input.clientId,assessmentCode:input.assessmentCode});
108
+ if(current.dateOfBirth!==wanted||current.effectiveDate===null||(input.allowLater?Date.parse(current.effectiveDate)<Date.parse(at):current.effectiveDate!==at))fail('saved birth date changed or effective timestamp does not match');
109
+ return current;
110
+ };
111
+ return {update,read:inspect,validate,verify};
112
+ }
@@ -0,0 +1,174 @@
1
+ import type {DateOfBirth} from './date-of-birth.mjs';
2
+ import type {Answers, AssessmentDefinition, GwtEnrollmentHelpers, Provider, WellSkyClient} from '../dist/esm/index.js';
3
+ import type {UiMutationStore,EnrollmentReader} from './index.mjs';
4
+ import type {ProviderAssessmentItem, ProgramDiscovery} from './program-discovery.mjs';
5
+ import type {HouseholdExitUi,HouseholdExitDetailInput} from './household-exit.mjs';
6
+ import type {MemberEntryDatePort} from './member-entry-date.mjs';
7
+
8
+ export interface EnrollmentSubassessmentPlan {
9
+ /** Stable source row key, retained across retries. */
10
+ key:string;
11
+ parentField:string;
12
+ answers:Answers;
13
+ }
14
+ export interface EnrollmentEndDatePlan {
15
+ parentField:string;
16
+ recordsetId:number;
17
+ /** Used to reject an end date before the stored row's start. */
18
+ startDateField:string;
19
+ endDateField:string;
20
+ endDate:string;
21
+ }
22
+ /** Every editable top-level field needs an answer, subassessment operation,
23
+ * explicit applicability reason, or a nonempty value to carry forward.
24
+ * The application determines applicability and required verification types. */
25
+ export interface EnrollmentAssessmentPlan {
26
+ /** SVPPROFDOB requires {value:'YYYY-MM-DD'} and the birthDates dependency. Other date answers remain timestamps. */
27
+ answers?:Answers;
28
+ subassessments?:EnrollmentSubassessmentPlan[];
29
+ endDates?:EnrollmentEndDatePlan[];
30
+ notApplicable?:Record<string,string>;
31
+ carryForward?:string[];
32
+ }
33
+ export interface EnrollmentDiscoveryInput {
34
+ clientId:number;
35
+ providerId:number;
36
+ type:'HUD'|'VA';
37
+ /** Timezone-qualified timestamp with whole-second precision. */
38
+ entryDate:string;
39
+ }
40
+ export interface EnrollmentWorkflowDiscovery {
41
+ provider:Provider;
42
+ entry:{assessment:ProviderAssessmentItem;definition:AssessmentDefinition};
43
+ interim:{assessment:ProviderAssessmentItem;definition:AssessmentDefinition};
44
+ }
45
+ export interface EnrollmentWorkflowInput extends EnrollmentDiscoveryInput {
46
+ key:string;
47
+ assessment:EnrollmentAssessmentPlan;
48
+ }
49
+ export interface InterimWorkflowInput {
50
+ key:string;
51
+ /** Key of a verified entry in the same durable store and client scope. */
52
+ enrollmentKey:string;
53
+ clientId:number;
54
+ reviewDate:string;
55
+ assessment:EnrollmentAssessmentPlan;
56
+ }
57
+ export interface EnrollmentWorkflowResult {
58
+ key:string;
59
+ outcome:'verified'|'already-present';
60
+ clientId:number;
61
+ enrollmentId:number;
62
+ assessmentCode:string;
63
+ /** The subassessment plan's stable row keys map to stored recordset IDs. */
64
+ recordsetIds:Record<string,number>;
65
+ effectiveDate:string;
66
+ /** Fresh client API and UI verification of the member's calendar DOB. */
67
+ birthDate?:{clientId:number;dateOfBirth:string;effectiveDate:string};
68
+ }
69
+ export interface InterimWorkflowResult extends EnrollmentWorkflowResult {reviewId:number;}
70
+ export interface ExitWorkflowResult extends EnrollmentWorkflowResult {projectExitDate:string;exitSubgroupId:string;}
71
+ export interface ExitWorkflowDiscovery extends EnrollmentWorkflowDiscovery {exit:{assessment:ProviderAssessmentItem;definition:AssessmentDefinition};}
72
+ export interface EnrollmentWorkflowStatus {
73
+ key:string;
74
+ kind:'entry'|'interim'|'exit';
75
+ stage:'pending'|'verified';
76
+ assessmentCode:string;
77
+ enrollmentId?:number;
78
+ reviewId?:number;
79
+ projectExitDate?:string;
80
+ exitSubgroupId?:string;
81
+ recordsetIds:Record<string,number>;
82
+ steps:{name:string;state:'attempted'|'verified'}[];
83
+ }
84
+ export interface EnrollmentWorkflow {
85
+ discover(input:EnrollmentDiscoveryInput):Promise<EnrollmentWorkflowDiscovery>;
86
+ discoverExit(input:EnrollmentDiscoveryInput):Promise<ExitWorkflowDiscovery>;
87
+ createEnrollment(input:EnrollmentWorkflowInput):Promise<EnrollmentWorkflowResult>;
88
+ createInterim(input:InterimWorkflowInput):Promise<InterimWorkflowResult>;
89
+ /** Reads the local journal only. A verified journal is not fresh server verification. */
90
+ getStatus(input:{clientId:number;key:string}):Promise<EnrollmentWorkflowStatus|null>;
91
+ createHouseholdEnrollment(input:HouseholdEnrollmentInput):Promise<HouseholdEnrollmentResult>;
92
+ addHouseholdMembers(input:HouseholdMemberAdditionInput):Promise<HouseholdEnrollmentResult>;
93
+ createHouseholdInterim(input:HouseholdInterimInput):Promise<HouseholdInterimResult>;
94
+ exitHousehold(input:HouseholdExitInput):Promise<HouseholdExitResult>;
95
+ /** Local progress only. Member keys can be passed to getStatus for individual steps. */
96
+ getHouseholdStatus(input:{headClientId:number;key:string}):Promise<HouseholdWorkflowStatus|null>;
97
+ }
98
+ export interface HouseholdMemberPlan {clientId:number;assessment:EnrollmentAssessmentPlan;}
99
+ export interface HouseholdEnrollmentInput extends Omit<EnrollmentDiscoveryInput,'clientId'> {
100
+ key:string;
101
+ /** Existing household with all selected clients already joined at entry. */
102
+ householdId:number;
103
+ headClientId:number;
104
+ /** At least two unique clients, including the head. Order is immaterial. */
105
+ members:HouseholdMemberPlan[];
106
+ }
107
+ export interface HouseholdMemberAdditionInput {
108
+ key:string;
109
+ /** Stable key of the original verified household enrollment. */
110
+ enrollmentKey:string;
111
+ headClientId:number;
112
+ /** Later than the original entry. Applies to the selected new clients only. */
113
+ entryDate:string;
114
+ /** At least one new client in the existing household, excluding the head. */
115
+ members:HouseholdMemberPlan[];
116
+ }
117
+ export interface HouseholdInterimInput {
118
+ key:string;
119
+ enrollmentKey:string;
120
+ headClientId:number;
121
+ reviewDate:string;
122
+ /** Nonempty selected subset of the enrolled cohort, including a singleton. The head may be excluded. */
123
+ members:HouseholdMemberPlan[];
124
+ }
125
+ export interface HouseholdExitInput extends HouseholdExitDetailInput {
126
+ key:string;
127
+ enrollmentKey:string;
128
+ headClientId:number;
129
+ exitDate:string;
130
+ /** Exact available labels from exitUi.listOptions. Shared only by selected members. */
131
+ destination:string;
132
+ reasonForLeaving:string;
133
+ /** Nonempty selected subset of the enrolled cohort, including a singleton. The head may be excluded.
134
+ * Assessment answers remain per person; unselected enrollments and exits are preserved. */
135
+ members:HouseholdMemberPlan[];
136
+ }
137
+ export interface HouseholdEnrollmentResult {
138
+ key:string;
139
+ outcome:'verified'|'already-present';
140
+ householdId:number;
141
+ headClientId:number;
142
+ members:EnrollmentWorkflowResult[];
143
+ }
144
+ export interface HouseholdInterimResult extends Omit<HouseholdEnrollmentResult,'members'> {members:InterimWorkflowResult[];}
145
+ export interface HouseholdExitResult extends Omit<HouseholdEnrollmentResult,'members'> {members:ExitWorkflowResult[];}
146
+ export interface HouseholdWorkflowStatus {
147
+ key:string;
148
+ kind:'entry'|'addition'|'interim'|'exit';
149
+ stage:'pending'|'verified';
150
+ householdId:number;
151
+ headClientId:number;
152
+ members:Array<{clientId:number;key:string}&Partial<Omit<InterimWorkflowResult&ExitWorkflowResult,'clientId'|'key'>>>;
153
+ }
154
+ export interface EnrollmentWorkflowOptions {
155
+ api:Pick<WellSkyClient,'tenant'|'clients'|'providers'|'enrollments'|'assessments'|'assessmentsMobile'|'subassessments'>&Partial<Pick<WellSkyClient,'households'>>;
156
+ discovery:Pick<ProgramDiscovery,'getProviderAssessments'>;
157
+ helpers:Pick<GwtEnrollmentHelpers,'createInterimUpdate'>&Partial<Pick<GwtEnrollmentHelpers,'addHouseholdMembers'>>;
158
+ store:UiMutationStore;
159
+ /** Required for SVPPROFDOB answers or carry-forward. Share this workflow's durable store. */
160
+ birthDates?:DateOfBirth;
161
+ /** Required, along with api.households and helpers.addHouseholdMembers, for household operations. */
162
+ enrollmentSession?:EnrollmentReader;
163
+ /** Required for exitHousehold; the workflow owns its durable write journal. */
164
+ exitUi?:HouseholdExitUi;
165
+ /** Required to set the later entry date for newly attached household members. */
166
+ memberEntryDatePort?:MemberEntryDatePort;
167
+ }
168
+ export class EnrollmentWorkflowError extends Error {
169
+ readonly reason:string;
170
+ constructor(reason:string);
171
+ }
172
+ /** Tenant-configured. Retain stable keys and one durable store for all cooperating writers.
173
+ * A partial or uncertain write requires reconciliation; no automatic write replay occurs. */
174
+ export function createEnrollmentWorkflow(options:EnrollmentWorkflowOptions):EnrollmentWorkflow;