fhirsmith 0.17.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 +113 -0
- package/README.md +80 -13
- package/config-template.json +106 -1
- package/ips/ai-client.js +263 -0
- package/ips/composer.js +805 -0
- package/ips/editor-schema.js +289 -0
- package/ips/editor.js +290 -0
- package/ips/fhirpath-ips.js +397 -0
- package/ips/ips-codeable-concepts.md +1742 -0
- package/ips/ips-template.html +125 -0
- package/ips/ips-valuesets.js +159 -0
- package/ips/ips.js +1736 -0
- package/ips/membership.js +179 -0
- package/ips/pipeline.js +1012 -0
- package/ips/presenter.js +974 -0
- package/ips/profile-editor.js +578 -0
- package/ips/profiles.js +506 -0
- package/ips/readme.md +460 -0
- package/ips/render.js +1482 -0
- package/ips/search-params.js +83 -0
- package/ips/search.js +667 -0
- package/ips/share.js +178 -0
- package/ips/store.js +650 -0
- package/ips/terminology.js +395 -0
- package/ips/tx-client.js +202 -0
- package/ips/valuesets/CodeSystem-absent-unknown-uv-ips.json +206 -0
- package/ips/valuesets/ValueSet-absent-or-unknown-immunizations-uv-ips.json +59 -0
- package/ips/valuesets/ValueSet-ips-allergy-code.json +59 -0
- package/ips/valuesets/ValueSet-ips-allergy-manifestation.json +356 -0
- package/ips/valuesets/ValueSet-ips-allergy-substance.json +37 -0
- package/ips/valuesets/ValueSet-ips-body-site.json +26 -0
- package/ips/valuesets/ValueSet-ips-clinical-finding-or-none.json +35 -0
- package/ips/valuesets/ValueSet-ips-clinical-finding.json +26 -0
- package/ips/valuesets/ValueSet-ips-condition-code.json +72 -0
- package/ips/valuesets/ValueSet-ips-condition-severity.json +30 -0
- package/ips/valuesets/ValueSet-ips-condition-stage.json +26 -0
- package/ips/valuesets/ValueSet-ips-exposure-route.json +26 -0
- package/ips/valuesets/ValueSet-ips-imaging-procedure.json +25 -0
- package/ips/valuesets/ValueSet-ips-immunization-status-reason.json +43 -0
- package/ips/valuesets/ValueSet-ips-international-codes.json +25 -0
- package/ips/valuesets/ValueSet-ips-medication-ingredient.json +26 -0
- package/ips/valuesets/ValueSet-ips-medication-status-reason.json +37 -0
- package/ips/valuesets/ValueSet-ips-medication.json +50 -0
- package/ips/valuesets/ValueSet-ips-observation-code.json +18 -0
- package/ips/valuesets/ValueSet-ips-observation-interpretation.json +18 -0
- package/ips/valuesets/ValueSet-ips-procedure-followup.json +51 -0
- package/ips/valuesets/ValueSet-ips-procedure-outcome.json +30 -0
- package/ips/valuesets/ValueSet-ips-procedure-reason.json +37 -0
- package/ips/valuesets/ValueSet-ips-procedure.json +116 -0
- package/ips/valuesets/ValueSet-ips-route.json +46 -0
- package/ips/valuesets/ValueSet-ips-target-disease.json +171 -0
- package/ips/valuesets/ValueSet-ips-vaccine.json +31 -0
- package/ips/valuesets/elements.json +44 -0
- package/library/daily-report.js +432 -0
- package/library/fhir-search-values.js +138 -0
- package/library/html-server.js +32 -23
- package/library/mailer.js +77 -0
- package/library/narrative-check.js +21 -2
- package/library/npm-audit.js +25 -0
- package/library/package-manager.js +4 -2
- package/library/sqlite-session-store.js +29 -1
- package/package.json +8 -3
- package/packages/packages.js +34 -3
- package/publisher/publisher.js +29 -0
- package/registry/auth-store.js +366 -0
- package/registry/auth.js +872 -0
- package/registry/crawler.js +41 -1
- package/registry/documentation/api.md +162 -0
- package/registry/documentation/configuration.md +93 -0
- package/registry/documentation/documents.md +74 -0
- package/registry/documentation/internals.md +151 -0
- package/registry/documentation/readme.md +100 -0
- package/registry/documentation/security.md +199 -0
- package/registry/documents.js +309 -0
- package/registry/openapi.yaml +30 -0
- package/registry/registry-template.html +1 -1
- package/registry/registry.js +230 -44
- package/registry/uptime.js +154 -0
- package/server.js +105 -44
- package/shl/shl.js +64 -9
- package/stats-db.js +93 -0
- package/stats.js +158 -1
- package/testing/search.js +2 -108
- package/testing/store.js +5 -0
- package/testing/testing.js +20 -0
- package/token/token-template.html +5 -4
- package/token/token.js +17 -10
- package/translations/Messages.properties +10 -0
- package/tx/README.md +52 -0
- package/tx/closure/closure-store.js +14 -0
- package/tx/cs/cs-api.js +92 -14
- package/tx/cs/cs-cs.js +64 -3
- package/tx/cs/cs-icd11.js +366 -112
- package/tx/cs/cs-icf.js +431 -0
- package/tx/cs/cs-loinc.js +296 -27
- package/tx/cs/cs-omop.js +10 -3
- package/tx/cs/cs-snomed.js +1210 -80
- package/tx/cs/cs-ucum.js +6 -0
- package/tx/cs/icd11-expressions.js +191 -78
- package/tx/importers/csv-records.js +35 -0
- package/tx/importers/export-icd11-codesystem.js +58 -12
- package/tx/importers/icd11-schema.md +9 -5
- package/tx/importers/import-loinc.module.js +177 -41
- package/tx/importers/import-sct.module.js +66 -1
- package/tx/importers/subset-loinc.module.js +25 -6
- package/tx/library/codesystem.js +4 -1
- package/tx/library/designations.js +23 -30
- package/tx/library.js +98 -4
- package/tx/openapi-operations.js +6 -1
- package/tx/params.js +8 -1
- package/tx/provider.js +24 -6
- package/tx/sct/documentation.md +251 -0
- package/tx/sct/ecl.js +758 -46
- package/tx/sct/expressions.js +113 -52
- package/tx/sct/structures.js +8 -2
- package/tx/syndication/syndication-feed.js +218 -0
- package/tx/syndication/syndication-store.js +415 -0
- package/tx/tx-html.js +15 -1
- package/tx/tx.fhir.org.yml +3 -4
- package/tx/tx.js +144 -2
- package/tx/vs/vs-vsac.js +25 -6
- package/tx/workers/closure.js +20 -0
- package/tx/workers/expand.js +35 -8
- package/tx/workers/lookup.js +61 -6
- package/tx/workers/subsumes.js +1 -1
- package/tx/workers/translate.js +166 -15
- package/tx/workers/validate.js +138 -6
- package/utilities/make-sct-loinc-subset.js +250 -0
- package/utilities/make-sct-test-extension.js +264 -0
- package/xig/xig.js +32 -0
- package/registry/readme.md +0 -124
package/ips/ips.js
ADDED
|
@@ -0,0 +1,1736 @@
|
|
|
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
|
+
* The /ips module: collections ("projects") of International Patient Summary documents.
|
|
9
|
+
*
|
|
10
|
+
* The root is the list of projects, which the administrator (whose login is in the
|
|
11
|
+
* configuration) creates and deletes, along with the users who manage them:
|
|
12
|
+
*
|
|
13
|
+
* GET {base} the projects (HTML)
|
|
14
|
+
* GET {base}/login login; POST {base}/logout
|
|
15
|
+
* GET {base}/admin/projects create, edit, delete projects; assign managers (administrator)
|
|
16
|
+
* GET {base}/admin/users create, edit, delete users (administrator)
|
|
17
|
+
*
|
|
18
|
+
* Each project is a FHIR endpoint and a web site at {base}/{code}:
|
|
19
|
+
*
|
|
20
|
+
* GET {base}/{code} the filterable list of documents (HTML)
|
|
21
|
+
* GET {base}/{code}/submit a form for submitting a document; POST the same
|
|
22
|
+
* GET {base}/{code}/settings the submission token (the project's managers)
|
|
23
|
+
* GET {base}/{code}/metadata CapabilityStatement
|
|
24
|
+
* POST {base}/{code}/Bundle submit a document (the project's token, if it has one)
|
|
25
|
+
* GET {base}/{code}/Bundle search - a Bundle, or the list for a browser
|
|
26
|
+
* GET {base}/{code}/Bundle/{id} read - JSON, or a view of the document for a browser (?view=)
|
|
27
|
+
* POST {base}/{code}/Bundle/{id}/delete, /requeue from the pages (managers)
|
|
28
|
+
*
|
|
29
|
+
* A document is an R4 Bundle of type document whose first entry is a Composition with a
|
|
30
|
+
* Patient subject. Once received it is given a serial id, indexed for searching (see
|
|
31
|
+
* search.js), and queued: a background loop takes documents in turn and runs the
|
|
32
|
+
* processing pipeline over them (pipeline.js - validation, so far), moving the status
|
|
33
|
+
* from 'submitted' through 'processing' to 'processed' or 'error', and keeping what each
|
|
34
|
+
* step made of the document for the pages to show.
|
|
35
|
+
*
|
|
36
|
+
* Logins work as the publisher's and the testing module's do: a session cookie, bcrypt
|
|
37
|
+
* password hashes, a rate limited login, CSRF tokens on every form.
|
|
38
|
+
*
|
|
39
|
+
* @module ips/ips
|
|
40
|
+
*/
|
|
41
|
+
|
|
42
|
+
const express = require('express');
|
|
43
|
+
const path = require('path');
|
|
44
|
+
const fs = require('fs');
|
|
45
|
+
const crypto = require('crypto');
|
|
46
|
+
const rateLimit = require('express-rate-limit');
|
|
47
|
+
const escape = require('escape-html');
|
|
48
|
+
const session = require('express-session');
|
|
49
|
+
const lusca = require('lusca');
|
|
50
|
+
const bcrypt = require('bcrypt');
|
|
51
|
+
|
|
52
|
+
const folders = require('../library/folder-setup');
|
|
53
|
+
const htmlServer = require('../library/html-server');
|
|
54
|
+
const { tokenMatches, tokenConfigured } = require('../library/request-token');
|
|
55
|
+
const { requireSameOrigin } = require('../library/same-origin');
|
|
56
|
+
const { checkNarrative, checkResourceNarratives } = require('../library/narrative-check');
|
|
57
|
+
const packageJson = require('../package.json');
|
|
58
|
+
const { IpsStore } = require('./store');
|
|
59
|
+
const { parseSearch, listQueryToSearch, capabilitySearchParams, compositionOf, patientOf } = require('./search');
|
|
60
|
+
const render = require('./render');
|
|
61
|
+
const { TxClient } = require('./tx-client');
|
|
62
|
+
const { STEPS, stripChecks } = require('./pipeline');
|
|
63
|
+
const { IpsValueSets } = require('./ips-valuesets');
|
|
64
|
+
const aiClient = require('./ai-client');
|
|
65
|
+
const { ShlClient } = require('./share');
|
|
66
|
+
const composer = require('./composer');
|
|
67
|
+
const editorSchema = require('./editor-schema');
|
|
68
|
+
const profiles = require('./profiles');
|
|
69
|
+
const { MembershipSession } = require('./membership');
|
|
70
|
+
|
|
71
|
+
const TEMPLATE = 'ips';
|
|
72
|
+
const DEFAULT_DATABASE = 'ips/ips-content.db';
|
|
73
|
+
const DEFAULT_MAX_SIZE = 10 * 1024 * 1024;
|
|
74
|
+
const DEFAULT_INTERVAL_SECONDS = 5;
|
|
75
|
+
// a terminology server on this server that isn't there: how often to look again, and how
|
|
76
|
+
// long after start-up documents wait for it
|
|
77
|
+
const TX_RECHECK_MS = 10 * 1000;
|
|
78
|
+
const TX_STARTUP_GRACE_MS = 5 * 60 * 1000;
|
|
79
|
+
const FHIR_JSON = 'application/fhir+json; charset=utf-8';
|
|
80
|
+
const CODE_RE = /^[a-z0-9][a-z0-9-]{1,31}$/;
|
|
81
|
+
// paths at the root that can't be project codes
|
|
82
|
+
const RESERVED = new Set(['login', 'logout', 'admin', 'metadata', 'openapi', 'static', 'assets', 'lookup']);
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* What's wrong with a submitted document, if anything.
|
|
86
|
+
*
|
|
87
|
+
* @param {*} b - the parsed JSON
|
|
88
|
+
* @returns {string[]} problems; empty if it's acceptable
|
|
89
|
+
*/
|
|
90
|
+
function validateDocument(b) {
|
|
91
|
+
if (b === null || typeof b !== 'object' || Array.isArray(b)) {
|
|
92
|
+
return ['The body must be a JSON object'];
|
|
93
|
+
}
|
|
94
|
+
if (b.resourceType !== 'Bundle') {
|
|
95
|
+
return [`The resource must be a Bundle, not ${typeof b.resourceType === 'string' ? b.resourceType : 'something without a resourceType'}`];
|
|
96
|
+
}
|
|
97
|
+
const problems = [];
|
|
98
|
+
if (b.type !== 'document') {
|
|
99
|
+
problems.push(`The Bundle must be of type 'document', not '${typeof b.type === 'string' ? b.type : ''}'`);
|
|
100
|
+
}
|
|
101
|
+
if (!Array.isArray(b.entry) || b.entry.length === 0) {
|
|
102
|
+
problems.push('The Bundle has no entries');
|
|
103
|
+
return problems;
|
|
104
|
+
}
|
|
105
|
+
for (const [i, e] of b.entry.entries()) {
|
|
106
|
+
if (e === null || typeof e !== 'object' || e.resource === null || typeof e.resource !== 'object' || typeof e.resource.resourceType !== 'string') {
|
|
107
|
+
problems.push(`Bundle.entry[${i}] has no resource`);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
if (problems.length > 0) {
|
|
111
|
+
return problems;
|
|
112
|
+
}
|
|
113
|
+
const comp = compositionOf(b);
|
|
114
|
+
if (!comp) {
|
|
115
|
+
problems.push(`The first entry must be a Composition, not a ${b.entry[0].resource.resourceType}`);
|
|
116
|
+
return problems;
|
|
117
|
+
}
|
|
118
|
+
if (!patientOf(b, comp)) {
|
|
119
|
+
problems.push("The Composition's subject must be a Patient in the bundle");
|
|
120
|
+
}
|
|
121
|
+
// every narrative in the document is shown on the web site, so none may have active
|
|
122
|
+
// content: the resources' own (and their contained resources'), and the sections'
|
|
123
|
+
const active = checkResourceNarratives(b);
|
|
124
|
+
if (active) {
|
|
125
|
+
problems.push(active);
|
|
126
|
+
} else {
|
|
127
|
+
const sections = (secs, where) => {
|
|
128
|
+
for (const [i, s] of (Array.isArray(secs) ? secs : []).entries()) {
|
|
129
|
+
const found = checkNarrative(s?.text?.div);
|
|
130
|
+
if (found) {
|
|
131
|
+
return `${where}[${i}]: ${found}`;
|
|
132
|
+
}
|
|
133
|
+
const nested = sections(s?.section, `${where}[${i}].section`);
|
|
134
|
+
if (nested) {
|
|
135
|
+
return nested;
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
return null;
|
|
139
|
+
};
|
|
140
|
+
const found = sections(comp.section, 'Composition.section');
|
|
141
|
+
if (found) {
|
|
142
|
+
problems.push(found);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
return problems;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function operationOutcome(severity, code, messages) {
|
|
149
|
+
return {
|
|
150
|
+
resourceType: 'OperationOutcome',
|
|
151
|
+
issue: (Array.isArray(messages) ? messages : [messages]).map(m => ({
|
|
152
|
+
severity, code, details: { text: m }
|
|
153
|
+
}))
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function preferValue(req, name) {
|
|
158
|
+
const prefer = req.get('Prefer') || '';
|
|
159
|
+
for (const part of prefer.split(/[,;]/)) {
|
|
160
|
+
const [k, v] = part.split('=').map(s => s && s.trim());
|
|
161
|
+
if (k === name) {
|
|
162
|
+
return v ? v.replace(/^"|"$/g, '') : '';
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
return null;
|
|
166
|
+
}
|
|
167
|
+
|
|
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
|
+
function wantsHtml(req) {
|
|
188
|
+
const f = req.query._format;
|
|
189
|
+
if (typeof f === 'string' && f !== '') {
|
|
190
|
+
return /html/i.test(f);
|
|
191
|
+
}
|
|
192
|
+
return (req.get('Accept') || '').includes('text/html');
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function docId(s) {
|
|
196
|
+
return /^\d{1,18}$/.test(s) ? parseInt(s, 10) : NaN;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
class IpsModule {
|
|
200
|
+
/**
|
|
201
|
+
* @param {Object} stats - a ModuleStats
|
|
202
|
+
* @param {Object} [log] - a logger; defaults to the server's
|
|
203
|
+
*/
|
|
204
|
+
constructor(stats, log) {
|
|
205
|
+
this.stats = stats;
|
|
206
|
+
this.router = express.Router();
|
|
207
|
+
this.store = null;
|
|
208
|
+
this.config = null;
|
|
209
|
+
this.queueTimer = null;
|
|
210
|
+
this.processing = false;
|
|
211
|
+
// the steps run over each document, in order (pipeline.js); tests swap them
|
|
212
|
+
this.steps = STEPS;
|
|
213
|
+
// the FHIR validator (fhir-validator-wrapper), when modules.ips.validator is enabled.
|
|
214
|
+
// Starting it takes a while, so it happens in the background and the queue waits for it
|
|
215
|
+
this.validator = null;
|
|
216
|
+
this.validatorStarting = null;
|
|
217
|
+
// the value sets the terminology check holds documents to (ips/valuesets/)
|
|
218
|
+
this.valueSets = null;
|
|
219
|
+
if (log) {
|
|
220
|
+
this.log = log;
|
|
221
|
+
} else {
|
|
222
|
+
const Logger = require('../library/logger');
|
|
223
|
+
this.log = Logger.getInstance().child({ module: 'ips' });
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* @param {Object} config - modules.ips
|
|
229
|
+
* @param {Function} [app] - the Express app, needed when txServer is a path on this server
|
|
230
|
+
*/
|
|
231
|
+
async initialize(config, app) {
|
|
232
|
+
this.config = config || {};
|
|
233
|
+
this.app = app || null;
|
|
234
|
+
const db = this.config.database || DEFAULT_DATABASE;
|
|
235
|
+
const dbPath = db === ':memory:' || path.isAbsolute(db) ? db : path.join(folders.databasesDir(), db);
|
|
236
|
+
if (dbPath !== ':memory:') {
|
|
237
|
+
fs.mkdirSync(path.dirname(dbPath), { recursive: true });
|
|
238
|
+
}
|
|
239
|
+
this.store = new IpsStore(dbPath);
|
|
240
|
+
// clinical profiles were once on Composition: they are on Bundle now
|
|
241
|
+
for (const p of this.store.profiles()) {
|
|
242
|
+
const moved = profiles.migrate(this.store.profile(p.id));
|
|
243
|
+
if (moved) {
|
|
244
|
+
this.store.saveProfile(p.id, moved);
|
|
245
|
+
this.log.info(`IPS module: clinical profile ${moved.id} (${moved.title}) moved from Composition onto Bundle`);
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
this.maxSize = Number.isInteger(this.config.maxSize) && this.config.maxSize > 0 ? this.config.maxSize : DEFAULT_MAX_SIZE;
|
|
249
|
+
|
|
250
|
+
htmlServer.loadTemplate(TEMPLATE, path.join(__dirname, 'ips-template.html'));
|
|
251
|
+
|
|
252
|
+
// the administrator's password is checked the same way as everyone else's: bcrypt against
|
|
253
|
+
// a hash, made once here, of the configured password
|
|
254
|
+
this.adminUsername = typeof this.config.adminUsername === 'string' && this.config.adminUsername ? this.config.adminUsername : 'admin';
|
|
255
|
+
this.adminHash = tokenConfigured(this.config.adminPassword) ? bcrypt.hashSync(this.config.adminPassword, 10) : null;
|
|
256
|
+
// compared against when a login names no user, so that takes as long as a real one
|
|
257
|
+
this.dummyHash = bcrypt.hashSync(crypto.randomBytes(16).toString('hex'), 10);
|
|
258
|
+
if (!this.adminHash) {
|
|
259
|
+
this.log.warn('IPS module: no adminPassword configured - nobody can create projects or users');
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
// the terminology server: links to code systems, and the analysis's queries. None
|
|
263
|
+
// configured, no links and no analysis
|
|
264
|
+
this.tx = null;
|
|
265
|
+
// set by checkTxServer while a path on this server has nothing (good) at it
|
|
266
|
+
this.txMissing = false;
|
|
267
|
+
if (this.config.txServer) {
|
|
268
|
+
this.tx = new TxClient(this.config.txServer, { app: this.app, log: this.log });
|
|
269
|
+
if (this.tx.internal) {
|
|
270
|
+
// a path on this server must be one of the tx module's endpoints. The tx module is
|
|
271
|
+
// set up after this one, so the check is made when the queue first runs (checkTxServer);
|
|
272
|
+
// until it has answered, the server is taken to be not there yet
|
|
273
|
+
this.txChecked = false;
|
|
274
|
+
this.txMissing = true;
|
|
275
|
+
}
|
|
276
|
+
this.startedAt = Date.now();
|
|
277
|
+
} else {
|
|
278
|
+
this.log.warn('IPS module: no txServer configured - code systems will not be linked, and documents will not be analysed');
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
// the secret the projects' AI keys are encrypted with: its own, else the session's
|
|
282
|
+
// (when that is configured - a random one would make the keys unreadable after a restart)
|
|
283
|
+
this.aiSecret = typeof this.config.aiKeySecret === 'string' && this.config.aiKeySecret ? this.config.aiKeySecret : (this.config.sessionSecret || null);
|
|
284
|
+
if (!this.aiSecret) {
|
|
285
|
+
this.log.warn('IPS module: no aiKeySecret or sessionSecret configured - projects cannot keep their own AI accounts');
|
|
286
|
+
}
|
|
287
|
+
if (this.config.ai?.apiKey) {
|
|
288
|
+
try {
|
|
289
|
+
aiClient.checkAccount({ provider: this.config.ai.provider || 'openai', baseUrl: this.config.ai.baseUrl, model: this.config.ai.model, apiKey: this.config.ai.apiKey });
|
|
290
|
+
this.log.info(`IPS module: AI assessments use ${this.config.ai.provider || 'openai'} / ${this.config.ai.model} by default`);
|
|
291
|
+
} catch (e) {
|
|
292
|
+
this.log.error(`IPS module: modules.ips.ai: ${e.message}`);
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
// sharing documents as SHL / VHL QR codes, through an SHL server (this server's own shl
|
|
297
|
+
// module, usually)
|
|
298
|
+
this.shl = null;
|
|
299
|
+
if (this.config.shl) {
|
|
300
|
+
try {
|
|
301
|
+
this.shl = new ShlClient(this.config.shl, { app: this.app, log: this.log });
|
|
302
|
+
this.log.info(`IPS module: documents can be shared as SHL/VHL through ${this.shl.url}`);
|
|
303
|
+
} catch (e) {
|
|
304
|
+
this.log.error(`IPS module: modules.ips.shl: ${e.message} - documents cannot be shared`);
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
this.setupSession();
|
|
309
|
+
this.setupRootRoutes();
|
|
310
|
+
this.setupProfileRoutes();
|
|
311
|
+
this.setupProjectRoutes();
|
|
312
|
+
this.setupValidator();
|
|
313
|
+
this.setupValueSets();
|
|
314
|
+
this.setupQueue();
|
|
315
|
+
this.log.info(`IPS module: ${this.store.projects().length} projects, ${this.store.count()} documents in ${dbPath}; max ${this.maxSize} bytes; ` +
|
|
316
|
+
`terminology server ${this.tx ? (this.tx.internal ? `${this.tx.url} (this server)` : this.tx.url) : 'none'}`);
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
/**
|
|
320
|
+
* The session (login) cookie and CSRF protection - as the testing module has them. The
|
|
321
|
+
* FHIR API (POST Bundle) is authenticated by a token in a header, not a cookie, so it
|
|
322
|
+
* gets neither; the pages get this.web, and every form post gets sameOrigin +
|
|
323
|
+
* this.session + a form parser + this.csrf.
|
|
324
|
+
*/
|
|
325
|
+
setupSession() {
|
|
326
|
+
if (!this.config.sessionSecret) {
|
|
327
|
+
this.log.warn('ips: no sessionSecret configured - using a random one, so logins will not ' +
|
|
328
|
+
'survive a restart. Set modules.ips.sessionSecret.');
|
|
329
|
+
}
|
|
330
|
+
this.session = session({
|
|
331
|
+
name: 'ips.sid',
|
|
332
|
+
secret: this.config.sessionSecret || crypto.randomBytes(64).toString('hex'),
|
|
333
|
+
resave: false,
|
|
334
|
+
saveUninitialized: false,
|
|
335
|
+
cookie: {
|
|
336
|
+
secure: this.config.cookieSecure ?? true,
|
|
337
|
+
sameSite: 'lax',
|
|
338
|
+
httpOnly: true,
|
|
339
|
+
maxAge: 24 * 60 * 60 * 1000
|
|
340
|
+
}
|
|
341
|
+
});
|
|
342
|
+
this.csrf = lusca.csrf({ key: '_csrf' });
|
|
343
|
+
const csrfIfLoggedIn = (req, res, next) => this.currentUser(req) ? this.csrf(req, res, next) : next();
|
|
344
|
+
this.web = [this.session, csrfIfLoggedIn];
|
|
345
|
+
this.form = express.urlencoded({ extended: false, limit: '64kb' });
|
|
346
|
+
// the submission form carries a whole document
|
|
347
|
+
this.bigForm = express.urlencoded({ extended: false, limit: Math.ceil(this.maxSize * 1.5) });
|
|
348
|
+
this.sameOrigin = requireSameOrigin((req, res) =>
|
|
349
|
+
htmlServer.sendErrorResponse(res, TEMPLATE, new Error('Cross-site form posts are not accepted'), 403));
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
// ---- routes ----
|
|
353
|
+
|
|
354
|
+
setupRootRoutes() {
|
|
355
|
+
const r = this.router;
|
|
356
|
+
const h = (name, fn) => (req, res) => this.handle(req, res, name, () => fn(req, res));
|
|
357
|
+
const loginLimiter = rateLimit({
|
|
358
|
+
windowMs: 15 * 60 * 1000,
|
|
359
|
+
limit: this.config.loginAttemptsPerWindow ?? 20,
|
|
360
|
+
standardHeaders: 'draft-7',
|
|
361
|
+
legacyHeaders: false,
|
|
362
|
+
handler: (req, res) => this.sendLogin(req, res, 429, 'Too many login attempts - try again later')
|
|
363
|
+
});
|
|
364
|
+
const admin = (req, res, next) => {
|
|
365
|
+
const user = this.currentUser(req);
|
|
366
|
+
if (!user) {
|
|
367
|
+
return res.redirect(req.baseUrl + '/login');
|
|
368
|
+
}
|
|
369
|
+
if (!user.isAdmin) {
|
|
370
|
+
return htmlServer.sendErrorResponse(res, TEMPLATE, new Error('Only the administrator can do that'), 403);
|
|
371
|
+
}
|
|
372
|
+
req.user = user;
|
|
373
|
+
next();
|
|
374
|
+
};
|
|
375
|
+
this.adminOnly = admin;
|
|
376
|
+
|
|
377
|
+
r.get('/', ...this.web, h('home', (req, res) => {
|
|
378
|
+
this.sendHtml(res, 'International Patient Summary', render.renderHome(this.store.projects(), this.ctx(req, res)), Date.now());
|
|
379
|
+
}));
|
|
380
|
+
|
|
381
|
+
// Every page with a form has this.session, this.csrf; every form post has sameOrigin,
|
|
382
|
+
// this.session, form, this.csrf. They are written out in each route rather than spread
|
|
383
|
+
// from an array, so that CodeQL can see the CSRF middleware on every cookie-using route.
|
|
384
|
+
r.get('/login', this.session, this.csrf, h('login', (req, res) => this.sendLogin(req, res, 200)));
|
|
385
|
+
r.post('/login', this.sameOrigin, loginLimiter, this.session, this.form, this.csrf, h('login', (req, res) => this.login(req, res)));
|
|
386
|
+
r.post('/logout', this.sameOrigin, this.session, this.form, this.csrf, h('logout', (req, res) => {
|
|
387
|
+
req.session.destroy(() => res.redirect(req.baseUrl || '/'));
|
|
388
|
+
}));
|
|
389
|
+
|
|
390
|
+
r.get('/admin/projects', this.session, this.csrf, admin, h('projects', (req, res) => this.sendProjects(req, res)));
|
|
391
|
+
r.post('/admin/projects', this.sameOrigin, this.session, this.form, this.csrf, admin, h('projects', (req, res) => this.saveProject(req, res, null)));
|
|
392
|
+
r.post('/admin/projects/:id', this.sameOrigin, this.session, this.form, this.csrf, admin, h('projects', (req, res) => this.saveProject(req, res, req.params.id)));
|
|
393
|
+
r.post('/admin/projects/:id/delete', this.sameOrigin, this.session, this.form, this.csrf, admin, h('projects', (req, res) => {
|
|
394
|
+
const p = this.store.projectById(parseInt(req.params.id, 10));
|
|
395
|
+
if (p && this.store.deleteProject(p.id)) {
|
|
396
|
+
this.log.info(`IPS module: project '${p.code}' deleted by the administrator`);
|
|
397
|
+
}
|
|
398
|
+
res.redirect(req.baseUrl + '/admin/projects');
|
|
399
|
+
}));
|
|
400
|
+
|
|
401
|
+
r.get('/admin/users', this.session, this.csrf, admin, h('users', (req, res) => this.sendUsers(req, res)));
|
|
402
|
+
r.post('/admin/users', this.sameOrigin, this.session, this.form, this.csrf, admin, h('users', (req, res) => this.saveUser(req, res, null)));
|
|
403
|
+
r.post('/admin/users/:id', this.sameOrigin, this.session, this.form, this.csrf, admin, h('users', (req, res) => this.saveUser(req, res, req.params.id)));
|
|
404
|
+
r.post('/admin/users/:id/delete', this.sameOrigin, this.session, this.form, this.csrf, admin, h('users', (req, res) => {
|
|
405
|
+
if (this.store.deleteUser(parseInt(req.params.id, 10))) {
|
|
406
|
+
this.log.info(`IPS module: user ${req.params.id} deleted`);
|
|
407
|
+
}
|
|
408
|
+
res.redirect(req.baseUrl + '/admin/users');
|
|
409
|
+
}));
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
/**
|
|
413
|
+
* The clinical profiles, across all projects: everyone can see them, only the
|
|
414
|
+
* administrator changes them. Before the project routes, so /StructureDefinition and
|
|
415
|
+
* /lookup aren't taken for project codes.
|
|
416
|
+
*/
|
|
417
|
+
setupProfileRoutes() {
|
|
418
|
+
const r = this.router;
|
|
419
|
+
const h = (name, fn) => (req, res) => this.handle(req, res, name, () => fn(req, res));
|
|
420
|
+
const rl = this.config.rateLimit || {};
|
|
421
|
+
// the editors' code lookups: many small requests, their own allowance
|
|
422
|
+
const lookupMax = rl.lookupMax === undefined ? 600 : rl.lookupMax;
|
|
423
|
+
this.lookups = lookupMax > 0 ? [rateLimit({
|
|
424
|
+
windowMs: (rl.windowMinutes || 1) * 60 * 1000,
|
|
425
|
+
limit: lookupMax,
|
|
426
|
+
standardHeaders: 'draft-7',
|
|
427
|
+
legacyHeaders: false,
|
|
428
|
+
handler: (req, res) => res.status(429).json({ error: 'Too many lookups; try again later' })
|
|
429
|
+
})] : [];
|
|
430
|
+
const admin = this.adminOnly;
|
|
431
|
+
// the administrator, for the editor's fetches: JSON, not a redirect to the login page
|
|
432
|
+
const adminJson = (req, res, next) => {
|
|
433
|
+
const user = this.currentUser(req);
|
|
434
|
+
if (!user || !user.isAdmin) {
|
|
435
|
+
return res.status(user ? 403 : 401).json({ problems: [user ? 'Only the administrator can change clinical profiles' : 'You are not logged in (or your session has expired)'] });
|
|
436
|
+
}
|
|
437
|
+
req.user = user;
|
|
438
|
+
next();
|
|
439
|
+
};
|
|
440
|
+
const json = express.json({ limit: '2mb' });
|
|
441
|
+
const profile = (req, res, next) => {
|
|
442
|
+
const m = /^profile-([a-f0-9]{12})$/.exec(req.params.id);
|
|
443
|
+
const sd = m ? this.store.profile(m[1]) : null;
|
|
444
|
+
if (!sd) {
|
|
445
|
+
return wantsHtml(req)
|
|
446
|
+
? htmlServer.sendErrorResponse(res, TEMPLATE, new Error(`There is no clinical profile '${req.params.id}'`), 404)
|
|
447
|
+
: this.sendOutcome(res, 404, 'not-found', `There is no clinical profile '${req.params.id}'`);
|
|
448
|
+
}
|
|
449
|
+
req.profileId = m[1];
|
|
450
|
+
req.profile = sd;
|
|
451
|
+
next();
|
|
452
|
+
};
|
|
453
|
+
|
|
454
|
+
r.get('/profile-editor.js', script('profile-editor.js'));
|
|
455
|
+
r.get('/lookup', this.session, adminJson, ...this.lookups, h('lookup', (req, res) => this.systemLookup(req, res)));
|
|
456
|
+
r.get('/lookup-vs', this.session, adminJson, ...this.lookups, h('lookup', (req, res) => this.valueSetLookup(req, res)));
|
|
457
|
+
r.get('/StructureDefinition', ...this.web, h('profiles', (req, res) => this.profileList(req, res)));
|
|
458
|
+
r.get('/StructureDefinition/new', this.session, this.csrf, admin, h('profiles', (req, res) => this.profileEditor(req, res, null)));
|
|
459
|
+
r.post('/StructureDefinition', this.sameOrigin, this.session, json, this.csrf, adminJson, h('profiles', (req, res) => this.profileSave(req, res, null)));
|
|
460
|
+
r.get('/StructureDefinition/:id', profile, ...this.web, h('profile', (req, res) => this.profileRead(req, res)));
|
|
461
|
+
r.get('/StructureDefinition/:id/edit', profile, this.session, this.csrf, admin, h('profile', (req, res) => this.profileEditor(req, res, req.profile)));
|
|
462
|
+
r.post('/StructureDefinition/:id', profile, this.sameOrigin, this.session, json, this.csrf, adminJson, h('profile', (req, res) => this.profileSave(req, res, req.profile)));
|
|
463
|
+
r.post('/StructureDefinition/:id/delete', profile, this.sameOrigin, this.session, this.form, this.csrf, admin, h('profile', (req, res) => {
|
|
464
|
+
this.store.deleteProfile(req.profileId);
|
|
465
|
+
res.redirect(req.baseUrl + '/StructureDefinition');
|
|
466
|
+
}));
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
setupProjectRoutes() {
|
|
470
|
+
const r = this.router;
|
|
471
|
+
const h = (name, fn) => (req, res) => this.handle(req, res, name, () => fn(req, res));
|
|
472
|
+
const rl = this.config.rateLimit || {};
|
|
473
|
+
const max = rl.max === undefined ? 60 : rl.max;
|
|
474
|
+
const posting = [];
|
|
475
|
+
if (max > 0) {
|
|
476
|
+
posting.push(rateLimit({
|
|
477
|
+
windowMs: (rl.windowMinutes || 1) * 60 * 1000,
|
|
478
|
+
limit: max,
|
|
479
|
+
standardHeaders: 'draft-7',
|
|
480
|
+
legacyHeaders: false,
|
|
481
|
+
handler: (req, res) => wantsHtml(req)
|
|
482
|
+
? htmlServer.sendErrorResponse(res, TEMPLATE, new Error('Too many documents submitted; try again later'), 429)
|
|
483
|
+
: this.sendOutcome(res, 429, 'throttled', 'Too many documents submitted; try again later')
|
|
484
|
+
}));
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
// the project, from its code; 404 if there isn't one
|
|
488
|
+
const project = (req, res, next) => {
|
|
489
|
+
const code = req.params.code;
|
|
490
|
+
const p = CODE_RE.test(code) && !RESERVED.has(code) ? this.store.projectByCode(code) : null;
|
|
491
|
+
if (!p) {
|
|
492
|
+
return wantsHtml(req)
|
|
493
|
+
? htmlServer.sendErrorResponse(res, TEMPLATE, new Error(`There is no project '${code}'`), 404)
|
|
494
|
+
: this.sendOutcome(res, 404, 'not-found', `There is no project '${code}'`);
|
|
495
|
+
}
|
|
496
|
+
req.project = p;
|
|
497
|
+
next();
|
|
498
|
+
};
|
|
499
|
+
// a manager of the project, or the administrator
|
|
500
|
+
const manager = (req, res, next) => {
|
|
501
|
+
const user = this.currentUser(req);
|
|
502
|
+
if (!user) {
|
|
503
|
+
return res.redirect(req.baseUrl + '/login');
|
|
504
|
+
}
|
|
505
|
+
if (!this.canManage(user, req.project)) {
|
|
506
|
+
return htmlServer.sendErrorResponse(res, TEMPLATE, new Error('You do not manage this project'), 403);
|
|
507
|
+
}
|
|
508
|
+
req.user = user;
|
|
509
|
+
next();
|
|
510
|
+
};
|
|
511
|
+
|
|
512
|
+
r.get('/editor.js', script('editor.js'));
|
|
513
|
+
r.get('/:code', project, ...this.web, h('list', (req, res) => this.htmlList(req, res)));
|
|
514
|
+
r.get('/:code/metadata', project, h('metadata', (req, res) => this.metadata(req, res)));
|
|
515
|
+
// The submission form has no CSRF token and starts no session: submitting is open to
|
|
516
|
+
// anyone (or needs the project's token, which is in the form itself), so there is no
|
|
517
|
+
// credential for a cross-site post to ride on, and a token for every visitor would give
|
|
518
|
+
// every crawler a session. The Origin check still applies to the post.
|
|
519
|
+
r.get('/:code/submit', project, ...this.web, h('submit', (req, res) => this.sendSubmit(req, res)));
|
|
520
|
+
// the editor: the page, its script (served before the project routes catch the name),
|
|
521
|
+
// the code lookups it makes, and what it posts
|
|
522
|
+
r.get('/:code/compose', project, ...this.web, h('compose', (req, res) => this.sendCompose(req, res)));
|
|
523
|
+
r.get('/:code/lookup', project, ...this.lookups, h('lookup', (req, res) => this.lookup(req, res)));
|
|
524
|
+
r.post('/:code/compose', project, this.sameOrigin, ...posting, this.session, express.json({ limit: '2mb' }), h('compose', (req, res) => this.compose(req, res)));
|
|
525
|
+
r.post('/:code/submit', project, this.sameOrigin, ...posting, this.session, this.bigForm, h('submit', (req, res) => this.submitForm(req, res)));
|
|
526
|
+
r.get('/:code/settings', project, this.session, this.csrf, manager, h('settings', (req, res) => this.sendSettings(req, res)));
|
|
527
|
+
r.post('/:code/settings', project, this.sameOrigin, this.session, this.form, this.csrf, manager, h('settings', (req, res) => this.saveSettings(req, res)));
|
|
528
|
+
|
|
529
|
+
// the token before the body: a submission without it is refused before anything is read,
|
|
530
|
+
// and the body is read no bigger than maxSize (the server's own parsers leave /ips alone)
|
|
531
|
+
const submitter = (req, res, next) => {
|
|
532
|
+
if (tokenConfigured(req.project.token) && !tokenMatches(req.project.token, this.suppliedToken(req))) {
|
|
533
|
+
res.set('WWW-Authenticate', 'Bearer');
|
|
534
|
+
return this.sendOutcome(res, 401, 'login', 'This project requires its submission token in the Authorization header');
|
|
535
|
+
}
|
|
536
|
+
next();
|
|
537
|
+
};
|
|
538
|
+
const documentBody = express.raw({ type: ['application/fhir+json', 'application/json'], limit: this.maxSize });
|
|
539
|
+
r.post('/:code/Bundle', project, ...posting, submitter, documentBody, h('create', (req, res) => this.create(req, res)));
|
|
540
|
+
r.get('/:code/Bundle', project, ...this.web, h('search', (req, res) =>
|
|
541
|
+
wantsHtml(req) ? this.htmlList(req, res) : this.search(req, res)));
|
|
542
|
+
r.get('/:code/Bundle/:id', project, ...this.web, h('read', (req, res) => this.read(req, res)));
|
|
543
|
+
// sharing: anyone who can read the document (same origin, and rate limited as
|
|
544
|
+
// submissions are, since each share stores a copy on the SHL server)
|
|
545
|
+
r.post('/:code/Bundle/:id/share', project, this.sameOrigin, ...posting, this.form, h('share', (req, res) => this.share(req, res)));
|
|
546
|
+
r.post('/:code/Bundle/:id/delete', project, this.sameOrigin, this.session, this.form, this.csrf, manager, h('delete', (req, res) => {
|
|
547
|
+
const id = docId(req.params.id);
|
|
548
|
+
if (!this.store.delete(id, req.project.id)) {
|
|
549
|
+
return htmlServer.sendErrorResponse(res, TEMPLATE, new Error(`No document with id '${req.params.id}'`), 404);
|
|
550
|
+
}
|
|
551
|
+
this.log.info(`IPS module: document ${id} in '${req.project.code}' deleted by ${req.user.name}`);
|
|
552
|
+
res.redirect(`${req.baseUrl}/${encodeURIComponent(req.project.code)}`);
|
|
553
|
+
}));
|
|
554
|
+
r.post('/:code/Bundle/:id/requeue', project, this.sameOrigin, this.session, this.form, this.csrf, manager, h('requeue', (req, res) => {
|
|
555
|
+
const id = docId(req.params.id);
|
|
556
|
+
const done = this.store.requeue(id, req.project.id);
|
|
557
|
+
if (!done) {
|
|
558
|
+
return htmlServer.sendErrorResponse(res, TEMPLATE, new Error(`No document with id '${req.params.id}'`), 404);
|
|
559
|
+
}
|
|
560
|
+
if (done === 'processing') {
|
|
561
|
+
return htmlServer.sendErrorResponse(res, TEMPLATE, new Error(`Document ${id} is being processed now; it can be queued again when that is done`), 409);
|
|
562
|
+
}
|
|
563
|
+
res.redirect(`${req.baseUrl}/${encodeURIComponent(req.project.code)}/Bundle/${id}`);
|
|
564
|
+
}));
|
|
565
|
+
|
|
566
|
+
// lusca rejects a form post with a missing or wrong token by passing on an error
|
|
567
|
+
// ("CSRF token missing" / "CSRF token mismatch"); anything else goes on as it was
|
|
568
|
+
r.use((err, req, res, next) => {
|
|
569
|
+
if (err && typeof err.message === 'string' && err.message.startsWith('CSRF token')) {
|
|
570
|
+
return htmlServer.sendErrorResponse(res, TEMPLATE,
|
|
571
|
+
new Error('The form has expired or did not come from this site - reload the page and try again'), 403);
|
|
572
|
+
}
|
|
573
|
+
// the body parsers': too big, or not what it claims to be - said in the route's own terms
|
|
574
|
+
if (err && (err.type === 'entity.too.large' || err.type === 'entity.parse.failed' || err.type === 'encoding.unsupported' || err.type === 'charset.unsupported')) {
|
|
575
|
+
const status = err.type === 'entity.too.large' ? 413 : (err.status || 400);
|
|
576
|
+
const message = err.type === 'entity.too.large' ? `The request is too big (the limit is ${err.limit} bytes)` : 'The body could not be read: ' + err.message;
|
|
577
|
+
if (/\/Bundle$/.test(req.path)) {
|
|
578
|
+
return this.sendOutcome(res, status, status === 413 ? 'too-costly' : 'structure', status === 413 ? `Documents can't be bigger than ${this.maxSize} bytes` : message);
|
|
579
|
+
}
|
|
580
|
+
if (!wantsHtml(req)) {
|
|
581
|
+
return res.status(status).json({ error: message, problems: [message] });
|
|
582
|
+
}
|
|
583
|
+
return htmlServer.sendErrorResponse(res, TEMPLATE, new Error(message), status);
|
|
584
|
+
}
|
|
585
|
+
next(err);
|
|
586
|
+
});
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
// ---- the processing queue ----
|
|
590
|
+
|
|
591
|
+
/**
|
|
592
|
+
* Start the FHIR validator, if it is configured, as the SHL module does: the validator
|
|
593
|
+
* CLI jar in the data folder's bin directory, run as a service on its own port, with
|
|
594
|
+
* the IPS package loaded. It is started in the background; until it is up, documents
|
|
595
|
+
* wait in the queue.
|
|
596
|
+
*/
|
|
597
|
+
setupValidator() {
|
|
598
|
+
const cfg = this.config.validator;
|
|
599
|
+
if (!cfg || cfg.enabled === false) {
|
|
600
|
+
this.log.warn('IPS module: no validator configured (modules.ips.validator) - documents will not be validated');
|
|
601
|
+
return;
|
|
602
|
+
}
|
|
603
|
+
const FhirValidator = require('fhir-validator-wrapper');
|
|
604
|
+
const jar = folders.filePath('bin', 'validator_cli.jar');
|
|
605
|
+
this.validator = new FhirValidator(jar, this.log);
|
|
606
|
+
const settings = {
|
|
607
|
+
version: cfg.version || '4.0.1',
|
|
608
|
+
txServer: cfg.txServer || 'http://tx.fhir.org/r4',
|
|
609
|
+
txLog: cfg.txLog || folders.filePath('logs', 'ips-validator-tx.log'),
|
|
610
|
+
port: cfg.port || 8082,
|
|
611
|
+
igs: Array.isArray(cfg.packages) ? cfg.packages : ['hl7.fhir.uv.ips#1.1.0'],
|
|
612
|
+
timeout: cfg.timeout || 120000
|
|
613
|
+
};
|
|
614
|
+
this.log.info(`IPS module: starting the FHIR validator on port ${settings.port} (tx ${settings.txServer}; ${settings.igs.join(', ')})`);
|
|
615
|
+
this.validatorStarting = this.validator.start(settings).then(() => {
|
|
616
|
+
this.log.info('IPS module: the FHIR validator is running');
|
|
617
|
+
}).catch(e => {
|
|
618
|
+
this.log.error('IPS module: the FHIR validator failed to start: ' + e.message);
|
|
619
|
+
this.validatorError = e.message;
|
|
620
|
+
}).finally(() => {
|
|
621
|
+
this.validatorStarting = null;
|
|
622
|
+
});
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
/** Read the value sets the terminology check holds documents to: the module's own, in ips/valuesets/. */
|
|
626
|
+
setupValueSets() {
|
|
627
|
+
try {
|
|
628
|
+
this.valueSets = new IpsValueSets().load();
|
|
629
|
+
this.log.info(`IPS module: ${this.valueSets.valueSets.size} value sets for ${this.valueSets.elements.size} elements read for the terminology check`);
|
|
630
|
+
} catch (e) {
|
|
631
|
+
this.valueSets = null;
|
|
632
|
+
this.log.error(`IPS module: could not read the value sets for the terminology check: ${e.message}`);
|
|
633
|
+
}
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
/**
|
|
637
|
+
* The AI account a project's assessments use: the project's own, when it has a key, else
|
|
638
|
+
* the server's default (modules.ips.ai), else null.
|
|
639
|
+
* @returns {{account: Object, source: 'project'|'server'}|null}
|
|
640
|
+
*/
|
|
641
|
+
aiAccountFor(project) {
|
|
642
|
+
const own = this.store.projectAi(project);
|
|
643
|
+
if (own.keyEnc) {
|
|
644
|
+
const apiKey = this.aiSecret ? aiClient.decryptKey(own.keyEnc, this.aiSecret) : null;
|
|
645
|
+
if (apiKey) {
|
|
646
|
+
return { account: { provider: own.provider, baseUrl: own.baseUrl || '', model: own.model, apiKey }, source: 'project' };
|
|
647
|
+
}
|
|
648
|
+
this.log.warn(`IPS module: the AI key for '${project.code}' cannot be read (the key secret has changed?) - using the server's default, if any`);
|
|
649
|
+
}
|
|
650
|
+
const cfg = this.config.ai;
|
|
651
|
+
if (cfg && typeof cfg.apiKey === 'string' && cfg.apiKey && typeof cfg.model === 'string') {
|
|
652
|
+
return { account: { provider: cfg.provider || 'openai', baseUrl: cfg.baseUrl || '', model: cfg.model, apiKey: cfg.apiKey }, source: 'server' };
|
|
653
|
+
}
|
|
654
|
+
return null;
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
/**
|
|
658
|
+
* Whether the pipeline can run now: not while the validator is still starting, nor -
|
|
659
|
+
* for a while after start-up - while a terminology server on this server isn't there
|
|
660
|
+
* yet (the tx module loads after this one, and can take a while).
|
|
661
|
+
*/
|
|
662
|
+
pipelineReady() {
|
|
663
|
+
if (this.validatorStarting) {
|
|
664
|
+
return false;
|
|
665
|
+
}
|
|
666
|
+
return !(this.txMissing && Date.now() - this.startedAt < TX_STARTUP_GRACE_MS);
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
/**
|
|
670
|
+
* Is the terminology server there? For a path on this server, that means the tx module
|
|
671
|
+
* answers at it (its endpoints are configured separately, as modules.tx.endpoints[].path,
|
|
672
|
+
* and /tx/r4 is only an example). The tx module is set up after this one and can take
|
|
673
|
+
* a while to load, so a miss is looked at again (at most every TX_RECHECK_MS) until it
|
|
674
|
+
* answers. A miss is logged once, and its arrival after that, so a wrong path is found
|
|
675
|
+
* in the log rather than as a question mark on every concept.
|
|
676
|
+
*/
|
|
677
|
+
async checkTxServer() {
|
|
678
|
+
if (!this.tx || !this.tx.internal) {
|
|
679
|
+
return;
|
|
680
|
+
}
|
|
681
|
+
// a check in progress is shared - even inside the recheck interval
|
|
682
|
+
if (this.txChecking) {
|
|
683
|
+
return this.txChecking;
|
|
684
|
+
}
|
|
685
|
+
if (this.txChecked === true || (this.txCheckedAt && Date.now() - this.txCheckedAt < TX_RECHECK_MS)) {
|
|
686
|
+
return;
|
|
687
|
+
}
|
|
688
|
+
this.txChecking = (async () => {
|
|
689
|
+
this.txCheckedAt = Date.now();
|
|
690
|
+
// anything but a CapabilityStatement is "not there yet": a 404 while the tx module
|
|
691
|
+
// hasn't mounted its endpoints, or a 5xx or no answer while it loads
|
|
692
|
+
let why = null;
|
|
693
|
+
try {
|
|
694
|
+
const r = await this.tx.get('/metadata');
|
|
695
|
+
if (r.status !== 200 || r.resource?.resourceType !== 'CapabilityStatement') {
|
|
696
|
+
why = r.status === 404 ? 'nothing answers' : `it answered ${r.status} to /metadata`;
|
|
697
|
+
}
|
|
698
|
+
} catch (e) {
|
|
699
|
+
why = `it could not be reached (${e.message})`;
|
|
700
|
+
}
|
|
701
|
+
try {
|
|
702
|
+
if (why) {
|
|
703
|
+
if (!this.txMissingWarned) {
|
|
704
|
+
this.txMissingWarned = true;
|
|
705
|
+
this.log.warn(`IPS module: the terminology server ${this.tx.url} on this server is not there (yet): ${why} - ` +
|
|
706
|
+
'is the tx module enabled, and is modules.ips.txServer one of its endpoint paths (modules.tx.endpoints[].path)? ' +
|
|
707
|
+
'Looking again until it is');
|
|
708
|
+
}
|
|
709
|
+
this.txMissing = true;
|
|
710
|
+
if (!this.txMissingLogged && Date.now() - this.startedAt >= TX_STARTUP_GRACE_MS) {
|
|
711
|
+
this.txMissingLogged = true;
|
|
712
|
+
this.log.error(`IPS module: still no terminology server at ${this.tx.url} on this server (${why}); documents are processed without it`);
|
|
713
|
+
}
|
|
714
|
+
return;
|
|
715
|
+
}
|
|
716
|
+
if (this.txMissingWarned) {
|
|
717
|
+
this.log.info(`IPS module: the terminology server ${this.tx.url} is there now`);
|
|
718
|
+
}
|
|
719
|
+
this.txMissing = false;
|
|
720
|
+
this.txChecked = true;
|
|
721
|
+
} finally {
|
|
722
|
+
this.txChecking = null;
|
|
723
|
+
}
|
|
724
|
+
})();
|
|
725
|
+
return this.txChecking;
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
setupQueue() {
|
|
729
|
+
const reset = this.store.resetProcessing();
|
|
730
|
+
if (reset > 0) {
|
|
731
|
+
this.log.info(`IPS module: ${reset} documents were being processed at the last shutdown; queued again`);
|
|
732
|
+
}
|
|
733
|
+
const cfg = this.config.processing || {};
|
|
734
|
+
if (cfg.enabled === false) {
|
|
735
|
+
return;
|
|
736
|
+
}
|
|
737
|
+
const seconds = typeof cfg.intervalSeconds === 'number' && cfg.intervalSeconds > 0 ? cfg.intervalSeconds : DEFAULT_INTERVAL_SECONDS;
|
|
738
|
+
if (this.stats) {
|
|
739
|
+
this.stats.addTask('IPS processing', `every ${seconds}s`);
|
|
740
|
+
}
|
|
741
|
+
this.queueTimer = setInterval(() => { this.processQueue().catch(e => this.log.error('IPS module: queue run failed: ' + e.message)); }, seconds * 1000);
|
|
742
|
+
this.queueTimer.unref();
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
/**
|
|
746
|
+
* Work through everything that is waiting. One document at a time, and never two
|
|
747
|
+
* runs at once; a run that finds nothing is free.
|
|
748
|
+
* @returns {Promise<number>} how many documents were processed
|
|
749
|
+
*/
|
|
750
|
+
async processQueue() {
|
|
751
|
+
if (this.processing || !this.store) {
|
|
752
|
+
return 0;
|
|
753
|
+
}
|
|
754
|
+
// claimed before anything is awaited, so two timer ticks can never both get past here
|
|
755
|
+
this.processing = true;
|
|
756
|
+
let n = 0;
|
|
757
|
+
try {
|
|
758
|
+
await this.checkTxServer();
|
|
759
|
+
if (!this.pipelineReady()) {
|
|
760
|
+
return 0;
|
|
761
|
+
}
|
|
762
|
+
for (;;) {
|
|
763
|
+
const row = this.store.claimNext();
|
|
764
|
+
if (!row) {
|
|
765
|
+
break;
|
|
766
|
+
}
|
|
767
|
+
n++;
|
|
768
|
+
const project = this.store.projectById(row.project_id);
|
|
769
|
+
try {
|
|
770
|
+
const note = await this.processDocument(row, project);
|
|
771
|
+
this.store.finish(row.id, true, note || null);
|
|
772
|
+
} catch (e) {
|
|
773
|
+
this.log.error(`IPS module: processing document ${row.id} failed: ${e.stack || e.message}`);
|
|
774
|
+
this.store.finish(row.id, false, e.message);
|
|
775
|
+
if (this.stats) {
|
|
776
|
+
this.stats.taskError('IPS processing', `document ${row.id}: ${e.message}`);
|
|
777
|
+
}
|
|
778
|
+
}
|
|
779
|
+
}
|
|
780
|
+
if (n > 0 && this.stats) {
|
|
781
|
+
this.stats.taskDone('IPS processing', `${n} processed`);
|
|
782
|
+
}
|
|
783
|
+
} finally {
|
|
784
|
+
this.processing = false;
|
|
785
|
+
}
|
|
786
|
+
return n;
|
|
787
|
+
}
|
|
788
|
+
|
|
789
|
+
/**
|
|
790
|
+
* Process one document: run the pipeline's steps over it, in order. A step that throws
|
|
791
|
+
* stops the pipeline, and its message becomes the document's.
|
|
792
|
+
*
|
|
793
|
+
* @param {Object} row - from store.read(): the columns and .bundle
|
|
794
|
+
* @param {Object} project
|
|
795
|
+
* @returns {Promise<string|null>} a note for the status: the steps' notes
|
|
796
|
+
*/
|
|
797
|
+
async processDocument(row, project) {
|
|
798
|
+
const notes = [];
|
|
799
|
+
// the analysed copy starts as the master, and the steps adorn it; whatever they managed
|
|
800
|
+
// is kept, even when one of them fails
|
|
801
|
+
row.analysed = stripChecks(structuredClone(row.bundle));
|
|
802
|
+
this.store.clearResults(row.id);
|
|
803
|
+
for (const step of this.steps) {
|
|
804
|
+
this.store.saveStepStatus(row.id, step.name, 'waiting');
|
|
805
|
+
}
|
|
806
|
+
try {
|
|
807
|
+
for (const step of this.steps) {
|
|
808
|
+
this.store.saveStepStatus(row.id, step.name, 'running');
|
|
809
|
+
this.log.info(`IPS module: document ${row.id} ('${project.code}'): ${step.name} starting`);
|
|
810
|
+
const started = Date.now();
|
|
811
|
+
let note;
|
|
812
|
+
try {
|
|
813
|
+
note = await step.run(row, project, this);
|
|
814
|
+
} catch (e) {
|
|
815
|
+
this.store.saveStepStatus(row.id, step.name, 'failed', e.message);
|
|
816
|
+
this.log.error(`IPS module: document ${row.id}: ${step.name} failed after ${Date.now() - started} ms: ${e.message}`);
|
|
817
|
+
throw new Error(`${step.name}: ${e.message}`);
|
|
818
|
+
}
|
|
819
|
+
this.store.saveStepStatus(row.id, step.name, 'done', typeof note === 'string' ? note : null);
|
|
820
|
+
this.log.info(`IPS module: document ${row.id}: ${step.name} done in ${Date.now() - started} ms${typeof note === 'string' && note ? ` - ${note}` : ''}`);
|
|
821
|
+
if (typeof note === 'string' && note) {
|
|
822
|
+
notes.push(note);
|
|
823
|
+
}
|
|
824
|
+
}
|
|
825
|
+
} finally {
|
|
826
|
+
this.store.saveAnalysed(row.id, row.analysed);
|
|
827
|
+
}
|
|
828
|
+
return notes.length > 0 ? notes.join('; ') : null;
|
|
829
|
+
}
|
|
830
|
+
|
|
831
|
+
/**
|
|
832
|
+
* The paragraph for the server's daily report: pages served, documents
|
|
833
|
+
* received in the period, and how many are stored.
|
|
834
|
+
*/
|
|
835
|
+
dailyReport({since, now}) {
|
|
836
|
+
const {plural} = require('../library/daily-report');
|
|
837
|
+
const sentences = [];
|
|
838
|
+
if (this.stats) {
|
|
839
|
+
const counts = this.stats.countsSince(since, now);
|
|
840
|
+
sentences.push(`${plural(counts.total, 'page')} ${counts.total === 1 ? 'was' : 'were'} served.`);
|
|
841
|
+
}
|
|
842
|
+
if (this.store) {
|
|
843
|
+
const received = this.store.countReceived(since, now);
|
|
844
|
+
const stored = this.store.count();
|
|
845
|
+
sentences.push(`${plural(received, 'IPS document')} ${received === 1 ? 'was' : 'were'} received; ${plural(stored, 'document')} ${stored === 1 ? 'is' : 'are'} stored.`);
|
|
846
|
+
} else {
|
|
847
|
+
sentences.push('The document store is not open.');
|
|
848
|
+
}
|
|
849
|
+
return sentences.join(' ');
|
|
850
|
+
}
|
|
851
|
+
|
|
852
|
+
async shutdown() {
|
|
853
|
+
if (this.queueTimer) {
|
|
854
|
+
clearInterval(this.queueTimer);
|
|
855
|
+
this.queueTimer = null;
|
|
856
|
+
}
|
|
857
|
+
if (this.validator) {
|
|
858
|
+
try {
|
|
859
|
+
if (this.validatorStarting) {
|
|
860
|
+
await this.validatorStarting;
|
|
861
|
+
}
|
|
862
|
+
await this.validator.stop();
|
|
863
|
+
} catch (e) {
|
|
864
|
+
this.log.error('IPS module: stopping the validator failed: ' + e.message);
|
|
865
|
+
}
|
|
866
|
+
this.validator = null;
|
|
867
|
+
}
|
|
868
|
+
if (this.store) {
|
|
869
|
+
this.store.close();
|
|
870
|
+
this.store = null;
|
|
871
|
+
}
|
|
872
|
+
}
|
|
873
|
+
|
|
874
|
+
// ---- plumbing ----
|
|
875
|
+
|
|
876
|
+
async handle(req, res, name, fn) {
|
|
877
|
+
const start = Date.now();
|
|
878
|
+
try {
|
|
879
|
+
await fn();
|
|
880
|
+
} catch (e) {
|
|
881
|
+
this.log.error(`IPS module: ${name} failed: ${e.stack || e.message}`);
|
|
882
|
+
if (!res.headersSent) {
|
|
883
|
+
// the detail is in the log; the response doesn't say what broke inside
|
|
884
|
+
if (wantsHtml(req)) {
|
|
885
|
+
htmlServer.sendErrorResponse(res, TEMPLATE, new Error('Something went wrong handling the request'));
|
|
886
|
+
} else {
|
|
887
|
+
this.sendOutcome(res, 500, 'exception', 'Internal error');
|
|
888
|
+
}
|
|
889
|
+
}
|
|
890
|
+
} finally {
|
|
891
|
+
if (this.stats) {
|
|
892
|
+
this.stats.countRequest(name, Date.now() - start);
|
|
893
|
+
}
|
|
894
|
+
}
|
|
895
|
+
}
|
|
896
|
+
|
|
897
|
+
baseUrl(req) {
|
|
898
|
+
return `${req.protocol}://${req.get('host')}${req.baseUrl}`;
|
|
899
|
+
}
|
|
900
|
+
|
|
901
|
+
projectUrl(req) {
|
|
902
|
+
return `${this.baseUrl(req)}/${encodeURIComponent(req.project.code)}`;
|
|
903
|
+
}
|
|
904
|
+
|
|
905
|
+
sendJson(res, status, body) {
|
|
906
|
+
res.status(status).set('Content-Type', FHIR_JSON).send(JSON.stringify(body, null, 2));
|
|
907
|
+
}
|
|
908
|
+
|
|
909
|
+
sendOutcome(res, status, code, messages) {
|
|
910
|
+
const severity = status < 400 ? 'information' : 'error';
|
|
911
|
+
this.sendJson(res, status, operationOutcome(severity, code, messages));
|
|
912
|
+
}
|
|
913
|
+
|
|
914
|
+
/**
|
|
915
|
+
* @param {Object} [req] - when given and the request is for a project, the nav bar
|
|
916
|
+
* gets a link to the project
|
|
917
|
+
*/
|
|
918
|
+
sendHtml(res, title, content, start, req) {
|
|
919
|
+
const p = req && req.project;
|
|
920
|
+
htmlServer.sendHtmlResponse(res, TEMPLATE, title, content, {
|
|
921
|
+
version: packageJson.version,
|
|
922
|
+
processingTime: Date.now() - start,
|
|
923
|
+
contextLink: p ? ` | <a href="${escape(req.baseUrl)}/${encodeURIComponent(p.code)}" style="color: gold">${escape(p.name)}</a>` : ''
|
|
924
|
+
});
|
|
925
|
+
}
|
|
926
|
+
|
|
927
|
+
/** What the renderers need to know about the request. */
|
|
928
|
+
ctx(req, res) {
|
|
929
|
+
const user = this.currentUser(req);
|
|
930
|
+
return {
|
|
931
|
+
base: req.baseUrl,
|
|
932
|
+
user,
|
|
933
|
+
csrf: res.locals._csrf,
|
|
934
|
+
canManage: req.project ? this.canManage(user, req.project) : false,
|
|
935
|
+
canShare: !!this.shl,
|
|
936
|
+
codeSystemLink: this.tx ? (system) => this.tx.codeSystemLink(system) : null
|
|
937
|
+
};
|
|
938
|
+
}
|
|
939
|
+
|
|
940
|
+
// ---- sharing ----
|
|
941
|
+
|
|
942
|
+
/**
|
|
943
|
+
* Share a document as an SHL or VHL QR code. The browser asks with fetch (JSON back);
|
|
944
|
+
* without scripts, the form posts and gets a page.
|
|
945
|
+
*/
|
|
946
|
+
async share(req, res) {
|
|
947
|
+
const json = !wantsHtml(req) || /json/.test(req.get('Accept') || '');
|
|
948
|
+
const fail = (status, message) => (json
|
|
949
|
+
? res.status(status).json({ error: message })
|
|
950
|
+
: htmlServer.sendErrorResponse(res, TEMPLATE, new Error(message), status));
|
|
951
|
+
if (!this.shl) {
|
|
952
|
+
return fail(404, 'Sharing is not configured on this server (modules.ips.shl)');
|
|
953
|
+
}
|
|
954
|
+
const id = docId(req.params.id);
|
|
955
|
+
const row = this.store.read(id, req.project.id);
|
|
956
|
+
if (!row) {
|
|
957
|
+
return fail(404, `No document with id '${req.params.id}'`);
|
|
958
|
+
}
|
|
959
|
+
const kind = req.body.kind === 'vhl' ? 'vhl' : 'shl';
|
|
960
|
+
const label = row.patient_name ? `Patient Health Document for ${row.patient_name}` : 'Patient Health Document';
|
|
961
|
+
let out;
|
|
962
|
+
try {
|
|
963
|
+
out = await this.shl.share(row.bundle, { kind, host: req.get('host'), label });
|
|
964
|
+
} catch (e) {
|
|
965
|
+
this.log.error(`IPS module: document ${id} could not be shared as ${kind}: ${e.message}`);
|
|
966
|
+
return fail(502, e.message);
|
|
967
|
+
}
|
|
968
|
+
this.log.info(`IPS module: document ${id} in '${req.project.code}' shared as ${kind.toUpperCase()} (${out.bytes} bytes, until ${out.expires})`);
|
|
969
|
+
if (json) {
|
|
970
|
+
return res.json(out);
|
|
971
|
+
}
|
|
972
|
+
this.sendHtml(res, `${req.project.name}: Document ${id} - ${kind.toUpperCase()}`,
|
|
973
|
+
render.renderShare(req.project, row, out, this.ctx(req, res)), Date.now(), req);
|
|
974
|
+
}
|
|
975
|
+
|
|
976
|
+
// ---- users ----
|
|
977
|
+
|
|
978
|
+
/**
|
|
979
|
+
* Who is logged in, with the projects they manage as they are now (not as they were at
|
|
980
|
+
* login, so taking a project away, or deleting the user, takes effect at once).
|
|
981
|
+
*/
|
|
982
|
+
currentUser(req) {
|
|
983
|
+
const s = req.session;
|
|
984
|
+
if (!s) {
|
|
985
|
+
return null;
|
|
986
|
+
}
|
|
987
|
+
if (s.ipsAdmin) {
|
|
988
|
+
return { name: 'Administrator', login: this.adminUsername, isAdmin: true, projects: [] };
|
|
989
|
+
}
|
|
990
|
+
if (s.ipsUserId) {
|
|
991
|
+
const u = this.store.userById(s.ipsUserId);
|
|
992
|
+
if (u) {
|
|
993
|
+
return { id: u.id, name: u.name, login: u.login, isAdmin: false, projects: this.store.managedProjectIds(u.id) };
|
|
994
|
+
}
|
|
995
|
+
}
|
|
996
|
+
return null;
|
|
997
|
+
}
|
|
998
|
+
|
|
999
|
+
canManage(user, project) {
|
|
1000
|
+
return !!user && (user.isAdmin || user.projects.includes(project.id));
|
|
1001
|
+
}
|
|
1002
|
+
|
|
1003
|
+
sendLogin(req, res, status, error) {
|
|
1004
|
+
res.status(status);
|
|
1005
|
+
this.sendHtml(res, 'Login', render.renderLogin({ base: req.baseUrl, csrf: res.locals._csrf }, error), Date.now());
|
|
1006
|
+
}
|
|
1007
|
+
|
|
1008
|
+
async login(req, res) {
|
|
1009
|
+
const login = typeof req.body.login === 'string' ? req.body.login.trim() : '';
|
|
1010
|
+
const password = typeof req.body.password === 'string' ? req.body.password : '';
|
|
1011
|
+
// for the log: no control characters, no novel
|
|
1012
|
+
const shown = login.replace(/[^\x20-\x7e]/g, '?').substring(0, 64);
|
|
1013
|
+
let ok = false;
|
|
1014
|
+
const regenerate = () => new Promise((resolve, reject) => req.session.regenerate(e => e ? reject(e) : resolve()));
|
|
1015
|
+
if (login === this.adminUsername) {
|
|
1016
|
+
if (this.adminHash && await bcrypt.compare(password, this.adminHash)) {
|
|
1017
|
+
await regenerate();
|
|
1018
|
+
req.session.ipsAdmin = true;
|
|
1019
|
+
ok = true;
|
|
1020
|
+
}
|
|
1021
|
+
} else {
|
|
1022
|
+
const user = login ? this.store.userByLogin(login) : null;
|
|
1023
|
+
// the comparison is made whether or not there is such a user, so the time taken
|
|
1024
|
+
// doesn't say which usernames exist
|
|
1025
|
+
if (await bcrypt.compare(password, user ? user.password_hash : this.dummyHash) && user) {
|
|
1026
|
+
await regenerate();
|
|
1027
|
+
req.session.ipsUserId = user.id;
|
|
1028
|
+
ok = true;
|
|
1029
|
+
}
|
|
1030
|
+
}
|
|
1031
|
+
if (!ok) {
|
|
1032
|
+
this.log.info(`IPS module: failed login for '${shown}' from ${req.ip}`);
|
|
1033
|
+
return this.sendLogin(req, res, 401, 'Unknown username or wrong password');
|
|
1034
|
+
}
|
|
1035
|
+
this.log.info(`IPS module: '${shown}' logged in from ${req.ip}`);
|
|
1036
|
+
res.redirect(req.baseUrl || '/');
|
|
1037
|
+
}
|
|
1038
|
+
|
|
1039
|
+
// ---- administration: projects and users ----
|
|
1040
|
+
|
|
1041
|
+
sendProjects(req, res, message, status = 200) {
|
|
1042
|
+
res.status(status);
|
|
1043
|
+
this.sendHtml(res, 'IPS Projects', render.renderProjects(this.store.projects(), this.store.users(), this.ctx(req, res), message), Date.now());
|
|
1044
|
+
}
|
|
1045
|
+
|
|
1046
|
+
saveProject(req, res, id) {
|
|
1047
|
+
const code = typeof req.body.code === 'string' ? req.body.code.trim().toLowerCase() : '';
|
|
1048
|
+
const name = typeof req.body.name === 'string' ? req.body.name.trim() : '';
|
|
1049
|
+
const description = typeof req.body.description === 'string' ? req.body.description.trim() : '';
|
|
1050
|
+
const managers = [].concat(req.body.manager || []).filter(m => /^\d{1,9}$/.test(m)).map(m => parseInt(m, 10));
|
|
1051
|
+
let problem = null;
|
|
1052
|
+
if (id === null && (!CODE_RE.test(code) || RESERVED.has(code))) {
|
|
1053
|
+
problem = 'The code must be 2-32 lower case letters, digits and hyphens, starting with a letter or digit' +
|
|
1054
|
+
(RESERVED.has(code) ? `, and '${code}' is reserved` : '');
|
|
1055
|
+
} else if (!name || name.length > 200) {
|
|
1056
|
+
problem = 'A name is required (up to 200 characters)';
|
|
1057
|
+
} else if (description.length > 2000) {
|
|
1058
|
+
problem = 'The description can be up to 2000 characters';
|
|
1059
|
+
}
|
|
1060
|
+
if (problem) {
|
|
1061
|
+
return this.sendProjects(req, res, problem, 400);
|
|
1062
|
+
}
|
|
1063
|
+
let projectId;
|
|
1064
|
+
if (id === null) {
|
|
1065
|
+
if (this.store.projectByCode(code)) {
|
|
1066
|
+
return this.sendProjects(req, res, `There is already a project '${code}'`, 409);
|
|
1067
|
+
}
|
|
1068
|
+
projectId = this.store.createProject(code, name, description);
|
|
1069
|
+
this.log.info(`IPS module: project '${code}' created`);
|
|
1070
|
+
} else {
|
|
1071
|
+
projectId = parseInt(id, 10);
|
|
1072
|
+
if (!this.store.updateProject(projectId, name, description)) {
|
|
1073
|
+
return this.sendProjects(req, res, 'That project no longer exists', 404);
|
|
1074
|
+
}
|
|
1075
|
+
}
|
|
1076
|
+
this.store.setProjectManagers(projectId, managers);
|
|
1077
|
+
res.redirect(req.baseUrl + '/admin/projects');
|
|
1078
|
+
}
|
|
1079
|
+
|
|
1080
|
+
sendUsers(req, res, message, status = 200) {
|
|
1081
|
+
res.status(status);
|
|
1082
|
+
this.sendHtml(res, 'IPS Users', render.renderUsers(this.store.users(), this.ctx(req, res), message), Date.now());
|
|
1083
|
+
}
|
|
1084
|
+
|
|
1085
|
+
async saveUser(req, res, id) {
|
|
1086
|
+
const login = typeof req.body.login === 'string' ? req.body.login.trim() : '';
|
|
1087
|
+
const name = typeof req.body.name === 'string' ? req.body.name.trim() : '';
|
|
1088
|
+
const password = typeof req.body.password === 'string' ? req.body.password : '';
|
|
1089
|
+
let problem = null;
|
|
1090
|
+
if (id === null && !/^[A-Za-z0-9._@-]{1,64}$/.test(login)) {
|
|
1091
|
+
problem = 'The username must be 1-64 letters, digits, or . _ @ -';
|
|
1092
|
+
} else if (id === null && login === this.adminUsername) {
|
|
1093
|
+
problem = `'${login}' is the administrator's login`;
|
|
1094
|
+
} else if (!name || name.length > 100) {
|
|
1095
|
+
problem = 'A name is required (up to 100 characters)';
|
|
1096
|
+
} else if ((id === null || password) && password.length < 8) {
|
|
1097
|
+
problem = 'Passwords must be at least 8 characters';
|
|
1098
|
+
}
|
|
1099
|
+
if (problem) {
|
|
1100
|
+
return this.sendUsers(req, res, problem, 400);
|
|
1101
|
+
}
|
|
1102
|
+
const hash = password ? await bcrypt.hash(password, 10) : null;
|
|
1103
|
+
if (id === null) {
|
|
1104
|
+
if (this.store.userByLogin(login)) {
|
|
1105
|
+
return this.sendUsers(req, res, `There is already a user '${login}'`, 409);
|
|
1106
|
+
}
|
|
1107
|
+
this.store.createUser(login, name, hash);
|
|
1108
|
+
this.log.info(`IPS module: user '${login}' created`);
|
|
1109
|
+
} else if (!this.store.updateUser(parseInt(id, 10), name, hash)) {
|
|
1110
|
+
return this.sendUsers(req, res, 'That user no longer exists', 404);
|
|
1111
|
+
}
|
|
1112
|
+
res.redirect(req.baseUrl + '/admin/users');
|
|
1113
|
+
}
|
|
1114
|
+
|
|
1115
|
+
// ---- a project's settings ----
|
|
1116
|
+
|
|
1117
|
+
projectWithManagers(project) {
|
|
1118
|
+
return { ...project, managers: (this.store.projects().find(p => p.id === project.id) || {}).managers || [] };
|
|
1119
|
+
}
|
|
1120
|
+
|
|
1121
|
+
sendSettings(req, res, message, status = 200) {
|
|
1122
|
+
res.status(status);
|
|
1123
|
+
const ai = this.store.projectAi(req.project);
|
|
1124
|
+
const now = Date.now();
|
|
1125
|
+
const usage = {
|
|
1126
|
+
day: this.store.aiUsage(req.project.id, now - 24 * 60 * 60 * 1000),
|
|
1127
|
+
month: this.store.aiUsage(req.project.id, now - 30 * 24 * 60 * 60 * 1000),
|
|
1128
|
+
all: this.store.aiUsage(req.project.id, 0)
|
|
1129
|
+
};
|
|
1130
|
+
this.sendHtml(res, `${req.project.name}: Settings`,
|
|
1131
|
+
render.renderSettings(this.projectWithManagers(req.project), this.ctx(req, res), message, {
|
|
1132
|
+
ai, serverDefault: !!(this.config.ai?.apiKey && this.config.ai?.model), canKeep: !!this.aiSecret,
|
|
1133
|
+
serverModel: this.config.ai?.model || null, dailyCap: Number.isInteger(this.config.ai?.dailyCap) ? this.config.ai.dailyCap : 50,
|
|
1134
|
+
serverNarrative: this.config.ai?.sendNarrative === true, usage
|
|
1135
|
+
}), Date.now(), req);
|
|
1136
|
+
}
|
|
1137
|
+
|
|
1138
|
+
saveSettings(req, res) {
|
|
1139
|
+
if (req.body.form === 'ai') {
|
|
1140
|
+
return this.saveAiSettings(req, res);
|
|
1141
|
+
}
|
|
1142
|
+
let token = typeof req.body.token === 'string' ? req.body.token.trim() : '';
|
|
1143
|
+
if (req.body.generate === '1') {
|
|
1144
|
+
token = crypto.randomUUID();
|
|
1145
|
+
}
|
|
1146
|
+
if (token && (token.length > 256 || /\s/.test(token))) {
|
|
1147
|
+
return this.sendSettings(req, res, 'The token must have no white space, and be at most 256 characters', 400);
|
|
1148
|
+
}
|
|
1149
|
+
this.store.setProjectToken(req.project.id, token || null);
|
|
1150
|
+
this.log.info(`IPS module: submission token for '${req.project.code}' ${token ? 'set' : 'cleared'} by ${req.user.name}`);
|
|
1151
|
+
req.project = this.store.projectById(req.project.id);
|
|
1152
|
+
this.sendSettings(req, res, token ? 'The token has been set' : 'The token has been cleared; anyone can submit');
|
|
1153
|
+
}
|
|
1154
|
+
|
|
1155
|
+
/** The AI assessment settings: on/off, the account (its key encrypted), the daily cap. */
|
|
1156
|
+
saveAiSettings(req, res) {
|
|
1157
|
+
const b = req.body;
|
|
1158
|
+
const text = (v, max) => (typeof v === 'string' ? v.trim().substring(0, max) : '');
|
|
1159
|
+
const current = this.store.projectAi(req.project);
|
|
1160
|
+
const settings = {
|
|
1161
|
+
enabled: b.enabled === '1',
|
|
1162
|
+
provider: text(b.provider, 20),
|
|
1163
|
+
baseUrl: text(b.baseUrl, 500),
|
|
1164
|
+
model: text(b.model, 200),
|
|
1165
|
+
keyEnc: current.keyEnc || null,
|
|
1166
|
+
dailyCap: current.dailyCap
|
|
1167
|
+
};
|
|
1168
|
+
if (b.narrative === 'send' || b.narrative === 'strip') {
|
|
1169
|
+
settings.sendNarrative = b.narrative === 'send';
|
|
1170
|
+
}
|
|
1171
|
+
if (!Object.prototype.hasOwnProperty.call(aiClient.PROVIDERS, settings.provider)) {
|
|
1172
|
+
settings.provider = 'openai';
|
|
1173
|
+
}
|
|
1174
|
+
// a key is for the service it was given for: pointing the account somewhere else (another
|
|
1175
|
+
// provider, another base URL) needs the key again, so a stored key can't be sent to a host
|
|
1176
|
+
// its owner didn't choose
|
|
1177
|
+
const sameService = settings.provider === (current.provider || 'openai') && settings.baseUrl.replace(/\/+$/, '') === String(current.baseUrl || '').replace(/\/+$/, '');
|
|
1178
|
+
let keyDropped = false;
|
|
1179
|
+
if (settings.keyEnc && !sameService) {
|
|
1180
|
+
settings.keyEnc = null;
|
|
1181
|
+
keyDropped = true;
|
|
1182
|
+
}
|
|
1183
|
+
const cap = text(b.dailyCap, 10);
|
|
1184
|
+
if (cap !== '') {
|
|
1185
|
+
if (!/^\d{1,6}$/.test(cap)) {
|
|
1186
|
+
return this.sendSettings(req, res, 'The daily cap must be a whole number', 400);
|
|
1187
|
+
}
|
|
1188
|
+
settings.dailyCap = parseInt(cap, 10);
|
|
1189
|
+
} else {
|
|
1190
|
+
delete settings.dailyCap;
|
|
1191
|
+
}
|
|
1192
|
+
const apiKey = text(b.apiKey, 1000);
|
|
1193
|
+
if (b.clearKey === '1') {
|
|
1194
|
+
settings.keyEnc = null;
|
|
1195
|
+
} else if (apiKey) {
|
|
1196
|
+
if (!this.aiSecret) {
|
|
1197
|
+
return this.sendSettings(req, res, 'This server cannot keep AI keys: it has no aiKeySecret (or sessionSecret) configured', 400);
|
|
1198
|
+
}
|
|
1199
|
+
settings.keyEnc = aiClient.encryptKey(apiKey, this.aiSecret);
|
|
1200
|
+
}
|
|
1201
|
+
if (settings.keyEnc) {
|
|
1202
|
+
try {
|
|
1203
|
+
aiClient.checkAccount({ provider: settings.provider, baseUrl: settings.baseUrl, model: settings.model, apiKey: 'x' });
|
|
1204
|
+
} catch (e) {
|
|
1205
|
+
return this.sendSettings(req, res, e.message, 400);
|
|
1206
|
+
}
|
|
1207
|
+
}
|
|
1208
|
+
if (!settings.keyEnc) {
|
|
1209
|
+
settings.keyEnc = null;
|
|
1210
|
+
}
|
|
1211
|
+
this.store.setProjectAi(req.project.id, settings);
|
|
1212
|
+
this.log.info(`IPS module: AI assessment settings for '${req.project.code}' saved by ${req.user.name} (${settings.enabled ? 'on' : 'off'}, ${settings.keyEnc ? 'own account' : 'server default'})`);
|
|
1213
|
+
req.project = this.store.projectById(req.project.id);
|
|
1214
|
+
this.sendSettings(req, res, keyDropped && !apiKey
|
|
1215
|
+
? 'The AI assessment settings have been saved. The stored API key was removed because the provider or base URL changed - enter it again for the new service'
|
|
1216
|
+
: 'The AI assessment settings have been saved');
|
|
1217
|
+
}
|
|
1218
|
+
|
|
1219
|
+
// ---- receiving documents ----
|
|
1220
|
+
|
|
1221
|
+
/**
|
|
1222
|
+
* Store a document, once it has passed validation and the token check.
|
|
1223
|
+
* @returns {{id: number, bundle: Object}}
|
|
1224
|
+
*/
|
|
1225
|
+
receive(req, bundle, source) {
|
|
1226
|
+
const now = Date.now();
|
|
1227
|
+
const id = this.store.insert(req.project.id, bundle, { ip: req.ip, source, receivedMs: now });
|
|
1228
|
+
this.log.info(`IPS module: document ${id} received in '${req.project.code}' (${source}, ${bundle.entry.length} entries)`);
|
|
1229
|
+
return { id, bundle };
|
|
1230
|
+
}
|
|
1231
|
+
|
|
1232
|
+
/** The token from the Authorization header, without any "Bearer " prefix. */
|
|
1233
|
+
suppliedToken(req) {
|
|
1234
|
+
const v = req.get('Authorization');
|
|
1235
|
+
return typeof v === 'string' ? v.replace(/^Bearer\s+/i, '').trim() : v;
|
|
1236
|
+
}
|
|
1237
|
+
|
|
1238
|
+
async create(req, res) {
|
|
1239
|
+
const p = req.project;
|
|
1240
|
+
if (tokenConfigured(p.token) && !tokenMatches(p.token, this.suppliedToken(req))) {
|
|
1241
|
+
res.set('WWW-Authenticate', 'Bearer');
|
|
1242
|
+
return this.sendOutcome(res, 401, 'login', "This project requires its submission token in the Authorization header");
|
|
1243
|
+
}
|
|
1244
|
+
if (!req.is('application/fhir+json') && !req.is('application/json')) {
|
|
1245
|
+
return this.sendOutcome(res, 415, 'not-supported', 'Documents must be JSON (application/fhir+json)');
|
|
1246
|
+
}
|
|
1247
|
+
const declared = parseInt(req.get('Content-Length') || '', 10);
|
|
1248
|
+
if (declared > this.maxSize) {
|
|
1249
|
+
return this.sendOutcome(res, 413, 'too-costly', `Documents can't be bigger than ${this.maxSize} bytes`);
|
|
1250
|
+
}
|
|
1251
|
+
|
|
1252
|
+
// the server's body parsers have already run: application/fhir+json arrives as a
|
|
1253
|
+
// Buffer, application/json already parsed
|
|
1254
|
+
let bundle;
|
|
1255
|
+
if (Buffer.isBuffer(req.body)) {
|
|
1256
|
+
if (req.body.length > this.maxSize) {
|
|
1257
|
+
return this.sendOutcome(res, 413, 'too-costly', `Documents can't be bigger than ${this.maxSize} bytes`);
|
|
1258
|
+
}
|
|
1259
|
+
try {
|
|
1260
|
+
bundle = JSON.parse(req.body.toString('utf8'));
|
|
1261
|
+
} catch (e) {
|
|
1262
|
+
return this.sendOutcome(res, 400, 'structure', 'The body is not valid JSON: ' + e.message);
|
|
1263
|
+
}
|
|
1264
|
+
} else if (req.body && typeof req.body === 'object' && !Array.isArray(req.body)) {
|
|
1265
|
+
bundle = req.body;
|
|
1266
|
+
if (Buffer.byteLength(JSON.stringify(bundle), 'utf8') > this.maxSize) {
|
|
1267
|
+
return this.sendOutcome(res, 413, 'too-costly', `Documents can't be bigger than ${this.maxSize} bytes`);
|
|
1268
|
+
}
|
|
1269
|
+
} else {
|
|
1270
|
+
return this.sendOutcome(res, 400, 'structure', 'No document was found in the body');
|
|
1271
|
+
}
|
|
1272
|
+
|
|
1273
|
+
const problems = validateDocument(bundle);
|
|
1274
|
+
if (problems.length > 0) {
|
|
1275
|
+
return this.sendOutcome(res, 400, 'invalid', problems);
|
|
1276
|
+
}
|
|
1277
|
+
const { id, bundle: stored } = this.receive(req, bundle, 'api');
|
|
1278
|
+
|
|
1279
|
+
const location = `${this.projectUrl(req)}/Bundle/${id}`;
|
|
1280
|
+
res.set('Location', location);
|
|
1281
|
+
res.set('Last-Modified', new Date(stored.meta.lastUpdated).toUTCString());
|
|
1282
|
+
const ret = preferValue(req, 'return');
|
|
1283
|
+
if (ret === 'minimal') {
|
|
1284
|
+
return res.status(201).end();
|
|
1285
|
+
}
|
|
1286
|
+
if (ret === 'OperationOutcome') {
|
|
1287
|
+
return this.sendOutcome(res, 201, 'informational', `Document stored as ${location}`);
|
|
1288
|
+
}
|
|
1289
|
+
return this.sendJson(res, 201, stored);
|
|
1290
|
+
}
|
|
1291
|
+
|
|
1292
|
+
sendSubmit(req, res, options = {}, status = 200) {
|
|
1293
|
+
res.status(status);
|
|
1294
|
+
this.sendHtml(res, `${req.project.name}: Submit a Document`,
|
|
1295
|
+
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
|
+
}
|
|
1297
|
+
|
|
1298
|
+
// ---- clinical profiles ----
|
|
1299
|
+
|
|
1300
|
+
/** The clinical profiles: a page, or a FHIR searchset of the StructureDefinitions. */
|
|
1301
|
+
profileList(req, res) {
|
|
1302
|
+
const list = this.store.profiles();
|
|
1303
|
+
if (!wantsHtml(req)) {
|
|
1304
|
+
const base = this.baseUrl(req);
|
|
1305
|
+
const entry = list.map(p => this.store.profile(p.id)).filter(Boolean).map(sd => ({ fullUrl: `${base}/StructureDefinition/${sd.id}`, resource: sd, search: { mode: 'match' } }));
|
|
1306
|
+
res.type('application/fhir+json');
|
|
1307
|
+
return res.send(JSON.stringify({ resourceType: 'Bundle', type: 'searchset', total: entry.length, entry }));
|
|
1308
|
+
}
|
|
1309
|
+
this.sendHtml(res, 'Clinical Profiles', render.renderProfiles(list, this.ctx(req, res)), Date.now(), req);
|
|
1310
|
+
}
|
|
1311
|
+
|
|
1312
|
+
/** A clinical profile: the page, the StructureDefinition, or the StructureDefinition as a download. */
|
|
1313
|
+
profileRead(req, res) {
|
|
1314
|
+
const sd = req.profile;
|
|
1315
|
+
if (req.query.download !== undefined) {
|
|
1316
|
+
res.setHeader('Content-Disposition', `attachment; filename="${sd.name || 'profile'}.json"`);
|
|
1317
|
+
res.type('application/fhir+json');
|
|
1318
|
+
return res.send(JSON.stringify(sd, null, 2));
|
|
1319
|
+
}
|
|
1320
|
+
if (!wantsHtml(req)) {
|
|
1321
|
+
res.type('application/fhir+json');
|
|
1322
|
+
return res.send(JSON.stringify(sd));
|
|
1323
|
+
}
|
|
1324
|
+
this.sendHtml(res, `Clinical Profile: ${sd.title}`, render.renderProfile(sd, { ...this.ctx(req, res), valueSetTitle: (url) => this.valueSetTitle(url) }), Date.now(), req);
|
|
1325
|
+
}
|
|
1326
|
+
|
|
1327
|
+
/** The title of one of this module's own value sets, or null. */
|
|
1328
|
+
valueSetTitle(url) {
|
|
1329
|
+
const vs = this.valueSets && typeof url === 'string' ? this.valueSets.valueSet(url.split('|')[0]) : null;
|
|
1330
|
+
return vs ? (vs.title || vs.name || null) : null;
|
|
1331
|
+
}
|
|
1332
|
+
|
|
1333
|
+
/** The editor, for a new profile or an existing one. */
|
|
1334
|
+
profileEditor(req, res, sd) {
|
|
1335
|
+
const constraints = profiles.constraintsOf(sd).map(c => ({ ...c, shape: profiles.parseShape(c.expression), rshape: profiles.parseResourceShape(c.expression) }));
|
|
1336
|
+
this.sendHtml(res, sd ? `Edit Clinical Profile: ${sd.title}` : 'New Clinical Profile',
|
|
1337
|
+
render.renderProfileEditor(sd, this.ctx(req, res), {
|
|
1338
|
+
profile: { id: sd ? sd.id : null, title: sd ? sd.title : '', constraints },
|
|
1339
|
+
sections: profiles.IPS_SECTIONS, systems: profiles.SYSTEMS, resourceTypes: profiles.RESOURCE_TYPES, sectionSystem: editorSchema.LOINC, canLookup: !!this.tx
|
|
1340
|
+
}), Date.now(), req);
|
|
1341
|
+
}
|
|
1342
|
+
|
|
1343
|
+
/**
|
|
1344
|
+
* Save a profile from the editor ({title, constraints}), or a new one from an uploaded
|
|
1345
|
+
* StructureDefinition ({upload}). 200/201 with {id, url}, or 422 with {problems}.
|
|
1346
|
+
*/
|
|
1347
|
+
profileSave(req, res, previous) {
|
|
1348
|
+
const body = req.body && typeof req.body === 'object' && !Array.isArray(req.body) ? req.body : {};
|
|
1349
|
+
let input = body;
|
|
1350
|
+
if (body.upload !== undefined) {
|
|
1351
|
+
if (previous) {
|
|
1352
|
+
return res.status(400).json({ problems: ['An upload makes a new profile; it can\'t replace one'] });
|
|
1353
|
+
}
|
|
1354
|
+
const up = profiles.fromUpload(body.upload);
|
|
1355
|
+
if (up.problems.length) {
|
|
1356
|
+
return res.status(422).json({ problems: up.problems });
|
|
1357
|
+
}
|
|
1358
|
+
input = up.input;
|
|
1359
|
+
}
|
|
1360
|
+
const checked = profiles.checkInput(input, previous);
|
|
1361
|
+
if (checked.problems.length) {
|
|
1362
|
+
return res.status(422).json({ problems: checked.problems });
|
|
1363
|
+
}
|
|
1364
|
+
const id = previous ? previous.id.substring('profile-'.length) : profiles.newId();
|
|
1365
|
+
const url = previous ? previous.url : `${this.baseUrl(req)}/StructureDefinition/profile-${id}`;
|
|
1366
|
+
const sd = profiles.build({ id, url, title: checked.title, constraints: checked.constraints });
|
|
1367
|
+
this.store.saveProfile(id, sd);
|
|
1368
|
+
this.log.info(`IPS module: clinical profile ${sd.id} (${sd.title}) ${previous ? 'saved' : (body.upload !== undefined ? 'uploaded' : 'created')}, ${checked.constraints.length} constraints`);
|
|
1369
|
+
res.status(previous ? 200 : 201).json({ id: sd.id, url: `${req.baseUrl}/StructureDefinition/${sd.id}` });
|
|
1370
|
+
}
|
|
1371
|
+
|
|
1372
|
+
// ---- the editor ----
|
|
1373
|
+
|
|
1374
|
+
sendCompose(req, res) {
|
|
1375
|
+
let model = null;
|
|
1376
|
+
let from = null;
|
|
1377
|
+
if (typeof req.query.from === 'string' && /^\d{1,12}$/.test(req.query.from)) {
|
|
1378
|
+
const row = this.store.read(parseInt(req.query.from, 10), req.project.id);
|
|
1379
|
+
if (!row) {
|
|
1380
|
+
return htmlServer.sendErrorResponse(res, TEMPLATE, new Error(`No document with id '${req.query.from}'`), 404);
|
|
1381
|
+
}
|
|
1382
|
+
model = composer.modelOf(row.bundle);
|
|
1383
|
+
from = row.id;
|
|
1384
|
+
}
|
|
1385
|
+
this.sendHtml(res, `${req.project.name}: ${from ? `Edit Document ${from}` : 'Compose a Document'}`,
|
|
1386
|
+
render.renderCompose(req.project, this.ctx(req, res), {
|
|
1387
|
+
schema: editorSchema.forBrowser(), model, from, needsToken: tokenConfigured(req.project.token),
|
|
1388
|
+
aiEnabled: this.store.projectAi(req.project).enabled === true, canLookup: !!(this.tx && this.valueSets)
|
|
1389
|
+
}), Date.now(), req);
|
|
1390
|
+
}
|
|
1391
|
+
|
|
1392
|
+
/**
|
|
1393
|
+
* A code lookup for the editor: the value set's codes whose display matches the text,
|
|
1394
|
+
* from the terminology server (ValueSet/$expand with a filter; the value set and what
|
|
1395
|
+
* it draws on go with the request).
|
|
1396
|
+
*/
|
|
1397
|
+
async lookup(req, res) {
|
|
1398
|
+
const vsId = typeof req.query.vs === 'string' ? req.query.vs : '';
|
|
1399
|
+
if (!this.tx || !this.valueSets) {
|
|
1400
|
+
return res.status(503).json({ error: 'No terminology server is configured', matches: [] });
|
|
1401
|
+
}
|
|
1402
|
+
const url = /^[a-z0-9-]{1,64}$/.test(vsId) ? `${editorSchema.VS}${vsId}` : '';
|
|
1403
|
+
const vs = url ? this.valueSets.valueSet(url) : null;
|
|
1404
|
+
if (!vs) {
|
|
1405
|
+
return res.status(400).json({ error: 'Unknown value set', matches: [] });
|
|
1406
|
+
}
|
|
1407
|
+
return this.expandLookup(req, res, vs, this.valueSets.referencedResources(url), vsId);
|
|
1408
|
+
}
|
|
1409
|
+
|
|
1410
|
+
/**
|
|
1411
|
+
* A code lookup in one code system (any code in it), for the clinical profile editor:
|
|
1412
|
+
* an ad hoc value set of the whole system.
|
|
1413
|
+
*/
|
|
1414
|
+
async systemLookup(req, res) {
|
|
1415
|
+
const system = typeof req.query.system === 'string' ? req.query.system.trim() : '';
|
|
1416
|
+
if (!this.tx) {
|
|
1417
|
+
return res.status(503).json({ error: 'No terminology server is configured', matches: [] });
|
|
1418
|
+
}
|
|
1419
|
+
if (!/^[a-z][a-z0-9+.-]*:\S{1,250}$/i.test(system)) {
|
|
1420
|
+
return res.status(400).json({ error: 'The code system must be a URI', matches: [] });
|
|
1421
|
+
}
|
|
1422
|
+
const vs = { resourceType: 'ValueSet', status: 'active', compose: { include: [{ system }] } };
|
|
1423
|
+
return this.expandLookup(req, res, vs, [], system);
|
|
1424
|
+
}
|
|
1425
|
+
|
|
1426
|
+
/**
|
|
1427
|
+
* Value sets, for the clinical profile editor: ?q= finds them by title - this module's
|
|
1428
|
+
* own first, then the terminology server's (a ValueSet search); ?url= finds the one with
|
|
1429
|
+
* that canonical (|version or not). {matches: [{url, version, title, own}]}
|
|
1430
|
+
*/
|
|
1431
|
+
async valueSetLookup(req, res) {
|
|
1432
|
+
const q = typeof req.query.q === 'string' ? req.query.q.trim().toLowerCase().substring(0, 100) : '';
|
|
1433
|
+
const url = typeof req.query.url === 'string' ? req.query.url.trim().substring(0, 300) : '';
|
|
1434
|
+
const canonical = url.split('|')[0];
|
|
1435
|
+
const own = this.valueSets ? this.valueSets.all().map(v => ({ ...v, own: true })) : [];
|
|
1436
|
+
let matches;
|
|
1437
|
+
if (url) {
|
|
1438
|
+
matches = own.filter(v => v.url === canonical);
|
|
1439
|
+
} else if (q.length >= 2) {
|
|
1440
|
+
matches = own.filter(v => v.title.toLowerCase().includes(q) || v.url.toLowerCase().includes(q));
|
|
1441
|
+
} else {
|
|
1442
|
+
return res.json({ matches: [] });
|
|
1443
|
+
}
|
|
1444
|
+
if (matches.length === 0 || (!url && matches.length < 20)) {
|
|
1445
|
+
if (!this.tx) {
|
|
1446
|
+
return res.json({ matches, error: matches.length ? undefined : 'No terminology server is configured' });
|
|
1447
|
+
}
|
|
1448
|
+
await this.checkTxServer();
|
|
1449
|
+
if (this.txMissing) {
|
|
1450
|
+
return res.json({ matches, error: matches.length ? undefined : `Nothing answers at the terminology server ${this.tx.url} on this server yet` });
|
|
1451
|
+
}
|
|
1452
|
+
let r;
|
|
1453
|
+
try {
|
|
1454
|
+
r = await this.tx.get('/ValueSet', { ...(url ? { url: canonical } : { title: q }), _elements: 'url,version,title,name', _count: 20 });
|
|
1455
|
+
} catch (e) {
|
|
1456
|
+
this.log.error(`IPS module: value set lookup failed: ${e.message}`);
|
|
1457
|
+
return res.json({ matches, error: matches.length ? undefined : 'The terminology server could not be reached' });
|
|
1458
|
+
}
|
|
1459
|
+
if (r.status === 200 && r.resource?.resourceType === 'Bundle') {
|
|
1460
|
+
const seen = new Set(matches.map(m => `${m.url}|${m.version}`));
|
|
1461
|
+
for (const e of Array.isArray(r.resource.entry) ? r.resource.entry : []) {
|
|
1462
|
+
const v = e?.resource;
|
|
1463
|
+
if (v?.resourceType === 'ValueSet' && typeof v.url === 'string' && !seen.has(`${v.url}|${v.version || null}`)) {
|
|
1464
|
+
seen.add(`${v.url}|${v.version || null}`);
|
|
1465
|
+
matches.push({ url: v.url, version: typeof v.version === 'string' ? v.version : null, title: typeof v.title === 'string' ? v.title : (typeof v.name === 'string' ? v.name : v.url), own: false });
|
|
1466
|
+
}
|
|
1467
|
+
}
|
|
1468
|
+
} else if (!matches.length) {
|
|
1469
|
+
return res.json({ matches, error: `The terminology server answered HTTP ${r.status} to a ValueSet search` });
|
|
1470
|
+
}
|
|
1471
|
+
}
|
|
1472
|
+
res.json({ matches: matches.slice(0, 20) });
|
|
1473
|
+
}
|
|
1474
|
+
|
|
1475
|
+
/**
|
|
1476
|
+
* A session for entryIn() and resourcesIn() over one document: membership asked of the
|
|
1477
|
+
* terminology server as the rules need it, the definite answers kept across documents.
|
|
1478
|
+
*/
|
|
1479
|
+
membershipSession() {
|
|
1480
|
+
return new MembershipSession(this.tx, this.valueSets, { cache: this.txCache || (this.txCache = new Map()), log: this.log });
|
|
1481
|
+
}
|
|
1482
|
+
|
|
1483
|
+
/** The matches for ?q= in a value set, from the terminology server ($expand with a filter). */
|
|
1484
|
+
async expandLookup(req, res, vs, txResources, label) {
|
|
1485
|
+
const q = typeof req.query.q === 'string' ? req.query.q.trim().substring(0, 100) : '';
|
|
1486
|
+
await this.checkTxServer();
|
|
1487
|
+
if (this.txMissing) {
|
|
1488
|
+
return res.status(503).json({ error: `Nothing answers at the terminology server ${this.tx.url} on this server yet (modules.ips.txServer)`, matches: [] });
|
|
1489
|
+
}
|
|
1490
|
+
if (q.length < 2) {
|
|
1491
|
+
return res.json({ matches: [] });
|
|
1492
|
+
}
|
|
1493
|
+
const params = { resourceType: 'Parameters', parameter: [
|
|
1494
|
+
{ name: 'valueSet', resource: vs },
|
|
1495
|
+
...txResources.map(r => ({ name: 'tx-resource', resource: r })),
|
|
1496
|
+
{ name: 'filter', valueString: q },
|
|
1497
|
+
{ name: 'count', valueInteger: 20 },
|
|
1498
|
+
{ name: 'includeDesignations', valueBoolean: false }
|
|
1499
|
+
] };
|
|
1500
|
+
let r;
|
|
1501
|
+
try {
|
|
1502
|
+
r = await this.tx.post('/ValueSet/$expand', params);
|
|
1503
|
+
} catch (e) {
|
|
1504
|
+
this.log.error(`IPS module: lookup in ${label} failed: ${e.message}`);
|
|
1505
|
+
return res.status(502).json({ error: 'The terminology server could not be reached', matches: [] });
|
|
1506
|
+
}
|
|
1507
|
+
// an expansion with nothing in it has no contains at all
|
|
1508
|
+
const expansion = r.resource?.resourceType === 'ValueSet' && r.resource.expansion && typeof r.resource.expansion === 'object' ? r.resource.expansion : null;
|
|
1509
|
+
const contains = expansion ? (Array.isArray(expansion.contains) ? expansion.contains : []) : null;
|
|
1510
|
+
if (r.status !== 200 || !contains) {
|
|
1511
|
+
const issue = (Array.isArray(r.resource?.issue) ? r.resource.issue : []).map(i => i?.details?.text || i?.diagnostics).filter(Boolean)[0];
|
|
1512
|
+
this.log.warn(`IPS module: lookup in ${label} for '${q}' answered ${r.status}: ${issue || (r.text || '').replace(/\s+/g, ' ').substring(0, 200) || '(no body)'}`);
|
|
1513
|
+
return res.status(502).json({ error: `The terminology server could not look that up (HTTP ${r.status}${issue ? `: ${issue.substring(0, 200)}` : ''})`, matches: [] });
|
|
1514
|
+
}
|
|
1515
|
+
res.json({ matches: contains.filter(c => c && typeof c.code === 'string').slice(0, 20).map(c => ({ system: c.system, code: c.code, display: typeof c.display === 'string' ? c.display : c.code })) });
|
|
1516
|
+
}
|
|
1517
|
+
|
|
1518
|
+
/** What the editor posts: build the document, and submit it or hand it back. */
|
|
1519
|
+
async compose(req, res) {
|
|
1520
|
+
const body = req.body && typeof req.body === 'object' && !Array.isArray(req.body) ? req.body : {};
|
|
1521
|
+
const action = body.action === 'download' ? 'download' : 'submit';
|
|
1522
|
+
const p = req.project;
|
|
1523
|
+
if (action === 'submit') {
|
|
1524
|
+
const supplied = typeof body.token === 'string' ? body.token.trim() : '';
|
|
1525
|
+
if (tokenConfigured(p.token) && !tokenMatches(p.token, supplied)) {
|
|
1526
|
+
return res.status(401).json({ error: 'The submission token is missing or wrong' });
|
|
1527
|
+
}
|
|
1528
|
+
}
|
|
1529
|
+
let built;
|
|
1530
|
+
try {
|
|
1531
|
+
built = await composer.build(body.model);
|
|
1532
|
+
} catch (e) {
|
|
1533
|
+
this.log.error(`IPS module: composing a document failed: ${e.message}`);
|
|
1534
|
+
return res.status(500).json({ error: 'The document could not be built' });
|
|
1535
|
+
}
|
|
1536
|
+
if (!built.bundle) {
|
|
1537
|
+
return res.status(400).json({ error: 'The document could not be built', problems: built.problems });
|
|
1538
|
+
}
|
|
1539
|
+
const problems = validateDocument(built.bundle);
|
|
1540
|
+
if (problems.length > 0) {
|
|
1541
|
+
return res.status(400).json({ error: 'The document could not be built', problems });
|
|
1542
|
+
}
|
|
1543
|
+
const text = JSON.stringify(built.bundle);
|
|
1544
|
+
if (Buffer.byteLength(text, 'utf8') > this.maxSize) {
|
|
1545
|
+
return res.status(413).json({ error: `Documents can't be bigger than ${this.maxSize} bytes` });
|
|
1546
|
+
}
|
|
1547
|
+
if (action === 'download') {
|
|
1548
|
+
res.type(FHIR_JSON);
|
|
1549
|
+
res.set('Content-Disposition', 'attachment; filename="ips.json"');
|
|
1550
|
+
return res.send(text);
|
|
1551
|
+
}
|
|
1552
|
+
const { id } = this.receive(req, built.bundle, 'editor');
|
|
1553
|
+
res.status(201).json({ id, url: `${req.baseUrl}/${encodeURIComponent(p.code)}/Bundle/${id}` });
|
|
1554
|
+
}
|
|
1555
|
+
|
|
1556
|
+
async submitForm(req, res) {
|
|
1557
|
+
const p = req.project;
|
|
1558
|
+
const text = typeof req.body.bundle === 'string' ? req.body.bundle : '';
|
|
1559
|
+
const supplied = typeof req.body.token === 'string' ? req.body.token.trim() : '';
|
|
1560
|
+
if (tokenConfigured(p.token) && !tokenMatches(p.token, supplied)) {
|
|
1561
|
+
return this.sendSubmit(req, res, { error: 'The submission token is missing or wrong', bundle: text }, 401);
|
|
1562
|
+
}
|
|
1563
|
+
if (Buffer.byteLength(text, 'utf8') > this.maxSize) {
|
|
1564
|
+
return this.sendSubmit(req, res, { error: `Documents can't be bigger than ${this.maxSize} bytes` }, 413);
|
|
1565
|
+
}
|
|
1566
|
+
let bundle;
|
|
1567
|
+
try {
|
|
1568
|
+
bundle = JSON.parse(text);
|
|
1569
|
+
} catch (e) {
|
|
1570
|
+
return this.sendSubmit(req, res, { error: 'The document is not valid JSON: ' + e.message, bundle: text }, 400);
|
|
1571
|
+
}
|
|
1572
|
+
const problems = validateDocument(bundle);
|
|
1573
|
+
if (problems.length > 0) {
|
|
1574
|
+
return this.sendSubmit(req, res, { error: problems.join('. '), bundle: text }, 400);
|
|
1575
|
+
}
|
|
1576
|
+
const { id } = this.receive(req, bundle, 'form');
|
|
1577
|
+
res.redirect(`${req.baseUrl}/${encodeURIComponent(p.code)}/Bundle/${id}`);
|
|
1578
|
+
}
|
|
1579
|
+
|
|
1580
|
+
// ---- FHIR API: read, search, metadata ----
|
|
1581
|
+
|
|
1582
|
+
async read(req, res) {
|
|
1583
|
+
const start = Date.now();
|
|
1584
|
+
const id = docId(req.params.id);
|
|
1585
|
+
const row = this.store.read(id, req.project.id);
|
|
1586
|
+
// ?download=1 is the JSON as a file, whatever the client is
|
|
1587
|
+
if (wantsHtml(req) && req.query.download !== '1') {
|
|
1588
|
+
if (!row) {
|
|
1589
|
+
return htmlServer.sendErrorResponse(res, TEMPLATE, new Error(`No document with id '${req.params.id}'`), 404);
|
|
1590
|
+
}
|
|
1591
|
+
const view = render.VIEWS.some(([v]) => v === req.query.view) ? req.query.view : render.VIEWS[0][0];
|
|
1592
|
+
row.results = {};
|
|
1593
|
+
for (const step of this.steps) {
|
|
1594
|
+
row.results[step.name] = this.store.readResult(row.id, step.name);
|
|
1595
|
+
}
|
|
1596
|
+
const ctx = {
|
|
1597
|
+
...this.ctx(req, res),
|
|
1598
|
+
steps: this.steps.map(st => st.name),
|
|
1599
|
+
level: typeof req.query.level === 'string' ? req.query.level : undefined,
|
|
1600
|
+
problemsOnly: req.query.problems === '1'
|
|
1601
|
+
};
|
|
1602
|
+
return this.sendHtml(res, `${req.project.name}: Document ${row.id}`, render.renderDocument(req.project, row, view, ctx), start, req);
|
|
1603
|
+
}
|
|
1604
|
+
if (!row) {
|
|
1605
|
+
return this.sendOutcome(res, 404, 'not-found', `No Bundle with id '${req.params.id}'`);
|
|
1606
|
+
}
|
|
1607
|
+
res.set('Last-Modified', new Date(row.received).toUTCString());
|
|
1608
|
+
if (req.query.download === '1') {
|
|
1609
|
+
res.set('Content-Disposition', `attachment; filename="ips-${req.project.code}-${row.id}.json"`);
|
|
1610
|
+
}
|
|
1611
|
+
return this.sendJson(res, 200, row.bundle);
|
|
1612
|
+
}
|
|
1613
|
+
|
|
1614
|
+
async search(req, res) {
|
|
1615
|
+
const search = parseSearch(req.query);
|
|
1616
|
+
if (search.errors.length > 0) {
|
|
1617
|
+
return this.sendOutcome(res, 400, 'invalid', search.errors);
|
|
1618
|
+
}
|
|
1619
|
+
if (search.unknown.length > 0 && preferValue(req, 'handling') === 'strict') {
|
|
1620
|
+
return this.sendOutcome(res, 400, 'not-supported', search.unknown.map(u => `Unknown search parameter '${u}'`));
|
|
1621
|
+
}
|
|
1622
|
+
const results = this.store.search(req.project.id, search, { withJson: true });
|
|
1623
|
+
const base = this.projectUrl(req);
|
|
1624
|
+
|
|
1625
|
+
const link = (offset) => {
|
|
1626
|
+
const u = new URLSearchParams();
|
|
1627
|
+
for (const [k, v] of search.used) {
|
|
1628
|
+
u.append(k, v);
|
|
1629
|
+
}
|
|
1630
|
+
if (search.sort) {
|
|
1631
|
+
u.append('_sort', search.sort);
|
|
1632
|
+
}
|
|
1633
|
+
if (search.summaryCount) {
|
|
1634
|
+
u.append('_summary', 'count');
|
|
1635
|
+
} else {
|
|
1636
|
+
u.append('_count', String(search.count));
|
|
1637
|
+
u.append('_offset', String(offset));
|
|
1638
|
+
}
|
|
1639
|
+
return `${base}/Bundle?${u.toString()}`;
|
|
1640
|
+
};
|
|
1641
|
+
const links = [{ relation: 'self', url: link(search.offset) }];
|
|
1642
|
+
if (!search.summaryCount && search.count > 0) {
|
|
1643
|
+
links.push({ relation: 'first', url: link(0) });
|
|
1644
|
+
if (search.offset > 0) {
|
|
1645
|
+
links.push({ relation: 'previous', url: link(Math.max(0, search.offset - search.count)) });
|
|
1646
|
+
}
|
|
1647
|
+
// the next page starts where this one stopped, which is short of _count when the
|
|
1648
|
+
// page's byte budget cut it
|
|
1649
|
+
const pageEnd = search.offset + (results.truncated ? results.rows.length : search.count);
|
|
1650
|
+
if (pageEnd < results.total) {
|
|
1651
|
+
links.push({ relation: 'next', url: link(pageEnd) });
|
|
1652
|
+
}
|
|
1653
|
+
links.push({ relation: 'last', url: link(results.total === 0 ? 0 : Math.floor((results.total - 1) / search.count) * search.count) });
|
|
1654
|
+
}
|
|
1655
|
+
|
|
1656
|
+
const bundle = {
|
|
1657
|
+
resourceType: 'Bundle',
|
|
1658
|
+
id: crypto.randomUUID(),
|
|
1659
|
+
meta: { lastUpdated: new Date().toISOString() },
|
|
1660
|
+
type: 'searchset',
|
|
1661
|
+
total: results.total,
|
|
1662
|
+
link: links,
|
|
1663
|
+
entry: results.rows.map(row => ({
|
|
1664
|
+
fullUrl: `${base}/Bundle/${row.id}`,
|
|
1665
|
+
resource: JSON.parse(row.json),
|
|
1666
|
+
search: { mode: 'match' }
|
|
1667
|
+
}))
|
|
1668
|
+
};
|
|
1669
|
+
if (search.unknown.length > 0) {
|
|
1670
|
+
bundle.entry.push({
|
|
1671
|
+
resource: operationOutcome('warning', 'not-supported', search.unknown.map(u => `Unknown search parameter '${u}' was ignored`)),
|
|
1672
|
+
search: { mode: 'outcome' }
|
|
1673
|
+
});
|
|
1674
|
+
}
|
|
1675
|
+
if (bundle.entry.length === 0) {
|
|
1676
|
+
delete bundle.entry;
|
|
1677
|
+
}
|
|
1678
|
+
return this.sendJson(res, 200, bundle);
|
|
1679
|
+
}
|
|
1680
|
+
|
|
1681
|
+
metadata(req, res) {
|
|
1682
|
+
const p = req.project;
|
|
1683
|
+
const cs = {
|
|
1684
|
+
resourceType: 'CapabilityStatement',
|
|
1685
|
+
status: 'active',
|
|
1686
|
+
date: new Date().toISOString(),
|
|
1687
|
+
kind: 'instance',
|
|
1688
|
+
software: { name: 'FHIRsmith', version: packageJson.version },
|
|
1689
|
+
implementation: {
|
|
1690
|
+
description: `FHIRsmith IPS repository: ${p.name}`,
|
|
1691
|
+
url: this.projectUrl(req)
|
|
1692
|
+
},
|
|
1693
|
+
fhirVersion: '4.0.1',
|
|
1694
|
+
format: ['json'],
|
|
1695
|
+
rest: [{
|
|
1696
|
+
mode: 'server',
|
|
1697
|
+
documentation: tokenConfigured(p.token)
|
|
1698
|
+
? 'Submitting a document requires the project\'s token in the Authorization header (Bearer)'
|
|
1699
|
+
: 'Anyone may submit a document',
|
|
1700
|
+
resource: [{
|
|
1701
|
+
type: 'Bundle',
|
|
1702
|
+
profile: 'http://hl7.org/fhir/uv/ips/StructureDefinition/Bundle-uv-ips',
|
|
1703
|
+
documentation: 'IPS documents: a Bundle of type document whose first entry is the Composition. ' +
|
|
1704
|
+
'Composition and Patient parameters are reached by chaining: composition.date, composition.patient.name',
|
|
1705
|
+
interaction: [{ code: 'create' }, { code: 'read' }, { code: 'search-type' }],
|
|
1706
|
+
versioning: 'no-version',
|
|
1707
|
+
readHistory: false,
|
|
1708
|
+
updateCreate: false,
|
|
1709
|
+
searchParam: capabilitySearchParams()
|
|
1710
|
+
}]
|
|
1711
|
+
}]
|
|
1712
|
+
};
|
|
1713
|
+
return this.sendJson(res, 200, cs);
|
|
1714
|
+
}
|
|
1715
|
+
|
|
1716
|
+
// ---- HTML ----
|
|
1717
|
+
|
|
1718
|
+
async htmlList(req, res) {
|
|
1719
|
+
const start = Date.now();
|
|
1720
|
+
const query = req.query || {};
|
|
1721
|
+
const search = parseSearch(listQueryToSearch(query));
|
|
1722
|
+
let problems = search.errors;
|
|
1723
|
+
if (problems.length > 0) {
|
|
1724
|
+
// show everything rather than nothing
|
|
1725
|
+
const fallback = parseSearch({ _count: query._count });
|
|
1726
|
+
search.where = fallback.where;
|
|
1727
|
+
search.args = fallback.args;
|
|
1728
|
+
}
|
|
1729
|
+
const results = this.store.search(req.project.id, search, { identifier: typeof query.identifier === 'string' ? query.identifier : undefined });
|
|
1730
|
+
this.sendHtml(res, req.project.name, render.renderProject(req.project, query, results, search, this.ctx(req, res), problems), start, req);
|
|
1731
|
+
}
|
|
1732
|
+
}
|
|
1733
|
+
|
|
1734
|
+
module.exports = IpsModule;
|
|
1735
|
+
module.exports.validateDocument = validateDocument;
|
|
1736
|
+
module.exports.CODE_RE = CODE_RE;
|