fhirsmith 0.7.1 → 0.7.3

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.
@@ -0,0 +1,697 @@
1
+ const express = require('express');
2
+ const path = require('path');
3
+ const Database = require('better-sqlite3');
4
+ const htmlServer = require('../library/html-server');
5
+ const escape = require('escape-html');
6
+ const packageJson = require('../package.json');
7
+
8
+ const TEMPLATE_NAME = 'ext-tracker';
9
+
10
+ class ExtensionTrackerModule {
11
+ constructor(stats) {
12
+ this.stats = stats;
13
+ this.db = null;
14
+ this.urlBase = '/ext-tracker';
15
+ }
16
+
17
+ initialize(config, app) {
18
+ const dbPath = config.database || path.join(config.folder || '.', 'extension-tracker.db');
19
+ this.db = new Database(dbPath);
20
+ this.db.pragma('journal_mode = WAL');
21
+ this.db.pragma('foreign_keys = ON');
22
+ this.createSchema();
23
+ this.prepareStatements();
24
+
25
+ this.urlBase = config.url || '/ext-tracker';
26
+
27
+ // load template
28
+ const templatePath = path.join(__dirname, 'extension-tracker-template.html');
29
+ htmlServer.loadTemplate(TEMPLATE_NAME, templatePath);
30
+
31
+ const router = express.Router();
32
+
33
+ // POST - submit extension data
34
+ router.post(this.urlBase, express.json({ limit: '5mb' }), (req, res) => {
35
+ this.handleSubmission(req, res);
36
+ });
37
+
38
+ // GET - HTML views
39
+ router.get(this.urlBase, (req, res) => this.handleHome(req, res));
40
+ router.get(this.urlBase + '/extensions', (req, res) => this.handleExtensions(req, res));
41
+ router.get(this.urlBase + '/extensions/packages', (req, res) => this.handleExtensionsByPackage(req, res));
42
+ router.get(this.urlBase + '/profiles', (req, res) => this.handleProfiles(req, res));
43
+ router.get(this.urlBase + '/usage', (req, res) => this.handleUsage(req, res));
44
+ router.get(this.urlBase + '/usage/packages', (req, res) => this.handleUsageByPackage(req, res));
45
+ router.get(this.urlBase + '/package/:pkg', (req, res) => this.handlePackageDetail(req, res));
46
+
47
+ app.use('/', router);
48
+ const count = this.db.prepare('SELECT COUNT(*) as count FROM packages').get().count;
49
+ console.log(`Extension tracker: loaded (${count} packages in database) at ${this.urlBase}`);
50
+ }
51
+
52
+ createSchema() {
53
+ this.db.exec(`
54
+ CREATE TABLE IF NOT EXISTS packages (
55
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
56
+ package TEXT NOT NULL UNIQUE,
57
+ version TEXT NOT NULL,
58
+ fhir_version TEXT NOT NULL,
59
+ jurisdiction TEXT,
60
+ submitted_at TEXT NOT NULL
61
+ );
62
+
63
+ CREATE TABLE IF NOT EXISTS extensions (
64
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
65
+ package_id INTEGER NOT NULL REFERENCES packages(id) ON DELETE CASCADE,
66
+ url TEXT NOT NULL,
67
+ title TEXT
68
+ );
69
+
70
+ CREATE TABLE IF NOT EXISTS extension_types (
71
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
72
+ extension_id INTEGER NOT NULL REFERENCES extensions(id) ON DELETE CASCADE,
73
+ type TEXT NOT NULL
74
+ );
75
+
76
+ CREATE TABLE IF NOT EXISTS profiles (
77
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
78
+ package_id INTEGER NOT NULL REFERENCES packages(id) ON DELETE CASCADE,
79
+ resource_type TEXT NOT NULL,
80
+ url TEXT NOT NULL,
81
+ title TEXT
82
+ );
83
+
84
+ CREATE TABLE IF NOT EXISTS usages (
85
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
86
+ package_id INTEGER NOT NULL REFERENCES packages(id) ON DELETE CASCADE,
87
+ extension_url TEXT NOT NULL,
88
+ location TEXT NOT NULL
89
+ );
90
+
91
+ CREATE INDEX IF NOT EXISTS idx_extensions_package ON extensions(package_id);
92
+ CREATE INDEX IF NOT EXISTS idx_extensions_url ON extensions(url);
93
+ CREATE INDEX IF NOT EXISTS idx_extension_types_ext ON extension_types(extension_id);
94
+ CREATE INDEX IF NOT EXISTS idx_profiles_package ON profiles(package_id);
95
+ CREATE INDEX IF NOT EXISTS idx_profiles_resource ON profiles(resource_type);
96
+ CREATE INDEX IF NOT EXISTS idx_usages_package ON usages(package_id);
97
+ CREATE INDEX IF NOT EXISTS idx_usages_url ON usages(extension_url);
98
+ CREATE INDEX IF NOT EXISTS idx_usages_location ON usages(location);
99
+ `);
100
+ }
101
+
102
+ prepareStatements() {
103
+ this.stmts = {
104
+ deletePackage: this.db.prepare('DELETE FROM packages WHERE package = ?'),
105
+ insertPackage: this.db.prepare(
106
+ 'INSERT INTO packages (package, version, fhir_version, jurisdiction, submitted_at) VALUES (?, ?, ?, ?, ?)'
107
+ ),
108
+ insertExtension: this.db.prepare(
109
+ 'INSERT INTO extensions (package_id, url, title) VALUES (?, ?, ?)'
110
+ ),
111
+ insertExtensionType: this.db.prepare(
112
+ 'INSERT INTO extension_types (extension_id, type) VALUES (?, ?)'
113
+ ),
114
+ insertProfile: this.db.prepare(
115
+ 'INSERT INTO profiles (package_id, resource_type, url, title) VALUES (?, ?, ?, ?)'
116
+ ),
117
+ insertUsage: this.db.prepare(
118
+ 'INSERT INTO usages (package_id, extension_url, location) VALUES (?, ?, ?)'
119
+ )
120
+ };
121
+ }
122
+
123
+ // ---- Client-side column filtering ----
124
+
125
+ /**
126
+ * Build a script block that adds filter inputs to each column header of a table.
127
+ * Filters combine (AND) and persist in cookies.
128
+ * @param {string} tableId - the id attribute of the table
129
+ * @param {Array} columns - array of { type: 'text'|'select', options?: string[] }
130
+ * @param {string} cookiePrefix - cookie name prefix for persistence
131
+ */
132
+ buildColumnFilters(tableId, columns, cookiePrefix) {
133
+ const colsJson = JSON.stringify(columns.map(c => ({
134
+ type: c.type || 'text',
135
+ options: c.options || []
136
+ })));
137
+
138
+ return `
139
+ <script>
140
+ (function() {
141
+ var table = document.getElementById(${JSON.stringify(tableId)});
142
+ if (!table) return;
143
+ var cols = ${colsJson};
144
+ var prefix = ${JSON.stringify(cookiePrefix)};
145
+ var headerRow = table.querySelector('tr');
146
+ var filterRow = document.createElement('tr');
147
+ filterRow.className = 'filter-row';
148
+ var html = '';
149
+ for (var i = 0; i < cols.length; i++) {
150
+ var c = cols[i];
151
+ if (c.type === 'select') {
152
+ html += '<th><select data-col="' + i + '" class="col-filter" style="width:100%;box-sizing:border-box;">';
153
+ html += '<option value="">All</option>';
154
+ for (var j = 0; j < c.options.length; j++) {
155
+ var o = c.options[j].replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/"/g,'&quot;');
156
+ html += '<option value="' + o + '">' + o + '</option>';
157
+ }
158
+ html += '</select></th>';
159
+ } else {
160
+ html += '<th><input type="text" data-col="' + i + '" class="col-filter" placeholder="filter..." style="width:100%;box-sizing:border-box;"></th>';
161
+ }
162
+ }
163
+ filterRow.innerHTML = html;
164
+ headerRow.parentNode.insertBefore(filterRow, headerRow.nextSibling);
165
+
166
+ function getCookie(name) {
167
+ var m = document.cookie.match(new RegExp('(^|;)\\\\s*' + name + '=([^;]*)'));
168
+ return m ? decodeURIComponent(m[2]) : '';
169
+ }
170
+ function setCookie(name, val) {
171
+ document.cookie = name + '=' + encodeURIComponent(val) + ';path=/;max-age=86400;SameSite=Lax';
172
+ }
173
+
174
+ var filters = filterRow.querySelectorAll('.col-filter');
175
+ var allRows = table.querySelectorAll('tr');
176
+ var rows = [];
177
+ for (var r = 2; r < allRows.length; r++) rows.push(allRows[r]);
178
+
179
+ function applyFilters() {
180
+ var visible = 0;
181
+ for (var ri = 0; ri < rows.length; ri++) {
182
+ var cells = rows[ri].querySelectorAll('td');
183
+ var show = true;
184
+ for (var fi = 0; fi < filters.length; fi++) {
185
+ var f = filters[fi];
186
+ var ci = parseInt(f.getAttribute('data-col'));
187
+ var val = f.value.toLowerCase();
188
+ if (val && cells[ci]) {
189
+ var text = cells[ci].textContent.toLowerCase();
190
+ if (f.tagName === 'SELECT') {
191
+ if (text !== val) show = false;
192
+ } else {
193
+ if (text.indexOf(val) === -1) show = false;
194
+ }
195
+ }
196
+ }
197
+ rows[ri].style.display = show ? '' : 'none';
198
+ if (show) visible++;
199
+ }
200
+ var counter = document.getElementById(${JSON.stringify(tableId)} + '-count');
201
+ if (counter) counter.textContent = visible + ' of ' + rows.length;
202
+ }
203
+
204
+ for (var fi = 0; fi < filters.length; fi++) {
205
+ (function(f) {
206
+ var ci = f.getAttribute('data-col');
207
+ var saved = getCookie(prefix + '_' + ci);
208
+ if (saved) f.value = saved;
209
+ var evt = f.tagName === 'SELECT' ? 'change' : 'input';
210
+ f.addEventListener(evt, function() {
211
+ setCookie(prefix + '_' + ci, f.value);
212
+ applyFilters();
213
+ });
214
+ })(filters[fi]);
215
+ }
216
+ applyFilters();
217
+ })();
218
+ </script>`;
219
+ }
220
+
221
+ // ---- Template rendering ----
222
+
223
+ renderPage(title, content, processingTime) {
224
+ if (!htmlServer.hasTemplate(TEMPLATE_NAME)) {
225
+ const templatePath = path.join(__dirname, 'extension-tracker-template.html');
226
+ htmlServer.loadTemplate(TEMPLATE_NAME, templatePath);
227
+ }
228
+ const count = this.db.prepare('SELECT COUNT(*) as count FROM packages').get().count;
229
+ const stats = {
230
+ version: packageJson.version,
231
+ totalPackages: count,
232
+ processingTime: processingTime
233
+ };
234
+ return htmlServer.renderPage(TEMPLATE_NAME, title, content, stats);
235
+ }
236
+
237
+ sendHtmlResponse(res, title, content, startTime) {
238
+ const html = this.renderPage(title, content, Date.now() - startTime);
239
+ res.setHeader('Content-Type', 'text/html');
240
+ res.send(html);
241
+ }
242
+
243
+ // ---- POST handler ----
244
+
245
+ handleSubmission(req, res) {
246
+ const startTime = Date.now();
247
+ const data = req.body;
248
+
249
+ if (!data.package || !data.version || !data.fhirVersion) {
250
+ return res.status(400).json({ error: 'Missing required fields: package, version, fhirVersion' });
251
+ }
252
+
253
+ try {
254
+ this.ingestData(data);
255
+ this.stats.countRequest('handleSubmission', Date.now() - startTime);
256
+ return res.status(200).json({ status: 'ok', package: data.package, version: data.version });
257
+ } catch (err) {
258
+ console.log(`Extension tracker: error ingesting ${data.package}: ${err.message}`);
259
+ return res.status(500).json({ error: 'Failed to process submission' });
260
+ }
261
+ }
262
+
263
+ ingestData(data) {
264
+ const ingest = this.db.transaction(() => {
265
+ // delete any existing entry for this package (cascade cleans children)
266
+ this.stmts.deletePackage.run(data.package);
267
+
268
+ // insert package
269
+ const result = this.stmts.insertPackage.run(
270
+ data.package, data.version, data.fhirVersion,
271
+ data.jurisdiction || null, new Date().toISOString()
272
+ );
273
+ const packageId = result.lastInsertRowid;
274
+
275
+ // insert extensions
276
+ if (Array.isArray(data.extensions)) {
277
+ for (const ext of data.extensions) {
278
+ const extResult = this.stmts.insertExtension.run(packageId, ext.url, ext.title || null);
279
+ const extId = extResult.lastInsertRowid;
280
+
281
+ // deduplicate types
282
+ if (Array.isArray(ext.types)) {
283
+ const seen = new Set();
284
+ for (const type of ext.types) {
285
+ if (!seen.has(type)) {
286
+ seen.add(type);
287
+ this.stmts.insertExtensionType.run(extId, type);
288
+ }
289
+ }
290
+ }
291
+ }
292
+ }
293
+
294
+ // insert profiles
295
+ if (data.profiles && typeof data.profiles === 'object') {
296
+ for (const [resourceType, profileList] of Object.entries(data.profiles)) {
297
+ if (Array.isArray(profileList)) {
298
+ for (const profile of profileList) {
299
+ this.stmts.insertProfile.run(packageId, resourceType, profile.url, profile.title || null);
300
+ }
301
+ }
302
+ }
303
+ }
304
+
305
+ // insert usage
306
+ if (data.usage && typeof data.usage === 'object') {
307
+ for (const [extUrl, locations] of Object.entries(data.usage)) {
308
+ if (Array.isArray(locations)) {
309
+ for (const location of locations) {
310
+ this.stmts.insertUsage.run(packageId, extUrl, location);
311
+ }
312
+ }
313
+ }
314
+ }
315
+ });
316
+
317
+ ingest();
318
+ }
319
+
320
+ // ---- GET handlers ----
321
+
322
+ handleHome(req, res) {
323
+ const startTime = Date.now();
324
+ try {
325
+ const summary = this.db.prepare(`
326
+ SELECT
327
+ COUNT(*) as packageCount,
328
+ COUNT(DISTINCT fhir_version) as fhirVersionCount
329
+ FROM packages
330
+ `).get();
331
+
332
+ const extCount = this.db.prepare('SELECT COUNT(*) as count FROM extensions').get().count;
333
+ const profileCount = this.db.prepare('SELECT COUNT(*) as count FROM profiles').get().count;
334
+ const usageCount = this.db.prepare('SELECT COUNT(*) as count FROM usages').get().count;
335
+
336
+ const packages = this.db.prepare(
337
+ 'SELECT package, version, fhir_version, jurisdiction, submitted_at FROM packages ORDER BY package'
338
+ ).all();
339
+
340
+ let content = '<table class="grid">';
341
+ content += `<tr><td><b>Packages</b></td><td>${summary.packageCount}</td></tr>`;
342
+ content += `<tr><td><b>FHIR Versions</b></td><td>${summary.fhirVersionCount}</td></tr>`;
343
+ content += `<tr><td><b>Extensions defined</b></td><td>${extCount}</td></tr>`;
344
+ content += `<tr><td><b>Profiles defined</b></td><td>${profileCount}</td></tr>`;
345
+ content += `<tr><td><b>Extension usages tracked</b></td><td>${usageCount}</td></tr>`;
346
+ content += '</table>';
347
+
348
+ content += '<h3>Packages</h3>';
349
+ content += `<p><span id="pkg-table-count">${packages.length}</span> packages</p>`;
350
+ content += '<table class="grid" id="pkg-table"><tr><th>Package</th><th>Version</th><th>FHIR</th><th>Jurisdiction</th><th>Submitted</th></tr>';
351
+ for (const p of packages) {
352
+ content += '<tr>';
353
+ content += `<td><a href="${this.urlBase}/package/${encodeURIComponent(p.package)}">${escape(p.package)}</a></td>`;
354
+ content += `<td>${escape(p.version)}</td>`;
355
+ content += `<td>${escape(p.fhir_version)}</td>`;
356
+ content += `<td>${escape(p.jurisdiction || '')}</td>`;
357
+ content += `<td>${escape(p.submitted_at.substring(0, 10))}</td>`;
358
+ content += '</tr>';
359
+ }
360
+ content += '</table>';
361
+
362
+ content += this.buildColumnFilters('pkg-table', [
363
+ { type: 'text' }, { type: 'text' }, { type: 'text' }, { type: 'text' }, { type: 'text' }
364
+ ], 'pkgf');
365
+
366
+ this.stats.countRequest('handleHome', Date.now() - startTime);
367
+ this.sendHtmlResponse(res, 'Extension Tracker', content, startTime);
368
+ } catch (error) {
369
+ console.log('Extension tracker: error rendering home:', error);
370
+ htmlServer.sendErrorResponse(res, TEMPLATE_NAME, error);
371
+ }
372
+ }
373
+
374
+ handleExtensions(req, res) {
375
+ const startTime = Date.now();
376
+ try {
377
+ // Aggregate by extension URL: combine types across all occurrences, count packages
378
+ const rows = this.db.prepare(`
379
+ SELECT e.url,
380
+ MAX(e.title) as title,
381
+ GROUP_CONCAT(DISTINCT et.type) as types,
382
+ COUNT(DISTINCT e.package_id) as package_count
383
+ FROM extensions e
384
+ LEFT JOIN extension_types et ON et.extension_id = e.id
385
+ GROUP BY e.url
386
+ ORDER BY e.url
387
+ `).all();
388
+
389
+ let content = `<p><span id="ext-table-count">${rows.length}</span> extensions</p>`;
390
+ content += '<table class="grid" id="ext-table"><tr><th>Extension</th><th>Title</th><th>Types</th><th>Packages</th></tr>';
391
+ for (const r of rows) {
392
+ content += '<tr>';
393
+ content += `<td>${escape(r.url)}</td>`;
394
+ content += `<td>${escape(r.title || '')}</td>`;
395
+ content += `<td>${escape(r.types || '')}</td>`;
396
+ content += `<td><a href="${this.urlBase}/extensions/packages?url=${encodeURIComponent(r.url)}">${r.package_count}</a></td>`;
397
+ content += '</tr>';
398
+ }
399
+ content += '</table>';
400
+
401
+ content += this.buildColumnFilters('ext-table', [
402
+ { type: 'text' }, { type: 'text' }, { type: 'text' }, { type: 'text' }
403
+ ], 'extf');
404
+
405
+ this.stats.countRequest('handleExtensions', Date.now() - startTime);
406
+ this.sendHtmlResponse(res, 'Extensions', content, startTime);
407
+ } catch (error) {
408
+ console.log('Extension tracker: error rendering extensions:', error);
409
+ htmlServer.sendErrorResponse(res, TEMPLATE_NAME, error);
410
+ }
411
+ }
412
+
413
+ handleExtensionsByPackage(req, res) {
414
+ const startTime = Date.now();
415
+ try {
416
+ const filterUrl = req.query.url || null;
417
+
418
+ let query = `
419
+ SELECT e.url, e.title, p.package, p.version,
420
+ GROUP_CONCAT(DISTINCT et.type) as types
421
+ FROM extensions e
422
+ JOIN packages p ON p.id = e.package_id
423
+ LEFT JOIN extension_types et ON et.extension_id = e.id
424
+ `;
425
+ const params = [];
426
+ if (filterUrl) {
427
+ query += ' WHERE e.url = ?';
428
+ params.push(filterUrl);
429
+ }
430
+ query += ' GROUP BY e.id ORDER BY p.package, e.url';
431
+
432
+ const rows = this.db.prepare(query).all(...params);
433
+
434
+ let content = '<p><a href="' + this.urlBase + '/extensions">&laquo; Back to extensions</a></p>';
435
+ if (filterUrl) {
436
+ content += `<p>Extension: <b>${escape(filterUrl)}</b></p>`;
437
+ }
438
+
439
+ content += `<p><span id="extpkg-table-count">${rows.length}</span> entries by package</p>`;
440
+ content += '<table class="grid" id="extpkg-table"><tr><th>Extension</th><th>Title</th><th>Types</th><th>Package</th></tr>';
441
+ for (const r of rows) {
442
+ content += '<tr>';
443
+ content += `<td>${escape(r.url)}</td>`;
444
+ content += `<td>${escape(r.title || '')}</td>`;
445
+ content += `<td>${escape(r.types || '')}</td>`;
446
+ content += `<td><a href="${this.urlBase}/package/${encodeURIComponent(r.package)}">${escape(r.package)}#${escape(r.version)}</a></td>`;
447
+ content += '</tr>';
448
+ }
449
+ content += '</table>';
450
+
451
+ content += this.buildColumnFilters('extpkg-table', [
452
+ { type: 'text' }, { type: 'text' }, { type: 'text' }, { type: 'text' }
453
+ ], 'extpkgf');
454
+
455
+ this.stats.countRequest('handleExtensionsByPackage', Date.now() - startTime);
456
+ this.sendHtmlResponse(res, 'Extensions by Package', content, startTime);
457
+ } catch (error) {
458
+ console.log('Extension tracker: error rendering extensions by package:', error);
459
+ htmlServer.sendErrorResponse(res, TEMPLATE_NAME, error);
460
+ }
461
+ }
462
+
463
+ handleProfiles(req, res) {
464
+ const startTime = Date.now();
465
+ try {
466
+ const rows = this.db.prepare(`
467
+ SELECT pr.resource_type, pr.url, pr.title, p.package, p.version
468
+ FROM profiles pr
469
+ JOIN packages p ON p.id = pr.package_id
470
+ ORDER BY pr.resource_type, p.package, pr.url
471
+ `).all();
472
+
473
+ // get distinct resource types for the select filter
474
+ const resourceTypes = this.db.prepare(
475
+ 'SELECT DISTINCT resource_type FROM profiles ORDER BY resource_type'
476
+ ).all().map(r => r.resource_type);
477
+
478
+ let content = `<p><span id="prof-table-count">${rows.length}</span> profiles</p>`;
479
+ content += '<table class="grid" id="prof-table"><tr><th>Resource</th><th>Profile</th><th>Title</th><th>Package</th></tr>';
480
+ for (const r of rows) {
481
+ content += '<tr>';
482
+ content += `<td>${escape(r.resource_type)}</td>`;
483
+ content += `<td>${escape(r.url)}</td>`;
484
+ content += `<td>${escape(r.title || '')}</td>`;
485
+ content += `<td><a href="${this.urlBase}/package/${encodeURIComponent(r.package)}">${escape(r.package)}#${escape(r.version)}</a></td>`;
486
+ content += '</tr>';
487
+ }
488
+ content += '</table>';
489
+
490
+ content += this.buildColumnFilters('prof-table', [
491
+ { type: 'select', options: resourceTypes },
492
+ { type: 'text' },
493
+ { type: 'text' },
494
+ { type: 'text' }
495
+ ], 'proff');
496
+
497
+ this.stats.countRequest('handleProfiles', Date.now() - startTime);
498
+ this.sendHtmlResponse(res, 'Profiles', content, startTime);
499
+ } catch (error) {
500
+ console.log('Extension tracker: error rendering profiles:', error);
501
+ htmlServer.sendErrorResponse(res, TEMPLATE_NAME, error);
502
+ }
503
+ }
504
+
505
+ handleUsage(req, res) {
506
+ const startTime = Date.now();
507
+ try {
508
+ // Aggregate usages by extension_url + location, counting distinct packages
509
+ const rows = this.db.prepare(`
510
+ SELECT u.extension_url, u.location, COUNT(DISTINCT u.package_id) as package_count
511
+ FROM usages u
512
+ GROUP BY u.extension_url, u.location
513
+ ORDER BY u.extension_url, u.location
514
+ `).all();
515
+
516
+ let content = `<p><span id="usage-table-count">${rows.length}</span> usage entries (aggregated across packages)</p>`;
517
+ content += '<table class="grid" id="usage-table"><tr><th>Extension</th><th>Used on</th><th>Packages</th></tr>';
518
+ for (const r of rows) {
519
+ content += '<tr>';
520
+ content += `<td>${escape(r.extension_url)}</td>`;
521
+ content += `<td>${escape(r.location)}</td>`;
522
+ content += `<td><a href="${this.urlBase}/usage/packages?url=${encodeURIComponent(r.extension_url)}&location=${encodeURIComponent(r.location)}">${r.package_count}</a></td>`;
523
+ content += '</tr>';
524
+ }
525
+ content += '</table>';
526
+
527
+ content += this.buildColumnFilters('usage-table', [
528
+ { type: 'text' }, { type: 'text' }, { type: 'text' }
529
+ ], 'usagef');
530
+
531
+ this.stats.countRequest('handleUsage', Date.now() - startTime);
532
+ this.sendHtmlResponse(res, 'Extension Usage', content, startTime);
533
+ } catch (error) {
534
+ console.log('Extension tracker: error rendering usage:', error);
535
+ htmlServer.sendErrorResponse(res, TEMPLATE_NAME, error);
536
+ }
537
+ }
538
+
539
+ handleUsageByPackage(req, res) {
540
+ const startTime = Date.now();
541
+ try {
542
+ const filterUrl = req.query.url || null;
543
+ const filterLocation = req.query.location || null;
544
+
545
+ let query = `
546
+ SELECT u.extension_url, u.location, p.package, p.version
547
+ FROM usages u
548
+ JOIN packages p ON p.id = u.package_id
549
+ `;
550
+ const conditions = [];
551
+ const params = [];
552
+ if (filterUrl) {
553
+ conditions.push('u.extension_url = ?');
554
+ params.push(filterUrl);
555
+ }
556
+ if (filterLocation) {
557
+ conditions.push('u.location = ?');
558
+ params.push(filterLocation);
559
+ }
560
+ if (conditions.length > 0) {
561
+ query += ' WHERE ' + conditions.join(' AND ');
562
+ }
563
+ query += ' ORDER BY p.package, u.extension_url, u.location';
564
+
565
+ const rows = this.db.prepare(query).all(...params);
566
+
567
+ let content = '<p><a href="' + this.urlBase + '/usage">&laquo; Back to aggregated usage</a></p>';
568
+ if (filterUrl || filterLocation) {
569
+ const parts = [];
570
+ if (filterUrl) parts.push(`extension: <b>${escape(filterUrl)}</b>`);
571
+ if (filterLocation) parts.push(`location: <b>${escape(filterLocation)}</b>`);
572
+ content += `<p>Filtered to ${parts.join(', ')}</p>`;
573
+ }
574
+
575
+ content += `<p><span id="usagepkg-table-count">${rows.length}</span> usage entries by package</p>`;
576
+ content += '<table class="grid" id="usagepkg-table"><tr><th>Extension</th><th>Used on</th><th>Package</th></tr>';
577
+ for (const r of rows) {
578
+ content += '<tr>';
579
+ content += `<td>${escape(r.extension_url)}</td>`;
580
+ content += `<td>${escape(r.location)}</td>`;
581
+ content += `<td><a href="${this.urlBase}/package/${encodeURIComponent(r.package)}">${escape(r.package)}#${escape(r.version)}</a></td>`;
582
+ content += '</tr>';
583
+ }
584
+ content += '</table>';
585
+
586
+ content += this.buildColumnFilters('usagepkg-table', [
587
+ { type: 'text' }, { type: 'text' }, { type: 'text' }
588
+ ], 'usagepkgf');
589
+
590
+ this.stats.countRequest('handleUsageByPackage', Date.now() - startTime);
591
+ this.sendHtmlResponse(res, 'Usage by Package', content, startTime);
592
+ } catch (error) {
593
+ console.log('Extension tracker: error rendering usage by package:', error);
594
+ htmlServer.sendErrorResponse(res, TEMPLATE_NAME, error);
595
+ }
596
+ }
597
+
598
+ handlePackageDetail(req, res) {
599
+ const startTime = Date.now();
600
+ try {
601
+ const pkgName = req.params.pkg;
602
+ const pkg = this.db.prepare('SELECT * FROM packages WHERE package = ?').get(pkgName);
603
+ if (!pkg) {
604
+ return res.status(404).send('Package not found');
605
+ }
606
+
607
+ const extensions = this.db.prepare(`
608
+ SELECT e.url, e.title, GROUP_CONCAT(DISTINCT et.type) as types
609
+ FROM extensions e
610
+ LEFT JOIN extension_types et ON et.extension_id = e.id
611
+ WHERE e.package_id = ?
612
+ GROUP BY e.id ORDER BY e.url
613
+ `).all(pkg.id);
614
+
615
+ const profiles = this.db.prepare(
616
+ 'SELECT resource_type, url, title FROM profiles WHERE package_id = ? ORDER BY resource_type, url'
617
+ ).all(pkg.id);
618
+
619
+ const usages = this.db.prepare(
620
+ 'SELECT extension_url, location FROM usages WHERE package_id = ? ORDER BY extension_url, location'
621
+ ).all(pkg.id);
622
+
623
+ let content = '<table class="grid">';
624
+ content += `<tr><td><b>Package</b></td><td>${escape(pkg.package)}</td></tr>`;
625
+ content += `<tr><td><b>Version</b></td><td>${escape(pkg.version)}</td></tr>`;
626
+ content += `<tr><td><b>FHIR Version</b></td><td>${escape(pkg.fhir_version)}</td></tr>`;
627
+ content += `<tr><td><b>Jurisdiction</b></td><td>${escape(pkg.jurisdiction || '')}</td></tr>`;
628
+ content += `<tr><td><b>Submitted</b></td><td>${escape(pkg.submitted_at)}</td></tr>`;
629
+ content += '</table>';
630
+
631
+ if (extensions.length > 0) {
632
+ content += `<h3>Extensions Defined (${extensions.length})</h3>`;
633
+ content += '<table class="grid"><tr><th>URL</th><th>Title</th><th>Types</th></tr>';
634
+ for (const e of extensions) {
635
+ content += '<tr>';
636
+ content += `<td>${escape(e.url)}</td>`;
637
+ content += `<td>${escape(e.title || '')}</td>`;
638
+ content += `<td>${escape(e.types || '')}</td>`;
639
+ content += '</tr>';
640
+ }
641
+ content += '</table>';
642
+ }
643
+
644
+ if (profiles.length > 0) {
645
+ content += `<h3>Profiles Defined (${profiles.length})</h3>`;
646
+ content += '<table class="grid"><tr><th>Resource</th><th>URL</th><th>Title</th></tr>';
647
+ for (const p of profiles) {
648
+ content += '<tr>';
649
+ content += `<td>${escape(p.resource_type)}</td>`;
650
+ content += `<td>${escape(p.url)}</td>`;
651
+ content += `<td>${escape(p.title || '')}</td>`;
652
+ content += '</tr>';
653
+ }
654
+ content += '</table>';
655
+ }
656
+
657
+ if (usages.length > 0) {
658
+ content += `<h3>Extension Usage (${usages.length})</h3>`;
659
+ content += '<table class="grid"><tr><th>Extension</th><th>Used on</th></tr>';
660
+ for (const u of usages) {
661
+ content += '<tr>';
662
+ content += `<td><a href="${this.urlBase}/usage?url=${encodeURIComponent(u.extension_url)}">${escape(u.extension_url)}</a></td>`;
663
+ content += `<td>${escape(u.location)}</td>`;
664
+ content += '</tr>';
665
+ }
666
+ content += '</table>';
667
+ }
668
+ this.stats.countRequest('packageDetail', Date.now() - startTime);
669
+
670
+ this.sendHtmlResponse(res, `${escape(pkgName)}#${escape(pkg.version)}`, content, startTime);
671
+ } catch (error) {
672
+ console.log('Extension tracker: error rendering package detail:', error);
673
+ htmlServer.sendErrorResponse(res, TEMPLATE_NAME, error);
674
+ }
675
+ }
676
+
677
+ // ---- Module lifecycle ----
678
+
679
+ shutdown() {
680
+ if (this.db) {
681
+ this.db.close();
682
+ this.db = null;
683
+ }
684
+ }
685
+
686
+ getStatus() {
687
+ let startTime = Date.now();
688
+ if (!this.db) {
689
+ return { status: 'closed' };
690
+ }
691
+ const count = this.db.prepare('SELECT COUNT(*) as count FROM packages').get().count;
692
+ this.stats.countRequest('search', Date.now() - startTime);
693
+ return { status: 'running', packages: count };
694
+ }
695
+ }
696
+
697
+ module.exports = ExtensionTrackerModule;