@kenyaemr/esm-pcs-app 5.4.5-pre.1092

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 (286) hide show
  1. package/.turbo/turbo-build.log +6 -0
  2. package/dist/1072.js +1 -0
  3. package/dist/1072.js.map +1 -0
  4. package/dist/1102.js +1 -0
  5. package/dist/1102.js.map +1 -0
  6. package/dist/1105.js +1 -0
  7. package/dist/1105.js.map +1 -0
  8. package/dist/1148.js +1 -0
  9. package/dist/1148.js.map +1 -0
  10. package/dist/1278.js +1 -0
  11. package/dist/1278.js.map +1 -0
  12. package/dist/1442.js +1 -0
  13. package/dist/1442.js.map +1 -0
  14. package/dist/15.js +1 -0
  15. package/dist/15.js.map +1 -0
  16. package/dist/1620.js +1 -0
  17. package/dist/1620.js.map +1 -0
  18. package/dist/1789.js +1 -0
  19. package/dist/1789.js.map +1 -0
  20. package/dist/1818.js +1 -0
  21. package/dist/1818.js.map +1 -0
  22. package/dist/1831.js +1 -0
  23. package/dist/1831.js.map +1 -0
  24. package/dist/1844.js +1 -0
  25. package/dist/1844.js.map +1 -0
  26. package/dist/1854.js +1 -0
  27. package/dist/1854.js.map +1 -0
  28. package/dist/1946.js +1 -0
  29. package/dist/1946.js.map +1 -0
  30. package/dist/2007.js +1 -0
  31. package/dist/2007.js.map +1 -0
  32. package/dist/2115.js +1 -0
  33. package/dist/2115.js.map +1 -0
  34. package/dist/2152.js +1 -0
  35. package/dist/2152.js.map +1 -0
  36. package/dist/2202.js +1 -0
  37. package/dist/2202.js.map +1 -0
  38. package/dist/2394.js +1 -0
  39. package/dist/2394.js.map +1 -0
  40. package/dist/2530.js +1 -0
  41. package/dist/2530.js.map +1 -0
  42. package/dist/2534.js +1 -0
  43. package/dist/2534.js.map +1 -0
  44. package/dist/2537.js +1 -0
  45. package/dist/2537.js.map +1 -0
  46. package/dist/2556.js +1 -0
  47. package/dist/2556.js.map +1 -0
  48. package/dist/2648.js +1 -0
  49. package/dist/2648.js.map +1 -0
  50. package/dist/2795.js +1 -0
  51. package/dist/2795.js.map +1 -0
  52. package/dist/2827.js +1 -0
  53. package/dist/2827.js.map +1 -0
  54. package/dist/2916.js +1 -0
  55. package/dist/2916.js.map +1 -0
  56. package/dist/2924.js +1 -0
  57. package/dist/2924.js.map +1 -0
  58. package/dist/2947.js +1 -0
  59. package/dist/2947.js.map +1 -0
  60. package/dist/3022.js +1 -0
  61. package/dist/3022.js.map +1 -0
  62. package/dist/3285.js +1 -0
  63. package/dist/3285.js.map +1 -0
  64. package/dist/3335.js +1 -0
  65. package/dist/3335.js.map +1 -0
  66. package/dist/3378.js +1 -0
  67. package/dist/3378.js.map +1 -0
  68. package/dist/3381.js +1 -0
  69. package/dist/3381.js.map +1 -0
  70. package/dist/3504.js +1 -0
  71. package/dist/3504.js.map +1 -0
  72. package/dist/3530.js +1 -0
  73. package/dist/3530.js.map +1 -0
  74. package/dist/356.js +1 -0
  75. package/dist/356.js.map +1 -0
  76. package/dist/3569.js +1 -0
  77. package/dist/3569.js.map +1 -0
  78. package/dist/3723.js +1 -0
  79. package/dist/3723.js.map +1 -0
  80. package/dist/3745.js +1 -0
  81. package/dist/3745.js.map +1 -0
  82. package/dist/3813.js +1 -0
  83. package/dist/3813.js.map +1 -0
  84. package/dist/393.js +6 -0
  85. package/dist/393.js.map +1 -0
  86. package/dist/3953.js +1 -0
  87. package/dist/3953.js.map +1 -0
  88. package/dist/3989.js +1 -0
  89. package/dist/3989.js.map +1 -0
  90. package/dist/412.js +1 -0
  91. package/dist/412.js.map +1 -0
  92. package/dist/4178.js +1 -0
  93. package/dist/4178.js.map +1 -0
  94. package/dist/4413.js +1 -0
  95. package/dist/4413.js.map +1 -0
  96. package/dist/4487.js +11 -0
  97. package/dist/4487.js.map +1 -0
  98. package/dist/4537.js +1 -0
  99. package/dist/4537.js.map +1 -0
  100. package/dist/4588.js +1 -0
  101. package/dist/4588.js.map +1 -0
  102. package/dist/4637.js +1 -0
  103. package/dist/4637.js.map +1 -0
  104. package/dist/4650.js +1 -0
  105. package/dist/4650.js.map +1 -0
  106. package/dist/466.js +1 -0
  107. package/dist/466.js.map +1 -0
  108. package/dist/4817.js +1 -0
  109. package/dist/4817.js.map +1 -0
  110. package/dist/4818.js +1 -0
  111. package/dist/4818.js.map +1 -0
  112. package/dist/500.js +1 -0
  113. package/dist/500.js.map +1 -0
  114. package/dist/5086.js +1 -0
  115. package/dist/5086.js.map +1 -0
  116. package/dist/5117.js +1 -0
  117. package/dist/5117.js.map +1 -0
  118. package/dist/5279.js +1 -0
  119. package/dist/5279.js.map +1 -0
  120. package/dist/5366.js +1 -0
  121. package/dist/5366.js.map +1 -0
  122. package/dist/5598.js +1 -0
  123. package/dist/5598.js.map +1 -0
  124. package/dist/5676.js +1 -0
  125. package/dist/5676.js.map +1 -0
  126. package/dist/5731.js +5 -0
  127. package/dist/5731.js.map +1 -0
  128. package/dist/5792.js +1 -0
  129. package/dist/5792.js.map +1 -0
  130. package/dist/5825.js +1 -0
  131. package/dist/5825.js.map +1 -0
  132. package/dist/5932.js +1 -0
  133. package/dist/5932.js.map +1 -0
  134. package/dist/5941.js +1 -0
  135. package/dist/5941.js.map +1 -0
  136. package/dist/5967.js +1 -0
  137. package/dist/5967.js.map +1 -0
  138. package/dist/6104.js +1 -0
  139. package/dist/6104.js.map +1 -0
  140. package/dist/624.js +1 -0
  141. package/dist/624.js.map +1 -0
  142. package/dist/6259.js +1 -0
  143. package/dist/6259.js.map +1 -0
  144. package/dist/6273.js +1 -0
  145. package/dist/6273.js.map +1 -0
  146. package/dist/6337.js +1 -0
  147. package/dist/6337.js.map +1 -0
  148. package/dist/6427.js +1 -0
  149. package/dist/6427.js.map +1 -0
  150. package/dist/6478.js +7 -0
  151. package/dist/6478.js.map +1 -0
  152. package/dist/6508.js +1 -0
  153. package/dist/6508.js.map +1 -0
  154. package/dist/6561.js +1 -0
  155. package/dist/6561.js.map +1 -0
  156. package/dist/6566.js +1 -0
  157. package/dist/6566.js.map +1 -0
  158. package/dist/6567.js +1 -0
  159. package/dist/6567.js.map +1 -0
  160. package/dist/6634.js +1 -0
  161. package/dist/6634.js.map +1 -0
  162. package/dist/7022.js +1 -0
  163. package/dist/7022.js.map +1 -0
  164. package/dist/7085.js +1 -0
  165. package/dist/7085.js.map +1 -0
  166. package/dist/7209.js +1 -0
  167. package/dist/7209.js.map +1 -0
  168. package/dist/7291.js +1 -0
  169. package/dist/7291.js.map +1 -0
  170. package/dist/7360.js +1 -0
  171. package/dist/7360.js.map +1 -0
  172. package/dist/7453.js +1 -0
  173. package/dist/7453.js.map +1 -0
  174. package/dist/7454.js +1 -0
  175. package/dist/7454.js.map +1 -0
  176. package/dist/7478.js +1 -0
  177. package/dist/7478.js.map +1 -0
  178. package/dist/7778.js +1 -0
  179. package/dist/7778.js.map +1 -0
  180. package/dist/7782.js +1 -0
  181. package/dist/7782.js.map +1 -0
  182. package/dist/8028.js +1 -0
  183. package/dist/8028.js.map +1 -0
  184. package/dist/8031.js +1 -0
  185. package/dist/8031.js.map +1 -0
  186. package/dist/8196.js +1 -0
  187. package/dist/8196.js.map +1 -0
  188. package/dist/820.js +1 -0
  189. package/dist/820.js.map +1 -0
  190. package/dist/8240.js +1 -0
  191. package/dist/8240.js.map +1 -0
  192. package/dist/8472.js +1 -0
  193. package/dist/8472.js.map +1 -0
  194. package/dist/8607.js +1 -0
  195. package/dist/8607.js.map +1 -0
  196. package/dist/8793.js +1 -0
  197. package/dist/8793.js.map +1 -0
  198. package/dist/8924.js +1 -0
  199. package/dist/8924.js.map +1 -0
  200. package/dist/8990.js +1 -0
  201. package/dist/8990.js.map +1 -0
  202. package/dist/9060.js +1 -0
  203. package/dist/9060.js.map +1 -0
  204. package/dist/9061.js +1 -0
  205. package/dist/9061.js.map +1 -0
  206. package/dist/9087.js +1 -0
  207. package/dist/9087.js.map +1 -0
  208. package/dist/9165.js +1 -0
  209. package/dist/9165.js.map +1 -0
  210. package/dist/9274.js +1 -0
  211. package/dist/9274.js.map +1 -0
  212. package/dist/9425.js +1 -0
  213. package/dist/9520.js +1 -0
  214. package/dist/9520.js.map +1 -0
  215. package/dist/9547.js +1 -0
  216. package/dist/9547.js.map +1 -0
  217. package/dist/9612.js +1 -0
  218. package/dist/9612.js.map +1 -0
  219. package/dist/9771.js +1 -0
  220. package/dist/9771.js.map +1 -0
  221. package/dist/9774.js +1 -0
  222. package/dist/9774.js.map +1 -0
  223. package/dist/9806.js +1 -0
  224. package/dist/9806.js.map +1 -0
  225. package/dist/9885.js +1 -0
  226. package/dist/9885.js.map +1 -0
  227. package/dist/992.js +1 -0
  228. package/dist/992.js.map +1 -0
  229. package/dist/9941.js +1 -0
  230. package/dist/9941.js.map +1 -0
  231. package/dist/997.js +1 -0
  232. package/dist/997.js.map +1 -0
  233. package/dist/kenyaemr-esm-pcs-app.js +3 -0
  234. package/dist/kenyaemr-esm-pcs-app.js.buildmanifest.json +3173 -0
  235. package/dist/kenyaemr-esm-pcs-app.js.map +1 -0
  236. package/dist/main.js +3 -0
  237. package/dist/main.js.map +1 -0
  238. package/dist/routes.json +1 -0
  239. package/package.json +50 -0
  240. package/rspack.config.js +1 -0
  241. package/src/add-dependant/add-dependant.scss +29 -0
  242. package/src/add-dependant/add-dependant.workspace.test.ts +65 -0
  243. package/src/add-dependant/add-dependant.workspace.tsx +249 -0
  244. package/src/config-schema.ts +67 -0
  245. package/src/declarations.d.ts +2 -0
  246. package/src/empty-svg.component.tsx +565 -0
  247. package/src/index.ts +34 -0
  248. package/src/link-hie-dependant/link-hie-dependant.scss +55 -0
  249. package/src/link-hie-dependant/link-hie-dependant.workspace.tsx +207 -0
  250. package/src/linked/dependant-row.component.test.tsx +134 -0
  251. package/src/linked/dependant-row.component.tsx +122 -0
  252. package/src/linked/dependants-list.component.tsx +60 -0
  253. package/src/linked/linked-participant-body.component.tsx +82 -0
  254. package/src/linked/linked-participant.component.test.tsx +134 -0
  255. package/src/linked/linked-participant.component.tsx +142 -0
  256. package/src/linked/participant-details.component.tsx +89 -0
  257. package/src/linked/use-study-sync-snackbars.ts +41 -0
  258. package/src/modals/delink-participant.modal.tsx +96 -0
  259. package/src/modals/link-dependant.modal.tsx +244 -0
  260. package/src/modals/link-participant.modal.tsx +143 -0
  261. package/src/modals/link-participant.scss +48 -0
  262. package/src/pcs.scss +241 -0
  263. package/src/resources/hie-contacts.ts +61 -0
  264. package/src/resources/identifiers.ts +52 -0
  265. package/src/resources/link-dependant.resource.test.ts +445 -0
  266. package/src/resources/link-dependant.resource.ts +268 -0
  267. package/src/resources/link-participant.resource.test.ts +336 -0
  268. package/src/resources/link-participant.resource.ts +452 -0
  269. package/src/resources/patient-create.resource.ts +296 -0
  270. package/src/resources/patient-search.resource.ts +106 -0
  271. package/src/resources/pcs.resource.test.ts +36 -0
  272. package/src/resources/pcs.resource.ts +182 -0
  273. package/src/resources/use-identifier-type-uuids.ts +38 -0
  274. package/src/routes.json +54 -0
  275. package/src/search/participant-results.component.tsx +96 -0
  276. package/src/search/participant-tile.component.tsx +133 -0
  277. package/src/search/pcs-empty-state.component.tsx +31 -0
  278. package/src/search/pcs-results-skeleton.component.tsx +21 -0
  279. package/src/search/pcs-search.component.test.tsx +169 -0
  280. package/src/search/pcs-search.component.tsx +228 -0
  281. package/src/types/index.ts +140 -0
  282. package/translations/am.json +120 -0
  283. package/translations/en.json +120 -0
  284. package/translations/fr.json +122 -0
  285. package/translations/sw.json +120 -0
  286. package/tsconfig.json +5 -0
@@ -0,0 +1,268 @@
1
+ import { openmrsFetch, restBaseUrl } from '@openmrs/esm-framework';
2
+ import { getLocalIdentifierValue } from './identifiers';
3
+ import { createDependantPatient, createPatientFromDemographics } from './patient-create.resource';
4
+ import { findExistingLocalPatient } from './patient-search.resource';
5
+ import useSWR from 'swr';
6
+ import { getPrimaryContact } from './pcs.resource';
7
+ import { launchCheckInWorkspace, readLocalPatient, stampAndCheckIn } from './link-participant.resource';
8
+ import {
9
+ type IdentifierTypeUuids,
10
+ type PcsDependantDemographics,
11
+ type PcsHieDependant,
12
+ type PcsParticipant,
13
+ } from '../types';
14
+
15
+ interface LinkDependantOptions {
16
+ /** A row from `getDependentsFromContacts` — an HIE contact, not necessarily a patient here yet. */
17
+ dependant: any;
18
+ parentPhoneNumber?: string;
19
+ participant: PcsParticipant;
20
+ studyParticipantIdentifierType: string;
21
+ pbidsEnrollmentAttributeType: string;
22
+ cardseEnrollmentAttributeType: string;
23
+ /** The identifier types a created patient's HIE identifiers are written against. */
24
+ uuids: IdentifierTypeUuids;
25
+ }
26
+
27
+ /**
28
+ * Resolves a dependant to a local record, creating them when they are not registered here.
29
+ *
30
+ * `isDependent: true` matters — it makes `extractPatientIdentifiers` skip `household-number`,
31
+ * which belongs to the parent, so a miss here doesn't silently match the parent's own record.
32
+ *
33
+ * Deliberately calls `createPatient` rather than `createDependentPatient`: the latter launches
34
+ * the visit workspace inside itself, which would fire before the study data is written.
35
+ */
36
+ async function resolveLocalDependant(
37
+ dependant: PcsHieDependant,
38
+ parentPhoneNumber: string | undefined,
39
+ uuids: IdentifierTypeUuids,
40
+ ) {
41
+ const existing = await findExistingLocalPatient(dependant.contactData, true);
42
+ if (existing) {
43
+ return existing;
44
+ }
45
+
46
+ return createDependantPatient(dependant, parentPhoneNumber ? { ...uuids } : uuids, parentPhoneNumber);
47
+ }
48
+
49
+ /**
50
+ * Links a dependant in the HIE dependants table to a participant from their mother's PCS
51
+ * record: create if needed, stamp the study identifier and both enrolment attributes, check in.
52
+ */
53
+ export async function linkDependantToParticipant({
54
+ dependant,
55
+ parentPhoneNumber,
56
+ participant,
57
+ studyParticipantIdentifierType,
58
+ pbidsEnrollmentAttributeType,
59
+ cardseEnrollmentAttributeType,
60
+ uuids,
61
+ }: LinkDependantOptions) {
62
+ const localPatient = await resolveLocalDependant(dependant, parentPhoneNumber, uuids);
63
+
64
+ return stampAndCheckIn({
65
+ localPatient,
66
+ participant,
67
+ studyParticipantIdentifierType,
68
+ pbidsEnrollmentAttributeType,
69
+ cardseEnrollmentAttributeType,
70
+ });
71
+ }
72
+
73
+ /**
74
+ * Which of the mother's HIE dependants are already linked to a PCS participant, keyed by
75
+ * candidate id, with the study ID each one holds.
76
+ *
77
+ * One SWR key over all candidates rather than a hook per row: Carbon's `RadioButtonGroup`
78
+ * clones its children expecting `RadioButton` elements, so a per-row wrapper component would
79
+ * break its `valueSelected` / `onChange` wiring.
80
+ */
81
+ export function useHieDependantLinkState(candidates: Array<any>, identifierTypes: Array<string>) {
82
+ const key = candidates.length
83
+ ? `pcs-hie-dependant-link-state/${candidates.map((candidate) => candidate.id).join(',')}`
84
+ : null;
85
+
86
+ const { data, isLoading } = useSWR(key, async () => {
87
+ const resolved = await Promise.all(
88
+ candidates.map(async (candidate) => {
89
+ const localPatient = await findExistingLocalPatient(candidate.contactData, true).catch(() => null);
90
+ const studyId = identifierTypes
91
+ .filter(Boolean)
92
+ .map((identifierType) => getLocalIdentifierValue(localPatient, identifierType))
93
+ .find(Boolean);
94
+
95
+ return [candidate.id, studyId] as const;
96
+ }),
97
+ );
98
+
99
+ return Object.fromEntries(resolved) as Record<string, string | undefined>;
100
+ });
101
+
102
+ return { linkedById: data ?? {}, isChecking: isLoading };
103
+ }
104
+
105
+ interface CreateAndLinkOptions {
106
+ participant: PcsParticipant;
107
+ nationalIdUUID: string;
108
+ phoneAttributeTypeUUID: string;
109
+ studyParticipantIdentifierType: string;
110
+ pbidsEnrollmentAttributeType: string;
111
+ cardseEnrollmentAttributeType: string;
112
+ }
113
+
114
+ /** A PCS participant mapped onto the same create. */
115
+ function createPatientFromParticipant(
116
+ participant: PcsParticipant,
117
+ nationalIdUUID: string,
118
+ phoneAttributeTypeUUID: string,
119
+ ) {
120
+ const contact = getPrimaryContact(participant);
121
+
122
+ return createPatientFromDemographics(
123
+ {
124
+ firstName: participant.firstName,
125
+ middleName: participant.middleName,
126
+ lastName: participant.lastName,
127
+ sex: participant.sex,
128
+ dateOfBirth: participant.dateOfBirth,
129
+ nationalId: contact?.nationalId,
130
+ phone: contact?.phone,
131
+ },
132
+ nationalIdUUID,
133
+ phoneAttributeTypeUUID,
134
+ );
135
+ }
136
+
137
+ /**
138
+ * For a PCS dependant the HIE has no record of: build the patient from PCS's own demographics,
139
+ * then stamp **this participant's** identifier — they already hold a permanent one, so nothing
140
+ * temporary is minted and PCS keeps a single record for the child.
141
+ */
142
+ export async function createAndLinkFromParticipant({
143
+ participant,
144
+ nationalIdUUID,
145
+ phoneAttributeTypeUUID,
146
+ studyParticipantIdentifierType,
147
+ pbidsEnrollmentAttributeType,
148
+ cardseEnrollmentAttributeType,
149
+ }: CreateAndLinkOptions) {
150
+ const localPatient = await createPatientFromParticipant(participant, nationalIdUUID, phoneAttributeTypeUUID);
151
+
152
+ return stampAndCheckIn({
153
+ localPatient,
154
+ participant,
155
+ studyParticipantIdentifierType,
156
+ pbidsEnrollmentAttributeType,
157
+ cardseEnrollmentAttributeType,
158
+ });
159
+ }
160
+
161
+ interface CreatePcsParticipantOptions {
162
+ patientUuid: string;
163
+ motherId: string;
164
+ }
165
+
166
+ /**
167
+ * The request as the module documents it. Kept real and separately testable so the path and
168
+ * both field names are pinned rather than discovered at integration time — the same split the
169
+ * search side uses with `buildParticipantSearchUrl`.
170
+ */
171
+ export function buildCreateParticipantRequest({ patientUuid, motherId }: CreatePcsParticipantOptions) {
172
+ return {
173
+ url: `${restBaseUrl}/pbids-participants`,
174
+ options: {
175
+ method: 'POST',
176
+ headers: { 'Content-Type': 'application/json' },
177
+ body: { patientUuid, motherId },
178
+ },
179
+ };
180
+ }
181
+
182
+ /**
183
+ * Documented failures, all of which surface through the form's notification rather than
184
+ * indicating a bug here: `409` when the patient already holds a study or temporary identifier,
185
+ * `400` for a missing field or an unknown patient or mother, and `503` when the PCS database is
186
+ * unreachable.
187
+ */
188
+ async function createPcsParticipant({ patientUuid, motherId }: CreatePcsParticipantOptions): Promise<PcsParticipant> {
189
+ const request = buildCreateParticipantRequest({ patientUuid, motherId });
190
+ const { data } = await openmrsFetch<PcsParticipant>(request.url, request.options);
191
+ return data;
192
+ }
193
+
194
+ interface CreateDependantWithTemporaryIdOptions {
195
+ demographics: PcsDependantDemographics;
196
+ motherIndividualId: string;
197
+ nationalIdUUID: string;
198
+ phoneAttributeTypeUUID: string;
199
+ }
200
+
201
+ /**
202
+ * The tail both temporary-ID flows share: have the module create a participant against the
203
+ * mother's and mint the study id, then start the visit.
204
+ *
205
+ * Nothing is written client-side. The module owns both sides — it assigns the identifier and
206
+ * sets the `PBIDS Enrolled` / `CARDSE Enrolled` attributes to match the PCS row, which the docs
207
+ * are explicit must stay in step. The re-read is how we see what it wrote; the record we hold
208
+ * could not carry it.
209
+ */
210
+ async function mintTemporaryIdAndCheckIn(localPatient: any, motherIndividualId: string) {
211
+ if (!localPatient?.uuid) {
212
+ throw new Error('The patient record could not be created');
213
+ }
214
+
215
+ const participant = await createPcsParticipant({ patientUuid: localPatient.uuid, motherId: motherIndividualId });
216
+
217
+ const updated = (await readLocalPatient(localPatient.uuid).catch(() => null)) ?? localPatient;
218
+
219
+ await launchCheckInWorkspace(updated, updated.uuid);
220
+
221
+ return { localPatient: updated, participant };
222
+ }
223
+
224
+ /**
225
+ * For an infant in neither PCS nor the HIE: register them here from typed demographics, then
226
+ * mint a temporary study id against their mother's participant.
227
+ */
228
+ export async function createDependantWithTemporaryId({
229
+ demographics,
230
+ motherIndividualId,
231
+ nationalIdUUID,
232
+ phoneAttributeTypeUUID,
233
+ }: CreateDependantWithTemporaryIdOptions) {
234
+ const localPatient = await createPatientFromDemographics(demographics, nationalIdUUID, phoneAttributeTypeUUID);
235
+
236
+ return mintTemporaryIdAndCheckIn(localPatient, motherIndividualId);
237
+ }
238
+
239
+ interface LinkHieDependantOptions {
240
+ /** A row from `getDependentsFromContacts` — an HIE contact, not necessarily a patient here yet. */
241
+ dependant: any;
242
+ parentPhoneNumber?: string;
243
+ motherIndividualId: string;
244
+ /** The identifier types a created patient's HIE identifiers are written against. */
245
+ uuids: IdentifierTypeUuids;
246
+ }
247
+
248
+ /**
249
+ * For a child the HIE lists under her mother but PCS has no row for: reuse her local record when
250
+ * she is already registered here, create it from the HIE contact when she isn't, then mint a
251
+ * temporary study id.
252
+ *
253
+ * Differs from `createDependantWithTemporaryId` only at the head — resolve-or-create from an HIE
254
+ * contact, rather than create from typed demographics.
255
+ *
256
+ * A child linked elsewhere between the list rendering and this call comes back as the documented
257
+ * `409`; that race belongs to the server, not to a second client-side check.
258
+ */
259
+ export async function linkHieDependantWithTemporaryId({
260
+ dependant,
261
+ parentPhoneNumber,
262
+ motherIndividualId,
263
+ uuids,
264
+ }: LinkHieDependantOptions) {
265
+ const localPatient = await resolveLocalDependant(dependant, parentPhoneNumber, uuids);
266
+
267
+ return mintTemporaryIdAndCheckIn(localPatient, motherIndividualId);
268
+ }
@@ -0,0 +1,336 @@
1
+ import { beforeEach, describe, expect, it, vi } from 'vitest';
2
+ import { getSessionLocation, launchWorkspace2, launchWorkspaceGroup2, openmrsFetch } from '@openmrs/esm-framework';
3
+ import { createDependantPatient, createPatientFromHiePatient } from './patient-create.resource';
4
+ import { findExistingLocalPatient } from './patient-search.resource';
5
+ import { delinkParticipant, linkParticipantToPatient, syncStudyAttributes } from './link-participant.resource';
6
+ import { type PcsParticipant, type PcsSearchSubject } from '../types';
7
+
8
+ vi.mock('@openmrs/esm-framework', async (importOriginal) => ({
9
+ ...(await importOriginal<typeof import('@openmrs/esm-framework')>()),
10
+ openmrsFetch: vi.fn(),
11
+ getSessionLocation: vi.fn(),
12
+ launchWorkspace2: vi.fn(),
13
+ launchWorkspaceGroup2: vi.fn(),
14
+ }));
15
+
16
+ vi.mock('./patient-create.resource', async (importOriginal) => ({
17
+ ...(await importOriginal<typeof import('./patient-create.resource')>()),
18
+ createDependantPatient: vi.fn(),
19
+ createPatientFromHiePatient: vi.fn(),
20
+ }));
21
+ vi.mock('./patient-search.resource', () => ({ findExistingLocalPatient: vi.fn() }));
22
+
23
+ const mockOpenmrsFetch = vi.mocked(openmrsFetch);
24
+ const mockGetSessionLocation = vi.mocked(getSessionLocation);
25
+ const mockCreateDependantPatient = vi.mocked(createDependantPatient);
26
+ const mockCreatePatientFromHie = vi.mocked(createPatientFromHiePatient);
27
+ const mockFindExistingLocalPatient = vi.mocked(findExistingLocalPatient);
28
+
29
+ const uuids = {
30
+ nationalIdUUID: 'national-id-type',
31
+ shaNumberUUID: 'sha-number-type',
32
+ passportUUID: 'passport-type',
33
+ birthCertificateUUID: 'birth-certificate-type',
34
+ crIdentificationNumberUUID: 'cr-number-type',
35
+ phoneAttributeTypeUUID: 'phone-attr-type',
36
+ };
37
+
38
+ const STUDY_ID_TYPE = 'study-id-type-uuid';
39
+ const PBIDS_ENROLLMENT_TYPE = 'pbids-enrollment-type-uuid';
40
+ const CARDSE_ENROLLMENT_TYPE = 'cardse-enrollment-type-uuid';
41
+
42
+ const participant = {
43
+ individualId: '901-1-1-3',
44
+ firstName: 'DENNIS',
45
+ lastName: 'ODONGO',
46
+ sex: 'M',
47
+ pbidsEnrolled: true,
48
+ cardse: false,
49
+ mother: null,
50
+ compound: { compoundId: '901-1', headIndividualId: '901-1-1-1', headFirstName: 'JOHN', headLastName: 'ODONGO' },
51
+ village: { code: '901', name: 'TEST ABUYA' },
52
+ contacts: [],
53
+ matchedOn: 'name',
54
+ matchType: 'EXACT',
55
+ } as PcsParticipant;
56
+
57
+ const localSubject: PcsSearchSubject = {
58
+ id: 'local-uuid',
59
+ source: 'local',
60
+ patient: { resourceType: 'Patient', id: 'local-uuid' },
61
+ name: 'Dennis Odongo',
62
+ };
63
+
64
+ const hieSubject: PcsSearchSubject = {
65
+ id: 'cr-number',
66
+ source: 'hie',
67
+ patient: { resourceType: 'Patient', id: 'cr-number' },
68
+ name: 'Dennis Odongo',
69
+ };
70
+
71
+ const link = (subject: PcsSearchSubject) =>
72
+ linkParticipantToPatient({
73
+ subject,
74
+ participant,
75
+ studyParticipantIdentifierType: STUDY_ID_TYPE,
76
+ pbidsEnrollmentAttributeType: PBIDS_ENROLLMENT_TYPE,
77
+ cardseEnrollmentAttributeType: CARDSE_ENROLLMENT_TYPE,
78
+ uuids,
79
+ });
80
+
81
+ /** Routes the mocked fetch by URL so each test only states what it cares about. */
82
+ const respondWith = ({ localPatient, attributes = [] }: { localPatient?: any; attributes?: Array<any> }) => {
83
+ let customReads = 0;
84
+
85
+ mockOpenmrsFetch.mockImplementation((url: string) => {
86
+ if (url.includes('/attribute?v=default')) {
87
+ return Promise.resolve({ data: { results: attributes } } as any);
88
+ }
89
+ if (url.includes('?v=custom')) {
90
+ // A local subject is read twice: once to resolve it, once again after the writes. Only
91
+ // the second is tagged, so a test asserting `reread` genuinely proves the caller got the
92
+ // refreshed record — returning the first one is what left the row showing "PCS Link".
93
+ customReads += 1;
94
+ return Promise.resolve({ data: { ...localPatient, reread: customReads > 1 } } as any);
95
+ }
96
+ return Promise.resolve({ data: {} } as any);
97
+ });
98
+ };
99
+
100
+ const writes = () =>
101
+ mockOpenmrsFetch.mock.calls.filter(([, options]) => (options as any)?.method === 'POST').map(([url]) => url);
102
+
103
+ beforeEach(() => {
104
+ vi.clearAllMocks();
105
+ mockGetSessionLocation.mockResolvedValue({ uuid: 'location-uuid' } as any);
106
+ });
107
+
108
+ describe('linkParticipantToPatient', () => {
109
+ it('uses the existing record for a local patient and never creates one', async () => {
110
+ respondWith({ localPatient: { uuid: 'local-uuid', identifiers: [] } });
111
+
112
+ const result = await link(localSubject);
113
+
114
+ expect(mockCreatePatientFromHie).not.toHaveBeenCalled();
115
+ expect(writes()).toContain('/ws/rest/v1/patient/local-uuid/identifier');
116
+ // Returning the pre-write object is what made the dependants row keep offering to link.
117
+ expect(result.reread).toBe(true);
118
+ });
119
+
120
+ it('creates the patient for an HIE match, then writes the study data, then checks in once', async () => {
121
+ mockFindExistingLocalPatient.mockResolvedValue(null);
122
+ mockCreatePatientFromHie.mockResolvedValue({ uuid: 'new-uuid', identifiers: [] } as any);
123
+ respondWith({});
124
+
125
+ await link(hieSubject);
126
+
127
+ expect(mockCreatePatientFromHie).toHaveBeenCalledOnce();
128
+ expect(writes()).toEqual([
129
+ '/ws/rest/v1/patient/new-uuid/identifier',
130
+ '/ws/rest/v1/person/new-uuid/attribute',
131
+ '/ws/rest/v1/person/new-uuid/attribute',
132
+ ]);
133
+ // The workspace must open last, and exactly once — the Check In path double-launches
134
+ // because createHIEPatient launches internally; this must not reproduce that.
135
+ expect(mockLaunchedAfterWrites()).toBe(true);
136
+ expect(launchWorkspaceGroup2).toHaveBeenCalledOnce();
137
+ expect(launchWorkspace2).toHaveBeenCalledOnce();
138
+ });
139
+
140
+ it('refuses to re-point a patient already linked to another participant', async () => {
141
+ respondWith({
142
+ localPatient: {
143
+ uuid: 'local-uuid',
144
+ identifiers: [{ uuid: 'identifier-uuid', identifier: '901-9-9-9', identifierType: { uuid: STUDY_ID_TYPE } }],
145
+ },
146
+ });
147
+
148
+ // This used to overwrite the identifier in place, silently moving the child from one
149
+ // participant to another and destroying the previous link.
150
+ await expect(link(localSubject)).rejects.toThrow('already linked to PCS participant 901-9-9-9');
151
+ expect(writes()).toEqual([]);
152
+ expect(launchWorkspace2).not.toHaveBeenCalled();
153
+ });
154
+
155
+ it('is a no-op when the patient already holds this same participant', async () => {
156
+ respondWith({
157
+ localPatient: {
158
+ uuid: 'local-uuid',
159
+ identifiers: [{ uuid: 'identifier-uuid', identifier: '901-1-1-3', identifierType: { uuid: STUDY_ID_TYPE } }],
160
+ },
161
+ });
162
+
163
+ await link(localSubject);
164
+
165
+ // Re-linking to the same participant stays idempotent — no identifier write at all.
166
+ expect(writes().filter((url) => url.includes('/identifier'))).toEqual([]);
167
+ expect(launchWorkspace2).toHaveBeenCalledOnce();
168
+ });
169
+
170
+ it('leaves an attribute alone when it already holds the right value', async () => {
171
+ respondWith({
172
+ localPatient: { uuid: 'local-uuid', identifiers: [] },
173
+ attributes: [{ uuid: 'attribute-uuid', value: 'true', attributeType: { uuid: PBIDS_ENROLLMENT_TYPE } }],
174
+ });
175
+
176
+ await link(localSubject);
177
+
178
+ // PBIDS enrollment already reads 'true'; only the CARDSE attribute is written.
179
+ expect(writes().filter((url) => url.includes('/attribute'))).toEqual(['/ws/rest/v1/person/local-uuid/attribute']);
180
+ });
181
+
182
+ it('writes both enrolment flags as boolean strings', async () => {
183
+ respondWith({ localPatient: { uuid: 'local-uuid', identifiers: [] } });
184
+
185
+ await link(localSubject);
186
+
187
+ const attributePosts = mockOpenmrsFetch.mock.calls
188
+ .filter(([url, options]) => url.includes('/attribute') && (options as any)?.method === 'POST')
189
+ .map(([, options]) => (options as any).body);
190
+
191
+ // The fixture is pbidsEnrolled: true, cardse: false — `false` is written, not skipped.
192
+ expect(attributePosts).toEqual([
193
+ { attributeType: PBIDS_ENROLLMENT_TYPE, value: 'true' },
194
+ { attributeType: CARDSE_ENROLLMENT_TYPE, value: 'false' },
195
+ ]);
196
+ });
197
+
198
+ it('throws without checking in when a write fails', async () => {
199
+ respondWith({ localPatient: { uuid: 'local-uuid', identifiers: [] } });
200
+ mockOpenmrsFetch.mockImplementation((url: string, options?: any) => {
201
+ if (options?.method === 'POST') {
202
+ return Promise.reject(new Error('Identifier already in use'));
203
+ }
204
+ return Promise.resolve({ data: { uuid: 'local-uuid', identifiers: [] } } as any);
205
+ });
206
+
207
+ await expect(link(localSubject)).rejects.toThrow('Identifier already in use');
208
+ expect(launchWorkspace2).not.toHaveBeenCalled();
209
+ });
210
+ });
211
+
212
+ /** True when every POST was issued before the workspace was launched. */
213
+ function mockLaunchedAfterWrites() {
214
+ return (
215
+ vi.mocked(launchWorkspaceGroup2).mock.invocationCallOrder[0] >
216
+ Math.max(...mockOpenmrsFetch.mock.invocationCallOrder)
217
+ );
218
+ }
219
+
220
+ describe('delinkParticipant', () => {
221
+ const delink = (localPatient: any) =>
222
+ delinkParticipant({
223
+ localPatient,
224
+ studyParticipantIdentifierType: STUDY_ID_TYPE,
225
+ pbidsEnrollmentAttributeType: PBIDS_ENROLLMENT_TYPE,
226
+ cardseEnrollmentAttributeType: CARDSE_ENROLLMENT_TYPE,
227
+ });
228
+
229
+ const deletes = () =>
230
+ mockOpenmrsFetch.mock.calls.filter(([, options]) => (options as any)?.method === 'DELETE').map(([url]) => url);
231
+
232
+ it('voids the identifier and both attributes', async () => {
233
+ mockOpenmrsFetch.mockResolvedValue({
234
+ data: {
235
+ results: [
236
+ { uuid: 'status-attr', attributeType: { uuid: PBIDS_ENROLLMENT_TYPE } },
237
+ { uuid: 'category-attr', attributeType: { uuid: CARDSE_ENROLLMENT_TYPE } },
238
+ ],
239
+ },
240
+ } as any);
241
+
242
+ await delink({
243
+ uuid: 'local-uuid',
244
+ identifiers: [{ uuid: 'identifier-uuid', identifierType: { uuid: STUDY_ID_TYPE } }],
245
+ });
246
+
247
+ expect(deletes()).toEqual([
248
+ '/ws/rest/v1/patient/local-uuid/identifier/identifier-uuid',
249
+ '/ws/rest/v1/person/local-uuid/attribute/status-attr',
250
+ '/ws/rest/v1/person/local-uuid/attribute/category-attr',
251
+ ]);
252
+ });
253
+
254
+ it('still succeeds when the study data was only partly written', async () => {
255
+ mockOpenmrsFetch.mockResolvedValue({ data: { results: [] } } as any);
256
+
257
+ await expect(delink({ uuid: 'local-uuid', identifiers: [] })).resolves.toBeUndefined();
258
+ expect(deletes()).toEqual([]);
259
+ });
260
+
261
+ it('leaves an already-voided attribute alone', async () => {
262
+ mockOpenmrsFetch.mockResolvedValue({
263
+ data: {
264
+ results: [{ uuid: 'status-attr', voided: true, attributeType: { uuid: PBIDS_ENROLLMENT_TYPE } }],
265
+ },
266
+ } as any);
267
+
268
+ await delink({ uuid: 'local-uuid', identifiers: [] });
269
+
270
+ expect(deletes()).toEqual([]);
271
+ });
272
+ });
273
+
274
+ describe('syncStudyAttributes', () => {
275
+ const sync = () =>
276
+ syncStudyAttributes({
277
+ personUuid: 'local-uuid',
278
+ participant,
279
+ pbidsEnrollmentAttributeType: PBIDS_ENROLLMENT_TYPE,
280
+ cardseEnrollmentAttributeType: CARDSE_ENROLLMENT_TYPE,
281
+ });
282
+
283
+ const posts = () =>
284
+ mockOpenmrsFetch.mock.calls
285
+ .filter(([, options]) => (options as any)?.method === 'POST')
286
+ .map(([url, options]) => [url, (options as any).body]);
287
+
288
+ /** The fixture is pbidsEnrolled: true, cardse: false. */
289
+ const withAttributes = (attributes: Array<any>) =>
290
+ mockOpenmrsFetch.mockImplementation((url: string, options?: any) => {
291
+ if (options?.method === 'POST') {
292
+ return Promise.resolve({ data: {} } as any);
293
+ }
294
+ return Promise.resolve({ data: { results: attributes } } as any);
295
+ });
296
+
297
+ it('writes only the flag that drifted and reports just that one', async () => {
298
+ withAttributes([
299
+ { uuid: 'pbids-attr', value: 'true', attributeType: { uuid: PBIDS_ENROLLMENT_TYPE } },
300
+ { uuid: 'cardse-attr', value: 'true', attributeType: { uuid: CARDSE_ENROLLMENT_TYPE } },
301
+ ]);
302
+
303
+ await expect(sync()).resolves.toEqual(['cardse']);
304
+ expect(posts()).toEqual([['/ws/rest/v1/person/local-uuid/attribute/cardse-attr', { value: 'false' }]]);
305
+ });
306
+
307
+ it('writes nothing when the patient already matches PCS', async () => {
308
+ withAttributes([
309
+ { uuid: 'pbids-attr', value: 'true', attributeType: { uuid: PBIDS_ENROLLMENT_TYPE } },
310
+ { uuid: 'cardse-attr', value: 'false', attributeType: { uuid: CARDSE_ENROLLMENT_TYPE } },
311
+ ]);
312
+
313
+ await expect(sync()).resolves.toEqual([]);
314
+ expect(posts()).toEqual([]);
315
+ });
316
+
317
+ it('creates both attributes when the patient has none', async () => {
318
+ withAttributes([]);
319
+
320
+ await expect(sync()).resolves.toEqual(['pbids', 'cardse']);
321
+ expect(posts()).toEqual([
322
+ ['/ws/rest/v1/person/local-uuid/attribute', { attributeType: PBIDS_ENROLLMENT_TYPE, value: 'true' }],
323
+ ['/ws/rest/v1/person/local-uuid/attribute', { attributeType: CARDSE_ENROLLMENT_TYPE, value: 'false' }],
324
+ ]);
325
+ });
326
+
327
+ it('propagates a write failure so the caller can report it', async () => {
328
+ mockOpenmrsFetch.mockImplementation((_url: string, options?: any) =>
329
+ options?.method === 'POST'
330
+ ? Promise.reject(new Error('Attribute type not found'))
331
+ : Promise.resolve({ data: { results: [] } } as any),
332
+ );
333
+
334
+ await expect(sync()).rejects.toThrow('Attribute type not found');
335
+ });
336
+ });