fhirsmith 0.18.0 → 0.18.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -5,6 +5,37 @@ 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.1] - 2026-10-25
9
+
10
+
11
+ ### Added
12
+
13
+ - 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
14
+ - 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)
15
+ - 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)
16
+ - 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
17
+ - 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
18
+ - `$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
19
+
20
+ ### Changed
21
+
22
+ - Global language packs: the `codesystem-supplement-type` code is now `global-lang-pack` (`lang-pack` is still accepted)
23
+ - `$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
24
+ - 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:`
25
+ - 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`
26
+
27
+ ### Fixed
28
+
29
+ - 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)
30
+ - 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
31
+ - `$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
32
+ - `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
33
+ - `$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
34
+
35
+ ### Tx Conformance Statement
36
+
37
+ 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)
38
+
8
39
  ## [0.18.0] - 2026-10-23
9
40
 
10
41
  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,7 +74,6 @@ Note: We are heading towards a 1.0.0 release.
43
74
  - 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
75
  - `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
76
  - `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
77
  - `$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
78
  - `$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
79
  - `$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`)
@@ -85,6 +85,15 @@ function formatSpan(ms) {
85
85
  return parts.join(' ');
86
86
  }
87
87
 
88
+ /** "24 hours", "3 days" - for the report title; rounded, since it's a description of the period */
89
+ function describeSpan(ms) {
90
+ const hours = Math.round(ms / 3600000);
91
+ if (hours < 48) {
92
+ return plural(Math.max(1, hours), 'hour');
93
+ }
94
+ return plural(Math.round(hours / 24), 'day');
95
+ }
96
+
88
97
  function plural(n, word) {
89
98
  return `${n} ${word}${n === 1 ? '' : 's'}`;
90
99
  }
@@ -243,8 +252,7 @@ class DailyReport {
243
252
  */
244
253
  async build(since, now = Date.now()) {
245
254
  const parts = [];
246
- parts.push(`# ${this.serverId} daily report`);
247
- parts.push(`${localDateTime(now)}, covering the ${formatSpan(now - since)} since ${localDateTime(since)}.`);
255
+ parts.push(`# ${this.serverId} daily report for ${localDateTime(now)} covering the last ${describeSpan(now - since)}`);
248
256
  parts.push('## Stability');
249
257
  parts.push(this.stabilityParagraph(since, now));
250
258
  for (const [name, module] of Object.entries(this.modules)) {
@@ -347,25 +355,26 @@ class DailyReport {
347
355
  * the disk they're on.
348
356
  */
349
357
  systemParagraph(now) {
350
- const sentences = [];
351
- sentences.push(`${this.serverId} is running FHIRsmith v${this.version} on Node.js ${process.version} (${os.type()} ${os.release()}, ${os.arch()}).`);
358
+ // one line each; the two trailing spaces are markdown's line break
359
+ const lines = [];
360
+ lines.push(`Versions: FHIRsmith = v${this.version}. Node = ${process.version} (${os.type()} ${os.release()}, ${os.arch()})`);
352
361
  if (this.stats && typeof this.stats.startTime === 'number') {
353
- let up = `This run has been up for ${formatSpan(now - this.stats.startTime)}, since ${localDateTime(this.stats.startTime)}`;
362
+ let up = `Uptime: This run = ${formatSpan(now - this.stats.startTime)}`;
354
363
  const overall = typeof this.stats.overallUptime === 'function' ? this.stats.overallUptime(now) : null;
355
364
  if (overall) {
356
365
  const pct = overall.percent >= 100 ? '100' : (Math.floor(overall.percent * 10) / 10).toFixed(1);
357
- up += `; over all its recorded runs (since ${localDate(overall.since)}) the server has been up ${pct}% of the time`;
366
+ up += `. All runs, ${pct}%`;
358
367
  }
359
- sentences.push(up + '.');
368
+ lines.push(up);
360
369
  }
361
- sentences.push(this.logsSentence());
370
+ lines.push(`Space: ${this.logsText()}`);
362
371
  if (this.npmAudit && typeof this.npmAudit.reportText === 'function') {
363
372
  const audit = this.npmAudit.reportText();
364
373
  if (audit) {
365
- sentences.push(audit);
374
+ lines.push(`Audit: ${audit}`);
366
375
  }
367
376
  }
368
- return sentences.join(' ');
377
+ return lines.join(' \n');
369
378
  }
370
379
 
371
380
  /**
@@ -388,7 +397,7 @@ class DailyReport {
388
397
  return `${plural(failed.length, 'background task')} ${failed.length === 1 ? 'is' : 'are'} in a failed state:\n\n${lines.join('\n')}`;
389
398
  }
390
399
 
391
- logsSentence() {
400
+ logsText() {
392
401
  const logDir = folders.logsDir();
393
402
  try {
394
403
  const files = fs.readdirSync(logDir).filter(f => f.endsWith('.log'));
@@ -402,13 +411,13 @@ class DailyReport {
402
411
  const free = fstats.bavail * fstats.bsize;
403
412
  const size = fstats.blocks * fstats.bsize;
404
413
  const freePct = size > 0 ? Math.round((free * 100) / size) : 0;
405
- disk = `, on a disk with ${formatBytes(free)} free of ${formatBytes(size)} (${freePct}% free)`;
414
+ disk = ` on a disk with ${formatBytes(free)} free of ${formatBytes(size)} (${freePct}% free)`;
406
415
  } catch {
407
- disk = ', on a disk whose free space could not be read';
416
+ disk = ' on a disk whose free space could not be read';
408
417
  }
409
- return `The logs folder (${logDir}) holds ${plural(files.length, 'log file')} totalling ${formatBytes(total)}${disk}.`;
418
+ return `${plural(files.length, 'log file')} totalling ${formatBytes(total)}${disk}.`;
410
419
  } catch (e) {
411
- return `The logs folder (${logDir}) could not be read: ${e.message}.`;
420
+ return `the logs folder (${logDir}) could not be read: ${e.message}.`;
412
421
  }
413
422
  }
414
423
  }
@@ -417,6 +426,7 @@ module.exports = DailyReport;
417
426
  module.exports.nextMidnight = nextMidnight;
418
427
  module.exports.localDate = localDate;
419
428
  module.exports.formatSpan = formatSpan;
429
+ module.exports.describeSpan = describeSpan;
420
430
  // for modules writing their paragraph: "3 packages", "1 package"
421
431
  module.exports.plural = plural;
422
432
  module.exports.localDateTime = localDateTime;
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "fhirsmith",
3
- "version": "0.18.0",
4
- "txVersion": "1.9.6",
3
+ "version": "0.18.1",
4
+ "txVersion": "1.10.0",
5
5
  "description": "A Node.js server that provides a collection of tools to serve the FHIR ecosystem",
6
6
  "main": "server.js",
7
7
  "engines": {
@@ -1129,6 +1129,7 @@ CLOSURE_NAME_NOT_UUID = The closure table name ''{0}'' is not valid: this server
1129
1129
  CLOSURE_PARAM_UNKNOWN = The parameter ''{0}'' is not supported by $closure on this server
1130
1130
  CLOSURE_CONCEPT_AND_VERSION = A $closure request can add concepts or resynchronise from a version, but not both at once
1131
1131
  CLOSURE_RESET_AND_VERSION = A $closure request can reset the closure table or resynchronise from a version, but not both at once
1132
+ CLOSURE_DISPOSE_NOT_ALONE = A $closure request that disposes of the closure table can only name the table: it cannot also add concepts, resynchronise, reset or configure it
1132
1133
  CLOSURE_TABLE_EXISTS = The closure table ''{0}'' already exists. To discard its content and start again, include the parameter reset = true
1133
1134
  CLOSURE_TABLE_UNKNOWN = The closure table ''{0}'' does not exist on this server. Initialise it first, with a $closure request that names it and has no concepts
1134
1135
  CLOSURE_CONFIG_NOT_ALLOWED = The parameter ''{0}'' configures a closure table, so it can only be supplied when the table is initialised (or reset)
@@ -286,6 +286,20 @@ class ClosureStore {
286
286
  return tx();
287
287
  }
288
288
 
289
+ /**
290
+ * Delete a table, and everything in it. The caller holds the table's lock.
291
+ * @param {string} name
292
+ * @returns {boolean} whether there was a table to delete
293
+ */
294
+ dropTable(name) {
295
+ const existing = this.stmt.getTable.get(name);
296
+ if (!existing) {
297
+ return false;
298
+ }
299
+ this.stmt.deleteTable.run(existing.id);
300
+ return true;
301
+ }
302
+
289
303
  /**
290
304
  * The parameters the table was initialised with, as Parameters.parameter entries.
291
305
  */
package/tx/cs/cs-api.js CHANGED
@@ -1036,9 +1036,11 @@ class CodeSystemFactoryProvider {
1036
1036
  * @param source - the source system URI
1037
1037
  * @param sourceVersion - the version of the source coding (may be null)
1038
1038
  * @param dest - the target system URI, possibly with |version
1039
+ * @param code - the code being translated, if known (under reverse, the target concept's
1040
+ * code), so a provider can leave out a map that has nothing to say about it
1039
1041
  */
1040
1042
  // eslint-disable-next-line no-unused-vars
1041
- async findImplicitConceptMaps(conceptMaps, source, sourceVersion, dest) {
1043
+ async findImplicitConceptMaps(conceptMaps, source, sourceVersion, dest, code = null) {
1042
1044
  return null;
1043
1045
  }
1044
1046
 
package/tx/cs/cs-cs.js CHANGED
@@ -1027,11 +1027,17 @@ class FhirCodeSystemProvider extends BaseCSServices {
1027
1027
  return;
1028
1028
  }
1029
1029
 
1030
- // Add properties if requested (or by default)
1031
- if (this._hasProp(props, 'property', true)) {
1030
+ // Add the concept's properties: each one if it was asked for by its code (or '*', or nothing
1031
+ // was asked for). 'property' asks for all of them - it used to be the only thing that did, so
1032
+ // asking for a property by its own code returned nothing. A code the server doesn't know is
1033
+ // simply not matched
1034
+ {
1032
1035
  const properties = await this.properties(ctxt);
1033
1036
  if (properties) {
1034
1037
  for (const property of properties) {
1038
+ if (!this._hasProp(props, property.code, true) && !this._hasProp(props, 'property', true)) {
1039
+ continue;
1040
+ }
1035
1041
  let parts = [];
1036
1042
  parts.push({ name: 'code', valueCode: property.code });
1037
1043
 
package/tx/cs/cs-icf.js CHANGED
@@ -12,10 +12,12 @@
12
12
  * s body structures extent, nature, localization
13
13
  * d activities/participation performance, capacity, then optionally capacity with
14
14
  * assistance and performance without assistance
15
- * e environmental factors not supported yet (barriers '.', facilitators '+')
15
+ * e environmental factors one: a barrier after '.', or a facilitator after '+' (in
16
+ * place of the '.') - e310.2, e310+3
16
17
  * Each digit must be a value of its position's scale, which the code system carries as the
17
- * concepts q1-n (extent of impairment), q2-n (nature), q3-n (localization) and q4-n (extent of
18
- * problem), under the group concepts q1..q4. A position cannot be omitted; 8 (not specified) is
18
+ * concepts q1-n (extent of impairment), q2-n (nature), q3-n (localization), q4-n (extent of
19
+ * problem), q5-n (barrier) and q6-n (facilitator, which has no 9), under the group concepts
20
+ * q1..q6. A position cannot be omitted; 8 (not specified) is
19
21
  * what fills it, and 8 says nothing in the display. Each position is reported by $lookup as the
20
22
  * property named in QUALIFIERS.
21
23
  *
@@ -37,32 +39,40 @@ const { Issue } = require('../library/operation-outcome');
37
39
  const { FhirCodeSystemProvider, FhirCodeSystemProviderContext, FhirCodeSystemProviderFilterContext } = require('./cs-cs');
38
40
 
39
41
  /**
40
- * The qualifier positions of each component: the property that reports the qualifier, the
41
- * scale (group concept) its values come from, and the label that introduces its value in the
42
- * display (none for b and s, whose qualifiers say what they are).
42
+ * The qualifier positions of each component, by the separator that introduces them: the
43
+ * property that reports the qualifier, the scale (group concept) its values come from, and the
44
+ * label that introduces its value in the display (none where the value says what it is).
45
+ * Only environmental factors take '+', which makes the qualifier a facilitator.
43
46
  */
44
47
  const QUALIFIERS = {
45
- b: [
46
- { property: 'extent', scale: 'q1' }
47
- ],
48
- s: [
49
- { property: 'extent', scale: 'q1' },
50
- { property: 'nature', scale: 'q2' },
51
- { property: 'localization', scale: 'q3' }
52
- ],
53
- d: [
54
- { property: 'performance', scale: 'q4', label: 'performance' },
55
- { property: 'capacity', scale: 'q4', label: 'capacity' },
56
- { property: 'capacity-with-assistance', scale: 'q4', label: 'capacity with assistance' },
57
- { property: 'performance-without-assistance', scale: 'q4', label: 'performance without assistance' }
58
- ],
59
- e: null // barriers and facilitators: not supported yet
48
+ b: {
49
+ '.': [{ property: 'extent', scale: 'q1' }]
50
+ },
51
+ s: {
52
+ '.': [
53
+ { property: 'extent', scale: 'q1' },
54
+ { property: 'nature', scale: 'q2' },
55
+ { property: 'localization', scale: 'q3' }
56
+ ]
57
+ },
58
+ d: {
59
+ '.': [
60
+ { property: 'performance', scale: 'q4', label: 'performance' },
61
+ { property: 'capacity', scale: 'q4', label: 'capacity' },
62
+ { property: 'capacity-with-assistance', scale: 'q4', label: 'capacity with assistance' },
63
+ { property: 'performance-without-assistance', scale: 'q4', label: 'performance without assistance' }
64
+ ]
65
+ },
66
+ e: {
67
+ '.': [{ property: 'barrier', scale: 'q5' }],
68
+ '+': [{ property: 'facilitator', scale: 'q6' }]
69
+ }
60
70
  };
61
71
 
62
72
  const COMPONENT_NAMES = { b: 'body functions', s: 'body structures', d: 'activities and participation', e: 'environmental factors' };
63
- const COMPONENT_CODES = { b: 'a body functions (b) code', s: 'a body structures (s) code', d: 'an activities and participation (d) code' };
73
+ const COMPONENT_CODES = { b: 'a body functions (b) code', s: 'a body structures (s) code', d: 'an activities and participation (d) code', e: 'an environmental factors (e) code' };
64
74
 
65
- const QUALIFIER_PROPERTIES = new Set(Object.values(QUALIFIERS).filter(q => q).flatMap(q => q.map(p => p.property)));
75
+ const QUALIFIER_PROPERTIES = new Set(Object.values(QUALIFIERS).flatMap(c => Object.values(c)).flatMap(q => q.map(p => p.property)));
66
76
 
67
77
  /**
68
78
  * A category with qualifiers. code and concept are the category's, so whatever is inherited
@@ -110,21 +120,22 @@ class IcfCodeSystemProvider extends FhirCodeSystemProvider {
110
120
  // -- locating -------------------------------------------------------------------------
111
121
 
112
122
  /**
113
- * A code in the code system, or a selectable category followed by '.' and one to three
114
- * qualifier digits. An unknown category is a plain miss (no message); a known category
115
- * with bad qualifiers says what is wrong with them.
123
+ * A code in the code system, or a selectable category followed by '.' (or, for an
124
+ * environmental factor, '+') and its qualifier digits. An unknown category is a plain miss
125
+ * (no message); a known category with bad qualifiers says what is wrong with them.
116
126
  */
117
127
  async locate(code) {
118
128
  const base = await super.locate(code);
119
129
  if (base.context || typeof code !== 'string') {
120
130
  return base;
121
131
  }
122
- const dot = code.indexOf('.');
123
- if (dot <= 0) {
132
+ const sep = code.search(/[.+]/);
133
+ if (sep <= 0) {
124
134
  return base;
125
135
  }
126
- const stemCode = code.substring(0, dot);
127
- const digits = code.substring(dot + 1);
136
+ const separator = code[sep];
137
+ const stemCode = code.substring(0, sep);
138
+ const digits = code.substring(sep + 1);
128
139
  const stem = this.codeSystem.getConceptByCode(stemCode);
129
140
  if (!stem) {
130
141
  return { context: null, message: undefined };
@@ -133,15 +144,17 @@ class IcfCodeSystemProvider extends FhirCodeSystemProvider {
133
144
  return { context: null, message: `The ICF code '${code}' is not valid: qualifiers can only follow a selectable ICF category, and '${stemCode}' is not one` };
134
145
  }
135
146
  const component = stemCode[0];
136
- const positions = QUALIFIERS[component];
147
+ const positions = (QUALIFIERS[component] || {})[separator];
137
148
  if (!positions) {
138
- return { context: null, message: `The ICF code '${code}' is not valid: qualifiers on ${COMPONENT_NAMES[component] || 'these'} codes are not supported` };
149
+ return { context: null, message: separator === '+'
150
+ ? `The ICF code '${code}' is not valid: only an environmental factor (e) can be a facilitator, written with '+'`
151
+ : `The ICF code '${code}' is not valid: qualifiers on ${COMPONENT_NAMES[component] || 'these'} codes are not supported` };
139
152
  }
140
153
  const names = positions.map(q => q.property).join(', ');
141
154
  if (!new RegExp(`^[0-9]{1,${positions.length}}$`).test(digits)) {
142
155
  return { context: null, message: positions.length === 1
143
- ? `The ICF code '${code}' is not valid: ${COMPONENT_CODES[component]} takes one qualifier digit after the '.' (${names})`
144
- : `The ICF code '${code}' is not valid: ${COMPONENT_CODES[component]} takes 1 to ${positions.length} qualifier digits after the '.' (${names})` };
156
+ ? `The ICF code '${code}' is not valid: ${COMPONENT_CODES[component]} takes one qualifier digit after the '${separator}' (${names})`
157
+ : `The ICF code '${code}' is not valid: ${COMPONENT_CODES[component]} takes 1 to ${positions.length} qualifier digits after the '${separator}' (${names})` };
145
158
  }
146
159
  const qualifiers = [];
147
160
  for (let i = 0; i < digits.length; i++) {