@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,425 @@
1
+ import {apiScope} from './connection.mjs';
2
+ import { createHash } from 'node:crypto';
3
+ import { toItems } from '../dist/esm/index.js';
4
+ const hash = v => createHash('sha256').update(JSON.stringify(v)).digest('hex');
5
+ const id = v => {
6
+ if (!Number.isSafeInteger(v) || v < 1)
7
+ throw Error('Positive shelter ID required');
8
+ return v;
9
+ };
10
+ const text = (v, n) => {
11
+ if (typeof v !== 'string' || !v.trim() || v.length > 200 || /[\0\r\n]/.test(v))
12
+ throw Error(`Invalid ${n}`);
13
+ return v;
14
+ };
15
+ const instant = v => {
16
+ if (typeof v !== 'string' || !/^\d{4}-\d\d-\d\dT\d\d:\d\d:\d\d(?:\.000)?(?:Z|[+-]\d\d:\d\d)$/.test(v) || !Number.isFinite(Date.parse(v)) || new Date(v.slice(0, 19) + 'Z').toISOString().slice(0, 19) !== v.slice(0, 19))
17
+ throw Error('Shelter time must be a valid timezone-qualified whole-second instant');
18
+ return new Date(v).toISOString();
19
+ };
20
+ const rest = v => v == null ? null : instant(/[Zz]|[+-]\d\d:\d\d$/.test(v) ? v : v + 'Z');
21
+ const label = v => v?.replace(/\s*\(HUD\)\s*$/, '').trim();
22
+ export class ShelterConflictError extends Error {
23
+ constructor(reason) { super(`Shelter conflict: ${reason}`); this.name = 'ShelterConflictError'; this.code = 'SHELTER_CONFLICT'; this.reason = reason; }
24
+ }
25
+ const fail = r => { throw new ShelterConflictError(r); };
26
+ /** Reads the actual provider setting; never infers ES mode from its name or AIRS code. */
27
+ export async function readShelterProgram(api, { providerId }) {
28
+ id(providerId);
29
+ const items = toItems(await api.providers.getComplete({ path: { providerId } }));
30
+ if (items.length !== 1 || items[0].providerId !== providerId)
31
+ fail('provider-unreadable');
32
+ const p = items[0], th = /^Transitional housing/i.test(p.projectType ?? ''), es = /^Emergency Shelter/i.test(p.projectType ?? '');
33
+ const mode = p.emergencyShelterTrackingMethod === 'Night-by-Night' ? 'night-by-night' : p.emergencyShelterTrackingMethod === 'Entry/Exit Date' ? 'entry-exit' : null;
34
+ if (!mode || !th && !es || th && mode !== 'entry-exit')
35
+ fail('unsupported-tracking-mode');
36
+ return { providerId, name: p.providerName, projectType: p.projectType, trackingMethod: p.emergencyShelterTrackingMethod, mode };
37
+ }
38
+ const normalizeIn = input => {
39
+ if (!input || Object.keys(input).some(k => !['key', 'providerId', 'unitListId', 'householdId', 'headClientId', 'entryDate', 'members', 'enrollmentStrategy'].includes(k)))
40
+ throw Error('Unsupported shelter check-in input');
41
+ const n = { key: text(input.key, 'source key'), providerId: id(input.providerId), unitListId: id(input.unitListId), householdId: id(input.householdId), headClientId: id(input.headClientId), entryDate: instant(input.entryDate), enrollmentStrategy: input.enrollmentStrategy ?? 'require-existing', members: input.members?.map(m => ({ clientId: id(m.clientId), bedName: text(m.bedName, 'bed name') })).sort((a, b) => a.clientId - b.clientId) };
42
+ if (!['require-existing', 'create-if-missing'].includes(n.enrollmentStrategy) || !Array.isArray(n.members) || !n.members.length || n.members.length > 20 || !n.members.some(m => m.clientId === n.headClientId) || new Set(n.members.map(m => m.clientId)).size !== n.members.length || new Set(n.members.map(m => m.bedName)).size !== n.members.length || n.members.some(m => /^Overflow/i.test(m.bedName)))
43
+ throw Error('Explicit unique household members and named beds required; overflow is not supported');
44
+ return n;
45
+ };
46
+ const calendar = (date, timeZone) => new Intl.DateTimeFormat('en-CA', { timeZone, year: 'numeric', month: '2-digit', day: '2-digit' }).format(new Date(date));
47
+ const normalizeOut = input => {
48
+ if (!input || Object.keys(input).some(k => !['key', 'checkInKey', 'clientIds', 'checkoutDate', 'lastNight', 'timeZone', 'destination', 'reasonForLeaving'].includes(k)))
49
+ throw Error('Unsupported shelter checkout input');
50
+ const n = { key: text(input.key, 'source key'), checkInKey: text(input.checkInKey, 'check-in key'), clientIds: input.clientIds?.map(id).sort((a, b) => a - b), checkoutDate: instant(input.checkoutDate), lastNight: input.lastNight, timeZone: text(input.timeZone, 'program timezone'), destination: input.destination ?? null, reasonForLeaving: input.reasonForLeaving ?? null };
51
+ if (!Array.isArray(n.clientIds) || !n.clientIds.length || new Set(n.clientIds).size !== n.clientIds.length || typeof n.lastNight !== 'string' || !/^\d{4}-\d\d-\d\d$/.test(n.lastNight) || !Number.isFinite(Date.parse(n.lastNight + 'T12:00:00Z')) || new Date(n.lastNight + 'T12:00:00Z').toISOString().slice(0, 10) !== n.lastNight)
52
+ throw Error('Exact checkout members and last shelter night required');
53
+ const next = new Date(n.lastNight + 'T12:00:00Z');
54
+ next.setUTCDate(next.getUTCDate() + 1);
55
+ if (calendar(n.checkoutDate, n.timeZone) !== next.toISOString().slice(0, 10))
56
+ fail('exit-must-follow-last-night');
57
+ for (const k of ['destination', 'reasonForLeaving'])
58
+ if (n[k] !== null)
59
+ text(n[k], k);
60
+ return n;
61
+ };
62
+ /** Durable workflow. Enrollment metadata only; assessment completion remains a separate explicit operation. */
63
+ export function createShelters({ api, ui, store, enrollmentHelpers }) {
64
+ const tenantScope=apiScope(api, ui, enrollmentHelpers);
65
+ if (!ui?.listEnrollments || !ui?.verifyStayIdentity || !ui?.assertBedsAvailable || !ui?.getUnitList || !ui?.checkIn || !store?.withLock || !store?.read || !store?.write)
66
+ throw Error('Shelter dependencies required');
67
+ const scope = JSON.stringify([tenantScope, 'shelters']);
68
+ const identity = async (record, found) => {
69
+ const rows = await ui.verifyStayIdentity({ stays: found, unitListId: record.input.unitListId, marker: `[sdk-shelter:${hash(record.key)}]` });
70
+ if (!Array.isArray(rows) || rows.length !== found.length || new Set(rows.map(r => r.serviceId)).size !== rows.length)
71
+ fail('stay-identity-incomplete');
72
+ const normalized = rows.map(r => {
73
+ id(r.bedId);
74
+ if (r.unitListId !== record.input.unitListId || !found.some(s => s.serviceId === r.serviceId && s.clientId === r.clientId))
75
+ fail('stay-identity-changed');
76
+ return { serviceId: r.serviceId, clientId: r.clientId, bedId: r.bedId, unitListId: r.unitListId };
77
+ }).sort((a, b) => a.serviceId - b.serviceId);
78
+ if (record.identities && JSON.stringify(normalized) !== JSON.stringify(record.identities))
79
+ fail('stay-identity-changed');
80
+ record.identities = normalized;
81
+ };
82
+ const readHousehold = async (n) => {
83
+ const rows = toItems(await api.households.get({ path: { householdId: n.householdId } }));
84
+ if (rows.length !== 1 || rows[0].householdId !== n.householdId || rows[0].isActive === false || !Array.isArray(rows[0].clients))
85
+ fail('household-unreadable');
86
+ const people = rows[0].clients;
87
+ if(people.some(p=>!Number.isSafeInteger(p.clientId)||p.clientId<1||typeof p.headofhouseholdStatus!=='boolean'))fail('household-unreadable');
88
+ if (new Set(people.map(p => p.clientId)).size !== people.length)
89
+ fail('ambiguous-membership');
90
+ const heads = people.filter(p => p.isActive !== false && !p.dateLeft && p.headofhouseholdStatus);
91
+ if (heads.length !== 1 || heads[0].clientId !== n.headClientId)
92
+ fail('head-of-household-missing-or-changed');
93
+ for (const m of n.members) {
94
+ const p = people.find(p => p.clientId === m.clientId);
95
+ if (!p || p.isActive === false || p.dateLeft || !p.dateJoined || rest(p.dateJoined) > n.entryDate)
96
+ fail('membership-does-not-cover-stay');
97
+ }
98
+ return people;
99
+ };
100
+ const entries = async (ids) => {
101
+ const rows = await ui.listEnrollments({ clientIds: ids });
102
+ if (!Array.isArray(rows) || new Set(rows.map(r => r.enrollmentId)).size !== rows.length)
103
+ fail('enrollment-read-incomplete');
104
+ for (const r of rows) {
105
+ id(r.enrollmentId);
106
+ id(r.providerId);
107
+ if(r.groupId!==null)id(r.groupId);
108
+ if (!ids.includes(r.clientId) || !r.entryDate || rest(r.entryDate) !== r.entryDate || r.exitDate !== null && rest(r.exitDate) !== r.exitDate)
109
+ fail('enrollment-read-invalid');
110
+ }
111
+ return rows;
112
+ };
113
+ const stays = async (n) => {
114
+ const result = [];
115
+ for (const { clientId } of n.members) {
116
+ let count;
117
+ const seen = new Set();
118
+ for (let startPage = 1; startPage <= 1000; startPage++) {
119
+ let r;
120
+ try {
121
+ r = await api.services.listForClient({ path: { clientId }, query: { perPage: 100, startPage, total_count: true } });
122
+ }
123
+ catch (error) {
124
+ if (startPage === 1 && error?.status === 404 && await ui.confirmNoServices?.({ clientId }))
125
+ r = { count: 0, values: [] };
126
+ else
127
+ throw error;
128
+ }
129
+ if (!Number.isSafeInteger(r?.count) || r.count < 0 || !Array.isArray(r.values) || count !== undefined && r.count !== count)
130
+ fail('stay-read-incomplete');
131
+ count = r.count;
132
+ for (const row of r.values) {
133
+ id(row.serviceId);
134
+ if (row.clientId !== clientId || seen.has(row.serviceId) || typeof row.isActive !== 'boolean' || typeof row.shelter_item !== 'boolean')
135
+ fail('stay-read-invalid');
136
+ if (row.shelter_item)
137
+ id(row.provider_id);
138
+ seen.add(row.serviceId);
139
+ if (row.isActive && row.shelter_item && row.provider_id === n.providerId) {
140
+ const detail = toItems(await api.services.getComplete({ path: { serviceId: row.serviceId } }));
141
+ if (detail.length !== 1 || detail[0].serviceId !== row.serviceId || detail[0].clientId !== clientId || detail[0].provider_id !== n.providerId || detail[0].shelter_item !== true || detail[0].isActive !== true)
142
+ fail('stay-detail-invalid');
143
+ const d = detail[0];
144
+ result.push({ serviceId: id(d.serviceId), clientId, providerId: n.providerId, needId: id(d.needId), start: rest(d.serviceStartDate), end: rest(d.serviceEndDate), unitListName: d.unitList, bedName: d.unitNameNumber, groupId: d.groupId, note: d.serviceNote ?? '' });
145
+ }
146
+ }
147
+ if (seen.size === count)
148
+ break;
149
+ // Tenants can cap the page size below the requested 100 rows.
150
+ if (!r.values.length || seen.size > count || startPage === 1000)
151
+ fail('stay-read-incomplete');
152
+ }
153
+ }
154
+ return result;
155
+ };
156
+ const covering = (rows, n) => n.members.map(m => {
157
+ const matches = rows.filter(e => e.clientId === m.clientId && e.providerId === n.providerId && e.entryDate <= n.entryDate && (e.exitDate === null || e.exitDate > n.entryDate));
158
+ if (matches.length !== 1)
159
+ fail(matches.length ? 'ambiguous-enrollment' : 'missing-same-program-enrollment');
160
+ return matches[0];
161
+ });
162
+ const groupGuard = (es, n) => {
163
+ const h = es.find(e => e.clientId === n.headClientId);
164
+ if (!h || es.some(e => e.clientId !== n.headClientId && (h.groupId === null || e.groupId !== h.groupId || e.entryDate < h.entryDate || h.exitDate !== null && (e.exitDate === null || e.exitDate > h.exitDate))))
165
+ fail('head-does-not-cover-enrollment-group');
166
+ };
167
+ const validateRecords = records => {
168
+ if (!Array.isArray(records) || records.some(r => r.version !== 1 || !['check-in', 'checkout'].includes(r.kind) || !['pending', 'verified', 'cancelled'].includes(r.stage) || typeof r.hash !== 'string' || typeof r.key !== 'string') || new Set(records.map(r => r.key)).size !== records.length)
169
+ fail('invalid-shelter-journal');
170
+ };
171
+ return {
172
+ getProgram: input => readShelterProgram(api, input),
173
+ async cancelUnsubmittedCheckIn({ key }) {
174
+ text(key, 'source key');
175
+ return store.withLock(scope, async () => {
176
+ const records = await store.read(scope);
177
+ validateRecords(records);
178
+ const record = records.find(r => r.key === key);
179
+ if (!record || record.kind !== 'check-in' || record.stage === 'verified' || record.steps.stay || record.steps.entry === 'attempted')
180
+ fail('check-in-cannot-be-cancelled');
181
+ if (record.stage === 'cancelled')
182
+ return { key, outcome: 'already-cancelled', enrollments: record.cancelledEnrollments };
183
+ const people = await readHousehold(record.input), all = await stays(record.input);
184
+ if (all.some(s => !record.baseline.includes(s.serviceId)))
185
+ fail('check-in-cannot-be-cancelled');
186
+ const es = await entries(people.map(p => p.clientId));
187
+ const retained = record.steps.entry === 'verified' ? covering(es, record.input) : [];
188
+ if (retained.length) {
189
+ groupGuard(retained, record.input);
190
+ if (!retained.some(e => e.enrollmentId === record.headEnrollmentId && e.clientId === record.input.headClientId))
191
+ fail('enrollment-changed');
192
+ }
193
+ record.cancelledEnrollments = retained;
194
+ record.stage = 'cancelled';
195
+ await store.write(scope, records);
196
+ return { key, outcome: 'cancelled', enrollments: retained };
197
+ });
198
+ },
199
+ async checkIn(raw) {
200
+ const n = normalizeIn(raw);
201
+ if (n.enrollmentStrategy === 'create-if-missing' && n.members.length > 1 && !enrollmentHelpers?.addHouseholdMembers)
202
+ throw Error('Household enrollment helper required before creation');
203
+ return store.withLock(scope, async () => {
204
+ const records = await store.read(scope);
205
+ validateRecords(records);
206
+ let record = records.find(r => r.key === n.key);
207
+ if (record?.stage === 'cancelled')
208
+ fail('check-in-cancelled');
209
+ if (record && (record.hash !== hash(n) || record.kind !== 'check-in'))
210
+ fail('intent-changed');
211
+ if (records.some(r => r.stage === 'pending' && r.key !== n.key))
212
+ fail('unfinished-shelter-operation');
213
+ const people = await readHousehold(n), ids = people.map(p => p.clientId), program = await readShelterProgram(api, n);
214
+ if (record && record.mode !== program.mode)
215
+ fail('tracking-mode-changed');
216
+ const list = await ui.getUnitList(n);
217
+ if (record && record.unitListName !== list.name)
218
+ fail('unit-list-changed');
219
+ if (!record) {
220
+ record = { version: 1, kind: 'check-in', key: n.key, hash: hash(n), input: n, mode: program.mode, unitListName: list.name, stage: 'pending', steps: {}, baseline: (await stays(n)).map(s => s.serviceId) };
221
+ records.push(record);
222
+ }
223
+ const persist = () => store.write(scope, records);
224
+ let es = await entries(ids);
225
+ if (program.mode === 'night-by-night' && n.enrollmentStrategy === 'create-if-missing') {
226
+ const initial = es.filter(e => e.providerId === n.providerId && n.members.some(m => m.clientId === e.clientId) && (e.exitDate === null || e.exitDate > n.entryDate));
227
+ if (!initial.length && !record.steps.entry) {
228
+ await ui.assertBedsAvailable(n);
229
+ record.steps.entry = 'attempted';
230
+ await persist();
231
+ try {
232
+ await api.enrollments.createEntryAssessment({ body: { clientId: n.headClientId, providerId: n.providerId, type: 'HUD', projectStartDate: n.entryDate } });
233
+ }
234
+ catch { }
235
+ es = await entries(ids);
236
+ }
237
+ if (record.steps.entry) {
238
+ const head = es.filter(e => e.clientId === n.headClientId && e.providerId === n.providerId && e.entryDate === n.entryDate && e.exitDate === null);
239
+ if (head.length !== 1 || record.headEnrollmentId && record.headEnrollmentId !== head[0].enrollmentId)
240
+ fail('uncertain-enrollment-create');
241
+ record.headEnrollmentId = head[0].enrollmentId;
242
+ record.steps.entry = 'verified';
243
+ await persist();
244
+ const missing = n.members.filter(m => !es.some(e => e.clientId === m.clientId && e.providerId === n.providerId && e.groupId === head[0].groupId && e.entryDate === n.entryDate));
245
+ if (missing.length) {
246
+ if (!enrollmentHelpers?.addHouseholdMembers)
247
+ fail('household-enrollment-helper-required');
248
+ await enrollmentHelpers.addHouseholdMembers({ clientId: n.headClientId, enrollmentId: head[0].enrollmentId, memberClientIds: n.members.filter(m => m.clientId !== n.headClientId).map(m => m.clientId) });
249
+ es = await entries(ids);
250
+ }
251
+ }
252
+ }
253
+ let all = await stays(n), found = all.filter(s => !record.baseline.includes(s.serviceId) && s.start === n.entryDate && s.unitListName === list.name);
254
+ const verify = () => {
255
+ if (found.length !== n.members.length || new Set(found.map(s => s.clientId)).size !== found.length)
256
+ fail('uncertain-or-partial-check-in');
257
+ for (const m of n.members) {
258
+ const s = found.find(s => s.clientId === m.clientId), pinned = record.stays?.find(s => s.clientId === m.clientId);
259
+ if (!s || s.bedName !== m.bedName || pinned && (pinned.serviceId !== s.serviceId || pinned.needId !== s.needId || pinned.groupId !== s.groupId || pinned.note !== s.note))
260
+ fail('stay-changed');
261
+ const exits = records.filter(r => r.kind === 'checkout' && r.input?.checkInKey === n.key && r.input.clientIds.includes(m.clientId) && (r.stage === 'verified' || r.attempted));
262
+ if (exits.length > 1 || s.end !== (exits[0]?.input.checkoutDate ?? null))
263
+ fail('stay-end-changed');
264
+ }
265
+ const stayGroup = found[0].groupId;
266
+ if (new Set(found.map(s => s.groupId)).size !== 1 || (stayGroup === null ? found.length > 1 : !Number.isSafeInteger(stayGroup) || stayGroup < 1))
267
+ fail('stay-household-group-mismatch');
268
+ const covered = covering(es, n);
269
+ groupGuard(covered, n);
270
+ if (record.enrollments && covered.some(e => !record.enrollments.some(p => p.enrollmentId === e.enrollmentId && p.clientId === e.clientId && p.entryDate === e.entryDate && p.groupId === e.groupId)))
271
+ fail('enrollment-changed');
272
+ if (record.stage === 'verified')
273
+ for (const e of covered) {
274
+ const exit = records.find(r => r.kind === 'checkout' && r.input?.checkInKey === n.key && r.input.clientIds.includes(e.clientId) && (r.stage === 'verified' || r.attempted));
275
+ const expectedExit = program.mode === 'entry-exit' && exit ? exit.input.checkoutDate : record.enrollments.find(p => p.enrollmentId === e.enrollmentId)?.exitDate;
276
+ if (e.exitDate !== expectedExit)
277
+ fail('enrollment-exit-changed');
278
+ }
279
+ return covered;
280
+ };
281
+ if (!found.length && !record.steps.stay) {
282
+ if (all.some(s => s.end === null || s.start <= n.entryDate && s.end > n.entryDate))
283
+ fail('overlapping-stay');
284
+ if (program.mode === 'night-by-night') {
285
+ const covered = covering(es, n);
286
+ groupGuard(covered, n);
287
+ record.enrollments = covered;
288
+ }
289
+ else if (es.some(e => e.providerId === n.providerId && n.members.some(m => m.clientId === e.clientId) && (e.exitDate === null || e.exitDate > n.entryDate)))
290
+ fail('existing-entry-exit-enrollment');
291
+ let attempted = false, failure;
292
+ try {
293
+ await ui.checkIn({ ...n, unitListName: list.name, mode: program.mode, marker: `[sdk-shelter:${hash(n.key)}]` }, async () => {
294
+ if (attempted)
295
+ fail('second-save');
296
+ record.steps.stay = 'attempted';
297
+ await persist();
298
+ attempted = true;
299
+ });
300
+ }
301
+ catch (e) {
302
+ failure = e;
303
+ }
304
+ if (!attempted)
305
+ throw failure ?? Error('Shelter save was not journaled');
306
+ all = await stays(n);
307
+ found = all.filter(s => !record.baseline.includes(s.serviceId) && s.start === n.entryDate && s.unitListName === list.name);
308
+ es = await entries(ids);
309
+ if (!found.length)
310
+ throw failure ?? new ShelterConflictError('uncertain-check-in');
311
+ }
312
+ const covered = verify();
313
+ await identity(record, found);
314
+ const outcome = record.stage === 'verified' ? 'already-present' : record.steps.stay === 'attempted' ? 'verified' : 'reconciled';
315
+ record.stays = found;
316
+ record.enrollments = covered;
317
+ record.steps.stay = 'verified';
318
+ record.stage = 'verified';
319
+ await persist();
320
+ return { key: n.key, outcome, mode: program.mode, stays: found, enrollments: covered };
321
+ });
322
+ },
323
+ async checkout(raw) {
324
+ const n = normalizeOut(raw);
325
+ if (!ui.checkout)
326
+ throw Error('Shelter checkout UI required');
327
+ if (label(n.destination) === 'Other' || label(n.reasonForLeaving) === 'Other' || label(n.destination) === 'Rental by client, with ongoing housing subsidy')
328
+ throw Error('Conditional exit details require a supported separate exit workflow');
329
+ return store.withLock(scope, async () => {
330
+ const records = await store.read(scope);
331
+ validateRecords(records);
332
+ let record = records.find(r => r.key === n.key);
333
+ if (record && (record.hash !== hash(n) || record.kind !== 'checkout'))
334
+ fail('intent-changed');
335
+ if (records.some(r => r.stage === 'pending' && r.key !== n.key))
336
+ fail('unfinished-shelter-operation');
337
+ const entry = records.find(r => r.key === n.checkInKey && r.kind === 'check-in' && r.stage === 'verified');
338
+ if (!entry || n.clientIds.some(c => !entry.input.members.some(m => m.clientId === c)))
339
+ fail('verified-check-in-required');
340
+ const people = await readHousehold(entry.input), ids = people.map(p => p.clientId), program = await readShelterProgram(api, entry.input);
341
+ if (program.mode !== entry.mode)
342
+ fail('tracking-mode-changed');
343
+ if (program.mode === 'entry-exit' && (!n.destination || !n.reasonForLeaving))
344
+ fail('exit-choices-required');
345
+ if (program.mode === 'night-by-night' && (n.destination || n.reasonForLeaving))
346
+ fail('nightly-checkout-must-not-exit-program');
347
+ if (n.checkoutDate <= entry.input.entryDate || calendar(entry.input.entryDate, n.timeZone) > n.lastNight)
348
+ fail('checkout-precedes-stay');
349
+ let es = await entries(ids), all = await stays(entry.input);
350
+ await identity(entry, entry.stays);
351
+ const selected = entry.stays.filter(s => n.clientIds.includes(s.clientId));
352
+ const current = () => selected.map(p => {
353
+ const s = all.find(s => s.serviceId === p.serviceId);
354
+ if (!s || s.clientId !== p.clientId || s.providerId !== p.providerId || s.start !== p.start || s.bedName !== p.bedName || s.unitListName !== p.unitListName || s.needId !== p.needId || s.groupId !== p.groupId || s.note !== p.note)
355
+ fail('stay-changed');
356
+ return s;
357
+ });
358
+ const covered = covering(es, entry.input);
359
+ groupGuard(covered, entry.input);
360
+ for (const p of entry.enrollments) {
361
+ const e = covered.find(e => e.clientId === p.clientId);
362
+ if (!e || e.enrollmentId !== p.enrollmentId || e.entryDate !== p.entryDate || e.groupId !== p.groupId)
363
+ fail('enrollment-changed');
364
+ }
365
+ if (program.mode === 'entry-exit' && n.clientIds.includes(entry.input.headClientId) && es.some(e => e.providerId === entry.input.providerId && e.groupId === covered.find(e => e.clientId === entry.input.headClientId).groupId && !n.clientIds.includes(e.clientId) && (e.exitDate === null || e.exitDate > n.checkoutDate)))
366
+ fail('head-cannot-exit-first');
367
+ if (current().some(s => s.end !== null && s.end !== n.checkoutDate))
368
+ fail('checkout-date-changed');
369
+ if (!record) {
370
+ record = { version: 1, kind: 'checkout', key: n.key, hash: hash(n), input: n, stage: 'pending', beforeEntries: es, beforeStays: all };
371
+ records.push(record);
372
+ }
373
+ const persist = () => store.write(scope, records);
374
+ const complete = () => current().every(s => s.end === n.checkoutDate) && covered.filter(e => n.clientIds.includes(e.clientId)).every(e => program.mode === 'night-by-night' ? e.exitDate === record.beforeEntries.find(p => p.enrollmentId === e.enrollmentId)?.exitDate : e.exitDate === n.checkoutDate && label(e.destination) === label(n.destination) && label(e.reason) === label(n.reasonForLeaving));
375
+ if (!current().every(s => s.end === n.checkoutDate) && !record.attempted) {
376
+ if (current().some(s => s.end !== null) || program.mode === 'entry-exit' && covered.some(e => n.clientIds.includes(e.clientId) && e.exitDate !== null))
377
+ fail('partial-checkout');
378
+ let failure, attempted = false;
379
+ try {
380
+ await ui.checkout({ ...entry.input, ...n, mode: program.mode, unitListName: entry.unitListName, stays: selected }, async () => {
381
+ if (attempted)
382
+ fail('second-save');
383
+ record.attempted = true;
384
+ await persist();
385
+ attempted = true;
386
+ });
387
+ }
388
+ catch (e) {
389
+ failure = e;
390
+ }
391
+ if (!attempted)
392
+ throw failure ?? Error('Checkout save was not journaled');
393
+ es = await entries(ids);
394
+ all = await stays(entry.input);
395
+ if (!current().every(s => s.end === n.checkoutDate))
396
+ throw failure ?? new ShelterConflictError('uncertain-checkout');
397
+ }
398
+ const finalEntries = covering(es, entry.input);
399
+ // Recheck pinned identities after the save, not only in preflight.
400
+ for (const p of entry.enrollments) {
401
+ const e = finalEntries.find(e => e.clientId === p.clientId);
402
+ if (!e || e.enrollmentId !== p.enrollmentId || e.entryDate !== p.entryDate || e.groupId !== p.groupId)
403
+ fail('enrollment-changed');
404
+ }
405
+ groupGuard(finalEntries, entry.input);
406
+ covered.splice(0, covered.length, ...finalEntries);
407
+ if (!complete())
408
+ fail('checkout-enrollment-mismatch');
409
+ await identity(entry, entry.stays);
410
+ for (const before of record.beforeStays.filter(s => !selected.some(p => p.serviceId === s.serviceId))) {
411
+ if (JSON.stringify(all.find(s => s.serviceId === before.serviceId)) !== JSON.stringify(before))
412
+ fail('unselected-stay-changed');
413
+ }
414
+ for (const before of record.beforeEntries.filter(e => program.mode === 'night-by-night' || !covered.some(p => p.enrollmentId === e.enrollmentId && n.clientIds.includes(p.clientId)))) {
415
+ if (JSON.stringify(es.find(e => e.enrollmentId === before.enrollmentId)) !== JSON.stringify(before))
416
+ fail('unselected-enrollment-changed');
417
+ }
418
+ const outcome = record.stage === 'verified' ? 'already-present' : 'verified';
419
+ record.stage = 'verified';
420
+ await persist();
421
+ return { key: n.key, outcome, mode: program.mode, stays: current(), enrollments: covered };
422
+ });
423
+ },
424
+ };
425
+ }
@@ -0,0 +1,21 @@
1
+ export type SsvfServiceType = "Outreach services" | "Case management services" | "Assistance obtaining VA benefits" | "Assistance obtaining/coordinating other public benefits" | "Direct provision of other public benefits" | "Other (non-TFA) supportive service approved by VA" | "Shallow Subsidy" | "Returning Home" | "Rapid Resolution" | "Healthcare Navigation";
2
+ export type SsvfFinancialAssistanceType = "Rental assistance" | "Utility fee payment assistance" | "Security deposit" | "Utility deposit" | "Moving costs" | "Transportation services: tokens/vouchers" | "Transportation services: vehicle repair/maintenance" | "Child Care" | "General housing stability assistance" | "Emergency housing assistance" | "Shallow subsidy financial assistance" | "Food assistance" | "Landlord incentive" | "Tenant incentive";
3
+ type FinancialDetails = {ssvfFinancialAssistanceType?:SsvfFinancialAssistanceType;ssvfFinancialAssistanceAmount?:number};
4
+ /** Dependent fields are optional in the verified UI, but belong to one service type only. */
5
+ export type SsvfInformation = FinancialDetails & (
6
+ {typeOfSsvfService?:never;ifObtainingVaAssistanceBenefits?:never;ifObtainingCoordinatingOtherPublicBenefits?:never;ifDirectProvisionOfOtherPublicBenefits?:never;ifOtherNonTfaSupportiveServiceApprovedByVa?:never}
7
+ | {typeOfSsvfService:"Outreach services";ifObtainingVaAssistanceBenefits?:never;ifObtainingCoordinatingOtherPublicBenefits?:never;ifDirectProvisionOfOtherPublicBenefits?:never;ifOtherNonTfaSupportiveServiceApprovedByVa?:never}
8
+ | {typeOfSsvfService:"Case management services";ifObtainingVaAssistanceBenefits?:never;ifObtainingCoordinatingOtherPublicBenefits?:never;ifDirectProvisionOfOtherPublicBenefits?:never;ifOtherNonTfaSupportiveServiceApprovedByVa?:never}
9
+ | {typeOfSsvfService:"Assistance obtaining VA benefits";ifObtainingVaAssistanceBenefits?:"VA vocational and rehabilitation counseling" | "Employment and training services" | "Educational assistance" | "Health care services";ifObtainingCoordinatingOtherPublicBenefits?:never;ifDirectProvisionOfOtherPublicBenefits?:never;ifOtherNonTfaSupportiveServiceApprovedByVa?:never}
10
+ | {typeOfSsvfService:"Assistance obtaining/coordinating other public benefits";ifObtainingVaAssistanceBenefits?:never;ifObtainingCoordinatingOtherPublicBenefits?:"Health care services" | "Daily living services" | "Personal financial planning services" | "Transportation services" | "Income support services" | "Fiduciary and representative payee services" | "Legal services - child support" | "Legal services - eviction prevention" | "Legal services - outstanding fines and penalties" | "Legal services - restore/acquire driver's license" | "Legal services - other" | "Child care" | "Housing counseling";ifDirectProvisionOfOtherPublicBenefits?:never;ifOtherNonTfaSupportiveServiceApprovedByVa?:never}
11
+ | {typeOfSsvfService:"Direct provision of other public benefits";ifObtainingVaAssistanceBenefits?:never;ifObtainingCoordinatingOtherPublicBenefits?:never;ifDirectProvisionOfOtherPublicBenefits?:"Personal financial planning services" | "Transportation services" | "Income support services" | "Fiduciary and representative payee services" | "Legal services - child support" | "Legal services - eviction prevention" | "Legal services - outstanding fines and penalties" | "Legal services - restore/acquire driver's license" | "Legal services - other" | "Child care" | "Housing counseling";ifOtherNonTfaSupportiveServiceApprovedByVa?:never}
12
+ | {typeOfSsvfService:"Other (non-TFA) supportive service approved by VA";ifObtainingVaAssistanceBenefits?:never;ifObtainingCoordinatingOtherPublicBenefits?:never;ifDirectProvisionOfOtherPublicBenefits?:never;ifOtherNonTfaSupportiveServiceApprovedByVa?:string}
13
+ | {typeOfSsvfService:"Shallow Subsidy";ifObtainingVaAssistanceBenefits?:never;ifObtainingCoordinatingOtherPublicBenefits?:never;ifDirectProvisionOfOtherPublicBenefits?:never;ifOtherNonTfaSupportiveServiceApprovedByVa?:never}
14
+ | {typeOfSsvfService:"Returning Home";ifObtainingVaAssistanceBenefits?:never;ifObtainingCoordinatingOtherPublicBenefits?:never;ifDirectProvisionOfOtherPublicBenefits?:never;ifOtherNonTfaSupportiveServiceApprovedByVa?:never}
15
+ | {typeOfSsvfService:"Rapid Resolution";ifObtainingVaAssistanceBenefits?:never;ifObtainingCoordinatingOtherPublicBenefits?:never;ifDirectProvisionOfOtherPublicBenefits?:never;ifOtherNonTfaSupportiveServiceApprovedByVa?:never}
16
+ | {typeOfSsvfService:"Healthcare Navigation";ifObtainingVaAssistanceBenefits?:never;ifObtainingCoordinatingOtherPublicBenefits?:never;ifDirectProvisionOfOtherPublicBenefits?:never;ifOtherNonTfaSupportiveServiceApprovedByVa?:never}
17
+ );
18
+ export interface SsvfFieldRule {key:string;label:string;control:"select"|"text"|"money"|"datetime";visible:boolean;required:boolean;transport:"rest"|"ui";options?:string[];maxLength?:number;visibleWhen?:{typeOfSsvfService:SsvfServiceType};}
19
+ export interface SsvfServiceRules {verifiedOn:string;serviceTypes:SsvfServiceType[];financialAssistanceTypes:SsvfFinancialAssistanceType[];fields:SsvfFieldRule[];}
20
+ /** Verified UI snapshot, not a live provider lookup. UI-required does not imply reporting completeness. */
21
+ export function getSsvfServiceRules(selection?:{typeOfSsvfService?:SsvfServiceType;ssvfFinancialAssistanceType?:SsvfFinancialAssistanceType}):SsvfServiceRules;
@@ -0,0 +1,145 @@
1
+ // Reproduced in a sandbox tenant on 2026-10-23. See SSVF.md for evidence and scope.
2
+ const serviceTypes=[
3
+ "Outreach services",
4
+ "Case management services",
5
+ "Assistance obtaining VA benefits",
6
+ "Assistance obtaining/coordinating other public benefits",
7
+ "Direct provision of other public benefits",
8
+ "Other (non-TFA) supportive service approved by VA",
9
+ "Shallow Subsidy",
10
+ "Returning Home",
11
+ "Rapid Resolution",
12
+ "Healthcare Navigation"
13
+ ];
14
+ const financialAssistanceTypes=[
15
+ "Rental assistance",
16
+ "Utility fee payment assistance",
17
+ "Security deposit",
18
+ "Utility deposit",
19
+ "Moving costs",
20
+ "Transportation services: tokens/vouchers",
21
+ "Transportation services: vehicle repair/maintenance",
22
+ "Child Care",
23
+ "General housing stability assistance",
24
+ "Emergency housing assistance",
25
+ "Shallow subsidy financial assistance",
26
+ "Food assistance",
27
+ "Landlord incentive",
28
+ "Tenant incentive"
29
+ ];
30
+ const dependents=[
31
+ {
32
+ "key": "ifObtainingVaAssistanceBenefits",
33
+ "label": "If \"Assistance obtaining VA benefits\"",
34
+ "serviceType": "Assistance obtaining VA benefits",
35
+ "control": "select",
36
+ "required": false,
37
+ "options": [
38
+ "VA vocational and rehabilitation counseling",
39
+ "Employment and training services",
40
+ "Educational assistance",
41
+ "Health care services"
42
+ ]
43
+ },
44
+ {
45
+ "key": "ifObtainingCoordinatingOtherPublicBenefits",
46
+ "label": "If \"Assistance obtaining/coordinating other public benefits\"",
47
+ "serviceType": "Assistance obtaining/coordinating other public benefits",
48
+ "control": "select",
49
+ "required": false,
50
+ "options": [
51
+ "Health care services",
52
+ "Daily living services",
53
+ "Personal financial planning services",
54
+ "Transportation services",
55
+ "Income support services",
56
+ "Fiduciary and representative payee services",
57
+ "Legal services - child support",
58
+ "Legal services - eviction prevention",
59
+ "Legal services - outstanding fines and penalties",
60
+ "Legal services - restore/acquire driver's license",
61
+ "Legal services - other",
62
+ "Child care",
63
+ "Housing counseling"
64
+ ]
65
+ },
66
+ {
67
+ "key": "ifDirectProvisionOfOtherPublicBenefits",
68
+ "label": "If \"Direct provision of other public benefits\"",
69
+ "serviceType": "Direct provision of other public benefits",
70
+ "control": "select",
71
+ "required": false,
72
+ "options": [
73
+ "Personal financial planning services",
74
+ "Transportation services",
75
+ "Income support services",
76
+ "Fiduciary and representative payee services",
77
+ "Legal services - child support",
78
+ "Legal services - eviction prevention",
79
+ "Legal services - outstanding fines and penalties",
80
+ "Legal services - restore/acquire driver's license",
81
+ "Legal services - other",
82
+ "Child care",
83
+ "Housing counseling"
84
+ ]
85
+ },
86
+ {
87
+ "key": "ifOtherNonTfaSupportiveServiceApprovedByVa",
88
+ "label": "If \"Other(Non-TFA) Supportive Service approved by VA\"",
89
+ "serviceType": "Other (non-TFA) supportive service approved by VA",
90
+ "control": "text",
91
+ "required": false,
92
+ "maxLength": 100
93
+ }
94
+ ];
95
+
96
+ /** Verified form contract, not a live provider configuration query.
97
+ * Return a copy so callers cannot mutate validation rules. Required means UI-required. */
98
+ export function getSsvfServiceRules({typeOfSsvfService,ssvfFinancialAssistanceType}={}) {
99
+ if(typeOfSsvfService!==undefined&&!serviceTypes.includes(typeOfSsvfService))throw Error('Unknown Type of SSVF Service');
100
+ if(ssvfFinancialAssistanceType!==undefined&&!financialAssistanceTypes.includes(ssvfFinancialAssistanceType))throw Error('Unknown SSVF Financial Assistance Type');
101
+ return structuredClone({verifiedOn:'2026-10-23',serviceTypes,financialAssistanceTypes,fields:[
102
+ {key:'typeOfSsvfService',label:'Type of SSVF Service',control:'select',visible:true,required:false,options:serviceTypes,transport:'rest'},
103
+ ...dependents.map(({serviceType,...field})=>({...field,visible:serviceType===typeOfSsvfService,visibleWhen:{typeOfSsvfService:serviceType},transport:'rest'})),
104
+ {key:'ssvfFinancialAssistanceType',label:'SSVF Financial Assistance Type',control:'select',visible:true,required:false,options:financialAssistanceTypes,transport:'rest'},
105
+ ...['start','end'].map(part=>({key:`financialAssistancePeriod.${part}`,label:`SSVF Financial Assistance ${part==='start'?'Start':'End'} Date`,control:'datetime',visible:ssvfFinancialAssistanceType!==undefined,required:ssvfFinancialAssistanceType!==undefined,transport:'ui'})),
106
+ {key:'ssvfFinancialAssistanceAmount',label:'SSVF Financial Assistance Amount',control:'money',visible:true,required:false,transport:'rest'},
107
+ ]});
108
+ }
109
+
110
+ /** Reject stale hidden answers and invalid picklist values before any row is reserved. */
111
+ export function validateSsvfInformation(info,period) {
112
+ if(info===undefined){if(period)throw Error('SSVF assistance period requires a financial assistance type');return;}
113
+ if(!info||typeof info!=='object'||Array.isArray(info))throw Error('SSVF information must be an object');
114
+ const rules=getSsvfServiceRules(info),fields=rules.fields.filter(f=>f.transport==='rest');
115
+ for(const [key,value] of Object.entries(info)){
116
+ const field=fields.find(f=>f.key===key);
117
+ if(!field)throw Error(`Unsupported SSVF field: ${key}`);
118
+ if(!field.visible)throw Error(`${field.label} is not applicable to ${info.typeOfSsvfService??'an unspecified service type'}`);
119
+ if(field.options&&!field.options.includes(value))throw Error(`Invalid ${field.label}`);
120
+ if(field.control==='text'&&(typeof value!=='string'||!value.trim()||value.length>field.maxLength))throw Error(`${field.label} must contain 1-${field.maxLength} characters`);
121
+ if(field.control==='money'&&(typeof value!=='number'||!Number.isFinite(value)||value<0||value>1e9||Math.abs(value*100-Math.round(value*100))>=1e-6))throw Error(`Invalid ${field.label}`);
122
+ }
123
+ if(info.ssvfFinancialAssistanceType&&!period)throw Error('SSVF Financial Assistance Start Date and End Date are required when a financial assistance type is selected');
124
+ if(period&&!info.ssvfFinancialAssistanceType)throw Error('SSVF assistance period requires a financial assistance type');
125
+ }
126
+
127
+ /** Read only visible controls, including text boxes whose labels use aria-labelledby. */
128
+ export async function readSsvfInformation(page) {
129
+ const fields=getSsvfServiceRules().fields.filter(f=>f.transport==='rest');
130
+ return page.evaluate(fields=>{
131
+ const values={};
132
+ for(const field of fields){
133
+ const matches=[...document.querySelectorAll('input,select,textarea')].filter(e=>{
134
+ const owner=e.closest('[aria-label],[aria-labelledby]');
135
+ const label=owner?.getAttribute('aria-label')??owner?.getAttribute('aria-labelledby')?.split(/\s+/).map(id=>document.getElementById(id)?.textContent).join(' ').trim();
136
+ return e.getClientRects().length&&label===field.label;
137
+ });
138
+ if(matches.length>1)throw Error('Ambiguous SSVF control');
139
+ if(!matches.length)continue;
140
+ const value=matches[0].value;
141
+ if(value&&value!=='-Select-')values[field.key]=field.control==='money'?Number(value.replace(/,/g,'')):value;
142
+ }
143
+ return values;
144
+ },fields);
145
+ }