fhirsmith 0.18.1 → 0.18.2
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.
- package/CHANGELOG.md +41 -9
- package/ips/ips.js +251 -26
- package/ips/jurisdictions.js +65 -0
- package/ips/pipeline.js +67 -6
- package/ips/profile-editor.js +283 -4
- package/ips/readme.md +48 -5
- package/ips/render.js +317 -46
- package/ips/scripts.js +46 -0
- package/ips/store.js +55 -0
- package/package.json +3 -3
- package/registry/api.js +179 -1
- package/registry/crawler.js +2 -0
- package/registry/documentation/api.md +68 -2
- package/registry/documentation/internals.md +4 -1
- package/registry/documentation/readme.md +3 -2
- package/registry/model.js +8 -0
- package/registry/openapi.yaml +84 -0
- package/registry/registry.js +53 -2
- package/tx/README.md +4 -0
- package/tx/cm/cm-package.js +21 -15
- package/tx/cs/cs-cs.js +51 -10
- package/tx/cs/cs-loinc.js +8 -1
- package/tx/cs/cs-snomed.js +253 -31
- package/tx/importers/import-sct.module.js +69 -4
- package/tx/library/tho-precedence.js +40 -0
- package/tx/provider.js +36 -25
- package/tx/sct/documentation.md +45 -21
- package/tx/sct/ecl.js +34 -11
- package/tx/sct/structures.js +9 -2
- package/tx/tx.fhir.org.yml +1 -1
- package/tx/vs/vs-package.js +17 -13
- package/tx/vs/vs-vsac.js +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,47 @@ All notable changes to Health Intersections FHIRsmith will be documented in this
|
|
|
5
5
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
|
6
6
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
7
|
|
|
8
|
+
## [0.18.2] - 2026-10-29
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
|
|
12
|
+
- IPS: national profiles - the profile on Bundle that a national IG defines for an IPS document: its jurisdiction (an ISO 3166 country code, or a UN M49 region), the IG's package id and version, and the profile's url. They are shared across all projects at `/ips/national-profiles`, where everyone can see them and only the administrator adds, edits and deletes them (also as JSON). A new processing step, after validation, validates each document against each national profile - loading the IG's package into the validator, once - and keeps the outcome and whether the document passed (no errors). The document's new National Profiles view lists them, and shows each one's validation as the Validation view does
|
|
13
|
+
- IPS: `modules.ips.validator.jar` - a validator jar of the module's own (a local build of org.hl7.fhir.core, say), used as it is, never downloaded or updated. Don't put such a jar at `bin/validator_cli.jar`: the wrapper takes that jar's version from the `.version` file it wrote when it downloaded the release, so a different jar there is started as if it were that release, and is replaced by the next one
|
|
14
|
+
- Registry: `GET /tx-reg/choose-server` - given the code systems a request involves and the client's default server, which one server to send it to, and a log of why. The same choice the Java tooling makes, for clients that aren't Java
|
|
15
|
+
- SNOMED CT ECL: member filters (`^ refset {{ M ... }}`) - `effectiveTime` (with a time value or set), `active`, `moduleId` (any sub expression) and the reference set's own fields (`targetComponentId = << X`, `mapTarget = "..."`, `mapGroup >= #2`, ...), comma-separated and repeatable. A member filter anywhere other than directly after a memberOf constraint is a syntax error (`INVALID_ECL`)
|
|
16
|
+
- SNOMED CT ECL: concept filters (`{{ C ... }}`) - `active`, `definitionStatus` (primitive/defined), `definitionStatusId`, `moduleId` (a concept, a bracketed concept list or any sub expression) and `effectiveTime`
|
|
17
|
+
- SNOMED CT ECL: description filters (`{{ D ... }}`) - `term` (match and `wild:` search terms, single or a set), `language`, `typeId` and `type` (syn/fsn/def), `dialectId` and `dialect` (en-us, en-gb and the other aliases in the ECL specification) with acceptability sets, `moduleId`, `effectiveTime`, `active` and `id`. The filters in one block apply to the same description; only active descriptions are considered unless `active` is given
|
|
18
|
+
- SNOMED CT ECL: history supplements (`{{ + HISTORY }}`, `HISTORY-MIN` / `-MOD` / `-MAX`, `HISTORY (*)`, `HISTORY (refset expression)`) - the inactive concepts that the selected historical association reference sets associate with the result are added to it
|
|
19
|
+
- SNOMED CT ECL: `AND` and `OR` between refinements - between attributes (`: A = x AND B = y`, `: A = x OR B = y`, with brackets for nesting), within an attribute group (`{ A = x OR B = y }`) and between groups (`{ ... } OR { ... }`). Mixing AND and OR without brackets is rejected, as it is for compound expressions. Previously only `,` (conjunction) was accepted
|
|
20
|
+
- SNOMED CT ECL: concrete values in refinements (`= #500`, `>= #1.5`, `!= #-1`, `= "text"`, `= true`). The importer now reads `sct2_RelationshipConcreteValues_*` into a new cache section (cache version 19, after 18's alternate identifiers); editions imported before this report a concrete-value refinement as unsupported. The test edition cache is now `sct_test_20250909e.cache`
|
|
21
|
+
- SNOMED CT ECL: `!=` in refinements (`: A != X` - at least one A whose value is not in X; `[0..0] A != X` - every A is in X), reversed attributes (`: R A = X` - the concepts that are the value of A on a concept in X), and `,` as a conjunction between expressions as well as between refinements
|
|
22
|
+
- SNOMED CT ECL: comments (`/* ... */`), top and bottom of set (`!!> (...)`, `!!< (...)`), and attribute names that are expressions rather than a single concept (`: << 762705008 = ...`, `: (A OR B) = ...`, `: ^ refset = ...`, `: * = ...`, and the same after `.` in dotted expressions)
|
|
23
|
+
- SNOMED CT ECL: alternate identifiers (`LOINC#14394-1`, `"LOINC#14394-1"`): the scheme alias names an identifier scheme (LOINC = 30051010000102 |LOINC code ID|, as imported from the LOINC Extension) and the code resolves to the concept(s) carrying it, which then take hierarchy operators like any set. An unknown scheme, or a code that no concept carries, is `INVALID_ECL`, as an unknown concept is
|
|
24
|
+
- SNOMED CT ECL: reference set field selection on member-of (`^ [targetComponentId] refset`) is parsed, and reported as `UNSUPPORTED_ECL` (the selected fields, and many of the referenced components, are descriptions) rather than as a syntax error
|
|
25
|
+
|
|
26
|
+
### Changed
|
|
27
|
+
|
|
28
|
+
- IPS: a clinical profile's page is the same for everyone. The required sections and entries are a table of Expectation, Section and Entry (`Code is system|code`, or `Code is in` a value set, with `[m..n]` when the cardinality isn't 1..1). The rules about resources are a table of Expectation, Resource Set (`Conditions where Code is ...`) and Rule, in words for `.exists()`, `.x.exists()` and `.x.y.exists()`, `.all(sameSubject())`, `.conformsTo(url)` and `.category.hasCoding(system, code)`. The administrator adds, edits and deletes the rules on that page one at a time, in a dialog, each saved by itself; the full editor is still there as Edit all
|
|
29
|
+
- IPS: `fhir-validator-wrapper` 1.6.1 (was 1.5.0) - it sends `/loadIG` the package in the request body (1.5.0 sent it in the query, which the validator ignored, so no national profile's package loaded), and starts validator 7.0.4 and later with `-txTests`, which the tx tests need
|
|
30
|
+
- Terminology server: code systems, value sets and concept maps that both hl7.terminology and an endpoint's FHIR core package provide. THO's is still always the default for the url, but the core versions are no longer dropped: a reference that names a core version (`url|5.0.0`) gets it, where THO doesn't have that version. Previously they weren't available at all, so a reference to one failed - including THO's own value sets that pin a core version (`ValueSet/concept-property-type` 6.0.1 includes `concept-property-type` 5.0.0) - and `valueSetVersion=5.0.0` found nothing
|
|
31
|
+
- Registry: the "Register your own server" and "Documentation" links on the main page come from the master registration file (`registration` and `ecosystem-documentation`), so each ecosystem has its own, and a link the file doesn't give is left out. They were hard-coded to the HL7 ecosystem's
|
|
32
|
+
- Tx tests: the `loinc` mode (the LOINC suite) is run
|
|
33
|
+
|
|
34
|
+
### Fixed
|
|
35
|
+
|
|
36
|
+
- VSAC: the cache folder `terminology-cache/vsac` is created if it doesn't exist. Only its parent was being created (the folder name was passed as if it were a file name), so on a fresh install the VSAC database could not be created (`SQLITE_CANTOPEN`) and every VSAC value set failed
|
|
37
|
+
- CodeSystem resources: the `notSelectable` property is recognised as the tx-ecosystem requirements say - by the `concept-properties#notSelectable` uri, whatever local code the code system gives it, and by the code `notSelectable` only where the code system doesn't declare that code with a different uri. A code system that used `notSelectable` for a property of its own had its concepts marked `abstract` in expansions and treated as not selectable (`notSelectable-unprop-all`, `notSelectable-unprop-true`). A filter on that code is still a filter on the code system's own property
|
|
38
|
+
- LOINC: `$lookup` returns `CLASS` as a string, the class name (e.g. `CHEM`), with the Part code for the class as `partCode`; it returned the Part code as a code
|
|
39
|
+
- SNOMED CT ECL: a hierarchy operator on a bracketed expression was silently dropped - `< (A OR B)` returned A and B themselves rather than their descendants. It now applies to every member of the set, as do operators on `*` and on `^ refset`, which were previously rejected as unsupported
|
|
40
|
+
- SNOMED CT ECL: `^` on a reference set whose members are descriptions (a language reference set) returned an empty expansion. It is now reported as `UNSUPPORTED_ECL` when the reference set is named, and skipped when it merely falls inside a computed operand such as `^ *`
|
|
41
|
+
- SNOMED CT ECL: an attribute name with no comparison (`: 363698007` with nothing after it) was accepted by the parser and failed later as unsupported; it is a syntax error
|
|
42
|
+
- SNOMED CT ECL: an evaluation error is now `UNSUPPORTED_ECL` unless it is about a concept that does not exist (`INVALID_ECL`). It was classified by matching the error text, which filed `= true` as a syntax error. A concept id that is not 6-18 digits is rejected by the parser
|
|
43
|
+
- IPS: the pages link the module's browser scripts with their version (`?v=`), so a browser picks up a changed script as soon as the server is restarted with it, instead of using its cached copy for up to an hour
|
|
44
|
+
|
|
45
|
+
### Tx Conformance Statement
|
|
46
|
+
|
|
47
|
+
FHIRsmith v0.18.2 passed all 4565 HL7 terminology service tests (modes tx.fhir.org+omop+general+snomed+mimetypes+icd-11+icf+closure+loinc, tests v1.10.1-snapshot, runner v7.0.3)
|
|
48
|
+
|
|
8
49
|
## [0.18.1] - 2026-10-25
|
|
9
50
|
|
|
10
51
|
|
|
@@ -79,12 +120,6 @@ Note: We are heading towards a 1.0.0 release.
|
|
|
79
120
|
- `$lookup`: a warning when the `displayLanguage` asked for has no display for the code, and the display is in the default language (`LOOKUP_DISPLAY_LANGUAGE_FALLBACK`)
|
|
80
121
|
- ICD-11: the `classificationCode` extension (provisional url `http://id.who.int/icd/schema/extension/classificationCode`) on a Coding carries the short code for an entity id, saving a lookup. When present it is checked in `$validate-code` (CodeSystem and ValueSet): a wrong value, or the extension on a code with no short code, on a short code, or on a system other than MMS, is an error
|
|
81
122
|
- ICD-11 `$lookup`: postcoordination is reported as one `postcoordination` property per axis (subproperties `axis` and `value` Codings, a `/` member's value with its own nested postcoordination), replacing the WHO `postcoordinationScale` shape. With the new parameter `post-coordination-metadata=true` every declared axis is listed with `required`, `allowMultipleValues` and its value set; otherwise only the valued axes. `source` (the Foundation entity) is a Coding, and asking for `source.details` adds what a `$lookup` on it would return, designations included. A single-valued axis with several values gets a message in `$validate-code`
|
|
82
|
-
- SNOMED CT ECL: member filters (`^ refset {{ M ... }}`) - `effectiveTime` (with a time value or set), `active`, `moduleId` (any sub expression) and the reference set's own fields (`targetComponentId = << X`, `mapTarget = "..."`, `mapGroup >= #2`, ...), comma-separated and repeatable. A member filter anywhere other than directly after a memberOf constraint is a syntax error (`INVALID_ECL`). Description filters (`{{ D }}`), concept filters (`{{ C }}`) and history supplements (`{{ + }}`) are now parsed and rejected as `UNSUPPORTED_ECL` rather than failing as a lexer error
|
|
83
|
-
- SNOMED CT ECL: concept filters (`{{ C ... }}`) - `active`, `definitionStatus` (primitive/defined), `definitionStatusId`, `moduleId` (a concept, a bracketed concept list or any sub expression) and `effectiveTime`
|
|
84
|
-
- SNOMED CT ECL: `AND` and `OR` between refinements - between attributes (`: A = x AND B = y`, `: A = x OR B = y`, with brackets for nesting), within an attribute group (`{ A = x OR B = y }`) and between groups (`{ ... } OR { ... }`). Mixing AND and OR without brackets is rejected, as for compound expressions. Previously only `,` (conjunction) was accepted
|
|
85
|
-
- SNOMED CT ECL: description filters (`{{ D ... }}`) - `term` (match and `wild:` search terms, single or a set), `language`, `typeId` and `type` (syn/fsn/def), `dialectId` and `dialect` (en-us, en-gb and the other aliases in the ECL specification) with acceptability sets, `moduleId`, `effectiveTime`, `active` and `id`. The filters in one block apply to the same description; only active descriptions are considered unless `active` is given
|
|
86
|
-
- SNOMED CT ECL: comments (`/* ... */`), top and bottom of set (`!!> (...)`, `!!< (...)`), and attribute names that are expressions rather than a single concept (`: << 762705008 = ...`, `: (A OR B) = ...`, `: ^ refset = ...`, `: * = ...`, and the same after `.` in dotted expressions)
|
|
87
|
-
- SNOMED CT ECL: concrete values in refinements (`= #5`, `!= "x"`, `= match:"x"`, `= true`, ...) and alternate identifiers (`"ICD10#A01.1"`, `SCTID#123`) and reference set field selection on member-of (`^ [targetComponentId] refset`) are now parsed, so they are reported as `UNSUPPORTED_ECL` with a message saying what is missing (concrete value relationships / alternate identifier reference sets are not loaded) rather than as a syntax error
|
|
88
123
|
- SNOMED CT: `tx/sct/documentation.md` - what the server supports for SNOMED CT: editions, ECL, expressions and the MRCM, implicit concept maps and translation
|
|
89
124
|
- `utilities/make-sct-test-extension.js`: builds the extension concepts (module TEST1000003) in the SNOMED CT test edition used by the tx tests - one of each shape (fully defined ungrouped, fully defined role-grouped, equivalent to an International concept, primitive), plus inactive ones with and without historical associations
|
|
90
125
|
- README: each module's status - in production; in production but still under development; under development / prototyping; or on the back-burner
|
|
@@ -106,9 +141,6 @@ Note: We are heading towards a 1.0.0 release.
|
|
|
106
141
|
- ValueSet `$validate-code`: an include with more than one filter only checked the first. The other filters' checks return a message when the code fails them, and a message was taken as a pass
|
|
107
142
|
- LOINC: the `exists` filter ignored its value. On a relationship property (`METHOD_TYP`, `COMPONENT`, `document-kind`, ...) the value was looked up as a code or display, so `exists true` and `exists false` both returned every code with the relationship (because a code displayed as "True" and one as "False" exist), and on a property (`CLASSTYPE`, `ORDER_OBS`, ...) `exists false` meant `exists true`. `true` now returns the codes that have the property, `false` every other code in the code system - Parts, AnswerLists and Answers included, so add `CLASSTYPE exists true` to restrict to LOINC terms (e.g. LOINC terms with no method: `METHOD_TYP exists false` + `CLASSTYPE exists true`). Any other value is not supported
|
|
108
143
|
- Packages: a package download or page request was counted twice in the statistics (once by the validation step, once by the handler), and tarball downloads were not told from requests for a version's JSON document; downloads are now counted as `download`, version documents as `version`, each once
|
|
109
|
-
- SNOMED CT ECL: a hierarchy operator on a bracketed expression was silently dropped - `< (A OR B)` returned A and B themselves rather than their descendants. It now applies to every member of the set, as do operators on `*` and on `^ refset`, which were previously rejected as unsupported
|
|
110
|
-
- SNOMED CT ECL: `^` on a reference set whose members are descriptions (a language reference set) returned an empty expansion; it is now reported as `UNSUPPORTED_ECL`
|
|
111
|
-
- SNOMED CT ECL: an evaluation error is now `UNSUPPORTED_ECL` unless it is about a concept that does not exist (`INVALID_ECL`); it was classified by matching the error text, which filed `= true` as a syntax error. A concept id that is not 6-18 digits is rejected by the parser
|
|
112
144
|
- SNOMED CT expressions: the MRCM content type taken as "postcoordinated" was 723594008, which is *all precoordinated content* (postcoordinated is 723595009). So the body-structure part-of attributes (|Regional part of| etc., precoordinated only) were allowed in expressions, |Has ingredient| (postcoordinated only) was not checked, and for the attributes with a precoordinated-specific range that range was used in place of the general one
|
|
113
145
|
|
|
114
146
|
- SHL: every access to a live link (`/shl/access/{uuid}`) hung without a response - the access-logging callback was a plain `function`, so `this.db` was undefined in it and the request was never answered (since 0.3.0). A `GET` on a link is now the U flag (direct file) access the SMART Health Links spec defines: a link with one file returns it as a JWE (`application/jose`), and `recipient` is read from the query and logged (it was always `anonymous`); a link with more than one file, or a VHL, still gets the manifest / VHL bundle. The manifest now has `status` (`finalized`), file `location`s are always https (they followed `req.secure`, so http behind a proxy without trust proxy), and `embedded` is the JWE itself (it was the base64 of it, as stored)
|
package/ips/ips.js
CHANGED
|
@@ -39,10 +39,12 @@
|
|
|
39
39
|
* @module ips/ips
|
|
40
40
|
*/
|
|
41
41
|
|
|
42
|
+
const http = require('http');
|
|
42
43
|
const express = require('express');
|
|
43
44
|
const path = require('path');
|
|
44
45
|
const fs = require('fs');
|
|
45
46
|
const crypto = require('crypto');
|
|
47
|
+
const scripts = require('./scripts');
|
|
46
48
|
const rateLimit = require('express-rate-limit');
|
|
47
49
|
const escape = require('escape-html');
|
|
48
50
|
const session = require('express-session');
|
|
@@ -79,7 +81,69 @@ const TX_STARTUP_GRACE_MS = 5 * 60 * 1000;
|
|
|
79
81
|
const FHIR_JSON = 'application/fhir+json; charset=utf-8';
|
|
80
82
|
const CODE_RE = /^[a-z0-9][a-z0-9-]{1,31}$/;
|
|
81
83
|
// paths at the root that can't be project codes
|
|
82
|
-
const
|
|
84
|
+
const { jurisdiction: jurisdictionOf } = require('./jurisdictions');
|
|
85
|
+
const LOAD_IG_TIMEOUT_MS = 300000;
|
|
86
|
+
const RESERVED = new Set(['login', 'logout', 'admin', 'metadata', 'openapi', 'static', 'assets', 'lookup', 'national-profiles']);
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Ask the validator service to load a package (id#version) - POST /loadIG with {"ig": ...} as
|
|
90
|
+
* the validator's LoadIGHTTPHandler takes it. fhir-validator-wrapper's own loadIG() (to 1.6.0
|
|
91
|
+
* at least) sends the package id and version as query parameters with no body, which the
|
|
92
|
+
* service refuses ("Error parsing JSON source: ... Eof at Line 1"), so it is only used for a
|
|
93
|
+
* validator with no address of its own (as the tests' are).
|
|
94
|
+
* @returns {Promise<Object>} the OperationOutcome the service answers with
|
|
95
|
+
*/
|
|
96
|
+
function loadIG(validator, packageId, version) {
|
|
97
|
+
if (!validator.baseUrl) {
|
|
98
|
+
return validator.loadIG(packageId, version);
|
|
99
|
+
}
|
|
100
|
+
const body = JSON.stringify({ ig: `${packageId}#${version}` });
|
|
101
|
+
const url = new URL(`${validator.baseUrl}/loadIG`);
|
|
102
|
+
return new Promise((resolve, reject) => {
|
|
103
|
+
const req = http.request({
|
|
104
|
+
hostname: url.hostname, port: url.port, path: url.pathname, method: 'POST',
|
|
105
|
+
headers: { 'Content-Type': 'application/json', 'Accept': 'application/fhir+json', 'Content-Length': Buffer.byteLength(body) }
|
|
106
|
+
}, (res) => {
|
|
107
|
+
const chunks = [];
|
|
108
|
+
res.on('data', (c) => chunks.push(c));
|
|
109
|
+
res.on('end', () => {
|
|
110
|
+
const text = Buffer.concat(chunks).toString('utf8');
|
|
111
|
+
try {
|
|
112
|
+
resolve(JSON.parse(text));
|
|
113
|
+
} catch (e) {
|
|
114
|
+
reject(new Error(`The validator answered HTTP ${res.statusCode} with a response that is not JSON (${text.length} bytes)`));
|
|
115
|
+
}
|
|
116
|
+
});
|
|
117
|
+
res.on('error', reject);
|
|
118
|
+
});
|
|
119
|
+
req.on('error', reject);
|
|
120
|
+
// loading a package can mean downloading it, and its dependencies
|
|
121
|
+
req.setTimeout(LOAD_IG_TIMEOUT_MS, () => req.destroy(new Error('The validator took too long to load the package')));
|
|
122
|
+
req.end(body);
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* What's wrong with a national profile's details, or null: a jurisdiction (a country's ISO 3166
|
|
128
|
+
* two-letter code, or a region's UN M49 three-digit code - see jurisdictions.js), a FHIR package id (lower case, at
|
|
129
|
+
* least two parts separated by dots), a version (as package versions are written), and the
|
|
130
|
+
* canonical URL (http or https) of the IG's profile on Bundle.
|
|
131
|
+
*/
|
|
132
|
+
function nationalProfileProblem(jurisdiction, packageId, version, url) {
|
|
133
|
+
if (!jurisdictionOf(jurisdiction)) {
|
|
134
|
+
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)';
|
|
135
|
+
}
|
|
136
|
+
if (packageId.length > 128 || !/^[a-z0-9][a-z0-9_-]*(\.[a-z0-9][a-z0-9_-]*)+$/.test(packageId)) {
|
|
137
|
+
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)';
|
|
138
|
+
}
|
|
139
|
+
if (!/^[0-9A-Za-z][0-9A-Za-z.+-]{0,63}$/.test(version)) {
|
|
140
|
+
return 'The version must be a package version (e.g. 1.0.0, or 1.1.0-ballot)';
|
|
141
|
+
}
|
|
142
|
+
if (url.length > 1024 || !/^https?:\/\/[^\s/?#]+(?:[/?#]\S*)?$/.test(url)) {
|
|
143
|
+
return 'The profile must be the canonical URL (http or https) of the IG\'s profile on Bundle';
|
|
144
|
+
}
|
|
145
|
+
return null;
|
|
146
|
+
}
|
|
83
147
|
|
|
84
148
|
/**
|
|
85
149
|
* What's wrong with a submitted document, if anything.
|
|
@@ -165,25 +229,6 @@ function preferValue(req, name) {
|
|
|
165
229
|
return null;
|
|
166
230
|
}
|
|
167
231
|
|
|
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
232
|
function wantsHtml(req) {
|
|
188
233
|
const f = req.query._format;
|
|
189
234
|
if (typeof f === 'string' && f !== '') {
|
|
@@ -308,6 +353,7 @@ class IpsModule {
|
|
|
308
353
|
this.setupSession();
|
|
309
354
|
this.setupRootRoutes();
|
|
310
355
|
this.setupProfileRoutes();
|
|
356
|
+
this.setupNationalProfileRoutes();
|
|
311
357
|
this.setupProjectRoutes();
|
|
312
358
|
this.setupValidator();
|
|
313
359
|
this.setupValueSets();
|
|
@@ -451,7 +497,7 @@ class IpsModule {
|
|
|
451
497
|
next();
|
|
452
498
|
};
|
|
453
499
|
|
|
454
|
-
r.get('/profile-editor.js',
|
|
500
|
+
r.get('/profile-editor.js', scripts.handler('profile-editor.js'));
|
|
455
501
|
r.get('/lookup', this.session, adminJson, ...this.lookups, h('lookup', (req, res) => this.systemLookup(req, res)));
|
|
456
502
|
r.get('/lookup-vs', this.session, adminJson, ...this.lookups, h('lookup', (req, res) => this.valueSetLookup(req, res)));
|
|
457
503
|
r.get('/StructureDefinition', ...this.web, h('profiles', (req, res) => this.profileList(req, res)));
|
|
@@ -460,12 +506,37 @@ class IpsModule {
|
|
|
460
506
|
r.get('/StructureDefinition/:id', profile, ...this.web, h('profile', (req, res) => this.profileRead(req, res)));
|
|
461
507
|
r.get('/StructureDefinition/:id/edit', profile, this.session, this.csrf, admin, h('profile', (req, res) => this.profileEditor(req, res, req.profile)));
|
|
462
508
|
r.post('/StructureDefinition/:id', profile, this.sameOrigin, this.session, json, this.csrf, adminJson, h('profile', (req, res) => this.profileSave(req, res, req.profile)));
|
|
509
|
+
// one constraint at a time, from the profile's page: add, edit (keeping its key), delete
|
|
510
|
+
r.post('/StructureDefinition/:id/constraint', profile, this.sameOrigin, this.session, json, this.csrf, adminJson, h('profile', (req, res) => this.constraintSave(req, res, null)));
|
|
511
|
+
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)));
|
|
512
|
+
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
513
|
r.post('/StructureDefinition/:id/delete', profile, this.sameOrigin, this.session, this.form, this.csrf, admin, h('profile', (req, res) => {
|
|
464
514
|
this.store.deleteProfile(req.profileId);
|
|
465
515
|
res.redirect(req.baseUrl + '/StructureDefinition');
|
|
466
516
|
}));
|
|
467
517
|
}
|
|
468
518
|
|
|
519
|
+
/**
|
|
520
|
+
* The national profiles, across all projects: everyone can see them, only the administrator
|
|
521
|
+
* adds, edits and deletes them. Before the project routes, so /national-profiles isn't taken
|
|
522
|
+
* for a project code.
|
|
523
|
+
*/
|
|
524
|
+
setupNationalProfileRoutes() {
|
|
525
|
+
const r = this.router;
|
|
526
|
+
const h = (name, fn) => (req, res) => this.handle(req, res, name, () => fn(req, res));
|
|
527
|
+
const admin = this.adminOnly;
|
|
528
|
+
r.get('/national-profiles', ...this.web, h('national-profiles', (req, res) => this.sendNationalProfiles(req, res)));
|
|
529
|
+
r.post('/national-profiles', this.sameOrigin, this.session, this.form, this.csrf, admin, h('national-profiles', (req, res) => this.saveNationalProfile(req, res, null)));
|
|
530
|
+
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)));
|
|
531
|
+
r.post('/national-profiles/:id/delete', this.sameOrigin, this.session, this.form, this.csrf, admin, h('national-profiles', (req, res) => {
|
|
532
|
+
const np = /^\d{1,15}$/.test(req.params.id) ? this.store.nationalProfile(parseInt(req.params.id, 10)) : null;
|
|
533
|
+
if (np && this.store.deleteNationalProfile(np.id)) {
|
|
534
|
+
this.log.info(`IPS module: national profile ${np.packageId}#${np.version} deleted`);
|
|
535
|
+
}
|
|
536
|
+
res.redirect(req.baseUrl + '/national-profiles');
|
|
537
|
+
}));
|
|
538
|
+
}
|
|
539
|
+
|
|
469
540
|
setupProjectRoutes() {
|
|
470
541
|
const r = this.router;
|
|
471
542
|
const h = (name, fn) => (req, res) => this.handle(req, res, name, () => fn(req, res));
|
|
@@ -509,7 +580,7 @@ class IpsModule {
|
|
|
509
580
|
next();
|
|
510
581
|
};
|
|
511
582
|
|
|
512
|
-
r.get('/editor.js',
|
|
583
|
+
r.get('/editor.js', scripts.handler('editor.js'));
|
|
513
584
|
r.get('/:code', project, ...this.web, h('list', (req, res) => this.htmlList(req, res)));
|
|
514
585
|
r.get('/:code/metadata', project, h('metadata', (req, res) => this.metadata(req, res)));
|
|
515
586
|
// The submission form has no CSRF token and starts no session: submitting is open to
|
|
@@ -595,15 +666,22 @@ class IpsModule {
|
|
|
595
666
|
* wait in the queue.
|
|
596
667
|
*/
|
|
597
668
|
setupValidator() {
|
|
669
|
+
// the national profiles' packages, as they are loaded into the validator: id#version -> Promise
|
|
670
|
+
this.nationalPackages = new Map();
|
|
598
671
|
const cfg = this.config.validator;
|
|
599
672
|
if (!cfg || cfg.enabled === false) {
|
|
600
673
|
this.log.warn('IPS module: no validator configured (modules.ips.validator) - documents will not be validated');
|
|
601
674
|
return;
|
|
602
675
|
}
|
|
603
676
|
const FhirValidator = require('fhir-validator-wrapper');
|
|
604
|
-
|
|
677
|
+
// a jar of its own (a validator built locally, say): used as it is, never downloaded or
|
|
678
|
+
// updated; otherwise the released one in the data folder's bin directory, kept up to date
|
|
679
|
+
const localJar = typeof cfg.jar === 'string' && cfg.jar.trim() ? (path.isAbsolute(cfg.jar) ? cfg.jar : folders.filePath(cfg.jar)) : null;
|
|
680
|
+
const jar = localJar || folders.filePath('bin', 'validator_cli.jar');
|
|
605
681
|
this.validator = new FhirValidator(jar, this.log);
|
|
606
682
|
const settings = {
|
|
683
|
+
autoDownload: !localJar,
|
|
684
|
+
skipUpdateCheck: !!localJar,
|
|
607
685
|
version: cfg.version || '4.0.1',
|
|
608
686
|
txServer: cfg.txServer || 'http://tx.fhir.org/r4',
|
|
609
687
|
txLog: cfg.txLog || folders.filePath('logs', 'ips-validator-tx.log'),
|
|
@@ -611,7 +689,7 @@ class IpsModule {
|
|
|
611
689
|
igs: Array.isArray(cfg.packages) ? cfg.packages : ['hl7.fhir.uv.ips#1.1.0'],
|
|
612
690
|
timeout: cfg.timeout || 120000
|
|
613
691
|
};
|
|
614
|
-
this.log.info(`IPS module: starting the FHIR validator on port ${settings.port} (tx ${settings.txServer}; ${settings.igs.join(', ')})`);
|
|
692
|
+
this.log.info(`IPS module: starting the FHIR validator${localJar ? ` (${localJar})` : ''} on port ${settings.port} (tx ${settings.txServer}; ${settings.igs.join(', ')})`);
|
|
615
693
|
this.validatorStarting = this.validator.start(settings).then(() => {
|
|
616
694
|
this.log.info('IPS module: the FHIR validator is running');
|
|
617
695
|
}).catch(e => {
|
|
@@ -622,6 +700,31 @@ class IpsModule {
|
|
|
622
700
|
});
|
|
623
701
|
}
|
|
624
702
|
|
|
703
|
+
/**
|
|
704
|
+
* Load a national profile's package into the validator - once: a package that loaded, or
|
|
705
|
+
* failed to (with why), is remembered until the national profiles are changed, so each
|
|
706
|
+
* document doesn't ask again. The validator's packages (modules.ips.validator.packages) are
|
|
707
|
+
* loaded when it starts; these are added to them.
|
|
708
|
+
* @returns {Promise<void>} rejects with the reason the package could not be loaded
|
|
709
|
+
*/
|
|
710
|
+
loadNationalPackage(packageId, version) {
|
|
711
|
+
const key = `${packageId}#${version}`;
|
|
712
|
+
let p = this.nationalPackages.get(key);
|
|
713
|
+
if (!p) {
|
|
714
|
+
p = loadIG(this.validator, packageId, version).then(outcome => {
|
|
715
|
+
const bad = (Array.isArray(outcome?.issue) ? outcome.issue : []).filter(i => i && (i.severity === 'error' || i.severity === 'fatal'));
|
|
716
|
+
if (bad.length > 0 || outcome?.resourceType !== 'OperationOutcome') {
|
|
717
|
+
const why = bad.map(i => i.details?.text || i.diagnostics || i.code).filter(Boolean).join('; ') || 'the validator did not return an OperationOutcome';
|
|
718
|
+
throw new Error(`The validator could not load ${key}: ${why}`);
|
|
719
|
+
}
|
|
720
|
+
this.log.info(`IPS module: national profile package ${key} loaded into the validator`);
|
|
721
|
+
});
|
|
722
|
+
p.catch(e => this.log.warn(`IPS module: ${e.message}`));
|
|
723
|
+
this.nationalPackages.set(key, p);
|
|
724
|
+
}
|
|
725
|
+
return p;
|
|
726
|
+
}
|
|
727
|
+
|
|
625
728
|
/** Read the value sets the terminology check holds documents to: the module's own, in ips/valuesets/. */
|
|
626
729
|
setupValueSets() {
|
|
627
730
|
try {
|
|
@@ -1295,6 +1398,52 @@ class IpsModule {
|
|
|
1295
1398
|
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
1399
|
}
|
|
1297
1400
|
|
|
1401
|
+
// ---- national profiles ----
|
|
1402
|
+
|
|
1403
|
+
/** The national profiles: a page (with the administrator's forms), or JSON. */
|
|
1404
|
+
sendNationalProfiles(req, res, message, status = 200) {
|
|
1405
|
+
const list = this.store.nationalProfiles();
|
|
1406
|
+
res.status(status);
|
|
1407
|
+
if (!wantsHtml(req) && !message) {
|
|
1408
|
+
return res.json(list.map(np => ({ jurisdiction: np.jurisdiction, packageId: np.packageId, version: np.version, url: np.url })));
|
|
1409
|
+
}
|
|
1410
|
+
this.sendHtml(res, 'National Profiles', render.renderNationalProfiles(list, this.ctx(req, res), message), Date.now(), req);
|
|
1411
|
+
}
|
|
1412
|
+
|
|
1413
|
+
/** Add (id null) or change a national profile, from the form: packageId, version, url. */
|
|
1414
|
+
saveNationalProfile(req, res, id) {
|
|
1415
|
+
const field = (name) => (typeof req.body[name] === 'string' ? req.body[name].trim() : '');
|
|
1416
|
+
const packageId = field('packageId');
|
|
1417
|
+
const version = field('version');
|
|
1418
|
+
const url = field('url');
|
|
1419
|
+
// a country code is upper case; a region's is digits
|
|
1420
|
+
const jurisdiction = field('jurisdiction').toUpperCase();
|
|
1421
|
+
const problem = nationalProfileProblem(jurisdiction, packageId, version, url);
|
|
1422
|
+
if (problem) {
|
|
1423
|
+
return this.sendNationalProfiles(req, res, problem, 400);
|
|
1424
|
+
}
|
|
1425
|
+
const existing = this.store.nationalProfileByPackage(packageId, version);
|
|
1426
|
+
if (id === null) {
|
|
1427
|
+
if (existing) {
|
|
1428
|
+
return this.sendNationalProfiles(req, res, `There is already a national profile for ${packageId}#${version}`, 409);
|
|
1429
|
+
}
|
|
1430
|
+
this.store.createNationalProfile(packageId, version, url, jurisdiction);
|
|
1431
|
+
this.log.info(`IPS module: national profile ${packageId}#${version} (${jurisdiction}) added (${url})`);
|
|
1432
|
+
} else {
|
|
1433
|
+
const n = /^\d{1,15}$/.test(id) ? parseInt(id, 10) : -1;
|
|
1434
|
+
if (existing && existing.id !== n) {
|
|
1435
|
+
return this.sendNationalProfiles(req, res, `There is already a national profile for ${packageId}#${version}`, 409);
|
|
1436
|
+
}
|
|
1437
|
+
if (!this.store.updateNationalProfile(n, packageId, version, url, jurisdiction)) {
|
|
1438
|
+
return this.sendNationalProfiles(req, res, 'That national profile no longer exists', 404);
|
|
1439
|
+
}
|
|
1440
|
+
this.log.info(`IPS module: national profile ${packageId}#${version} (${jurisdiction}) saved (${url})`);
|
|
1441
|
+
}
|
|
1442
|
+
// a package that failed to load is tried again
|
|
1443
|
+
this.nationalPackages = new Map();
|
|
1444
|
+
res.redirect(req.baseUrl + '/national-profiles');
|
|
1445
|
+
}
|
|
1446
|
+
|
|
1298
1447
|
// ---- clinical profiles ----
|
|
1299
1448
|
|
|
1300
1449
|
/** The clinical profiles: a page, or a FHIR searchset of the StructureDefinitions. */
|
|
@@ -1321,7 +1470,31 @@ class IpsModule {
|
|
|
1321
1470
|
res.type('application/fhir+json');
|
|
1322
1471
|
return res.send(JSON.stringify(sd));
|
|
1323
1472
|
}
|
|
1324
|
-
|
|
1473
|
+
const ctx = { ...this.ctx(req, res), valueSetTitle: (url) => this.valueSetTitle(url), profileTitle: (url) => this.profileTitle(url) };
|
|
1474
|
+
// the administrator edits the rows in place, a row at a time
|
|
1475
|
+
const editor = ctx.user?.isAdmin ? {
|
|
1476
|
+
constraints: this.editorConstraints(sd),
|
|
1477
|
+
sections: profiles.IPS_SECTIONS, systems: profiles.SYSTEMS, resourceTypes: profiles.RESOURCE_TYPES, sectionSystem: editorSchema.LOINC, canLookup: !!this.tx
|
|
1478
|
+
} : null;
|
|
1479
|
+
this.sendHtml(res, `Clinical Profile: ${sd.title}`, render.renderProfile(sd, ctx, editor), Date.now(), req);
|
|
1480
|
+
}
|
|
1481
|
+
|
|
1482
|
+
/**
|
|
1483
|
+
* What to call a profile a rule says resources conform to: one of the clinical profiles (by
|
|
1484
|
+
* its title), or one of the national profiles (by its jurisdiction and package), or null.
|
|
1485
|
+
*/
|
|
1486
|
+
profileTitle(url) {
|
|
1487
|
+
if (typeof url !== 'string') {
|
|
1488
|
+
return null;
|
|
1489
|
+
}
|
|
1490
|
+
const plain = url.split('|')[0];
|
|
1491
|
+
const m = /\/StructureDefinition\/profile-([a-f0-9]{12})$/.exec(plain);
|
|
1492
|
+
const sd = m ? this.store.profile(m[1]) : null;
|
|
1493
|
+
if (sd && sd.url === plain) {
|
|
1494
|
+
return sd.title;
|
|
1495
|
+
}
|
|
1496
|
+
const np = this.store.nationalProfiles().find(p => p.url === plain);
|
|
1497
|
+
return np ? `${np.jurisdiction ? `${np.jurisdiction} ` : ''}national profile (${np.packageId}#${np.version})` : null;
|
|
1325
1498
|
}
|
|
1326
1499
|
|
|
1327
1500
|
/** The title of one of this module's own value sets, or null. */
|
|
@@ -1331,8 +1504,12 @@ class IpsModule {
|
|
|
1331
1504
|
}
|
|
1332
1505
|
|
|
1333
1506
|
/** The editor, for a new profile or an existing one. */
|
|
1507
|
+
editorConstraints(sd) {
|
|
1508
|
+
return profiles.constraintsOf(sd).map(c => ({ ...c, shape: profiles.parseShape(c.expression), rshape: profiles.parseResourceShape(c.expression) }));
|
|
1509
|
+
}
|
|
1510
|
+
|
|
1334
1511
|
profileEditor(req, res, sd) {
|
|
1335
|
-
const constraints =
|
|
1512
|
+
const constraints = this.editorConstraints(sd);
|
|
1336
1513
|
this.sendHtml(res, sd ? `Edit Clinical Profile: ${sd.title}` : 'New Clinical Profile',
|
|
1337
1514
|
render.renderProfileEditor(sd, this.ctx(req, res), {
|
|
1338
1515
|
profile: { id: sd ? sd.id : null, title: sd ? sd.title : '', constraints },
|
|
@@ -1369,6 +1546,53 @@ class IpsModule {
|
|
|
1369
1546
|
res.status(previous ? 200 : 201).json({ id: sd.id, url: `${req.baseUrl}/StructureDefinition/${sd.id}` });
|
|
1370
1547
|
}
|
|
1371
1548
|
|
|
1549
|
+
/**
|
|
1550
|
+
* Add a constraint to a profile ({human, expression}), or replace one (by its key, which it
|
|
1551
|
+
* keeps). 201/200 with {key}, 404 when there is no constraint with that key, 422 with {problems}.
|
|
1552
|
+
*/
|
|
1553
|
+
constraintSave(req, res, key) {
|
|
1554
|
+
const previous = req.profile;
|
|
1555
|
+
const body = req.body && typeof req.body === 'object' && !Array.isArray(req.body) ? req.body : {};
|
|
1556
|
+
const row = { key, human: body.human, expression: body.expression };
|
|
1557
|
+
const constraints = profiles.constraintsOf(previous);
|
|
1558
|
+
const at = key === null ? constraints.length : constraints.findIndex(c => c.key === key);
|
|
1559
|
+
if (at < 0) {
|
|
1560
|
+
return res.status(404).json({ problems: [`The profile has no constraint '${key}'`] });
|
|
1561
|
+
}
|
|
1562
|
+
// the row by itself first, so what is wrong is said of it, not of a number in the list
|
|
1563
|
+
const alone = profiles.checkInput({ title: previous.title, constraints: [{ human: row.human, expression: row.expression }] }, null);
|
|
1564
|
+
if (alone.problems.length) {
|
|
1565
|
+
return res.status(422).json({ problems: alone.problems.map(p => p.replace(/^Constraint 1/, 'The rule')) });
|
|
1566
|
+
}
|
|
1567
|
+
constraints.splice(at, key === null ? 0 : 1, row);
|
|
1568
|
+
const checked = profiles.checkInput({ title: previous.title, constraints }, previous);
|
|
1569
|
+
if (checked.problems.length) {
|
|
1570
|
+
return res.status(422).json({ problems: checked.problems });
|
|
1571
|
+
}
|
|
1572
|
+
this.saveConstraints(previous, checked.constraints);
|
|
1573
|
+
this.log.info(`IPS module: clinical profile ${previous.id}: constraint ${checked.constraints[at].key} ${key === null ? 'added' : 'saved'}`);
|
|
1574
|
+
res.status(key === null ? 201 : 200).json({ key: checked.constraints[at].key });
|
|
1575
|
+
}
|
|
1576
|
+
|
|
1577
|
+
/** Delete one of a profile's constraints, back to the profile's page. */
|
|
1578
|
+
constraintDelete(req, res) {
|
|
1579
|
+
const previous = req.profile;
|
|
1580
|
+
const constraints = profiles.constraintsOf(previous);
|
|
1581
|
+
const at = constraints.findIndex(c => c.key === req.params.key);
|
|
1582
|
+
if (at < 0) {
|
|
1583
|
+
return htmlServer.sendErrorResponse(res, TEMPLATE, new Error(`The profile has no constraint '${req.params.key}'`), 404);
|
|
1584
|
+
}
|
|
1585
|
+
constraints.splice(at, 1);
|
|
1586
|
+
this.saveConstraints(previous, constraints);
|
|
1587
|
+
this.log.info(`IPS module: clinical profile ${previous.id}: constraint ${req.params.key} deleted`);
|
|
1588
|
+
res.redirect(`${req.baseUrl}/StructureDefinition/${previous.id}`);
|
|
1589
|
+
}
|
|
1590
|
+
|
|
1591
|
+
saveConstraints(previous, constraints) {
|
|
1592
|
+
const id = previous.id.substring('profile-'.length);
|
|
1593
|
+
this.store.saveProfile(id, profiles.build({ id, url: previous.url, title: previous.title, constraints }));
|
|
1594
|
+
}
|
|
1595
|
+
|
|
1372
1596
|
// ---- the editor ----
|
|
1373
1597
|
|
|
1374
1598
|
sendCompose(req, res) {
|
|
@@ -1597,6 +1821,7 @@ class IpsModule {
|
|
|
1597
1821
|
...this.ctx(req, res),
|
|
1598
1822
|
steps: this.steps.map(st => st.name),
|
|
1599
1823
|
level: typeof req.query.level === 'string' ? req.query.level : undefined,
|
|
1824
|
+
np: typeof req.query.np === 'string' ? req.query.np : undefined,
|
|
1600
1825
|
problemsOnly: req.query.problems === '1'
|
|
1601
1826
|
};
|
|
1602
1827
|
return this.sendHtml(res, `${req.project.name}: Document ${row.id}`, render.renderDocument(req.project, row, view, ctx), start, req);
|
|
@@ -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 };
|
package/ips/pipeline.js
CHANGED
|
@@ -12,10 +12,11 @@
|
|
|
12
12
|
* 1. translate translate CodeableConcepts that fail the terminology check to codes that pass it
|
|
13
13
|
* 2. narrative generate the narrative with the IPS presenter (presenter.js), into the analysed copy
|
|
14
14
|
* 3. validation validate the (translated) document with the FHIR validator (fhir-validator-wrapper)
|
|
15
|
-
* 4.
|
|
16
|
-
* 5.
|
|
17
|
-
* 6.
|
|
18
|
-
* 7.
|
|
15
|
+
* 4. national validate it against each national profile (the national IGs' profiles on Bundle)
|
|
16
|
+
* 5. terminology check each CodeableConcept against the value sets for international exchange
|
|
17
|
+
* 6. piqi measure the quality of the document with PIQI (not yet)
|
|
18
|
+
* 7. profiles check the document against each clinical profile (profiles.js)
|
|
19
|
+
* 8. ai assess the document for consistency and its terminology with an AI service (ai-client.js)
|
|
19
20
|
*
|
|
20
21
|
* Each step is { name, run(row, project, module) -> result }. A step keeps what it finds in
|
|
21
22
|
* one of two places: as a result stored against the document (store.saveResult - the
|
|
@@ -122,6 +123,66 @@ const validation = {
|
|
|
122
123
|
}
|
|
123
124
|
};
|
|
124
125
|
|
|
126
|
+
/**
|
|
127
|
+
* National profiles: the document is validated against each national profile - the
|
|
128
|
+
* national IG's package is loaded into the validator first (once), then the document is
|
|
129
|
+
* validated against the IG's profile on Bundle, as the validation step validates it. For
|
|
130
|
+
* each profile, the result keeps the OperationOutcome and whether the document passed: no
|
|
131
|
+
* errors (or fatal issues). A profile whose package can't be loaded, or that the validator
|
|
132
|
+
* can't validate against, is kept with the reason (pass is then null), and doesn't stop the
|
|
133
|
+
* pipeline.
|
|
134
|
+
*/
|
|
135
|
+
const national = {
|
|
136
|
+
name: 'national',
|
|
137
|
+
async run(row, project, module) {
|
|
138
|
+
const list = module.store.nationalProfiles();
|
|
139
|
+
const started = Date.now();
|
|
140
|
+
if (list.length === 0) {
|
|
141
|
+
module.store.saveResult(row.id, 'national', { ms: 0, profiles: [] });
|
|
142
|
+
return 'national profiles: none';
|
|
143
|
+
}
|
|
144
|
+
const v = module.validator;
|
|
145
|
+
if (!v) {
|
|
146
|
+
throw new Error('The validator is not configured (modules.ips.validator)');
|
|
147
|
+
}
|
|
148
|
+
if (!v.isRunning()) {
|
|
149
|
+
throw new Error('The validator is not running');
|
|
150
|
+
}
|
|
151
|
+
const cfg = module.config.validator || {};
|
|
152
|
+
const checkIpsCodes = cfg.checkIpsCodes !== false;
|
|
153
|
+
const doc = JSON.stringify(row.analysed || row.bundle);
|
|
154
|
+
const profiles = [];
|
|
155
|
+
for (const np of list) {
|
|
156
|
+
const entry = { id: np.id, jurisdiction: np.jurisdiction, packageId: np.packageId, version: np.version, url: np.url };
|
|
157
|
+
const t0 = Date.now();
|
|
158
|
+
try {
|
|
159
|
+
await module.loadNationalPackage(np.packageId, np.version);
|
|
160
|
+
const outcome = await v.validate(doc, { profiles: [np.url], checkIpsCodes, timeout: VALIDATE_TIMEOUT_MS });
|
|
161
|
+
if (!outcome || outcome.resourceType !== 'OperationOutcome') {
|
|
162
|
+
throw new Error('The validator did not return an OperationOutcome');
|
|
163
|
+
}
|
|
164
|
+
entry.counts = issueCounts(outcome);
|
|
165
|
+
entry.pass = !entry.counts.error && !entry.counts.fatal;
|
|
166
|
+
entry.outcome = outcome;
|
|
167
|
+
} catch (e) {
|
|
168
|
+
entry.pass = null;
|
|
169
|
+
entry.error = e.message;
|
|
170
|
+
}
|
|
171
|
+
entry.ms = Date.now() - t0;
|
|
172
|
+
profiles.push(entry);
|
|
173
|
+
}
|
|
174
|
+
module.store.saveResult(row.id, 'national', {
|
|
175
|
+
ms: Date.now() - started,
|
|
176
|
+
validatorVersion: typeof v.jarVersion === 'function' ? v.jarVersion() : null,
|
|
177
|
+
profiles
|
|
178
|
+
});
|
|
179
|
+
const passed = profiles.filter(p => p.pass === true).length;
|
|
180
|
+
const failed = profiles.filter(p => p.pass === false).length;
|
|
181
|
+
const unchecked = profiles.length - passed - failed;
|
|
182
|
+
return `national profiles: ${passed} passed, ${failed} failed` + (unchecked ? `, ${unchecked} not checked` : '');
|
|
183
|
+
}
|
|
184
|
+
};
|
|
185
|
+
|
|
125
186
|
// ---- the terminology check ------------------------------------------------------------
|
|
126
187
|
|
|
127
188
|
/**
|
|
@@ -1003,10 +1064,10 @@ const aiStep = {
|
|
|
1003
1064
|
};
|
|
1004
1065
|
|
|
1005
1066
|
/** The steps, in order. Only the ones that exist yet. */
|
|
1006
|
-
const STEPS = [translate, narrative, validation, terminology, profilesStep, aiStep];
|
|
1067
|
+
const STEPS = [translate, narrative, validation, national, terminology, profilesStep, aiStep];
|
|
1007
1068
|
|
|
1008
1069
|
module.exports = {
|
|
1009
|
-
STEPS, translate, narrative, validation, terminology, profiles: profilesStep, ai: aiStep, checkConstraint, valueSetsIn, lookupDisplay, checkConcept, documentForAi, assessmentOf, aiSystemPrompt, AI_SYSTEM_PROMPT, checkOf, addCheck, stripChecks, translateConcept, translationOf,
|
|
1070
|
+
STEPS, translate, narrative, validation, national, terminology, profiles: profilesStep, ai: aiStep, checkConstraint, valueSetsIn, lookupDisplay, checkConcept, documentForAi, assessmentOf, aiSystemPrompt, AI_SYSTEM_PROMPT, checkOf, addCheck, stripChecks, translateConcept, translationOf,
|
|
1010
1071
|
validateCodeInValueSet, validateCodeInSystem, issueCounts, issueSummary,
|
|
1011
1072
|
IPS_BUNDLE_PROFILE, CHECK_EXTENSION, TRANSLATION_EXTENSION, MAX_CHECKS_PER_DOCUMENT, MAX_CACHED_ANSWERS
|
|
1012
1073
|
};
|