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.
Files changed (121) hide show
  1. package/CHANGELOG.md +83 -0
  2. package/README.md +80 -13
  3. package/config-template.json +106 -1
  4. package/ips/ai-client.js +263 -0
  5. package/ips/composer.js +805 -0
  6. package/ips/editor-schema.js +289 -0
  7. package/ips/editor.js +290 -0
  8. package/ips/fhirpath-ips.js +397 -0
  9. package/ips/ips-codeable-concepts.md +1742 -0
  10. package/ips/ips-template.html +125 -0
  11. package/ips/ips-valuesets.js +159 -0
  12. package/ips/ips.js +1736 -0
  13. package/ips/membership.js +179 -0
  14. package/ips/pipeline.js +1012 -0
  15. package/ips/presenter.js +974 -0
  16. package/ips/profile-editor.js +578 -0
  17. package/ips/profiles.js +506 -0
  18. package/ips/readme.md +460 -0
  19. package/ips/render.js +1482 -0
  20. package/ips/search-params.js +83 -0
  21. package/ips/search.js +667 -0
  22. package/ips/share.js +178 -0
  23. package/ips/store.js +650 -0
  24. package/ips/terminology.js +395 -0
  25. package/ips/tx-client.js +202 -0
  26. package/ips/valuesets/CodeSystem-absent-unknown-uv-ips.json +206 -0
  27. package/ips/valuesets/ValueSet-absent-or-unknown-immunizations-uv-ips.json +59 -0
  28. package/ips/valuesets/ValueSet-ips-allergy-code.json +59 -0
  29. package/ips/valuesets/ValueSet-ips-allergy-manifestation.json +356 -0
  30. package/ips/valuesets/ValueSet-ips-allergy-substance.json +37 -0
  31. package/ips/valuesets/ValueSet-ips-body-site.json +26 -0
  32. package/ips/valuesets/ValueSet-ips-clinical-finding-or-none.json +35 -0
  33. package/ips/valuesets/ValueSet-ips-clinical-finding.json +26 -0
  34. package/ips/valuesets/ValueSet-ips-condition-code.json +72 -0
  35. package/ips/valuesets/ValueSet-ips-condition-severity.json +30 -0
  36. package/ips/valuesets/ValueSet-ips-condition-stage.json +26 -0
  37. package/ips/valuesets/ValueSet-ips-exposure-route.json +26 -0
  38. package/ips/valuesets/ValueSet-ips-imaging-procedure.json +25 -0
  39. package/ips/valuesets/ValueSet-ips-immunization-status-reason.json +43 -0
  40. package/ips/valuesets/ValueSet-ips-international-codes.json +25 -0
  41. package/ips/valuesets/ValueSet-ips-medication-ingredient.json +26 -0
  42. package/ips/valuesets/ValueSet-ips-medication-status-reason.json +37 -0
  43. package/ips/valuesets/ValueSet-ips-medication.json +50 -0
  44. package/ips/valuesets/ValueSet-ips-observation-code.json +18 -0
  45. package/ips/valuesets/ValueSet-ips-observation-interpretation.json +18 -0
  46. package/ips/valuesets/ValueSet-ips-procedure-followup.json +51 -0
  47. package/ips/valuesets/ValueSet-ips-procedure-outcome.json +30 -0
  48. package/ips/valuesets/ValueSet-ips-procedure-reason.json +37 -0
  49. package/ips/valuesets/ValueSet-ips-procedure.json +116 -0
  50. package/ips/valuesets/ValueSet-ips-route.json +46 -0
  51. package/ips/valuesets/ValueSet-ips-target-disease.json +171 -0
  52. package/ips/valuesets/ValueSet-ips-vaccine.json +31 -0
  53. package/ips/valuesets/elements.json +44 -0
  54. package/library/daily-report.js +422 -0
  55. package/library/fhir-search-values.js +138 -0
  56. package/library/html-server.js +32 -23
  57. package/library/mailer.js +77 -0
  58. package/library/narrative-check.js +21 -2
  59. package/library/npm-audit.js +25 -0
  60. package/library/package-manager.js +4 -2
  61. package/library/sqlite-session-store.js +29 -1
  62. package/package.json +7 -2
  63. package/packages/packages.js +34 -3
  64. package/publisher/publisher.js +29 -0
  65. package/registry/auth-store.js +366 -0
  66. package/registry/auth.js +872 -0
  67. package/registry/crawler.js +41 -1
  68. package/registry/documentation/api.md +162 -0
  69. package/registry/documentation/configuration.md +93 -0
  70. package/registry/documentation/documents.md +74 -0
  71. package/registry/documentation/internals.md +151 -0
  72. package/registry/documentation/readme.md +100 -0
  73. package/registry/documentation/security.md +199 -0
  74. package/registry/documents.js +309 -0
  75. package/registry/openapi.yaml +30 -0
  76. package/registry/registry-template.html +1 -1
  77. package/registry/registry.js +230 -44
  78. package/registry/uptime.js +154 -0
  79. package/server.js +105 -44
  80. package/shl/shl.js +64 -9
  81. package/stats-db.js +93 -0
  82. package/stats.js +158 -1
  83. package/testing/search.js +2 -108
  84. package/testing/store.js +5 -0
  85. package/testing/testing.js +20 -0
  86. package/token/token-template.html +5 -4
  87. package/token/token.js +17 -10
  88. package/translations/Messages.properties +9 -0
  89. package/tx/README.md +52 -0
  90. package/tx/cs/cs-api.js +90 -14
  91. package/tx/cs/cs-cs.js +56 -1
  92. package/tx/cs/cs-icd11.js +366 -112
  93. package/tx/cs/cs-icf.js +418 -0
  94. package/tx/cs/cs-loinc.js +47 -25
  95. package/tx/cs/cs-omop.js +10 -3
  96. package/tx/cs/cs-snomed.js +918 -55
  97. package/tx/cs/cs-ucum.js +6 -0
  98. package/tx/cs/icd11-expressions.js +191 -78
  99. package/tx/importers/export-icd11-codesystem.js +58 -12
  100. package/tx/importers/icd11-schema.md +9 -5
  101. package/tx/library.js +98 -4
  102. package/tx/openapi-operations.js +4 -0
  103. package/tx/params.js +8 -1
  104. package/tx/provider.js +16 -4
  105. package/tx/sct/documentation.md +251 -0
  106. package/tx/sct/ecl.js +758 -46
  107. package/tx/sct/expressions.js +113 -52
  108. package/tx/syndication/syndication-feed.js +218 -0
  109. package/tx/syndication/syndication-store.js +415 -0
  110. package/tx/tx-html.js +15 -1
  111. package/tx/tx.fhir.org.yml +2 -3
  112. package/tx/tx.js +144 -2
  113. package/tx/vs/vs-vsac.js +25 -6
  114. package/tx/workers/expand.js +33 -6
  115. package/tx/workers/lookup.js +61 -6
  116. package/tx/workers/subsumes.js +1 -1
  117. package/tx/workers/translate.js +166 -15
  118. package/tx/workers/validate.js +128 -6
  119. package/utilities/make-sct-test-extension.js +264 -0
  120. package/xig/xig.js +32 -0
  121. 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 };