fhirsmith 0.17.0 → 0.18.1

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 (131) hide show
  1. package/CHANGELOG.md +113 -0
  2. package/README.md +80 -13
  3. package/config-template.json +106 -1
  4. package/ips/ai-client.js +263 -0
  5. package/ips/composer.js +805 -0
  6. package/ips/editor-schema.js +289 -0
  7. package/ips/editor.js +290 -0
  8. package/ips/fhirpath-ips.js +397 -0
  9. package/ips/ips-codeable-concepts.md +1742 -0
  10. package/ips/ips-template.html +125 -0
  11. package/ips/ips-valuesets.js +159 -0
  12. package/ips/ips.js +1736 -0
  13. package/ips/membership.js +179 -0
  14. package/ips/pipeline.js +1012 -0
  15. package/ips/presenter.js +974 -0
  16. package/ips/profile-editor.js +578 -0
  17. package/ips/profiles.js +506 -0
  18. package/ips/readme.md +460 -0
  19. package/ips/render.js +1482 -0
  20. package/ips/search-params.js +83 -0
  21. package/ips/search.js +667 -0
  22. package/ips/share.js +178 -0
  23. package/ips/store.js +650 -0
  24. package/ips/terminology.js +395 -0
  25. package/ips/tx-client.js +202 -0
  26. package/ips/valuesets/CodeSystem-absent-unknown-uv-ips.json +206 -0
  27. package/ips/valuesets/ValueSet-absent-or-unknown-immunizations-uv-ips.json +59 -0
  28. package/ips/valuesets/ValueSet-ips-allergy-code.json +59 -0
  29. package/ips/valuesets/ValueSet-ips-allergy-manifestation.json +356 -0
  30. package/ips/valuesets/ValueSet-ips-allergy-substance.json +37 -0
  31. package/ips/valuesets/ValueSet-ips-body-site.json +26 -0
  32. package/ips/valuesets/ValueSet-ips-clinical-finding-or-none.json +35 -0
  33. package/ips/valuesets/ValueSet-ips-clinical-finding.json +26 -0
  34. package/ips/valuesets/ValueSet-ips-condition-code.json +72 -0
  35. package/ips/valuesets/ValueSet-ips-condition-severity.json +30 -0
  36. package/ips/valuesets/ValueSet-ips-condition-stage.json +26 -0
  37. package/ips/valuesets/ValueSet-ips-exposure-route.json +26 -0
  38. package/ips/valuesets/ValueSet-ips-imaging-procedure.json +25 -0
  39. package/ips/valuesets/ValueSet-ips-immunization-status-reason.json +43 -0
  40. package/ips/valuesets/ValueSet-ips-international-codes.json +25 -0
  41. package/ips/valuesets/ValueSet-ips-medication-ingredient.json +26 -0
  42. package/ips/valuesets/ValueSet-ips-medication-status-reason.json +37 -0
  43. package/ips/valuesets/ValueSet-ips-medication.json +50 -0
  44. package/ips/valuesets/ValueSet-ips-observation-code.json +18 -0
  45. package/ips/valuesets/ValueSet-ips-observation-interpretation.json +18 -0
  46. package/ips/valuesets/ValueSet-ips-procedure-followup.json +51 -0
  47. package/ips/valuesets/ValueSet-ips-procedure-outcome.json +30 -0
  48. package/ips/valuesets/ValueSet-ips-procedure-reason.json +37 -0
  49. package/ips/valuesets/ValueSet-ips-procedure.json +116 -0
  50. package/ips/valuesets/ValueSet-ips-route.json +46 -0
  51. package/ips/valuesets/ValueSet-ips-target-disease.json +171 -0
  52. package/ips/valuesets/ValueSet-ips-vaccine.json +31 -0
  53. package/ips/valuesets/elements.json +44 -0
  54. package/library/daily-report.js +432 -0
  55. package/library/fhir-search-values.js +138 -0
  56. package/library/html-server.js +32 -23
  57. package/library/mailer.js +77 -0
  58. package/library/narrative-check.js +21 -2
  59. package/library/npm-audit.js +25 -0
  60. package/library/package-manager.js +4 -2
  61. package/library/sqlite-session-store.js +29 -1
  62. package/package.json +8 -3
  63. package/packages/packages.js +34 -3
  64. package/publisher/publisher.js +29 -0
  65. package/registry/auth-store.js +366 -0
  66. package/registry/auth.js +872 -0
  67. package/registry/crawler.js +41 -1
  68. package/registry/documentation/api.md +162 -0
  69. package/registry/documentation/configuration.md +93 -0
  70. package/registry/documentation/documents.md +74 -0
  71. package/registry/documentation/internals.md +151 -0
  72. package/registry/documentation/readme.md +100 -0
  73. package/registry/documentation/security.md +199 -0
  74. package/registry/documents.js +309 -0
  75. package/registry/openapi.yaml +30 -0
  76. package/registry/registry-template.html +1 -1
  77. package/registry/registry.js +230 -44
  78. package/registry/uptime.js +154 -0
  79. package/server.js +105 -44
  80. package/shl/shl.js +64 -9
  81. package/stats-db.js +93 -0
  82. package/stats.js +158 -1
  83. package/testing/search.js +2 -108
  84. package/testing/store.js +5 -0
  85. package/testing/testing.js +20 -0
  86. package/token/token-template.html +5 -4
  87. package/token/token.js +17 -10
  88. package/translations/Messages.properties +10 -0
  89. package/tx/README.md +52 -0
  90. package/tx/closure/closure-store.js +14 -0
  91. package/tx/cs/cs-api.js +92 -14
  92. package/tx/cs/cs-cs.js +64 -3
  93. package/tx/cs/cs-icd11.js +366 -112
  94. package/tx/cs/cs-icf.js +431 -0
  95. package/tx/cs/cs-loinc.js +296 -27
  96. package/tx/cs/cs-omop.js +10 -3
  97. package/tx/cs/cs-snomed.js +1210 -80
  98. package/tx/cs/cs-ucum.js +6 -0
  99. package/tx/cs/icd11-expressions.js +191 -78
  100. package/tx/importers/csv-records.js +35 -0
  101. package/tx/importers/export-icd11-codesystem.js +58 -12
  102. package/tx/importers/icd11-schema.md +9 -5
  103. package/tx/importers/import-loinc.module.js +177 -41
  104. package/tx/importers/import-sct.module.js +66 -1
  105. package/tx/importers/subset-loinc.module.js +25 -6
  106. package/tx/library/codesystem.js +4 -1
  107. package/tx/library/designations.js +23 -30
  108. package/tx/library.js +98 -4
  109. package/tx/openapi-operations.js +6 -1
  110. package/tx/params.js +8 -1
  111. package/tx/provider.js +24 -6
  112. package/tx/sct/documentation.md +251 -0
  113. package/tx/sct/ecl.js +758 -46
  114. package/tx/sct/expressions.js +113 -52
  115. package/tx/sct/structures.js +8 -2
  116. package/tx/syndication/syndication-feed.js +218 -0
  117. package/tx/syndication/syndication-store.js +415 -0
  118. package/tx/tx-html.js +15 -1
  119. package/tx/tx.fhir.org.yml +3 -4
  120. package/tx/tx.js +144 -2
  121. package/tx/vs/vs-vsac.js +25 -6
  122. package/tx/workers/closure.js +20 -0
  123. package/tx/workers/expand.js +35 -8
  124. package/tx/workers/lookup.js +61 -6
  125. package/tx/workers/subsumes.js +1 -1
  126. package/tx/workers/translate.js +166 -15
  127. package/tx/workers/validate.js +138 -6
  128. package/utilities/make-sct-loinc-subset.js +250 -0
  129. package/utilities/make-sct-test-extension.js +264 -0
  130. package/xig/xig.js +32 -0
  131. package/registry/readme.md +0 -124
@@ -0,0 +1,805 @@
1
+ //
2
+ // Copyright 2026, Health Intersections Pty Ltd (http://www.healthintersections.com.au)
3
+ //
4
+ // Licensed under BSD-3: https://opensource.org/license/bsd-3-clause
5
+ //
6
+
7
+ /**
8
+ * The composer: between what the IPS editor holds (a model - the patient, the document's
9
+ * header, and each section's mode and rows, as editor-schema.js describes them) and a FHIR
10
+ * document Bundle. `build` makes the document from a model, checking the model as it goes;
11
+ * `modelOf` reads a document back into a model, as far as the form can hold it, so an
12
+ * existing document can be edited.
13
+ *
14
+ * The model:
15
+ * { patient: { family, given, gender, birthDate, identifierSystem, identifier, phone, email, address },
16
+ * document: { title, author, authorType, custodian, date },
17
+ * sections: { <key>: { mode: 'data' | 'narrative' | 'empty' | 'omit', emptyReason, narrative, rows: [ { <field>: value, <sub>: [ {...} ] } ] } } }
18
+ * A code value is { system, code, display, text }; a quantity is { value, unit }; a select
19
+ * is its code; a list field holds an array of those.
20
+ *
21
+ * @module ips/composer
22
+ */
23
+
24
+ const crypto = require('crypto');
25
+ const escape = require('escape-html');
26
+ const parse5 = require('parse5');
27
+ const { HEADER, SECTIONS, EMPTY_REASONS, EMPTY_REASON_SYSTEM, LOINC } = require('./editor-schema');
28
+ const { Presenter } = require('./presenter');
29
+
30
+ const UCUM = 'http://unitsofmeasure.org';
31
+ const XHTML = 'http://www.w3.org/1999/xhtml';
32
+ const COMPOSITION_TYPE = { coding: [{ system: LOINC, code: '60591-5', display: 'Patient summary Document' }] };
33
+ const MAX_ROWS = 200;
34
+ const MAX_TEXT = 4000;
35
+ const MAX_NARRATIVE = 20000;
36
+ // the elements that repeat, when a path goes through them
37
+ const ARRAYS = new Set(['protocolApplied', 'dosage', 'referenceRange', 'category', 'note', 'bodySite', 'reasonCode', 'interpretation', 'manifestation', 'reaction', 'component', 'targetDisease', 'deviceName']);
38
+
39
+ function isObject(v) {
40
+ return v !== null && typeof v === 'object' && !Array.isArray(v);
41
+ }
42
+
43
+ function str(v) {
44
+ return typeof v === 'string' ? v.trim() : '';
45
+ }
46
+
47
+ const uuid = () => `urn:uuid:${crypto.randomUUID()}`;
48
+
49
+ // ---- values -------------------------------------------------------------------------------
50
+
51
+ /** A code value from the form as a CodeableConcept, or null when it is empty. */
52
+ function codeableOf(v) {
53
+ if (typeof v === 'string') {
54
+ return str(v) ? { text: str(v) } : null;
55
+ }
56
+ if (!isObject(v)) {
57
+ return null;
58
+ }
59
+ const cc = {};
60
+ if (str(v.system) && str(v.code)) {
61
+ const coding = { system: str(v.system), code: str(v.code) };
62
+ if (str(v.display)) {
63
+ coding.display = str(v.display);
64
+ }
65
+ cc.coding = [coding];
66
+ }
67
+ if (str(v.text)) {
68
+ cc.text = str(v.text);
69
+ } else if (!cc.coding && str(v.display)) {
70
+ cc.text = str(v.display);
71
+ }
72
+ return cc.coding || cc.text ? cc : null;
73
+ }
74
+
75
+ function quantityOf(v) {
76
+ if (!isObject(v)) {
77
+ return null;
78
+ }
79
+ const n = typeof v.value === 'number' ? v.value : (str(v.value) ? Number(v.value) : NaN);
80
+ if (!Number.isFinite(n)) {
81
+ return null;
82
+ }
83
+ const q = { value: n };
84
+ if (str(v.unit)) {
85
+ q.unit = str(v.unit);
86
+ q.system = UCUM;
87
+ q.code = str(v.unit);
88
+ }
89
+ return q;
90
+ }
91
+
92
+ /** The value of a field, as FHIR: null when there is nothing. */
93
+ function fhirValue(field, v) {
94
+ switch (field.type) {
95
+ case 'code': return codeableOf(v);
96
+ case 'quantity': return quantityOf(v);
97
+ case 'select': {
98
+ const c = str(v);
99
+ if (!c) {
100
+ return null;
101
+ }
102
+ const opt = (field.options || []).find(o => o.code === c);
103
+ if (!opt) {
104
+ return undefined; // not one of the options
105
+ }
106
+ return field.system ? { coding: [{ system: field.system, code: opt.code, display: opt.display }] } : opt.code;
107
+ }
108
+ case 'date': {
109
+ const d = str(v);
110
+ if (!d) {
111
+ return null;
112
+ }
113
+ return /^\d{4}(-\d\d(-\d\d)?)?$/.test(d) ? d : undefined;
114
+ }
115
+ default: {
116
+ const t = str(v);
117
+ if (!t) {
118
+ return null;
119
+ }
120
+ return field.as === 'codeable' ? { text: t } : t;
121
+ }
122
+ }
123
+ }
124
+
125
+ /** The name a choice element takes for a field's type: onset[x] + date -> onsetDateTime. */
126
+ function choiceName(base, field, value) {
127
+ if (field.type === 'quantity') {
128
+ return base + 'Quantity';
129
+ }
130
+ if (field.type === 'date') {
131
+ return base + 'DateTime';
132
+ }
133
+ if (field.type === 'code' || (field.type === 'select' && field.system)) {
134
+ return base + 'CodeableConcept';
135
+ }
136
+ return base + (typeof value === 'object' ? 'CodeableConcept' : 'String');
137
+ }
138
+
139
+ /**
140
+ * The choice types a field reads back (the reverse of choiceName), so that fields sharing a
141
+ * value[x] each read only their own: a text field a valueString, not the valueCodeableConcept
142
+ * a code field made. A date reads any (dateTime, Period, ...); null for any.
143
+ */
144
+ function choiceTypes(field) {
145
+ if (!field || field.type === 'date') {
146
+ return null;
147
+ }
148
+ if (field.type === 'quantity') {
149
+ return ['Quantity'];
150
+ }
151
+ if (field.type === 'code' || (field.type === 'select' && field.system)) {
152
+ return ['CodeableConcept'];
153
+ }
154
+ if (field.type === 'text' || field.type === 'textarea' || field.type === 'select') {
155
+ return ['String'];
156
+ }
157
+ return null;
158
+ }
159
+
160
+ // path parts that would reach an object's prototype rather than a property of its own
161
+ const UNSAFE_PARTS = new Set(['__proto__', 'constructor', 'prototype']);
162
+
163
+ /**
164
+ * The parts of a path. The paths are the schema's own (editor-schema.js), never a
165
+ * request's; even so, a part that would reach a prototype is refused.
166
+ */
167
+ function pathParts(path) {
168
+ const parts = String(path).split('.');
169
+ if (parts.some(p => UNSAFE_PARTS.has(p) || UNSAFE_PARTS.has(p.replace(/\[x\]$/, '')))) {
170
+ throw new Error(`The path '${path}' is not one a resource can have`);
171
+ }
172
+ return parts;
173
+ }
174
+
175
+ /** Put a value at a path in a resource: a.b nests, an array element takes its first member. */
176
+ function setPath(resource, path, value, field) {
177
+ const parts = pathParts(path);
178
+ let node = resource;
179
+ for (let i = 0; i < parts.length; i++) {
180
+ let name = parts[i];
181
+ const last = i === parts.length - 1;
182
+ if (name.endsWith('[x]')) {
183
+ name = choiceName(name.substring(0, name.length - 3), field, value);
184
+ }
185
+ if (last) {
186
+ if (field.list || ARRAYS.has(name) && !['protocolApplied', 'dosage', 'referenceRange', 'reaction', 'component', 'deviceName'].includes(name)) {
187
+ const list = Array.isArray(value) ? value : [value];
188
+ node[name] = (node[name] || []).concat(list);
189
+ } else {
190
+ node[name] = value;
191
+ }
192
+ return;
193
+ }
194
+ if (ARRAYS.has(name)) {
195
+ if (!Array.isArray(node[name]) || node[name].length === 0) {
196
+ node[name] = [{}];
197
+ }
198
+ node = node[name][0];
199
+ } else {
200
+ if (!isObject(node[name])) {
201
+ node[name] = {};
202
+ }
203
+ node = node[name];
204
+ }
205
+ }
206
+ }
207
+
208
+ /** Read a value at a path: the reverse of setPath, taking first members of arrays. */
209
+ function getPath(resource, path, field) {
210
+ const parts = pathParts(path);
211
+ let node = resource;
212
+ for (let i = 0; i < parts.length; i++) {
213
+ if (!isObject(node)) {
214
+ return undefined;
215
+ }
216
+ let name = parts[i];
217
+ if (name.endsWith('[x]')) {
218
+ const base = name.substring(0, name.length - 3);
219
+ const types = i === parts.length - 1 ? choiceTypes(field) : null;
220
+ name = Object.keys(node).find(k => k.startsWith(base) && k.length > base.length && /^[A-Z]/.test(k[base.length]) &&
221
+ (!types || types.includes(k.substring(base.length))));
222
+ if (!name) {
223
+ return undefined;
224
+ }
225
+ }
226
+ let v = node[name];
227
+ if (Array.isArray(v) && !(i === parts.length - 1 && field?.list)) {
228
+ v = v[0];
229
+ }
230
+ if (i === parts.length - 1) {
231
+ return v;
232
+ }
233
+ node = v;
234
+ }
235
+ return undefined;
236
+ }
237
+
238
+ /** A form value from FHIR: the reverse of fhirValue. */
239
+ function formValue(field, v) {
240
+ if (v === undefined || v === null) {
241
+ return undefined;
242
+ }
243
+ const one = (x) => {
244
+ switch (field.type) {
245
+ case 'code': {
246
+ if (typeof x === 'string') {
247
+ return { text: x };
248
+ }
249
+ if (!isObject(x)) {
250
+ return undefined;
251
+ }
252
+ const c = (Array.isArray(x.coding) ? x.coding : []).find(isObject) || {};
253
+ const out = { system: str(c.system), code: str(c.code), display: str(c.display), text: str(x.text) };
254
+ return out.code || out.text ? out : undefined;
255
+ }
256
+ case 'quantity': return isObject(x) && typeof x.value === 'number' ? { value: x.value, unit: str(x.unit) || str(x.code) } : undefined;
257
+ case 'select': {
258
+ const c = typeof x === 'string' ? x : (isObject(x) ? str((Array.isArray(x.coding) ? x.coding : []).find(isObject)?.code) : '');
259
+ return (field.options || []).some(o => o.code === c) ? c : undefined;
260
+ }
261
+ case 'date': return typeof x === 'string' && /^\d{4}(-\d\d(-\d\d)?)?/.test(x) ? x.substring(0, 10) : undefined;
262
+ default: {
263
+ if (typeof x === 'string') {
264
+ return x;
265
+ }
266
+ if (isObject(x)) {
267
+ return str(x.text) || str((Array.isArray(x.coding) ? x.coding : []).find(isObject)?.display) || undefined;
268
+ }
269
+ if (Array.isArray(x)) {
270
+ return x.map(n => (isObject(n) ? str(n.text) : str(n))).filter(Boolean).join('\n') || undefined;
271
+ }
272
+ return undefined;
273
+ }
274
+ }
275
+ };
276
+ if (field.list) {
277
+ const list = (Array.isArray(v) ? v : [v]).map(one).filter(x => x !== undefined);
278
+ return list.length ? list : undefined;
279
+ }
280
+ return one(Array.isArray(v) ? v[0] : v);
281
+ }
282
+
283
+ // ---- building -------------------------------------------------------------------------------
284
+
285
+ /**
286
+ * Build the document.
287
+ * @param {Object} model
288
+ * @param {{now?: Date}} [options]
289
+ * @returns {Promise<{bundle: Object|null, problems: string[]}>} the document, or the problems with the model
290
+ */
291
+ async function build(model, options = {}) {
292
+ const problems = [];
293
+ if (!isObject(model)) {
294
+ return { bundle: null, problems: ['The model must be an object'] };
295
+ }
296
+ const now = options.now || new Date();
297
+ const patientIn = isObject(model.patient) ? model.patient : {};
298
+ const docIn = isObject(model.document) ? model.document : {};
299
+ const sectionsIn = isObject(model.sections) ? model.sections : {};
300
+ const bad = (what) => problems.push(what);
301
+
302
+ // the people and organizations
303
+ const patient = { resourceType: 'Patient', id: crypto.randomUUID() };
304
+ const family = str(patientIn.family);
305
+ if (!family) {
306
+ bad('The patient needs a family name');
307
+ }
308
+ const name = { family };
309
+ const given = str(patientIn.given);
310
+ if (given) {
311
+ name.given = given.split(/\s+/).filter(Boolean);
312
+ }
313
+ patient.name = [name];
314
+ const gender = fhirValue(HEADER.patient.find(f => f.name === 'gender'), patientIn.gender);
315
+ if (gender === undefined) {
316
+ bad('The patient\'s gender is not one of the choices');
317
+ } else if (gender) {
318
+ patient.gender = gender;
319
+ }
320
+ const birthDate = fhirValue(HEADER.patient.find(f => f.name === 'birthDate'), patientIn.birthDate);
321
+ if (birthDate === undefined) {
322
+ bad('The patient\'s date of birth is not a date (YYYY, YYYY-MM or YYYY-MM-DD)');
323
+ } else if (birthDate) {
324
+ patient.birthDate = birthDate;
325
+ }
326
+ if (str(patientIn.identifier)) {
327
+ patient.identifier = [{ value: str(patientIn.identifier).substring(0, 200), ...(str(patientIn.identifierSystem) ? { system: str(patientIn.identifierSystem).substring(0, 500) } : {}) }];
328
+ }
329
+ const telecom = [];
330
+ if (str(patientIn.phone)) {
331
+ telecom.push({ system: 'phone', value: str(patientIn.phone).substring(0, 100) });
332
+ }
333
+ if (str(patientIn.email)) {
334
+ telecom.push({ system: 'email', value: str(patientIn.email).substring(0, 200) });
335
+ }
336
+ if (telecom.length) {
337
+ patient.telecom = telecom;
338
+ }
339
+ if (str(patientIn.address)) {
340
+ patient.address = [{ text: str(patientIn.address).substring(0, 500) }];
341
+ }
342
+ const authorName = str(docIn.author).substring(0, 200);
343
+ if (!authorName) {
344
+ bad('The document needs an author');
345
+ }
346
+ const authorType = docIn.authorType === 'Organization' ? 'Organization' : 'Practitioner';
347
+ const author = authorType === 'Organization'
348
+ ? { resourceType: 'Organization', id: crypto.randomUUID(), name: authorName }
349
+ : { resourceType: 'Practitioner', id: crypto.randomUUID(), name: [{ text: authorName }] };
350
+ const custodian = str(docIn.custodian) ? { resourceType: 'Organization', id: crypto.randomUUID(), name: str(docIn.custodian).substring(0, 200) } : null;
351
+ const date = str(docIn.date) ? fhirValue({ type: 'date' }, docIn.date) : now.toISOString();
352
+ if (date === undefined) {
353
+ bad('The document\'s date is not a date');
354
+ }
355
+
356
+ const entries = [];
357
+ const add = (resource) => {
358
+ const fullUrl = uuid();
359
+ entries.push({ fullUrl, resource });
360
+ return fullUrl;
361
+ };
362
+ const composition = {
363
+ resourceType: 'Composition', id: crypto.randomUUID(), status: 'final', type: COMPOSITION_TYPE, date: date || now.toISOString(),
364
+ title: str(docIn.title).substring(0, 200) || `Patient Summary as of ${(date || now.toISOString()).substring(0, 10)}`,
365
+ section: []
366
+ };
367
+ add(composition);
368
+ const patientUrl = add(patient);
369
+ const authorUrl = add(author);
370
+ composition.subject = { reference: patientUrl, display: [given, family].filter(Boolean).join(' ') };
371
+ composition.author = [{ reference: authorUrl, display: authorName }];
372
+ if (custodian) {
373
+ composition.custodian = { reference: add(custodian), display: custodian.name };
374
+ }
375
+
376
+ // the sections
377
+ const userNarratives = new Map();
378
+ for (const def of SECTIONS) {
379
+ const s = isObject(sectionsIn[def.key]) ? sectionsIn[def.key] : null;
380
+ const mode = s && ['data', 'narrative', 'empty', 'omit'].includes(s.mode) ? s.mode : (s && Array.isArray(s.rows) && s.rows.length ? 'data' : 'omit');
381
+ if (mode === 'omit') {
382
+ if (def.required) {
383
+ bad(`The ${def.title} section is required: enter its entries, a narrative, or why it is empty`);
384
+ }
385
+ continue;
386
+ }
387
+ const section = { title: def.title, code: { coding: [{ system: LOINC, code: def.code, display: def.title }] } };
388
+ composition.section.push(section);
389
+ if (mode === 'empty') {
390
+ const reason = EMPTY_REASONS.find(r => r.code === str(s.emptyReason));
391
+ if (!reason) {
392
+ bad(`The ${def.title} section needs a reason for being empty`);
393
+ continue;
394
+ }
395
+ section.emptyReason = { coding: [{ system: EMPTY_REASON_SYSTEM, code: reason.code, display: reason.display }] };
396
+ continue;
397
+ }
398
+ if (mode === 'narrative') {
399
+ const narrative = str(s.narrative);
400
+ if (!narrative) {
401
+ bad(`The ${def.title} section's narrative is empty`);
402
+ continue;
403
+ }
404
+ if (narrative.length > MAX_NARRATIVE) {
405
+ bad(`The ${def.title} section's narrative is too long (${MAX_NARRATIVE} characters at most)`);
406
+ continue;
407
+ }
408
+ userNarratives.set(section, narrative);
409
+ continue;
410
+ }
411
+ const rows = Array.isArray(s.rows) ? s.rows.filter(isObject) : [];
412
+ if (rows.length === 0) {
413
+ bad(`The ${def.title} section has no entries: add some, or choose a narrative or a reason for being empty`);
414
+ continue;
415
+ }
416
+ if (rows.length > MAX_ROWS) {
417
+ bad(`The ${def.title} section has too many entries (${MAX_ROWS} at most)`);
418
+ continue;
419
+ }
420
+ section.entry = [];
421
+ rows.forEach((row, i) => {
422
+ const where = `${def.title}, entry ${i + 1}`;
423
+ const built = buildRow(def, row, where, patientUrl, bad);
424
+ if (!built) {
425
+ return;
426
+ }
427
+ entries.push(...built.extras);
428
+ section.entry.push({ reference: add(built.resource) });
429
+ });
430
+ }
431
+ if (problems.length > 0) {
432
+ return { bundle: null, problems };
433
+ }
434
+
435
+ const bundle = {
436
+ resourceType: 'Bundle',
437
+ identifier: { system: 'urn:ietf:rfc:3986', value: uuid() },
438
+ type: 'document',
439
+ timestamp: now.toISOString(),
440
+ entry: entries
441
+ };
442
+ // the narratives: the presenter's for the header and the data sections, the user's for theirs
443
+ const presenter = new Presenter();
444
+ const out = await presenter.render(bundle);
445
+ composition.text = { status: 'generated', div: out.header };
446
+ for (const sec of out.sections) {
447
+ sec.section.text = userNarratives.has(sec.section)
448
+ ? { status: 'additional', div: narrativeDiv(userNarratives.get(sec.section)) }
449
+ : { status: 'generated', div: sec.html };
450
+ }
451
+ return { bundle, problems: [] };
452
+ }
453
+
454
+ /** A user's narrative as xhtml: paragraphs at blank lines, everything escaped. */
455
+ function narrativeDiv(text) {
456
+ const paragraphs = text.split(/\n\s*\n/).map(p => p.trim()).filter(Boolean);
457
+ return `<div xmlns="${XHTML}">${paragraphs.map(p => `<p>${escape(p).replace(/\n/g, '<br/>')}</p>`).join('')}</div>`;
458
+ }
459
+
460
+ /**
461
+ * One row of a section as its resource (and any companion resource - a Device for a
462
+ * DeviceUseStatement).
463
+ */
464
+ function buildRow(def, row, where, patientUrl, bad) {
465
+ const resource = { resourceType: def.resourceType, id: crypto.randomUUID() };
466
+ const extras = [];
467
+ let ok = true;
468
+ let device = null;
469
+ let diastolic = null;
470
+ const fail = (m) => {
471
+ bad(`${where}: ${m}`);
472
+ ok = false;
473
+ };
474
+ const apply = (target, field, raw, label) => {
475
+ if (raw === undefined || raw === null || raw === '' || (Array.isArray(raw) && raw.length === 0)) {
476
+ if (field.required) {
477
+ fail(`${label} is required`);
478
+ }
479
+ return;
480
+ }
481
+ const values = field.list ? (Array.isArray(raw) ? raw : [raw]) : [raw];
482
+ const out = [];
483
+ for (const v of values) {
484
+ if (typeof v === 'string' && v.length > MAX_TEXT) {
485
+ fail(`${label} is too long`);
486
+ return;
487
+ }
488
+ const fv = fhirValue(field, v);
489
+ if (fv === undefined) {
490
+ fail(`${label} is not valid`);
491
+ return;
492
+ }
493
+ if (fv !== null) {
494
+ out.push(fv);
495
+ }
496
+ }
497
+ if (out.length === 0) {
498
+ if (field.required) {
499
+ fail(`${label} is required`);
500
+ }
501
+ return;
502
+ }
503
+ if (field.path === '$diastolic') {
504
+ diastolic = out[0];
505
+ return;
506
+ }
507
+ if (field.path.startsWith('$device.')) {
508
+ device = device || { resourceType: 'Device', id: crypto.randomUUID() };
509
+ setPath(device, field.path.substring('$device.'.length), out[0], field);
510
+ if (field.path === '$device.deviceName.name') {
511
+ device.deviceName[0].type = 'user-friendly-name';
512
+ }
513
+ return;
514
+ }
515
+ if (field.path === '$narrative') {
516
+ target.text = { status: 'additional', div: narrativeDiv(out[0]) };
517
+ return;
518
+ }
519
+ if (field.name === 'note') {
520
+ target.note = [{ text: out[0] }];
521
+ return;
522
+ }
523
+ // a choice element takes one type: a second field for the same value[x] (a quantity
524
+ // and a text, say) would make two values, which FHIR doesn't allow
525
+ if (field.path.endsWith('[x]') && !field.path.includes('.')) {
526
+ const base = field.path.substring(0, field.path.length - 3);
527
+ const taken = Object.keys(target).find(k => k.startsWith(base) && k.length > base.length && /^[A-Z]/.test(k[base.length]));
528
+ if (taken) {
529
+ fail(`${label}: only one of the ${base} fields can be filled in (${taken} already is)`);
530
+ return;
531
+ }
532
+ }
533
+ setPath(target, field.path, field.list ? out : out[0], field);
534
+ };
535
+ for (const field of def.fields) {
536
+ apply(resource, field, row[field.name], field.label);
537
+ }
538
+ for (const sub of def.subs || []) {
539
+ const items = Array.isArray(row[sub.name]) ? row[sub.name].filter(isObject) : [];
540
+ if (items.length > MAX_ROWS) {
541
+ fail(`too many ${sub.label.toLowerCase()}`);
542
+ continue;
543
+ }
544
+ const built = [];
545
+ for (const item of items) {
546
+ const part = {};
547
+ for (const field of sub.fields) {
548
+ apply(part, field, item[field.name], `${sub.label}: ${field.label}`);
549
+ }
550
+ if (Object.keys(part).length > 0) {
551
+ built.push(part);
552
+ }
553
+ }
554
+ if (built.length > 0) {
555
+ resource[sub.path] = built;
556
+ }
557
+ }
558
+ if (!ok) {
559
+ return null;
560
+ }
561
+ // what every resource of the type carries
562
+ for (const [k, v] of Object.entries(def.fixed || {})) {
563
+ if (resource[k] === undefined) {
564
+ resource[k] = structuredClone(v);
565
+ }
566
+ }
567
+ // the patient
568
+ const subjectField = { Condition: 'subject', AllergyIntolerance: 'patient', MedicationStatement: 'subject', Immunization: 'patient', Procedure: 'subject', DeviceUseStatement: 'subject', Observation: 'subject', CarePlan: 'subject', Consent: 'patient', Flag: 'subject' }[def.resourceType];
569
+ if (subjectField) {
570
+ resource[subjectField] = { reference: patientUrl };
571
+ }
572
+ // a blood pressure: the two values as components, no value of its own
573
+ if (def.key === 'vitals') {
574
+ const option = def.fields[0].options.find(o => o.code === row.code);
575
+ if (option && option.components) {
576
+ const systolic = resource.valueQuantity;
577
+ delete resource.valueQuantity;
578
+ if (!systolic || !diastolic) {
579
+ fail('a blood pressure needs both the systolic and the diastolic value');
580
+ return null;
581
+ }
582
+ resource.component = [
583
+ { code: { coding: [{ system: LOINC, ...option.components[0] }] }, valueQuantity: systolic },
584
+ { code: { coding: [{ system: LOINC, ...option.components[1] }] }, valueQuantity: diastolic }
585
+ ];
586
+ } else if (!resource.valueQuantity) {
587
+ fail('a value is required');
588
+ return null;
589
+ } else if (diastolic) {
590
+ fail('only a blood pressure has a diastolic value');
591
+ return null;
592
+ }
593
+ if (option && option.units && resource.valueQuantity && !resource.valueQuantity.unit) {
594
+ resource.valueQuantity.unit = option.units;
595
+ resource.valueQuantity.system = UCUM;
596
+ resource.valueQuantity.code = option.units;
597
+ }
598
+ for (const c of resource.component || []) {
599
+ if (!c.valueQuantity.unit) {
600
+ c.valueQuantity.unit = option.units;
601
+ c.valueQuantity.system = UCUM;
602
+ c.valueQuantity.code = option.units;
603
+ }
604
+ }
605
+ }
606
+ if (def.resourceType === 'Observation' && !resource.valueQuantity && !resource.valueString && !resource.valueCodeableConcept && !resource.valueDateTime && !resource.component && !resource.dataAbsentReason) {
607
+ fail('a value is required');
608
+ return null;
609
+ }
610
+ if (device) {
611
+ const deviceUrl = uuid();
612
+ extras.push({ fullUrl: deviceUrl, resource: device });
613
+ resource.device = { reference: deviceUrl, display: device.deviceName?.[0]?.name };
614
+ }
615
+ return { resource, extras };
616
+ }
617
+
618
+ // ---- reading a document back ------------------------------------------------------------------
619
+
620
+ /**
621
+ * A model from a document, as far as the form can hold it (what it cannot - other
622
+ * resource types, elements the form has no field for - is not carried; a section with
623
+ * entries the form cannot represent becomes a narrative section with the document's own).
624
+ */
625
+ function modelOf(bundle) {
626
+ const entries = Array.isArray(bundle?.entry) ? bundle.entry.filter(isObject) : [];
627
+ const byRef = new Map();
628
+ for (const e of entries) {
629
+ if (isObject(e.resource)) {
630
+ if (typeof e.fullUrl === 'string') {
631
+ byRef.set(e.fullUrl, e.resource);
632
+ }
633
+ if (typeof e.resource.id === 'string') {
634
+ byRef.set(`${e.resource.resourceType}/${e.resource.id}`, e.resource);
635
+ }
636
+ }
637
+ }
638
+ const resolve = (ref) => (isObject(ref) && typeof ref.reference === 'string' ? byRef.get(ref.reference) || null : null);
639
+ const composition = entries[0]?.resource?.resourceType === 'Composition' ? entries[0].resource : null;
640
+ const model = { patient: {}, document: {}, sections: {} };
641
+ const patient = resolve(composition?.subject) || entries.map(e => e.resource).find(r => r?.resourceType === 'Patient') || null;
642
+ if (patient) {
643
+ const name = (Array.isArray(patient.name) ? patient.name : []).find(n => isObject(n) && n.use === 'official') || (Array.isArray(patient.name) ? patient.name : []).find(isObject) || {};
644
+ model.patient.family = str(name.family) || str(name.text);
645
+ model.patient.given = (Array.isArray(name.given) ? name.given : []).map(str).filter(Boolean).join(' ');
646
+ model.patient.gender = str(patient.gender);
647
+ model.patient.birthDate = str(patient.birthDate);
648
+ const id = (Array.isArray(patient.identifier) ? patient.identifier : []).find(isObject);
649
+ if (id) {
650
+ model.patient.identifier = str(id.value);
651
+ model.patient.identifierSystem = str(id.system);
652
+ }
653
+ for (const t of Array.isArray(patient.telecom) ? patient.telecom : []) {
654
+ if (isObject(t) && t.system === 'phone' && !model.patient.phone) {
655
+ model.patient.phone = str(t.value);
656
+ }
657
+ if (isObject(t) && t.system === 'email' && !model.patient.email) {
658
+ model.patient.email = str(t.value);
659
+ }
660
+ }
661
+ const a = (Array.isArray(patient.address) ? patient.address : []).find(isObject);
662
+ if (a) {
663
+ model.patient.address = str(a.text) || [...(Array.isArray(a.line) ? a.line : []), a.city, a.state, a.postalCode, a.country].map(str).filter(Boolean).join(', ');
664
+ }
665
+ }
666
+ if (composition) {
667
+ model.document.title = str(composition.title);
668
+ model.document.date = str(composition.date).substring(0, 10);
669
+ const authorRef = (Array.isArray(composition.author) ? composition.author : []).find(isObject);
670
+ const author = resolve(authorRef);
671
+ if (author?.resourceType === 'Organization') {
672
+ model.document.authorType = 'Organization';
673
+ model.document.author = str(author.name);
674
+ } else if (author) {
675
+ model.document.authorType = 'Practitioner';
676
+ const n = (Array.isArray(author.name) ? author.name : []).find(isObject) || {};
677
+ model.document.author = str(n.text) || [...(Array.isArray(n.given) ? n.given : []), n.family].map(str).filter(Boolean).join(' ');
678
+ } else if (authorRef) {
679
+ model.document.author = str(authorRef.display);
680
+ }
681
+ const custodian = resolve(composition.custodian);
682
+ model.document.custodian = custodian ? str(custodian.name) : str(composition.custodian?.display);
683
+ }
684
+ const sections = Array.isArray(composition?.section) ? composition.section.filter(isObject) : [];
685
+ for (const def of SECTIONS) {
686
+ const section = sections.find(s => (Array.isArray(s.code?.coding) ? s.code.coding : []).some(c => isObject(c) && c.code === def.code));
687
+ if (!section) {
688
+ continue;
689
+ }
690
+ const out = { mode: 'omit' };
691
+ model.sections[def.key] = out;
692
+ const refs = Array.isArray(section.entry) ? section.entry : [];
693
+ if (refs.length > 0) {
694
+ const rows = [];
695
+ let unheld = false;
696
+ for (const ref of refs) {
697
+ const r = resolve(ref);
698
+ if (!r || r.resourceType !== def.resourceType) {
699
+ unheld = true;
700
+ continue;
701
+ }
702
+ rows.push(rowOf(def, r, resolve));
703
+ }
704
+ if (rows.length > 0) {
705
+ out.mode = 'data';
706
+ out.rows = rows;
707
+ out.partial = unheld;
708
+ continue;
709
+ }
710
+ }
711
+ const reason = (Array.isArray(section.emptyReason?.coding) ? section.emptyReason.coding : []).find(isObject);
712
+ if (reason && EMPTY_REASONS.some(e => e.code === reason.code)) {
713
+ out.mode = 'empty';
714
+ out.emptyReason = reason.code;
715
+ continue;
716
+ }
717
+ const div = str(section.text?.div);
718
+ if (div) {
719
+ out.mode = 'narrative';
720
+ out.narrative = textOf(div);
721
+ }
722
+ }
723
+ return model;
724
+ }
725
+
726
+ /** One resource as a row of its section. */
727
+ function rowOf(def, resource, resolve) {
728
+ const row = {};
729
+ const device = def.key === 'devices' ? resolve(resource.device) : null;
730
+ for (const field of def.fields) {
731
+ let v;
732
+ if (field.path === '$diastolic') {
733
+ const c = (Array.isArray(resource.component) ? resource.component : []).find(x => (Array.isArray(x?.code?.coding) ? x.code.coding : []).some(k => k?.code === '8462-4'));
734
+ v = c ? formValue(field, c.valueQuantity) : undefined;
735
+ } else if (field.path.startsWith('$device.')) {
736
+ v = device ? formValue(field, getPath(device, field.path.substring('$device.'.length), field)) : undefined;
737
+ } else if (field.path === '$narrative') {
738
+ v = str(resource.text?.div) ? textOf(resource.text.div) : undefined;
739
+ } else if (field.name === 'note') {
740
+ v = (Array.isArray(resource.note) ? resource.note : []).map(n => str(n?.text)).filter(Boolean).join('\n') || undefined;
741
+ } else if (def.key === 'vitals' && field.name === 'value' && Array.isArray(resource.component)) {
742
+ const c = resource.component.find(x => (Array.isArray(x?.code?.coding) ? x.code.coding : []).some(k => k?.code === '8480-6'));
743
+ v = c ? formValue(field, c.valueQuantity) : formValue(field, getPath(resource, field.path, field));
744
+ } else {
745
+ v = formValue(field, getPath(resource, field.path, field));
746
+ }
747
+ if (v !== undefined) {
748
+ row[field.name] = v;
749
+ }
750
+ }
751
+ for (const sub of def.subs || []) {
752
+ const items = Array.isArray(resource[sub.path]) ? resource[sub.path].filter(isObject) : [];
753
+ if (items.length) {
754
+ row[sub.name] = items.map(item => {
755
+ const part = {};
756
+ for (const field of sub.fields) {
757
+ const v = formValue(field, getPath(item, field.path, field));
758
+ if (v !== undefined) {
759
+ part[field.name] = v;
760
+ }
761
+ }
762
+ return part;
763
+ });
764
+ }
765
+ }
766
+ return row;
767
+ }
768
+
769
+ // elements whose end is a line break in the text, and those whose content isn't text
770
+ const BLOCKS = new Set(['p', 'div', 'li', 'tr', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'table', 'ul', 'ol', 'dl', 'dt', 'dd', 'pre', 'blockquote']);
771
+ const NOT_TEXT = new Set(['script', 'style', 'template', 'head', 'title']);
772
+
773
+ /**
774
+ * The text of a narrative, for the editor's narrative box: what a reader sees, with block
775
+ * ends and <br/> as line breaks. From a parse (parse5), not by stripping tags: the result
776
+ * is plain text - characters such as < are themselves - and is only ever used as text
777
+ * (a textarea's value; narrativeDiv() escapes it on the way back).
778
+ */
779
+ function textOf(div) {
780
+ const out = [];
781
+ const walk = (node) => {
782
+ for (const child of node.childNodes || []) {
783
+ if (child.nodeName === '#text') {
784
+ out.push(child.value);
785
+ } else if (child.nodeName === 'br') {
786
+ out.push('\n');
787
+ } else if (child.tagName && !NOT_TEXT.has(child.tagName)) {
788
+ walk(child.content || child);
789
+ if (child.tagName === 'p') {
790
+ out.push('\n\n');
791
+ } else if (BLOCKS.has(child.tagName)) {
792
+ out.push('\n');
793
+ } else if (child.tagName === 'td' || child.tagName === 'th') {
794
+ out.push(' ');
795
+ }
796
+ }
797
+ }
798
+ };
799
+ walk(parse5.parseFragment(String(div)));
800
+ // lines, with a blank line where a paragraph ended (as narrativeDiv() reads them back)
801
+ return out.join('').split('\n').map(l => l.replace(/\s+/g, ' ').trim()).join('\n')
802
+ .replace(/\n{3,}/g, '\n\n').replace(/^\n+|\n+$/g, '');
803
+ }
804
+
805
+ module.exports = { build, modelOf, fhirValue, formValue, setPath, getPath, narrativeDiv, textOf, MAX_ROWS, MAX_TEXT, MAX_NARRATIVE };