fhirsmith 0.16.1 → 0.18.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (142) hide show
  1. package/CHANGELOG.md +130 -0
  2. package/README.md +105 -30
  3. package/config-template.json +130 -4
  4. package/extension-tracker/extension-tracker.js +111 -3
  5. package/extension-tracker/readme.md +3 -0
  6. package/ips/ai-client.js +263 -0
  7. package/ips/composer.js +805 -0
  8. package/ips/editor-schema.js +289 -0
  9. package/ips/editor.js +290 -0
  10. package/ips/fhirpath-ips.js +397 -0
  11. package/ips/ips-codeable-concepts.md +1742 -0
  12. package/ips/ips-template.html +125 -0
  13. package/ips/ips-valuesets.js +159 -0
  14. package/ips/ips.js +1736 -0
  15. package/ips/membership.js +179 -0
  16. package/ips/pipeline.js +1012 -0
  17. package/ips/presenter.js +974 -0
  18. package/ips/profile-editor.js +578 -0
  19. package/ips/profiles.js +506 -0
  20. package/ips/readme.md +460 -0
  21. package/ips/render.js +1482 -0
  22. package/ips/search-params.js +83 -0
  23. package/ips/search.js +667 -0
  24. package/ips/share.js +178 -0
  25. package/ips/store.js +650 -0
  26. package/ips/terminology.js +395 -0
  27. package/ips/tx-client.js +202 -0
  28. package/ips/valuesets/CodeSystem-absent-unknown-uv-ips.json +206 -0
  29. package/ips/valuesets/ValueSet-absent-or-unknown-immunizations-uv-ips.json +59 -0
  30. package/ips/valuesets/ValueSet-ips-allergy-code.json +59 -0
  31. package/ips/valuesets/ValueSet-ips-allergy-manifestation.json +356 -0
  32. package/ips/valuesets/ValueSet-ips-allergy-substance.json +37 -0
  33. package/ips/valuesets/ValueSet-ips-body-site.json +26 -0
  34. package/ips/valuesets/ValueSet-ips-clinical-finding-or-none.json +35 -0
  35. package/ips/valuesets/ValueSet-ips-clinical-finding.json +26 -0
  36. package/ips/valuesets/ValueSet-ips-condition-code.json +72 -0
  37. package/ips/valuesets/ValueSet-ips-condition-severity.json +30 -0
  38. package/ips/valuesets/ValueSet-ips-condition-stage.json +26 -0
  39. package/ips/valuesets/ValueSet-ips-exposure-route.json +26 -0
  40. package/ips/valuesets/ValueSet-ips-imaging-procedure.json +25 -0
  41. package/ips/valuesets/ValueSet-ips-immunization-status-reason.json +43 -0
  42. package/ips/valuesets/ValueSet-ips-international-codes.json +25 -0
  43. package/ips/valuesets/ValueSet-ips-medication-ingredient.json +26 -0
  44. package/ips/valuesets/ValueSet-ips-medication-status-reason.json +37 -0
  45. package/ips/valuesets/ValueSet-ips-medication.json +50 -0
  46. package/ips/valuesets/ValueSet-ips-observation-code.json +18 -0
  47. package/ips/valuesets/ValueSet-ips-observation-interpretation.json +18 -0
  48. package/ips/valuesets/ValueSet-ips-procedure-followup.json +51 -0
  49. package/ips/valuesets/ValueSet-ips-procedure-outcome.json +30 -0
  50. package/ips/valuesets/ValueSet-ips-procedure-reason.json +37 -0
  51. package/ips/valuesets/ValueSet-ips-procedure.json +116 -0
  52. package/ips/valuesets/ValueSet-ips-route.json +46 -0
  53. package/ips/valuesets/ValueSet-ips-target-disease.json +171 -0
  54. package/ips/valuesets/ValueSet-ips-vaccine.json +31 -0
  55. package/ips/valuesets/elements.json +44 -0
  56. package/library/daily-report.js +422 -0
  57. package/library/fhir-search-values.js +138 -0
  58. package/library/html-server.js +32 -23
  59. package/library/html.js +9 -0
  60. package/library/mailer.js +77 -0
  61. package/library/narrative-check.js +222 -0
  62. package/library/npm-audit.js +25 -0
  63. package/library/package-manager.js +4 -2
  64. package/library/sqlite-session-store.js +29 -1
  65. package/package.json +8 -2
  66. package/packages/package-crawler.js +189 -82
  67. package/packages/packages.js +36 -63
  68. package/packages/utilities/dedupe-versions.js +22 -8
  69. package/publisher/publisher.js +29 -0
  70. package/registry/auth-store.js +366 -0
  71. package/registry/auth.js +872 -0
  72. package/registry/crawler.js +48 -1
  73. package/registry/documentation/api.md +162 -0
  74. package/registry/documentation/configuration.md +93 -0
  75. package/registry/documentation/documents.md +74 -0
  76. package/registry/documentation/internals.md +151 -0
  77. package/registry/documentation/readme.md +100 -0
  78. package/registry/documentation/security.md +199 -0
  79. package/registry/documents.js +309 -0
  80. package/registry/openapi.yaml +30 -0
  81. package/registry/registry-template.html +1 -1
  82. package/registry/registry.js +247 -54
  83. package/registry/uptime.js +154 -0
  84. package/release.sh +80 -0
  85. package/server.js +105 -44
  86. package/shl/shl.js +64 -9
  87. package/stats-db.js +93 -0
  88. package/stats.js +158 -1
  89. package/testing/readme.md +1 -1
  90. package/testing/search.js +2 -108
  91. package/testing/store.js +5 -0
  92. package/testing/testing.js +20 -0
  93. package/token/token-template.html +5 -4
  94. package/token/token.js +17 -10
  95. package/translations/Messages.properties +9 -0
  96. package/tx/README.md +52 -0
  97. package/tx/cm/cm-database.js +62 -0
  98. package/tx/cm/cm-package.js +20 -3
  99. package/tx/cs/cs-api.js +90 -14
  100. package/tx/cs/cs-cs.js +142 -19
  101. package/tx/cs/cs-icd11.js +366 -112
  102. package/tx/cs/cs-icf.js +418 -0
  103. package/tx/cs/cs-loinc.js +79 -39
  104. package/tx/cs/cs-omop.js +10 -3
  105. package/tx/cs/cs-snomed.js +918 -55
  106. package/tx/cs/cs-ucum.js +6 -0
  107. package/tx/cs/icd11-expressions.js +191 -78
  108. package/tx/importers/export-icd11-codesystem.js +58 -12
  109. package/tx/importers/icd11-schema.md +9 -5
  110. package/tx/library.js +107 -6
  111. package/tx/ocl/README.md +62 -6
  112. package/tx/ocl/cm-ocl.cjs +166 -44
  113. package/tx/ocl/cs-ocl.cjs +163 -13
  114. package/tx/ocl/resolve/public-access.js +143 -0
  115. package/tx/ocl/resolve/reference-resolver.js +458 -0
  116. package/tx/ocl/vs-ocl.cjs +167 -4
  117. package/tx/openapi-operations.js +4 -0
  118. package/tx/params.js +8 -1
  119. package/tx/problems.js +7 -5
  120. package/tx/provider.js +21 -5
  121. package/tx/sct/documentation.md +251 -0
  122. package/tx/sct/ecl.js +758 -46
  123. package/tx/sct/expressions.js +113 -52
  124. package/tx/syndication/syndication-feed.js +218 -0
  125. package/tx/syndication/syndication-store.js +415 -0
  126. package/tx/tx-html.js +37 -8
  127. package/tx/tx.fhir.org.yml +2 -3
  128. package/tx/tx.js +148 -5
  129. package/tx/vs/vs-database.js +26 -0
  130. package/tx/vs/vs-package.js +42 -10
  131. package/tx/vs/vs-vsac.js +25 -6
  132. package/tx/workers/expand.js +34 -7
  133. package/tx/workers/lookup.js +61 -6
  134. package/tx/workers/metadata.js +6 -1
  135. package/tx/workers/subsumes.js +1 -1
  136. package/tx/workers/translate.js +166 -15
  137. package/tx/workers/validate.js +128 -6
  138. package/utilities/make-sct-test-extension.js +264 -0
  139. package/utilities/submit-test-report.js +1 -1
  140. package/xig/xig.js +120 -12
  141. package/gr.sh +0 -3
  142. package/registry/readme.md +0 -124
@@ -0,0 +1,154 @@
1
+ // registry/uptime.js
2
+ // Uptime history for the registered servers: one record per endpoint per scan, saying
3
+ // whether the endpoint answered. The registry's own scan is the probe - nothing else is
4
+ // pinged - so a scan that didn't happen (the registry was down, or the master list could
5
+ // not be read) is not held against any server.
6
+
7
+ const DAY = 24 * 60 * 60 * 1000;
8
+
9
+ class UptimeHistory {
10
+ /**
11
+ * @param {object} [options]
12
+ * @param {number} [options.retentionDays] how long records are kept (default 90)
13
+ * @param {number} [options.windowDays] the period uptime is reported over (default 30)
14
+ */
15
+ constructor(options = {}) {
16
+ this.retentionMs = (options.retentionDays || 90) * DAY;
17
+ this.windowMs = (options.windowDays || 30) * DAY;
18
+ this.records = new Map(); // address -> [[timeMs, ok(1|0)], ...] in time order
19
+ }
20
+
21
+ /**
22
+ * Record the outcome of one probe of an endpoint
23
+ * @param {string} address the endpoint URL
24
+ * @param {boolean} ok whether the scan of it succeeded
25
+ * @param {number} [time] when the scan ran (ms)
26
+ */
27
+ record(address, ok, time = Date.now()) {
28
+ if (!address) {
29
+ return;
30
+ }
31
+ let list = this.records.get(address);
32
+ if (!list) {
33
+ list = [];
34
+ this.records.set(address, list);
35
+ }
36
+ list.push([time, ok ? 1 : 0]);
37
+ this.prune(address, time);
38
+ }
39
+
40
+ prune(address, now = Date.now()) {
41
+ const list = this.records.get(address);
42
+ if (!list) {
43
+ return;
44
+ }
45
+ const cutoff = now - this.retentionMs;
46
+ let drop = 0;
47
+ while (drop < list.length && list[drop][0] < cutoff) {
48
+ drop++;
49
+ }
50
+ if (drop > 0) {
51
+ list.splice(0, drop);
52
+ }
53
+ }
54
+
55
+ /**
56
+ * Uptime over the reporting window for a set of endpoints, as the fraction of their
57
+ * probes that succeeded: a server with two endpoints of which one is down for a scan
58
+ * is half down for that scan.
59
+ * @param {string|string[]} addresses one endpoint, or the endpoints of one server
60
+ * @param {number} [now]
61
+ * @param {number} [from] the start of the period; the reporting window by default
62
+ * @returns {{percent: number, probes: number, failures: number, since: number}|null}
63
+ * null when there have been no probes in the window
64
+ */
65
+ uptime(addresses, now = Date.now(), from = now - this.windowMs) {
66
+ const list = Array.isArray(addresses) ? addresses : [addresses];
67
+ let probes = 0;
68
+ let failures = 0;
69
+ let since = Infinity;
70
+ for (const address of list) {
71
+ for (const [time, ok] of this.records.get(address) || []) {
72
+ if (time < from || time > now) {
73
+ continue;
74
+ }
75
+ probes++;
76
+ if (!ok) {
77
+ failures++;
78
+ }
79
+ if (time < since) {
80
+ since = time;
81
+ }
82
+ }
83
+ }
84
+ if (probes === 0) {
85
+ return null;
86
+ }
87
+ return { percent: 100 * (probes - failures) / probes, probes, failures, since };
88
+ }
89
+
90
+ /**
91
+ * Forget what is past retention. Only age decides: an endpoint missing from one crawl (a
92
+ * sub-registry that failed to fetch, a crawl cut short at shutdown) keeps its history,
93
+ * and one that has really gone ages out with its last probes
94
+ * @param {Iterable<string>} [addresses] the endpoints of the last crawl (no longer used:
95
+ * a crawl can miss endpoints that still exist)
96
+ */
97
+ retain(addresses, now = Date.now()) {
98
+ for (const address of [...this.records.keys()]) {
99
+ this.prune(address, now);
100
+ if (this.records.get(address).length === 0) {
101
+ this.records.delete(address);
102
+ }
103
+ }
104
+ }
105
+
106
+ toJSON() {
107
+ const endpoints = {};
108
+ for (const [address, list] of this.records) {
109
+ if (list.length > 0) {
110
+ endpoints[address] = list.map(([t, ok]) => [t, ok]);
111
+ }
112
+ }
113
+ return { version: '1', endpoints };
114
+ }
115
+
116
+ static fromJSON(json, options) {
117
+ const history = new UptimeHistory(options);
118
+ if (!json || json.version !== '1' || typeof json.endpoints !== 'object') {
119
+ return history;
120
+ }
121
+ for (const [address, list] of Object.entries(json.endpoints || {})) {
122
+ if (!Array.isArray(list)) {
123
+ continue;
124
+ }
125
+ const clean = list
126
+ .filter(r => Array.isArray(r) && Number.isFinite(r[0]))
127
+ .map(r => [r[0], r[1] ? 1 : 0])
128
+ .sort((a, b) => a[0] - b[0]);
129
+ if (clean.length > 0) {
130
+ history.records.set(address, clean);
131
+ }
132
+ }
133
+ return history;
134
+ }
135
+ }
136
+
137
+ /**
138
+ * Format an uptime for display: 100%, 99.9%, 97%, ... - the precision that is honest
139
+ * for the number of probes behind it
140
+ */
141
+ function formatUptime(u) {
142
+ if (!u) {
143
+ return '';
144
+ }
145
+ if (u.failures === 0) {
146
+ return '100%';
147
+ }
148
+ if (u.percent >= 99) {
149
+ return `${(Math.floor(u.percent * 10) / 10).toFixed(1)}%`;
150
+ }
151
+ return `${Math.floor(u.percent)}%`;
152
+ }
153
+
154
+ module.exports = { UptimeHistory, formatUptime };
package/release.sh ADDED
@@ -0,0 +1,80 @@
1
+ #!/usr/bin/env bash
2
+ #
3
+ # Release FHIRsmith: ./release.sh 0.17.0
4
+ #
5
+ # Checks that the release is ready, submits the tx test report to the /testing server, then
6
+ # tags the release and pushes the tag (which starts .github/workflows/release.yml).
7
+ # Stops at the first problem, before anything is tagged.
8
+ #
9
+ # The test report: run the tx tests first (npx jest tests/tx/test-cases.test.js). If the
10
+ # /testing server needs a token, set FHIRSMITH_TESTING_TOKEN.
11
+
12
+ set -euo pipefail
13
+
14
+ fail() {
15
+ echo "gr.sh: $*" >&2
16
+ exit 1
17
+ }
18
+
19
+ [ $# -eq 1 ] || fail "usage: ./gr.sh <version>, e.g. ./gr.sh 0.17.0"
20
+ VERSION="${1#v}"
21
+ TAG="v$VERSION"
22
+ [[ "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]] || fail "'$1' is not a release version (X.Y.Z)"
23
+
24
+ cd "$(dirname "$0")"
25
+
26
+ # --- git: on main, nothing uncommitted, and exactly what is on GitHub -------------------
27
+ [ "$(git rev-parse --abbrev-ref HEAD)" = "main" ] || fail "not on main"
28
+ [ -z "$(git status --porcelain --untracked-files=no)" ] || fail "there are uncommitted changes"
29
+ git fetch --quiet origin main --tags
30
+ [ "$(git rev-parse HEAD)" = "$(git rev-parse origin/main)" ] \
31
+ || fail "main is not the same as origin/main - push (or pull) first, so the tag is on a commit GitHub has"
32
+ ! git rev-parse -q --verify "refs/tags/$TAG" >/dev/null || fail "tag $TAG already exists"
33
+
34
+ # --- versions: what release.yml's verify-version job checks ----------------------------
35
+ check_version() {
36
+ local what="$1" found="$2"
37
+ [ "$found" = "$VERSION" ] || fail "$what is '$found', not $VERSION (npm version --no-git-tag-version $VERSION && npm install --package-lock-only)"
38
+ }
39
+ check_version "package.json version" "$(node -p "require('./package.json').version")"
40
+ check_version "package-lock.json version" "$(node -p "require('./package-lock.json').version")"
41
+ check_version 'package-lock.json packages[""]' "$(node -p "require('./package-lock.json').packages[''].version")"
42
+
43
+ # --- CHANGELOG: a dated section for this version, with the conformance statement in -----
44
+ HEADING=$(grep -E "^## \[v?${VERSION//./\\.}\]" CHANGELOG.md || true)
45
+ [ -n "$HEADING" ] || fail "CHANGELOG.md has no section for $VERSION"
46
+ [[ "$HEADING" =~ [0-9]{4}-[0-9]{2}-[0-9]{2}$ ]] || fail "the CHANGELOG.md section for $VERSION has no date: $HEADING"
47
+ SECTION=$(sed -n "/^## \[v\{0,1\}${VERSION//./\\.}\]/,/^## \[/p" CHANGELOG.md | sed '$d')
48
+ ! grep -qx '(paste)' <<<"$SECTION" || fail "the Tx Conformance Statement for $VERSION hasn't been pasted into CHANGELOG.md"
49
+
50
+ # --- the test report: this version, passed, and run on what is being released ----------
51
+ REPORT=test-cases-report.json
52
+ [ -f "$REPORT" ] || fail "no $REPORT - run the tx tests first (npx jest tests/tx/test-cases.test.js)"
53
+ # the last commit that changed what the tests run - not the notes, nor the version bump
54
+ CODE_COMMITTED=$(git log -1 --format=%cI -- . ':(exclude)*.md' ':(exclude)release.sh' ':(exclude)package.json' ':(exclude)package-lock.json')
55
+ node - "$REPORT" "$VERSION" "$CODE_COMMITTED" <<'NODE' || exit 1
56
+ const [file, version, committed] = process.argv.slice(2);
57
+ const report = JSON.parse(require('fs').readFileSync(file, 'utf8'));
58
+ const fhirsmith = (report.participant || []).find(p => p.uri === 'https://github.com/HealthIntersections/FHIRsmith');
59
+ const problems = [];
60
+ if (!fhirsmith || fhirsmith.version !== version) {
61
+ problems.push(`it is from FHIRsmith ${fhirsmith ? fhirsmith.version : '(unknown)'}, not ${version}`);
62
+ }
63
+ if (report.result !== 'pass') {
64
+ problems.push(`its result is '${report.result}', not 'pass'`);
65
+ }
66
+ if (!report.issued || new Date(report.issued) < new Date(committed)) {
67
+ problems.push(`it was run (${report.issued}) before the last change to the code (${committed})`);
68
+ }
69
+ if (problems.length > 0) {
70
+ console.error(`gr.sh: ${file} can't be submitted for ${version}: ${problems.join('; ')} - run the tx tests again`);
71
+ process.exit(1);
72
+ }
73
+ NODE
74
+
75
+ # --- submit the report, then tag ---------------------------------------------------------
76
+ node utilities/submit-test-report.js -file "$REPORT" || fail "the test report was not accepted, so $TAG has not been tagged"
77
+
78
+ git tag -a "$TAG" -m "FHIRsmith $VERSION"
79
+ git push origin "$TAG"
80
+ echo "Tagged and pushed $TAG - the release workflow is now running"
package/server.js CHANGED
@@ -18,6 +18,8 @@ const escape = require('escape-html');
18
18
  const { resolveWithin } = require('./library/path-safety');
19
19
  const { readCgroupMemoryLimit } = require('./library/cgroup-memory');
20
20
  const { NpmAudit } = require('./library/npm-audit');
21
+ const Mailer = require('./library/mailer');
22
+ const DailyReport = require('./library/daily-report');
21
23
 
22
24
  // Load configuration BEFORE logger
23
25
  let config;
@@ -51,7 +53,7 @@ serverLog.info(`Data directory: ${folders.dataDir()}`);
51
53
  serverLog.info(`========================================`);
52
54
 
53
55
  const activeModules = config.modules ? Object.keys(config.modules)
54
- .filter(mod => config.modules[mod].enabled)
56
+ .filter(mod => config.modules[mod]?.enabled)
55
57
  .join(', ') : [];
56
58
  serverLog.info(`Loaded Configuration. Active modules = ${activeModules}`);
57
59
 
@@ -66,6 +68,7 @@ const TokenModule = require('./token/token.js');
66
68
  const NpmProjectorModule = require('./npmprojector/npmprojector.js');
67
69
  const TXModule = require('./tx/tx.js');
68
70
  const TestingModule = require('./testing/testing.js');
71
+ const IpsModule = require('./ips/ips.js');
69
72
 
70
73
  const htmlServer = require('./library/html-server');
71
74
  const ServerStats = require("./stats");
@@ -90,9 +93,15 @@ app.set('trust proxy', config.server.trustProxy ?? DEFAULT_TRUST_PROXY);
90
93
  const PORT = process.env.PORT || config.server.port || 3000;
91
94
 
92
95
  // Middleware
93
- app.use(express.raw({ type: 'application/fhir+json', limit: '50mb' }));
94
- app.use(express.raw({ type: 'application/fhir+xml', limit: '50mb' }));
95
- app.use(express.json({ limit: '50mb' }));
96
+ // The body parsers, for every module but those that read their own bodies with their own
97
+ // limits and checks (the ips module: a document is read only once its submitter's token has
98
+ // been checked, and no bigger than the module's maxSize)
99
+ const OWN_BODIES = ['/ips'];
100
+ const ownsBody = (req) => OWN_BODIES.some(p => req.path === p || req.path.startsWith(p + '/'));
101
+ const unlessOwnBody = (parser) => (req, res, next) => (ownsBody(req) ? next() : parser(req, res, next));
102
+ app.use(unlessOwnBody(express.raw({ type: 'application/fhir+json', limit: '50mb' })));
103
+ app.use(unlessOwnBody(express.raw({ type: 'application/fhir+xml', limit: '50mb' })));
104
+ app.use(unlessOwnBody(express.json({ limit: '50mb' })));
96
105
  app.use((req, res, next) => {
97
106
  const requestId = req.headers['x-request-id'];
98
107
  if (requestId) {
@@ -113,6 +122,8 @@ const modules = {};
113
122
  let stats = null;
114
123
  // periodic npm advisory check of the installed packages - see library/npm-audit.js
115
124
  let npmAudit = null;
125
+ // the daily report, emailed at midnight when "email" and "dailyReport.to" are configured - see library/daily-report.js
126
+ let dailyReport = null;
116
127
 
117
128
  // Initialize modules based on configuration
118
129
  async function initializeModules() {
@@ -247,6 +258,22 @@ async function initializeModules() {
247
258
  throw error;
248
259
  }
249
260
  }
261
+ if (config.modules?.ips?.enabled) {
262
+ try {
263
+ serverLog.info('Initializing module: ips...');
264
+ // sharing through this server's own shl module: its password, unless the ips
265
+ // configuration names an SHL server of its own
266
+ if (config.modules.ips.shl && !config.modules.ips.shl.password && config.modules.shl?.password) {
267
+ config.modules.ips.shl = { server: '/shl', ...config.modules.ips.shl, password: config.modules.shl.password };
268
+ }
269
+ modules.ips = new IpsModule(stats.forModule('ips'));
270
+ await modules.ips.initialize(config.modules.ips, app);
271
+ app.use('/ips', modules.ips.router);
272
+ } catch (error) {
273
+ serverLog.error('Failed to initialize ips module:', error);
274
+ throw error;
275
+ }
276
+ }
250
277
 
251
278
  // Initialize TX module
252
279
  // Note: TX module registers its own endpoints directly on the app
@@ -255,7 +282,7 @@ async function initializeModules() {
255
282
  try {
256
283
  serverLog.info('Initializing module: tx...');
257
284
  modules.tx = new TXModule(stats.forModule('tx'));
258
- await modules.tx.initialize(config.modules.tx, app);
285
+ await modules.tx.initialize(config.modules.tx, app, { hostName: config.hostName });
259
286
  } catch (error) {
260
287
  serverLog.error('Failed to initialize TX module:', error);
261
288
  throw error;
@@ -317,52 +344,52 @@ async function buildRootPageContent() {
317
344
  content += '<ul class="list-group">';
318
345
 
319
346
  // Check which modules are enabled and add them to the list
320
- if (config.modules.packages.enabled) {
347
+ if (config.modules?.packages?.enabled) {
321
348
  content += '<li class="list-group-item">';
322
349
  content += '<a href="/packages" class="text-decoration-none">Package Server</a>: Browse and download FHIR Implementation Guide packages (<a href="/packages/openapi">API</a>)';
323
350
  content += '</li>';
324
351
  }
325
352
 
326
- if (config.modules.xig.enabled) {
353
+ if (config.modules?.xig?.enabled) {
327
354
  content += '<li class="list-group-item">';
328
355
  content += '<a href="/xig" class="text-decoration-none">FHIR IG Statistics</a>: Statistics and analysis of FHIR Implementation Guides';
329
356
  content += '</li>';
330
357
  }
331
358
 
332
- if (config.modules.shl.enabled) {
359
+ if (config.modules?.shl?.enabled) {
333
360
  content += '<li class="list-group-item">';
334
361
  content += '<a href="/shl" class="text-decoration-none">SHL Server</a>: SMART Health Links management and validation';
335
362
  content += '</li>';
336
363
  }
337
364
 
338
- if (config.modules.vcl.enabled) {
365
+ if (config.modules?.vcl?.enabled) {
339
366
  content += '<li class="list-group-item">';
340
367
  content += '<a href="/VCL" class="text-decoration-none">VCL Server</a>: ValueSet Compose Language expression parsing';
341
368
  content += '</li>';
342
369
  }
343
370
 
344
- if (config.modules.registry && config.modules.registry.enabled) {
371
+ if (config.modules?.registry?.enabled) {
345
372
  content += '<li class="list-group-item">';
346
373
  content += '<a href="/tx-reg" class="text-decoration-none">Terminology Server Registry</a>: ';
347
374
  content += 'Discover and query FHIR terminology servers for code system and value set support (<a href="/tx-reg/openapi">API</a>)';
348
375
  content += '</li>';
349
376
  }
350
377
 
351
- if (config.modules.publisher && config.modules.publisher.enabled) {
378
+ if (config.modules?.publisher?.enabled) {
352
379
  content += '<li class="list-group-item">';
353
380
  content += '<a href="/publisher" class="text-decoration-none">FHIR Publisher</a>: ';
354
381
  content += 'Manage FHIR Implementation Guide publication tasks and approvals';
355
382
  content += '</li>';
356
383
  }
357
384
 
358
- if (config.modules.token && config.modules.token.enabled) {
385
+ if (config.modules?.token?.enabled) {
359
386
  content += '<li class="list-group-item">';
360
387
  content += '<a href="/token" class="text-decoration-none">Token Server</a>: ';
361
388
  content += 'OAuth authentication and API key management for FHIR services';
362
389
  content += '</li>';
363
390
  }
364
391
 
365
- if (config.modules.npmprojector && config.modules.npmprojector.enabled) {
392
+ if (config.modules?.npmprojector?.enabled) {
366
393
  content += '<li class="list-group-item">';
367
394
  content += '<a href="/npmprojector" class="text-decoration-none">NpmProjector</a>: ';
368
395
  content += 'Hot-reloading FHIR server with FHIRPath-based search indexes';
@@ -383,7 +410,14 @@ async function buildRootPageContent() {
383
410
  content += '</li>';
384
411
  }
385
412
 
386
- if (config.modules.folder && config.modules.folder.enabled) {
413
+ if (config.modules?.ips?.enabled) {
414
+ content += '<li class="list-group-item">';
415
+ content += '<a href="/ips" class="text-decoration-none">IPS</a>: ';
416
+ content += 'Collections of International Patient Summary documents, each with its own FHIR endpoint';
417
+ content += '</li>';
418
+ }
419
+
420
+ if (config.modules?.folder?.enabled) {
387
421
  content += '<li class="list-group-item">';
388
422
  content += '<strong>Cache Folder</strong>: ';
389
423
  content += 'Cache Folder for Kindling';
@@ -400,7 +434,7 @@ async function buildRootPageContent() {
400
434
  }
401
435
 
402
436
 
403
- if (config.modules.tx && config.modules.tx.enabled) {
437
+ if (config.modules?.tx?.enabled) {
404
438
  content += '<li class="list-group-item">';
405
439
  content += '<strong>TX Terminology Server</strong>: ';
406
440
  content += 'FHIR terminology services (CodeSystem, ValueSet, ConceptMap)';
@@ -422,16 +456,7 @@ async function buildRootPageContent() {
422
456
 
423
457
  // Calculate uptime
424
458
  const uptimeMs = Date.now() - stats.startTime;
425
- const uptimeSeconds = Math.floor(uptimeMs / 1000);
426
- const uptimeDays = Math.floor(uptimeSeconds / 86400);
427
- const uptimeHours = Math.floor((uptimeSeconds % 86400) / 3600);
428
- const uptimeMinutes = Math.floor((uptimeSeconds % 3600) / 60);
429
- const uptimeSecs = uptimeSeconds % 60;
430
- let uptimeStr = '';
431
- if (uptimeDays > 0) uptimeStr += `${uptimeDays}d `;
432
- if (uptimeHours > 0 || uptimeDays > 0) uptimeStr += `${uptimeHours}h `;
433
- if (uptimeMinutes > 0 || uptimeHours > 0 || uptimeDays > 0) uptimeStr += `${uptimeMinutes}m `;
434
- uptimeStr += `${uptimeSecs}s`;
459
+ const uptimeStr = uptimeText(uptimeMs);
435
460
 
436
461
  // Memory usage
437
462
  const memUsage = process.memoryUsage();
@@ -550,7 +575,7 @@ app.get('/', async (req, res) => {
550
575
  // Build basic stats for root page
551
576
  const stats = {
552
577
  version: packageJson.version,
553
- enabledModules: Object.keys(config.modules).filter(m => config.modules[m].enabled).length,
578
+ enabledModules: Object.keys(config.modules).filter(m => config.modules[m]?.enabled).length,
554
579
  processingTime: Date.now() - startTime,
555
580
  about
556
581
  };
@@ -616,16 +641,7 @@ app.get('/dashboard', async (req, res) => {
616
641
  const startTime = Date.now();
617
642
  // Calculate uptime
618
643
  const uptimeMs = Date.now() - stats.startTime;
619
- const uptimeSeconds = Math.floor(uptimeMs / 1000);
620
- const uptimeDays = Math.floor(uptimeSeconds / 86400);
621
- const uptimeHours = Math.floor((uptimeSeconds % 86400) / 3600);
622
- const uptimeMinutes = Math.floor((uptimeSeconds % 3600) / 60);
623
- const uptimeSecs = uptimeSeconds % 60;
624
- let uptimeStr = '';
625
- if (uptimeDays > 0) uptimeStr += `${uptimeDays}d `;
626
- if (uptimeHours > 0 || uptimeDays > 0) uptimeStr += `${uptimeHours}h `;
627
- if (uptimeMinutes > 0 || uptimeHours > 0 || uptimeDays > 0) uptimeStr += `${uptimeMinutes}m `;
628
- uptimeStr += `${uptimeSecs}s`;
644
+ const uptimeStr = uptimeText(uptimeMs);
629
645
 
630
646
  // Memory usage
631
647
  const memUsage = process.memoryUsage();
@@ -674,7 +690,7 @@ app.get('/dashboard', async (req, res) => {
674
690
 
675
691
  const pageStats = {
676
692
  version: packageJson.version,
677
- enabledModules: Object.keys(config.modules).filter(m => config.modules[m].enabled).length,
693
+ enabledModules: Object.keys(config.modules).filter(m => config.modules[m]?.enabled).length,
678
694
  processingTime: Date.now() - startTime
679
695
  };
680
696
 
@@ -701,6 +717,30 @@ function formatBytes(bytes) {
701
717
  return `${(bytes / 1024 / 1024 / 1024).toFixed(2)} GB`;
702
718
  }
703
719
 
720
+ /**
721
+ * "3d 4h 5m 6s" for this run, followed by the proportion of time the server has
722
+ * been up over all its recorded runs, when the statistics database is keeping that.
723
+ */
724
+ function uptimeText(uptimeMs) {
725
+ const uptimeSeconds = Math.floor(uptimeMs / 1000);
726
+ const uptimeDays = Math.floor(uptimeSeconds / 86400);
727
+ const uptimeHours = Math.floor((uptimeSeconds % 86400) / 3600);
728
+ const uptimeMinutes = Math.floor((uptimeSeconds % 3600) / 60);
729
+ const uptimeSecs = uptimeSeconds % 60;
730
+ let uptimeStr = '';
731
+ if (uptimeDays > 0) uptimeStr += `${uptimeDays}d `;
732
+ if (uptimeHours > 0 || uptimeDays > 0) uptimeStr += `${uptimeHours}h `;
733
+ if (uptimeMinutes > 0 || uptimeHours > 0 || uptimeDays > 0) uptimeStr += `${uptimeMinutes}m `;
734
+ uptimeStr += `${uptimeSecs}s`;
735
+ const overall = stats.overallUptime();
736
+ if (overall) {
737
+ // floor rather than round, so 99.96% doesn't claim to be 100%
738
+ const pct = overall.percent >= 100 ? '100' : (Math.floor(overall.percent * 10) / 10).toFixed(1);
739
+ uptimeStr += ` (${pct}% overall)`;
740
+ }
741
+ return uptimeStr;
742
+ }
743
+
704
744
  function pctColor(pct) {
705
745
  // Gradient from green (#deffe0) at 0% to red (#ffd3d1) at 100%
706
746
  const t = Math.max(0, Math.min(100, pct)) / 100;
@@ -797,11 +837,21 @@ async function startServer() {
797
837
 
798
838
  // Start server
799
839
  app.listen(PORT, () => {
800
- stats.markStarted();
840
+ stats.markStarted(packageJson.version);
801
841
  serverLog.info(`=== Server running on http://localhost:${PORT} ===`);
802
842
  });
803
843
  npmAudit.start();
804
- if (modules.packages && config.modules.packages.enabled) {
844
+ dailyReport = new DailyReport(config.dailyReport, {
845
+ log: Logger.getInstance().child({ module: 'daily-report' }),
846
+ stats: stats,
847
+ modules: modules,
848
+ mailer: new Mailer(config.email, Logger.getInstance().child({ module: 'email' })),
849
+ npmAudit: npmAudit,
850
+ version: packageJson.version,
851
+ serverId: config.hostName
852
+ });
853
+ dailyReport.start();
854
+ if (modules.packages && config.modules?.packages?.enabled) {
805
855
  modules.packages.startInitialCrawler();
806
856
  }
807
857
  // Run usage tracker in background after startup
@@ -825,12 +875,21 @@ async function startServer() {
825
875
  }
826
876
  }
827
877
 
828
- // Graceful shutdown
829
- process.on('SIGINT', async () => {
878
+ // Graceful shutdown: on Ctrl-C (SIGINT), and on SIGTERM - what systemd, docker stop and
879
+ // kill send - so the modules can save what they hold (usage counts, crawl data) either way
880
+ let shuttingDown = false;
881
+ async function shutdown() {
882
+ if (shuttingDown) {
883
+ return;
884
+ }
885
+ shuttingDown = true;
830
886
  serverLog.info('\nShutting down server...');
831
887
  if (npmAudit) {
832
888
  npmAudit.stop();
833
889
  }
890
+ if (dailyReport) {
891
+ dailyReport.stop();
892
+ }
834
893
 
835
894
  // Shutdown all modules
836
895
  for (const [moduleName, moduleInstance] of Object.entries(modules)) {
@@ -847,7 +906,9 @@ process.on('SIGINT', async () => {
847
906
  stats.finishStats();
848
907
  serverLog.info('Server shutdown complete');
849
908
  process.exit(0);
850
- });
909
+ }
910
+ process.on('SIGINT', () => void shutdown());
911
+ process.on('SIGTERM', () => void shutdown());
851
912
 
852
913
  async function serveFhirsmithHome(req, res) {
853
914
  // Check if client wants HTML response
@@ -868,7 +929,7 @@ async function serveFhirsmithHome(req, res) {
868
929
  // Build basic stats for root page
869
930
  const stats = {
870
931
  version: packageJson.version,
871
- enabledModules: Object.keys(config.modules).filter(m => config.modules[m].enabled).length,
932
+ enabledModules: Object.keys(config.modules).filter(m => config.modules[m]?.enabled).length,
872
933
  processingTime: Date.now() - startTime
873
934
  };
874
935
 
@@ -885,7 +946,7 @@ async function serveFhirsmithHome(req, res) {
885
946
  // Return JSON response for API clients
886
947
  const enabledModules = {};
887
948
  Object.keys(config.modules).forEach(moduleName => {
888
- if (config.modules[moduleName].enabled) {
949
+ if (config.modules[moduleName]?.enabled) {
889
950
  if (moduleName === 'tx') {
890
951
  // TX module has multiple endpoints
891
952
  enabledModules[moduleName] = {