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.
Files changed (47) hide show
  1. package/CHANGELOG.md +72 -10
  2. package/ips/ips.js +251 -26
  3. package/ips/jurisdictions.js +65 -0
  4. package/ips/pipeline.js +67 -6
  5. package/ips/profile-editor.js +283 -4
  6. package/ips/readme.md +48 -5
  7. package/ips/render.js +317 -46
  8. package/ips/scripts.js +46 -0
  9. package/ips/store.js +55 -0
  10. package/library/daily-report.js +25 -15
  11. package/package.json +3 -3
  12. package/registry/api.js +179 -1
  13. package/registry/crawler.js +2 -0
  14. package/registry/documentation/api.md +68 -2
  15. package/registry/documentation/internals.md +4 -1
  16. package/registry/documentation/readme.md +3 -2
  17. package/registry/model.js +8 -0
  18. package/registry/openapi.yaml +84 -0
  19. package/registry/registry.js +53 -2
  20. package/translations/Messages.properties +1 -0
  21. package/tx/README.md +4 -0
  22. package/tx/closure/closure-store.js +14 -0
  23. package/tx/cm/cm-package.js +21 -15
  24. package/tx/cs/cs-api.js +3 -1
  25. package/tx/cs/cs-cs.js +59 -12
  26. package/tx/cs/cs-icf.js +47 -34
  27. package/tx/cs/cs-loinc.js +257 -3
  28. package/tx/cs/cs-snomed.js +544 -55
  29. package/tx/importers/csv-records.js +35 -0
  30. package/tx/importers/import-loinc.module.js +177 -41
  31. package/tx/importers/import-sct.module.js +131 -1
  32. package/tx/importers/subset-loinc.module.js +25 -6
  33. package/tx/library/codesystem.js +4 -1
  34. package/tx/library/designations.js +23 -30
  35. package/tx/library/tho-precedence.js +40 -0
  36. package/tx/openapi-operations.js +2 -1
  37. package/tx/provider.js +46 -29
  38. package/tx/sct/documentation.md +45 -21
  39. package/tx/sct/ecl.js +34 -11
  40. package/tx/sct/structures.js +15 -2
  41. package/tx/tx.fhir.org.yml +2 -2
  42. package/tx/vs/vs-package.js +17 -13
  43. package/tx/vs/vs-vsac.js +1 -1
  44. package/tx/workers/closure.js +20 -0
  45. package/tx/workers/expand.js +2 -2
  46. package/tx/workers/validate.js +10 -0
  47. 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 RESERVED = new Set(['login', 'logout', 'admin', 'metadata', 'openapi', 'static', 'assets', 'lookup']);
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', script('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', script('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
- const jar = folders.filePath('bin', 'validator_cli.jar');
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
- this.sendHtml(res, `Clinical Profile: ${sd.title}`, render.renderProfile(sd, { ...this.ctx(req, res), valueSetTitle: (url) => this.valueSetTitle(url) }), Date.now(), req);
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 = profiles.constraintsOf(sd).map(c => ({ ...c, shape: profiles.parseShape(c.expression), rshape: profiles.parseResourceShape(c.expression) }));
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 };