@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,46 @@
1
+ // Decode only the observed mapped-read wire types. Never evaluate server JavaScript.
2
+ export function decodeMappedResponse(body, expectedOffset) {
3
+ if (!body.startsWith('//OK[') || body.length > 5_000_000) throw Error('GWT read failed');
4
+ // GWT emits long tokens in single quotes. Leave ordinary JSON strings untouched,
5
+ // including note text such as "selected 'No'" and escaped quotation marks.
6
+ const values = JSON.parse(body.slice(4).replace(/"(?:[^"\\]|\\.)*"|'([A-Za-z0-9$_]+)'/g, (token, long) => long === undefined ? token.replace(/\\(?:x([0-9a-fA-F]{2})|.)/g, (escape, hex) => hex === undefined ? escape : `\\u00${hex}`) : JSON.stringify(long)));
7
+ if (values.pop() !== 7 || values.pop() !== 0) throw Error('Unsupported GWT response');
8
+ const strings = values.pop(), refs = [];
9
+ if (!Array.isArray(strings) || strings.some(s => typeof s !== 'string')) throw Error('Invalid GWT strings');
10
+ const pop = () => { if (!values.length) throw Error('Truncated GWT response'); return values.pop(); };
11
+ const integer = () => { const n=pop(); if(!Number.isSafeInteger(n))throw Error('Invalid GWT integer');return n; };
12
+ const long = () => {
13
+ const s=pop(); if(typeof s!=='string'||!s.length)throw Error('Invalid GWT long');
14
+ let n=0n;for(const c of s){const digit='ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789$_'.indexOf(c);if(digit<0)throw Error('Invalid GWT long');n=n*64n+BigInt(digit);}
15
+ n=BigInt.asIntN(64,n);const number=Number(n);if(!Number.isSafeInteger(number))throw Error('GWT integer exceeds safe range');return number;
16
+ };
17
+ const object = () => {
18
+ const tag=integer();if(tag===0)return null;if(tag<0){if(-tag>refs.length)throw Error('Invalid GWT reference');return refs[-tag-1];}
19
+ const type=strings[tag-1],slot=refs.length;refs.push(null);
20
+ let result;
21
+ switch(type){
22
+ case 'java.lang.String/2004016611': result=strings[integer()-1];if(typeof result!=='string')throw Error('Invalid string reference');break;
23
+ case 'java.lang.Integer/3438268394': result=integer();break;
24
+ case 'java.lang.Double/858496421': result=pop();if(typeof result!=='number'||!Number.isFinite(result))throw Error('Invalid GWT double');break;
25
+ case 'java.lang.Long/4227064769': result=long();break;
26
+ case 'java.lang.Boolean/476441737': {const n=integer();if(n!==0&&n!==1)throw Error('Invalid boolean');result=!!n;break;}
27
+ case 'java.util.Date/3385151746': result=new Date(long()).toISOString();break;
28
+ case 'java.util.ArrayList/4159755760': {
29
+ result=[];refs[slot]=result;const count=integer();if(count<0||count>10000)throw Error('Invalid list size');for(let i=0;i<count;i++)result.push(object());break;
30
+ }
31
+ case 'java.util.HashMap/1797211028': {
32
+ result=Object.create(null);refs[slot]=result;const count=integer();if(count<0||count>1000)throw Error('Invalid map size');
33
+ for(let i=0;i<count;i++){const key=object();if(typeof key!=='string'||Object.hasOwn(result,key))throw Error('Invalid map key');result[key]=object();}break;
34
+ }
35
+ default: throw Error('Unsupported GWT mapped type');
36
+ }
37
+ refs[slot]=result;return result;
38
+ };
39
+ const root=integer();if(strings[root-1]!=='com.bowmansystems.sp5.core.client.ui.MappedQueryResults/340212582')throw Error('Not mapped results');
40
+ refs.push(null);
41
+ // MappedQueryResults serializes rows, firstRow and lastRow as primitive
42
+ // integers, then the boxed total. A zero firstRow is not a null object.
43
+ const rows=object(),offset=integer(),lastRow=integer(),total=object();
44
+ if(values.length||!Array.isArray(rows)||offset<0||lastRow < -1||expectedOffset!==undefined&&offset!==expectedOffset||!Number.isSafeInteger(total)||total<rows.length||offset+rows.length>total)throw Error('Incomplete mapped response');
45
+ return {rows,total};
46
+ }
@@ -0,0 +1,34 @@
1
+ import type {Page} from 'playwright-core';
2
+ export interface HouseholdExitTarget {clientId:number;enrollmentId:number;}
3
+ export type HouseholdExitDestinationDetails =
4
+ | {kind:'rental-subsidy';rentalSubsidyType:string}
5
+ | {kind:'other';explanation:string};
6
+ export interface HouseholdExitDetailInput {
7
+ /** Required for the corresponding destination; omitted for every other destination. */
8
+ destinationDetails?:HouseholdExitDestinationDetails;
9
+ /** Required only when the reason is Other. Explanations are 1..100 characters. */
10
+ reasonForLeavingOther?:string;
11
+ }
12
+ export interface HouseholdExitUiInput extends HouseholdExitTarget,HouseholdExitDetailInput {
13
+ /** Nonempty selected subset of the enrollment group. Includes target clientId, which need not be the head. */
14
+ clientIds:number[];exitDate:string;destination:string;reasonForLeaving:string;
15
+ }
16
+ export interface HouseholdExitOptions {
17
+ destinations:{value:string;name:string}[];reasons:{value:string;name:string}[];
18
+ rentalSubsidyTypes:{value:string;name:string}[];
19
+ }
20
+ export interface HouseholdExitStoredDetails {
21
+ destinationDetails:HouseholdExitDestinationDetails|null;
22
+ reasonForLeavingOther:string|null;
23
+ }
24
+ export interface HouseholdExitUi {
25
+ readonly connection?:import("./connection.mjs").UiConnection;
26
+ listOptions(target:HouseholdExitTarget):Promise<HouseholdExitOptions>;
27
+ /** Reopen the saved member exit and read the persisted conditional fields, then cancel. */
28
+ readExitDetails(target:HouseholdExitTarget):Promise<HouseholdExitStoredDetails>;
29
+ validateExit(input:HouseholdExitUiInput):Promise<void>;
30
+ /** One save attempt only. Use through the durable exitHousehold workflow. */
31
+ saveExit(input:HouseholdExitUiInput):Promise<void>;
32
+ }
33
+ /** UI port with fresh conditional choices and per-member readback. */
34
+ export function createHouseholdExitUi(options:{page:Page}):HouseholdExitUi;
@@ -0,0 +1,102 @@
1
+ import {pageConnection} from './connection.mjs';
2
+ import {createPlaywrightGwtSession} from './playwright-session.mjs';
3
+ import {withPageLock} from './page-lock.mjs';
4
+ import {expectedExitDetails,rentalDestination} from './exit-details.mjs';
5
+
6
+ const label=value=>value.replace(/\s*\(HUD\)\s*$/,'').trim();
7
+ const id=value=>Number.isSafeInteger(value)&&value>0;
8
+ /** Low-level UI port. Use through exitHousehold, which journals before save.
9
+ * This port makes at most one Save click and never retries an uncertain write. */
10
+ export function createHouseholdExitUi({page}){
11
+ const connection=pageConnection(page),base=connection.moduleBaseUrl;
12
+ const reader=createPlaywrightGwtSession({page,tenant:connection.tenant,moduleBaseUrl:base});
13
+ const control=name=>page.locator(`[id="${name}"]:visible`);
14
+ const ready=()=>page.waitForFunction(()=>!document.body.innerText.includes('Processing...')&&!document.body.innerText.includes('Loading...'));
15
+ const cancel=async()=>{const button=control('ExitDataPopup.cancel-button');if(await button.count()){await button.click();await ready();}};
16
+ const validate=input=>{
17
+ if(!input||!id(input.clientId)||!id(input.enrollmentId))throw Error('Valid exit target required');
18
+ if(!Array.isArray(input.clientIds)||input.clientIds.length<1||input.clientIds.some(v=>!id(v))||new Set(input.clientIds).size!==input.clientIds.length||!input.clientIds.includes(input.clientId))throw Error('Exact household exit members required');
19
+ if(typeof input.exitDate!=='string'||!/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.000)?(?:Z|[+-]\d{2}:\d{2})$/.test(input.exitDate)||!Number.isFinite(Date.parse(input.exitDate))||new Date(input.exitDate.slice(0,19)+'Z').toISOString().slice(0,19)!==input.exitDate.slice(0,19))throw Error('Timezone-qualified exit date required');
20
+ for(const field of ['destination','reasonForLeaving'])if(typeof input[field]!=='string'||!input[field].trim())throw Error('Exit choice required');
21
+ expectedExitDetails(input);
22
+ };
23
+ const options=async name=>(await page.getByLabel(name,{exact:true}).locator('option').evaluateAll(es=>es.filter(e=>!e.disabled&&e.value&&e.value!=='-Select-'&&!e.text.startsWith('-----')).map(e=>({value:e.value,name:e.text})))).map(o=>({...o,name:label(o.name)}));
24
+ const open=async target=>{
25
+ const url=new URL(page.url());if(url.origin!==new URL(base).origin||url.pathname!==new URL(base).pathname+'index.html')throw Error('Configured application page required');
26
+ await cancel();const snapshot=await reader.readEnrollment(target);
27
+ await control(`ClientEntryExitTab.editExit-imageLink_${target.enrollmentId}`).click();await control('ExitDataPopup.cancel-button').waitFor();await ready();
28
+ if(!(await page.locator('body').innerText()).includes(`Edit Exit Data - (${target.clientId})`))throw Error('Exit client mismatch');
29
+ return snapshot;
30
+ };
31
+ // Locate each explanation relative to its own labeled select, never by field order.
32
+ const otherField=name=>page.getByLabel(name,{exact:true}).locator('xpath=ancestor::tr[following-sibling::tr[1]//input[@maxlength="100"]][1]/following-sibling::tr[1]').getByRole('textbox');
33
+ const selected=async name=>page.getByLabel(name,{exact:true}).evaluate(e=>{
34
+ const o=e.selectedOptions;if(o.length!==1||!o[0].value||o[0].value==='-Select-')throw Error('Exit selection missing');return o[0].text;
35
+ });
36
+ const readDetails=async()=>{
37
+ const destination=label(await selected('Destination')),reasonForLeaving=label(await selected('Reason for Leaving'));
38
+ const destinationOther=await otherField('Destination').inputValue(),reasonOther=await otherField('Reason for Leaving').inputValue();
39
+ if(await page.getByLabel('Subsidy',{exact:true}).isVisible())throw Error('Unrecognized legacy subsidy form');
40
+ const rentalVisible=await page.getByLabel('Rental Subsidy Type',{exact:true}).isVisible();
41
+ if(rentalVisible!==(destination===rentalDestination))throw Error('Exit subsidy form mismatch');
42
+ if(destination!=='Other'&&destinationOther||reasonForLeaving!=='Other'&&reasonOther)throw Error('Unexpected stored Other explanation');
43
+ const destinationDetails=destination===rentalDestination?{kind:'rental-subsidy',rentalSubsidyType:label(await selected('Rental Subsidy Type'))}:destination==='Other'?{kind:'other',explanation:destinationOther}:undefined;
44
+ return expectedExitDetails({destination,reasonForLeaving,...(destinationDetails?{destinationDetails}:{}),...(reasonForLeaving==='Other'?{reasonForLeavingOther:reasonOther}:{})});
45
+ };
46
+ const configure=async input=>{
47
+ validate(input);const snapshot=await open({clientId:input.clientId,enrollmentId:input.enrollmentId});
48
+ const desired=[...input.clientIds].sort((a,b)=>a-b);
49
+ if(!Array.isArray(snapshot.members)||!Array.isArray(snapshot.exits))throw Error('Exit household read incomplete');
50
+ for(const clientId of desired){
51
+ const members=snapshot.members.filter(m=>m.clientId===clientId);
52
+ if(members.length!==1)throw Error('Selected exit member missing or ambiguous');
53
+ const exits=snapshot.exits.filter(e=>e.clientId===clientId);
54
+ if(exits.length!==1||exits[0].enrollmentId!==members[0].enrollmentId)throw Error('Selected exit metadata missing or ambiguous');
55
+ if(exits[0].projectExitDate!==null)throw Error('Selected household member already exited');
56
+ }
57
+ if(Date.parse(input.exitDate)<Date.parse(snapshot.projectStartDate)||snapshot.reviews.some(r=>Date.parse(r.reviewDate)>Date.parse(input.exitDate)))throw Error('Exit date precedes enrollment or review');
58
+ const choices={destinations:await options('Destination'),reasons:await options('Reason for Leaving')};
59
+ const chosen={};
60
+ for(const [field,name,items]of [['destination','Destination',choices.destinations],['reasonForLeaving','Reason for Leaving',choices.reasons]]){
61
+ const matching=items.filter(o=>o.name===label(input[field]));if(matching.length!==1)throw Error('Exit choice unavailable or ambiguous');chosen[field]=matching[0].value;await page.getByLabel(name,{exact:true}).selectOption(chosen[field]);
62
+ }
63
+ await ready();
64
+ if(await page.getByLabel('Subsidy',{exact:true}).isVisible())throw Error('Unrecognized legacy subsidy form');
65
+ if(input.destinationDetails?.kind==='rental-subsidy'){
66
+ const field=page.getByLabel('Rental Subsidy Type',{exact:true});
67
+ if(!await field.isVisible())throw Error('Rental subsidy field unavailable');
68
+ const matches=(await options('Rental Subsidy Type')).filter(o=>o.name===input.destinationDetails.rentalSubsidyType);
69
+ if(matches.length!==1)throw Error('Rental subsidy choice unavailable or ambiguous');
70
+ await field.selectOption(matches[0].value);if(await field.inputValue()!==matches[0].value)throw Error('Rental subsidy selection did not match');
71
+ }else if(await page.getByLabel('Rental Subsidy Type',{exact:true}).isVisible())throw Error('Unexpected rental subsidy field');
72
+ for(const [name,value]of [['Destination',input.destinationDetails?.kind==='other'?input.destinationDetails.explanation:''],['Reason for Leaving',input.reasonForLeavingOther??'']]){
73
+ const field=otherField(name);if(await field.count()!==1||!await field.isVisible()||await field.getAttribute('maxlength')!=='100')throw Error('Other explanation field unavailable');
74
+ await field.fill(value);if(await field.inputValue()!==value)throw Error('Other explanation did not match');
75
+ }
76
+ const boxes=page.locator('[id^="HouseholdsCheckboxTree.householdCheckbox."]:visible input');
77
+ for(let i=0;i<await boxes.count();i++){
78
+ const box=boxes.nth(i),clientId=await box.evaluate(e=>Number(e.parentElement.id.match(/\.client-(\d+)$/)?.[1]));
79
+ if(await box.isEnabled())await box.setChecked(desired.includes(clientId));
80
+ if((await box.isChecked())!==desired.includes(clientId))throw Error('Exit household selection mismatch');
81
+ }
82
+ const selected=await page.locator('[id^="HouseholdsCheckboxTree.householdCheckbox."]:visible input:checked').evaluateAll(es=>es.map(e=>Number(e.parentElement.id.match(/\.client-(\d+)$/)?.[1])).sort((a,b)=>a-b));
83
+ if(JSON.stringify(selected)!==JSON.stringify(desired))throw Error('Exit household selection incomplete');
84
+ const parts=await page.evaluate(value=>{const d=new Date(value),local=new Date(d.getFullYear(),d.getMonth(),d.getDate(),d.getHours(),d.getMinutes(),d.getSeconds());if(+d!==+local)throw Error('Ambiguous local exit time');return {Month:String(d.getMonth()+1).padStart(2,'0'),Day:String(d.getDate()).padStart(2,'0'),Year:String(d.getFullYear()),Hour:String(d.getHours()%12||12),Minute:String(d.getMinutes()).padStart(2,'0'),Second:String(d.getSeconds()).padStart(2,'0'),'AM/PM':d.getHours()<12?'AM':'PM'};},input.exitDate);
85
+ for(const [part,value]of Object.entries(parts)){const field=page.getByLabel(`Exit Date ${part}`,{exact:true});if(['Month','Day','Year'].includes(part))await field.fill(value);else await field.selectOption({label:value});if(await field.inputValue()!==value)throw Error('Exit date did not match');}
86
+ for(const [field,name]of [['destination','Destination'],['reasonForLeaving','Reason for Leaving']])if(await page.getByLabel(name,{exact:true}).inputValue()!==chosen[field])throw Error('Exit choice did not match');
87
+ };
88
+ return {connection,
89
+ listOptions:target=>withPageLock(page,async()=>{if(!id(target?.clientId)||!id(target?.enrollmentId))throw Error('Valid exit target required');try{await open(target);const result={destinations:await options('Destination'),reasons:await options('Reason for Leaving'),rentalSubsidyTypes:[]};const rental=result.destinations.filter(o=>o.name===rentalDestination);if(rental.length>1)throw Error('Ambiguous rental destination');if(rental.length){await page.getByLabel('Destination',{exact:true}).selectOption(rental[0].value);await ready();if(!await page.getByLabel('Rental Subsidy Type',{exact:true}).isVisible())throw Error('Rental subsidy field unavailable');result.rentalSubsidyTypes=await options('Rental Subsidy Type');}return result;}finally{await cancel();}}),
90
+ readExitDetails:target=>withPageLock(page,async()=>{if(!id(target?.clientId)||!id(target?.enrollmentId))throw Error('Valid exit target required');try{const snapshot=await open(target);if(!snapshot.exits.some(e=>e.clientId===target.clientId&&e.enrollmentId===target.enrollmentId&&e.projectExitDate))throw Error('Saved exit required');return await readDetails();}finally{await cancel();}}),
91
+ validateExit:input=>withPageLock(page,async()=>{try{await configure(input);}finally{await cancel();}}),
92
+ saveExit:input=>withPageLock(page,async()=>{
93
+ try{
94
+ await configure(input);
95
+ await control('ExitDataPopup.saveAndContinue-button').click();
96
+ await control('ExitDataPopup.cancel-button').waitFor({state:'hidden',timeout:30000});
97
+ await ready();
98
+ // A success screen is not proof of a committed household exit. The caller must read it back.
99
+ }finally{await cancel();}
100
+ }),
101
+ };
102
+ }
@@ -0,0 +1,20 @@
1
+ import {pageConnection} from './connection.mjs';
2
+ import {withPageLock} from './page-lock.mjs';
3
+
4
+ /** Read-only household absence check. Never clicks Continue, which creates a household. */
5
+ export function createHouseholdUi({page}) {
6
+ const connection=pageConnection(page),base=connection.moduleBaseUrl;
7
+ const confirmNoHouseholds=clientId=>withPageLock(page,async()=>{
8
+ 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');
9
+ if(await page.locator('[role=dialog]:visible').count())throw Error('Close dialogs before household workflow');
10
+ const ready=()=>page.waitForFunction(()=>!document.body.innerText.includes('Processing...')&&!document.body.innerText.includes('Loading...'),null,{timeout:30000});
11
+ if(/Client - \(\d+\)/.test(await page.locator('body').innerText())){
12
+ await page.getByRole('tab',{name:'Client Information',exact:true}).click();await page.getByRole('tab',{name:'Summary',exact:true}).click();await ready();await page.getByRole('button',{name:'Exit',exact:true}).click();await page.waitForURL(u=>u.hash==='#clientPointClientMain');await ready();
13
+ }
14
+ await page.goto(base+'index.html#loadClient;clientId='+clientId);
15
+ await page.waitForFunction(id=>document.body.innerText.includes(`Client - (${id})`)&&!document.body.innerText.includes('Processing...')&&!document.body.innerText.includes('Loading...'),clientId,{timeout:30000});
16
+ await page.getByRole('tab',{name:'Households',exact:true}).click();await ready();
17
+ return await page.getByText('This Client is not currently a member of any Households.',{exact:true}).isVisible();
18
+ });
19
+ return {connection,confirmNoHouseholds};
20
+ }
@@ -0,0 +1,22 @@
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 HouseholdMemberInput {clientId:number;relationship:string;dateJoined:string;}
5
+ export interface HouseholdMember extends HouseholdMemberInput {head:boolean;dateEnded:string|null;isActive:boolean;}
6
+ export interface HouseholdSnapshot {householdId:number;householdType:string;members:HouseholdMember[];}
7
+ export interface HouseholdResult {key:string;outcome:'verified'|'already-present';household:HouseholdSnapshot;}
8
+ export interface HouseholdUi {
9
+ readonly connection?:import("./connection.mjs").UiConnection;confirmNoHouseholds(clientId:number):Promise<boolean>;}
10
+ export interface HouseholdMutation {key:string;householdId:number;headClientId:number;}
11
+ export interface Households {
12
+ read(householdId:number):Promise<HouseholdSnapshot>;
13
+ create(input:{key:string;headClientId:number;householdType:string;members:HouseholdMemberInput[]}):Promise<HouseholdResult>;
14
+ addMembers(input:HouseholdMutation&{householdType?:string;members:HouseholdMemberInput[]}):Promise<HouseholdResult>;
15
+ /** Ends household membership only. The head cannot leave an active household. */
16
+ endMembers(input:HouseholdMutation&{clientIds:number[];dateEnded:string}):Promise<HouseholdResult>;
17
+ /** Only non-head relationships may change. */
18
+ updateRelationships(input:HouseholdMutation&{members:{clientId:number;relationship:string}[]}):Promise<HouseholdResult>;
19
+ }
20
+ export function createHouseholds(options:{api:WellSkyClient;store:UiMutationStore;ui:HouseholdUi}):Households;
21
+ export function getHouseholdOptions():{householdTypes:string[];relationships:string[];verifiedOn:string};
22
+ export function createHouseholdUi(options:{page:Page}):HouseholdUi;
@@ -0,0 +1,115 @@
1
+ import {apiScope} from './connection.mjs';
2
+ import {createHash} from 'node:crypto';
3
+ import {toItems} from '../dist/esm/index.js';
4
+
5
+ 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');
6
+ const id=n=>{if(!Number.isSafeInteger(n)||n<1||n>2147483647)throw Error('Positive household/client ID required');return n;};
7
+ const fields=(v,keys)=>{if(!v||typeof v!=='object'||Array.isArray(v)||Object.keys(v).some(k=>!keys.includes(k)))throw Error('Unknown household field');};
8
+ const instant=value=>{
9
+ 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))throw Error('Valid whole-second timezone-qualified membership date required');
10
+ return new Date(value).toISOString();
11
+ };
12
+ const serverDate=value=>instant(/(?:Z|[+-]\d\d:\d\d)$/.test(value)?value:value+'Z');
13
+ const types=['Individual','Female Single Parent','Male Single Parent','Two Parent Family','Foster Parent(s)','Couple With No Children','Non-custodial Caregiver(s)','Grandparent(s) and Child','Children Only Household','Other'];
14
+ const relationships=['Daughter','Father','Granddaughter','Grandfather','Grandmother','Grandson','Husband','Husband and Father','Mother','Other non-relative','Other relative','Self','Significant other','Son','Step-daughter','Step-son','Unknown','Wife','Wife and Mother'];
15
+ /** Display labels observed in the training UI, October 24, 2026. Recheck for other tenants. */
16
+ export const getHouseholdOptions=()=>({householdTypes:[...types],relationships:[...relationships],verifiedOn:'2026-10-24'});
17
+ const sorted=members=>members.sort((a,b)=>a.clientId-b.clientId);
18
+ const model=row=>{
19
+ id(row?.householdId);if(row.isActive!==true||!types.includes(row.householdType)||!Array.isArray(row.clients))throw Error('Active household readback unavailable');
20
+ const members=sorted(row.clients.map(m=>({clientId:id(m.clientId),head:m.headofhouseholdStatus,relationship:m.headofhouseholdRelationship,dateJoined:serverDate(m.dateJoined),dateEnded:m.dateLeft?serverDate(m.dateLeft):null,isActive:m.isActive})));
21
+ guard(members);return {householdId:row.householdId,householdType:row.householdType,members};
22
+ };
23
+ const guard=members=>{
24
+ if(!members.length||new Set(members.map(m=>m.clientId)).size!==members.length)throw Error('Unique household members required');
25
+ const heads=members.filter(m=>m.head);
26
+ if(heads.length!==1||heads[0].isActive!==true||heads[0].dateEnded||heads[0].relationship!=='Self')throw Error('Household data error: exactly one active head with relationship Self is required');
27
+ for(const m of members){
28
+ if(typeof m.head!=='boolean'||typeof m.isActive!=='boolean'||!relationships.includes(m.relationship)||(!m.head&&m.relationship==='Self'))throw Error('Invalid household relationship or head status');
29
+ if(m.dateJoined<heads[0].dateJoined||m.dateEnded&&m.dateEnded<m.dateJoined)throw Error('Head must cover every membership date');
30
+ }
31
+ return heads[0].clientId;
32
+ };
33
+ const member=m=>{fields(m,['clientId','relationship','dateJoined']);if(!relationships.includes(m.relationship))throw Error('Unavailable household relationship');return {clientId:id(m.clientId),relationship:m.relationship,dateJoined:instant(m.dateJoined),dateEnded:null,isActive:true};};
34
+ const body=s=>({householdType:s.householdType,householdClients:s.members.map(m=>({clientId:m.clientId,headOfHouseholdStatus:m.head,headOfHouseholdRelationship:m.relationship,dateJoined:m.dateJoined,...(s.householdId?{dateEnded:m.dateEnded,isActive:m.isActive}:{})}))});
35
+ const equal=(a,b)=>hash({householdType:a.householdType,members:a.members})===hash({householdType:b.householdType,members:b.members});
36
+
37
+ /** Durable household management. Does not attach members to enrollments or exit them.
38
+ * Share one store across callers. Ambiguous writes never automatically repeat. */
39
+ export function createHouseholds({api,store,ui}) {
40
+ const tenantScope=apiScope(api, ui);
41
+ if(!store?.withLock||!ui?.confirmNoHouseholds)throw Error('REST API, durable store and household UI reader required');
42
+ const read=async householdId=>{id(householdId);const rows=toItems(await api.households.get({path:{householdId}}));if(rows.length!==1||rows[0].householdId!==householdId)throw Error('Household readback ambiguous');return model(rows[0]);};
43
+ const list=async(clientId,allowEmpty)=>{
44
+ const rows=[],ids=new Set();let total;
45
+ for(let startPage=1;startPage<=100;startPage++){
46
+ let result;try{result=await api.households.listForClient({path:{clientId},query:{perPage:100,startPage,total_count:true}});}catch(e){if(e.status===404&&startPage===1&&allowEmpty&&await ui.confirmNoHouseholds(clientId))return [];throw e;}
47
+ if(!Number.isSafeInteger(result?.count)||result.count<0||!Array.isArray(result.values)||total!==undefined&&total!==result.count)throw Error('Household list count unavailable or changed');total=result.count;
48
+ for(const row of result.values){id(row.householdId);if(ids.has(row.householdId)||!row.clients?.some(c=>c.clientId===clientId))throw Error('Invalid household list');ids.add(row.householdId);rows.push(row);}
49
+ if(rows.length===total)return rows;if(!result.values.length||rows.length>total)throw Error('Incomplete household list');
50
+ }throw Error('Household list exceeds safety limit');
51
+ };
52
+ const run=async(kind,input)=>{
53
+ const keys={create:['key','headClientId','householdType','members'],addMembers:['key','householdId','headClientId','householdType','members'],endMembers:['key','householdId','headClientId','clientIds','dateEnded'],updateRelationships:['key','householdId','headClientId','members']};
54
+ fields(input,keys[kind]);id(input.headClientId);if(typeof input.key!=='string'||!input.key.trim()||input.key.length>200)throw Error('Stable household key required');
55
+ const intent={kind,headClientId:input.headClientId};
56
+ if(kind!=='create')intent.householdId=id(input.householdId);
57
+ if(kind==='create'||input.householdType!==undefined){if(!types.includes(input.householdType))throw Error('Unavailable household type');intent.householdType=input.householdType;}
58
+ if(kind==='create'||kind==='addMembers'){
59
+ if(!Array.isArray(input.members)||!input.members.length)throw Error('Members required');intent.members=sorted(input.members.map(member));
60
+ if(new Set(intent.members.map(m=>m.clientId)).size!==intent.members.length)throw Error('Duplicate client IDs');
61
+ for(const m of intent.members){m.head=m.clientId===input.headClientId;if(m.head?m.relationship!=='Self':m.relationship==='Self')throw Error('Head changes are not supported');}
62
+ if(kind==='create')guard(intent.members);else if(intent.members.some(m=>m.head))throw Error('Head changes are not supported');
63
+ }else if(kind==='endMembers'){
64
+ if(!Array.isArray(input.clientIds)||!input.clientIds.length)throw Error('Client IDs required');intent.clientIds=input.clientIds.map(id).sort((a,b)=>a-b);intent.dateEnded=instant(input.dateEnded);
65
+ if(new Set(intent.clientIds).size!==intent.clientIds.length)throw Error('Duplicate client IDs');
66
+ if(intent.clientIds.includes(input.headClientId))throw Error('Head cannot leave an active household; household closure is a separate workflow');
67
+ }else{
68
+ if(!Array.isArray(input.members)||!input.members.length)throw Error('Members required');intent.members=sorted(input.members.map(m=>{fields(m,['clientId','relationship']);id(m.clientId);if(m.clientId===input.headClientId||m.relationship==='Self')throw Error('Head changes are not supported');if(!relationships.includes(m.relationship))throw Error('Unavailable household relationship');return {clientId:m.clientId,relationship:m.relationship};}));
69
+ if(new Set(intent.members.map(m=>m.clientId)).size!==intent.members.length)throw Error('Duplicate client IDs');
70
+ }
71
+ const scope=JSON.stringify([tenantScope,'household-management']),keyHash=hash(input.key),intentHash=hash(intent);
72
+ return store.withLock(scope,async()=>{
73
+ const entries=await store.read(scope);if(!Array.isArray(entries)||entries.some(e=>e.version!==1||!['planned','attempted','verified'].includes(e.stage)))throw Error('Invalid household journal');
74
+ let entry=entries.find(e=>e.keyHash===keyHash);if(entries.filter(e=>e.keyHash===keyHash).length>1||entry&&entry.intentHash!==intentHash)throw Error('Household key conflicts with earlier intent');
75
+ if(entries.some(e=>e.keyHash!==keyHash&&e.stage!=='verified'))throw Error('Another household write is unresolved');
76
+ const persist=async stage=>{entry={...entry,version:1,keyHash,intentHash,stage};const i=entries.findIndex(e=>e.keyHash===keyHash);if(i<0)entries.push(entry);else entries[i]=entry;await store.write(scope,structuredClone(entries));};
77
+ if(entry?.stage==='verified'){
78
+ const current=await read(entry.householdId),latest=entries.filter(e=>e.householdId===entry.householdId&&e.stage==='verified').at(-1);
79
+ if(guard(current.members)!==input.headClientId||!equal(current,latest.target))throw Error('Household changed since verification; reconcile before continuing');
80
+ return {key:input.key,outcome:'already-present',household:current};
81
+ }
82
+ let current;
83
+ if(kind==='create'){
84
+ const existing=await list(input.headClientId,!entry);
85
+ if(!entry){entry={target:{householdType:intent.householdType,members:intent.members},baselineIds:existing.map(h=>h.householdId)};await persist('planned');}
86
+ const matches=existing.filter(h=>h.isActive===true).map(model).filter(h=>equal(h,entry.target));
87
+ if(matches.length>1)throw Error('Multiple matching households');current=matches[0];
88
+ }else{
89
+ current=await read(input.householdId);if(guard(current.members)!==input.headClientId)throw Error('Head changes are not supported');
90
+ if(!entry){
91
+ const target=structuredClone(current);if(intent.householdType)target.householdType=intent.householdType;
92
+ if(kind==='addMembers')for(const m of intent.members){if(target.members.some(v=>v.clientId===m.clientId))throw Error('Client already belongs to household; rejoining needs an explicit history workflow');target.members.push(m);}
93
+ else for(const change of kind==='endMembers'?intent.clientIds.map(clientId=>({clientId})):intent.members){
94
+ const m=target.members.find(v=>v.clientId===change.clientId);if(!m||!m.isActive||m.dateEnded)throw Error('Active household member required');if(kind==='endMembers')m.dateEnded=intent.dateEnded;else m.relationship=change.relationship;
95
+ }
96
+ sorted(target.members);guard(target.members);entry={householdId:current.householdId,baseline:current,target};await persist('planned');
97
+ }
98
+ }
99
+ if(!current||!equal(current,entry.target)){
100
+ if(entry.stage!=='planned')throw Error('Household write unresolved; refusing another mutation');
101
+ if(current&&!equal(current,entry.baseline))throw Error('Household changed before write');
102
+ for(const m of entry.target.members.filter(m=>!entry.baseline?.members.some(v=>v.clientId===m.clientId))){const rows=toItems(await api.clients.get({path:{clientId:m.clientId}}));if(rows.length!==1||rows[0].clientId!==m.clientId||rows[0].isActive!==true)throw Error('Active client required');}
103
+ await persist('attempted');let error;
104
+ try{if(kind==='create')await api.households.create({body:body(entry.target)});else await api.households.update({path:{householdId:input.householdId},body:body(entry.target)});}catch(e){error=e;}
105
+ if(kind==='create'){
106
+ const found=(await list(input.headClientId,false)).filter(h=>!entry.baselineIds.includes(h.householdId)&&h.isActive===true).map(model).filter(h=>equal(h,entry.target));
107
+ if(found.length!==1)throw error??Error('Created household not uniquely verified');current=found[0];
108
+ }else current=await read(input.householdId);
109
+ if(!equal(current,entry.target))throw error??Error('Household readback mismatch; refusing replay');
110
+ }
111
+ entry.householdId=current.householdId;await persist('verified');return {key:input.key,outcome:'verified',household:current};
112
+ });
113
+ };
114
+ return {read,create:input=>run('create',input),addMembers:input=>run('addMembers',input),endMembers:input=>run('endMembers',input),updateRelationships:input=>run('updateRelationships',input)};
115
+ }
@@ -0,0 +1,78 @@
1
+ import type {UiConnection,UiConnectionOptions} from './connection.mjs';
2
+ import type {Browser, BrowserContext, BrowserType, Page} from 'playwright-core';
3
+ import type {GwtEnrollmentExit,GwtEnrollmentHelpers,GwtEnrollmentSession,GwtEnrollmentSnapshot,GwtMutationStore} from '../dist/esm/index.js';
4
+ export interface LoginOptions extends UiConnectionOptions {
5
+ chromium: BrowserType;
6
+ envFile?: string;
7
+ executablePath?: string;
8
+ timeoutMs?: number;
9
+ passwordRecovery?: false | PasswordRecoveryOptions;
10
+ }
11
+ /** Enabled by default with a private per-user file journal. Share a store across hosts. */
12
+ export interface PasswordRecoveryOptions {store?:UiMutationStore;appendCharacter?:string;}
13
+ export class LoginError extends Error {
14
+ readonly code:'PASSWORD_EXPIRED'|'RECOVERY_UNCERTAIN'|'RECOVERY_CONFIGURATION'|'LOGIN_FAILED';
15
+ readonly stage:string;
16
+ readonly recoveryStage?:string;
17
+ }
18
+ export interface UiSession {readonly connection:UiConnection;browser: Browser;context: BrowserContext;page: Page;passwordRecovery:'restored-original'|'not-needed';ensureAuthenticated():Promise<void>;reauthenticate():Promise<void>;close():Promise<void>;}
19
+ export interface PlaywrightGwtOptions {page:Page;tenant:string;moduleBaseUrl:string;}
20
+ export interface PlaywrightEnrollmentSnapshot extends GwtEnrollmentSnapshot {providerId:number;enrollmentType:string;exits:readonly GwtEnrollmentExit[];}
21
+ export interface EnrollmentReader {
22
+ readonly connection?:UiConnection;
23
+ readEnrollment(target:{clientId:number;enrollmentId:number}):Promise<PlaywrightEnrollmentSnapshot>;
24
+ }
25
+ export function openUiSession(options:LoginOptions):Promise<UiSession>;
26
+ /** The read-only enrollment session shares the page lock with mutation helpers. */
27
+ export function openEnrollmentSession(options:LoginOptions&{store:GwtMutationStore & UiMutationStore}):Promise<UiSession&{helpers:GwtEnrollmentHelpers;enrollmentSession:EnrollmentReader;workflows:ClientWorkflows}>;
28
+ export function createPlaywrightGwtSession(options:PlaywrightGwtOptions):Omit<GwtEnrollmentSession,'readEnrollment'>&EnrollmentReader;
29
+ export function createPlaywrightEnrollmentHelpers(options:PlaywrightGwtOptions&{store:GwtMutationStore}):GwtEnrollmentHelpers;
30
+
31
+ /** Durable JSON journal. Use a separate namespace from enrollment intents. */
32
+ export interface UiMutationStore {
33
+ withLock<T>(scope:string,work:()=>Promise<T>):Promise<T>;
34
+ read(scope:string):Promise<readonly unknown[]>;
35
+ write(scope:string,records:readonly unknown[]):Promise<void>;
36
+ }
37
+ export interface BrowserAttachment {
38
+ attachmentId:number;entityId:number|null;entityType:string;entityName:string;
39
+ name:string;extension:string;description:string|null;
40
+ }
41
+ export type AttachmentConflictReason='intent-changed'|'duplicate-name'|'identity-changed'|'content-changed'|'unresolved';
42
+ export class AttachmentConflictError extends Error {
43
+ constructor(reason:AttachmentConflictReason,message:string);
44
+ readonly code:'ATTACHMENT_CONFLICT';
45
+ readonly reason:AttachmentConflictReason;
46
+ }
47
+ export interface BrowserCaseNote {noteId:number;goalId:number;clientId:number;noteDate:string;note:string;providerId:number;}
48
+ export interface ClientWorkflows {
49
+ readonly connection?:import("./connection.mjs").UiConnection;
50
+ listAttachments(clientId:number):Promise<BrowserAttachment[]>;
51
+ downloadAttachment(input:{clientId:number;attachmentId:number}):Promise<Uint8Array>;
52
+ uploadAttachment(input:{clientId:number;location:{kind:'profile'|'case-plans'}|{kind:'goal';goalId:number};file:{name:string;mimeType?:string;buffer:Uint8Array};description?:string}):Promise<{outcome:'written'|'reconciled'|'already-present';attachment:BrowserAttachment;checksum:string}>;
53
+ listCaseNotes(input:{clientId:number;goalId:number}):Promise<BrowserCaseNote[]>;
54
+ createCaseNote(input:{key?:string;clientId:number;goalId:number;providerId:number;noteDate:string;note:string}):Promise<{outcome:'written'|'reconciled'|'already-present';note:BrowserCaseNote}>;
55
+ }
56
+ export function createClientWorkflows(options:{page:Page;store:UiMutationStore}):ClientWorkflows;
57
+ export function createFileGwtMutationStore(directory:string):GwtMutationStore & UiMutationStore;
58
+ export * from './service-recording.mjs';
59
+ export * from './ssvf-rules.mjs';
60
+ export * from './program-discovery.mjs';
61
+ export * from './enrollment-workflow.mjs';
62
+ export * from './member-entry-date.mjs';
63
+ export * from './household-exit.mjs';
64
+
65
+ export class SessionInterruptedError extends Error {readonly code:'SESSION_INTERRUPTED';readonly reauthenticated:boolean;}
66
+ export * from './client-profile.mjs';
67
+ export * from './date-of-birth.mjs';
68
+ export * from './households.mjs';
69
+ export * from './client-creation.mjs';
70
+ export * from './case-plans.mjs';
71
+ export * from './need-recording.mjs';
72
+ export * from './shelters.mjs';
73
+ export * from './shelter-inventory.mjs';
74
+
75
+ export * from './current-living-situation.mjs';
76
+
77
+ export {resolveUiConnection} from './connection.mjs';
78
+ export type {UiConnection,UiConnectionOptions} from './connection.mjs';
@@ -0,0 +1,46 @@
1
+ export {createPlaywrightEnrollmentHelpers,createPlaywrightGwtSession} from './playwright-session.mjs';
2
+ export {openUiSession,LoginError} from './ui-login.mjs';
3
+ import {openUiSession} from './ui-login.mjs';
4
+ import {createPlaywrightEnrollmentHelpers,createPlaywrightGwtSession} from './playwright-session.mjs';
5
+ import {createClientWorkflows} from './client-workflows.mjs';
6
+ import {withPageLock} from './page-lock.mjs';
7
+ /** Authenticate once, use the same page for all writes, and retain the caller's durable journal. */
8
+ export async function openEnrollmentSession({store,...loginOptions}) {
9
+ const session=await openUiSession(loginOptions);
10
+ try {
11
+ const helpers=createPlaywrightEnrollmentHelpers({page:session.page,store,...session.connection});
12
+ const reader=createPlaywrightGwtSession({page:session.page,...session.connection});
13
+ const enrollmentSession={connection:session.connection,readEnrollment:target=>withPageLock(session.page,()=>reader.readEnrollment(target))};
14
+ return Object.assign(session,{helpers,enrollmentSession,workflows:createClientWorkflows({page:session.page,store})});
15
+ }catch(error){await session.close();throw error;}
16
+ }
17
+ export {createClientWorkflows} from './client-workflows.mjs';
18
+ export {AttachmentConflictError} from './attachment-conflict.mjs';
19
+ export {createFileGwtMutationStore} from './file-store.mjs';
20
+ export {createServiceUi} from './service-ui.mjs';
21
+ export {createServiceRecorder} from './service-recording.mjs';
22
+ export {getSsvfServiceRules} from './ssvf-rules.mjs';
23
+ export {createProgramDiscovery} from './program-discovery.mjs';
24
+ export {createEnrollmentWorkflow,EnrollmentWorkflowError} from './enrollment-workflow.mjs';
25
+ export {createMemberEntryDatePort} from './member-entry-date.mjs';
26
+ export {createHouseholdExitUi} from './household-exit.mjs';
27
+
28
+ export {SessionInterruptedError} from './session-authentication.mjs';
29
+ export {createClientProfile} from './client-profile.mjs';
30
+ export {createClientProfileUi} from './client-profile-ui.mjs';
31
+ export {createDateOfBirth,validateBirthDate} from './date-of-birth.mjs';
32
+ export {createDateOfBirthUi} from './date-of-birth-ui.mjs';
33
+ export {createHouseholds,getHouseholdOptions} from './households.mjs';
34
+ export {createHouseholdUi} from './households-ui.mjs';
35
+ export {createClientCreator} from './client-creation.mjs';
36
+ export {createCasePlans} from './case-plans.mjs';
37
+ export {createCasePlanUi} from './case-plans-ui.mjs';
38
+ export {createNeedRecorder,NeedConflictError} from './need-recording.mjs';
39
+ export {createNeedUi} from './need-ui.mjs';
40
+ export {createShelters,readShelterProgram,ShelterConflictError} from './shelters.mjs';
41
+ export {createShelterUi} from './shelter-ui.mjs';
42
+ export {createShelterInventory, ShelterInventoryError} from './shelter-inventory.mjs';
43
+
44
+ export * from './current-living-situation.mjs';
45
+
46
+ export {resolveUiConnection} from './connection.mjs';
@@ -0,0 +1,30 @@
1
+ import type {GwtEnrollmentExit,WellSkyClient} from '../dist/esm/index.js';
2
+ import type {EnrollmentReader} from './index.mjs';
3
+
4
+ export interface MemberEntryDateCohortMember {
5
+ clientId:number;
6
+ enrollmentId:number;
7
+ /** Original start date, retained for every member except the requested target. */
8
+ entryDate:string;
9
+ /** Complete authoritative exit metadata, which must remain unchanged. */
10
+ exit:GwtEnrollmentExit;
11
+ }
12
+ export interface MemberEntryDateInput {
13
+ clientId:number;
14
+ enrollmentId:number;
15
+ providerId:number;
16
+ type:'HUD'|'VA';
17
+ /** Requested later start date for this one open enrollment. */
18
+ entryDate:string;
19
+ /** Complete current linked cohort, including the target at its original start date. */
20
+ cohort:readonly MemberEntryDateCohortMember[];
21
+ }
22
+ export interface MemberEntryDatePort {
23
+ validateMemberEntryDate(input:MemberEntryDateInput):Promise<void>;
24
+ /** At most one date-only PUT. Caller journals before save and owns uncertain outcomes. */
25
+ saveMemberEntryDate(input:MemberEntryDateInput):Promise<void>;
26
+ }
27
+ export function createMemberEntryDatePort(options:{
28
+ api:Pick<WellSkyClient,'tenant'|'enrollments'>;
29
+ enrollmentSession:EnrollmentReader;
30
+ }):MemberEntryDatePort;
@@ -0,0 +1,71 @@
1
+ import {apiScope} from './connection.mjs';
2
+ const object=value=>value!==null&&typeof value==='object'&&!Array.isArray(value);
3
+ const positive=value=>Number.isSafeInteger(value)&&value>0;
4
+ const fail=message=>{throw Error(`Member entry date: ${message}`);};
5
+ const equal=(a,b)=>JSON.stringify(a)===JSON.stringify(b);
6
+ function instant(value){
7
+ if(typeof value!=='string'||!/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.000)?(?:Z|[+-]\d{2}:\d{2})$/.test(value)||!Number.isFinite(Date.parse(value))||new Date(value.slice(0,19)+'Z').toISOString().slice(0,19)!==value.slice(0,19))fail('timezone-qualified whole-second date required');
8
+ return new Date(value).toISOString();
9
+ }
10
+ function ids(values){
11
+ if(!Array.isArray(values)||!values.length||values.some(value=>!positive(value))||new Set(values).size!==values.length)fail('unique positive IDs required');
12
+ return [...values].sort((a,b)=>a-b);
13
+ }
14
+ function identity(value){
15
+ if(!object(value)||!positive(value.clientId)||!positive(value.enrollmentId))fail('member identity required');
16
+ return {clientId:value.clientId,enrollmentId:value.enrollmentId};
17
+ }
18
+ function exit(value){
19
+ const target=identity(value),clientIds=ids(value.clientIds),enrollmentIds=ids(value.enrollmentIds);
20
+ if(!clientIds.includes(target.clientId)||!enrollmentIds.includes(target.enrollmentId))fail('exit subgroup omits member');
21
+ const result={...target,projectExitDate:value.projectExitDate===null?null:instant(value.projectExitDate)};
22
+ for(const field of ['destination','destinationCode','reasonForLeaving','reasonForLeavingCode','exitSubgroupId']){
23
+ if(value[field]!==null&&(typeof value[field]!=='string'||!value[field]))fail('complete exit metadata required');
24
+ result[field]=value[field];
25
+ }
26
+ return {...result,clientIds,enrollmentIds};
27
+ }
28
+ function normalize(input){
29
+ const target=identity(input);
30
+ if(!positive(input.providerId)||!['HUD','VA'].includes(input.type)||!Array.isArray(input.cohort)||input.cohort.length<2)fail('program and complete household cohort required');
31
+ const entryDate=instant(input.entryDate),cohort=input.cohort.map(member=>({ ...identity(member),entryDate:instant(member.entryDate),exit:exit(member.exit)})).sort((a,b)=>a.clientId-b.clientId);
32
+ ids(cohort.map(m=>m.clientId));ids(cohort.map(m=>m.enrollmentId));
33
+ for(const member of cohort)if(member.exit.clientId!==member.clientId||member.exit.enrollmentId!==member.enrollmentId)fail('exit identity mismatch');
34
+ const member=cohort.find(m=>m.clientId===target.clientId&&m.enrollmentId===target.enrollmentId);
35
+ if(!member||member.exit.projectExitDate!==null||Date.parse(entryDate)<Date.parse(member.entryDate))fail('open target and non-decreasing entry date required');
36
+ return {...target,providerId:input.providerId,type:input.type,entryDate,cohort};
37
+ }
38
+
39
+ /** Date-only REST port, verified through fresh GWT reads. The caller must
40
+ * durably journal before save and reconcile uncertain responses. Never retries. */
41
+ export function createMemberEntryDatePort({api,enrollmentSession}){
42
+ apiScope(api, enrollmentSession);
43
+ if(typeof api.enrollments?.updateEntryAssessment!=='function'||typeof enrollmentSession?.readEnrollment!=='function')fail('REST API and authoritative enrollment reader required');
44
+ const inspect=async(input,after=false)=>{
45
+ const wantedMembers=input.cohort.map(identity),wantedExits=input.cohort.map(m=>m.exit);let alreadyPresent=false;
46
+ for(const member of input.cohort){
47
+ const snapshot=await enrollmentSession.readEnrollment(identity(member));
48
+ if(snapshot?.source!=='gwt-rpc'||snapshot.tenant!==api.tenant||snapshot.clientId!==member.clientId||snapshot.enrollmentId!==member.enrollmentId||snapshot.providerId!==input.providerId||snapshot.enrollmentType!==input.type)fail('authoritative enrollment context mismatch');
49
+ if(!Array.isArray(snapshot.members)||!Array.isArray(snapshot.exits))fail('complete enrollment membership and exits required');
50
+ const members=snapshot.members.map(identity).sort((a,b)=>a.clientId-b.clientId),exits=snapshot.exits.map(exit).sort((a,b)=>a.clientId-b.clientId);
51
+ if(!equal(members,wantedMembers)||!equal(exits,wantedExits))fail('cohort membership or exit metadata changed');
52
+ const actual=instant(snapshot.projectStartDate),isTarget=member.clientId===input.clientId;
53
+ if(isTarget){
54
+ alreadyPresent=actual===input.entryDate;
55
+ if(!alreadyPresent&&(after||actual!==member.entryDate))fail('target entry date changed unexpectedly');
56
+ if(!Array.isArray(snapshot.reviews)||snapshot.reviews.some(r=>!positive(r.reviewId)||typeof r.type!=='string'||!r.type||Date.parse(instant(r.reviewDate))<Date.parse(input.entryDate)))fail('target review precedes requested entry or is incomplete');
57
+ }else if(actual!==member.entryDate)fail('existing member entry date changed');
58
+ }
59
+ return alreadyPresent;
60
+ };
61
+ return {
62
+ async validateMemberEntryDate(raw){await inspect(normalize(raw));},
63
+ async saveMemberEntryDate(raw){
64
+ const input=normalize(raw);if(await inspect(input))return;
65
+ // Swagger makes all other fields optional. Omitting them avoids provider,
66
+ // type, assessment answer and assessment effective-date updates.
67
+ await api.enrollments.updateEntryAssessment({query:{enrollment_id:input.enrollmentId},body:{projectStartDate:input.entryDate}});
68
+ await inspect(input,true);
69
+ },
70
+ };
71
+ }