fhirsmith 0.18.1 → 1.0.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 (214) hide show
  1. package/ACCESSIBILITY.md +119 -0
  2. package/CHANGELOG.md +190 -9
  3. package/CONFIGURATION.md +305 -0
  4. package/LICENSE +29 -0
  5. package/README.md +29 -122
  6. package/cluster.js +1220 -0
  7. package/cluster.md +707 -0
  8. package/config-template.json +77 -8
  9. package/extension-tracker/extension-tracker-template.html +5 -8
  10. package/extension-tracker/extension-tracker.js +0 -2
  11. package/folder/folder.js +7 -6
  12. package/ips/analysis.js +560 -0
  13. package/ips/ips-template.html +5 -8
  14. package/ips/ips.js +331 -33
  15. package/ips/jurisdictions.js +65 -0
  16. package/ips/nationality.js +120 -0
  17. package/ips/pipeline.js +131 -17
  18. package/ips/presenter.js +1 -1
  19. package/ips/profile-editor.js +283 -4
  20. package/ips/readme.md +170 -20
  21. package/ips/render.js +769 -109
  22. package/ips/scripts.js +46 -0
  23. package/ips/store.js +123 -11
  24. package/ips/structure.js +247 -0
  25. package/ips/terminology.js +1 -1
  26. package/library/body-limit.js +22 -0
  27. package/library/cache-generation.js +15 -0
  28. package/library/cgroup-memory.js +133 -1
  29. package/library/cluster-counts.js +121 -0
  30. package/library/cluster-events.js +153 -0
  31. package/library/cluster-metrics.js +193 -0
  32. package/library/cluster-status.js +468 -0
  33. package/library/config-reader.js +96 -0
  34. package/library/daily-report.js +100 -2
  35. package/library/html-server.js +27 -1
  36. package/library/i18nsupport.js +23 -1
  37. package/library/keep-alive.js +18 -0
  38. package/library/languages.js +17 -3
  39. package/library/logger.js +31 -0
  40. package/library/memory-guard.js +428 -0
  41. package/library/native-memory.js +52 -0
  42. package/library/npm-audit.js +51 -25
  43. package/library/openapi-doc.js +7 -5
  44. package/library/package-manager.js +8 -0
  45. package/library/role.js +197 -0
  46. package/library/server-credentials.js +169 -0
  47. package/library/sqlite-utilities.js +59 -0
  48. package/library/ssrf-guard.js +100 -0
  49. package/library/static-response-stats.js +16 -0
  50. package/library/version-utilities.js +50 -0
  51. package/library/worker-count.js +192 -0
  52. package/nginx.md +30 -11
  53. package/npmprojector/indexer.js +18 -10
  54. package/package.json +11 -8
  55. package/packages/feed-check-page.js +117 -0
  56. package/packages/feed-check.js +279 -0
  57. package/packages/npm-check.js +57 -3
  58. package/packages/npm-signing.js +219 -0
  59. package/packages/openapi.yaml +207 -1
  60. package/packages/package-crawler.js +322 -41
  61. package/packages/packages-template.html +6 -8
  62. package/packages/packages.js +604 -57
  63. package/packages/readme.md +113 -1
  64. package/publisher/publisher-template.html +2 -5
  65. package/publisher/publisher.js +121 -110
  66. package/publisher/readme.md +75 -0
  67. package/registry/api.js +179 -1
  68. package/registry/crawler.js +86 -108
  69. package/registry/documentation/api.md +68 -2
  70. package/registry/documentation/configuration.md +34 -0
  71. package/registry/documentation/internals.md +4 -1
  72. package/registry/documentation/readme.md +3 -2
  73. package/registry/model.js +8 -0
  74. package/registry/openapi.yaml +84 -0
  75. package/registry/registry-template.html +5 -8
  76. package/registry/registry.js +58 -6
  77. package/root-bare-template.html +7 -4
  78. package/root-template.html +5 -8
  79. package/server.js +398 -198
  80. package/shl/shl.js +38 -37
  81. package/static/a11y/a11y-check.js +832 -0
  82. package/static/a11y/axe.min.js +12 -0
  83. package/static/assets/css/bootstrap-fhir.css +15 -15
  84. package/static/assets/images/flags/au.svg +18 -0
  85. package/static/assets/images/flags/unknown.svg +11 -0
  86. package/static/assets/images/flags/us.svg +63 -0
  87. package/static/assets/images/icon_audit.png +0 -0
  88. package/static/assets/images/icon_clinical.png +0 -0
  89. package/static/assets/images/icon_document.png +0 -0
  90. package/static/assets/images/icon_external.png +0 -0
  91. package/static/assets/images/icon_medication.png +0 -0
  92. package/static/assets/images/icon_organization.png +0 -0
  93. package/static/assets/images/icon_other.png +0 -0
  94. package/static/assets/images/icon_person.png +0 -0
  95. package/static/assets/images/icon_section.png +0 -0
  96. package/stats-db.js +45 -0
  97. package/stats.js +197 -10
  98. package/testing/testing-template.html +5 -8
  99. package/token/nginx_passport_setup.md +1 -1
  100. package/token/security_guide.md +1 -1
  101. package/token/token-template.html +2 -5
  102. package/token/token.js +40 -36
  103. package/translations/Messages.properties +19 -0
  104. package/tx/README.md +158 -4
  105. package/tx/closure/closure-store.js +126 -3
  106. package/tx/cm/cm-database.js +69 -57
  107. package/tx/cm/cm-package.js +50 -16
  108. package/tx/cs/cs-api.js +284 -19
  109. package/tx/cs/cs-api.md +41 -4
  110. package/tx/cs/cs-areacode.js +1 -1
  111. package/tx/cs/cs-country.js +17 -5
  112. package/tx/cs/cs-cpt.js +33 -16
  113. package/tx/cs/cs-cs.js +199 -24
  114. package/tx/cs/cs-currency.js +1 -1
  115. package/tx/cs/cs-icd11.js +21 -13
  116. package/tx/cs/cs-icf.js +22 -3
  117. package/tx/cs/cs-lang.js +2 -2
  118. package/tx/cs/cs-loinc.js +193 -67
  119. package/tx/cs/cs-mimetypes.js +12 -2
  120. package/tx/cs/cs-ndc.js +40 -25
  121. package/tx/cs/cs-omop.js +54 -29
  122. package/tx/cs/cs-rxnorm.js +300 -196
  123. package/tx/cs/cs-snomed.js +861 -95
  124. package/tx/cs/cs-ucum.js +14 -4
  125. package/tx/cs/cs-unii.js +18 -14
  126. package/tx/cs/cs-virtual.js +210 -0
  127. package/tx/ecosystem-resolver.js +413 -0
  128. package/tx/html/cluster-dash-metrics.liquid +26 -0
  129. package/tx/html/cluster-home-metrics.liquid +31 -0
  130. package/tx/html/cluster-metrics-script.liquid +251 -0
  131. package/tx/html/codesystem-operations.liquid +3 -3
  132. package/tx/html/ecl-panel.liquid +1 -1
  133. package/tx/html/external-metrics.liquid +17 -0
  134. package/tx/html/operations-form.liquid +17 -17
  135. package/tx/html/search-form.liquid +11 -11
  136. package/tx/html/status-line.liquid +58 -0
  137. package/tx/html/status-panel.liquid +140 -0
  138. package/tx/html/status-tasks.liquid +25 -0
  139. package/tx/html/tx-template.html +2 -5
  140. package/tx/importers/atc-to-fhir.js +17 -16
  141. package/tx/importers/export-icd11-codesystem.js +3 -2
  142. package/tx/importers/import-icd11.js +4 -3
  143. package/tx/importers/import-loinc.module.js +118 -56
  144. package/tx/importers/import-ndc.module.js +56 -44
  145. package/tx/importers/import-rxnorm.module.js +118 -79
  146. package/tx/importers/import-sct.module.js +125 -61
  147. package/tx/importers/import-unii.module.js +37 -36
  148. package/tx/importers/subset-loinc.module.js +49 -48
  149. package/tx/importers/subset-rxnorm.module.js +29 -28
  150. package/tx/importers/terminal.js +18 -0
  151. package/tx/importers/tx-import-base.js +13 -12
  152. package/tx/importers/tx-import-settings.js +5 -4
  153. package/tx/importers/tx-import.js +53 -52
  154. package/tx/library/canonical-resource.js +49 -0
  155. package/tx/library/codesystem.js +94 -5
  156. package/tx/library/designations.js +45 -2
  157. package/tx/library/extensions.js +13 -7
  158. package/tx/library/implicit-valuesets.js +66 -0
  159. package/tx/library/renderer.js +77 -21
  160. package/tx/library/sqlite-utilities.js +2 -0
  161. package/tx/library/tho-precedence.js +40 -0
  162. package/tx/library/ucum-parsers.js +11 -1
  163. package/tx/library/wait-for-cache.js +43 -0
  164. package/tx/library.js +321 -22
  165. package/tx/openapi-operations.js +6 -4
  166. package/tx/operation-context.js +460 -99
  167. package/tx/params.js +25 -0
  168. package/tx/problems.js +3 -5
  169. package/tx/provider.js +314 -67
  170. package/tx/resource-cache-sqlite.js +1050 -0
  171. package/tx/sct/documentation.md +45 -21
  172. package/tx/sct/ecl.js +81 -17
  173. package/tx/sct/editions.js +39 -0
  174. package/tx/sct/expressions.js +10 -1
  175. package/tx/sct/load-config.js +107 -0
  176. package/tx/sct/mmap.js +109 -0
  177. package/tx/sct/structures.js +175 -15
  178. package/tx/sct/text-index.js +5 -1
  179. package/tx/syndication/syndication-feed.js +2 -2
  180. package/tx/tx-html.js +44 -8
  181. package/tx/tx.fhir.org.yml +8 -8
  182. package/tx/tx.js +282 -80
  183. package/tx/usage-tracker.js +39 -11
  184. package/tx/vs/vs-api.js +19 -0
  185. package/tx/vs/vs-database.js +386 -62
  186. package/tx/vs/vs-package.js +44 -17
  187. package/tx/vs/vs-vsac.js +649 -101
  188. package/tx/workers/batch-validate.js +37 -10
  189. package/tx/workers/batch.js +6 -1
  190. package/tx/workers/cache-control.js +15 -4
  191. package/tx/workers/closure.js +156 -13
  192. package/tx/workers/compare.js +8 -5
  193. package/tx/workers/expand.js +1173 -289
  194. package/tx/workers/metadata.js +35 -0
  195. package/tx/workers/search.js +53 -1
  196. package/tx/workers/supplement-filters.js +145 -0
  197. package/tx/workers/translate.js +169 -4
  198. package/tx/workers/validate.js +223 -119
  199. package/tx/workers/worker.js +123 -31
  200. package/tx/xversion/xv-valueset.js +7 -4
  201. package/utilities/submit-test-report.js +3 -1
  202. package/utilities/tx-stress-test.js +482 -0
  203. package/vcl/readme.md +37 -0
  204. package/vcl/vcl-parser.js +15 -0
  205. package/xig/readme.md +52 -0
  206. package/xig/xig-template.html +5 -8
  207. package/xig/xig.js +238 -422
  208. package/changelog-template.md +0 -22
  209. package/registry/test-runner.js +0 -237
  210. package/release.sh +0 -80
  211. package/test-scripts/repro-re2-wasm-leak.js +0 -34
  212. package/tsconfig.eslint.json +0 -9
  213. package/tx/html/dash-metrics.liquid +0 -147
  214. package/tx/html/home-metrics.liquid +0 -225
package/ips/ips.js CHANGED
@@ -43,6 +43,7 @@ const express = require('express');
43
43
  const path = require('path');
44
44
  const fs = require('fs');
45
45
  const crypto = require('crypto');
46
+ const scripts = require('./scripts');
46
47
  const rateLimit = require('express-rate-limit');
47
48
  const escape = require('escape-html');
48
49
  const session = require('express-session');
@@ -60,6 +61,9 @@ const { parseSearch, listQueryToSearch, capabilitySearchParams, compositionOf, p
60
61
  const render = require('./render');
61
62
  const { TxClient } = require('./tx-client');
62
63
  const { STEPS, stripChecks } = require('./pipeline');
64
+
65
+ // the extension definitions remembered from the validator (the analysis step)
66
+ const MAX_CACHED_EXTENSIONS = 5000;
63
67
  const { IpsValueSets } = require('./ips-valuesets');
64
68
  const aiClient = require('./ai-client');
65
69
  const { ShlClient } = require('./share');
@@ -79,7 +83,35 @@ const TX_STARTUP_GRACE_MS = 5 * 60 * 1000;
79
83
  const FHIR_JSON = 'application/fhir+json; charset=utf-8';
80
84
  const CODE_RE = /^[a-z0-9][a-z0-9-]{1,31}$/;
81
85
  // paths at the root that can't be project codes
82
- const RESERVED = new Set(['login', 'logout', 'admin', 'metadata', 'openapi', 'static', 'assets', 'lookup']);
86
+ const { jurisdiction: jurisdictionOf } = require('./jurisdictions');
87
+ const { SNOMED_LINKABLE_EDITIONS } = require('../tx/sct/editions');
88
+ const RESERVED = new Set(['login', 'logout', 'admin', 'metadata', 'openapi', 'static', 'assets', 'lookup', 'national-profiles']);
89
+
90
+ /**
91
+ * What's wrong with a national profile's details, or null: a jurisdiction (a country's ISO 3166
92
+ * two-letter code, or a region's UN M49 three-digit code - see jurisdictions.js), a FHIR package id (lower case, at
93
+ * least two parts separated by dots), a version (as package versions are written), the
94
+ * canonical URL (http or https) of the IG's profile on Bundle, and - optionally - the SNOMED CT
95
+ * edition the IG takes for granted, by its module id (one of SNOMED_LINKABLE_EDITIONS).
96
+ */
97
+ function nationalProfileProblem(jurisdiction, packageId, version, url, snomedEdition) {
98
+ if (!jurisdictionOf(jurisdiction)) {
99
+ return 'The jurisdiction must be a country, by its ISO 3166 two-letter code (e.g. AU), or a region, by its UN M49 three-digit code (e.g. 150 for Europe)';
100
+ }
101
+ if (packageId.length > 128 || !/^[a-z0-9][a-z0-9_-]*(\.[a-z0-9][a-z0-9_-]*)+$/.test(packageId)) {
102
+ return 'The package id must be a FHIR package id: lower case letters, digits, - and _, in two or more parts separated by dots (e.g. hl7.fhir.au.ips)';
103
+ }
104
+ if (!/^[0-9A-Za-z][0-9A-Za-z.+-]{0,63}$/.test(version)) {
105
+ return 'The version must be a package version (e.g. 1.0.0, or 1.1.0-ballot)';
106
+ }
107
+ if (url.length > 1024 || !/^https?:\/\/[^\s/?#]+(?:[/?#]\S*)?$/.test(url)) {
108
+ return 'The profile must be the canonical URL (http or https) of the IG\'s profile on Bundle';
109
+ }
110
+ if (snomedEdition && !SNOMED_LINKABLE_EDITIONS.has(snomedEdition)) {
111
+ return 'The SNOMED CT edition must be one of the editions offered (or none)';
112
+ }
113
+ return null;
114
+ }
83
115
 
84
116
  /**
85
117
  * What's wrong with a submitted document, if anything.
@@ -165,25 +197,6 @@ function preferValue(req, name) {
165
197
  return null;
166
198
  }
167
199
 
168
- // the browser scripts: the module's own files, read once and served from memory (so no
169
- // request reaches the file system), with an ETag so a browser can keep its copy
170
- const SCRIPTS = new Map();
171
- function script(name) {
172
- if (!SCRIPTS.has(name)) {
173
- const body = fs.readFileSync(path.join(__dirname, name), 'utf8');
174
- SCRIPTS.set(name, { body, etag: `"${crypto.createHash('sha256').update(body).digest('base64url').substring(0, 27)}"` });
175
- }
176
- const s = SCRIPTS.get(name);
177
- return (req, res) => {
178
- res.setHeader('Cache-Control', 'public, max-age=3600');
179
- res.setHeader('ETag', s.etag);
180
- if (req.get('If-None-Match') === s.etag) {
181
- return res.status(304).end();
182
- }
183
- res.type('application/javascript').send(s.body);
184
- };
185
- }
186
-
187
200
  function wantsHtml(req) {
188
201
  const f = req.query._format;
189
202
  if (typeof f === 'string' && f !== '') {
@@ -308,6 +321,7 @@ class IpsModule {
308
321
  this.setupSession();
309
322
  this.setupRootRoutes();
310
323
  this.setupProfileRoutes();
324
+ this.setupNationalProfileRoutes();
311
325
  this.setupProjectRoutes();
312
326
  this.setupValidator();
313
327
  this.setupValueSets();
@@ -451,7 +465,7 @@ class IpsModule {
451
465
  next();
452
466
  };
453
467
 
454
- r.get('/profile-editor.js', script('profile-editor.js'));
468
+ r.get('/profile-editor.js', scripts.handler('profile-editor.js'));
455
469
  r.get('/lookup', this.session, adminJson, ...this.lookups, h('lookup', (req, res) => this.systemLookup(req, res)));
456
470
  r.get('/lookup-vs', this.session, adminJson, ...this.lookups, h('lookup', (req, res) => this.valueSetLookup(req, res)));
457
471
  r.get('/StructureDefinition', ...this.web, h('profiles', (req, res) => this.profileList(req, res)));
@@ -460,12 +474,37 @@ class IpsModule {
460
474
  r.get('/StructureDefinition/:id', profile, ...this.web, h('profile', (req, res) => this.profileRead(req, res)));
461
475
  r.get('/StructureDefinition/:id/edit', profile, this.session, this.csrf, admin, h('profile', (req, res) => this.profileEditor(req, res, req.profile)));
462
476
  r.post('/StructureDefinition/:id', profile, this.sameOrigin, this.session, json, this.csrf, adminJson, h('profile', (req, res) => this.profileSave(req, res, req.profile)));
477
+ // one constraint at a time, from the profile's page: add, edit (keeping its key), delete
478
+ r.post('/StructureDefinition/:id/constraint', profile, this.sameOrigin, this.session, json, this.csrf, adminJson, h('profile', (req, res) => this.constraintSave(req, res, null)));
479
+ r.post('/StructureDefinition/:id/constraint/:key', profile, this.sameOrigin, this.session, json, this.csrf, adminJson, h('profile', (req, res) => this.constraintSave(req, res, req.params.key)));
480
+ r.post('/StructureDefinition/:id/constraint/:key/delete', profile, this.sameOrigin, this.session, this.form, this.csrf, admin, h('profile', (req, res) => this.constraintDelete(req, res)));
463
481
  r.post('/StructureDefinition/:id/delete', profile, this.sameOrigin, this.session, this.form, this.csrf, admin, h('profile', (req, res) => {
464
482
  this.store.deleteProfile(req.profileId);
465
483
  res.redirect(req.baseUrl + '/StructureDefinition');
466
484
  }));
467
485
  }
468
486
 
487
+ /**
488
+ * The national profiles, across all projects: everyone can see them, only the administrator
489
+ * adds, edits and deletes them. Before the project routes, so /national-profiles isn't taken
490
+ * for a project code.
491
+ */
492
+ setupNationalProfileRoutes() {
493
+ const r = this.router;
494
+ const h = (name, fn) => (req, res) => this.handle(req, res, name, () => fn(req, res));
495
+ const admin = this.adminOnly;
496
+ r.get('/national-profiles', ...this.web, h('national-profiles', (req, res) => this.sendNationalProfiles(req, res)));
497
+ r.post('/national-profiles', this.sameOrigin, this.session, this.form, this.csrf, admin, h('national-profiles', (req, res) => this.saveNationalProfile(req, res, null)));
498
+ r.post('/national-profiles/:id', this.sameOrigin, this.session, this.form, this.csrf, admin, h('national-profiles', (req, res) => this.saveNationalProfile(req, res, req.params.id)));
499
+ r.post('/national-profiles/:id/delete', this.sameOrigin, this.session, this.form, this.csrf, admin, h('national-profiles', (req, res) => {
500
+ const np = /^\d{1,15}$/.test(req.params.id) ? this.store.nationalProfile(parseInt(req.params.id, 10)) : null;
501
+ if (np && this.store.deleteNationalProfile(np.id)) {
502
+ this.log.info(`IPS module: national profile ${np.packageId}#${np.version} deleted`);
503
+ }
504
+ res.redirect(req.baseUrl + '/national-profiles');
505
+ }));
506
+ }
507
+
469
508
  setupProjectRoutes() {
470
509
  const r = this.router;
471
510
  const h = (name, fn) => (req, res) => this.handle(req, res, name, () => fn(req, res));
@@ -509,7 +548,7 @@ class IpsModule {
509
548
  next();
510
549
  };
511
550
 
512
- r.get('/editor.js', script('editor.js'));
551
+ r.get('/editor.js', scripts.handler('editor.js'));
513
552
  r.get('/:code', project, ...this.web, h('list', (req, res) => this.htmlList(req, res)));
514
553
  r.get('/:code/metadata', project, h('metadata', (req, res) => this.metadata(req, res)));
515
554
  // The submission form has no CSRF token and starts no session: submitting is open to
@@ -595,25 +634,39 @@ class IpsModule {
595
634
  * wait in the queue.
596
635
  */
597
636
  setupValidator() {
637
+ // the national profiles' packages, as they are loaded into the validator: id#version -> Promise
638
+ this.nationalPackages = new Map();
598
639
  const cfg = this.config.validator;
599
640
  if (!cfg || cfg.enabled === false) {
600
641
  this.log.warn('IPS module: no validator configured (modules.ips.validator) - documents will not be validated');
601
642
  return;
602
643
  }
603
644
  const FhirValidator = require('fhir-validator-wrapper');
604
- const jar = folders.filePath('bin', 'validator_cli.jar');
645
+ // a jar of its own (a validator built locally, say): used as it is, never downloaded or
646
+ // updated; otherwise the released one in the data folder's bin directory, kept up to date
647
+ const localJar = typeof cfg.jar === 'string' && cfg.jar.trim() ? (path.isAbsolute(cfg.jar) ? cfg.jar : folders.filePath(cfg.jar)) : null;
648
+ const jar = localJar || folders.filePath('bin', 'validator_cli.jar');
605
649
  this.validator = new FhirValidator(jar, this.log);
606
650
  const settings = {
651
+ autoDownload: !localJar,
652
+ skipUpdateCheck: !!localJar,
607
653
  version: cfg.version || '4.0.1',
608
654
  txServer: cfg.txServer || 'http://tx.fhir.org/r4',
609
655
  txLog: cfg.txLog || folders.filePath('logs', 'ips-validator-tx.log'),
610
656
  port: cfg.port || 8082,
611
657
  igs: Array.isArray(cfg.packages) ? cfg.packages : ['hl7.fhir.uv.ips#1.1.0'],
612
- timeout: cfg.timeout || 120000
658
+ timeout: cfg.timeout || 120000,
659
+ // lookupExtension(): an extension the validator doesn't have is looked for the way the
660
+ // validator does while validating - the package that defines it is found from the url,
661
+ // downloaded and loaded (validator 7.0.5 and later; fhir-validator-wrapper 1.7.0 and later)
662
+ autoLoadPackages: cfg.autoLoadPackages !== false
613
663
  };
614
- this.log.info(`IPS module: starting the FHIR validator on port ${settings.port} (tx ${settings.txServer}; ${settings.igs.join(', ')})`);
664
+ this.log.info(`IPS module: starting the FHIR validator${localJar ? ` (${localJar})` : ''} on port ${settings.port} (tx ${settings.txServer}; ${settings.igs.join(', ')})`);
615
665
  this.validatorStarting = this.validator.start(settings).then(() => {
616
666
  this.log.info('IPS module: the FHIR validator is running');
667
+ // documents wait for this too, so the analysis step - which looks up extensions before
668
+ // the national step would load the packages - finds what the national IGs define
669
+ return this.loadNationalPackages();
617
670
  }).catch(e => {
618
671
  this.log.error('IPS module: the FHIR validator failed to start: ' + e.message);
619
672
  this.validatorError = e.message;
@@ -622,6 +675,60 @@ class IpsModule {
622
675
  });
623
676
  }
624
677
 
678
+ /**
679
+ * Load every national profile's package into the validator (as the national step would), so
680
+ * that what they define is there from the start. A package that won't load is logged (and
681
+ * remembered) by loadNationalPackage(), and doesn't stop the others.
682
+ * @returns {Promise<void>}
683
+ */
684
+ async loadNationalPackages() {
685
+ if (!this.validator || !this.store) {
686
+ return;
687
+ }
688
+ const list = this.store.nationalProfiles();
689
+ if (list.length === 0) {
690
+ return;
691
+ }
692
+ const started = Date.now();
693
+ this.log.info(`IPS module: loading the packages of ${list.length} national profiles into the validator`);
694
+ // one at a time: the validator loads one package per request anyway
695
+ for (const np of list) {
696
+ try {
697
+ await this.loadNationalPackage(np.packageId, np.version);
698
+ } catch (e) {
699
+ // already logged
700
+ }
701
+ }
702
+ this.log.info(`IPS module: national profile packages loaded in ${Math.round((Date.now() - started) / 1000)}s`);
703
+ }
704
+
705
+ /**
706
+ * Load a national profile's package into the validator - once: a package that loaded, or
707
+ * failed to (with why), is remembered until the national profiles are changed, so each
708
+ * document doesn't ask again. The validator's packages (modules.ips.validator.packages) are
709
+ * loaded when it starts; these are added to them.
710
+ * @returns {Promise<void>} rejects with the reason the package could not be loaded
711
+ */
712
+ loadNationalPackage(packageId, version) {
713
+ const key = `${packageId}#${version}`;
714
+ let p = this.nationalPackages.get(key);
715
+ if (!p) {
716
+ p = this.validator.loadIG(packageId, version).then(outcome => {
717
+ const bad = (Array.isArray(outcome?.issue) ? outcome.issue : []).filter(i => i && (i.severity === 'error' || i.severity === 'fatal'));
718
+ if (bad.length > 0 || outcome?.resourceType !== 'OperationOutcome') {
719
+ const why = bad.map(i => i.details?.text || i.diagnostics || i.code).filter(Boolean).join('; ') || 'the validator did not return an OperationOutcome';
720
+ throw new Error(`The validator could not load ${key}: ${why}`);
721
+ }
722
+ this.log.info(`IPS module: national profile package ${key} loaded into the validator`);
723
+ // the validator knows more now: extensions it couldn't find before may be there
724
+ this.forgetMissingExtensions();
725
+ });
726
+ p.catch(e => this.log.warn(`IPS module: ${e.message}`));
727
+ this.nationalPackages.set(key, p);
728
+ }
729
+ return p;
730
+ }
731
+
625
732
  /** Read the value sets the terminology check holds documents to: the module's own, in ips/valuesets/. */
626
733
  setupValueSets() {
627
734
  try {
@@ -654,6 +761,58 @@ class IpsModule {
654
761
  return null;
655
762
  }
656
763
 
764
+ /**
765
+ * Forget the extensions the validator couldn't find: it has loaded a package since, and may
766
+ * know them now. The ones it found are kept.
767
+ */
768
+ forgetMissingExtensions() {
769
+ if (this.extensionCache) {
770
+ for (const [url, answer] of this.extensionCache) {
771
+ if (answer === null) {
772
+ this.extensionCache.delete(url);
773
+ }
774
+ }
775
+ }
776
+ }
777
+
778
+ /**
779
+ * The definition of an extension, for the analysis step: {url, title, name, description},
780
+ * or null when it can't be found. It is asked of the validator (the wrapper's
781
+ * lookupCanonical(), the service's /canonical, which looks in the validator's context - and,
782
+ * with modules.ips.validator.autoLoadPackages (on by default), finds and loads the package that
783
+ * defines one it doesn't have).
784
+ * Answers are remembered (a bounded cache); a validator that isn't there, or that fails, or
785
+ * that predates /canonical, isn't an answer, and is asked again next time.
786
+ * @param {string} url
787
+ * @returns {Promise<Object|null>}
788
+ */
789
+ async lookupExtension(url) {
790
+ const v = this.validator;
791
+ if (!v || typeof v.lookupCanonical !== 'function' || typeof v.isRunning !== 'function' || !v.isRunning()) {
792
+ return null;
793
+ }
794
+ const cache = this.extensionCache || (this.extensionCache = new Map());
795
+ if (cache.has(url)) {
796
+ return cache.get(url);
797
+ }
798
+ let answer = null;
799
+ try {
800
+ const r = await v.lookupCanonical(url);
801
+ if (r) {
802
+ const str = (x) => (typeof x === 'string' ? x : '');
803
+ answer = { url: str(r.url), title: str(r.title), name: str(r.name), description: str(r.description).substring(0, 1000) };
804
+ }
805
+ } catch (e) {
806
+ this.log.warn(`IPS module: looking up extension ${url} failed: ${e.message}`);
807
+ return null;
808
+ }
809
+ if (cache.size >= MAX_CACHED_EXTENSIONS) {
810
+ cache.delete(cache.keys().next().value);
811
+ }
812
+ cache.set(url, answer);
813
+ return answer;
814
+ }
815
+
657
816
  /**
658
817
  * Whether the pipeline can run now: not while the validator is still starting, nor -
659
818
  * for a while after start-up - while a terminology server on this server isn't there
@@ -829,20 +988,29 @@ class IpsModule {
829
988
  }
830
989
 
831
990
  /**
832
- * The paragraph for the server's daily report: pages served, documents
833
- * received in the period, and how many are stored.
991
+ * The paragraph for the server's daily report: pages served (and how many were about documents - a
992
+ * document's page, in any of its views), new documents and how many are stored, and the
993
+ * processing pipeline - how many times it was started, and how many of those didn't finish
994
+ * (it failed, or the server stopped while it ran).
834
995
  */
835
996
  dailyReport({since, now}) {
836
997
  const {plural} = require('../library/daily-report');
998
+ const was = (n) => n === 1 ? 'was' : 'were';
837
999
  const sentences = [];
838
1000
  if (this.stats) {
839
1001
  const counts = this.stats.countsSince(since, now);
840
- sentences.push(`${plural(counts.total, 'page')} ${counts.total === 1 ? 'was' : 'were'} served.`);
1002
+ const docs = counts.operations.read || 0;
1003
+ sentences.push(`${plural(counts.total, 'page')} ${was(counts.total)} served${counts.total ? `, ${docs} of them about documents` : ''}.`);
841
1004
  }
842
1005
  if (this.store) {
843
1006
  const received = this.store.countReceived(since, now);
844
1007
  const stored = this.store.count();
845
- sentences.push(`${plural(received, 'IPS document')} ${received === 1 ? 'was' : 'were'} received; ${plural(stored, 'document')} ${stored === 1 ? 'is' : 'are'} stored.`);
1008
+ sentences.push(`${plural(received, 'new document')} ${was(received)} received; ${plural(stored, 'document')} ${stored === 1 ? 'is' : 'are'} stored.`);
1009
+ const runs = this.store.countRuns(since, now);
1010
+ const unfinished = runs.failed + runs.interrupted;
1011
+ const why = [runs.failed ? `${runs.failed} failed` : '', runs.interrupted ? `${runs.interrupted} interrupted by a shutdown` : ''].filter(Boolean).join(', ');
1012
+ sentences.push(`The processing pipeline was started ${plural(runs.started, 'time')}; ${unfinished} didn't finish${why ? ` (${why})` : ''}` +
1013
+ `${runs.running ? `, and ${runs.running} ${runs.running === 1 ? 'is' : 'are'} still running` : ''}.`);
846
1014
  } else {
847
1015
  sentences.push('The document store is not open.');
848
1016
  }
@@ -1295,6 +1463,59 @@ class IpsModule {
1295
1463
  render.renderSubmit(req.project, this.ctx(req, res), { needsToken: tokenConfigured(req.project.token), aiEnabled: this.store.projectAi(req.project).enabled === true, ...options }), Date.now(), req);
1296
1464
  }
1297
1465
 
1466
+ // ---- national profiles ----
1467
+
1468
+ /** The national profiles: a page (with the administrator's forms), or JSON. */
1469
+ sendNationalProfiles(req, res, message, status = 200) {
1470
+ const list = this.store.nationalProfiles();
1471
+ res.status(status);
1472
+ if (!wantsHtml(req) && !message) {
1473
+ return res.json(list.map(np => ({ jurisdiction: np.jurisdiction, packageId: np.packageId, version: np.version, url: np.url,
1474
+ ...(np.snomedEdition ? { snomedEdition: np.snomedEdition } : {}) })));
1475
+ }
1476
+ this.sendHtml(res, 'National Profiles', render.renderNationalProfiles(list, this.ctx(req, res), message), Date.now(), req);
1477
+ }
1478
+
1479
+ /** Add (id null) or change a national profile, from the form: jurisdiction, packageId, version, url, snomedEdition (optional). */
1480
+ saveNationalProfile(req, res, id) {
1481
+ const field = (name) => (typeof req.body[name] === 'string' ? req.body[name].trim() : '');
1482
+ const packageId = field('packageId');
1483
+ const version = field('version');
1484
+ const url = field('url');
1485
+ // a country code is upper case; a region's is digits
1486
+ const jurisdiction = field('jurisdiction').toUpperCase();
1487
+ // the edition's module id; empty for none
1488
+ const snomedEdition = field('snomedEdition') || null;
1489
+ const problem = nationalProfileProblem(jurisdiction, packageId, version, url, snomedEdition);
1490
+ if (problem) {
1491
+ return this.sendNationalProfiles(req, res, problem, 400);
1492
+ }
1493
+ const existing = this.store.nationalProfileByPackage(packageId, version);
1494
+ if (id === null) {
1495
+ if (existing) {
1496
+ return this.sendNationalProfiles(req, res, `There is already a national profile for ${packageId}#${version}`, 409);
1497
+ }
1498
+ this.store.createNationalProfile(packageId, version, url, jurisdiction, snomedEdition);
1499
+ this.log.info(`IPS module: national profile ${packageId}#${version} (${jurisdiction}${snomedEdition ? `, SNOMED CT ${snomedEdition}` : ''}) added (${url})`);
1500
+ } else {
1501
+ const n = /^\d{1,15}$/.test(id) ? parseInt(id, 10) : -1;
1502
+ if (existing && existing.id !== n) {
1503
+ return this.sendNationalProfiles(req, res, `There is already a national profile for ${packageId}#${version}`, 409);
1504
+ }
1505
+ if (!this.store.updateNationalProfile(n, packageId, version, url, jurisdiction, snomedEdition)) {
1506
+ return this.sendNationalProfiles(req, res, 'That national profile no longer exists', 404);
1507
+ }
1508
+ this.log.info(`IPS module: national profile ${packageId}#${version} (${jurisdiction}${snomedEdition ? `, SNOMED CT ${snomedEdition}` : ''}) saved (${url})`);
1509
+ }
1510
+ // a package that failed to load is tried again; the packages are loaded now (in the
1511
+ // background), so the analysis step finds what they define
1512
+ this.nationalPackages = new Map();
1513
+ if (this.validator && this.validator.isRunning()) {
1514
+ this.loadNationalPackages().catch(e => this.log.warn(`IPS module: loading the national profile packages failed: ${e.message}`));
1515
+ }
1516
+ res.redirect(req.baseUrl + '/national-profiles');
1517
+ }
1518
+
1298
1519
  // ---- clinical profiles ----
1299
1520
 
1300
1521
  /** The clinical profiles: a page, or a FHIR searchset of the StructureDefinitions. */
@@ -1321,7 +1542,31 @@ class IpsModule {
1321
1542
  res.type('application/fhir+json');
1322
1543
  return res.send(JSON.stringify(sd));
1323
1544
  }
1324
- this.sendHtml(res, `Clinical Profile: ${sd.title}`, render.renderProfile(sd, { ...this.ctx(req, res), valueSetTitle: (url) => this.valueSetTitle(url) }), Date.now(), req);
1545
+ const ctx = { ...this.ctx(req, res), valueSetTitle: (url) => this.valueSetTitle(url), profileTitle: (url) => this.profileTitle(url) };
1546
+ // the administrator edits the rows in place, a row at a time
1547
+ const editor = ctx.user?.isAdmin ? {
1548
+ constraints: this.editorConstraints(sd),
1549
+ sections: profiles.IPS_SECTIONS, systems: profiles.SYSTEMS, resourceTypes: profiles.RESOURCE_TYPES, sectionSystem: editorSchema.LOINC, canLookup: !!this.tx
1550
+ } : null;
1551
+ this.sendHtml(res, `Clinical Profile: ${sd.title}`, render.renderProfile(sd, ctx, editor), Date.now(), req);
1552
+ }
1553
+
1554
+ /**
1555
+ * What to call a profile a rule says resources conform to: one of the clinical profiles (by
1556
+ * its title), or one of the national profiles (by its jurisdiction and package), or null.
1557
+ */
1558
+ profileTitle(url) {
1559
+ if (typeof url !== 'string') {
1560
+ return null;
1561
+ }
1562
+ const plain = url.split('|')[0];
1563
+ const m = /\/StructureDefinition\/profile-([a-f0-9]{12})$/.exec(plain);
1564
+ const sd = m ? this.store.profile(m[1]) : null;
1565
+ if (sd && sd.url === plain) {
1566
+ return sd.title;
1567
+ }
1568
+ const np = this.store.nationalProfiles().find(p => p.url === plain);
1569
+ return np ? `${np.jurisdiction ? `${np.jurisdiction} ` : ''}national profile (${np.packageId}#${np.version})` : null;
1325
1570
  }
1326
1571
 
1327
1572
  /** The title of one of this module's own value sets, or null. */
@@ -1331,8 +1576,12 @@ class IpsModule {
1331
1576
  }
1332
1577
 
1333
1578
  /** The editor, for a new profile or an existing one. */
1579
+ editorConstraints(sd) {
1580
+ return profiles.constraintsOf(sd).map(c => ({ ...c, shape: profiles.parseShape(c.expression), rshape: profiles.parseResourceShape(c.expression) }));
1581
+ }
1582
+
1334
1583
  profileEditor(req, res, sd) {
1335
- const constraints = profiles.constraintsOf(sd).map(c => ({ ...c, shape: profiles.parseShape(c.expression), rshape: profiles.parseResourceShape(c.expression) }));
1584
+ const constraints = this.editorConstraints(sd);
1336
1585
  this.sendHtml(res, sd ? `Edit Clinical Profile: ${sd.title}` : 'New Clinical Profile',
1337
1586
  render.renderProfileEditor(sd, this.ctx(req, res), {
1338
1587
  profile: { id: sd ? sd.id : null, title: sd ? sd.title : '', constraints },
@@ -1369,6 +1618,53 @@ class IpsModule {
1369
1618
  res.status(previous ? 200 : 201).json({ id: sd.id, url: `${req.baseUrl}/StructureDefinition/${sd.id}` });
1370
1619
  }
1371
1620
 
1621
+ /**
1622
+ * Add a constraint to a profile ({human, expression}), or replace one (by its key, which it
1623
+ * keeps). 201/200 with {key}, 404 when there is no constraint with that key, 422 with {problems}.
1624
+ */
1625
+ constraintSave(req, res, key) {
1626
+ const previous = req.profile;
1627
+ const body = req.body && typeof req.body === 'object' && !Array.isArray(req.body) ? req.body : {};
1628
+ const row = { key, human: body.human, expression: body.expression };
1629
+ const constraints = profiles.constraintsOf(previous);
1630
+ const at = key === null ? constraints.length : constraints.findIndex(c => c.key === key);
1631
+ if (at < 0) {
1632
+ return res.status(404).json({ problems: [`The profile has no constraint '${key}'`] });
1633
+ }
1634
+ // the row by itself first, so what is wrong is said of it, not of a number in the list
1635
+ const alone = profiles.checkInput({ title: previous.title, constraints: [{ human: row.human, expression: row.expression }] }, null);
1636
+ if (alone.problems.length) {
1637
+ return res.status(422).json({ problems: alone.problems.map(p => p.replace(/^Constraint 1/, 'The rule')) });
1638
+ }
1639
+ constraints.splice(at, key === null ? 0 : 1, row);
1640
+ const checked = profiles.checkInput({ title: previous.title, constraints }, previous);
1641
+ if (checked.problems.length) {
1642
+ return res.status(422).json({ problems: checked.problems });
1643
+ }
1644
+ this.saveConstraints(previous, checked.constraints);
1645
+ this.log.info(`IPS module: clinical profile ${previous.id}: constraint ${checked.constraints[at].key} ${key === null ? 'added' : 'saved'}`);
1646
+ res.status(key === null ? 201 : 200).json({ key: checked.constraints[at].key });
1647
+ }
1648
+
1649
+ /** Delete one of a profile's constraints, back to the profile's page. */
1650
+ constraintDelete(req, res) {
1651
+ const previous = req.profile;
1652
+ const constraints = profiles.constraintsOf(previous);
1653
+ const at = constraints.findIndex(c => c.key === req.params.key);
1654
+ if (at < 0) {
1655
+ return htmlServer.sendErrorResponse(res, TEMPLATE, new Error(`The profile has no constraint '${req.params.key}'`), 404);
1656
+ }
1657
+ constraints.splice(at, 1);
1658
+ this.saveConstraints(previous, constraints);
1659
+ this.log.info(`IPS module: clinical profile ${previous.id}: constraint ${req.params.key} deleted`);
1660
+ res.redirect(`${req.baseUrl}/StructureDefinition/${previous.id}`);
1661
+ }
1662
+
1663
+ saveConstraints(previous, constraints) {
1664
+ const id = previous.id.substring('profile-'.length);
1665
+ this.store.saveProfile(id, profiles.build({ id, url: previous.url, title: previous.title, constraints }));
1666
+ }
1667
+
1372
1668
  // ---- the editor ----
1373
1669
 
1374
1670
  sendCompose(req, res) {
@@ -1588,7 +1884,7 @@ class IpsModule {
1588
1884
  if (!row) {
1589
1885
  return htmlServer.sendErrorResponse(res, TEMPLATE, new Error(`No document with id '${req.params.id}'`), 404);
1590
1886
  }
1591
- const view = render.VIEWS.some(([v]) => v === req.query.view) ? req.query.view : render.VIEWS[0][0];
1887
+ const { view, narrative } = render.viewOf(req.query);
1592
1888
  row.results = {};
1593
1889
  for (const step of this.steps) {
1594
1890
  row.results[step.name] = this.store.readResult(row.id, step.name);
@@ -1597,7 +1893,9 @@ class IpsModule {
1597
1893
  ...this.ctx(req, res),
1598
1894
  steps: this.steps.map(st => st.name),
1599
1895
  level: typeof req.query.level === 'string' ? req.query.level : undefined,
1600
- problemsOnly: req.query.problems === '1'
1896
+ np: typeof req.query.np === 'string' ? req.query.np : undefined,
1897
+ problemsOnly: req.query.problems === '1',
1898
+ narrative
1601
1899
  };
1602
1900
  return this.sendHtml(res, `${req.project.name}: Document ${row.id}`, render.renderDocument(req.project, row, view, ctx), start, req);
1603
1901
  }
@@ -0,0 +1,65 @@
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
+ * Jurisdictions, as http://hl7.org/fhir/ValueSet/jurisdiction has them: a country, by its
9
+ * ISO 3166-1 two-letter code (urn:iso:std:iso:3166), or a region, by its UN M49 three-digit
10
+ * code (http://unstats.un.org/unsd/methods/m49/m49.htm). The codes and names are the ones
11
+ * the terminology module's providers hold (tx/cs/cs-country.js, tx/cs/cs-areacode.js).
12
+ *
13
+ * @module ips/jurisdictions
14
+ */
15
+
16
+ const { CountryCodeFactoryProvider } = require('../tx/cs/cs-country');
17
+ const { AreaCodeFactoryProvider } = require('../tx/cs/cs-areacode');
18
+
19
+ const ISO_3166 = 'urn:iso:std:iso:3166';
20
+ const M49 = 'http://unstats.un.org/unsd/methods/m49/m49.htm';
21
+ // in the country list, but not countries
22
+ const UNKNOWN = new Set(['XX', 'ZZ']);
23
+
24
+ let tables = null;
25
+
26
+ /**
27
+ * The providers' code lists, read once. Only their data is wanted, not code system
28
+ * providers, so their load() runs on an object with no i18n (it only fills codes/codeMap).
29
+ */
30
+ function load() {
31
+ if (!tables) {
32
+ const countries = Object.create(CountryCodeFactoryProvider.prototype);
33
+ const areas = Object.create(AreaCodeFactoryProvider.prototype);
34
+ // load() is async in form, but does its work synchronously
35
+ countries.load();
36
+ areas.load();
37
+ tables = { countries: countries.codeMap, areas: areas.codeMap };
38
+ }
39
+ return tables;
40
+ }
41
+
42
+ /**
43
+ * A jurisdiction by its code: two upper case letters (a country), or three digits (a
44
+ * region - an M49 region, not a country's numeric code).
45
+ * @param {string} code
46
+ * @returns {{system: string, code: string, display: string}|null} null if it is neither
47
+ */
48
+ function jurisdiction(code) {
49
+ if (typeof code !== 'string') {
50
+ return null;
51
+ }
52
+ const t = load();
53
+ if (/^[A-Z]{2}$/.test(code)) {
54
+ const c = t.countries.get(code);
55
+ // not the user-assigned codes (XK, QM-QZ...), nor the 'unknown' ones (XX, ZZ): they aren't countries ISO 3166 names
56
+ return c && !c.userDefined && !UNKNOWN.has(code) ? { system: ISO_3166, code, display: c.display } : null;
57
+ }
58
+ if (/^\d{3}$/.test(code)) {
59
+ const a = t.areas.get(code);
60
+ return a && a.class_ === 'region' ? { system: M49, code, display: a.display } : null;
61
+ }
62
+ return null;
63
+ }
64
+
65
+ module.exports = { jurisdiction, ISO_3166, M49 };