@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,20 @@
1
+ import type {Page} from 'playwright-core';
2
+ import type {WellSkyClient} from '../dist/esm/index.js';
3
+ import type {UiMutationStore} from './index.mjs';
4
+ export interface CasePlanUi {
5
+ readonly connection?:import("./connection.mjs").UiConnection;confirmEmpty(input:{kind:'note'|'goal';clientId:number}):Promise<boolean>;}
6
+ export interface NoteValues {note_date:string;note:string;}
7
+ export interface GoalValues {providerId:number;dateGoalsSet:string;classification:string;type:string;overallStatus:string;goalDescription:string;targetDate?:string|null;outcome?:string;outcomeDate?:string|null;projectFollowupDate?:string|null;followupMade?:boolean;followupUser?:string;percentComplete?:string;caseManager?:string;}
8
+ export interface ActionValues {providerId:number;dateSet:string;actionStep:string;status:string;targetDate?:string|null;outcome?:string;outcomeDate?:string|null;followupOutcome?:string;}
9
+ export interface CasePlanResult {key:string;recordId:number;kind:'note'|'goal'|'action';outcome:'verified'|'already-present';values:Record<string,string|number|boolean|null>;}
10
+ export interface RecordInput<T> {key:string;clientId:number;values:T;}
11
+ export interface CasePlans {
12
+ createClientNote(input:RecordInput<NoteValues>):Promise<CasePlanResult>;
13
+ updateClientNote(input:RecordInput<Partial<NoteValues>>&{recordId:number}):Promise<CasePlanResult>;
14
+ createGoal(input:RecordInput<GoalValues>):Promise<CasePlanResult>;
15
+ updateGoal(input:RecordInput<Partial<GoalValues>>&{recordId:number}):Promise<CasePlanResult>;
16
+ createActionStep(input:RecordInput<ActionValues>&{goalId:number}):Promise<CasePlanResult>;
17
+ updateActionStep(input:RecordInput<Partial<ActionValues>>&{goalId:number;recordId:number}):Promise<CasePlanResult>;
18
+ }
19
+ export function createCasePlans(options:{api:WellSkyClient;ui:CasePlanUi;store:UiMutationStore}):CasePlans;
20
+ export function createCasePlanUi(options:{page:Page}):CasePlanUi;
@@ -0,0 +1,89 @@
1
+ import {apiScope} from './connection.mjs';
2
+ import {createHash} from 'node:crypto';
3
+ import {toItems} from '../dist/esm/index.js';
4
+ const digest=v=>createHash('sha256').update(JSON.stringify(v,(_,x)=>x&&typeof x==='object'&&!Array.isArray(x)?Object.fromEntries(Object.entries(x).sort(([a],[b])=>a.localeCompare(b))):x)).digest('hex');
5
+ const id=n=>{if(!Number.isSafeInteger(n)||n<1)throw Error('Positive record ID required');return n;};
6
+ const configs={
7
+ note:{id:'client_note_id',client:'client_id',active:'active',fields:['note_date','note'],required:['note_date','note'],dates:['note_date']},
8
+ goal:{id:'goalId',client:'clientId',active:'isActive',fields:['providerId','dateGoalsSet','classification','type','targetDate','overallStatus','outcome','outcomeDate','projectFollowupDate','followupMade','followupUser','percentComplete','caseManager','goalDescription'],required:['providerId','dateGoalsSet','classification','type','overallStatus','goalDescription'],dates:['dateGoalsSet','targetDate','outcomeDate','projectFollowupDate']},
9
+ action:{id:'goalActionStepId',client:'clientId',active:'isActive',fields:['providerId','dateSet','actionStep','targetDate','status','outcome','outcomeDate','followupOutcome'],required:['providerId','dateSet','actionStep','status'],dates:['dateSet','targetDate','outcomeDate']},
10
+ };
11
+ const instant=s=>{if(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))throw Error('Valid timezone-qualified record date required');return new Date(s).toISOString();};
12
+ const serverDate=s=>instant(/(?:Z|[+-]\d\d:\d\d)$/.test(s)?s:s+'Z');
13
+ const normalize=(kind,row)=>{
14
+ const c=configs[kind],values={};
15
+ for(const k of c.fields){const value=row[kind==='action'&&k==='actionStep'?'actionstep':k];values[k]=c.dates.includes(k)?value?serverDate(value):null:k==='followupMade'?value??false:k==='providerId'?value:value??'';}
16
+ return values;
17
+ };
18
+ const matches=(values,expected)=>Object.entries(expected).every(([k,v])=>values[k]===v);
19
+ /** Journaled single-client notes, goals and action steps. Each write is read back field by field.
20
+ * Goal response household arrays are intentionally ignored. This is not a household mutation. */
21
+ export function createCasePlans({api,store,ui}){
22
+ const tenantScope=apiScope(api, ui);
23
+ if(!store?.withLock||!ui?.confirmEmpty)throw Error('REST API, durable store and case-plan UI reader required');
24
+ const goal=async(clientId,goalId)=>{const rows=toItems(await api.goals.get({path:{goalId}}));if(rows.length!==1||rows[0].goalId!==goalId||rows[0].clientId!==clientId||rows[0].isActive!==true)throw Error('Goal ownership or active state mismatch');return rows[0];};
25
+ const list=async(kind,clientId,goalId,initial)=>{
26
+ if(kind==='action'){const g=await goal(clientId,goalId);if(!Array.isArray(g.actionSteps))throw Error('Action steps unavailable');return g.actionSteps;}
27
+ const rows=[];let count;
28
+ for(let startPage=1;startPage<=100;startPage++){
29
+ let r;const query={perPage:100,startPage,total_count:true};
30
+ try{r=kind==='note'?await api.clientNotes.list({query:{...query,query:`client_id eq ${clientId}`}}):await api.goals.listForClient({path:{clientId},query});}
31
+ catch(e){if(initial&&startPage===1&&e.status===404&&await ui.confirmEmpty({kind,clientId}))return [];throw e;}
32
+ if(!Number.isSafeInteger(r?.count)||r.count<0||!Array.isArray(r.values)||count!==undefined&&count!==r.count)throw Error('Record list count unavailable or changed');count=r.count;rows.push(...r.values);
33
+ if(rows.length===count)return rows;if(!r.values.length||rows.length>count)throw Error('Incomplete record list');
34
+ }throw Error('Record list exceeds safety limit');
35
+ };
36
+ const run=async(kind,mode,input)=>{
37
+ const c=configs[kind];if(!input||Object.keys(input).some(k=>!['key','clientId','goalId','recordId','values'].includes(k))||typeof input.key!=='string'||!input.key.trim()||input.key.length>500)throw Error('Stable case-plan source key and supported fields required');
38
+ const clientId=id(input.clientId);if(kind==='action')id(input.goalId);else if(input.goalId!==undefined)throw Error('goalId only belongs to action steps');
39
+ if(mode==='update')id(input.recordId);else if(input.recordId!==undefined)throw Error('Create cannot specify recordId');
40
+ const values=structuredClone(input.values);if(!values||Array.isArray(values)||!Object.keys(values).length||Object.keys(values).some(k=>!c.fields.includes(k)))throw Error('Unsupported case-plan values');
41
+ if(c.required.some(k=>(mode==='create'||k in values)&&(values[k]===undefined||values[k]===null||values[k]==='')))throw Error('Required case-plan values missing');
42
+ for(const [k,v]of Object.entries(values)){
43
+ if(c.dates.includes(k))values[k]=v===null?null:instant(v);
44
+ else if(k==='providerId')id(v);else if(k==='followupMade'){if(typeof v!=='boolean')throw Error('followupMade must be boolean');}
45
+ else if(typeof v!=='string'||v.length>100000||v.includes('\0'))throw Error('Invalid case-plan text');
46
+ }
47
+ if(kind==='goal'&&'percentComplete' in values&&values.percentComplete!==''&&!/^[1-9]0%$/.test(values.percentComplete))throw Error('Percent complete must be a UI label from 10% through 90%, or empty');
48
+ const intent={kind,mode,clientId,goalId:input.goalId,recordId:input.recordId,values};const keyHash=digest(input.key),intentHash=digest(intent),scope=JSON.stringify([tenantScope,'case-plans',clientId]);
49
+ return store.withLock(scope,async()=>{
50
+ const entries=await store.read(scope);if(!Array.isArray(entries)||entries.some(e=>e.version!==1||!['planned','attempted','verified'].includes(e.stage)))throw Error('Invalid case-plan journal');
51
+ let e=entries.find(v=>v.keyHash===keyHash);if(entries.filter(v=>v.keyHash===keyHash).length>1||e&&e.intentHash!==intentHash)throw Error('Case-plan key conflicts with earlier intent');
52
+ if(entries.some(v=>v.keyHash!==keyHash&&v.stage!=='verified'))throw Error('Another case-plan write is unresolved');
53
+ const persist=async stage=>{e={...e,version:1,keyHash,intentHash,kind,goalId:input.goalId,stage};const i=entries.findIndex(v=>v.keyHash===keyHash);if(i<0)entries.push(e);else entries[i]=e;await store.write(scope,structuredClone(entries));};
54
+ const readAll=async initial=>{
55
+ const rows=await list(kind,clientId,input.goalId,initial),ids=new Set();
56
+ for(const r of rows){id(r[c.id]);if(ids.has(r[c.id])||r[c.client]!==clientId||kind==='action'&&r.goalId!==input.goalId)throw Error('Record list ownership or duplicate ID mismatch');ids.add(r[c.id]);}
57
+ return rows.filter(r=>r[c.active]===true);
58
+ };
59
+ let rows=await readAll(!e),current;
60
+ if(e?.recordId||mode==='update')current=rows.find(r=>r[c.id]===(e?.recordId??input.recordId));
61
+ else {const found=rows.filter(r=>matches(normalize(kind,r),values));if(found.length>1)throw Error('Duplicate matching records require review');current=found[0];}
62
+ if(e?.stage==='verified'){
63
+ const latest=entries.filter(v=>v.kind===kind&&v.recordId===e.recordId&&v.stage==='verified').at(-1);
64
+ if(!current||!matches(normalize(kind,current),latest.target))throw Error('Record changed since verification; refusing replay');return {key:input.key,recordId:e.recordId,kind,outcome:'already-present',values:normalize(kind,current)};
65
+ }
66
+ if(!e){
67
+ if(mode==='update'&&!current)throw Error('Active record required for update');
68
+ const baseline=current?normalize(kind,current):null;e={baseline,recordId:current?.[c.id],target:mode==='update'?{...baseline,...values}:values};await persist('planned');
69
+ }
70
+ if(!current||!matches(normalize(kind,current),e.target)){
71
+ if(e.stage!=='planned')throw Error('Record write unresolved; refusing another mutation');
72
+ if(mode==='update'&&(!current||!matches(normalize(kind,current),e.baseline)))throw Error('Record changed before update');
73
+ const body=mode==='update'?e.target:values;
74
+ const clients=toItems(await api.clients.get({path:{clientId}}));if(clients.length!==1||clients[0].clientId!==clientId||clients[0].isActive!==true)throw Error('Active client required');
75
+ await persist('attempted');let failure;
76
+ try{
77
+ if(kind==='note'){if(mode==='create')await api.clientNotes.create({body:{client_id:clientId,...body}});else await api.clientNotes.update({query:{clientNoteId:input.recordId},body});}
78
+ if(kind==='goal'){if(mode==='create')await api.goals.create({body:{clientId,...body}});else await api.goals.update({path:{goalId:input.recordId},body:{clientId,...body}});}
79
+ if(kind==='action'){if(mode==='create')await api.goals.createAction({path:{goalId:input.goalId},body:{clientId,...body}});else await api.goals.updateAction({path:{goalActionStepId:input.recordId},body});}
80
+ }catch(error){failure=error;}
81
+ rows=await readAll(false);
82
+ const found=rows.filter(r=>(mode==='create'||r[c.id]===input.recordId)&&matches(normalize(kind,r),e.target));
83
+ if(found.length!==1)throw failure??Error('Record not uniquely verified; journal blocks replay');current=found[0];
84
+ }
85
+ e.recordId=current[c.id];e.target=normalize(kind,current);await persist('verified');return {key:input.key,recordId:e.recordId,kind,outcome:'verified',values:e.target};
86
+ });
87
+ };
88
+ return {createClientNote:i=>run('note','create',i),updateClientNote:i=>run('note','update',i),createGoal:i=>run('goal','create',i),updateGoal:i=>run('goal','update',i),createActionStep:i=>run('action','create',i),updateActionStep:i=>run('action','update',i)};
89
+ }
@@ -0,0 +1,16 @@
1
+ import type {WellSkyClient} from '../dist/esm/index.js';
2
+ import type {UiMutationStore} from './index.mjs';
3
+ export type VeteranStatus='Yes (HUD)'|'No (HUD)'|"Client doesn't know (HUD)"|'Client prefers not to answer (HUD)'|'Data not collected (HUD)';
4
+ export interface ClientCreationInput {
5
+ sourceId:string;
6
+ body:{firstName:string;lastName:string;dateEffective:string;middleName?:string;suffix?:string;search_alias?:string;nameDataQuality?:string;socSecNo?:string;ssnDataQuality?:string;veteranStatus?:VeteranStatus};
7
+ /** Explicitly create a distinct person despite flagged candidates. Never merges. */allowDistinctClient?:boolean;
8
+ }
9
+ export interface ClientCreationResult {sourceId:string;clientId:number;outcome:'verified'|'already-present';}
10
+ export interface ClientCreator {
11
+ create(input:ClientCreationInput):Promise<ClientCreationResult>;
12
+ /** Operator must verify this ID belongs to the attempted create before calling. No server write. */
13
+ resolveCreated(input:ClientCreationInput&{clientId:number}):Promise<ClientCreationResult>;
14
+ lookup(sourceId:string):Promise<{clientId:number|null;stage:'attempted'|'created'|'verified'}|null>;
15
+ }
16
+ export function createClientCreator(options:{api:WellSkyClient;store:UiMutationStore}):ClientCreator;
@@ -0,0 +1,60 @@
1
+ import {apiScope} from './connection.mjs';
2
+ import {createHash} from 'node:crypto';
3
+ import {toItems} from '../dist/esm/index.js';
4
+ const digest=v=>createHash('sha256').update(JSON.stringify(v,(_,x)=>x&&typeof x==='object'&&!Array.isArray(x)?Object.fromEntries(Object.entries(x).sort(([a],[b])=>a.localeCompare(b))):x)).digest('hex');
5
+ const fields=['firstName','middleName','lastName','suffix','search_alias','nameDataQuality','socSecNo','ssnDataQuality','veteranStatus','dateEffective'];
6
+ const veteranStatuses=['Yes (HUD)','No (HUD)',"Client doesn't know (HUD)",'Client prefers not to answer (HUD)','Data not collected (HUD)'];
7
+ const id=n=>{if(!Number.isSafeInteger(n)||n<1)throw Error('Positive client ID required');return n;};
8
+ const error=(code,message,details={})=>Object.assign(Error(message),{code,...details});
9
+ function prepareClientCreation(input){
10
+ if(!input||Object.keys(input).some(k=>!['sourceId','body','allowDistinctClient'].includes(k))||typeof input.sourceId!=='string'||!input.sourceId.trim()||input.sourceId.length>500)throw Error('Stable source ID required');
11
+ const body=structuredClone(input.body);if(!body||Object.keys(body).some(k=>!fields.includes(k))||!body.firstName?.trim()||!body.lastName?.trim())throw Error('Supported identity fields and names required; use DOB/profile helpers for assessment fields');
12
+ if('veteranStatus' in body&&!veteranStatuses.includes(body.veteranStatus))throw error('INVALID_VETERAN_STATUS',`Veteran status must be omitted or one of: ${veteranStatuses.join('; ')}`);
13
+ for(const [k,v]of Object.entries(body))if(typeof v!=='string'||/[\r\n\0]/.test(v)||v.length>255)throw Error('Invalid client identity field');
14
+ if(body.socSecNo!==undefined&&!/^\d{9}$/.test(body.socSecNo))throw Error('SSN must contain nine digits');
15
+ if(!/^\d{4}-\d\d-\d\dT\d\d:\d\d:\d\d(?:\.000)?(?:Z|[+-]\d\d:\d\d)$/.test(body.dateEffective??'')||!Number.isFinite(Date.parse(body.dateEffective))||new Date(body.dateEffective.slice(0,19)+'Z').toISOString().slice(0,19)!==body.dateEffective.slice(0,19))throw Error('Explicit valid creation effective date required');
16
+ body.dateEffective=new Date(body.dateEffective).toISOString();if(input.allowDistinctClient!==undefined&&typeof input.allowDistinctClient!=='boolean')throw Error('allowDistinctClient must be boolean');
17
+ return {body,sourceHash:digest(input.sourceId),intentHash:digest({body,allowDistinctClient:input.allowDistinctClient??false})};
18
+ }
19
+ /** Source IDs map to server-returned IDs. Names/SSNs only flag candidates and never select a client.
20
+ * A missing creation receipt requires explicit resolveCreated after operator verification. */
21
+ export function createClientCreator({api,store}){
22
+ const tenantScope=apiScope(api);
23
+ if(!store?.withLock)throw Error('REST API and durable store required');
24
+ const scope=JSON.stringify([tenantScope,'client-creation']);
25
+ const read=async clientId=>{const rows=toItems(await api.clients.get({path:{clientId:id(clientId)}}));if(rows.length!==1||rows[0].clientId!==clientId||rows[0].isActive!==true)throw Error('Active client readback required');return rows[0];};
26
+ const verify=(client,body)=>{for(const [k,v]of Object.entries(body))if(k!=='dateEffective'&&client[k]!==v)throw error('CLIENT_CREATION_DRIFT','Client field does not match creation intent',{field:k});};
27
+ const candidates=async body=>{
28
+ const quote=s=>"'"+s.replaceAll("'","''")+"'";const filter=`(first_name eq ${quote(body.firstName)} and last_name eq ${quote(body.lastName)})`+(body.socSecNo?` or soc_sec_no eq ${quote(body.socSecNo)}`:'');
29
+ let result;try{result=await api.clients.list({query:{query:filter,total_count:true,perPage:100,startPage:1}});}catch(e){if(e.status===404)return [];throw e;}
30
+ if(!Number.isSafeInteger(result?.count)||!Array.isArray(result.values)||result.count!==result.values.length||result.count>100)throw Error('Candidate list incomplete; review before creating');
31
+ const ids=result.values.map(c=>id(c.clientId));if(new Set(ids).size!==ids.length)throw Error('Duplicate candidate IDs');return ids;
32
+ };
33
+ const run=async(input,resolution)=>{
34
+ const p=prepareClientCreation(input);
35
+ return store.withLock(scope,async()=>{
36
+ const entries=await store.read(scope);if(!Array.isArray(entries)||entries.some(e=>e.version!==1||!['attempted','created','verified'].includes(e.stage)))throw Error('Invalid client mapping journal');
37
+ let entry=entries.find(e=>e.sourceHash===p.sourceHash);if(entries.filter(e=>e.sourceHash===p.sourceHash).length>1||entry&&entry.intentHash!==p.intentHash)throw error('SOURCE_ID_CONFLICT','Source ID was already used with different client details');
38
+ const persist=async stage=>{entry={...entry,version:1,sourceHash:p.sourceHash,intentHash:p.intentHash,stage};const i=entries.findIndex(e=>e.sourceHash===p.sourceHash);if(i<0)entries.push(entry);else entries[i]=entry;await store.write(scope,structuredClone(entries));};
39
+ const previous=entry?.stage;
40
+ if(resolution!==undefined){
41
+ id(resolution);if(!entry||entry.stage!=='attempted'||entry.clientId)throw error('RESOLUTION_NOT_REQUIRED','Only a creation attempt without a receipt can be explicitly resolved');
42
+ if(entries.some(e=>e.clientId===resolution))throw error('CLIENT_ALREADY_MAPPED','Client ID already belongs to another source');
43
+ verify(await read(resolution),p.body);entry.clientId=resolution;await persist('created');
44
+ }
45
+ if(entry&&!entry.clientId)throw error('CLIENT_CREATE_UNCERTAIN','No client receipt was saved; verify the server result and explicitly resolveCreated. Do not resend creation',{stage:entry.stage});
46
+ if(!entry){
47
+ const found=await candidates(p.body);if(found.length&&!input.allowDistinctClient)throw error('POSSIBLE_DUPLICATE','Potential existing clients require review; no automatic matching or merging',{candidateClientIds:found});
48
+ await persist('attempted');let response;
49
+ try{response=await api.clients.create({body:p.body});}catch(cause){throw error('CLIENT_CREATE_UNCERTAIN','Creation receipt unavailable; explicit resolution required',{stage:'attempted',cause});}
50
+ const rows=toItems(response);if(rows.length!==1||!Number.isSafeInteger(rows[0].clientId)||rows[0].clientId<1)throw error('CLIENT_CREATE_UNCERTAIN','Creation returned no unambiguous client ID',{stage:'attempted'});
51
+ if(entries.some(e=>e.sourceHash!==p.sourceHash&&e.clientId===rows[0].clientId))throw error('CLIENT_ALREADY_MAPPED','Server returned an ID mapped to another source');
52
+ entry.clientId=rows[0].clientId;await persist('created');
53
+ }
54
+ const client=await read(entry.clientId);verify(client,p.body);await persist('verified');return {clientId:entry.clientId,sourceId:input.sourceId,outcome:previous==='verified'?'already-present':'verified'};
55
+ });
56
+ };
57
+ return {create:input=>run(input),resolveCreated:({clientId,...input})=>run(input,id(clientId)),lookup:async sourceId=>{
58
+ if(typeof sourceId!=='string'||!sourceId.trim())throw Error('Source ID required');return store.withLock(scope,async()=>{const entries=await store.read(scope),matches=entries.filter(e=>e.sourceHash===digest(sourceId));if(matches.length>1)throw Error('Duplicate source mappings');const e=matches[0];return e?{clientId:e.clientId??null,stage:e.stage}:null;});
59
+ }};
60
+ }
@@ -0,0 +1,59 @@
1
+ import {pageConnection} from './connection.mjs';
2
+ import {withPageLock} from './page-lock.mjs';
3
+
4
+ const nameLabels={firstName:'Client First Name',middleName:'Client Middle Name',lastName:'Client Last Name',suffix:'Client Suffix'};
5
+ const rowLabels={search_alias:'Alias',socSecNo:'Social Security',nameDataQuality:'Name Data Quality',ssnDataQuality:'SSN Data Quality',veteranStatus:'U.S. Military Veteran?',sex:'Sex'};
6
+ const id=n=>{if(!Number.isSafeInteger(n)||n<=0)throw Error('Positive client ID required');};
7
+
8
+ /** Fresh Client Record forms; Sex is saved by the UI's current GWT serializer. */
9
+ export function createClientProfileUi({page}) {
10
+ const connection=pageConnection(page),base=connection.moduleBaseUrl;
11
+ const ready=()=>page.waitForFunction(()=>!document.body.innerText.includes('Processing...')&&!document.body.innerText.includes('Loading...'),null,{timeout:30000});
12
+ const dialog=()=>page.getByRole('dialog',{name:'Client Record',exact:true});
13
+ const row=label=>dialog().getByText(label,{exact:true}).locator('xpath=ancestor::tr[.//input or .//select][1]');
14
+ const cancel=async()=>{if(await dialog().isVisible()){await dialog().getByRole('button',{name:'Cancel',exact:true}).click();await dialog().waitFor({state:'hidden'});}};
15
+ const open=async clientId=>{
16
+ id(clientId);if(new URL(page.url()).origin+new URL(page.url()).pathname!==base+'index.html')throw Error('Configured application page required');await ready();
17
+ if(await page.locator('[role=dialog]:visible').count())throw Error('Close dialogs before profile workflow');
18
+ // Leaving the client discards the cached model without reloading the authenticated app.
19
+ if(/Client - \(\d+\)/.test(await page.locator('body').innerText())){
20
+ await page.getByRole('tab',{name:'Client Information',exact:true}).click();await page.getByRole('tab',{name:'Summary',exact:true}).click();await ready();
21
+ await page.getByRole('button',{name:'Exit',exact:true}).click();await page.waitForURL(u=>u.hash==='#clientPointClientMain');await ready();
22
+ }
23
+ await page.goto(base+'index.html#loadClient;clientId='+clientId);
24
+ await page.waitForFunction(clientId=>document.body.innerText.includes(`Client - (${clientId})`)&&!document.body.innerText.includes('Processing...')&&!document.body.innerText.includes('Loading...'),clientId,{timeout:30000});
25
+ await page.getByRole('tab',{name:'Client Profile',exact:true}).click();await ready();
26
+ await page.getByTitle('Edit Client Record',{exact:true}).click();await dialog().waitFor();await ready();
27
+ await page.waitForFunction(()=>[...document.querySelectorAll('[role="dialog"] select')].length===4&&[...document.querySelectorAll('[role="dialog"] select')].every(e=>e.options.length>1),null,{timeout:30000});
28
+ };
29
+ const read=async()=>{
30
+ const values={},choices={};
31
+ for(const [key,label] of Object.entries(nameLabels))values[key]=await dialog().getByLabel(label,{exact:true}).inputValue();
32
+ for(const [key,label] of Object.entries(rowLabels)){
33
+ const field=row(label).locator(key==='search_alias'||key==='socSecNo'?'input':'select');
34
+ if(key==='socSecNo'){
35
+ const parts=await field.evaluateAll(es=>es.map(e=>({value:e.value,maxLength:e.maxLength})));
36
+ if(JSON.stringify(parts.map(p=>p.maxLength))!=='[3,2,4]')throw Error('SSN widget changed');values[key]=parts.map(p=>p.value).join('');
37
+ }else{
38
+ if(await field.count()!==1)throw Error('Profile field is ambiguous');const value=await field.inputValue();values[key]=value==='-Select-'?'':value;
39
+ if(key!=='search_alias')choices[key]=(await field.locator('option').allTextContents()).filter(v=>v!=='-Select-');
40
+ }
41
+ }
42
+ return {values,choices};
43
+ };
44
+ const inspect=clientId=>withPageLock(page,async()=>{await open(clientId);try{return await read();}finally{await cancel();}});
45
+ const updateSex=({clientId,sex,expected,beforeSave})=>withPageLock(page,async()=>{
46
+ await open(clientId);let attempted=false,verified=false;
47
+ try{
48
+ const current=await read();
49
+ if(!current.choices.sex.includes(sex))throw Error('Sex option unavailable');
50
+ for(const [key,value] of Object.entries(expected))if(key!=='sex'&&current.values[key]!==value)throw Error('Client Record changed before Sex save');
51
+ if(current.values.sex===sex)return current;
52
+ await row('Sex').locator('select').selectOption({label:sex});
53
+ await beforeSave();attempted=true;
54
+ await dialog().getByRole('button',{name:'Save',exact:true}).click();await dialog().waitFor({state:'hidden'});await ready();
55
+ await open(clientId);const result=await read();if(result.values.sex!==sex)throw Error('Sex did not persist');verified=true;return result;
56
+ }finally{if(!attempted||verified)await cancel();}
57
+ });
58
+ return {connection,inspect,updateSex};
59
+ }
@@ -0,0 +1,24 @@
1
+ import type {Page} from 'playwright-core';
2
+ import type {WellSkyClient} from '../dist/esm/index.js';
3
+ import type {UiMutationStore} from './index.mjs';
4
+ export type ClientSex='Male'|'Female'|"Client doesn't know"|'Client prefers not to answer'|'Data not collected';
5
+ /** Fields in Client Profile > Client Record. DOB, legacy Gender and race are separate. */
6
+ export interface ClientRecordValues {
7
+ firstName:string;middleName:string;lastName:string;suffix:string;search_alias:string;
8
+ socSecNo:string;nameDataQuality:string;ssnDataQuality:string;veteranStatus:string;sex:ClientSex|'';
9
+ }
10
+ export interface ClientProfileSnapshot {values:ClientRecordValues;choices:Record<'nameDataQuality'|'ssnDataQuality'|'veteranStatus'|'sex',string[]>;}
11
+ export interface ClientProfileUi {
12
+ readonly connection?:import("./connection.mjs").UiConnection;
13
+ inspect(clientId:number):Promise<ClientProfileSnapshot>;
14
+ updateSex(input:{clientId:number;sex:ClientSex;expected:ClientRecordValues;beforeSave:()=>Promise<void>}):Promise<ClientProfileSnapshot>;
15
+ }
16
+ export type ClientProfileChanges=Partial<Omit<ClientRecordValues,'sex'>>&{sex?:ClientSex};
17
+ export interface ClientProfileUpdate {clientId:number;key:string;changes:ClientProfileChanges;/** Required for REST changes; Sex uses current UI audit context. */dateEffective?:string;}
18
+ export interface ClientProfile {
19
+ read(clientId:number):Promise<ClientProfileSnapshot&{clientId:number}>;
20
+ /** Same key/input/journal reconciles uncertain writes. Never automatically replays an update. */
21
+ update(input:ClientProfileUpdate):Promise<{clientId:number;key:string;outcome:'verified'|'already-present';values:ClientRecordValues}>;
22
+ }
23
+ export function createClientProfile(options:{api:WellSkyClient;ui:ClientProfileUi;store:UiMutationStore}):ClientProfile;
24
+ export function createClientProfileUi(options:{page:Page}):ClientProfileUi;
@@ -0,0 +1,94 @@
1
+ import {apiScope} from './connection.mjs';
2
+ import {createHash} from 'node:crypto';
3
+ import {toItems} from '../dist/esm/index.js';
4
+
5
+ const restKeys=['firstName','middleName','lastName','suffix','search_alias','socSecNo','nameDataQuality','ssnDataQuality','veteranStatus'];
6
+ const limits={firstName:30,middleName:30,lastName:30,suffix:10,search_alias:255};
7
+ const protectedKeys=['svpprofdob','svpprofdobtype','svpprofgender','hud_raceethnicity','hud_raceethnicitydetail','phone','email'];
8
+ const digest=value=>createHash('sha256').update(JSON.stringify(value,(_,v)=>v&&typeof v==='object'&&!Array.isArray(v)?Object.fromEntries(Object.entries(v).sort(([a],[b])=>a.localeCompare(b))):v)).digest('hex');
9
+ const pick=(value,keys)=>Object.fromEntries(keys.map(k=>[k,value[k]??'']));
10
+ const same=(a,b)=>Object.entries(b).every(([k,v])=>a[k]===v);
11
+ const id=n=>{if(!Number.isSafeInteger(n)||n<=0)throw Error('Positive client ID required');};
12
+ 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);
13
+
14
+ /** Client Record section only. REST preserves the complete record; Sex uses the UI.
15
+ * Durable hashes prevent replay of uncertain writes without retaining SSNs in the journal. */
16
+ export function createClientProfile({api,ui,store}) {
17
+ const tenantScope=apiScope(api, ui);
18
+ if(!ui?.inspect||!ui?.updateSex||!store)throw Error('REST client, profile UI and durable store required');
19
+ const readRest=async clientId=>{
20
+ const rows=toItems(await api.clients.get({path:{clientId}}));
21
+ if(rows.length!==1||rows[0].clientId!==clientId||rows[0].isActive===false)throw Error('Active client readback unavailable');return rows[0];
22
+ };
23
+ const read=async clientId=>{
24
+ id(clientId);const snapshot=await ui.inspect(clientId),rest=await readRest(clientId);
25
+ if(!same(snapshot.values,pick(rest,restKeys)))throw Error('Client Record REST and UI disagree');
26
+ return {clientId,...snapshot};
27
+ };
28
+ const update=async input=>{
29
+ if(!input||Object.keys(input).some(k=>!['key','clientId','changes','dateEffective'].includes(k)))throw Error('Unknown profile update field');
30
+ const {key,clientId,dateEffective}=input;id(clientId);
31
+ if(typeof key!=='string'||!key.trim()||key.length>200)throw Error('Stable profile update key required');
32
+ const changes=structuredClone(input.changes);
33
+ if(!changes||typeof changes!=='object'||Array.isArray(changes)||!Object.keys(changes).length)throw Error('Profile changes required');
34
+ for(const [field,value] of Object.entries(changes)){
35
+ if(![...restKeys,'sex'].includes(field))throw Error(`Unsupported Client Record field: ${field}`);
36
+ if(typeof value!=='string'||/[\r\n\0]/.test(value))throw Error(`Invalid Client Record field: ${field}`);
37
+ if(limits[field]!==undefined&&value.length>limits[field])throw Error(`${field} exceeds ${limits[field]} characters`);
38
+ if(field==='socSecNo'&&value!==''&&!/^\d{9}$/.test(value))throw Error('SSN must be nine digits or an empty string');
39
+ }
40
+ const restChanges=Object.fromEntries(Object.entries(changes).filter(([k])=>k!=='sex'));
41
+ if(Object.keys(restChanges).length&&!instant(dateEffective))throw Error('Timezone-qualified dateEffective required for REST profile changes');
42
+ if(dateEffective!==undefined&&!instant(dateEffective))throw Error('Invalid dateEffective');
43
+ const keyHash=digest(key),intentHash=digest({clientId,changes,dateEffective});
44
+ return store.withLock(JSON.stringify([tenantScope,'client-profile',clientId]),async()=>{
45
+ const scope=JSON.stringify([tenantScope,'client-profile',clientId]),entries=await store.read(scope);
46
+ if(!Array.isArray(entries))throw Error('Invalid profile journal');
47
+ let entry=entries.find(e=>e.keyHash===keyHash);
48
+ if(entries.filter(e=>e.keyHash===keyHash).length>1||entry&&entry.intentHash!==intentHash)throw Error('Profile key conflicts with an earlier intent');
49
+ if(entries.some(e=>e.keyHash!==keyHash&&e.stage!=='verified'))throw Error('Another profile update is unresolved');
50
+ if(entry&&(!['planned','rest-attempted','rest-verified','ui-attempted','verified'].includes(entry.stage)||entry.version!==1))throw Error('Invalid profile journal stage');
51
+ const wasVerified=entry?.stage==='verified';
52
+ const persist=async stage=>{
53
+ entry={...entry,version:1,keyHash,intentHash,stage};
54
+ const i=entries.findIndex(e=>e.keyHash===keyHash);if(i<0)entries.push(entry);else entries[i]=entry;
55
+ await store.write(scope,structuredClone(entries));
56
+ };
57
+ let snapshot=await ui.inspect(clientId),rest=await readRest(clientId);
58
+ for(const [field,options] of Object.entries(snapshot.choices))if(field in changes&&!options.includes(changes[field]))throw Error(`Unavailable Client Record choice: ${field}`);
59
+ const core=pick(rest,restKeys);
60
+ if(!same(snapshot.values,core))throw Error('Client Record REST and UI disagree');
61
+ if(!entry){
62
+ const target={...core,...restChanges};
63
+ entry={restExpectedHash:digest(target),protectedHash:digest(pick(rest,protectedKeys)),uiExpectedHash:digest({...target,sex:changes.sex??snapshot.values.sex})};
64
+ await persist('planned');
65
+ }
66
+ const protectedMatch=()=>digest(pick(rest,protectedKeys))===entry.protectedHash;
67
+ if(!protectedMatch())throw Error('Unrequested profile fields changed; reconcile before continuing');
68
+ let restMatches=digest(pick(rest,restKeys))===entry.restExpectedHash;
69
+ if(!restMatches){
70
+ if(entry.stage!=='planned')throw Error('REST profile update unresolved or changed; refusing another write');
71
+ // WellSky clears SSN when omitted, even during a middle-name-only update.
72
+ const body={...pick(rest,restKeys),...restChanges,dateEffective};
73
+ if(digest(pick(body,restKeys))!==entry.restExpectedHash)throw Error('Client Record changed before REST update');
74
+ await persist('rest-attempted');
75
+ let error;try{await api.clients.update({path:{client_id:clientId},body});}catch(e){error=e;}
76
+ rest=await readRest(clientId);
77
+ restMatches=digest(pick(rest,restKeys))===entry.restExpectedHash;
78
+ if(!restMatches||!protectedMatch())throw error??Error('REST profile readback mismatch; journal blocks another write');
79
+ snapshot=await ui.inspect(clientId);
80
+ if(!same(snapshot.values,pick(rest,restKeys)))throw Error('Client Record UI did not reflect the REST update');
81
+ }
82
+ if(entry.stage==='planned'||entry.stage==='rest-attempted')await persist('rest-verified');
83
+ if(digest(snapshot.values)!==entry.uiExpectedHash){
84
+ if(entry.stage==='ui-attempted'||wasVerified)throw Error('Client Record UI save unresolved or changed; refusing another save');
85
+ if(changes.sex===undefined)throw Error('Unrequested Sex value changed');
86
+ snapshot=await ui.updateSex({clientId,sex:changes.sex,expected:snapshot.values,beforeSave:()=>persist('ui-attempted')});
87
+ }
88
+ rest=await readRest(clientId);
89
+ if(digest(snapshot.values)!==entry.uiExpectedHash||digest(pick(rest,restKeys))!==entry.restExpectedHash||!protectedMatch())throw Error('Profile verification mismatch; journal blocks replay');
90
+ await persist('verified');return {clientId,key,outcome:wasVerified?'already-present':'verified',values:snapshot.values};
91
+ });
92
+ };
93
+ return {read,update};
94
+ }
@@ -0,0 +1,135 @@
1
+ import {pageConnection} from './connection.mjs';
2
+ import {createHash} from 'node:crypto';
3
+ import {decodeMappedResponse} from './gwt-mapped.mjs';
4
+ import {withPageLock} from './page-lock.mjs';
5
+ import {AttachmentConflictError} from './attachment-conflict.mjs';
6
+ const id=n=>{if(!Number.isSafeInteger(n)||n<=0)throw Error('Positive integer ID required');return n;};
7
+ const hash=b=>createHash('sha256').update(b).digest('hex');
8
+ const maxBytes=10_000_000;
9
+
10
+ /** Browser-authenticated attachment access where the REST account cannot upload/read files.
11
+ * Own this page exclusively. The store must persist before resolving and survive restarts. */
12
+ export function createClientWorkflows({page,store}) {
13
+ const connection=pageConnection(page),base=connection.moduleBaseUrl;
14
+ const guard=()=>{const u=new URL(page.url());if(u.origin+u.pathname!==base+'index.html')throw Error('Configured application page required');};
15
+ const ready=()=>page.waitForFunction(()=>!document.body.innerText.includes('Processing...')&&!document.body.innerText.includes('Loading...'),null,{timeout:30000});
16
+ let wire;
17
+ const openClient=async clientId=>{
18
+ id(clientId);guard();
19
+ if(await page.locator('[role=dialog]:visible').count())throw Error('Close dialogs before client workflow');
20
+ wire=undefined;
21
+ let pendingHeaders;
22
+ const listener=request=>{
23
+ if(request.url()===base+'service/MetaQueryService'&&request.postData()?.includes('|getMappedResults|')){
24
+ const policy=request.postData().split('|')[4];pendingHeaders=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
+ page.on('request',listener);
28
+ try{
29
+ if(new URL(page.url()).hash!==`#loadClient;clientId=${clientId}`)await page.goto(base+`index.html#loadClient;clientId=${clientId}`);
30
+ await page.getByRole('tab',{name:'Summary',exact:true}).click();await ready();await page.getByRole('tab',{name:'Client Profile',exact:true}).click();await ready();await pendingHeaders;
31
+ if(!wire||!new RegExp(`Client - \\(${clientId}\\)`).test(await page.locator('body').innerText())||await page.locator('[id="formfield-password"]:visible').count())throw Error('Client identity unavailable');
32
+ }finally{page.off('request',listener);}
33
+ };
34
+ const mapped=async query=>{
35
+ guard();if(!wire||!/^SELECT /.test(query)||/[|\\\r\n]/.test(query))throw Error('Invalid mapped read');
36
+ 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 1001,0'];
37
+ const body=['7','0',strings.length,...strings,1,2,3,4,5,5,6,7,7,8,9,0,0,1001,1,''].join('|');
38
+ 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});
39
+ if(r.status!==200)throw Error('GWT read failed');const result=decodeMappedResponse(r.body);if(result.total!==result.rows.length||result.total>1000)throw Error('Incomplete client list');return result.rows;
40
+ };
41
+ const list=clientId=>mapped(`SELECT l.fileAttachmentId AS attachmentId, l.entityKeyId AS entityId, l.entityType.entityType AS entityType, l.entityType.entityName AS entityName, l.fileAttachment.fileName AS name, l.fileAttachment.extension AS extension, l.fileAttachment.description AS description FROM SpFileAttachmentLink AS l WHERE l.active = TRUE AND l.fileAttachment.active = TRUE AND l.clientId = ${id(clientId)}`);
42
+ const download=async attachmentId=>{
43
+ guard();const result=await page.evaluate(async({url,limit})=>{
44
+ const r=await fetch(url,{credentials:'same-origin',redirect:'error',signal:AbortSignal.timeout(30000)});
45
+ if(!r.ok||!r.body||Number(r.headers.get('content-length'))>limit)throw Error('Attachment download failed');
46
+ const reader=r.body.getReader(),chunks=[];let size=0;
47
+ try{for(;;){const {done,value}=await reader.read();if(done)break;size+=value.length;if(size>limit)throw Error('Attachment too large');chunks.push(value);}}finally{await reader.cancel();}
48
+ const bytes=new Uint8Array(size);let offset=0;for(const c of chunks){bytes.set(c,offset);offset+=c.length;}
49
+ let binary='';for(let i=0;i<bytes.length;i+=8192)binary+=String.fromCharCode(...bytes.subarray(i,i+8192));return btoa(binary);
50
+ },{url:base+`downloadAttachment?attachmentId=${id(attachmentId)}`,limit:maxBytes});
51
+ return Buffer.from(result,'base64');
52
+ };
53
+ const listAttachments=clientId=>withPageLock(page,async()=>{await openClient(clientId);return list(clientId);});
54
+ const downloadAttachment=({clientId,attachmentId})=>withPageLock(page,async()=>{
55
+ await openClient(clientId);if(!(await list(clientId)).some(a=>a.attachmentId===id(attachmentId)))throw Error('Attachment not linked to this client');return download(attachmentId);
56
+ });
57
+ const uploadAttachment=({clientId,location,file,description=''})=>withPageLock(page,async()=>{
58
+ id(clientId);if(!file||typeof file.name!=='string'||!file.name||/[\\/\r\n\0]/.test(file.name)||!(file.buffer instanceof Uint8Array)||!file.buffer.length||file.buffer.length>maxBytes||typeof description!=='string')throw Error('Invalid attachment or size exceeds 10,000,000 bytes');
59
+ const bytes=Buffer.from(file.buffer),checksum=hash(bytes),entityType={profile:'SpClient','case-plans':'CasePlans',goal:'SpGoal'}[location?.kind];
60
+ if(!entityType)throw Error('Unsupported attachment location');const entityId=location.kind==='goal'?id(location.goalId):null;
61
+ const intent={clientId,entityType,entityId,name:file.name,description,checksum};
62
+ const scope=JSON.stringify([base,'attachment',clientId,entityType,entityId,file.name]);
63
+ return store.withLock(scope,async()=>{
64
+ await openClient(clientId);
65
+ if(entityId&&!(await mapped(`SELECT g.goalId AS goalId FROM SpGoal AS g WHERE g.active = TRUE AND g.goalId = ${entityId} AND g.clientId = ${clientId}`)).length)throw Error('Goal does not belong to client');
66
+ const records=await store.read(scope);if(!Array.isArray(records)||records.length>1)throw Error('Invalid upload journal');const previous=records[0];
67
+ if(previous&&JSON.stringify(previous.intent)!==JSON.stringify(intent))throw new AttachmentConflictError('intent-changed','Attachment intent conflicts with prior attempt');
68
+ const matching=async()=>{
69
+ const matches=(await list(clientId)).filter(a=>a.entityType===entityType&&(a.entityId??null)===entityId&&a.name===file.name);
70
+ if(matches.length>1)throw new AttachmentConflictError('duplicate-name','Duplicate attachment names');
71
+ if(previous?.attachmentId&&matches[0]?.attachmentId!==previous.attachmentId)throw new AttachmentConflictError('identity-changed','Saved attachment identity changed or missing prior attachment; reconcile before retrying');
72
+ if(matches.length&&(matches[0].description!==description||hash(await download(matches[0].attachmentId))!==checksum))throw new AttachmentConflictError('content-changed','Existing attachment differs');return matches[0];
73
+ };
74
+ let found=await matching();if(found){await store.write(scope,[{intent,state:'verified',attachmentId:found.attachmentId}]);return {outcome:previous?'reconciled':'already-present',attachment:found,checksum};}
75
+ if(previous)throw new AttachmentConflictError('unresolved','Unresolved or missing prior attachment; refusing another upload');
76
+ await store.write(scope,[{intent,state:'attempted'}]);
77
+ let response;
78
+ try{response=await page.evaluate(async({url,clientId,entityType,entityId,name,mimeType,description,data})=>{
79
+ const form=new FormData();const bytes=Uint8Array.from(atob(data),c=>c.charCodeAt(0));form.set('uploadFormElement',new Blob([bytes],{type:mimeType}),name);form.set('descriptionFormElement',description);form.set('clientId',String(clientId));form.set('entityType',entityType);if(entityId!==null)form.set('entityKey',String(entityId));
80
+ const r=await fetch(url,{method:'POST',body:form,credentials:'same-origin',redirect:'error',signal:AbortSignal.timeout(30000)});return {status:r.status,body:await r.text()};
81
+ },{url:base+'/uploadAttachment',clientId,entityType,entityId,name:file.name,mimeType:file.mimeType??'application/octet-stream',description,data:bytes.toString('base64')});}catch{/* Never replay an uncertain upload. */}
82
+ found=await matching();if(!found)throw Error('Attachment outcome uncertain; journal blocks repeat upload');
83
+ await store.write(scope,[{intent,state:'verified',attachmentId:found.attachmentId}]);
84
+ return {outcome:response?.status===200&&response.body===`${found.attachmentId}:SUCCESS`?'written':'reconciled',attachment:found,checksum};
85
+ });
86
+ });
87
+ const notes=(clientId,goalId)=>mapped(`SELECT n.goalCasenoteId AS noteId, n.goalId AS goalId, n.clientId AS clientId, n.noteDate AS noteDate, n.note AS note, n.providerId AS providerId FROM SpGoalCasenote AS n WHERE n.active = TRUE AND n.clientId = ${id(clientId)} AND n.goalId = ${id(goalId)}`);
88
+ const listCaseNotes=({clientId,goalId})=>withPageLock(page,async()=>{await openClient(clientId);return notes(clientId,goalId);});
89
+ const createCaseNote=async({key,clientId,goalId,providerId,noteDate,note})=>{
90
+ id(clientId);id(goalId);id(providerId);
91
+ if(typeof note!=='string'||!note.trim()||note.length>100000||typeof noteDate!=='string'||!/^\d{4}-\d{2}-\d{2}$/.test(noteDate)||new Date(noteDate+'T12:00:00Z').toISOString().slice(0,10)!==noteDate)throw Error('Note and valid calendar date required');
92
+ if(key!==undefined&&(typeof key!=='string'||!key.trim()||key.length>500))throw Error('Stable case note source key required');
93
+ const intent={clientId,goalId,providerId,noteDate,checksum:hash(note)};
94
+ const scope=JSON.stringify(key===undefined?[base,'case-note',clientId,goalId,providerId,noteDate,hash(note)]:[base,'case-note-source',hash(key)]);
95
+ return store.withLock(scope,async()=>{
96
+ const records=await store.read(scope);if(!Array.isArray(records)||records.length>1)throw Error('Invalid case note journal');const previous=records[0];
97
+ if(previous&&JSON.stringify(previous.intent)!==JSON.stringify(intent))throw Object.assign(Error('Case note source key conflicts with earlier intent'),{code:'CASE_NOTE_SOURCE_CONFLICT'});
98
+ return withPageLock(page,async()=>{
99
+ await openClient(clientId);
100
+ if(!(await mapped(`SELECT g.goalId AS goalId FROM SpGoal AS g WHERE g.active = TRUE AND g.goalId = ${goalId} AND g.clientId = ${clientId}`)).length)throw Error('Goal does not belong to client');
101
+ const match=async()=>{
102
+ const current=await notes(clientId,goalId);
103
+ // Date-only UI controls use the browser's local calendar. Do not apply the REST mobile offset.
104
+ const dates=await page.evaluate(values=>values.map(value=>{const d=new Date(value);return [d.getFullYear(),String(d.getMonth()+1).padStart(2,'0'),String(d.getDate()).padStart(2,'0')].join('-');}),current.map(n=>n.noteDate));
105
+ const matches=current.filter((n,i)=>n.note===note&&dates[i]===noteDate&&n.providerId===providerId);
106
+ if(matches.length>1)throw Error('Duplicate case notes require review');
107
+ if(previous?.noteId&&matches[0]?.noteId!==previous.noteId)throw Error('Saved case note identity changed; reconcile before retrying');return matches[0];
108
+ };
109
+ let found=await match();if(found){await store.write(scope,[{intent,state:'verified',noteId:found.noteId}]);return {outcome:previous?'reconciled':'already-present',note:found};}
110
+ if(previous)throw Error('Unresolved or missing prior case note; refusing another write');
111
+ await page.getByRole('tab',{name:'Case Plans',exact:true}).click();await ready();
112
+ await page.locator(`[id="ClientCasePlansTab.editGoal-imageLink_${goalId}"]`).click();
113
+ const goal=page.getByRole('dialog',{name:'Goal',exact:true});await goal.waitFor();await ready();
114
+ if(!(await goal.innerText()).includes(`Goal - (${clientId})`))throw Error('Goal client mismatch');
115
+ await goal.getByRole('button',{name:'Add Case Note',exact:true}).click();
116
+ const dialog=page.getByRole('dialog',{name:'Case Note',exact:true});await dialog.waitFor();await ready();
117
+ const providerRow=await dialog.getByText('Provider',{exact:true}).locator('xpath=ancestor::tr[.//*[@role="button"]][1]').innerText();
118
+ if(!(await dialog.innerText()).includes(`Case Note - (${clientId})`)||!providerRow.includes(`(${providerId})`))throw Error('Case note provider/client mismatch');
119
+ const checkboxes=dialog.locator('[id^="HouseholdsCheckboxTree.householdCheckbox."] input');
120
+ for(let i=0;i<await checkboxes.count();i++){const box=checkboxes.nth(i);const member=await box.evaluate(e=>Number(e.parentElement.id.match(/\.client-(\d+)$/)?.[1]));if(await box.isEnabled())await box.setChecked(member===clientId);if(await box.isChecked()&&member!==clientId)throw Error('Unexpected case note recipient');}
121
+ const fields=dialog.locator('input.gwt-TextBox');if(await fields.count()!==3)throw Error('Unexpected case note date controls');
122
+ const [year,month,day]=noteDate.split('-');for(const [i,value]of [month,day,year].entries())await fields.nth(i).fill(value);
123
+ await dialog.locator('textarea').fill(note);
124
+ await store.write(scope,[{intent,state:'attempted'}]);
125
+ let acknowledged=false;
126
+ try{await page.locator('[id="CaseNotePopup.saveCasenote"]').click();await dialog.waitFor({state:'hidden'});await ready();acknowledged=true;}catch{/* Reconcile, never press Save again. */}
127
+ found=await match();if(!found)throw Error('Case note outcome uncertain; journal blocks repeat');
128
+ await store.write(scope,[{intent,state:'verified',noteId:found.noteId}]);
129
+ if(await dialog.isVisible())await page.locator('[id="CaseNotePopup.cancel"]').click();
130
+ await goal.getByRole('button',{name:'Exit',exact:true}).click();await ready();return {outcome:acknowledged?'written':'reconciled',note:found};
131
+ });
132
+ });
133
+ };
134
+ return {connection,listAttachments,downloadAttachment,uploadAttachment,listCaseNotes,createCaseNote};
135
+ }
@@ -0,0 +1,4 @@
1
+ export interface UiConnectionOptions {tenant:string;moduleBaseUrl?:string;}
2
+ export interface UiConnection {readonly tenant:string;readonly moduleBaseUrl:string;readonly scope:string;}
3
+ /** Resolve a tenant's application URL. Both live and training use this contract. */
4
+ export function resolveUiConnection(options:UiConnectionOptions):UiConnection;
@@ -0,0 +1,41 @@
1
+ const moduleSuffix = '/com.bowmansystems.sp5.core.ServicePoint/';
2
+ const defaultOrigin = 'https://wscs.wellsky.com';
3
+ const defaultApi = 'https://csclientapi.wellsky.com/client-api-prod';
4
+ const pages = new WeakMap();
5
+
6
+ export function validateTenant(tenant) {
7
+ if (typeof tenant !== 'string' || !/^[A-Za-z0-9_-]+$/.test(tenant)) throw new TypeError('A valid tenant is required');
8
+ return tenant;
9
+ }
10
+
11
+ /** The tenant selects the deployment; live and training use identical operations. */
12
+ export function resolveUiConnection({tenant, moduleBaseUrl} = {}) {
13
+ validateTenant(tenant);
14
+ const url = new URL(moduleBaseUrl ?? `${defaultOrigin}/${tenant}${moduleSuffix}`);
15
+ if (url.protocol !== 'https:' || url.username || url.password || url.search || url.hash || url.pathname !== `/${tenant}${moduleSuffix}`) throw new TypeError('Module URL must match the configured tenant');
16
+ return Object.freeze({tenant, moduleBaseUrl: url.href, scope: url.origin === defaultOrigin ? tenant : JSON.stringify([url.origin, tenant])});
17
+ }
18
+
19
+ /** Pin a page once. A helper must never follow a later navigation into another tenant. */
20
+ export function pageConnection(page) {
21
+ let connection = pages.get(page);
22
+ if (!connection) {
23
+ const url = new URL(page.url());
24
+ const match = url.pathname.match(/^\/([A-Za-z0-9_-]+)\/com\.bowmansystems\.sp5\.core\.ServicePoint\/index\.html$/);
25
+ if (!match) throw new TypeError('A Community Services application page is required');
26
+ connection = resolveUiConnection({tenant: match[1], moduleBaseUrl: url.origin + url.pathname.slice(0, -'index.html'.length)});
27
+ pages.set(page, connection);
28
+ }
29
+ return connection;
30
+ }
31
+
32
+ export function apiScope(api, ...adapters) {
33
+ const tenant = validateTenant(api?.tenant);
34
+ for (const adapter of adapters) {
35
+ const adapterTenant = adapter?.connection?.tenant ?? adapter?.tenant;
36
+ if (adapterTenant !== undefined && adapterTenant !== tenant) throw new TypeError('REST and UI tenants must match');
37
+ }
38
+ const base = (api.baseUrl ?? defaultApi).replace(/\/$/, '');
39
+ // Preserve existing journals on the default deployment, including uncertain writes.
40
+ return base === defaultApi ? tenant : JSON.stringify([base, tenant]);
41
+ }