@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,80 @@
1
+ import {pageConnection} from './connection.mjs';
2
+ import {decodeMappedResponse} from './gwt-mapped.mjs';
3
+ import {withPageLock} from './page-lock.mjs';
4
+
5
+ const positiveId=value=>{if(!Number.isSafeInteger(value)||value<1)throw Error('Positive provider ID required');return value;};
6
+
7
+ /** Read-only catalog adapter. Requires Provider Admin / Assessment Admin access.
8
+ * Use a dedicated page: navigation leaves any currently displayed editor. No Save is clicked.
9
+ * Available/assigned assessments and default enrollment instruments are different settings. */
10
+ export function createProgramDiscovery({page}) {
11
+ const connection=pageConnection(page),base=connection.moduleBaseUrl;
12
+ let wire;
13
+ const guard=()=>{const u=new URL(page.url());if(u.origin+u.pathname!==base+'index.html')throw Error('Configured application page required');};
14
+ const ready=async()=>{
15
+ await page.waitForFunction(()=>!document.body.innerText.includes('Processing...')&&!document.body.innerText.includes('Loading...'),null,{timeout:30000});
16
+ guard();if(await page.locator('[id="formfield-password"]:visible').count())throw Error('Session expired');
17
+ };
18
+ const initialize=async()=>{
19
+ guard();if(await page.locator('[role=dialog]:visible').count())throw Error('Close dialogs before catalog discovery');
20
+ let pending;
21
+ const listener=request=>{
22
+ if(request.url()===base+'service/MetaQueryService'&&request.postData()?.includes('|getMappedResults|')){
23
+ const policy=request.postData().split('|')[4];
24
+ pending=request.allHeaders().then(h=>{wire={policy,headers:Object.fromEntries(['content-type','x-gwt-module-base','x-gwt-permutation'].map(k=>[k,h[k]]))};});
25
+ }
26
+ };
27
+ wire=undefined;page.on('request',listener);
28
+ try{
29
+ await page.goto(base+'index.html#adminAssessments');await ready();
30
+ await page.getByRole('button',{name:'Search',exact:true}).click();await ready();await pending;
31
+ if(!wire?.policy||Object.values(wire.headers).some(v=>!v))throw Error('Assessment catalog permission or session unavailable');
32
+ }finally{page.off('request',listener);}
33
+ };
34
+ const mapped=async query=>{
35
+ guard();if(!wire)throw Error('Catalog session unavailable');
36
+ const rows=[],ids=new Set();let total;
37
+ for(let offset=0;offset<10000;offset+=100){
38
+ 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 100,${offset}`];
39
+ const body=['7','0',strings.length,...strings,1,2,3,4,5,5,6,7,7,8,9,0,0,100,1,''].join('|');
40
+ const r=await page.evaluate(async({url,headers,body})=>{const r=await fetch(url,{method:'POST',headers,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});
41
+ if(r.status!==200)throw Error('Assessment catalog read failed');
42
+ const result=decodeMappedResponse(r.body);
43
+ if(total!==undefined&&total!==result.total)throw Error('Assessment catalog changed during pagination');
44
+ total=result.total;
45
+ if(total>10000||result.rows.length>100||result.rows.length!==Math.min(100,total-offset))throw Error('Assessment catalog exceeds 10000 rows or is incomplete');
46
+ for(const row of result.rows){
47
+ if(!Number.isSafeInteger(row.assessmentId)||row.assessmentId<1||ids.has(row.assessmentId)||typeof row.name!=='string'||!row.name.trim()||typeof row.code!=='string'||!row.code.trim())throw Error('Invalid or duplicate assessment catalog row');
48
+ ids.add(row.assessmentId);rows.push(row);
49
+ }
50
+ if(rows.length===total)return rows;
51
+ }
52
+ throw Error('Assessment catalog pagination incomplete');
53
+ };
54
+ const listAssessments=()=>withPageLock(page,async()=>{
55
+ await initialize();
56
+ const assessments=await mapped("SELECT a.assessmentId AS assessmentId, a.name AS name, a.code AS code FROM DaAssessment AS a WHERE a.active = true AND a.hidden = false AND a.published = true AND a.assessmentTypeId = 1 AND a.code <> 'CLIENTDEMOGRAPHICS' AND a.code <> 'CLIENTMERGECOMPARISON' ORDER BY a.name ASC, a.assessmentId ASC");
57
+ return {source:'admin-catalog',retrievedAt:new Date().toISOString(),assessments};
58
+ });
59
+ const getProviderAssessments=({providerId})=>withPageLock(page,async()=>{
60
+ positiveId(providerId);await initialize();
61
+ const rows=await mapped(`SELECT a.assessment.assessmentId AS assessmentId, a.assessment.name AS name, a.assessment.code AS code, a.visible AS visible FROM DaAssessmentProvider AS a WHERE a.providerId = ${providerId} AND a.assessment.hidden = false AND a.assessment.active = true AND a.assessment.published = true ORDER BY a.assessment.name ASC, a.assessment.assessmentId ASC`);
62
+ const assessments=rows.map(({visible,...row})=>{if(typeof visible!=='boolean')throw Error('Invalid assessment visibility');return {...row,assignedToAssessmentTab:visible};});
63
+ await page.goto(base+`index.html#loadProvider;providerId=${providerId};subview=moduleSettings`);await ready();
64
+ await page.getByRole('tab',{name:'Display Settings',exact:true}).click();await ready();
65
+ await page.getByRole('tab',{name:'Assessment Display Settings',exact:true}).click();await ready();
66
+ if(!(await page.locator('body').innerText()).includes(`(${providerId}) `))throw Error('Provider identity unavailable');
67
+ const display=await page.locator('[id^="selectAssessment-"]').evaluateAll(elements=>elements.filter(e=>e.getBoundingClientRect().width>0).map(e=>{
68
+ const row=e.closest('tr');if(!row||row.cells.length<4)throw Error('Unexpected assessment display controls');
69
+ return {location:e.id.slice('selectAssessment-'.length),label:row.cells[0].innerText.trim(),name:row.cells[1].innerText.trim(),allowMultiple:row.querySelector('input[type=checkbox]')?.checked??null};
70
+ }));
71
+ if(display.length!==38||new Set(display.map(r=>r.location)).size!==38)throw Error('Assessment display settings incomplete or changed');
72
+ const defaults=display.map(({name,...row})=>{
73
+ const matches=name?assessments.filter(a=>a.name===name):[];
74
+ // A duplicate name must not resolve to an arbitrary instrument code.
75
+ return {...row,assessmentName:name||null,resolution:!name?'unset':matches.length===1?'resolved':'unresolved',assessment:matches.length===1?matches[0]:null};
76
+ });
77
+ return {source:'provider-display-settings',providerId,retrievedAt:new Date().toISOString(),assessments,defaults};
78
+ });
79
+ return {connection,listAssessments,getProviderAssessments};
80
+ }
@@ -0,0 +1,9 @@
1
+ /** Serializable diagnostics only. Never copies response bodies, headers, tokens or request data. */
2
+ export function recoveryDiagnostics(error,depth=0){
3
+ const result={name:typeof error?.name==='string'?error.name:'Error'};
4
+ for(const key of ['code','kind','stage','recoveryStage','operation','requestId','correlationId'])if(typeof error?.[key]==='string')result[key]=error[key];
5
+ for(const key of ['status','retryAfterMs'])if(Number.isFinite(error?.[key]))result[key]=error[key];
6
+ if(typeof error?.reauthenticated==='boolean')result.reauthenticated=error.reauthenticated;
7
+ if(depth<2&&error?.cause&&error.cause!==error)result.cause=recoveryDiagnostics(error.cause,depth+1);
8
+ return result;
9
+ }
@@ -0,0 +1,45 @@
1
+ import type {Page} from 'playwright-core';
2
+ import type {WellSkyClient,ServiceAddViewModel} from '../dist/esm/index.js';
3
+ import type {UiMutationStore} from './index.mjs';
4
+ import type {SsvfInformation} from './ssvf-rules.mjs';
5
+ export interface ServiceUiContext {clientId:number;serviceId:number;providerId:number;serviceNote?:string;}
6
+ export interface FundingSourceOption {fundId:number;fundCycleId:number;name:string;description:string|null;remainingBalance:number|null;submissionDeadline:string|null;}
7
+ export interface FundingAllocation {fundId:number;fundCycleId:number;name:string;amount:number;}
8
+ export interface ServiceUiDetails {financialAssistancePeriod?:{start:string;end:string}|null;fundingSources:FundingAllocation[];ssvfInformation?:SsvfInformation;}
9
+ export interface ServiceUiSnapshot {
10
+ /** Visible SSVF controls, verified separately from REST readback. */
11
+ ssvfInformation?:Record<string,string|number>;
12
+ needStatus:string|null;needOutcome:string|null;serviceStart:string|null;serviceEnd:string|null;
13
+ financialAssistancePeriod:{start:string|null;end:string|null};timezone:string;
14
+ fundingSources:(FundingAllocation&{allocationId:number})[];
15
+ }
16
+ export interface ServiceUi {
17
+ readonly connection?:import("./connection.mjs").UiConnection;
18
+ assertReady():Promise<void>;
19
+ inspect(context:ServiceUiContext):Promise<ServiceUiSnapshot>;
20
+ matches(snapshot:ServiceUiSnapshot,expected:ServiceUiDetails):boolean;
21
+ complete(context:ServiceUiContext,expected:ServiceUiDetails,beforeSave:()=>Promise<void>):Promise<ServiceUiSnapshot>;
22
+ /** Read-only context supplies the current UI query/policy. Overrides filter choices before creating a service. */
23
+ listFundingSources(input:{context:ServiceUiContext;providerId?:number;serviceCodeId?:number}):Promise<FundingSourceOption[]>;
24
+ }
25
+ export interface ServiceRecordInput {
26
+ /** Stable source row identifier, never a batch position or randomly regenerated retry key. */
27
+ key:string;
28
+ body:Omit<Pick<ServiceAddViewModel,'clientId'|'serviceProvider'|'serviceCode'|'serviceStartDate'|'serviceEndDate'|'serviceNote'|'serviceCosts'>,'serviceEndDate'>&{
29
+ serviceEndDate:string;needStatus?:'Closed';
30
+ ssvfInformation?:SsvfInformation;
31
+ };
32
+ /** Required when ssvfFinancialAssistanceType is supplied; rejected otherwise. */
33
+ financialAssistancePeriod?:{start:string;end:string};
34
+ fundingSources?:FundingAllocation[];
35
+ }
36
+ export interface ServiceRecordResult {key:string;outcome:'verified'|'already-present';serviceId:number;needId:number;ui:ServiceUiSnapshot;}
37
+ export interface RecoveryDiagnostic {name:string;code?:string;kind?:string;stage?:string;recoveryStage?:string;operation?:string;requestId?:string;correlationId?:string;status?:number;retryAfterMs?:number;reauthenticated?:boolean;cause?:RecoveryDiagnostic;}
38
+ export type ServiceBulkResult = {index:number;key:string;outcome:'fulfilled';value:ServiceRecordResult}|{index:number;key:string;outcome:'rejected';message:string;error:RecoveryDiagnostic}|{index:number;key:string;outcome:'skipped'};
39
+ export interface ServiceRecorder {
40
+ record(input:ServiceRecordInput):Promise<ServiceRecordResult>;
41
+ /** Sequential UI saves; stops at first failed row. Retry with the same keys/store to reconcile. */
42
+ recordBulk(inputs:ServiceRecordInput[],options?:{signal?:AbortSignal;onResult?:(result:ServiceBulkResult)=>void|Promise<void>}):Promise<{results:ServiceBulkResult[];callbackFailed:boolean}>;
43
+ }
44
+ export function createServiceRecorder(options:{api:WellSkyClient;ui:ServiceUi;store:UiMutationStore}):ServiceRecorder;
45
+ export function createServiceUi(options:{page:Page}):ServiceUi;
@@ -0,0 +1,148 @@
1
+ import {apiScope} from './connection.mjs';
2
+ import {createHash} from 'node:crypto';
3
+ import {recoveryDiagnostics} from './recovery-diagnostics.mjs';
4
+ import {toItems} from '../dist/esm/index.js';
5
+ import {validateSsvfInformation} from './ssvf-rules.mjs';
6
+
7
+ const digest=value=>createHash('sha256').update(value).digest('hex');
8
+ const canonical=value=>JSON.stringify(value,(_,v)=>v&&typeof v==='object'&&!Array.isArray(v)?Object.fromEntries(Object.keys(v).sort().map(k=>[k,v[k]])):v);
9
+ const positive=n=>Number.isSafeInteger(n)&&n>0&&n<=2147483647;
10
+ const object=v=>v!==null&&typeof v==='object'&&!Array.isArray(v);
11
+ const instant=s=>typeof s==='string'&&/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.000)?(?:Z|[+-]\d{2}:\d{2})$/.test(s)&&Number.isFinite(Date.parse(s))&&new Date(s.slice(0,19)+'Z').toISOString().slice(0,19)===s.slice(0,19);
12
+ const timestamp=s=>Date.parse(/(?:Z|[+-]\d\d:\d\d)$/.test(s)?s:s+'Z');
13
+ const one=response=>{const items=toItems(response);if(items.length!==1)throw Error('Expected exactly one record');return items[0];};
14
+ const money=n=>typeof n==='number'&&Number.isFinite(n)&&n>=0&&n<=1e9&&Math.abs(n*100-Math.round(n*100))<1e-6;
15
+
16
+ function prepare(input) {
17
+ if(!input||typeof input.key!=='string'||!input.key.trim()||input.key.length>200)throw Error('Stable service row key required');
18
+ if(Object.keys(input).some(k=>!['key','body','financialAssistancePeriod','fundingSources'].includes(k)))throw Error('Unknown service row field');
19
+ const body=structuredClone(input.body);
20
+ if(!object(body)||!positive(body.clientId)||!positive(body.serviceProvider)||typeof body.serviceCode!=='string'||!body.serviceCode.trim()||!instant(body.serviceStartDate)||!instant(body.serviceEndDate)||timestamp(body.serviceEndDate)<timestamp(body.serviceStartDate))throw Error('Client, provider, code and ordered timezone-qualified service dates required');
21
+ for(const field of ['serviceCosts','ssvfInformation'])if(body[field]!==undefined&&!object(body[field]))throw Error('Service details must be objects');
22
+ if(body.needStatus!==undefined&&body.needStatus!=='Closed')throw Error('Service recorder requires Closed needs');
23
+ if(Object.keys(body).some(k=>!['clientId','serviceProvider','serviceCode','serviceStartDate','serviceEndDate','serviceNote','needStatus','ssvfInformation','serviceCosts'].includes(k)))throw Error('Unsupported service recorder field');
24
+ if(body.serviceNote!==undefined&&(typeof body.serviceNote!=='string'||body.serviceNote.length>10000||body.serviceNote.includes('[sdk-service:')))throw Error('Invalid service note');
25
+ if(body.serviceCosts&&Object.entries(body.serviceCosts).some(([k,v])=>!['number_of_units','unit_type','total_cost_of_units'].includes(k)||(k==='unit_type'?typeof v!=='string':!money(v))))throw Error('Invalid service costs');
26
+ const period=structuredClone(input.financialAssistancePeriod??null);
27
+ if(period!==null&&!object(period))throw Error('Assistance period must be an object');
28
+ if(period&&(!body.ssvfInformation||!instant(period.start)||!instant(period.end)||timestamp(period.end)<timestamp(period.start)||Object.keys(period).some(k=>!['start','end'].includes(k))))throw Error('Invalid SSVF assistance period');
29
+ validateSsvfInformation(body.ssvfInformation,period);
30
+ const fundingSources=structuredClone(input.fundingSources??[]);
31
+ if(!Array.isArray(fundingSources)||fundingSources.length>20||fundingSources.some(f=>!f||!positive(f.fundId)||!positive(f.fundCycleId)||typeof f.name!=='string'||!f.name.trim()||!money(f.amount)||f.amount===0||Object.keys(f).some(k=>!['fundId','fundCycleId','name','amount'].includes(k)))||new Set(fundingSources.map(f=>f.fundCycleId)).size!==fundingSources.length||new Set(fundingSources.map(f=>f.name)).size!==fundingSources.length)throw Error('Invalid or duplicate funding sources');
32
+ body.needStatus='Closed';
33
+ const intentHash=digest(canonical({body,period,fundingSources}));
34
+ const keyHash=digest(input.key),marker=`[sdk-service:${keyHash}]`;
35
+ body.serviceNote=[body.serviceNote,marker].filter(Boolean).join('\n');
36
+ return {key:input.key,keyHash,intentHash,body,period,fundingSources,marker};
37
+ }
38
+
39
+ function verifyService(service,body) {
40
+ for(const field of ['clientId','serviceProvider','serviceCode','serviceNote'])if(service[field]!==body[field])throw Error('Stored service differs from requested row');
41
+ for(const field of ['serviceStartDate','serviceEndDate'])if(timestamp(service[field])!==timestamp(body[field]))throw Error('Stored service date differs');
42
+ for(const field of ['ssvfInformation','serviceCosts'])for(const [key,value]of Object.entries(body[field]??{}))if(service[field]?.[key]!==value)throw Error('Stored service details differ');
43
+ if(!positive(service.needId)||!positive(service.serviceId)||service.isActive===false)throw Error('Service has no active associated need');
44
+ }
45
+
46
+ async function serviceList(api,clientId,allowInitial404) {
47
+ const rows=[],ids=new Set();let count;
48
+ for(let page=1;page<=100;page++){
49
+ let result;try{result=await api.services.listForClient({path:{clientId},query:{perPage:100,startPage:page,total_count:true}});}catch(error){if(page===1&&allowInitial404&&error.status===404)return [];throw error;}
50
+ if(!Number.isSafeInteger(result?.count)||result.count<0||!Array.isArray(result.values)||count!==undefined&&count!==result.count)throw Error('Service list count unavailable or changed');
51
+ count=result.count;
52
+ for(const row of result.values){if(!positive(row.serviceId)||row.clientId!==clientId||ids.has(row.serviceId))throw Error('Invalid or duplicate service list record');ids.add(row.serviceId);rows.push(row);}
53
+ if(rows.length===count)return rows;
54
+ if(rows.length>count||!result.values.length)throw Error('Incomplete service list');
55
+ }
56
+ throw Error('Service list exceeds 100 pages');
57
+ }
58
+
59
+ /** Tenant-configured orchestration. REST creates once, UI fills contract gaps, both read back.
60
+ * Keep the same durable store and stable row keys across restarts and reordered batches.
61
+ * An uncertain write is reconciled, never automatically repeated. */
62
+ export function createServiceRecorder({api,ui,store}) {
63
+ const tenantScope=apiScope(api, ui);
64
+ if(!ui||!store)throw Error('REST client, service UI adapter and durable store required');
65
+ const recordPrepared=async input=>{
66
+ const {body,keyHash,intentHash,marker,period,fundingSources}=input;
67
+ const scope=JSON.stringify([tenantScope,'service-recording',body.clientId]);
68
+ let failedStage='journal-lock';
69
+ try{return await store.withLock(scope,async()=>{
70
+ failedStage='journal-read';const entries=await store.read(scope);
71
+ if(!Array.isArray(entries))throw Error('Invalid service journal');
72
+ let entry=entries.find(e=>e.keyHash===keyHash);
73
+ if(entries.filter(e=>e.keyHash===keyHash).length>1||entry&&entry.intentHash!==intentHash)throw Error('Service key conflicts with earlier intent');
74
+ const persist=async stage=>{entry={...entry,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,entries);};
75
+ let service;
76
+ failedStage='session-preflight';await ui.assertReady();failedStage='rest-read';
77
+ if(entry?.serviceId){service=one(await api.services.getComplete({path:{serviceId:entry.serviceId}}));}
78
+ else {
79
+ const matches=[];
80
+ // Paginate fully before deciding that an earlier create is absent.
81
+ for(const item of await serviceList(api,body.clientId,!entry)){
82
+ if(item.serviceProvider!==body.serviceProvider||item.serviceCode!==body.serviceCode)continue;
83
+ const detail=one(await api.services.getComplete({path:{serviceId:item.serviceId}}));
84
+ if(detail.serviceId!==item.serviceId)throw Error('Service readback identity mismatch');
85
+ if(detail.serviceNote?.split('\n').includes(marker))matches.push(detail);
86
+ }
87
+ if(matches.length>1)throw Error('Duplicate service markers require review');
88
+ service=matches[0];
89
+ if(!service){
90
+ if(entry)throw Error('Unresolved REST create; refusing another create');
91
+ await persist('rest-attempted');
92
+ failedStage='rest-create';let createError;
93
+ try{service=one(await api.services.create({body}));}catch(error){createError=error;}
94
+ failedStage='rest-reconcile';
95
+ if(!service){
96
+ try{for(const item of await serviceList(api,body.clientId,false)){
97
+ if(item.serviceProvider!==body.serviceProvider||item.serviceCode!==body.serviceCode)continue;
98
+ const detail=one(await api.services.getComplete({path:{serviceId:item.serviceId}}));
99
+ if(detail.serviceId!==item.serviceId)throw Error('Service readback identity mismatch');
100
+ if(detail.serviceNote?.split('\n').includes(marker))matches.push(detail);
101
+ }
102
+ if(matches.length!==1)throw Error('REST create outcome uncertain; journal blocks repetition');
103
+ }catch(error){if(createError&&error!==createError&&!error.cause)error.cause=createError;throw error;}
104
+ service=matches[0];
105
+ }
106
+ }
107
+ verifyService(service,body);entry={...entry,serviceId:service.serviceId,needId:service.needId};await persist('rest-verified');
108
+ }
109
+ verifyService(service,body);
110
+ if(service.serviceId!==entry.serviceId||entry.needId!==undefined&&service.needId!==entry.needId)throw Error('Saved service or need identity changed');
111
+ // Older journals did not retain need IDs. Pin the first verified read
112
+ // before UI work, then require that association on every later retry.
113
+ if(entry.needId===undefined){entry.needId=service.needId;await persist(entry.stage);}
114
+ const context={clientId:body.clientId,serviceId:service.serviceId,providerId:body.serviceProvider,serviceNote:body.serviceNote};
115
+ const expected={financialAssistancePeriod:period,fundingSources,ssvfInformation:body.ssvfInformation};
116
+ failedStage='ui-inspect';let snapshot=await ui.inspect(context);
117
+ const wasVerified=entry.stage==='verified';
118
+ if(!ui.matches(snapshot,expected)){
119
+ if(entry.stage==='ui-attempted'||wasVerified)throw Error('UI save unresolved or stored row changed; refusing automatic replay');
120
+ failedStage='ui-complete';snapshot=await ui.complete(context,expected,()=>persist('ui-attempted'));
121
+ }
122
+ failedStage='verification';if(!ui.matches(snapshot,expected))throw Error('UI details did not verify; journal blocks repetition');
123
+ service=one(await api.services.getComplete({path:{serviceId:service.serviceId}}));verifyService(service,body);
124
+ if(service.serviceId!==entry.serviceId||service.needId!==entry.needId)throw Error('Saved service or need identity changed');
125
+ const need=one(await api.needs.getComplete({path:{needId:service.needId}}));
126
+ if(need.needId!==entry.needId||need.isActive===false||need.clientId!==body.clientId||need.needProvider!==body.serviceProvider||need.statusInformation?.needStatus!=='Closed'||need.statusInformation?.needOutcome!=='Fully Met')throw Error('Associated need did not verify identity, active state and Closed / Fully Met');
127
+ failedStage='journal-verify';await persist('verified');
128
+ return {key:input.key,outcome:wasVerified?'already-present':'verified',serviceId:service.serviceId,needId:service.needId,ui:snapshot};
129
+ });}catch(error){if(error&&typeof error==='object'&&!error.stage)error.stage=failedStage;throw error;}
130
+ };
131
+ return {
132
+ record:async input=>recordPrepared(prepare(input)),
133
+ async recordBulk(inputs,{signal,onResult}={}) {
134
+ if(!Array.isArray(inputs)||inputs.length>10000)throw Error('At most 10,000 rows per batch');
135
+ // Validate every row before any write. A shared browser executes sequentially.
136
+ const keys=new Set(),prepared=inputs.map(input=>{const row=prepare(input),key=`${row.body.clientId}:${row.keyHash}`;if(keys.has(key))throw Error('Duplicate row keys in batch');keys.add(key);return row;});
137
+ const results=[];let stopped=false,callbackFailed=false;
138
+ for(const [index,row]of prepared.entries()){
139
+ let result;
140
+ if(signal?.aborted||stopped)result={index,key:row.key,outcome:'skipped'};
141
+ else try{result={index,key:row.key,outcome:'fulfilled',value:await recordPrepared(row)};}catch(error){result={index,key:row.key,outcome:'rejected',message:error.message,error:recoveryDiagnostics(error)};stopped=true;}
142
+ results.push(result);
143
+ if(onResult&&!callbackFailed)try{await onResult(result);}catch{stopped=true;callbackFailed=true;}
144
+ }
145
+ return {results,callbackFailed};
146
+ },
147
+ };
148
+ }
@@ -0,0 +1,160 @@
1
+ import {pageConnection} from './connection.mjs';
2
+ import {decodeMappedResponse} from './gwt-mapped.mjs';
3
+ import {withPageLock} from './page-lock.mjs';
4
+ import {readSsvfInformation} from './ssvf-rules.mjs';
5
+ const id=n=>{if(!Number.isSafeInteger(n)||n<=0)throw Error('Positive integer ID required');return n;};
6
+
7
+ /** Fresh UI forms generate their own GWT payloads. No captured write payload is replayed. */
8
+ export function createServiceUi({page}) {
9
+ const connection=pageConnection(page),base=connection.moduleBaseUrl;
10
+ const guard=()=>{if(new URL(page.url()).origin+new URL(page.url()).pathname!==base+'index.html')throw Error('Configured application page required');};
11
+ const ready=()=>page.waitForFunction(()=>!document.body.innerText.includes('Processing...')&&!document.body.innerText.includes('Loading...'),null,{timeout:30000});
12
+ const assertReady=async()=>{guard();await ready();if(await page.locator('[role=dialog]:visible').count()||await page.locator('[id="formfield-password"]:visible').count())throw Error('Authenticated application page with no open dialogs required');};
13
+ let readWire,groupId;
14
+ const exit=async()=>{if(await page.locator('[id="ServiceView.exit"]').isVisible()){await page.locator('[id="ServiceView.exit"]').click();await page.locator('[id="ServiceView.exit"]').waitFor({state:'hidden'});await ready();}};
15
+ const open=async context=>{
16
+ id(context.clientId);id(context.serviceId);id(context.providerId);guard();
17
+ if(await page.locator('[role=dialog]:visible').count())throw Error('Close dialogs before service workflow');
18
+ readWire=undefined;groupId=undefined;
19
+ await exit();
20
+ const visibleClient=(await page.locator('body').innerText()).match(/Client - \((\d+)\)/)?.[1];
21
+ if(visibleClient&&Number(visibleClient)!==context.clientId){
22
+ await page.getByRole('tab',{name:'Client Information',exact:true}).click();await page.getByRole('tab',{name:'Summary',exact:true}).click();await ready();
23
+ await page.getByRole('button',{name:'Exit',exact:true}).click();
24
+ await page.waitForURL(url=>url.hash==='#clientPointClientMain');await ready();
25
+ }
26
+ if(new URL(page.url()).hash!==`#loadClient;clientId=${context.clientId}`)await page.goto(base+`index.html#loadClient;clientId=${context.clientId}`);
27
+ await page.waitForFunction(clientId=>document.body.innerText.includes(`Client - (${clientId})`)&&!document.body.innerText.includes('Processing...'),context.clientId,{timeout:30000});
28
+ await page.getByRole('tab',{name:'Client Information',exact:true}).click();
29
+ await page.getByRole('tab',{name:'Summary',exact:true}).click();await ready();
30
+ if(!(await page.locator('body').innerText()).includes(`Client - (${context.clientId})`))throw Error('Wrong client page');
31
+ let target=page.locator(`[id="ClientSummaryTab.editService-imageLink_${context.serviceId}"]`);
32
+ if(!await target.count()){
33
+ await page.getByRole('tab',{name:'Service Transactions',exact:true}).click();await ready();
34
+ const previous=page.getByRole('button',{name:'View Previous Service Transactions',exact:true});if(await previous.isVisible())await previous.click();
35
+ await page.getByRole('tab',{name:'Services',exact:true}).click();await ready();
36
+ const dates=page.locator('select').filter({has:page.locator('option',{hasText:/^All Dates$/})});
37
+ await dates.selectOption({label:'All Dates'});await page.getByRole('button',{name:'Search',exact:true}).click();await ready();
38
+ target=page.locator(`[id="ServicesPanel.editService-imageLink_${context.serviceId}"]`);
39
+ for(let i=0;!await target.count()&&i<100;i++){
40
+ const next=page.getByRole('button',{name:'Next',exact:true});if(!await next.isVisible()||await next.getAttribute('aria-disabled')==='true')break;
41
+ const response=page.waitForResponse(r=>r.request().postData()?.includes('|getMappedResults|')&&r.request().postData()?.includes('SpNeedService'));
42
+ void response.catch(()=>{});
43
+ await next.click();await response;await ready();
44
+ }
45
+ }
46
+ const funding=page.waitForResponse(r=>r.url()===base+'service/FundManagerService'&&r.request().postData()?.includes('|getFundingSources|'));
47
+ const mapped=page.waitForResponse(r=>r.url()===base+'service/MetaQueryService'&&r.request().postData()?.includes('|getMappedResults|'));
48
+ const loaded=page.waitForResponse(r=>r.url()===base+'service/ServiceTransactionService'&&r.request().postData()?.includes('|getService|')&&r.request().postData()?.endsWith(`|${context.serviceId}|`));
49
+ // A failed click must not leave later timeout rejections unhandled.
50
+ const pending=Promise.all([funding,mapped,loaded]);void pending.catch(()=>{});
51
+ await target.click();const response=await loaded;if(response.status()!==200||!(await response.text()).startsWith('//OK'))throw Error('Service load failed');
52
+ await page.locator('[id="ServiceView.saveAndExit"]').waitFor();await ready();
53
+ await page.waitForFunction(({provider,note})=>document.body.innerText.includes(`(${provider})`)&&document.querySelector('[aria-label="Service Notes"]')&&(note===undefined||document.querySelector('[aria-label="Service Notes"]').value===note),{provider:context.providerId,note:context.serviceNote});
54
+ const [fundResponse,mapResponse]=await pending;
55
+ if(fundResponse.status()!==200||!(await fundResponse.text()).startsWith('//OK'))throw Error('Funding state unavailable');
56
+ groupId=id(Number(fundResponse.request().postData().split('|').at(-2)));
57
+ const request=mapResponse.request(),headers=await request.allHeaders();
58
+ readWire={policy:request.postData().split('|')[4],headers:Object.fromEntries(['content-type','x-gwt-module-base','x-gwt-permutation'].map(k=>[k,headers[k]]))};
59
+ };
60
+ const expandFunds=async()=>{const expand=page.getByTitle('Expand Apply Funds for Service Panel',{exact:true});if(await expand.isVisible())await expand.click();await page.locator('[id^="SpFundingSourcesPanel.addFundingSource-"]').waitFor();};
61
+ const read=async()=>{
62
+ await ready();
63
+ const values=await page.evaluate(()=>{
64
+ const field=label=>document.querySelector(`[aria-label="${label}"]`)?.value??null;
65
+ const date=prefix=>{
66
+ const year=field(prefix+' Year'),month=field(prefix+' Month'),day=field(prefix+' Day');if(!year&&!month&&!day)return null;
67
+ let hour=Number(field(prefix+' Hour'));if(field(prefix+' AM/PM')==='PM'&&hour!==12)hour+=12;if(field(prefix+' AM/PM')==='AM'&&hour===12)hour=0;
68
+ return new Date(Number(year),Number(month)-1,Number(day),hour,Number(field(prefix+' Minute')),Number(field(prefix+' Second'))).toISOString();
69
+ };
70
+ return {needStatus:field('Need Status'),needOutcome:field('Outcome of Need'),serviceStart:date('Start Date'),serviceEnd:date('End Date'),financialAssistancePeriod:{start:date('SSVF Financial Assistance Start Date'),end:date('SSVF Financial Assistance End Date')},timezone:Intl.DateTimeFormat().resolvedOptions().timeZone};
71
+ });
72
+ values.ssvfInformation=await readSsvfInformation(page);
73
+ await expandFunds();
74
+ values.fundingSources=await page.locator('[id^="SpFundingSourcesPanel.addFundingSource-"]').evaluate(button=>{
75
+ let panel=button.parentElement;while(panel&&!panel.innerText.includes('Client Co-Pay'))panel=panel.parentElement;
76
+ if(!panel)throw Error('Funding panel not found');const rows=[];
77
+ for(const link of panel.querySelectorAll('a')){
78
+ let row=link.closest('tr');while(row&&!row.querySelector('input.sp5-textbox'))row=row.parentElement.closest('tr');
79
+ if(!row)throw Error('Funding amount not found');const inputs=[...row.querySelectorAll('input.sp5-textbox')];
80
+ if(inputs.length!==1)throw Error('Ambiguous funding amount');rows.push({name:link.textContent,amount:Number(inputs[0].value.replace(/,/g,''))});
81
+ }
82
+ return rows;
83
+ });
84
+ const strings=[base,readWire.policy,'com.bowmansystems.sp5.core.client.services.GenericService','getMappedResults','java.lang.String/2004016611','java.util.HashMap/1797211028','I','Z',`SELECT f.needServiceGroupFundId AS allocationId, f.fundId AS fundId, f.fundCycleId AS fundCycleId, f.fund.name AS name, f.amount AS amount FROM SpNeedServiceGroupFund AS f WHERE f.needServiceGroupId = ${id(groupId)} AND f.active = true`];
85
+ 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:readWire.headers,body:['7','0',9,...strings,1,2,3,4,5,5,6,7,7,8,9,0,0,1001,1,''].join('|')});
86
+ if(result.status!==200)throw Error('Stored funding read failed');const stored=decodeMappedResponse(result.body);
87
+ if(stored.total!==stored.rows.length||stored.total>1000||stored.rows.length!==values.fundingSources.length)throw Error('Funding UI and stored allocation counts differ');
88
+ for(const f of stored.rows){id(f.allocationId);id(f.fundId);id(f.fundCycleId);if(values.fundingSources.filter(s=>s.name===f.name&&s.amount===f.amount).length!==1)throw Error('Stored funding differs from UI');}
89
+ values.fundingSources=stored.rows;return values;
90
+ };
91
+ const matches=(snapshot,expected)=>snapshot.needStatus==='Closed'&&snapshot.needOutcome==='Fully Met'&&Object.entries(expected.ssvfInformation??{}).every(([key,value])=>snapshot.ssvfInformation?.[key]===value)&&(!expected.financialAssistancePeriod||['start','end'].every(k=>Date.parse(snapshot.financialAssistancePeriod[k])===Date.parse(expected.financialAssistancePeriod[k])))&&expected.fundingSources.length===snapshot.fundingSources.length&&expected.fundingSources.every(f=>snapshot.fundingSources.filter(s=>s.fundId===f.fundId&&s.fundCycleId===f.fundCycleId&&s.name===f.name&&s.amount===f.amount).length===1);
92
+ const searchFunds=async()=>{
93
+ await expandFunds();
94
+ const responsePromise=page.waitForResponse(r=>r.url()===base+'service/MetaQueryService'&&r.request().postData()?.includes('SELECT SpFundCycleBalanceView.name AS name'));
95
+ void responsePromise.catch(()=>{});
96
+ await page.locator('[id^="SpFundingSourcesPanel.addFundingSource-"]').click();
97
+ const response=await responsePromise;await ready();
98
+ if(response.status()!==200)throw Error('Funding lookup failed');
99
+ const request=response.request(),headers=await request.allHeaders();
100
+ return {body:request.postData(),headers:Object.fromEntries(['content-type','x-gwt-module-base','x-gwt-permutation'].map(k=>[k,headers[k]]))};
101
+ };
102
+ const fundOptions=async(wire,overrides={})=>{
103
+ const parts=wire.body.split('|');if(parts[0]!=='7'||parts[1]!=='0'||Number(parts[2])!==9||parts[6]!=='getMappedResults')throw Error('Funding lookup format changed');
104
+ let query=parts[11];
105
+ if(!query.startsWith('SELECT SpFundCycleBalanceView.name AS name')||!query.includes('SpFundCompatibleCode.compatibleCodeId')||!query.includes('SpFundEnforcement'))throw Error('Funding filter missing');
106
+ if(overrides.providerId!==undefined)query=query.replace(/\bproviderId = \d+/g,`providerId = ${id(overrides.providerId)}`);
107
+ if(overrides.serviceCodeId!==undefined)query=query.replace(/\bcompatibleCodeId = \d+/g,`compatibleCodeId = ${id(overrides.serviceCodeId)}`);
108
+ parts[11]=query.replace(/LIMIT \d+,\d+$/,'LIMIT 1001,0');
109
+ // v7 getMappedResults(String,HashMap,int,int,boolean), observed null map and zero offset.
110
+ if(parts.slice(12).join('|')!=='1|2|3|4|5|5|6|7|7|8|9|0|0|10|1|')throw Error('Funding query arguments changed');
111
+ parts[25]='1001';
112
+ const result=await page.evaluate(async({url,headers,body})=>{const r=await fetch(url,{method:'POST',headers,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:parts.join('|')});
113
+ if(result.status!==200)throw Error('Funding read failed');const decoded=decodeMappedResponse(result.body);
114
+ if(decoded.total!==decoded.rows.length||decoded.total>1000)throw Error('Incomplete funding list');
115
+ for(const row of decoded.rows){id(row.fundId);id(row.fundCycleId);if(typeof row.name!=='string'||!row.name)throw Error('Invalid funding source');}
116
+ return decoded.rows;
117
+ };
118
+ const listFundingSources=input=>withPageLock(page,async()=>{
119
+ await open(input.context);
120
+ try{const wire=await searchFunds();try{return await fundOptions(wire,input);}finally{await page.locator('[id="FundServicesSearchPopup.cancel"]').click();}}finally{await exit();}
121
+ });
122
+ const inspect=context=>withPageLock(page,async()=>{await open(context);try{return await read();}finally{await exit();}});
123
+ const fillDate=async(prefix,value)=>{
124
+ const parts=await page.evaluate(value=>{const d=new Date(value);const rebuilt=new Date(d.getFullYear(),d.getMonth(),d.getDate(),d.getHours(),d.getMinutes(),d.getSeconds());if(rebuilt.getTime()!==d.getTime())throw Error('Ambiguous local 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'};},value);
125
+ for(const [part,value]of Object.entries(parts)){const field=page.getByLabel(prefix+' '+part,{exact:true});if(['Month','Day','Year'].includes(part)){await field.fill(value);await field.press('Tab');}else await field.selectOption(value);}
126
+ };
127
+ const complete=(context,expected,beforeSave)=>withPageLock(page,async()=>{
128
+ await open(context);let attempted=false;
129
+ try{
130
+ const current=await read();if(matches(current,expected))return current;
131
+ if(current.fundingSources.length)throw Error('Existing funding differs; manual reconciliation required');
132
+ await page.getByLabel('Need Status',{exact:true}).selectOption({label:'Closed'});
133
+ await page.getByLabel('Outcome of Need',{exact:true}).selectOption({label:'Fully Met'});
134
+ if(expected.financialAssistancePeriod){await fillDate('SSVF Financial Assistance Start Date',expected.financialAssistancePeriod.start);await fillDate('SSVF Financial Assistance End Date',expected.financialAssistancePeriod.end);}
135
+ for(const fund of expected.fundingSources){
136
+ const wire=await searchFunds(),options=await fundOptions(wire);
137
+ const matches=options.filter(f=>f.fundId===fund.fundId&&f.fundCycleId===fund.fundCycleId&&f.name===fund.name);
138
+ if(matches.length!==1||options.filter(f=>f.name===fund.name).length!==1)throw Error('Funding source unavailable or ambiguous');
139
+ const dialog=page.getByRole('dialog',{name:'Add Funding Source',exact:true});
140
+ const search=dialog.locator('input[name="txtSearchBox"]');await search.fill('');await search.pressSequentially(fund.name);
141
+ const searched=page.waitForResponse(r=>r.url()===base+'service/MetaQueryService'&&r.request().postData()?.includes('SELECT SpFundCycleBalanceView.name AS name'));
142
+ void searched.catch(()=>{});
143
+ await page.locator('[id="FundServicesSearchView.searchButton"]').click();await searched;await ready();
144
+ // Reject ambiguous names/cycles above. Select only the row displaying this verified name.
145
+ const row=dialog.getByText(fund.name,{exact:true}).locator('xpath=ancestor::tr[@role="row"][1]');
146
+ await row.getByTitle('Select Fund',{exact:true}).click();await dialog.waitFor({state:'hidden'});await ready();
147
+ const amount=page.getByText(fund.name,{exact:true}).locator('xpath=ancestor::tr[.//input[contains(@class,"textbox")]][1]').locator('input.sp5-textbox');await amount.fill(fund.amount.toFixed(2));await amount.press('Tab');
148
+ }
149
+ await beforeSave();attempted=true;
150
+ await page.locator('[id="ServiceView.saveAndExit"]').click();
151
+ await page.locator('[id="ServiceView.saveAndExit"]').waitFor({state:'hidden'});await ready();
152
+ await open(context);const saved=await read();if(!matches(saved,expected))throw Error('Service UI readback mismatch');return saved;
153
+ }finally{
154
+ // Never dismiss an uncertain save/validation dialog or click Save again.
155
+ if(!await page.locator('[role=dialog]:visible').count())await exit();
156
+ else if(!attempted&&await page.locator('[id="FundServicesSearchPopup.cancel"]').isVisible()){await page.locator('[id="FundServicesSearchPopup.cancel"]').click();await exit();}
157
+ }
158
+ });
159
+ return {connection,assertReady:()=>withPageLock(page,assertReady),inspect,matches,complete,listFundingSources};
160
+ }
@@ -0,0 +1,40 @@
1
+ import {pageConnection} from './connection.mjs';
2
+ import {randomUUID} from 'node:crypto';
3
+ import {AttachmentConflictError} from './attachment-conflict.mjs';
4
+ const sessions=new WeakMap();
5
+ export class SessionInterruptedError extends Error {
6
+ constructor(reauthenticated){
7
+ super(reauthenticated?'Session reauthenticated after an interrupted operation. Retry with the same durable journal and operation key to reconcile; the operation was not replayed.':'Session reauthentication failed after an interrupted operation. Resolve authentication, then reconcile with the same durable journal and operation key.');
8
+ this.name='SessionInterruptedError';this.code='SESSION_INTERRUPTED';this.reauthenticated=reauthenticated;
9
+ }
10
+ }
11
+ export const sessionAuthentication=page=>sessions.get(page);
12
+ export const sessionGeneration=page=>sessions.get(page)?.generation??'unmanaged';
13
+ /** Internal hooks run only while the page lock is held. Never rerun a work callback. */
14
+ export function attachAuthentication(page,reconnect,probe,connection=pageConnection(page)){
15
+ const target=new URL(connection.moduleBaseUrl);
16
+ let expired=false,blocked,closed=false,generation=randomUUID();
17
+ const response=r=>{const u=new URL(r.url());if(u.origin===target.origin&&u.pathname.startsWith('/'+connection.tenant+'/')&&r.status()===401)expired=true;};
18
+ page.on('response',response);
19
+ const state={
20
+ get generation(){return generation;},
21
+ async isExpired(){return expired||await page.locator('[id="formfield-password"]:visible').count()>0;},
22
+ async reauthenticate(){
23
+ if(closed)throw Error('Session closed');
24
+ // Invalidate all prepared payloads even if authentication subsequently fails.
25
+ generation=randomUUID();expired=true;
26
+ try{await reconnect();expired=false;blocked=undefined;}catch(error){blocked=error;throw error;}
27
+ },
28
+ async before(){if(closed)throw Error('Session closed');if(blocked)throw blocked;if(await state.isExpired())await state.reauthenticate();},
29
+ async afterFailure(error){
30
+ // Preserve confirmed conflicts even if the session expired independently.
31
+ // The next operation still performs normal authentication preflight.
32
+ if(error instanceof AttachmentConflictError)throw error;
33
+ if(!await state.isExpired()&&!await Promise.resolve().then(()=>probe?.()).catch(()=>false))throw error;
34
+ try{await state.reauthenticate();}catch{throw new SessionInterruptedError(false);}
35
+ throw new SessionInterruptedError(true);
36
+ },
37
+ close(){closed=true;page.off('response',response);},
38
+ };
39
+ sessions.set(page,state);return state;
40
+ }
@@ -0,0 +1,103 @@
1
+ import type { Page } from 'playwright-core';
2
+
3
+ export interface ShelterInventoryFreshness {
4
+ /** Host UTC timestamps spanning the reads, not a server transaction timestamp. */
5
+ startedAt: string;
6
+ observedAt: string;
7
+ completedAt: string;
8
+ sourceUpdatedAt: null;
9
+ atomic: false;
10
+ }
11
+ export interface ShelterInventoryLimits {
12
+ maxPages: number;
13
+ maxUnitLists: number;
14
+ /** Named beds plus occupied overflow rows. The new-overflow action is excluded. */
15
+ maxBeds: number;
16
+ }
17
+ export interface ShelterInventoryReportedCounts {
18
+ totalBeds: number;
19
+ /** Includes held beds. Do not use this as the number of occupants. */
20
+ usedBeds: number;
21
+ availableBeds: number;
22
+ overflowOccupants: number;
23
+ }
24
+ export interface ShelterUnitList {
25
+ providerId: number;
26
+ unitListId: number;
27
+ name: string;
28
+ type: string;
29
+ reported: ShelterInventoryReportedCounts;
30
+ }
31
+ export interface ShelterInventoryOccupant {
32
+ clientId: number;
33
+ displayName: string;
34
+ /** UI text only. No timezone or exact check-in instant is inferred. */
35
+ dateInText: string | null;
36
+ groupId: number | null;
37
+ /** A scheduled checkout never makes the bed vacant in this snapshot. */
38
+ scheduledCheckout: { status: 'scheduled'; localDateTimeText: string } | { status: 'not-indicated' | 'unknown' };
39
+ }
40
+ interface ShelterInventoryBedIdentity {
41
+ /** JSON tuple of tenant, providerId, unitListId, unit, address, name.
42
+ * Stable across row ordering and occupancy changes, changes on renaming.
43
+ * Never pass this key as a server bed ID. Duplicate paths reject the read.
44
+ */
45
+ bedKey: string;
46
+ /** The visible inventory does not expose a server bed ID. */
47
+ bedId: null;
48
+ unit: string;
49
+ address: string;
50
+ name: string;
51
+ }
52
+ export type ShelterInventoryBed = ShelterInventoryBedIdentity & (
53
+ { status: 'occupied'; reason: null; occupant: ShelterInventoryOccupant } |
54
+ { status: 'vacant'; reason: null; occupant: null } |
55
+ { status: 'unavailable'; reason: 'held'; occupant: null } |
56
+ { status: 'unknown'; reason: 'unrecognized-row' | 'check-in-unavailable'; occupant: null }
57
+ );
58
+ export type ShelterVacancySnapshot = {
59
+ status: 'known'; vacantCount: number; vacantBedKeys: string[];
60
+ } | {
61
+ status: 'unknown'; vacantCount: null; vacantBedKeys: []; reasons: ['unclassified-beds'];
62
+ };
63
+ interface ShelterInventoryObservation {
64
+ provider: { providerId: number; name: string };
65
+ source: 'ui';
66
+ freshness: ShelterInventoryFreshness;
67
+ limits: ShelterInventoryLimits;
68
+ }
69
+ export interface ShelterUnitListSnapshot extends ShelterInventoryObservation {
70
+ completeness: 'complete-visible-provider';
71
+ unitLists: ShelterUnitList[];
72
+ }
73
+ export interface ShelterInventorySnapshot extends ShelterInventoryObservation {
74
+ completeness: 'complete-visible-unit-list';
75
+ unitList: ShelterUnitList;
76
+ beds: ShelterInventoryBed[];
77
+ counts: { total: number; occupied: number; vacant: number; unavailable: number; unknown: number };
78
+ overflow: { occupants: ShelterInventoryOccupant[]; entryAvailable: boolean };
79
+ /** A snapshot, not a reservation or an admission/eligibility decision. */
80
+ availability: ShelterVacancySnapshot;
81
+ }
82
+ export interface ShelterInventory {
83
+ readonly connection?:import("./connection.mjs").UiConnection;
84
+ listUnitLists(input: { providerId: number }): Promise<ShelterUnitListSnapshot>;
85
+ getInventory(input: { providerId: number; unitListId: number }): Promise<ShelterInventorySnapshot>;
86
+ }
87
+ export function createShelterInventory(options: {
88
+ /** Dedicated authenticated application Page, shared with other SDK helpers through their page lock. */
89
+ page: Page;
90
+ /** Default and maximum 100 counted availability pages. */
91
+ maxPages?: number;
92
+ /** Default and maximum 1,000 visible unit lists. */
93
+ maxUnitLists?: number;
94
+ /** Default and maximum 10,000 bed/occupied-overflow rows. */
95
+ maxBeds?: number;
96
+ /** Per-wait timeout, default 30,000 ms, maximum 60,000 ms. */
97
+ timeoutMs?: number;
98
+ }): ShelterInventory;
99
+ export class ShelterInventoryError extends Error {
100
+ readonly code: 'SHELTER_INVENTORY_UNAVAILABLE';
101
+ readonly reason: string;
102
+ constructor(reason: string);
103
+ }