fhirsmith 0.17.0 → 0.18.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +83 -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 +422 -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 +7 -2
- 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 +9 -0
- package/tx/README.md +52 -0
- package/tx/cs/cs-api.js +90 -14
- package/tx/cs/cs-cs.js +56 -1
- package/tx/cs/cs-icd11.js +366 -112
- package/tx/cs/cs-icf.js +418 -0
- package/tx/cs/cs-loinc.js +47 -25
- package/tx/cs/cs-omop.js +10 -3
- package/tx/cs/cs-snomed.js +918 -55
- package/tx/cs/cs-ucum.js +6 -0
- package/tx/cs/icd11-expressions.js +191 -78
- package/tx/importers/export-icd11-codesystem.js +58 -12
- package/tx/importers/icd11-schema.md +9 -5
- package/tx/library.js +98 -4
- package/tx/openapi-operations.js +4 -0
- package/tx/params.js +8 -1
- package/tx/provider.js +16 -4
- package/tx/sct/documentation.md +251 -0
- package/tx/sct/ecl.js +758 -46
- package/tx/sct/expressions.js +113 -52
- 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 +2 -3
- package/tx/tx.js +144 -2
- package/tx/vs/vs-vsac.js +25 -6
- package/tx/workers/expand.js +33 -6
- 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 +128 -6
- package/utilities/make-sct-test-extension.js +264 -0
- package/xig/xig.js +32 -0
- package/registry/readme.md +0 -124
package/ips/store.js
ADDED
|
@@ -0,0 +1,650 @@
|
|
|
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
|
+
* SQLite storage for the /ips module: projects, their managers, the users, and the IPS
|
|
9
|
+
* documents in each project, with the search index over them.
|
|
10
|
+
*
|
|
11
|
+
* A document is kept whole, as the JSON that was stored (with the server's id and
|
|
12
|
+
* meta.lastUpdated), alongside a few columns the list page shows and the status of its
|
|
13
|
+
* processing. Document ids are one serial across the whole database, so an id names
|
|
14
|
+
* one document whichever project it is asked for in. The submitter's IP address is
|
|
15
|
+
* recorded for tracing abuse, and is never returned by anything in this module.
|
|
16
|
+
*
|
|
17
|
+
* @module ips/store
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
const Database = require('better-sqlite3');
|
|
21
|
+
const { indexDocument, parseSearch, compositionOf, patientOf, identifierClause } = require('./search');
|
|
22
|
+
|
|
23
|
+
// the most document JSON one search page carries
|
|
24
|
+
const MAX_PAGE_BYTES = 32 * 1024 * 1024;
|
|
25
|
+
|
|
26
|
+
const SCHEMA = `
|
|
27
|
+
CREATE TABLE IF NOT EXISTS projects (
|
|
28
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
29
|
+
code TEXT UNIQUE NOT NULL,
|
|
30
|
+
name TEXT NOT NULL,
|
|
31
|
+
description TEXT,
|
|
32
|
+
-- the token submissions must carry (the web form, or an Authorization header). The
|
|
33
|
+
-- project's managers need to read it back to hand it out, so it is not hashed
|
|
34
|
+
token TEXT,
|
|
35
|
+
created TEXT NOT NULL
|
|
36
|
+
);
|
|
37
|
+
|
|
38
|
+
-- the people who can manage projects. The administrator is not in here: their login is
|
|
39
|
+
-- in the configuration
|
|
40
|
+
CREATE TABLE IF NOT EXISTS users (
|
|
41
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
42
|
+
login TEXT UNIQUE NOT NULL,
|
|
43
|
+
name TEXT NOT NULL,
|
|
44
|
+
password_hash TEXT NOT NULL,
|
|
45
|
+
created TEXT NOT NULL
|
|
46
|
+
);
|
|
47
|
+
|
|
48
|
+
CREATE TABLE IF NOT EXISTS project_managers (
|
|
49
|
+
project_id INTEGER NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
|
|
50
|
+
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
|
51
|
+
PRIMARY KEY (project_id, user_id)
|
|
52
|
+
);
|
|
53
|
+
|
|
54
|
+
CREATE TABLE IF NOT EXISTS documents (
|
|
55
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
56
|
+
project_id INTEGER NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
|
|
57
|
+
received TEXT NOT NULL, -- meta.lastUpdated
|
|
58
|
+
received_ms INTEGER NOT NULL,
|
|
59
|
+
ip TEXT, -- never shared
|
|
60
|
+
source TEXT NOT NULL, -- 'api' or 'form'
|
|
61
|
+
status TEXT NOT NULL, -- submitted | processing | processed | error
|
|
62
|
+
status_message TEXT,
|
|
63
|
+
processed_ms INTEGER,
|
|
64
|
+
size INTEGER NOT NULL,
|
|
65
|
+
-- for the list page
|
|
66
|
+
patient_name TEXT,
|
|
67
|
+
patient_gender TEXT,
|
|
68
|
+
patient_birth TEXT,
|
|
69
|
+
comp_date TEXT,
|
|
70
|
+
comp_title TEXT,
|
|
71
|
+
json TEXT NOT NULL,
|
|
72
|
+
-- the analysed copy: the document as the processing steps adorned it (pipeline.js)
|
|
73
|
+
analysed_json TEXT
|
|
74
|
+
);
|
|
75
|
+
|
|
76
|
+
-- what each processing step made of a document (see pipeline.js): one row per step
|
|
77
|
+
CREATE TABLE IF NOT EXISTS document_results (
|
|
78
|
+
doc_id INTEGER NOT NULL REFERENCES documents(id) ON DELETE CASCADE,
|
|
79
|
+
step TEXT NOT NULL,
|
|
80
|
+
made_ms INTEGER NOT NULL,
|
|
81
|
+
json TEXT NOT NULL,
|
|
82
|
+
PRIMARY KEY (doc_id, step)
|
|
83
|
+
);
|
|
84
|
+
|
|
85
|
+
-- the AI assessment calls made, per project: for the daily cap, and what they cost
|
|
86
|
+
CREATE TABLE IF NOT EXISTS ai_calls (
|
|
87
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
88
|
+
project_id INTEGER NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
|
|
89
|
+
at_ms INTEGER NOT NULL,
|
|
90
|
+
model TEXT,
|
|
91
|
+
input_tokens INTEGER,
|
|
92
|
+
output_tokens INTEGER,
|
|
93
|
+
cost_usd REAL
|
|
94
|
+
);
|
|
95
|
+
CREATE INDEX IF NOT EXISTS ai_calls_project ON ai_calls (project_id, at_ms);
|
|
96
|
+
|
|
97
|
+
-- clinical profiles (StructureDefinitions on Composition), across all projects
|
|
98
|
+
CREATE TABLE IF NOT EXISTS clinical_profiles (
|
|
99
|
+
id TEXT PRIMARY KEY, -- private: the StructureDefinition's id is 'profile-' + this
|
|
100
|
+
title TEXT NOT NULL,
|
|
101
|
+
updated_ms INTEGER NOT NULL,
|
|
102
|
+
json TEXT NOT NULL
|
|
103
|
+
);
|
|
104
|
+
|
|
105
|
+
CREATE TABLE IF NOT EXISTS ips_index (
|
|
106
|
+
doc_id INTEGER NOT NULL REFERENCES documents(id) ON DELETE CASCADE,
|
|
107
|
+
res TEXT NOT NULL, -- Bundle | Composition | Patient
|
|
108
|
+
param TEXT NOT NULL,
|
|
109
|
+
type TEXT NOT NULL,
|
|
110
|
+
str TEXT,
|
|
111
|
+
str_lc TEXT,
|
|
112
|
+
sys TEXT,
|
|
113
|
+
code TEXT,
|
|
114
|
+
lo INTEGER,
|
|
115
|
+
hi INTEGER,
|
|
116
|
+
ref TEXT
|
|
117
|
+
);
|
|
118
|
+
|
|
119
|
+
CREATE INDEX IF NOT EXISTS idx_documents_project ON documents(project_id, id);
|
|
120
|
+
CREATE INDEX IF NOT EXISTS idx_documents_status ON documents(status, id);
|
|
121
|
+
CREATE INDEX IF NOT EXISTS idx_ips_index_doc ON ips_index(doc_id, res, param);
|
|
122
|
+
CREATE INDEX IF NOT EXISTS idx_ips_index_str ON ips_index(res, param, str_lc);
|
|
123
|
+
CREATE INDEX IF NOT EXISTS idx_ips_index_code ON ips_index(res, param, code);
|
|
124
|
+
CREATE INDEX IF NOT EXISTS idx_ips_index_date ON ips_index(res, param, lo);
|
|
125
|
+
CREATE INDEX IF NOT EXISTS idx_ips_index_ref ON ips_index(res, param, ref);
|
|
126
|
+
CREATE INDEX IF NOT EXISTS idx_project_managers_user ON project_managers(user_id);
|
|
127
|
+
`;
|
|
128
|
+
|
|
129
|
+
function str(v, max = 200) {
|
|
130
|
+
return typeof v === 'string' && v !== '' ? v.substring(0, max) : null;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** A HumanName as text: its text, or prefix given family suffix. */
|
|
134
|
+
function nameText(n) {
|
|
135
|
+
if (!n || typeof n !== 'object') {
|
|
136
|
+
return null;
|
|
137
|
+
}
|
|
138
|
+
if (typeof n.text === 'string' && n.text.trim()) {
|
|
139
|
+
return n.text.trim();
|
|
140
|
+
}
|
|
141
|
+
const parts = [].concat(n.prefix || [], n.given || [], n.family || [], n.suffix || []).filter(s => typeof s === 'string' && s);
|
|
142
|
+
return parts.length ? parts.join(' ') : null;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** What the list page shows for a document. */
|
|
146
|
+
function summaryOf(bundle) {
|
|
147
|
+
const comp = compositionOf(bundle);
|
|
148
|
+
const patient = patientOf(bundle, comp);
|
|
149
|
+
const names = Array.isArray(patient?.name) ? patient.name : [];
|
|
150
|
+
// the official or usual name first, if there is one
|
|
151
|
+
const best = names.find(n => n?.use === 'official') || names.find(n => n?.use === 'usual') || names.find(n => n?.use !== 'old') || names[0];
|
|
152
|
+
return {
|
|
153
|
+
patient_name: str(nameText(best)),
|
|
154
|
+
patient_gender: str(patient?.gender, 20),
|
|
155
|
+
patient_birth: str(patient?.birthDate, 20),
|
|
156
|
+
comp_date: str(comp?.date, 40),
|
|
157
|
+
comp_title: str(comp?.title, 300)
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
class IpsStore {
|
|
162
|
+
/**
|
|
163
|
+
* @param {string} dbPath - file name, or ':memory:'
|
|
164
|
+
*/
|
|
165
|
+
constructor(dbPath) {
|
|
166
|
+
this.db = new Database(dbPath);
|
|
167
|
+
this.db.pragma('journal_mode = WAL');
|
|
168
|
+
this.db.pragma('foreign_keys = ON');
|
|
169
|
+
this.db.exec(SCHEMA);
|
|
170
|
+
this.migrate();
|
|
171
|
+
this.stmts = {
|
|
172
|
+
insertDoc: this.db.prepare(`
|
|
173
|
+
INSERT INTO documents (project_id, received, received_ms, ip, source, status, size,
|
|
174
|
+
patient_name, patient_gender, patient_birth, comp_date, comp_title, json)
|
|
175
|
+
VALUES (@project_id, @received, @received_ms, @ip, @source, 'submitted', @size,
|
|
176
|
+
@patient_name, @patient_gender, @patient_birth, @comp_date, @comp_title, @json)`),
|
|
177
|
+
setJson: this.db.prepare('UPDATE documents SET json = ? WHERE id = ?'),
|
|
178
|
+
insertIndex: this.db.prepare(`
|
|
179
|
+
INSERT INTO ips_index (doc_id, res, param, type, str, str_lc, sys, code, lo, hi, ref)
|
|
180
|
+
VALUES (@doc_id, @res, @param, @type, @str, @str_lc, @sys, @code, @lo, @hi, @ref)`),
|
|
181
|
+
readDoc: this.db.prepare('SELECT id, project_id, received, received_ms, source, status, status_message, processed_ms, size, patient_name, patient_gender, patient_birth, comp_date, comp_title, json, analysed_json FROM documents WHERE id = ?'),
|
|
182
|
+
saveAnalysed: this.db.prepare('UPDATE documents SET analysed_json = ? WHERE id = ?'),
|
|
183
|
+
deleteDoc: this.db.prepare('DELETE FROM documents WHERE id = ? AND project_id = ?'),
|
|
184
|
+
countDocs: this.db.prepare('SELECT COUNT(*) AS n FROM documents WHERE project_id = ?'),
|
|
185
|
+
countAll: this.db.prepare('SELECT COUNT(*) AS n FROM documents'),
|
|
186
|
+
nextSubmitted: this.db.prepare("SELECT id FROM documents WHERE status = 'submitted' ORDER BY id LIMIT 1"),
|
|
187
|
+
claim: this.db.prepare("UPDATE documents SET status = 'processing', status_message = NULL WHERE id = ? AND status = 'submitted'"),
|
|
188
|
+
// only a document being processed is finished: one requeued or deleted meanwhile isn't
|
|
189
|
+
finish: this.db.prepare("UPDATE documents SET status = ?, status_message = ?, processed_ms = ? WHERE id = ? AND status = 'processing'"),
|
|
190
|
+
// a document being processed is left alone: its pipeline would finish it over the requeue
|
|
191
|
+
requeue: this.db.prepare("UPDATE documents SET status = 'submitted', status_message = NULL, processed_ms = NULL, analysed_json = NULL WHERE id = ? AND project_id = ? AND status <> 'processing'"),
|
|
192
|
+
saveResult: this.db.prepare('INSERT OR REPLACE INTO document_results (doc_id, step, made_ms, json) VALUES (?, ?, ?, ?)'),
|
|
193
|
+
readResult: this.db.prepare('SELECT made_ms, json FROM document_results WHERE doc_id = ? AND step = ?'),
|
|
194
|
+
resultSteps: this.db.prepare('SELECT step, made_ms FROM document_results WHERE doc_id = ? ORDER BY step'),
|
|
195
|
+
clearResults: this.db.prepare('DELETE FROM document_results WHERE doc_id = ?'),
|
|
196
|
+
resetProcessing: this.db.prepare("UPDATE documents SET status = 'submitted' WHERE status = 'processing'")
|
|
197
|
+
};
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/** Bring a database made by an earlier version of the module up to date. */
|
|
201
|
+
migrate() {
|
|
202
|
+
const has = (table, col) => this.db.prepare(`PRAGMA table_info(${table})`).all().some(c => c.name === col);
|
|
203
|
+
if (!has('documents', 'analysed_json')) {
|
|
204
|
+
this.db.exec('ALTER TABLE documents ADD COLUMN analysed_json TEXT');
|
|
205
|
+
}
|
|
206
|
+
if (!has('projects', 'ai_json')) {
|
|
207
|
+
// the project's AI assessment settings (JSON; the API key encrypted)
|
|
208
|
+
this.db.exec('ALTER TABLE projects ADD COLUMN ai_json TEXT');
|
|
209
|
+
}
|
|
210
|
+
if (!has('ai_calls', 'id')) {
|
|
211
|
+
// the first shape of ai_calls had no id and no usage columns: rebuild it, keeping the calls
|
|
212
|
+
this.db.exec(`
|
|
213
|
+
ALTER TABLE ai_calls RENAME TO ai_calls_old;
|
|
214
|
+
CREATE TABLE ai_calls (
|
|
215
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
216
|
+
project_id INTEGER NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
|
|
217
|
+
at_ms INTEGER NOT NULL,
|
|
218
|
+
model TEXT,
|
|
219
|
+
input_tokens INTEGER,
|
|
220
|
+
output_tokens INTEGER,
|
|
221
|
+
cost_usd REAL
|
|
222
|
+
);
|
|
223
|
+
INSERT INTO ai_calls (project_id, at_ms) SELECT project_id, at_ms FROM ai_calls_old;
|
|
224
|
+
DROP TABLE ai_calls_old;
|
|
225
|
+
CREATE INDEX IF NOT EXISTS ai_calls_project ON ai_calls (project_id, at_ms);`);
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
close() {
|
|
230
|
+
if (this.db) {
|
|
231
|
+
this.db.close();
|
|
232
|
+
this.db = null;
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// ---- clinical profiles ----
|
|
237
|
+
|
|
238
|
+
/** All the clinical profiles: {id, title, updated_ms, constraints}, by title. */
|
|
239
|
+
profiles() {
|
|
240
|
+
return this.db.prepare('SELECT id, title, updated_ms, json FROM clinical_profiles ORDER BY title COLLATE NOCASE, id').all()
|
|
241
|
+
.map(r => {
|
|
242
|
+
const sd = JSON.parse(r.json);
|
|
243
|
+
const el = sd?.differential?.element?.[0];
|
|
244
|
+
return { id: r.id, title: r.title, updated_ms: r.updated_ms, constraints: Array.isArray(el?.constraint) ? el.constraint.length : 0 };
|
|
245
|
+
});
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/** @returns {Object|null} the StructureDefinition */
|
|
249
|
+
profile(id) {
|
|
250
|
+
const r = this.db.prepare('SELECT json FROM clinical_profiles WHERE id = ?').get(id);
|
|
251
|
+
return r ? JSON.parse(r.json) : null;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
saveProfile(id, sd, nowMs = Date.now()) {
|
|
255
|
+
this.db.prepare('INSERT INTO clinical_profiles (id, title, updated_ms, json) VALUES (?, ?, ?, ?) ' +
|
|
256
|
+
'ON CONFLICT(id) DO UPDATE SET title = excluded.title, updated_ms = excluded.updated_ms, json = excluded.json')
|
|
257
|
+
.run(id, sd.title, nowMs, JSON.stringify(sd));
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
deleteProfile(id) {
|
|
261
|
+
return this.db.prepare('DELETE FROM clinical_profiles WHERE id = ?').run(id).changes > 0;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
// ---- projects ----
|
|
265
|
+
|
|
266
|
+
/** All projects, with their document counts and managers; by name. */
|
|
267
|
+
projects() {
|
|
268
|
+
const rows = this.db.prepare(`
|
|
269
|
+
SELECT p.id, p.code, p.name, p.description, p.created,
|
|
270
|
+
(SELECT COUNT(*) FROM documents d WHERE d.project_id = p.id) AS documents
|
|
271
|
+
FROM projects p ORDER BY p.name COLLATE NOCASE, p.code`).all();
|
|
272
|
+
const managers = this.db.prepare(`
|
|
273
|
+
SELECT pm.project_id, u.id, u.login, u.name FROM project_managers pm JOIN users u ON u.id = pm.user_id ORDER BY u.login`).all();
|
|
274
|
+
const byProject = new Map();
|
|
275
|
+
for (const m of managers) {
|
|
276
|
+
if (!byProject.has(m.project_id)) {
|
|
277
|
+
byProject.set(m.project_id, []);
|
|
278
|
+
}
|
|
279
|
+
byProject.get(m.project_id).push({ id: m.id, login: m.login, name: m.name });
|
|
280
|
+
}
|
|
281
|
+
for (const p of rows) {
|
|
282
|
+
p.managers = byProject.get(p.id) || [];
|
|
283
|
+
}
|
|
284
|
+
return rows;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/** @returns {Object|null} the project, including its token */
|
|
288
|
+
projectByCode(code) {
|
|
289
|
+
return this.db.prepare('SELECT * FROM projects WHERE code = ?').get(code) || null;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
projectById(id) {
|
|
293
|
+
return this.db.prepare('SELECT * FROM projects WHERE id = ?').get(id) || null;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
createProject(code, name, description) {
|
|
297
|
+
const r = this.db.prepare('INSERT INTO projects (code, name, description, created) VALUES (?, ?, ?, ?)')
|
|
298
|
+
.run(code, name, description || null, new Date().toISOString());
|
|
299
|
+
return Number(r.lastInsertRowid);
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
updateProject(id, name, description) {
|
|
303
|
+
return this.db.prepare('UPDATE projects SET name = ?, description = ? WHERE id = ?').run(name, description || null, id).changes > 0;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/** A project's AI assessment settings ({enabled, provider, baseUrl, model, keyEnc, dailyCap}), or {}. */
|
|
307
|
+
projectAi(project) {
|
|
308
|
+
if (!project || typeof project.ai_json !== 'string') {
|
|
309
|
+
return {};
|
|
310
|
+
}
|
|
311
|
+
try {
|
|
312
|
+
const v = JSON.parse(project.ai_json);
|
|
313
|
+
return v && typeof v === 'object' && !Array.isArray(v) ? v : {};
|
|
314
|
+
} catch {
|
|
315
|
+
return {};
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
setProjectAi(id, settings) {
|
|
320
|
+
return this.db.prepare('UPDATE projects SET ai_json = ? WHERE id = ?').run(settings ? JSON.stringify(settings) : null, id).changes > 0;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
/** Record an AI assessment call for a project (the daily cap). @returns {number} its id */
|
|
324
|
+
recordAiCall(projectId, atMs = Date.now()) {
|
|
325
|
+
return Number(this.db.prepare('INSERT INTO ai_calls (project_id, at_ms) VALUES (?, ?)').run(projectId, atMs).lastInsertRowid);
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
/** What a call used, once it has answered: the model, the tokens, the cost (when known). */
|
|
329
|
+
finishAiCall(id, { model, input, output, cost }) {
|
|
330
|
+
this.db.prepare('UPDATE ai_calls SET model = ?, input_tokens = ?, output_tokens = ?, cost_usd = ? WHERE id = ?')
|
|
331
|
+
.run(model || null, Number.isFinite(input) ? input : null, Number.isFinite(output) ? output : null, Number.isFinite(cost) ? cost : null, id);
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
/** The AI assessment calls a project has made since a time. */
|
|
335
|
+
aiCallsSince(projectId, sinceMs) {
|
|
336
|
+
return this.db.prepare('SELECT COUNT(*) AS n FROM ai_calls WHERE project_id = ? AND at_ms >= ?').get(projectId, sinceMs).n;
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
/**
|
|
340
|
+
* What a project's AI assessments have used since a time: calls, tokens, cost (the sum
|
|
341
|
+
* of the calls with a known cost, and how many had one).
|
|
342
|
+
*/
|
|
343
|
+
aiUsage(projectId, sinceMs = 0) {
|
|
344
|
+
return this.db.prepare(`SELECT COUNT(*) AS calls, COALESCE(SUM(input_tokens), 0) AS input, COALESCE(SUM(output_tokens), 0) AS output,
|
|
345
|
+
COALESCE(SUM(cost_usd), 0) AS cost, SUM(cost_usd IS NOT NULL) AS costed FROM ai_calls WHERE project_id = ? AND at_ms >= ?`).get(projectId, sinceMs);
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
/** Set (or, with null, clear) the token submissions to the project must carry. */
|
|
349
|
+
setProjectToken(id, token) {
|
|
350
|
+
return this.db.prepare('UPDATE projects SET token = ? WHERE id = ?').run(token || null, id).changes > 0;
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
/** Delete a project and everything in it. @returns {boolean} whether there was one */
|
|
354
|
+
deleteProject(id) {
|
|
355
|
+
return this.db.prepare('DELETE FROM projects WHERE id = ?').run(id).changes > 0;
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
/** Replace the set of users who manage a project. */
|
|
359
|
+
setProjectManagers(projectId, userIds) {
|
|
360
|
+
this.db.transaction(() => {
|
|
361
|
+
this.db.prepare('DELETE FROM project_managers WHERE project_id = ?').run(projectId);
|
|
362
|
+
const ins = this.db.prepare('INSERT OR IGNORE INTO project_managers (project_id, user_id) VALUES (?, ?)');
|
|
363
|
+
for (const u of userIds) {
|
|
364
|
+
ins.run(projectId, u);
|
|
365
|
+
}
|
|
366
|
+
})();
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
/** The ids of the projects a user manages. */
|
|
370
|
+
managedProjectIds(userId) {
|
|
371
|
+
return this.db.prepare('SELECT project_id FROM project_managers WHERE user_id = ?').all(userId).map(r => r.project_id);
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
// ---- users ----
|
|
375
|
+
|
|
376
|
+
/** @returns {Array} users, without their password hashes, with the codes of the projects they manage */
|
|
377
|
+
users() {
|
|
378
|
+
const rows = this.db.prepare('SELECT id, login, name, created FROM users ORDER BY login').all();
|
|
379
|
+
const pm = this.db.prepare('SELECT pm.user_id, p.code FROM project_managers pm JOIN projects p ON p.id = pm.project_id ORDER BY p.code').all();
|
|
380
|
+
const by = new Map();
|
|
381
|
+
for (const m of pm) {
|
|
382
|
+
if (!by.has(m.user_id)) {
|
|
383
|
+
by.set(m.user_id, []);
|
|
384
|
+
}
|
|
385
|
+
by.get(m.user_id).push(m.code);
|
|
386
|
+
}
|
|
387
|
+
for (const u of rows) {
|
|
388
|
+
u.projects = by.get(u.id) || [];
|
|
389
|
+
}
|
|
390
|
+
return rows;
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
/** @returns {Object|null} the user, including the password hash */
|
|
394
|
+
userByLogin(login) {
|
|
395
|
+
return this.db.prepare('SELECT * FROM users WHERE login = ?').get(login) || null;
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
userById(id) {
|
|
399
|
+
return this.db.prepare('SELECT id, login, name FROM users WHERE id = ?').get(id) || null;
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
createUser(login, name, passwordHash) {
|
|
403
|
+
const r = this.db.prepare('INSERT INTO users (login, name, password_hash, created) VALUES (?, ?, ?, ?)')
|
|
404
|
+
.run(login, name, passwordHash, new Date().toISOString());
|
|
405
|
+
return Number(r.lastInsertRowid);
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
updateUser(id, name, passwordHash) {
|
|
409
|
+
const n = this.db.prepare('UPDATE users SET name = ? WHERE id = ?').run(name, id).changes;
|
|
410
|
+
if (n > 0 && passwordHash) {
|
|
411
|
+
this.db.prepare('UPDATE users SET password_hash = ? WHERE id = ?').run(passwordHash, id);
|
|
412
|
+
}
|
|
413
|
+
return n > 0;
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
deleteUser(id) {
|
|
417
|
+
return this.db.prepare('DELETE FROM users WHERE id = ?').run(id).changes > 0;
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
// ---- documents ----
|
|
421
|
+
|
|
422
|
+
/**
|
|
423
|
+
* Store a document that has already been validated. The bundle gets its id (the
|
|
424
|
+
* serial the database assigns) and meta.lastUpdated here, and is indexed.
|
|
425
|
+
*
|
|
426
|
+
* @param {number} projectId
|
|
427
|
+
* @param {Object} bundle - the document; changed in place (id, meta.lastUpdated)
|
|
428
|
+
* @param {{ip?: string, source: string, receivedMs: number}} info
|
|
429
|
+
* @returns {number} the document's id
|
|
430
|
+
*/
|
|
431
|
+
insert(projectId, bundle, info) {
|
|
432
|
+
const received = new Date(info.receivedMs).toISOString();
|
|
433
|
+
bundle.meta = { ...(bundle.meta || {}), lastUpdated: received };
|
|
434
|
+
delete bundle.meta.versionId;
|
|
435
|
+
return this.db.transaction(() => {
|
|
436
|
+
// the id is the serial, so the row is inserted first, then the JSON is written with it
|
|
437
|
+
const r = this.stmts.insertDoc.run({
|
|
438
|
+
project_id: projectId, received, received_ms: info.receivedMs, ip: info.ip || null,
|
|
439
|
+
source: info.source, size: 0, ...summaryOf(bundle), json: '{}'
|
|
440
|
+
});
|
|
441
|
+
const id = Number(r.lastInsertRowid);
|
|
442
|
+
// id first in the JSON, as it is in the spec's examples
|
|
443
|
+
const stored = { resourceType: 'Bundle', id: String(id), meta: bundle.meta };
|
|
444
|
+
for (const [k, v] of Object.entries(bundle)) {
|
|
445
|
+
if (!(k in stored)) {
|
|
446
|
+
stored[k] = v;
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
const json = JSON.stringify(stored);
|
|
450
|
+
this.db.prepare('UPDATE documents SET json = ?, size = ? WHERE id = ?').run(json, Buffer.byteLength(json, 'utf8'), id);
|
|
451
|
+
for (const row of indexDocument(stored)) {
|
|
452
|
+
this.stmts.insertIndex.run({ doc_id: id, ...row });
|
|
453
|
+
}
|
|
454
|
+
// give the caller what was stored
|
|
455
|
+
for (const k of Object.keys(bundle)) {
|
|
456
|
+
delete bundle[k];
|
|
457
|
+
}
|
|
458
|
+
Object.assign(bundle, stored);
|
|
459
|
+
return id;
|
|
460
|
+
})();
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
/**
|
|
464
|
+
* @param {number} id
|
|
465
|
+
* @param {number} [projectId] - if given, the document must be in this project
|
|
466
|
+
* @returns {Object|null} the row, with the bundle parsed as .bundle
|
|
467
|
+
*/
|
|
468
|
+
read(id, projectId) {
|
|
469
|
+
if (!Number.isInteger(id)) {
|
|
470
|
+
return null;
|
|
471
|
+
}
|
|
472
|
+
const row = this.stmts.readDoc.get(id);
|
|
473
|
+
if (!row || (projectId !== undefined && row.project_id !== projectId)) {
|
|
474
|
+
return null;
|
|
475
|
+
}
|
|
476
|
+
row.bundle = JSON.parse(row.json);
|
|
477
|
+
delete row.json;
|
|
478
|
+
row.analysed = row.analysed_json ? JSON.parse(row.analysed_json) : null;
|
|
479
|
+
delete row.analysed_json;
|
|
480
|
+
return row;
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
/** Keep the analysed copy of a document (null to drop it). */
|
|
484
|
+
saveAnalysed(id, bundle) {
|
|
485
|
+
this.stmts.saveAnalysed.run(bundle ? JSON.stringify(bundle) : null, id);
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
/** @returns {boolean} whether there was such a document in the project */
|
|
489
|
+
delete(id, projectId) {
|
|
490
|
+
return Number.isInteger(id) && this.stmts.deleteDoc.run(id, projectId).changes > 0;
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
count(projectId) {
|
|
494
|
+
return projectId === undefined ? this.stmts.countAll.get().n : this.stmts.countDocs.get(projectId).n;
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
/** How many documents were received in (sinceMs, nowMs], over all projects */
|
|
498
|
+
countReceived(sinceMs, nowMs) {
|
|
499
|
+
return this.db.prepare('SELECT COUNT(*) AS n FROM documents WHERE received_ms > ? AND received_ms <= ?').get(sinceMs, nowMs).n;
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
/**
|
|
503
|
+
* Run a parsed search (see search.js) over a project's documents.
|
|
504
|
+
*
|
|
505
|
+
* @param {number} projectId
|
|
506
|
+
* @param {Object} search - from parseSearch
|
|
507
|
+
* @param {{withJson?: boolean, identifier?: string}} options - include the stored JSON
|
|
508
|
+
* (FHIR API), or just the columns the HTML list needs; identifier is the web
|
|
509
|
+
* filter's partial match on the id or patient identifiers
|
|
510
|
+
* @returns {{total: number, rows: Object[], truncated: boolean}} truncated: the page was
|
|
511
|
+
* cut short of _count by the byte budget (the next page starts at offset + rows.length)
|
|
512
|
+
*/
|
|
513
|
+
search(projectId, search, options = {}) {
|
|
514
|
+
const where = ['d.project_id = ?', ...search.where];
|
|
515
|
+
const args = [projectId, ...search.args];
|
|
516
|
+
const idc = identifierClause(options.identifier);
|
|
517
|
+
if (idc) {
|
|
518
|
+
where.push(idc.sql);
|
|
519
|
+
args.push(...idc.args);
|
|
520
|
+
}
|
|
521
|
+
const w = 'WHERE ' + where.join(' AND ');
|
|
522
|
+
const total = this.db.prepare(`SELECT COUNT(*) AS n FROM documents d ${w}`).get(...args).n;
|
|
523
|
+
if (search.summaryCount || search.count === 0) {
|
|
524
|
+
return { total, rows: [] };
|
|
525
|
+
}
|
|
526
|
+
if (!options.withJson) {
|
|
527
|
+
const cols = 'd.id, d.received, d.source, d.status, d.status_message, d.size, d.patient_name, d.patient_gender, d.patient_birth, d.comp_date, d.comp_title';
|
|
528
|
+
const rows = this.db.prepare(`SELECT ${cols} FROM documents d ${w} ORDER BY ${search.orderBy} LIMIT ? OFFSET ?`)
|
|
529
|
+
.all(...args, ...search.orderArgs, search.count, search.offset);
|
|
530
|
+
return { total, rows, truncated: false };
|
|
531
|
+
}
|
|
532
|
+
// the documents themselves: a page is cut short once it holds MAX_PAGE_BYTES of them (but
|
|
533
|
+
// always at least one), so that _count=500 of 10MB documents is not a 5GB response
|
|
534
|
+
const ids = this.db.prepare(`SELECT d.id, d.size FROM documents d ${w} ORDER BY ${search.orderBy} LIMIT ? OFFSET ?`)
|
|
535
|
+
.all(...args, ...search.orderArgs, search.count, search.offset);
|
|
536
|
+
const keep = [];
|
|
537
|
+
let bytes = 0;
|
|
538
|
+
for (const r of ids) {
|
|
539
|
+
if (keep.length > 0 && bytes + r.size > MAX_PAGE_BYTES) {
|
|
540
|
+
break;
|
|
541
|
+
}
|
|
542
|
+
keep.push(r.id);
|
|
543
|
+
bytes += r.size;
|
|
544
|
+
}
|
|
545
|
+
const rows = keep.map(id => this.db.prepare('SELECT id, received, json FROM documents WHERE id = ?').get(id));
|
|
546
|
+
return { total, rows, truncated: keep.length < ids.length };
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
/** The documents of a project the list page shows, all of them, newest first. */
|
|
550
|
+
list(projectId) {
|
|
551
|
+
return this.search(projectId, parseSearch({ _count: String(1000) })).rows;
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
// ---- the processing queue ----
|
|
555
|
+
|
|
556
|
+
/**
|
|
557
|
+
* Take the oldest submitted document for processing, marking it 'processing'.
|
|
558
|
+
* @returns {Object|null} the row (as read()) or null if nothing is waiting
|
|
559
|
+
*/
|
|
560
|
+
claimNext() {
|
|
561
|
+
return this.db.transaction(() => {
|
|
562
|
+
const next = this.stmts.nextSubmitted.get();
|
|
563
|
+
if (!next || this.stmts.claim.run(next.id).changes === 0) {
|
|
564
|
+
return null;
|
|
565
|
+
}
|
|
566
|
+
return this.read(next.id);
|
|
567
|
+
})();
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
/** Record the outcome of processing a document. */
|
|
571
|
+
finish(id, ok, message) {
|
|
572
|
+
this.stmts.finish.run(ok ? 'processed' : 'error', message || null, Date.now(), id);
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
/**
|
|
576
|
+
* Put a document back in the queue - not while it is being processed.
|
|
577
|
+
* @returns {'queued'|'processing'|null} null when there is no such document
|
|
578
|
+
*/
|
|
579
|
+
requeue(id, projectId) {
|
|
580
|
+
if (!Number.isInteger(id)) {
|
|
581
|
+
return null;
|
|
582
|
+
}
|
|
583
|
+
const row = this.db.prepare('SELECT status FROM documents WHERE id = ? AND project_id = ?').get(id, projectId);
|
|
584
|
+
if (!row) {
|
|
585
|
+
return null;
|
|
586
|
+
}
|
|
587
|
+
if (row.status === 'processing' || this.stmts.requeue.run(id, projectId).changes === 0) {
|
|
588
|
+
return 'processing';
|
|
589
|
+
}
|
|
590
|
+
this.stmts.clearResults.run(id);
|
|
591
|
+
return 'queued';
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
// ---- the processing steps' results ----
|
|
595
|
+
|
|
596
|
+
/** Keep what a step made of a document, replacing any earlier run's. */
|
|
597
|
+
saveResult(docId, step, result) {
|
|
598
|
+
this.stmts.saveResult.run(docId, step, Date.now(), JSON.stringify(result));
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
/** @returns {{made: string, result: Object}|null} what a step made of a document */
|
|
602
|
+
readResult(docId, step) {
|
|
603
|
+
const row = this.stmts.readResult.get(docId, step);
|
|
604
|
+
return row ? { made: new Date(row.made_ms).toISOString(), result: JSON.parse(row.json) } : null;
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
/**
|
|
608
|
+
* Record where a step is with a document - waiting, running, done or failed - with its
|
|
609
|
+
* note or error, merged into whatever result the step has kept.
|
|
610
|
+
*/
|
|
611
|
+
saveStepStatus(docId, step, status, message) {
|
|
612
|
+
const existing = this.readResult(docId, step);
|
|
613
|
+
const result = existing ? existing.result : {};
|
|
614
|
+
result.status = status;
|
|
615
|
+
result.message = message || null;
|
|
616
|
+
this.saveResult(docId, step, result);
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
/** Drop every step's result for a document (it is being processed again). */
|
|
620
|
+
clearResults(docId) {
|
|
621
|
+
this.stmts.clearResults.run(docId);
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
/** The steps that have results for a document. */
|
|
625
|
+
resultSteps(docId) {
|
|
626
|
+
return this.stmts.resultSteps.all(docId).map(r => r.step);
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
/**
|
|
630
|
+
* Anything left 'processing' was being worked on when the server stopped; it goes back
|
|
631
|
+
* in the queue. Called at startup. @returns {number} how many
|
|
632
|
+
*/
|
|
633
|
+
resetProcessing() {
|
|
634
|
+
return this.stmts.resetProcessing.run().changes;
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
/** How many documents are in each state, for the dashboard. */
|
|
638
|
+
statusCounts(projectId) {
|
|
639
|
+
const rows = projectId === undefined
|
|
640
|
+
? this.db.prepare('SELECT status, COUNT(*) AS n FROM documents GROUP BY status').all()
|
|
641
|
+
: this.db.prepare('SELECT status, COUNT(*) AS n FROM documents WHERE project_id = ? GROUP BY status').all(projectId);
|
|
642
|
+
const out = {};
|
|
643
|
+
for (const r of rows) {
|
|
644
|
+
out[r.status] = r.n;
|
|
645
|
+
}
|
|
646
|
+
return out;
|
|
647
|
+
}
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
module.exports = { IpsStore, summaryOf, nameText, MAX_PAGE_BYTES };
|