@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,37 @@
1
+ import type {Page} from 'playwright-core';
2
+ import type {UiMutationStore} from './index.mjs';
3
+ export interface IndependentNeedInput {
4
+ /** Stable upstream event ID. Reuse with identical intent and durable store after failures. */
5
+ key:string;clientId:number;providerId:number;serviceCodeId:number;
6
+ /** Timezone-qualified instant, precision to whole seconds. No REST/mobile offset correction. */
7
+ needDate:string;status:string;outcome:string;reasonUnmet?:string|null;note?:string;amount?:number|null;
8
+ }
9
+ export interface IndependentNeedValues {clientId:number;providerId:number;serviceCodeId:number;needDate:string;status:string;outcome:string;reasonUnmet:string|null;note:string;amount:number|null;}
10
+ export interface IndependentNeedRecord extends IndependentNeedValues {needId:number;serviceCount:number;}
11
+ export interface ServiceTypeOption {serviceCodeId:number;code:string;description:string;codeSetId:number;parentServiceCodeId:number|null;}
12
+ export interface ServiceTypeCatalog {
13
+ codeSets:{id:number;name:string}[];
14
+ /** Tenant-wide active assignable types. Not provider quicklists or service eligibility. */
15
+ types:ServiceTypeOption[];
16
+ }
17
+ export interface ServiceQuicklist {
18
+ providerId:number;types:ServiceTypeOption[];
19
+ needOptions:{statuses:{value:string;label:string}[];outcomes:{value:string;label:string}[];unmetReasons:{value:string;label:string}[]};
20
+ }
21
+ export interface IndependentNeedUi {
22
+ list(context:{clientId:number}):Promise<IndependentNeedRecord[]>;
23
+ /** Adapter must call beforeSave immediately before its one save, never retry the click. */
24
+ create(values:IndependentNeedValues,beforeSave:()=>Promise<void>):Promise<void>;
25
+ }
26
+ export interface NeedUi extends IndependentNeedUi {
27
+ readonly connection:import("./connection.mjs").UiConnection;
28
+ /** Both services and needs use this AIRS/service-code catalog; client supplies UI context only. */
29
+ listServiceTypes(context:{clientId:number}):Promise<ServiceTypeCatalog>;
30
+ /** Small provider/program quicklist read; does not enumerate the full tenant taxonomy. */
31
+ listServiceQuicklist(context:{clientId:number;providerId:number}):Promise<ServiceQuicklist>;
32
+ }
33
+ export interface IndependentNeedResult {key:string;need:IndependentNeedRecord;outcome:'verified'|'reconciled'|'already-present';}
34
+ export interface NeedRecorder {record(input:IndependentNeedInput):Promise<IndependentNeedResult>;}
35
+ export class NeedConflictError extends Error {readonly code:'NEED_CONFLICT';readonly reason:string;constructor(reason:string);}
36
+ export function createNeedRecorder(options:{store:UiMutationStore}&({ui:IndependentNeedUi;tenant:string}|{ui:IndependentNeedUi&{readonly connection:import("./connection.mjs").UiConnection};tenant?:string})):NeedRecorder;
37
+ export function createNeedUi(options:{page:Page}):NeedUi;
@@ -0,0 +1,68 @@
1
+ import {validateTenant} from './connection.mjs';
2
+ import {createHash} from 'node:crypto';
3
+ const hash=value=>createHash('sha256').update(JSON.stringify(value)).digest('hex');
4
+ const id=value=>{if(!Number.isSafeInteger(value)||value<1)throw Error('Positive integer ID required');return value;};
5
+ const text=(value,name,limit)=>{if(typeof value!=='string'||!value.trim()||value.length>limit||/[\0\r]/.test(value))throw Error(`Invalid ${name}`);return value;};
6
+ export class NeedConflictError extends Error {
7
+ constructor(reason){super(`Independent need conflict: ${reason}`);this.name='NeedConflictError';this.code='NEED_CONFLICT';this.reason=reason;}
8
+ }
9
+ /** Validate before opening a form or touching the durable journal. */
10
+ export function normalizeNeedInput(input){
11
+ if(!input||Object.keys(input).some(k=>!['key','clientId','providerId','serviceCodeId','needDate','status','outcome','reasonUnmet','note','amount'].includes(k)))throw Error('Unsupported independent need input');
12
+ const key=text(input.key,'source key',200),clientId=id(input.clientId),providerId=id(input.providerId),serviceCodeId=id(input.serviceCodeId);
13
+ const date=input.needDate;
14
+ if(typeof date!=='string'||!/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.000)?(?:Z|[+-]\d{2}:\d{2})$/.test(date)||!Number.isFinite(Date.parse(date))||new Date(date.slice(0,19)+'Z').toISOString().slice(0,19)!==date.slice(0,19))throw Error('Need date must be a valid timezone-qualified instant with whole seconds');
15
+ const status=text(input.status,'need status',100),outcome=text(input.outcome,'need outcome',100);
16
+ const reasonUnmet=input.reasonUnmet==null?null:text(input.reasonUnmet,'unmet reason',100);
17
+ if(reasonUnmet&&outcome!=='Not Met')throw Error('Unmet reason requires Not Met outcome');
18
+ const note=input.note??'';if(typeof note!=='string'||note.length>8000||/[\0\r]/.test(note)||note.includes('[sdk-need:'))throw Error('Invalid need note');
19
+ const amount=input.amount??null;if(amount!==null&&(!Number.isFinite(amount)||amount<0||Math.abs(amount*100-Math.round(amount*100))>0.000001))throw Error('Need amount must be nonnegative with at most two decimal places');
20
+ const keyHash=hash(key),marker=`[sdk-need:${keyHash}]`;
21
+ const values={clientId,providerId,serviceCodeId,needDate:new Date(date).toISOString(),status,outcome,reasonUnmet,note:note?`${note}\n${marker}`:marker,amount};
22
+ return {key,keyHash,marker,values,intentHash:hash(values)};
23
+ }
24
+ const validateRows=(rows,clientId)=>{
25
+ if(!Array.isArray(rows)||new Set(rows.map(n=>n.needId)).size!==rows.length)throw Error('Incomplete or duplicate need read');
26
+ for(const n of rows){id(n.needId);if(n.clientId!==clientId||typeof n.note!=='string'||!Number.isSafeInteger(n.serviceCount)||n.serviceCount<0)throw Error('Invalid need read');}
27
+ return rows;
28
+ };
29
+ const verify=(row,values)=>{
30
+ if(Object.entries(values).some(([key,value])=>row[key]!==value))throw new NeedConflictError('stored-fields-changed');
31
+ if(row.serviceCount!==0)throw new NeedConflictError('service-attached');
32
+ return row;
33
+ };
34
+ /** A fresh Add Need form, one save, and counted readback. Never creates a service. */
35
+ export function createNeedRecorder({ui,store,tenant=ui?.connection?.tenant}){
36
+ const tenantScope=ui?.connection?.scope??validateTenant(tenant);
37
+ if(ui?.connection&&ui.connection.tenant!==validateTenant(tenant))throw Error('Need UI tenant mismatch');
38
+ if(!ui?.list||!ui?.create||!store?.withLock||!store?.read||!store?.write)throw Error('Need UI and durable store required');
39
+ return {async record(input){
40
+ const n=normalizeNeedInput(input),scope=JSON.stringify([tenantScope,'independent-need',n.keyHash]);
41
+ return store.withLock(scope,async()=>{
42
+ const entries=await store.read(scope);
43
+ if(!Array.isArray(entries)||entries.length>1||entries.some(e=>e.version!==1||!['attempted','verified'].includes(e.stage)||typeof e.intentHash!=='string'||e.stage==='verified'&&(!Number.isSafeInteger(e.needId)||e.needId<1)))throw Error('Invalid independent need journal');
44
+ let entry=entries[0];
45
+ if(entry&&entry.intentHash!==n.intentHash)throw new NeedConflictError('intent-changed');
46
+ const locate=async()=>{
47
+ const rows=validateRows(await ui.list({clientId:n.values.clientId}),n.values.clientId),matches=rows.filter(r=>r.note.includes(n.marker));
48
+ if(matches.length>1)throw new NeedConflictError('duplicate-marker');
49
+ if(entry?.needId&&(!matches[0]||matches[0].needId!==entry.needId))throw new NeedConflictError('identity-changed');
50
+ return matches[0]?verify(matches[0],n.values):null;
51
+ };
52
+ let row=await locate();
53
+ if(row){await store.write(scope,[{version:1,intentHash:n.intentHash,stage:'verified',needId:row.needId}]);return {key:n.key,need:row,outcome:entry?.stage==='attempted'?'reconciled':'already-present'};}
54
+ if(entry)throw new NeedConflictError('unresolved-save');
55
+ let failure,attempted=false;
56
+ try{await ui.create(n.values,async()=>{
57
+ if(attempted)throw new NeedConflictError('second-save');
58
+ entry={version:1,intentHash:n.intentHash,stage:'attempted'};
59
+ await store.write(scope,[entry]);attempted=true;
60
+ });}catch(error){failure=error;}
61
+ if(!attempted)throw failure??Error('Need adapter did not journal its save');
62
+ row=await locate();
63
+ if(!row)throw failure??new NeedConflictError('unresolved-save');
64
+ await store.write(scope,[{version:1,intentHash:n.intentHash,stage:'verified',needId:row.needId}]);
65
+ return {key:n.key,need:row,outcome:failure?'reconciled':'verified'};
66
+ });
67
+ }};
68
+ }
@@ -0,0 +1,138 @@
1
+ import {pageConnection} from './connection.mjs';
2
+ import {decodeMappedResponse} from './gwt-mapped.mjs';
3
+ import {withPageLock} from './page-lock.mjs';
4
+ const id=n=>{if(!Number.isSafeInteger(n)||n<1)throw Error('Positive integer ID required');return n;};
5
+
6
+ /** Counted, ordered pages. Never interpret a failed, truncated, or changing read as absence. */
7
+ export async function collectNeedPages(read,key,limit=50000){
8
+ const rows=[],seen=new Set();let total;
9
+ for(let offset=0;offset<=limit;offset+=500){
10
+ const result=await read(offset,500);
11
+ if(!Number.isSafeInteger(result?.total)||result.total<0||result.total>limit||!Array.isArray(result.rows)||result.rows.length>500||total!==undefined&&result.total!==total)throw Error('Catalog/need count unavailable or changed');
12
+ total=result.total;
13
+ for(const row of result.rows){id(row[key]);if(seen.has(row[key]))throw Error('Duplicate catalog/need row');seen.add(row[key]);rows.push(row);}
14
+ if(rows.length===total)return rows;
15
+ if(result.rows.length!==500||rows.length>total)throw Error('Incomplete catalog/need read');
16
+ }
17
+ throw Error('Catalog/need safety limit exceeded');
18
+ }
19
+
20
+ /** UI adapter. Catalog reads are shared by services and independent needs. */
21
+ export function createNeedUi({page}){
22
+ const connection=pageConnection(page),base=connection.moduleBaseUrl;
23
+ let wire;
24
+ const guard=()=>{if(new URL(page.url()).origin+new URL(page.url()).pathname!==base+'index.html')throw Error('Configured application page required');};
25
+ const ready=()=>page.waitForFunction(()=>!document.body.innerText.includes('Processing...')&&!document.body.innerText.includes('Loading...'),null,{timeout:30000});
26
+ const byId=value=>page.locator(`[id="${value}"]`);
27
+ const open=async clientId=>{
28
+ id(clientId);guard();
29
+ if(await page.locator('[role=dialog]:visible').count()||await page.locator('[id$=".saveAndExit"]:visible,[id="EditNeedView.save"]:visible').count())throw Error('Close unfinished forms before need/catalog workflow');
30
+ wire=undefined;let pending;
31
+ const listener=request=>{if(request.url()===base+'service/MetaQueryService'&&request.postData()?.startsWith('7|0|')){
32
+ const policy=request.postData().split('|')[4];pending=request.allHeaders().then(h=>{wire={policy,headers:Object.fromEntries(['content-type','x-gwt-module-base','x-gwt-permutation'].map(k=>[k,h[k]]))};});
33
+ }};
34
+ page.on('request',listener);
35
+ try{
36
+ const visibleClient=(await page.locator('body').innerText()).match(/Client - \((\d+)\)/)?.[1];
37
+ if(visibleClient&&Number(visibleClient)!==clientId){
38
+ await page.getByRole('tab',{name:'Client Information',exact:true}).click({timeout:30000});await page.getByRole('tab',{name:'Summary',exact:true}).click({timeout:30000});await ready();
39
+ await page.getByRole('button',{name:'Exit',exact:true}).click({timeout:30000});await page.waitForURL(url=>url.hash==='#clientPointClientMain');await ready();
40
+ }
41
+ if(new URL(page.url()).hash!==`#loadClient;clientId=${clientId}`)await page.goto(base+`index.html#loadClient;clientId=${clientId}`);
42
+ await page.waitForFunction(c=>document.body.innerText.includes(`Client - (${c})`)&&!document.body.innerText.includes('Processing...'),clientId,{timeout:30000});
43
+ await page.getByRole('tab',{name:'Client Information',exact:true}).click({timeout:30000});await page.getByRole('tab',{name:'Summary',exact:true}).click({timeout:30000});await ready();
44
+ await page.getByRole('tab',{name:'Service Transactions',exact:true}).click({timeout:30000});await ready();await pending;
45
+ guard();if(!wire||await byId('formfield-password').isVisible())throw Error('Authenticated need read context unavailable');
46
+ }finally{page.off('request',listener);}
47
+ };
48
+ const mapped=async(query,offset=0,size=500)=>{
49
+ guard();if(!wire||!query.startsWith('SELECT ')||/[|\\\r\n]/.test(query))throw Error('Invalid need/catalog read');
50
+ const strings=[base,wire.policy,'com.bowmansystems.sp5.core.client.services.GenericService','getMappedResults','java.lang.String/2004016611','java.util.HashMap/1797211028','I','Z',`${query} LIMIT ${size},${offset}`];
51
+ const result=await page.evaluate(async w=>{const r=await fetch(w.url,{method:'POST',headers:w.headers,body:w.body,credentials:'same-origin',redirect:'error',signal:AbortSignal.timeout(30000)});return {status:r.status,body:await r.text()};},{url:base+'service/MetaQueryService',headers:wire.headers,body:['7','0',9,...strings,1,2,3,4,5,5,6,7,7,8,9,0,offset,size,1,''].join('|')});
52
+ if(result.status!==200)throw Error('Need/catalog read failed');return decodeMappedResponse(result.body,offset);
53
+ };
54
+ const all=(query,key)=>collectNeedPages((offset,size)=>mapped(query,offset,size),key);
55
+ const readNeeds=async clientId=>{
56
+ const rows=await all(`SELECT n.needId AS needId, n.clientId AS clientId, n.providerId AS providerId, n.codeId AS serviceCodeId, n.dateSet AS needDate, n.note AS note, n.amount AS amount, n.status.name AS status, n.outcome.name AS outcome, n.reasonUnmet.name AS reasonUnmet FROM SpNeed AS n WHERE n.active = true AND n.clientId = ${id(clientId)} ORDER BY n.needId`,'needId');
57
+ const services=await all(`SELECT s.needServiceId AS serviceId, s.needId AS needId FROM SpNeedService AS s WHERE s.active = true AND s.clientId = ${id(clientId)} ORDER BY s.needServiceId`,'serviceId');
58
+ return rows.map(n=>({...n,note:n.note??'',serviceCount:services.filter(s=>s.needId===n.needId).length}));
59
+ };
60
+ const providerRow=()=>page.getByText('Provider',{exact:true}).locator('xpath=ancestor::tr[.//*[@role="button"]][1]');
61
+ const addForm=async({clientId,providerId})=>{
62
+ if(providerId!==undefined)id(providerId);await open(clientId);await page.getByRole('button',{name:'Add Need',exact:true}).click({timeout:30000});await byId('ClientAddNeedView.saveAndExit').waitFor({timeout:30000});await ready();
63
+ if(providerId!==undefined&&!(await providerRow().innerText()).includes(`(${providerId})`)){
64
+ await providerRow().getByRole('button',{name:'Search',exact:true}).click({timeout:30000});
65
+ const dialog=page.getByRole('dialog');await dialog.getByText(/Showing \d+-\d+ of \d+/).waitFor({timeout:30000});await ready();
66
+ await dialog.getByLabel('Provider ID #',{exact:true}).fill(String(providerId),{timeout:30000});await dialog.getByLabel('Provider ID #',{exact:true}).press('Tab',{timeout:30000});
67
+ await dialog.getByRole('button',{name:'Submit',exact:true}).click({timeout:30000});await ready();
68
+ await byId(`SpProviderSearchPopup.setProvider-imageLink_${providerId}`).click({timeout:30000});await dialog.waitFor({state:'hidden',timeout:30000});await ready();
69
+ }
70
+ if(providerId!==undefined&&!(await providerRow().innerText()).includes(`(${providerId})`))throw Error('Need provider mismatch');
71
+ };
72
+ const needSelect=()=>page.locator('select:visible').filter({has:page.locator('option[value="-Select-"]')}).filter({hasNot:page.locator('option[value="Closed"],option[value="Fully Met"],option[value="All Services Full"]')});
73
+ const options=async label=>page.locator(`select[aria-label="${label}"]`).locator('option').evaluateAll(es=>es.filter(e=>e.value!=='-Select-').map(e=>({value:e.value,label:e.textContent})));
74
+ const lookup=async()=>{await byId('ClientAddNeedView.lookUp').click({timeout:30000});await byId('SpServiceCodeSearch.codeSets-listbox').waitFor({timeout:30000});await ready();};
75
+ const cancelLookup=async()=>{await byId('SpServiceCodeSearch.btnExit-button').click({timeout:30000});await byId('SpServiceCodeSearch.btnExit-button').waitFor({state:'hidden',timeout:30000});await ready();};
76
+ const cancel=async()=>{await byId('ClientAddNeedView.cancel').click({timeout:30000});await byId('ClientAddNeedView.cancel').waitFor({state:'hidden',timeout:30000});await ready();};
77
+ const readTypes=async filter=>{
78
+ const types=await all(`SELECT c.serviceCodeId AS serviceCodeId, c.code AS code, c.description AS description, c.serviceCodeTypeId AS codeSetId, c.parentServiceCodeId AS parentServiceCodeId FROM SpServiceCode AS c WHERE c.active = true AND c.assignable = true AND ${filter} AND NOT c.code LIKE 'Y%' AND (c.facet <> 'Modality/Delivery Format' OR c.facet IS NULL) ORDER BY c.serviceCodeId`,'serviceCodeId');
79
+ for(const t of types){id(t.codeSetId);if(typeof t.code!=='string'||!t.code||typeof t.description!=='string')throw Error('Invalid service code catalog');}
80
+ return types;
81
+ };
82
+ const catalog=async({clientId})=>{
83
+ await addForm({clientId});await lookup();
84
+ const codeSets=await byId('SpServiceCodeSearch.codeSets-listbox').locator('option').evaluateAll(es=>es.map(e=>({id:Number(e.value),name:e.textContent})));
85
+ if(!codeSets.length)throw Error('No accessible service code sets');codeSets.forEach(s=>{id(s.id);if(typeof s.name!=='string'||!s.name)throw Error('Invalid code set');});
86
+ const types=await readTypes(`c.serviceCodeTypeId IN (${codeSets.map(s=>s.id).join(',')})`);
87
+ if(types.some(t=>!codeSets.some(s=>s.id===t.codeSetId)))throw Error('Unexpected code set in catalog');
88
+ await cancelLookup();await cancel();return {codeSets,types};
89
+ };
90
+ const quicklist=async context=>{
91
+ id(context.providerId);await addForm(context);
92
+ const quickIds=await needSelect().locator('option').evaluateAll(es=>es.filter(e=>e.value!=='-Select-').map(e=>Number(e.value)));quickIds.forEach(id);
93
+ if(new Set(quickIds).size!==quickIds.length)throw Error('Duplicate provider quicklist entries');
94
+ const needOptions={statuses:await options('Need Status'),outcomes:await options('Outcome of Need'),unmetReasons:await options('If Need is Not Met, Reason')};
95
+ const types=quickIds.length?await readTypes(`c.serviceCodeId IN (${quickIds.join(',')})`):[];
96
+ if(types.length!==quickIds.length||quickIds.some(i=>!types.some(t=>t.serviceCodeId===i)))throw Error('Provider quicklist differs from active assignable types');
97
+ await cancel();return {providerId:context.providerId,types:quickIds.map(i=>types.find(t=>t.serviceCodeId===i)),needOptions};
98
+ };
99
+ const chooseCode=async serviceCodeId=>{
100
+ id(serviceCodeId);
101
+ // Cached choices must still be active/assignable at the time of the write.
102
+ const found=await mapped(`SELECT c.serviceCodeId AS serviceCodeId, c.code AS code, c.serviceCodeTypeId AS codeSetId FROM SpServiceCode AS c WHERE c.active = true AND c.assignable = true AND c.serviceCodeId = ${serviceCodeId}`);
103
+ if(found.total!==1||found.rows.length!==1||found.rows[0].serviceCodeId!==serviceCodeId)throw Error('Selected service type is no longer active/assignable');
104
+ if(await needSelect().locator(`option[value="${serviceCodeId}"]`).count()){await needSelect().selectOption(String(serviceCodeId),{timeout:30000});return;}
105
+ await lookup();
106
+ const sets=await byId('SpServiceCodeSearch.codeSets-listbox').locator('option').evaluateAll(es=>es.map(e=>Number(e.value)));
107
+ if(!sets.includes(found.rows[0].codeSetId))throw Error('Selected service type is not accessible');
108
+ const code=found.rows[0];await byId('SpServiceCodeSearch.codeSets-listbox').selectOption(String(code.codeSetId),{timeout:30000});
109
+ await byId('SpServiceCodeSearch.searchTerm-listbox').selectOption('Code',{timeout:30000});await byId('SpServiceCodeSearch.searchText-textbox').fill(code.code,{timeout:30000});
110
+ await byId('SpServiceCodeSearch.searchBtn-button').click({timeout:30000});await ready();
111
+ await byId(`SpServiceCodeSearch.selectLink-code-${serviceCodeId}`).click({timeout:30000});await byId('SpServiceCodeSearch.btnSubmit-button').click({timeout:30000});await byId('SpServiceCodeSearch.btnSubmit-button').waitFor({state:'hidden',timeout:30000});await ready();
112
+ if(await needSelect().inputValue()!==String(serviceCodeId))throw Error('Need code selection mismatch');
113
+ };
114
+ const fillDate=async instant=>{
115
+ const parts=await page.evaluate(value=>{const d=new Date(value),copy=new Date(d.getFullYear(),d.getMonth(),d.getDate(),d.getHours(),d.getMinutes(),d.getSeconds());if(copy.getTime()!==d.getTime())throw Error('Ambiguous local need time');return {Month:String(d.getMonth()+1),Day:String(d.getDate()),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'};},instant);
116
+ for(const [part,value]of Object.entries(parts)){const field=page.getByLabel('Date of Need '+part,{exact:true});if(['Month','Day','Year'].includes(part)){await field.fill(value,{timeout:30000});await field.press('Tab',{timeout:30000});}else await field.selectOption(value,{timeout:30000});}
117
+ };
118
+ return {connection,
119
+ list:({clientId})=>withPageLock(page,async()=>{await open(clientId);return readNeeds(clientId);}),
120
+ listServiceTypes:context=>withPageLock(page,()=>catalog(context)),
121
+ listServiceQuicklist:context=>withPageLock(page,()=>quicklist(context)),
122
+ create:(values,beforeSave)=>withPageLock(page,async()=>{
123
+ if(typeof beforeSave!=='function')throw Error('Durable beforeSave hook required');
124
+ await addForm(values);await chooseCode(values.serviceCodeId);
125
+ // A single independent need, never implicitly include household checkboxes.
126
+ const checked=page.locator('input[type=checkbox]:visible:checked');
127
+ if(await checked.count())throw Error('Unexpected checked household members; refusing a group need');
128
+ await fillDate(values.needDate);await page.locator('select[aria-label="Need Status"]').selectOption({label:values.status},{timeout:30000});
129
+ await page.locator('select[aria-label="Outcome of Need"]').selectOption({label:values.outcome},{timeout:30000});
130
+ if(values.reasonUnmet)await page.locator('select[aria-label="If Need is Not Met, Reason"]').selectOption({label:values.reasonUnmet},{timeout:30000});
131
+ await page.locator('input[aria-label="Amount if Financial"]').fill(values.amount===null?'':String(values.amount),{timeout:30000});
132
+ await page.locator('textarea[aria-label="Notes"]').fill(values.note,{timeout:30000});
133
+ if(!(await providerRow().innerText()).includes(`(${values.providerId})`)||await needSelect().inputValue()!==String(values.serviceCodeId))throw Error('Need context changed before save');
134
+ await beforeSave();await byId('ClientAddNeedView.saveAndExit').click({timeout:30000});
135
+ await byId('ClientAddNeedView.saveAndExit').waitFor({state:'hidden',timeout:30000});await ready();
136
+ }),
137
+ };
138
+ }
@@ -0,0 +1,11 @@
1
+ import {sessionAuthentication} from './session-authentication.mjs';
2
+ const locks=new WeakMap();
3
+ export async function withPageLock(page,work,{authentication=true}={}){
4
+ const previous=locks.get(page)??Promise.resolve();let release;
5
+ const current=new Promise(resolve=>{release=resolve;});locks.set(page,current);await previous;
6
+ try{
7
+ const auth=authentication?sessionAuthentication(page):undefined;
8
+ await auth?.before();
9
+ try{return await work();}catch(error){if(auth)return await auth.afterFailure(error);throw error;}
10
+ }finally{release();if(locks.get(page)===current)locks.delete(page);}
11
+ }
@@ -0,0 +1,49 @@
1
+ // Internal UI steps. The login orchestrator owns the account lock and write journal.
2
+ export async function waitForLogin(page,timeoutMs){
3
+ await page.waitForFunction(()=>{
4
+ const visible=e=>e&&e.getBoundingClientRect().width>0;
5
+ const expired=document.body.innerText.includes('Password has expired!')&&visible(document.getElementById('formfield-password2'))&&visible(document.getElementById('formfield-password'));
6
+ const dashboard=location.hash==='#mainPage'&&!visible(document.getElementById('formfield-password'))&&document.body.innerText.includes('Home Page Dashboard')&&!document.body.innerText.includes('Processing...')&&!document.body.innerText.includes('Loading Modules');
7
+ return expired||dashboard;
8
+ },null,{timeout:timeoutMs});
9
+ return await page.locator('[id="formfield-password2"]').isVisible()?'expired':'dashboard';
10
+ }
11
+ export async function submitExpiredPassword(page,password,beforeSubmit,timeoutMs){
12
+ if(await waitForLogin(page,timeoutMs)!=='expired')throw Error('Expected expired-password form');
13
+ await page.locator('[id="formfield-password2"]').fill(password);
14
+ await page.locator('[id="formfield-password"]').fill(password);
15
+ await beforeSubmit();
16
+ await page.locator('[id="LoginView.fbtn_submit"]').click();
17
+ // Waiting for a dashboard prevents interpreting the unchanged reset form as success.
18
+ await page.waitForFunction(()=>location.hash==='#mainPage'&&!document.body.innerText.includes('Password has expired!'),null,{timeout:timeoutMs});
19
+ if(await waitForLogin(page,timeoutMs)!=='dashboard')throw Error('Temporary password was not confirmed');
20
+ }
21
+ export async function restoreOriginalPassword(page,temporaryPassword,originalPassword,beforeSubmit,timeoutMs){
22
+ await page.locator('[title^="Edit User Profile for "]').click();
23
+ await page.locator('[id="UserProfilePopup.changePassword-button"]').click();
24
+ const save=page.locator('[id="UserProfileChangePasswordPopup.save-button"]');
25
+ await save.waitFor({state:'visible',timeout:timeoutMs});
26
+ const dialog=save.locator('xpath=ancestor::*[@role="dialog"][1]');
27
+ for(const [name,value]of [['Current Password',temporaryPassword],['New Password',originalPassword],['Re-enter New Password',originalPassword]]){
28
+ const field=dialog.getByLabel(name,{exact:true});
29
+ if(await field.count()!==1||await field.getAttribute('maxlength')!=='50')throw Error('Password settings form changed');
30
+ await field.fill(value);
31
+ }
32
+ await beforeSubmit();
33
+ await save.click();
34
+ await save.waitFor({state:'hidden',timeout:timeoutMs});
35
+ // The login orchestrator verifies the original password in a fresh context.
36
+ }
37
+
38
+ /** A separate read-only page distinguishes stale-session HTTP 500s from real server errors. */
39
+ export async function probeAuthentication(context,moduleUrl,timeoutMs){
40
+ const page=await context.newPage();
41
+ try{
42
+ await page.goto(moduleUrl+'index.html',{waitUntil:'domcontentloaded',timeout:timeoutMs});
43
+ await page.waitForFunction(()=>{
44
+ const password=document.getElementById('formfield-password');
45
+ return password&&password.getBoundingClientRect().width>0||location.hash==='#mainPage'&&document.body.innerText.includes('Home Page Dashboard');
46
+ },null,{timeout:timeoutMs});
47
+ return await page.locator('[id="formfield-password"]').isVisible();
48
+ }finally{await page.close();}
49
+ }
@@ -0,0 +1,196 @@
1
+ import {createHash,randomUUID} from 'node:crypto';
2
+ import {GwtEnrollmentHelpers} from '../dist/esm/index.js';
3
+ import {decodeMappedResponse} from './gwt-mapped.mjs';
4
+
5
+ import {withPageLock} from './page-lock.mjs';
6
+ import {sessionGeneration} from './session-authentication.mjs';
7
+ import {resolveUiConnection} from './connection.mjs';
8
+ const rpcInterface='com.bowmansystems.sp5.core.client.services.GenericService';
9
+ const id=n=>{if(!Number.isSafeInteger(n)||n<=0)throw Error('Positive integer ID required');return n;};
10
+ const literal=value=>{if(Number.isSafeInteger(value))return String(value);if(typeof value==='string'&&/^[a-zA-Z0-9_-]+$/.test(value))return `'${value}'`;throw Error('Unsupported group identifier');};
11
+ const digest=value=>createHash('sha256').update(value).digest('hex');
12
+ const control=(page,value)=>page.locator(`[id="${value}"]:visible`);
13
+
14
+ /** Own the supplied page exclusively. Keep the browser context alive; managed sessions reauthenticate only between operations.
15
+ * UI selectors and wire signatures are experimental and fail closed when the application changes. */
16
+ export function createPlaywrightEnrollmentHelpers({page,store,tenant,moduleBaseUrl}) {
17
+ const session=createPlaywrightGwtSession({page,tenant,moduleBaseUrl});
18
+ // The durable store serializes enrollment writers across processes. This lock additionally
19
+ // serializes the ENTIRE operation across all helper instances sharing one browser page.
20
+ const lockedStore={read:scope=>store.read(scope),write:(scope,records)=>store.write(scope,records),withLock:(scope,work)=>withPageLock(page,()=>store.withLock(scope,work))};
21
+ return new GwtEnrollmentHelpers({tenant,moduleBaseUrl,session,store:lockedStore,maxPayloadAgeMs:60000});
22
+ }
23
+
24
+ /** Low-level adapter. Prefer the factory above, which also locks the page. */
25
+ export function createPlaywrightGwtSession({page,tenant,moduleBaseUrl}) {
26
+ const base=new URL(moduleBaseUrl);
27
+ if(base.protocol!=='https:'||base.username||base.password||base.search||base.hash||!/^\w[\w-]*$/.test(tenant)||base.pathname!==`/${tenant}/com.bowmansystems.sp5.core.ServicePoint/`)throw Error('Invalid GWT module URL');
28
+ page.setDefaultTimeout(30000);
29
+ const endpoint=moduleBaseUrl+'service/ClientService';
30
+ let sessionId=randomUUID(),generation=sessionGeneration(page),wire,pending;
31
+ const checkGeneration=()=>{
32
+ const current=sessionGeneration(page);
33
+ if(current!==generation){generation=current;sessionId=randomUUID();wire=undefined;pending=undefined;}
34
+ };
35
+ const ready=()=>page.waitForFunction(()=>!document.body.innerText.includes('Processing...')&&!document.body.innerText.includes('Loading...'),null,{timeout:30000});
36
+ const guard=async()=>{
37
+ checkGeneration();
38
+ const u=new URL(page.url());if(u.origin!==base.origin||u.pathname!==base.pathname+'index.html'||await control(page,'formfield-password').count())throw Error('Authenticated page unavailable');
39
+ };
40
+ const closeDialogs=async()=>{
41
+ for(let i=0;i<12;i++){
42
+ let closed=false;
43
+ for(const name of ['AddAdditionalHouseholdMembersPopup.cancelBtn','ModifyReviewPopup.cancel-button','EntryDataPopup.cancel-button','ExitDataPopup.cancel-button','EntryExitReviewClientCountPopup.btnExit','EntryExitClientCountPopup.btnExit','EntryExitAssessment.btnExit-button','EntryExitReviewListPopup.btnExit']){
44
+ const c=control(page,name);if(await c.count()){await c.last().click();await ready();closed=true;break;}
45
+ }
46
+ if(!closed)return;
47
+ }
48
+ throw Error('Could not close enrollment dialogs');
49
+ };
50
+ const openTarget=async(target)=>{
51
+ id(target.clientId);id(target.enrollmentId);await guard();await closeDialogs();
52
+ const clientHeading=page.getByText(new RegExp(`^Client - \\(${target.clientId}\\)`));
53
+ if(!await clientHeading.isVisible()){
54
+ const switcher=page.locator('select:visible').filter({has:page.locator(`option[value="${target.clientId}"]`)});
55
+ if(await switcher.count()===1&&await control(page,'ClientMainView.householdSubmitButton').isVisible()){
56
+ await switcher.selectOption(String(target.clientId));await control(page,'ClientMainView.householdSubmitButton').click();
57
+ }else{
58
+ // A hash alone is not proof that GWT switched the displayed client.
59
+ if(new URL(page.url()).hash===`#loadClient;clientId=${target.clientId}`)throw Error('Client navigation did not load the requested client');
60
+ await page.goto(moduleBaseUrl+`index.html#loadClient;clientId=${target.clientId}`);
61
+ }
62
+ await clientHeading.waitFor();await ready();
63
+ }
64
+ await page.getByRole('tab',{name:'Summary',exact:true}).click();await ready();
65
+ await page.getByRole('tab',{name:'Entry / Exit',exact:true}).click();await ready();
66
+ const edit=control(page,`ClientEntryExitTab.editEntry-imageLink_${target.enrollmentId}`);
67
+ await edit.waitFor();
68
+ const response=page.waitForResponse(r=>r.url()===endpoint&&r.request().postData()?.includes('|getEntryExit|')&&r.request().postData()?.endsWith(`|${target.enrollmentId}|`));
69
+ await edit.click();const r=await response,body=await r.text();
70
+ if(r.status()!==200||!body.startsWith('//OK['))throw Error('Entry read failed');
71
+ const request=r.request(),tokens=request.postData().split('|'),headers=await request.allHeaders();
72
+ wire={policy:tokens[4],headers:Object.fromEntries(['content-type','x-gwt-module-base','x-gwt-permutation'].map(k=>[k,headers[k]]))};
73
+ await control(page,'EntryDataPopup.cancel-button').waitFor();await ready();
74
+ if(!(await page.locator('body').innerText()).includes(`Edit Project Start Data - (${target.clientId})`))throw Error('Entry client mismatch');
75
+ return body;
76
+ };
77
+ const post=async request=>{
78
+ await guard();
79
+ return page.evaluate(async({url,headers,body})=>{
80
+ const controller=new AbortController(),timer=setTimeout(()=>controller.abort(),30000);
81
+ try{const r=await fetch(url,{method:'POST',headers,body,credentials:'same-origin',redirect:'error',signal:controller.signal});return {status:r.status,body:await r.text()};}finally{clearTimeout(timer);}
82
+ },request);
83
+ };
84
+ const mapped=async query=>{
85
+ if(!wire||!/^SELECT /.test(query)||/[|\\\r\n]/.test(query))throw Error('Invalid mapped read');
86
+ const strings=[moduleBaseUrl,wire.policy,rpcInterface,'getMappedResults','java.lang.String/2004016611','java.util.HashMap/1797211028','I','Z',query+' LIMIT 1001,0'];
87
+ const body=['7','0',strings.length,...strings,1,2,3,4,5,5,6,7,7,8,9,0,0,1001,1,''].join('|');
88
+ const response=await post({url:moduleBaseUrl+'service/MetaQueryService',headers:wire.headers,body});if(response.status!==200)throw Error('Mapped read failed');
89
+ const data=decodeMappedResponse(response.body);if(data.total!==data.rows.length||data.total>1000)throw Error('Incomplete enrollment list');return data.rows;
90
+ };
91
+ const householdIds=async target=>{
92
+ const link=page.getByText('Include Additional Household Members',{exact:true});
93
+ if(await link.isVisible()){
94
+ await link.click();await control(page,'AddAdditionalHouseholdMembersPopup.saveBtn').waitFor();await ready();
95
+ }
96
+ const ids=await page.locator('[id^="HouseholdsCheckboxTree.householdCheckbox."]:visible').evaluateAll(es=>es.map(e=>Number(e.id.match(/\.client-(\d+)$/)?.[1])));
97
+ if(ids.some(n=>!Number.isSafeInteger(n)||n<=0))throw Error('Invalid household tree');
98
+ await closeDialogs();return [...new Set([target.clientId,...ids])].sort((a,b)=>a-b);
99
+ };
100
+ const readEnrollment=async target=>{
101
+ const model=await openTarget(target),householdClientIds=await householdIds(target);
102
+ const entries=await mapped(`SELECT e.entryExitId AS enrollmentId, e.clientId AS clientId, e.entryDate AS projectStartDate, e.groupId AS groupId, e.providerId AS providerId, e.typeEntryExit.name AS enrollmentType FROM SpEntryExit AS e WHERE e.active = TRUE AND e.entryExitId = ${id(target.enrollmentId)} AND e.clientId = ${id(target.clientId)}`);
103
+ if(entries.length!==1)throw Error('Enrollment missing');const entry=entries[0];
104
+ if(entry.enrollmentId!==target.enrollmentId||entry.clientId!==target.clientId||!Number.isSafeInteger(entry.providerId)||entry.providerId<=0||typeof entry.enrollmentType!=='string'||!entry.enrollmentType.trim())throw Error('Enrollment context incomplete');
105
+ const members=entry.groupId===null?[{clientId:target.clientId,enrollmentId:target.enrollmentId}]:await mapped(`SELECT e.entryExitId AS enrollmentId, e.clientId AS clientId FROM SpEntryExit AS e WHERE e.active = TRUE AND e.client.active = TRUE AND e.groupId = ${literal(entry.groupId)}`);
106
+ members.sort((a,b)=>a.clientId-b.clientId);
107
+ const exitRows=await mapped(`SELECT e.entryExitId AS enrollmentId, e.clientId AS clientId, e.exitDate AS projectExitDate, e.destinationValue.name AS destination, e.destinationValue.code AS destinationCode, e.reasonLeavingValue.name AS reasonForLeaving, e.reasonLeavingValue.code AS reasonForLeavingCode, e.exitSubgroupId AS exitSubgroupId FROM SpEntryExit AS e WHERE e.active = TRUE AND e.client.active = TRUE AND ${entry.groupId===null?`e.entryExitId = ${id(target.enrollmentId)}`:`e.groupId = ${literal(entry.groupId)}`}`);
108
+ if(exitRows.length!==members.length||new Set(exitRows.map(r=>r.enrollmentId)).size!==exitRows.length||exitRows.some(r=>!members.some(m=>m.clientId===r.clientId&&m.enrollmentId===r.enrollmentId)))throw Error('Enrollment exit list incomplete');
109
+ const exitGroups=new Map(),exits=[];
110
+ for(const row of exitRows){
111
+ if(row.projectExitDate!==null&&(typeof row.projectExitDate!=='string'||!Number.isFinite(Date.parse(row.projectExitDate))))throw Error('Enrollment exit date invalid');
112
+ for(const field of ['destination','destinationCode','reasonForLeaving','reasonForLeavingCode'])if(row[field]!==null&&(typeof row[field]!=='string'||!row[field]))throw Error('Enrollment exit value invalid');
113
+ if(row.exitSubgroupId!==null&&(typeof row.exitSubgroupId!=='string'||!row.exitSubgroupId))throw Error('Enrollment exit subgroup invalid');
114
+ let group=[{clientId:row.clientId,enrollmentId:row.enrollmentId}];
115
+ if(row.exitSubgroupId!==null){
116
+ if(!exitGroups.has(row.exitSubgroupId))exitGroups.set(row.exitSubgroupId,await mapped(`SELECT e.clientId AS clientId, e.entryExitId AS enrollmentId FROM SpEntryExit AS e WHERE e.active = TRUE AND e.client.active = TRUE AND e.exitSubgroupId = ${literal(row.exitSubgroupId)}`));
117
+ group=exitGroups.get(row.exitSubgroupId);
118
+ if(!group.some(m=>m.clientId===row.clientId&&m.enrollmentId===row.enrollmentId)||new Set(group.map(m=>m.clientId)).size!==group.length||new Set(group.map(m=>m.enrollmentId)).size!==group.length)throw Error('Enrollment exit subgroup incomplete');
119
+ }
120
+ exits.push({...row,clientIds:group.map(m=>id(m.clientId)).sort((a,b)=>a-b),enrollmentIds:group.map(m=>id(m.enrollmentId)).sort((a,b)=>a-b)});
121
+ }
122
+ exits.sort((a,b)=>a.clientId-b.clientId);
123
+ const rows=await mapped(`SELECT r.entryExitReviewId AS reviewId, r.subgroupId AS subgroupId, r.reviewDate AS reviewDate, r.reviewType.name AS type FROM SpEntryExitReview AS r WHERE r.active = TRUE AND r.entryExitId = ${id(target.enrollmentId)} AND r.pointInTimeType.code = 'EEPOINTINTIME_INTERIM'`);
124
+ const reviews=[];
125
+ for(const row of rows){
126
+ const clients=row.subgroupId===null?[{clientId:target.clientId}]:await mapped(`SELECT r.entryExit.clientId AS clientId FROM SpEntryExitReview AS r WHERE r.active = TRUE AND r.entryExit.active = TRUE AND r.entryExit.client.active = TRUE AND r.subgroupId = ${literal(row.subgroupId)}`);
127
+ reviews.push({reviewId:row.reviewId,type:row.type,reviewDate:row.reviewDate,clientIds:clients.map(c=>c.clientId).sort((a,b)=>a-b)});
128
+ }
129
+ reviews.sort((a,b)=>a.reviewId-b.reviewId);
130
+ const data={source:'gwt-rpc',tenant,sessionId,...target,projectStartDate:entry.projectStartDate,providerId:entry.providerId,enrollmentType:entry.enrollmentType,householdClientIds,members,exits,reviews};
131
+ return {...data,revision:digest(model+JSON.stringify({...data,sessionId:undefined}))};
132
+ };
133
+ const chooseMembers=async(desired,implicitClientId,dialogButton)=>{
134
+ const surface=control(page,dialogButton).locator('xpath=ancestor::*[@role="dialog"][1]');
135
+ if(await surface.count()!==1)throw Error('Active household dialog unavailable');
136
+ const boxes=surface.locator('[id^="HouseholdsCheckboxTree.householdCheckbox."]:visible input');
137
+ // Clients without a household have no checkbox tree. Their review dialog
138
+ // binds the sole client implicitly and emits the single-client RPC.
139
+ if(await boxes.count()===0&&implicitClientId!==undefined){
140
+ if(desired.length!==1||desired[0]!==implicitClientId||!(await page.locator('body').innerText()).includes(`Add Interim Review - (${implicitClientId})`))throw Error('Implicit review client mismatch');
141
+ return true;
142
+ }
143
+ for(let i=0;i<await boxes.count();i++){
144
+ const box=boxes.nth(i),clientId=await box.evaluate(e=>Number(e.parentElement.id.match(/\.client-(\d+)$/)?.[1]));
145
+ if(await box.isEnabled())await box.setChecked(desired.includes(clientId));
146
+ if((await box.isChecked())!==desired.includes(clientId))throw Error('Unexpected included household member');
147
+ }
148
+ const selected=await surface.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));
149
+ if(JSON.stringify(selected)!==JSON.stringify([...desired].sort((a,b)=>a-b)))throw Error('Household selection incomplete');
150
+ return false;
151
+ };
152
+ const dateFields=async label=>{
153
+ const fields={};for(const part of ['Month','Day','Year','Hour','Minute','Second','AM/PM'])fields[part]=await page.getByLabel(`${label} ${part}`,{exact:true}).inputValue();return fields;
154
+ };
155
+ const prepare=async(intent,snapshot)=>{
156
+ checkGeneration();if(snapshot.sessionId!==sessionId)throw Error('Session mismatch');pending=undefined;
157
+ await openTarget(intent);const entryFields=await dateFields('Project Start Date');
158
+ let save,singleClientReview=false;
159
+ if(intent.kind==='add-household-members'){
160
+ await page.getByText('Include Additional Household Members',{exact:true}).click();await control(page,'AddAdditionalHouseholdMembersPopup.saveBtn').waitFor();await ready();
161
+ await chooseMembers([...new Set([...snapshot.members.map(m=>m.clientId),...intent.memberClientIds])],undefined,'AddAdditionalHouseholdMembersPopup.saveBtn');
162
+ await control(page,'AddAdditionalHouseholdMembersPopup.saveBtn').click();
163
+ // Continue starts a second asynchronous household-tree render. A visible Save button is insufficient.
164
+ await page.getByRole('checkbox',{name:new RegExp(`^\\(${intent.memberClientIds[0]}\\)`)}).waitFor();await ready();
165
+ // Existing members remain linked when unchecked here; these boxes choose
166
+ // whose entry data is updated. Never copy the anchor date to other members.
167
+ await chooseMembers([...new Set([intent.clientId,...intent.memberClientIds])],undefined,'EntryDataPopup.saveAndContinue-button');
168
+ if(JSON.stringify(entryFields)!==JSON.stringify(await dateFields('Project Start Date')))throw Error('Entry date changed');
169
+ save=control(page,'EntryDataPopup.saveAndContinue-button');
170
+ }else{
171
+ await closeDialogs();const row=control(page,`ClientEntryExitTab.editEntry-imageLink_${intent.enrollmentId}`).locator('xpath=ancestor::tr');
172
+ await row.locator('[title^="Interim Reviews:"]').click();await control(page,'EntryExitReviewListPopup.addReviewButton').waitFor();await ready();await control(page,'EntryExitReviewListPopup.addReviewButton').click();await control(page,'ModifyReviewPopup.saveAndContinue-button').waitFor();await ready();
173
+ const soleClient=snapshot.members.length===1&&snapshot.members[0].clientId===intent.clientId&&snapshot.householdClientIds.length===1&&snapshot.householdClientIds[0]===intent.clientId;
174
+ singleClientReview=await chooseMembers(intent.clientIds,soleClient?intent.clientId:undefined,'ModifyReviewPopup.saveAndContinue-button');await page.getByLabel('Interim Review Type',{exact:true}).selectOption({label:'Update'});
175
+ const fields=await page.evaluate(value=>{const d=new Date(value);if(d.getMilliseconds())throw Error('UI supports whole seconds only');const local=new Date(d.getFullYear(),d.getMonth(),d.getDate(),d.getHours(),d.getMinutes(),d.getSeconds());if(+local!==+d)throw Error('Ambiguous local review 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'};},intent.reviewDate);
176
+ for(const [part,value]of Object.entries(fields)){const c=page.getByLabel(`Review Date ${part}`,{exact:true});if(['Month','Day','Year'].includes(part))await c.fill(value);else await c.selectOption({label:value});}
177
+ if(JSON.stringify(fields)!==JSON.stringify(await dateFields('Review Date')))throw Error('Review date mismatch');
178
+ save=control(page,'ModifyReviewPopup.saveAndContinue-button');
179
+ }
180
+ const expected=intent.kind==='add-household-members'?'updateEntryExitWithHouseholdMembers':singleClientReview?'createEntryExitReview':'createEntryExitReviewWithHouseholdMembers';
181
+ let resolveCapture;const captured=new Promise(resolve=>{resolveCapture=resolve;});
182
+ const handler=async route=>{
183
+ const r=route.request(),body=r.postData()??'';
184
+ // Block EVERY request while pressing Save. No unexpected RPC can leak through the capture step.
185
+ await route.abort();const tokens=body.split('|');
186
+ if(r.url()===endpoint&&tokens[6]===expected){const h=await r.allHeaders();resolveCapture({intent:structuredClone(intent),sessionId,revision:snapshot.revision,capturedAt:Date.now(),url:endpoint,body,headers:Object.fromEntries(['content-type','x-gwt-module-base','x-gwt-permutation'].map(k=>[k,h[k]]))});}
187
+ else resolveCapture(null);
188
+ };
189
+ await page.route('**/service/**',handler);let timer;
190
+ try{await save.click();const result=await Promise.race([captured,new Promise(resolve=>{timer=setTimeout(()=>resolve(null),15000);})]);if(!result)throw Error('Expected mutation was not captured');pending=digest(JSON.stringify({url:result.url,headers:result.headers,body:result.body}));return result;}
191
+ finally{clearTimeout(timer);await page.unroute('**/service/**',handler);await ready();await closeDialogs();}
192
+ };
193
+ return {connection:resolveUiConnection({tenant,moduleBaseUrl}),readEnrollment,prepare,async send(request){
194
+ checkGeneration();const fingerprint=digest(JSON.stringify(request));if(!pending||fingerprint!==pending)throw Error('Only the newly prepared request can be sent once');pending=undefined;return post(request);
195
+ }};
196
+ }
@@ -0,0 +1,22 @@
1
+ import type {Page} from 'playwright-core';
2
+ export interface AssessmentCatalogItem { assessmentId:number; name:string; code:string }
3
+ export interface ProviderAssessmentItem extends AssessmentCatalogItem { assignedToAssessmentTab:boolean }
4
+ export interface AssessmentDisplayDefault {
5
+ location:string;
6
+ label:string;
7
+ allowMultiple:boolean|null;
8
+ assessmentName:string|null;
9
+ /** Unresolved includes absent/ambiguous names in the active published provider catalog. */
10
+ resolution:'unset'|'resolved'|'unresolved';
11
+ assessment:ProviderAssessmentItem|null;
12
+ }
13
+ export interface ProgramDiscovery {
14
+ readonly connection?:import("./connection.mjs").UiConnection;
15
+ listAssessments():Promise<{source:'admin-catalog';retrievedAt:string;assessments:AssessmentCatalogItem[]}>;
16
+ getProviderAssessments(input:{providerId:number}):Promise<{
17
+ source:'provider-display-settings';providerId:number;retrievedAt:string;
18
+ assessments:ProviderAssessmentItem[];defaults:AssessmentDisplayDefault[];
19
+ }>;
20
+ }
21
+ /** Requires an authenticated, dedicated application page and administrative read access. No Save is clicked. */
22
+ export function createProgramDiscovery(input:{page:Page}):ProgramDiscovery;