fhirsmith 0.8.0 → 0.8.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.
@@ -6,6 +6,7 @@ const bcrypt = require('bcrypt');
6
6
  const session = require('express-session');
7
7
  const folders = require('../library/folder-setup');
8
8
  const escape = require('escape-html');
9
+ const {Utilities} = require("../library/utilities");
9
10
 
10
11
 
11
12
  class PublisherModule {
@@ -51,8 +52,8 @@ class PublisherModule {
51
52
  async initializeDatabase() {
52
53
  // Ensure database directory exists
53
54
  const dbPath = path.isAbsolute(this.config.database)
54
- ? this.config.database
55
- : folders.filePath('publisher', this.config.database);
55
+ ? this.config.database
56
+ : folders.filePath('publisher', this.config.database);
56
57
  const dbDir = path.dirname(dbPath);
57
58
  const fs = require('fs');
58
59
  if (!fs.existsSync(dbDir)) {
@@ -78,84 +79,84 @@ class PublisherModule {
78
79
  const tables = [
79
80
  // Users table
80
81
  `CREATE TABLE IF NOT EXISTS users (
81
- id INTEGER PRIMARY KEY AUTOINCREMENT,
82
- name TEXT NOT NULL,
83
- login TEXT UNIQUE NOT NULL,
84
- password_hash TEXT NOT NULL,
85
- is_admin BOOLEAN DEFAULT 0,
86
- created_at DATETIME DEFAULT CURRENT_TIMESTAMP
82
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
83
+ name TEXT NOT NULL,
84
+ login TEXT UNIQUE NOT NULL,
85
+ password_hash TEXT NOT NULL,
86
+ is_admin BOOLEAN DEFAULT 0,
87
+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP
87
88
  )`,
88
89
 
89
90
  // Websites table
90
91
  `CREATE TABLE IF NOT EXISTS websites (
91
- id INTEGER PRIMARY KEY AUTOINCREMENT,
92
- name TEXT NOT NULL,
93
- local_folder TEXT NOT NULL,
94
- history_templates TEXT NOT NULL,
95
- web_templates TEXT NOT NULL,
96
- server_update_script TEXT NOT NULL,
97
- is_active BOOLEAN DEFAULT 1,
98
- created_at DATETIME DEFAULT CURRENT_TIMESTAMP
92
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
93
+ name TEXT NOT NULL,
94
+ local_folder TEXT NOT NULL,
95
+ history_templates TEXT NOT NULL,
96
+ web_templates TEXT NOT NULL,
97
+ server_update_script TEXT NOT NULL,
98
+ is_active BOOLEAN DEFAULT 1,
99
+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP
99
100
  )`,
100
101
 
101
102
  // User website permissions
102
103
  `CREATE TABLE IF NOT EXISTS user_website_permissions (
103
- user_id INTEGER,
104
- website_id INTEGER,
105
- can_queue BOOLEAN DEFAULT 0,
106
- can_approve BOOLEAN DEFAULT 0,
107
- PRIMARY KEY (user_id, website_id),
108
- FOREIGN KEY (user_id) REFERENCES users (id),
109
- FOREIGN KEY (website_id) REFERENCES websites (id)
110
- )`,
104
+ user_id INTEGER,
105
+ website_id INTEGER,
106
+ can_queue BOOLEAN DEFAULT 0,
107
+ can_approve BOOLEAN DEFAULT 0,
108
+ PRIMARY KEY (user_id, website_id),
109
+ FOREIGN KEY (user_id) REFERENCES users (id),
110
+ FOREIGN KEY (website_id) REFERENCES websites (id)
111
+ )`,
111
112
 
112
113
  // Tasks table
113
114
  `CREATE TABLE IF NOT EXISTS tasks (
114
- id INTEGER PRIMARY KEY AUTOINCREMENT,
115
- user_id INTEGER NOT NULL,
116
- website_id INTEGER NOT NULL,
117
- status TEXT DEFAULT 'queued',
118
- github_org TEXT NOT NULL,
119
- github_repo TEXT NOT NULL,
120
- git_branch TEXT NOT NULL,
121
- npm_package_id TEXT NOT NULL,
122
- version TEXT NOT NULL,
123
- local_folder TEXT,
124
- build_output_path TEXT,
125
- failure_reason TEXT,
126
- announcement TEXT,
127
- approved_by INTEGER,
128
- queued_at DATETIME DEFAULT CURRENT_TIMESTAMP,
129
- building_at DATETIME,
130
- waiting_approval_at DATETIME,
131
- publishing_at DATETIME,
132
- completed_at DATETIME,
133
- failed_at DATETIME,
134
- FOREIGN KEY (user_id) REFERENCES users (id),
135
- FOREIGN KEY (website_id) REFERENCES websites (id),
136
- FOREIGN KEY (approved_by) REFERENCES users (id)
137
- )`,
115
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
116
+ user_id INTEGER NOT NULL,
117
+ website_id INTEGER NOT NULL,
118
+ status TEXT DEFAULT 'queued',
119
+ github_org TEXT NOT NULL,
120
+ github_repo TEXT NOT NULL,
121
+ git_branch TEXT NOT NULL,
122
+ npm_package_id TEXT NOT NULL,
123
+ version TEXT NOT NULL,
124
+ local_folder TEXT,
125
+ build_output_path TEXT,
126
+ failure_reason TEXT,
127
+ announcement TEXT,
128
+ approved_by INTEGER,
129
+ queued_at DATETIME DEFAULT CURRENT_TIMESTAMP,
130
+ building_at DATETIME,
131
+ waiting_approval_at DATETIME,
132
+ publishing_at DATETIME,
133
+ completed_at DATETIME,
134
+ failed_at DATETIME,
135
+ FOREIGN KEY (user_id) REFERENCES users (id),
136
+ FOREIGN KEY (website_id) REFERENCES websites (id),
137
+ FOREIGN KEY (approved_by) REFERENCES users (id)
138
+ )`,
138
139
 
139
140
  // Task logs
140
141
  `CREATE TABLE IF NOT EXISTS task_logs (
141
- id INTEGER PRIMARY KEY AUTOINCREMENT,
142
- task_id TEXT NOT NULL,
143
- timestamp DATETIME DEFAULT CURRENT_TIMESTAMP,
144
- level TEXT NOT NULL,
145
- message TEXT NOT NULL,
146
- FOREIGN KEY (task_id) REFERENCES tasks (id)
147
- )`,
142
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
143
+ task_id TEXT NOT NULL,
144
+ timestamp DATETIME DEFAULT CURRENT_TIMESTAMP,
145
+ level TEXT NOT NULL,
146
+ message TEXT NOT NULL,
147
+ FOREIGN KEY (task_id) REFERENCES tasks (id)
148
+ )`,
148
149
 
149
150
  // User actions audit
150
151
  `CREATE TABLE IF NOT EXISTS user_actions (
151
- id INTEGER PRIMARY KEY AUTOINCREMENT,
152
- user_id INTEGER,
153
- action TEXT NOT NULL,
154
- target_id TEXT,
155
- timestamp DATETIME DEFAULT CURRENT_TIMESTAMP,
156
- ip_address TEXT,
157
- FOREIGN KEY (user_id) REFERENCES users (id)
158
- )`
152
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
153
+ user_id INTEGER,
154
+ action TEXT NOT NULL,
155
+ target_id TEXT,
156
+ timestamp DATETIME DEFAULT CURRENT_TIMESTAMP,
157
+ ip_address TEXT,
158
+ FOREIGN KEY (user_id) REFERENCES users (id)
159
+ )`
159
160
  ];
160
161
 
161
162
  for (const sql of tables) {
@@ -235,17 +236,17 @@ class PublisherModule {
235
236
  }
236
237
 
237
238
  this.db.run(
238
- 'INSERT INTO users (name, login, password_hash, is_admin) VALUES (?, ?, ?, ?)',
239
- ['Administrator', 'admin', hash, 1],
240
- (err) => {
241
- if (err) {
242
- this.logger.error('Failed to create default admin:', err);
243
- reject(err);
244
- } else {
245
- this.logger.warn('Created default admin user - login: admin, password: admin123 - CHANGE THIS!');
246
- resolve();
239
+ 'INSERT INTO users (name, login, password_hash, is_admin) VALUES (?, ?, ?, ?)',
240
+ ['Administrator', 'admin', hash, 1],
241
+ (err) => {
242
+ if (err) {
243
+ this.logger.error('Failed to create default admin:', err);
244
+ reject(err);
245
+ } else {
246
+ this.logger.warn('Created default admin user - login: admin, password: admin123 - CHANGE THIS!');
247
+ resolve();
248
+ }
247
249
  }
248
- }
249
250
  );
250
251
  });
251
252
  } else {
@@ -268,7 +269,7 @@ class PublisherModule {
268
269
  this.router.get('/tasks', this.renderTasks.bind(this));
269
270
  this.router.post('/tasks', this.requireAuth.bind(this), this.createTask.bind(this));
270
271
  this.router.post('/tasks/:id/approve', this.requireAuth.bind(this), this.approveTask.bind(this));
271
- this.router.post('/tasks/:id/delete', this.requireAdmin.bind(this), this.deleteTask.bind(this));
272
+ this.router.post('/tasks/:id/delete', this.requireAuth.bind(this), this.deleteTask.bind(this));
272
273
  this.router.post('/tasks/:id/retry', this.requireAuth.bind(this), this.retryTask.bind(this));
273
274
  this.router.get('/tasks/:id/output', this.getTaskOutput.bind(this));
274
275
  this.router.get('/tasks/:id/history', this.getTaskHistory.bind(this));
@@ -301,6 +302,8 @@ class PublisherModule {
301
302
  this.logger.info('Starting task processor with ' + pollInterval + 'ms poll interval');
302
303
  this.isProcessingStarted = null;
303
304
 
305
+ this.stats.addTask('Publisher', Utilities.formatDuration(pollInterval)); // or however you want to display the frequency
306
+
304
307
  this.taskProcessor = setInterval(async () => {
305
308
  if (this.shutdownRequested) return;
306
309
 
@@ -326,20 +329,25 @@ class PublisherModule {
326
329
  // Look for queued tasks first (draft builds)
327
330
  let task = await this.getNextQueuedTask();
328
331
  if (task) {
332
+ this.stats.task('Publisher', 'Building ' + task.npm_package_id + '#' + task.version);
329
333
  await this.processDraftBuild(task);
334
+ this.stats.taskDone('Publisher', 'Built ' + task.npm_package_id + '#' + task.version);
330
335
  return;
331
336
  }
332
337
 
333
338
  // Then look for approved tasks (publishing)
334
339
  task = await this.getNextApprovedTask();
335
340
  if (task) {
341
+ this.stats.task('Publisher', 'Publishing ' + task.npm_package_id + '#' + task.version);
336
342
  await this.processPublication(task);
343
+ this.stats.taskDone('Publisher', 'Published ' + task.npm_package_id + '#' + task.version);
337
344
  return;
338
345
  }
339
346
 
340
347
  // No tasks to process
341
348
  } catch (error) {
342
349
  this.logger.error('Error in task processor:', error);
350
+ this.stats.taskError('Publisher', 'Error: ' + error.message);
343
351
  } finally {
344
352
  this.isProcessing = false;
345
353
  }
@@ -348,12 +356,12 @@ class PublisherModule {
348
356
  async getNextQueuedTask() {
349
357
  return new Promise((resolve, reject) => {
350
358
  this.db.get(
351
- 'SELECT * FROM tasks WHERE status = ? ORDER BY queued_at ASC LIMIT 1',
352
- ['queued'],
353
- (err, row) => {
354
- if (err) reject(err);
355
- else resolve(row);
356
- }
359
+ 'SELECT * FROM tasks WHERE status = ? ORDER BY queued_at ASC LIMIT 1',
360
+ ['queued'],
361
+ (err, row) => {
362
+ if (err) reject(err);
363
+ else resolve(row);
364
+ }
357
365
  );
358
366
  });
359
367
  }
@@ -361,12 +369,12 @@ class PublisherModule {
361
369
  async getNextApprovedTask() {
362
370
  return new Promise((resolve, reject) => {
363
371
  this.db.get(
364
- 'SELECT * FROM tasks WHERE status = ? ORDER BY publishing_at ASC LIMIT 1',
365
- ['publishing'],
366
- (err, row) => {
367
- if (err) reject(err);
368
- else resolve(row);
369
- }
372
+ 'SELECT * FROM tasks WHERE status = ? ORDER BY publishing_at ASC LIMIT 1',
373
+ ['publishing'],
374
+ (err, row) => {
375
+ if (err) reject(err);
376
+ else resolve(row);
377
+ }
370
378
  );
371
379
  });
372
380
  }
@@ -396,12 +404,12 @@ class PublisherModule {
396
404
 
397
405
  return new Promise((resolve, reject) => {
398
406
  this.db.run(
399
- 'UPDATE tasks SET ' + fields.join(', ') + ' WHERE id = ?',
400
- values,
401
- (err) => {
402
- if (err) reject(err);
403
- else resolve();
404
- }
407
+ 'UPDATE tasks SET ' + fields.join(', ') + ' WHERE id = ?',
408
+ values,
409
+ (err) => {
410
+ if (err) reject(err);
411
+ else resolve();
412
+ }
405
413
  );
406
414
  });
407
415
  }
@@ -409,9 +417,9 @@ class PublisherModule {
409
417
  async logTaskMessage(taskId, level, message) {
410
418
  return new Promise((resolve) => {
411
419
  this.db.run(
412
- 'INSERT INTO task_logs (task_id, level, message) VALUES (?, ?, ?)',
413
- [taskId.toString(), level, message],
414
- () => resolve() // Don't fail if logging fails
420
+ 'INSERT INTO task_logs (task_id, level, message) VALUES (?, ?, ?)',
421
+ [taskId.toString(), level, message],
422
+ () => resolve() // Don't fail if logging fails
415
423
  );
416
424
  });
417
425
  }
@@ -467,8 +475,8 @@ class PublisherModule {
467
475
 
468
476
  async runDraftBuild(task) {
469
477
  const workspaceRoot = path.isAbsolute(this.config.workspaceRoot)
470
- ? this.config.workspaceRoot
471
- : folders.filePath('publisher', this.config.workspaceRoot);
478
+ ? this.config.workspaceRoot
479
+ : folders.filePath('publisher', this.config.workspaceRoot);
472
480
 
473
481
  const taskDir = path.join(workspaceRoot, 'task-' + task.id);
474
482
  const draftDir = path.join(taskDir, 'draft');
@@ -521,7 +529,7 @@ class PublisherModule {
521
529
  // Get latest release info from GitHub API
522
530
  const releaseResponse = await axios.get('https://api.github.com/repos/HL7/fhir-ig-publisher/releases/latest');
523
531
  const downloadUrl = releaseResponse.data.assets.find(asset =>
524
- asset.name === 'publisher.jar'
532
+ asset.name === 'publisher.jar'
525
533
  )?.browser_download_url;
526
534
 
527
535
  if (!downloadUrl) {
@@ -710,13 +718,13 @@ class PublisherModule {
710
718
  const templatesDir = path.join(taskDir, 'fhir-web-templates');
711
719
 
712
720
  await this.runCommand('git', ['clone', 'git@github.com:FHIR/ig-registry.git', registryDir],
713
- {}, task.id, 'Cloning ig-registry');
721
+ {}, task.id, 'Cloning ig-registry');
714
722
 
715
723
  await this.runCommand('git', ['clone', 'https://github.com/HL7/fhir-ig-history-template.git', historyDir],
716
- {}, task.id, 'Cloning fhir-ig-history-template');
724
+ {}, task.id, 'Cloning fhir-ig-history-template');
717
725
 
718
726
  await this.runCommand('git', ['clone', 'https://github.com/HL7/fhir-web-templates.git', templatesDir],
719
- {}, task.id, 'Cloning fhir-web-templates');
727
+ {}, task.id, 'Cloning fhir-web-templates');
720
728
 
721
729
  // Step 2: Reuse the publisher.jar from the draft build
722
730
  const publisherJar = path.join(taskDir, 'publisher.jar');
@@ -729,7 +737,7 @@ class PublisherModule {
729
737
 
730
738
  // Step 4: Run the IG publisher in go-publish mode
731
739
  await this.runPublisherGoPublish(task.id, publisherJar, draftDir, website.local_folder,
732
- registryDir, historyDir, templatesDir, zipsDir, publishLogFile);
740
+ registryDir, historyDir, templatesDir, zipsDir, publishLogFile);
733
741
 
734
742
  // Step 5: Verify publication succeeded by checking for the log file
735
743
  const pubLogName = task.npm_package_id + '#' + task.version + '.log';
@@ -886,7 +894,7 @@ class PublisherModule {
886
894
  content += '<a href="/publisher/admin/users" class="btn btn-secondary">Manage Users</a>';
887
895
  }
888
896
  content += '<form style="display: inline-block; margin-left: 10px;" method="post" action="/publisher/logout">';
889
- content += '<button type="submit" class="btn btn-outline-secondary">Logout</button>';
897
+ content += '<button type="submit" class="btn btn-outline-secondary">' + escape(req.session.userName) + ' \u2014 Logout</button>';
890
898
  content += '</form>';
891
899
  content += '</div>';
892
900
  } else {
@@ -924,7 +932,7 @@ class PublisherModule {
924
932
  const html = htmlServer.renderPage('publisher', 'FHIR Publisher', content, {
925
933
  taskCount: tasks.length,
926
934
  templateVars: {
927
- loginTitle: req.session.userId ? "Logout" : 'Login',
935
+ loginTitle: req.session.userId ? (req.session.userName + ' \u2014 Logout') : 'Login',
928
936
  loginPath: req.session.userId ? "logout" : 'login',
929
937
  loginAction: req.session.userId ? "POST" : 'GET'
930
938
  }
@@ -966,7 +974,7 @@ class PublisherModule {
966
974
 
967
975
  const html = htmlServer.renderPage('publisher', 'Login - FHIR Publisher', content, {
968
976
  templateVars: {
969
- loginTitle: req.session.userId ? "Logout" : 'Login',
977
+ loginTitle: req.session.userId ? (req.session.userName + ' \u2014 Logout') : 'Login',
970
978
  loginPath: req.session.userId ? "logout" : 'login',
971
979
  loginAction: req.session.userId ? "POST" : 'GET'
972
980
  }});
@@ -986,12 +994,12 @@ class PublisherModule {
986
994
 
987
995
  const user = await new Promise((resolve, reject) => {
988
996
  this.db.get(
989
- 'SELECT * FROM users WHERE login = ?',
990
- [login],
991
- (err, row) => {
992
- if (err) reject(err);
993
- else resolve(row);
994
- }
997
+ 'SELECT * FROM users WHERE login = ?',
998
+ [login],
999
+ (err, row) => {
1000
+ if (err) reject(err);
1001
+ else resolve(row);
1002
+ }
995
1003
  );
996
1004
  });
997
1005
 
@@ -1100,7 +1108,15 @@ class PublisherModule {
1100
1108
 
1101
1109
  for (const task of tasks) {
1102
1110
  const canApprove = req.session.userId && task.status === 'waiting for approval' &&
1103
- await this.userCanApprove(req.session.userId, task.website_id);
1111
+ await this.userCanApprove(req.session.userId, task.website_id);
1112
+
1113
+ // Determine if the current user can delete this task (mirrors deleteTask backend logic)
1114
+ const isPreApprovalStatus = task.status === 'waiting for approval' || task.status === 'queued' || (task.status === 'failed' && !task.approved_by);
1115
+ const isPostApprovalFailed = task.status === 'failed' && task.approved_by;
1116
+ const canDelete = req.session.userId && (
1117
+ (isPreApprovalStatus && await this.userCanQueue(req.session.userId, task.website_id)) ||
1118
+ (isPostApprovalFailed && req.session.isAdmin)
1119
+ );
1104
1120
 
1105
1121
  content += '<tr>';
1106
1122
  content += '<td><strong>#' + task.id + '</strong></td>';
@@ -1134,7 +1150,7 @@ class PublisherModule {
1134
1150
  }
1135
1151
  }
1136
1152
 
1137
- if (req.session.isAdmin && (task.status === 'waiting for approval' || task.status === 'failed')) {
1153
+ if (canDelete) {
1138
1154
  content += '<form method="post" action="/publisher/tasks/' + task.id + '/delete" style="display: inline;" onsubmit="return confirm(\'Delete task #' + task.id + ' and all its build output? This cannot be undone.\')">';
1139
1155
  content += '<button type="submit" class="btn btn-sm btn-danger">Delete</button>';
1140
1156
  content += '</form>';
@@ -1159,7 +1175,7 @@ class PublisherModule {
1159
1175
 
1160
1176
  const html = htmlServer.renderPage('publisher', 'Tasks - FHIR Publisher', content, {
1161
1177
  templateVars: {
1162
- loginTitle: req.session.userId ? "Logout" : 'Login',
1178
+ loginTitle: req.session.userId ? (req.session.userName + ' \u2014 Logout') : 'Login',
1163
1179
  loginPath: req.session.userId ? "logout" : 'login',
1164
1180
  loginAction: req.session.userId ? "POST" : 'GET'
1165
1181
  }});
@@ -1198,12 +1214,12 @@ class PublisherModule {
1198
1214
  // Insert task (ID will be auto-generated)
1199
1215
  const result = await new Promise((resolve, reject) => {
1200
1216
  this.db.run(
1201
- 'INSERT INTO tasks (user_id, website_id, github_org, github_repo, git_branch, npm_package_id, version) VALUES (?, ?, ?, ?, ?, ?, ?)',
1202
- [req.session.userId, website_id, github_org, github_repo, git_branch, npm_package_id, version],
1203
- function (err) {
1204
- if (err) reject(err);
1205
- else resolve(this.lastID);
1206
- }
1217
+ 'INSERT INTO tasks (user_id, website_id, github_org, github_repo, git_branch, npm_package_id, version) VALUES (?, ?, ?, ?, ?, ?, ?)',
1218
+ [req.session.userId, website_id, github_org, github_repo, git_branch, npm_package_id, version],
1219
+ function (err) {
1220
+ if (err) reject(err);
1221
+ else resolve(this.lastID);
1222
+ }
1207
1223
  );
1208
1224
  });
1209
1225
 
@@ -1248,12 +1264,12 @@ class PublisherModule {
1248
1264
  // Update task status
1249
1265
  await new Promise((resolve, reject) => {
1250
1266
  this.db.run(
1251
- 'UPDATE tasks SET status = ?, approved_by = ?, publishing_at = CURRENT_TIMESTAMP WHERE id = ?',
1252
- ['publishing', req.session.userId, taskId],
1253
- (err) => {
1254
- if (err) reject(err);
1255
- else resolve();
1256
- }
1267
+ 'UPDATE tasks SET status = ?, approved_by = ?, publishing_at = CURRENT_TIMESTAMP WHERE id = ?',
1268
+ ['publishing', req.session.userId, taskId],
1269
+ (err) => {
1270
+ if (err) reject(err);
1271
+ else resolve();
1272
+ }
1257
1273
  );
1258
1274
  });
1259
1275
 
@@ -1282,8 +1298,22 @@ class PublisherModule {
1282
1298
  return res.status(404).send('Task not found');
1283
1299
  }
1284
1300
 
1285
- if (task.status !== 'waiting for approval' && task.status !== 'failed') {
1286
- return res.status(400).send('Only tasks waiting for approval or failed can be deleted');
1301
+ if (task.status !== 'waiting for approval' && task.status !== 'failed' && task.status !== 'queued') {
1302
+ return res.status(400).send('Only tasks that are queued, waiting for approval, or failed can be deleted');
1303
+ }
1304
+
1305
+ // Pre-approval statuses: any user with queue rights on this website can delete
1306
+ // Post-approval (failed after publishing started): admin only
1307
+ const isPostApproval = task.status === 'failed' && task.approved_by;
1308
+ if (isPostApproval) {
1309
+ if (!req.session.isAdmin) {
1310
+ return res.status(403).send('Admin access required to delete a task that has been approved');
1311
+ }
1312
+ } else {
1313
+ const canQueue = await this.userCanQueue(req.session.userId, task.website_id);
1314
+ if (!canQueue) {
1315
+ return res.status(403).send('You do not have permission to delete tasks for this website');
1316
+ }
1287
1317
  }
1288
1318
 
1289
1319
  // Remove build output directory
@@ -1312,7 +1342,7 @@ class PublisherModule {
1312
1342
  });
1313
1343
 
1314
1344
  this.logUserAction(req.session.userId, 'delete_task', taskId, req.ip);
1315
- this.logger.info('Task deleted: ' + taskId + ' by admin ' + req.session.userId);
1345
+ this.logger.info('Task deleted: ' + taskId + ' by user ' + req.session.userId);
1316
1346
  res.redirect('/publisher/tasks');
1317
1347
  } catch (error) {
1318
1348
  this.logger.error('Error deleting task:', error);
@@ -1337,9 +1367,9 @@ class PublisherModule {
1337
1367
  if (existingTask) return res.status(400).send('An active task for this package and version is already in progress.');
1338
1368
  const newTaskId = await new Promise((resolve, reject) => {
1339
1369
  this.db.run(
1340
- 'INSERT INTO tasks (user_id, website_id, github_org, github_repo, git_branch, npm_package_id, version) VALUES (?, ?, ?, ?, ?, ?, ?)',
1341
- [req.session.userId, task.website_id, task.github_org, task.github_repo, task.git_branch, task.npm_package_id, task.version],
1342
- function (err) { if (err) reject(err); else resolve(this.lastID); }
1370
+ 'INSERT INTO tasks (user_id, website_id, github_org, github_repo, git_branch, npm_package_id, version) VALUES (?, ?, ?, ?, ?, ?, ?)',
1371
+ [req.session.userId, task.website_id, task.github_org, task.github_repo, task.git_branch, task.npm_package_id, task.version],
1372
+ function (err) { if (err) reject(err); else resolve(this.lastID); }
1343
1373
  );
1344
1374
  });
1345
1375
  this.logUserAction(req.session.userId, 'retry_task', newTaskId.toString(), req.ip);
@@ -1449,7 +1479,7 @@ class PublisherModule {
1449
1479
 
1450
1480
  const html = htmlServer.renderPage('publisher', 'Task Output - FHIR Publisher', content, {
1451
1481
  templateVars: {
1452
- loginTitle: req.session.userId ? "Logout" : 'Login',
1482
+ loginTitle: req.session.userId ? (req.session.userName + ' \u2014 Logout') : 'Login',
1453
1483
  loginPath: req.session.userId ? "logout" : 'login',
1454
1484
  loginAction: req.session.userId ? "POST" : 'GET'
1455
1485
  }});
@@ -1640,8 +1670,8 @@ class PublisherModule {
1640
1670
  for (const evt of events) {
1641
1671
  const ts = new Date(evt.timestamp).toLocaleString();
1642
1672
  const typeBadge = evt.type === 'status' ? '<span class="badge bg-primary">status</span>'
1643
- : evt.type === 'action' ? '<span class="badge bg-info">user</span>'
1644
- : '<span class="badge bg-secondary">log</span>';
1673
+ : evt.type === 'action' ? '<span class="badge bg-info">user</span>'
1674
+ : '<span class="badge bg-secondary">log</span>';
1645
1675
  content += '<tr>';
1646
1676
  content += '<td class="text-nowrap"><small>' + ts + '</small></td>';
1647
1677
  content += '<td>' + evt.icon + '</td>';
@@ -1669,7 +1699,16 @@ class PublisherModule {
1669
1699
  content += '<button type="submit" class="btn btn-warning">Retry</button>';
1670
1700
  content += '</form>';
1671
1701
  }
1672
- if (req.session.isAdmin && task.status === 'failed') {
1702
+ // Show delete button using same logic as backend deleteTask:
1703
+ // Pre-approval (queued, waiting for approval, or failed without approval): user with queue rights can delete
1704
+ // Post-approval (failed after approval): admin only
1705
+ const detailIsPreApproval = task.status === 'waiting for approval' || task.status === 'queued' || (task.status === 'failed' && !task.approved_by);
1706
+ const detailIsPostApprovalFailed = task.status === 'failed' && task.approved_by;
1707
+ const detailCanDelete = req.session.userId && (
1708
+ (detailIsPreApproval && await this.userCanQueue(req.session.userId, task.website_id)) ||
1709
+ (detailIsPostApprovalFailed && req.session.isAdmin)
1710
+ );
1711
+ if (detailCanDelete) {
1673
1712
  content += '<form method="post" action="/publisher/tasks/' + task.id + '/delete" style="display: inline;" class="me-2" onsubmit="return confirm(\'Delete task #' + task.id + ' and all its build output? This cannot be undone.\')">';
1674
1713
  content += '<button type="submit" class="btn btn-danger">Delete</button>';
1675
1714
  content += '</form>';
@@ -1679,7 +1718,7 @@ class PublisherModule {
1679
1718
 
1680
1719
  const html = htmlServer.renderPage('publisher', 'Task History - FHIR Publisher', content, {
1681
1720
  templateVars: {
1682
- loginTitle: req.session.userId ? "Logout" : 'Login',
1721
+ loginTitle: req.session.userId ? (req.session.userName + ' \u2014 Logout') : 'Login',
1683
1722
  loginPath: req.session.userId ? "logout" : 'login',
1684
1723
  loginAction: req.session.userId ? "POST" : 'GET'
1685
1724
  }});
@@ -1722,7 +1761,7 @@ class PublisherModule {
1722
1761
  content += '</form>';
1723
1762
 
1724
1763
  const html = htmlServer.renderPage('publisher', 'Edit Website - FHIR Publisher', content, {
1725
- templateVars: { loginTitle: 'Logout', loginPath: 'logout', loginAction: 'POST' }
1764
+ templateVars: { loginTitle: (req.session.userName || '') + ' \u2014 Logout', loginPath: 'logout', loginAction: 'POST' }
1726
1765
  });
1727
1766
  res.setHeader('Content-Type', 'text/html');
1728
1767
  res.send(html);
@@ -1742,9 +1781,9 @@ class PublisherModule {
1742
1781
 
1743
1782
  await new Promise((resolve, reject) => {
1744
1783
  this.db.run(
1745
- 'UPDATE websites SET name=?, local_folder=?, git_root = ?, history_templates=?, web_templates=?, server_update_script=?, is_active=? WHERE id=?',
1746
- [name, local_folder, git_root, history_templates, web_templates, server_update_script, is_active === '1' ? 1 : 0, websiteId],
1747
- (err) => err ? reject(err) : resolve()
1784
+ 'UPDATE websites SET name=?, local_folder=?, git_root = ?, history_templates=?, web_templates=?, server_update_script=?, is_active=? WHERE id=?',
1785
+ [name, local_folder, git_root, history_templates, web_templates, server_update_script, is_active === '1' ? 1 : 0, websiteId],
1786
+ (err) => err ? reject(err) : resolve()
1748
1787
  );
1749
1788
  });
1750
1789
 
@@ -1840,7 +1879,7 @@ class PublisherModule {
1840
1879
 
1841
1880
  const html = htmlServer.renderPage('publisher', 'Websites - FHIR Publisher', content, {
1842
1881
  templateVars: {
1843
- loginTitle: req.session.userId ? "Logout" : 'Login',
1882
+ loginTitle: req.session.userId ? (req.session.userName + ' \u2014 Logout') : 'Login',
1844
1883
  loginPath: req.session.userId ? "logout" : 'login',
1845
1884
  loginAction: req.session.userId ? "POST" : 'GET'
1846
1885
  }});
@@ -1863,12 +1902,12 @@ class PublisherModule {
1863
1902
 
1864
1903
  await new Promise((resolve, reject) => {
1865
1904
  this.db.run(
1866
- 'INSERT INTO websites (name, local_folder, git_root, history_templates, web_templates, server_update_script) VALUES (?, ?, ?, ?, ?, ?)',
1867
- [name, local_folder, git_root, history_templates, web_templates, server_update_script],
1868
- function (err) {
1869
- if (err) reject(err);
1870
- else resolve();
1871
- }
1905
+ 'INSERT INTO websites (name, local_folder, git_root, history_templates, web_templates, server_update_script) VALUES (?, ?, ?, ?, ?, ?)',
1906
+ [name, local_folder, git_root, history_templates, web_templates, server_update_script],
1907
+ function (err) {
1908
+ if (err) reject(err);
1909
+ else resolve();
1910
+ }
1872
1911
  );
1873
1912
  });
1874
1913
 
@@ -1982,7 +2021,7 @@ class PublisherModule {
1982
2021
 
1983
2022
  const html = htmlServer.renderPage('publisher', 'Users - FHIR Publisher', content, {
1984
2023
  templateVars: {
1985
- loginTitle: req.session.userId ? "Logout" : 'Login',
2024
+ loginTitle: req.session.userId ? (req.session.userName + ' \u2014 Logout') : 'Login',
1986
2025
  loginPath: req.session.userId ? "logout" : 'login',
1987
2026
  loginAction: req.session.userId ? "POST" : 'GET'
1988
2027
  }});
@@ -2007,12 +2046,12 @@ class PublisherModule {
2007
2046
 
2008
2047
  await new Promise((resolve, reject) => {
2009
2048
  this.db.run(
2010
- 'INSERT INTO users (name, login, password_hash, is_admin) VALUES (?, ?, ?, ?)',
2011
- [name, login, passwordHash, is_admin ? 1 : 0],
2012
- function (err) {
2013
- if (err) reject(err);
2014
- else resolve();
2015
- }
2049
+ 'INSERT INTO users (name, login, password_hash, is_admin) VALUES (?, ?, ?, ?)',
2050
+ [name, login, passwordHash, is_admin ? 1 : 0],
2051
+ function (err) {
2052
+ if (err) reject(err);
2053
+ else resolve();
2054
+ }
2016
2055
  );
2017
2056
  });
2018
2057
 
@@ -2057,12 +2096,12 @@ class PublisherModule {
2057
2096
  if (canQueue || canApprove) {
2058
2097
  await new Promise((resolve, reject) => {
2059
2098
  this.db.run(
2060
- 'INSERT INTO user_website_permissions (user_id, website_id, can_queue, can_approve) VALUES (?, ?, ?, ?)',
2061
- [user_id, website.id, canQueue ? 1 : 0, canApprove ? 1 : 0],
2062
- (err) => {
2063
- if (err) reject(err);
2064
- else resolve();
2065
- }
2099
+ 'INSERT INTO user_website_permissions (user_id, website_id, can_queue, can_approve) VALUES (?, ?, ?, ?)',
2100
+ [user_id, website.id, canQueue ? 1 : 0, canApprove ? 1 : 0],
2101
+ (err) => {
2102
+ if (err) reject(err);
2103
+ else resolve();
2104
+ }
2066
2105
  );
2067
2106
  });
2068
2107
  }
@@ -2098,12 +2137,12 @@ class PublisherModule {
2098
2137
  async getTask(taskId) {
2099
2138
  return new Promise((resolve, reject) => {
2100
2139
  this.db.get(
2101
- 'SELECT t.*, u.name as user_name, u.login as user_login, w.name as website_name, approver.name as approved_by_name FROM tasks t JOIN users u ON t.user_id = u.id JOIN websites w ON t.website_id = w.id LEFT JOIN users approver ON t.approved_by = approver.id WHERE t.id = ?',
2102
- [taskId],
2103
- (err, row) => {
2104
- if (err) reject(err);
2105
- else resolve(row);
2106
- }
2140
+ 'SELECT t.*, u.name as user_name, u.login as user_login, w.name as website_name, approver.name as approved_by_name FROM tasks t JOIN users u ON t.user_id = u.id JOIN websites w ON t.website_id = w.id LEFT JOIN users approver ON t.approved_by = approver.id WHERE t.id = ?',
2141
+ [taskId],
2142
+ (err, row) => {
2143
+ if (err) reject(err);
2144
+ else resolve(row);
2145
+ }
2107
2146
  );
2108
2147
  });
2109
2148
  }
@@ -2111,12 +2150,12 @@ class PublisherModule {
2111
2150
  async getTaskLogs(taskId) {
2112
2151
  return new Promise((resolve, reject) => {
2113
2152
  this.db.all(
2114
- 'SELECT * FROM task_logs WHERE task_id = ? ORDER BY timestamp ASC',
2115
- [taskId.toString()],
2116
- (err, rows) => {
2117
- if (err) reject(err);
2118
- else resolve(rows || []);
2119
- }
2153
+ 'SELECT * FROM task_logs WHERE task_id = ? ORDER BY timestamp ASC',
2154
+ [taskId.toString()],
2155
+ (err, rows) => {
2156
+ if (err) reject(err);
2157
+ else resolve(rows || []);
2158
+ }
2120
2159
  );
2121
2160
  });
2122
2161
  }
@@ -2124,12 +2163,12 @@ class PublisherModule {
2124
2163
  async getTaskActions(taskId) {
2125
2164
  return new Promise((resolve, reject) => {
2126
2165
  this.db.all(
2127
- 'SELECT ua.*, u.name as user_name, u.login as user_login FROM user_actions ua LEFT JOIN users u ON ua.user_id = u.id WHERE ua.target_id = ? ORDER BY ua.timestamp ASC',
2128
- [taskId.toString()],
2129
- (err, rows) => {
2130
- if (err) reject(err);
2131
- else resolve(rows || []);
2132
- }
2166
+ 'SELECT ua.*, u.name as user_name, u.login as user_login FROM user_actions ua LEFT JOIN users u ON ua.user_id = u.id WHERE ua.target_id = ? ORDER BY ua.timestamp ASC',
2167
+ [taskId.toString()],
2168
+ (err, rows) => {
2169
+ if (err) reject(err);
2170
+ else resolve(rows || []);
2171
+ }
2133
2172
  );
2134
2173
  });
2135
2174
  }
@@ -2137,12 +2176,12 @@ class PublisherModule {
2137
2176
  async getUserWebsites(userId) {
2138
2177
  return new Promise((resolve, reject) => {
2139
2178
  this.db.all(
2140
- 'SELECT w.* FROM websites w JOIN user_website_permissions p ON w.id = p.website_id WHERE p.user_id = ? AND p.can_queue = 1 AND w.is_active = 1',
2141
- [userId],
2142
- (err, rows) => {
2143
- if (err) reject(err);
2144
- else resolve(rows || []);
2145
- }
2179
+ 'SELECT w.* FROM websites w JOIN user_website_permissions p ON w.id = p.website_id WHERE p.user_id = ? AND p.can_queue = 1 AND w.is_active = 1',
2180
+ [userId],
2181
+ (err, rows) => {
2182
+ if (err) reject(err);
2183
+ else resolve(rows || []);
2184
+ }
2146
2185
  );
2147
2186
  });
2148
2187
  }
@@ -2150,12 +2189,12 @@ class PublisherModule {
2150
2189
  async userCanQueue(userId, websiteId) {
2151
2190
  return new Promise((resolve, reject) => {
2152
2191
  this.db.get(
2153
- 'SELECT can_queue FROM user_website_permissions WHERE user_id = ? AND website_id = ?',
2154
- [userId, websiteId],
2155
- (err, row) => {
2156
- if (err) reject(err);
2157
- else resolve(row && row.can_queue);
2158
- }
2192
+ 'SELECT can_queue FROM user_website_permissions WHERE user_id = ? AND website_id = ?',
2193
+ [userId, websiteId],
2194
+ (err, row) => {
2195
+ if (err) reject(err);
2196
+ else resolve(row && row.can_queue);
2197
+ }
2159
2198
  );
2160
2199
  });
2161
2200
  }
@@ -2163,12 +2202,12 @@ class PublisherModule {
2163
2202
  async userCanApprove(userId, websiteId) {
2164
2203
  return new Promise((resolve, reject) => {
2165
2204
  this.db.get(
2166
- 'SELECT can_approve FROM user_website_permissions WHERE user_id = ? AND website_id = ?',
2167
- [userId, websiteId],
2168
- (err, row) => {
2169
- if (err) reject(err);
2170
- else resolve(row && row.can_approve);
2171
- }
2205
+ 'SELECT can_approve FROM user_website_permissions WHERE user_id = ? AND website_id = ?',
2206
+ [userId, websiteId],
2207
+ (err, row) => {
2208
+ if (err) reject(err);
2209
+ else resolve(row && row.can_approve);
2210
+ }
2172
2211
  );
2173
2212
  });
2174
2213
  }
@@ -2176,12 +2215,12 @@ class PublisherModule {
2176
2215
  async findActiveTask(packageId, version) {
2177
2216
  return new Promise((resolve, reject) => {
2178
2217
  this.db.get(
2179
- 'SELECT * FROM tasks WHERE npm_package_id = ? AND version = ? AND status NOT IN (?, ?) ORDER BY queued_at DESC LIMIT 1',
2180
- [packageId, version, 'complete', 'failed'],
2181
- (err, row) => {
2182
- if (err) reject(err);
2183
- else resolve(row);
2184
- }
2218
+ 'SELECT * FROM tasks WHERE npm_package_id = ? AND version = ? AND status NOT IN (?, ?) ORDER BY queued_at DESC LIMIT 1',
2219
+ [packageId, version, 'complete', 'failed'],
2220
+ (err, row) => {
2221
+ if (err) reject(err);
2222
+ else resolve(row);
2223
+ }
2185
2224
  );
2186
2225
  });
2187
2226
  }
@@ -2189,12 +2228,12 @@ class PublisherModule {
2189
2228
  async findExistingTask(packageId, version) {
2190
2229
  return new Promise((resolve, reject) => {
2191
2230
  this.db.get(
2192
- 'SELECT * FROM tasks WHERE npm_package_id = ? AND version = ? ORDER BY queued_at DESC LIMIT 1',
2193
- [packageId, version],
2194
- (err, row) => {
2195
- if (err) reject(err);
2196
- else resolve(row);
2197
- }
2231
+ 'SELECT * FROM tasks WHERE npm_package_id = ? AND version = ? ORDER BY queued_at DESC LIMIT 1',
2232
+ [packageId, version],
2233
+ (err, row) => {
2234
+ if (err) reject(err);
2235
+ else resolve(row);
2236
+ }
2198
2237
  );
2199
2238
  });
2200
2239
  }
@@ -2268,12 +2307,12 @@ class PublisherModule {
2268
2307
  async getUserPermissions(userId) {
2269
2308
  return new Promise((resolve, reject) => {
2270
2309
  this.db.all(
2271
- 'SELECT * FROM user_website_permissions WHERE user_id = ?',
2272
- [userId],
2273
- (err, rows) => {
2274
- if (err) reject(err);
2275
- else resolve(rows || []);
2276
- }
2310
+ 'SELECT * FROM user_website_permissions WHERE user_id = ?',
2311
+ [userId],
2312
+ (err, rows) => {
2313
+ if (err) reject(err);
2314
+ else resolve(rows || []);
2315
+ }
2277
2316
  );
2278
2317
  });
2279
2318
  }
@@ -2329,9 +2368,9 @@ class PublisherModule {
2329
2368
  async logUserAction(userId, action, targetId, ipAddress) {
2330
2369
  return new Promise((resolve) => {
2331
2370
  this.db.run(
2332
- 'INSERT INTO user_actions (user_id, action, target_id, ip_address) VALUES (?, ?, ?, ?)',
2333
- [userId, action, targetId, ipAddress],
2334
- () => resolve() // Don't fail if logging fails
2371
+ 'INSERT INTO user_actions (user_id, action, target_id, ip_address) VALUES (?, ?, ?, ?)',
2372
+ [userId, action, targetId, ipAddress],
2373
+ () => resolve() // Don't fail if logging fails
2335
2374
  );
2336
2375
  });
2337
2376
  }