fhirsmith 0.18.0 → 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 +72 -10
- 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/library/daily-report.js +25 -15
- 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/translations/Messages.properties +1 -0
- package/tx/README.md +4 -0
- package/tx/closure/closure-store.js +14 -0
- package/tx/cm/cm-package.js +21 -15
- package/tx/cs/cs-api.js +3 -1
- package/tx/cs/cs-cs.js +59 -12
- package/tx/cs/cs-icf.js +47 -34
- package/tx/cs/cs-loinc.js +257 -3
- package/tx/cs/cs-snomed.js +544 -55
- package/tx/importers/csv-records.js +35 -0
- package/tx/importers/import-loinc.module.js +177 -41
- package/tx/importers/import-sct.module.js +131 -1
- package/tx/importers/subset-loinc.module.js +25 -6
- package/tx/library/codesystem.js +4 -1
- package/tx/library/designations.js +23 -30
- package/tx/library/tho-precedence.js +40 -0
- package/tx/openapi-operations.js +2 -1
- package/tx/provider.js +46 -29
- package/tx/sct/documentation.md +45 -21
- package/tx/sct/ecl.js +34 -11
- package/tx/sct/structures.js +15 -2
- package/tx/tx.fhir.org.yml +2 -2
- package/tx/vs/vs-package.js +17 -13
- package/tx/vs/vs-vsac.js +1 -1
- package/tx/workers/closure.js +20 -0
- package/tx/workers/expand.js +2 -2
- package/tx/workers/validate.js +10 -0
- package/utilities/make-sct-loinc-subset.js +250 -0
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,78 @@ 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
|
+
|
|
49
|
+
## [0.18.1] - 2026-10-25
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
### Added
|
|
53
|
+
|
|
54
|
+
- ICF (`http://hl7.org/fhir/sid/icf`): qualified codes now cover environmental factors (`e`) - a barrier after `.`, or a facilitator after `+` in place of the `.` (no `+9`), on the new `q5` (barrier) and `q6` (facilitator) scales. As a whole: A category may be followed by `.` and one qualifier digit per position, the positions depending on the component: extent for body functions (`b`); extent, nature and localization for body structures (`s`); performance, capacity, and optionally capacity with assistance and performance without assistance for activities and participation (`d`); one for environmental factors (`e`), a barrier after `.` or a facilitator after `+` in place of the `.`. Each digit is checked against its scale in the code system (`q1`-`q6`, added to the ICF code systems in fhir.tx.support with a `kind` property). A qualified code gets a generated display (8, not specified, is left out of it; `d` qualifiers are labelled `performance`, `capacity`, ...), its qualifiers as properties in `$lookup` (`extent`, `nature`, `localization`, `performance`, `capacity`, `capacity-with-assistance`, `performance-without-assistance`, `barrier`, `facilitator`), and sits under its category for subsumption and `is-a` filters. Value sets can control qualifiers with an `exists` `true`/`false` filter on any of those properties (8 counts as absent) and `qualified` `=` `false`. ICF is now marked as not closed. The behaviour is in `tx/cs/cs-icf.js`, a subclass of the CodeSystem provider that `FhirCodeSystemProvider.create()` picks by url - the way to give any code system loaded from a package behaviour of its own
|
|
55
|
+
- SNOMED CT alternate identifiers (`sct2_Identifier`, e.g. the LOINC Extension's LOINC codes): the importer reads them into the cache (cache format 18, written only when there are identifiers, so other caches stay 17). `$lookup` returns them as Coding properties named for the scheme (`loinc` = `http://loinc.org#...`), by default and when asked for. `$expand`: the filter `mapped:<scheme>` `exists` `true`/`false` (e.g. `mapped:30051010000102`) for concepts that do or don't have one. `$translate`: each module with identifiers defines an implicit map `<edition>/version/<date>?fhir_cm=<module>` (e.g. `?fhir_cm=11010000107`), `equivalent` in both directions, nominated by url or found from the systems (`targetSystem` `http://loinc.org` from a SNOMED CT coding, or `http://snomed.info/sct|<edition>` from a LOINC one)
|
|
56
|
+
- SNOMED CT test edition: a subset of the LOINC Extension (LOINC ontology, module `11010000107`) - twelve LOINC-coded concepts with their LOINC identifiers (`sct2_Identifier`), plus the module and identifier scheme concepts - generated from the LOINC Extension release by `utilities/make-sct-loinc-subset.js`. The test cache is now `sct_test_20250909d.cache` (2280 concepts)
|
|
57
|
+
- LOINC importer: mappings from LOINC codes, parts and answers to other code systems, in two new tables - `ExternalCodes` (system, version, code, display; one row per target) and `ExternalMappings` (LOINC code to target, with an equivalence). Read from `PartFile/PartRelatedCodeMapping.csv` (the file's own equivalence; the RadLex rows are left out for now), `LoincIeeeMedicalDeviceCodeMappingTable` (MDC, `urn:iso:std:iso:11073:10101`, the CF_CODE10 as the code and the REFID as display; IEEE's `broader`/`narrower` are from the LOINC side, so they become `narrower`/`wider`) and the `ExtCode*` columns of `AnswerList.csv` (`equivalent`; LOINC's `CMS/HCPCS` and `https://www.cdc.gov/phin` systems become `http://www.cms.gov/Medicare/Coding/HCPCSReleaseCodeSets` and `urn:oid:2.16.840.1.113883.6.238`). Both mapping files are optional. LOINC caches need rebuilding to get them; nothing uses them yet
|
|
58
|
+
- LOINC: the mappings to other code systems are used. `$lookup` returns them as Coding properties named for the map (`sct`, `chebi`, `mdc`, `race`...), with the equivalence as a subproperty, by default and when asked for. `$translate`: each target system has an implicit map `http://loinc.org/cm/<name>` - `loinc`, `ncbi` (PubChem), `sct`, `hcpcs`, `gene-names`, `rxnorm`, `chebi`, `ncbi-clinvar`, `ncbi-gene`, `ncbi-taxo`, `mdc`, `race` - that works in both directions (LOINC to the system, and the system to LOINC), found from the target system or nominated by url. Found from the systems, a map is only offered for a code it has a mapping for, so translations that a SNOMED CT edition answers (the LOINC Extension) are unchanged. Targets in other systems (`CMS LCDS`, `Unicode`) are not used yet. Code system providers' `findImplicitConceptMaps` now also gets the code being translated
|
|
59
|
+
- `$closure`: `dispose` - a request with `name` and `dispose` = `true` says the client has finished with the closure table, and the server deletes it (not in the specification; disposing of a table that doesn't exist is fine - it may already have been pruned). It can't be combined with concepts, a version, `reset` or configuration (`CLOSURE_DISPOSE_NOT_ALONE`, 400). Documented in the OpenAPI
|
|
60
|
+
|
|
61
|
+
### Changed
|
|
62
|
+
|
|
63
|
+
- Global language packs: the `codesystem-supplement-type` code is now `global-lang-pack` (`lang-pack` is still accepted)
|
|
64
|
+
- `$lookup` on SNOMED CT: when the request names properties, only those are returned - effectiveTime, module, parent, child and the defining relationships are no longer added regardless
|
|
65
|
+
- Daily report: the title is now "{server} daily report for {date} covering the last {24 hours | n days}", and the summary is one line per item - `Versions:` (FHIRsmith and Node), `Uptime:` (this run, and the percentage over all recorded runs), `Space:` (the log files and the disk) and `Audit:`
|
|
66
|
+
- Tx test generator (`tx/tests/testcases-generator.js`): disabled suites and tests are left out, and `version` gates (including negated ones, `!4.0`) and `modes` lists are read as the Java runner reads them, so tests gated `!4.0` now run at R5. The rules are in `tx/tests/test-case-gates.js`
|
|
67
|
+
|
|
68
|
+
### Fixed
|
|
69
|
+
|
|
70
|
+
- Implicit ConceptMaps nominated by url are looked for in every version of a code system, not just the first loaded (a SNOMED CT edition's maps are named by its edition URI)
|
|
71
|
+
- LOINC importer (and subsetter): CSV files are read by record, not by line, so a quoted field with a line break in it is one record. LOINC 2.83's `ComponentHierarchyBySystem.csv` has one (the text of `LP409416-7`), and the import failed with `UNIQUE constraint failed: Config.ConfigKey` - the second half of the record looked like a second root
|
|
72
|
+
- `$expand`: a supplement that only supplied the chosen display or a designation - a global language pack, typically - was never reported as a `used-supplement`. Designations from a supplement carry it as `.supplement`, but the reporting looked for `.source`, which is never set
|
|
73
|
+
- `CodeSystem/$validate-code` with `useSupplement`: the supplement was always reported as missing (`VALUESET_SUPPLEMENT_MISSING`, 422), even when it was passed in `tx-resource`, because the supplements were only checked off while preparing a value set, and validating against a code system doesn't prepare one
|
|
74
|
+
- `$lookup` on a CodeSystem resource: a property asked for by its own code (`property=prop`) was not returned - only `property=property`, `*` or no `property` parameter returned the code system's properties. A property the server doesn't know is still ignored
|
|
75
|
+
|
|
76
|
+
### Tx Conformance Statement
|
|
77
|
+
|
|
78
|
+
FHIRsmith v0.18.1 passed all 4337 HL7 terminology service tests (modes tx.fhir.org+omop+general+snomed+mimetypes+icd-11+icf+closure, tests v1.10.0, runner v7.0.3)
|
|
79
|
+
|
|
8
80
|
## [0.18.0] - 2026-10-23
|
|
9
81
|
|
|
10
82
|
A big release: a new IPS module (prototype), a secured and documented registry with uptime reporting, a syndication feed for the terminology server, a daily email report, ICF qualified codes, SNOMED CT edition to edition translation and much more ECL, and the ICD-11 representation agreed at the October 2026 test-case review meetings. Plus the outcome of security reviews of the registry, IPS and token modules. The README now says what state each module is in.
|
|
@@ -43,18 +115,11 @@ Note: We are heading towards a 1.0.0 release.
|
|
|
43
115
|
- Home page and dashboard: the uptime box now also reports the server's overall uptime - `Uptime: 38m 16s (99.3% overall)` - the proportion of the time since the first recorded run that the server has been running. Each run is recorded in the statistics database (`server_runs` in `stats.db`) with a heartbeat every minute, so a crash loses at most a minute. Not shown when statistics are turned off
|
|
44
116
|
- `library/fhir-search-values.js`: the date range, prefix and comma-splitting parts of FHIR search, moved out of the testing module for sharing
|
|
45
117
|
- `library/package-manager.js`: a cached package folder with no `package/package.json` (a download that failed after the folder was made) is fetched again instead of being used
|
|
46
|
-
- ICF (`http://hl7.org/fhir/sid/icf`): qualified codes. A category may be followed by `.` and one qualifier digit per position, the positions depending on the component: extent for body functions (`b`); extent, nature and localization for body structures (`s`); performance, capacity, and optionally capacity with assistance and performance without assistance for activities and participation (`d`). Qualifiers on environmental factors (`e`) are not supported yet. Each digit is checked against its scale in the code system (`q1`-`q4`, added to the ICF code systems in fhir.tx.support with a `kind` property). A qualified code gets a generated display (8, not specified, is left out of it; `d` qualifiers are labelled `performance`, `capacity`, ...), its qualifiers as properties in `$lookup` (`extent`, `nature`, `localization`, `performance`, `capacity`, `capacity-with-assistance`, `performance-without-assistance`), and sits under its category for subsumption and `is-a` filters. Value sets can control qualifiers with an `exists` `true`/`false` filter on any of those properties (8 counts as absent) and `qualified` `=` `false`. ICF is now marked as not closed. The behaviour is in `tx/cs/cs-icf.js`, a subclass of the CodeSystem provider that `FhirCodeSystemProvider.create()` picks by url - the way to give any code system loaded from a package behaviour of its own
|
|
47
118
|
- `$translate`: SNOMED CT edition to edition translation with no map - from an edition to one it depends on (per the module dependency reference set), using the implicit map `<edition>/version/<date>?fhir_cm=edition/<target module>` (the url agreed with the SNOMED committee), found from `targetSystem` or nominated by url. A concept in the target edition's modules is its own translation; an inactive concept translates through its historical associations; a fully defined extension concept translates to its normal form (or the target concept with that normal form), when that uses only target edition content; anything else translates to its proximal target-edition ancestors as `source-is-narrower-than-target`, unless those are only top-level hierarchies, which is reported as the reason there is no translation. The source concept in a match carries its version, as given
|
|
48
119
|
- `$translate`: when the target code a map directs to is not in its code system's normal form, the match has a `normalized-concept`. A source code that has a normal form is looked up both as given and in its normal form (a map entry written in the normal form matches any code with that normal form; the map's own entries are not normalised). A target in a code system the server doesn't know gets a `note` saying it was not validated, and a target code that is not valid gives an `error`
|
|
49
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`)
|
|
50
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
|
|
51
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`
|
|
52
|
-
- 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
|
|
53
|
-
- SNOMED CT ECL: concept filters (`{{ C ... }}`) - `active`, `definitionStatus` (primitive/defined), `definitionStatusId`, `moduleId` (a concept, a bracketed concept list or any sub expression) and `effectiveTime`
|
|
54
|
-
- 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
|
|
55
|
-
- 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
|
|
56
|
-
- 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)
|
|
57
|
-
- 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
|
|
58
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
|
|
59
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
|
|
60
125
|
- README: each module's status - in production; in production but still under development; under development / prototyping; or on the back-burner
|
|
@@ -76,9 +141,6 @@ Note: We are heading towards a 1.0.0 release.
|
|
|
76
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
|
|
77
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
|
|
78
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
|
|
79
|
-
- 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
|
|
80
|
-
- 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`
|
|
81
|
-
- 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
|
|
82
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
|
|
83
145
|
|
|
84
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 };
|