fhirsmith 0.3.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 (277) hide show
  1. package/CHANGELOG.md +42 -0
  2. package/FHIRsmith.png +0 -0
  3. package/README.md +277 -0
  4. package/config-template.json +144 -0
  5. package/library/folder-setup.js +58 -0
  6. package/library/html-server.js +166 -0
  7. package/library/html.js +835 -0
  8. package/library/i18nsupport.js +259 -0
  9. package/library/languages.js +779 -0
  10. package/library/logger-telnet.js +205 -0
  11. package/library/logger.js +279 -0
  12. package/library/package-manager.js +876 -0
  13. package/library/utilities.js +196 -0
  14. package/library/version-utilities.js +1056 -0
  15. package/npmprojector/config-example.json +13 -0
  16. package/npmprojector/indexer.js +394 -0
  17. package/npmprojector/npmprojector.js +395 -0
  18. package/npmprojector/readme.md +174 -0
  19. package/npmprojector/watcher.js +335 -0
  20. package/package.json +119 -0
  21. package/packages/package-crawler.js +846 -0
  22. package/packages/packages-template.html +126 -0
  23. package/packages/packages.js +2838 -0
  24. package/passwords.ini +2 -0
  25. package/publisher/publisher-template.html +208 -0
  26. package/publisher/publisher.js +2167 -0
  27. package/publisher/task-draft.js +458 -0
  28. package/registry/api.js +735 -0
  29. package/registry/crawler.js +637 -0
  30. package/registry/model.js +513 -0
  31. package/registry/readme.md +243 -0
  32. package/registry/registry-data.json +121015 -0
  33. package/registry/registry-template.html +126 -0
  34. package/registry/registry.js +1395 -0
  35. package/registry/test-runner.js +237 -0
  36. package/root-template.html +124 -0
  37. package/server.js +524 -0
  38. package/shl/private-key.pem +5 -0
  39. package/shl/public-key.pem +18 -0
  40. package/shl/shl.js +1125 -0
  41. package/shl/vhl.js +69 -0
  42. package/static/FHIRsmith128.png +0 -0
  43. package/static/FHIRsmith16.png +0 -0
  44. package/static/FHIRsmith32.png +0 -0
  45. package/static/FHIRsmith64.png +0 -0
  46. package/static/assets/css/bootstrap-fhir.css +5302 -0
  47. package/static/assets/css/bootstrap-glyphicons.css +2 -0
  48. package/static/assets/css/bootstrap.css +4097 -0
  49. package/static/assets/css/jquery-ui.css +523 -0
  50. package/static/assets/css/jquery-ui.structure.css +863 -0
  51. package/static/assets/css/jquery-ui.structure.min.css +5 -0
  52. package/static/assets/css/jquery-ui.theme.css +439 -0
  53. package/static/assets/css/jquery-ui.theme.min.css +5 -0
  54. package/static/assets/css/jquery.ui.all.css +7 -0
  55. package/static/assets/css/modules.css +18 -0
  56. package/static/assets/css/project.css +367 -0
  57. package/static/assets/css/pygments-manni.css +66 -0
  58. package/static/assets/css/tags.css +74 -0
  59. package/static/assets/css/xml.css +2 -0
  60. package/static/assets/fonts/glyphiconshalflings-regular.eot +0 -0
  61. package/static/assets/fonts/glyphiconshalflings-regular.otf +0 -0
  62. package/static/assets/fonts/glyphiconshalflings-regular.svg +175 -0
  63. package/static/assets/fonts/glyphiconshalflings-regular.ttf +0 -0
  64. package/static/assets/fonts/glyphiconshalflings-regular.woff +0 -0
  65. package/static/assets/ico/apple-touch-icon-114-precomposed.png +0 -0
  66. package/static/assets/ico/apple-touch-icon-144-precomposed.png +0 -0
  67. package/static/assets/ico/apple-touch-icon-57-precomposed.png +0 -0
  68. package/static/assets/ico/apple-touch-icon-72-precomposed.png +0 -0
  69. package/static/assets/ico/favicon.ico +0 -0
  70. package/static/assets/ico/favicon.png +0 -0
  71. package/static/assets/images/fhir-logo-www.png +0 -0
  72. package/static/assets/images/fhir-logo.png +0 -0
  73. package/static/assets/images/hl7-logo.png +0 -0
  74. package/static/assets/images/logo_ansinew.jpg +0 -0
  75. package/static/assets/images/search.png +0 -0
  76. package/static/assets/images/stripe.png +0 -0
  77. package/static/assets/images/target.png +0 -0
  78. package/static/assets/images/tx-registry-root.gif +0 -0
  79. package/static/assets/images/tx-registry.png +0 -0
  80. package/static/assets/images/tx-server.png +0 -0
  81. package/static/assets/images/tx-version.png +0 -0
  82. package/static/assets/js/bootstrap.min.js +6 -0
  83. package/static/assets/js/fhir-gw.js +259 -0
  84. package/static/assets/js/fhir.js +2 -0
  85. package/static/assets/js/html5shiv.js +8 -0
  86. package/static/assets/js/jcookie.js +96 -0
  87. package/static/assets/js/jquery-ui.min.js +6 -0
  88. package/static/assets/js/jquery.js +10716 -0
  89. package/static/assets/js/jquery.min.js +2 -0
  90. package/static/assets/js/jquery.ui.core.js +314 -0
  91. package/static/assets/js/jquery.ui.draggable.js +825 -0
  92. package/static/assets/js/jquery.ui.mouse.js +162 -0
  93. package/static/assets/js/jquery.ui.resizable.js +842 -0
  94. package/static/assets/js/jquery.ui.widget.js +268 -0
  95. package/static/assets/js/json2.js +487 -0
  96. package/static/assets/js/jtip.js +97 -0
  97. package/static/assets/js/respond.min.js +6 -0
  98. package/static/assets/js/statuspage.js +70 -0
  99. package/static/assets/js/xml.js +2 -0
  100. package/static/dist/js/bootstrap.js +1964 -0
  101. package/static/favicon.png +0 -0
  102. package/static/fhir.css +626 -0
  103. package/static/icon-fhir-16.png +0 -0
  104. package/static/images/ui-bg_diagonals-thick_18_b81900_40x40.png +0 -0
  105. package/static/images/ui-bg_diagonals-thick_20_666666_40x40.png +0 -0
  106. package/static/images/ui-bg_flat_10_000000_40x100.png +0 -0
  107. package/static/images/ui-bg_glass_100_f6f6f6_1x400.png +0 -0
  108. package/static/images/ui-bg_glass_100_fdf5ce_1x400.png +0 -0
  109. package/static/images/ui-bg_glass_65_ffffff_1x400.png +0 -0
  110. package/static/images/ui-bg_gloss-wave_35_f6a828_500x100.png +0 -0
  111. package/static/images/ui-bg_highlight-soft_100_eeeeee_1x100.png +0 -0
  112. package/static/images/ui-bg_highlight-soft_75_ffe45c_1x100.png +0 -0
  113. package/static/images/ui-icons_222222_256x240.png +0 -0
  114. package/static/images/ui-icons_228ef1_256x240.png +0 -0
  115. package/static/images/ui-icons_ef8c08_256x240.png +0 -0
  116. package/static/images/ui-icons_ffd27a_256x240.png +0 -0
  117. package/static/images/ui-icons_ffffff_256x240.png +0 -0
  118. package/static/js/jquery.effects.blind.js +49 -0
  119. package/static/js/jquery.effects.bounce.js +78 -0
  120. package/static/js/jquery.effects.clip.js +54 -0
  121. package/static/js/jquery.effects.core.js +763 -0
  122. package/static/js/jquery.effects.drop.js +50 -0
  123. package/static/js/jquery.effects.explode.js +79 -0
  124. package/static/js/jquery.effects.fade.js +32 -0
  125. package/static/js/jquery.effects.fold.js +56 -0
  126. package/static/js/jquery.effects.highlight.js +50 -0
  127. package/static/js/jquery.effects.pulsate.js +51 -0
  128. package/static/js/jquery.effects.scale.js +178 -0
  129. package/static/js/jquery.effects.shake.js +57 -0
  130. package/static/js/jquery.effects.slide.js +50 -0
  131. package/static/js/jquery.effects.transfer.js +45 -0
  132. package/static/js/jquery.ui.accordion.js +611 -0
  133. package/static/js/jquery.ui.autocomplete.js +612 -0
  134. package/static/js/jquery.ui.button.js +416 -0
  135. package/static/js/jquery.ui.datepicker.js +1823 -0
  136. package/static/js/jquery.ui.dialog.js +878 -0
  137. package/static/js/jquery.ui.droppable.js +296 -0
  138. package/static/js/jquery.ui.position.js +252 -0
  139. package/static/js/jquery.ui.progressbar.js +109 -0
  140. package/static/js/jquery.ui.selectable.js +266 -0
  141. package/static/js/jquery.ui.slider.js +666 -0
  142. package/static/js/jquery.ui.sortable.js +1077 -0
  143. package/static/js/jquery.ui.tabs.js +758 -0
  144. package/stats.js +80 -0
  145. package/test-cache/vsac/vsac-valuesets.db +0 -0
  146. package/token/nginx_passport_setup.md +383 -0
  147. package/token/security_guide.md +294 -0
  148. package/token/token-template.html +330 -0
  149. package/token/token.js +1300 -0
  150. package/translations/Messages.properties +1510 -0
  151. package/translations/Messages_ar.properties +1399 -0
  152. package/translations/Messages_de.properties +836 -0
  153. package/translations/Messages_es.properties +737 -0
  154. package/translations/Messages_fr.properties +1 -0
  155. package/translations/Messages_ja.properties +893 -0
  156. package/translations/Messages_nl.properties +1357 -0
  157. package/translations/Messages_pt.properties +1302 -0
  158. package/translations/Messages_ru.properties +1 -0
  159. package/translations/Messages_uz.properties +1 -0
  160. package/translations/Messages_zh.properties +1 -0
  161. package/translations/rendering-phrases.properties +1128 -0
  162. package/translations/rendering-phrases_ar.properties +1091 -0
  163. package/translations/rendering-phrases_de.properties +6 -0
  164. package/translations/rendering-phrases_es.properties +6 -0
  165. package/translations/rendering-phrases_fr.properties +624 -0
  166. package/translations/rendering-phrases_ja.properties +21 -0
  167. package/translations/rendering-phrases_nl.properties +970 -0
  168. package/translations/rendering-phrases_pt.properties +1020 -0
  169. package/translations/rendering-phrases_ru.properties +1094 -0
  170. package/translations/rendering-phrases_uz.properties +1 -0
  171. package/translations/rendering-phrases_zh.properties +1 -0
  172. package/tx/README.md +418 -0
  173. package/tx/cm/cm-api.js +110 -0
  174. package/tx/cm/cm-database.js +735 -0
  175. package/tx/cm/cm-package.js +325 -0
  176. package/tx/cs/cs-api.js +789 -0
  177. package/tx/cs/cs-areacode.js +615 -0
  178. package/tx/cs/cs-country.js +1110 -0
  179. package/tx/cs/cs-cpt.js +785 -0
  180. package/tx/cs/cs-cs.js +1579 -0
  181. package/tx/cs/cs-currency.js +539 -0
  182. package/tx/cs/cs-db.js +1321 -0
  183. package/tx/cs/cs-hgvs.js +329 -0
  184. package/tx/cs/cs-lang.js +465 -0
  185. package/tx/cs/cs-loinc.js +1485 -0
  186. package/tx/cs/cs-mimetypes.js +238 -0
  187. package/tx/cs/cs-ndc.js +704 -0
  188. package/tx/cs/cs-omop.js +1025 -0
  189. package/tx/cs/cs-provider-api.js +43 -0
  190. package/tx/cs/cs-provider-list.js +37 -0
  191. package/tx/cs/cs-rxnorm.js +808 -0
  192. package/tx/cs/cs-snomed.js +1102 -0
  193. package/tx/cs/cs-ucum.js +514 -0
  194. package/tx/cs/cs-unii.js +271 -0
  195. package/tx/cs/cs-uri.js +218 -0
  196. package/tx/cs/cs-usstates.js +305 -0
  197. package/tx/dev.fhir.org.yml +14 -0
  198. package/tx/fixtures/test-cases-setup.json +18 -0
  199. package/tx/fixtures/test-cases.yml +16 -0
  200. package/tx/html/codesystem-operations.liquid +25 -0
  201. package/tx/html/home-metrics.liquid +247 -0
  202. package/tx/html/operations-form.liquid +148 -0
  203. package/tx/html/search-form.liquid +62 -0
  204. package/tx/html/tx-template.html +133 -0
  205. package/tx/html/valueset-operations.liquid +54 -0
  206. package/tx/importers/atc-to-fhir.js +316 -0
  207. package/tx/importers/import-loinc.module.js +1536 -0
  208. package/tx/importers/import-ndc.module.js +1088 -0
  209. package/tx/importers/import-rxnorm.module.js +898 -0
  210. package/tx/importers/import-sct.module.js +2457 -0
  211. package/tx/importers/import-unii.module.js +601 -0
  212. package/tx/importers/readme.md +453 -0
  213. package/tx/importers/subset-loinc.module.js +1081 -0
  214. package/tx/importers/subset-rxnorm.module.js +938 -0
  215. package/tx/importers/tx-import-base.js +351 -0
  216. package/tx/importers/tx-import-settings.js +310 -0
  217. package/tx/importers/tx-import.js +357 -0
  218. package/tx/library/canonical-resource.js +88 -0
  219. package/tx/library/capabilitystatement.js +292 -0
  220. package/tx/library/codesystem.js +774 -0
  221. package/tx/library/conceptmap.js +568 -0
  222. package/tx/library/designations.js +932 -0
  223. package/tx/library/errors.js +77 -0
  224. package/tx/library/extensions.js +117 -0
  225. package/tx/library/namingsystem.js +322 -0
  226. package/tx/library/operation-outcome.js +127 -0
  227. package/tx/library/parameters.js +105 -0
  228. package/tx/library/renderer.js +1559 -0
  229. package/tx/library/terminologycapabilities.js +418 -0
  230. package/tx/library/ucum-parsers.js +1029 -0
  231. package/tx/library/ucum-service.js +370 -0
  232. package/tx/library/ucum-types.js +1099 -0
  233. package/tx/library/valueset.js +543 -0
  234. package/tx/library.js +676 -0
  235. package/tx/ocl/cm-ocl.js +106 -0
  236. package/tx/ocl/cs-ocl.js +39 -0
  237. package/tx/ocl/vs-ocl.js +105 -0
  238. package/tx/operation-context.js +568 -0
  239. package/tx/params.js +613 -0
  240. package/tx/provider.js +403 -0
  241. package/tx/sct/ecl.js +1560 -0
  242. package/tx/sct/expressions.js +2077 -0
  243. package/tx/sct/structures.js +1396 -0
  244. package/tx/tx-html.js +1063 -0
  245. package/tx/tx.fhir.org.yml +39 -0
  246. package/tx/tx.js +927 -0
  247. package/tx/vs/vs-api.js +112 -0
  248. package/tx/vs/vs-database.js +786 -0
  249. package/tx/vs/vs-package.js +358 -0
  250. package/tx/vs/vs-vsac.js +366 -0
  251. package/tx/workers/batch-validate.js +129 -0
  252. package/tx/workers/batch.js +361 -0
  253. package/tx/workers/closure.js +32 -0
  254. package/tx/workers/expand.js +1845 -0
  255. package/tx/workers/lookup.js +407 -0
  256. package/tx/workers/metadata.js +467 -0
  257. package/tx/workers/operations.js +34 -0
  258. package/tx/workers/read.js +164 -0
  259. package/tx/workers/search.js +384 -0
  260. package/tx/workers/subsumes.js +334 -0
  261. package/tx/workers/translate.js +492 -0
  262. package/tx/workers/validate.js +2504 -0
  263. package/tx/workers/worker.js +904 -0
  264. package/tx/xml/capabilitystatement-xml.js +63 -0
  265. package/tx/xml/codesystem-xml.js +62 -0
  266. package/tx/xml/conceptmap-xml.js +65 -0
  267. package/tx/xml/namingsystem-xml.js +65 -0
  268. package/tx/xml/operationoutcome-xml.js +127 -0
  269. package/tx/xml/parameters-xml.js +312 -0
  270. package/tx/xml/terminologycapabilities-xml.js +64 -0
  271. package/tx/xml/valueset-xml.js +64 -0
  272. package/tx/xml/xml-base.js +603 -0
  273. package/vcl/vcl-parser.js +1098 -0
  274. package/vcl/vcl.js +253 -0
  275. package/windows-install.js +19 -0
  276. package/xig/xig-template.html +124 -0
  277. package/xig/xig.js +3049 -0
@@ -0,0 +1,2167 @@
1
+ const express = require('express');
2
+ const path = require('path');
3
+ const fs = require('fs');
4
+ const Database = require('sqlite3').Database;
5
+ const bcrypt = require('bcrypt');
6
+ const session = require('express-session');
7
+ const folders = require('../library/folder-setup');
8
+
9
+
10
+ class PublisherModule {
11
+ constructor(stats) {
12
+ this.router = express.Router();
13
+ this.db = null;
14
+ this.config = null;
15
+ this.logger = null;
16
+ this.taskProcessor = null;
17
+ this.isProcessing = false;
18
+ this.shutdownRequested = false;
19
+ this.stats = stats;
20
+ }
21
+
22
+ async initialize(config) {
23
+ this.config = config;
24
+ this.logger = require('../library/logger').getInstance().child({ module: 'publisher' });
25
+
26
+ // Initialize database first
27
+ await this.initializeDatabase();
28
+
29
+ // Set up session middleware - use in-memory sessions or separate session db
30
+ this.router.use(session({
31
+ secret: this.config.sessionSecret || 'your-secret-key-change-this',
32
+ resave: false,
33
+ saveUninitialized: false,
34
+ cookie: { secure: false, maxAge: 24 * 60 * 60 * 1000 } // 24 hours
35
+ // Not using SQLiteStore to avoid the database conflict
36
+ }));
37
+
38
+ // Parse form data
39
+ this.router.use(express.urlencoded({ extended: true }));
40
+
41
+ // Set up routes
42
+ this.setupRoutes();
43
+
44
+ // Start background task processor
45
+ this.startTaskProcessor();
46
+
47
+ this.logger.info('Publisher module initialized');
48
+ }
49
+
50
+ async initializeDatabase() {
51
+ // Ensure database directory exists
52
+ const dbPath = path.isAbsolute(this.config.database)
53
+ ? this.config.database
54
+ : folders.filePath('publisher', this.config.database);
55
+ const dbDir = path.dirname(dbPath);
56
+ const fs = require('fs');
57
+ if (!fs.existsSync(dbDir)) {
58
+ fs.mkdirSync(dbDir, { recursive: true });
59
+ this.logger.info('Created database directory: ' + dbDir);
60
+ }
61
+
62
+ return new Promise((resolve, reject) => {
63
+ this.db = new Database(dbPath, (err) => {
64
+ if (err) {
65
+ this.logger.error('Failed to connect to database:', err);
66
+ reject(err);
67
+ return;
68
+ }
69
+
70
+ this.logger.info('Connected to SQLite database: ' + dbPath);
71
+ this.createTables().then(resolve).catch(reject);
72
+ });
73
+ });
74
+ }
75
+
76
+ async createTables() {
77
+ const tables = [
78
+ // Users table
79
+ `CREATE TABLE IF NOT EXISTS users (
80
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
81
+ name TEXT NOT NULL,
82
+ login TEXT UNIQUE NOT NULL,
83
+ password_hash TEXT NOT NULL,
84
+ is_admin BOOLEAN DEFAULT 0,
85
+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP
86
+ )`,
87
+
88
+ // Websites table
89
+ `CREATE TABLE IF NOT EXISTS websites (
90
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
91
+ name TEXT NOT NULL,
92
+ local_folder TEXT NOT NULL,
93
+ server_update_script TEXT NOT NULL,
94
+ is_active BOOLEAN DEFAULT 1,
95
+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP
96
+ )`,
97
+
98
+ // User website permissions
99
+ `CREATE TABLE IF NOT EXISTS user_website_permissions (
100
+ user_id INTEGER,
101
+ website_id INTEGER,
102
+ can_queue BOOLEAN DEFAULT 0,
103
+ can_approve BOOLEAN DEFAULT 0,
104
+ PRIMARY KEY (user_id, website_id),
105
+ FOREIGN KEY (user_id) REFERENCES users (id),
106
+ FOREIGN KEY (website_id) REFERENCES websites (id)
107
+ )`,
108
+
109
+ // Tasks table
110
+ `CREATE TABLE IF NOT EXISTS tasks (
111
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
112
+ user_id INTEGER NOT NULL,
113
+ website_id INTEGER NOT NULL,
114
+ status TEXT DEFAULT 'queued',
115
+ github_org TEXT NOT NULL,
116
+ github_repo TEXT NOT NULL,
117
+ git_branch TEXT NOT NULL,
118
+ npm_package_id TEXT NOT NULL,
119
+ version TEXT NOT NULL,
120
+ local_folder TEXT,
121
+ build_output_path TEXT,
122
+ failure_reason TEXT,
123
+ announcement TEXT,
124
+ approved_by INTEGER,
125
+ queued_at DATETIME DEFAULT CURRENT_TIMESTAMP,
126
+ building_at DATETIME,
127
+ waiting_approval_at DATETIME,
128
+ publishing_at DATETIME,
129
+ completed_at DATETIME,
130
+ failed_at DATETIME,
131
+ FOREIGN KEY (user_id) REFERENCES users (id),
132
+ FOREIGN KEY (website_id) REFERENCES websites (id),
133
+ FOREIGN KEY (approved_by) REFERENCES users (id)
134
+ )`,
135
+
136
+ // Task logs
137
+ `CREATE TABLE IF NOT EXISTS task_logs (
138
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
139
+ task_id TEXT NOT NULL,
140
+ timestamp DATETIME DEFAULT CURRENT_TIMESTAMP,
141
+ level TEXT NOT NULL,
142
+ message TEXT NOT NULL,
143
+ FOREIGN KEY (task_id) REFERENCES tasks (id)
144
+ )`,
145
+
146
+ // User actions audit
147
+ `CREATE TABLE IF NOT EXISTS user_actions (
148
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
149
+ user_id INTEGER,
150
+ action TEXT NOT NULL,
151
+ target_id TEXT,
152
+ timestamp DATETIME DEFAULT CURRENT_TIMESTAMP,
153
+ ip_address TEXT,
154
+ FOREIGN KEY (user_id) REFERENCES users (id)
155
+ )`
156
+ ];
157
+
158
+ for (const sql of tables) {
159
+ await new Promise((resolve, reject) => {
160
+ this.db.run(sql, (err) => {
161
+ if (err) {
162
+ this.logger.error('Failed to create table:', err);
163
+ reject(err);
164
+ } else {
165
+ resolve();
166
+ }
167
+ });
168
+ });
169
+ }
170
+
171
+ // Create default admin user if none exists
172
+ await this.createDefaultAdmin();
173
+
174
+ // Schema migrations for existing databases
175
+ await this.runMigrations();
176
+ }
177
+
178
+ async runMigrations() {
179
+ // Add announcement column if it doesn't exist (added after initial schema)
180
+ const columns = await new Promise((resolve, reject) => {
181
+ this.db.all("PRAGMA table_info(tasks)", (err, rows) => {
182
+ if (err) reject(err);
183
+ else resolve(rows || []);
184
+ });
185
+ });
186
+ const columnNames = columns.map(c => c.name);
187
+ if (!columnNames.includes('announcement')) {
188
+ await new Promise((resolve, reject) => {
189
+ this.db.run('ALTER TABLE tasks ADD COLUMN announcement TEXT', (err) => {
190
+ if (err) reject(err);
191
+ else {
192
+ this.logger.info('Migration: added announcement column to tasks table');
193
+ resolve();
194
+ }
195
+ });
196
+ });
197
+ }
198
+ }
199
+
200
+ async createDefaultAdmin() {
201
+ return new Promise((resolve, reject) => {
202
+ this.db.get('SELECT COUNT(*) as count FROM users WHERE is_admin = 1', (err, row) => {
203
+ if (err) {
204
+ reject(err);
205
+ return;
206
+ }
207
+
208
+ if (row.count === 0) {
209
+ const defaultPassword = 'admin123'; // Change this!
210
+ bcrypt.hash(defaultPassword, 10, (err, hash) => {
211
+ if (err) {
212
+ reject(err);
213
+ return;
214
+ }
215
+
216
+ this.db.run(
217
+ 'INSERT INTO users (name, login, password_hash, is_admin) VALUES (?, ?, ?, ?)',
218
+ ['Administrator', 'admin', hash, 1],
219
+ (err) => {
220
+ if (err) {
221
+ this.logger.error('Failed to create default admin:', err);
222
+ reject(err);
223
+ } else {
224
+ this.logger.warn('Created default admin user - login: admin, password: admin123 - CHANGE THIS!');
225
+ resolve();
226
+ }
227
+ }
228
+ );
229
+ });
230
+ } else {
231
+ resolve();
232
+ }
233
+ });
234
+ });
235
+ }
236
+
237
+ setupRoutes() {
238
+ // Main dashboard
239
+ this.router.get('/', this.renderDashboard.bind(this));
240
+
241
+ // Authentication
242
+ this.router.get('/login', this.renderLogin.bind(this));
243
+ this.router.post('/login', this.handleLogin.bind(this));
244
+ this.router.post('/logout', this.handleLogout.bind(this));
245
+
246
+ // Tasks
247
+ this.router.get('/tasks', this.renderTasks.bind(this));
248
+ this.router.post('/tasks', this.requireAuth.bind(this), this.createTask.bind(this));
249
+ this.router.post('/tasks/:id/approve', this.requireAuth.bind(this), this.approveTask.bind(this));
250
+ this.router.post('/tasks/:id/delete', this.requireAdmin.bind(this), this.deleteTask.bind(this));
251
+ this.router.get('/tasks/:id/output', this.getTaskOutput.bind(this));
252
+ this.router.get('/tasks/:id/history', this.getTaskHistory.bind(this));
253
+ this.router.get('/tasks/:id/qa', this.getTaskQA.bind(this));
254
+ this.router.use('/tasks/:id/qa-files', (req, res, next) => {
255
+ const taskId = req.params.id;
256
+ this.getTask(taskId).then(task => {
257
+ if (!task || !task.local_folder) {
258
+ return res.status(404).send('Not found');
259
+ }
260
+ const outputDir = path.join(task.local_folder, 'draft', 'output');
261
+ express.static(outputDir)(req, res, next);
262
+ }).catch(() => res.status(500).send('Error'));
263
+ });
264
+
265
+ // Admin routes
266
+ this.router.get('/admin/websites', this.requireAdmin.bind(this), this.renderWebsites.bind(this));
267
+ this.router.post('/admin/websites', this.requireAdmin.bind(this), this.createWebsite.bind(this));
268
+ this.router.get('/admin/users', this.requireAdmin.bind(this), this.renderUsers.bind(this));
269
+ this.router.post('/admin/users', this.requireAdmin.bind(this), this.createUser.bind(this));
270
+ this.router.post('/admin/permissions', this.requireAdmin.bind(this), this.updatePermissions.bind(this));
271
+ }
272
+
273
+ // Background Task Processing
274
+ startTaskProcessor() {
275
+ const pollInterval = this.config.pollInterval || 5000; // Default 5 seconds
276
+
277
+ this.logger.info('Starting task processor with ' + pollInterval + 'ms poll interval');
278
+
279
+ this.taskProcessor = setInterval(async () => {
280
+ if (!this.isProcessing && !this.shutdownRequested) {
281
+ await this.processNextTask();
282
+ }
283
+ }, pollInterval);
284
+ }
285
+
286
+ async processNextTask() {
287
+ this.isProcessing = true;
288
+
289
+ try {
290
+ // Look for queued tasks first (draft builds)
291
+ let task = await this.getNextQueuedTask();
292
+ if (task) {
293
+ await this.processDraftBuild(task);
294
+ return;
295
+ }
296
+
297
+ // Then look for approved tasks (publishing)
298
+ task = await this.getNextApprovedTask();
299
+ if (task) {
300
+ await this.processPublication(task);
301
+ return;
302
+ }
303
+
304
+ // No tasks to process
305
+ } catch (error) {
306
+ this.logger.error('Error in task processor:', error);
307
+ } finally {
308
+ this.isProcessing = false;
309
+ }
310
+ }
311
+
312
+ async getNextQueuedTask() {
313
+ return new Promise((resolve, reject) => {
314
+ this.db.get(
315
+ 'SELECT * FROM tasks WHERE status = ? ORDER BY queued_at ASC LIMIT 1',
316
+ ['queued'],
317
+ (err, row) => {
318
+ if (err) reject(err);
319
+ else resolve(row);
320
+ }
321
+ );
322
+ });
323
+ }
324
+
325
+ async getNextApprovedTask() {
326
+ return new Promise((resolve, reject) => {
327
+ this.db.get(
328
+ 'SELECT * FROM tasks WHERE status = ? ORDER BY publishing_at ASC LIMIT 1',
329
+ ['publishing'],
330
+ (err, row) => {
331
+ if (err) reject(err);
332
+ else resolve(row);
333
+ }
334
+ );
335
+ });
336
+ }
337
+
338
+ async updateTaskStatus(taskId, status, additionalFields = {}) {
339
+ const fields = ['status = ?'];
340
+ const values = [status];
341
+
342
+ // Add timestamp field based on status
343
+ if (status === 'building') {
344
+ fields.push('building_at = CURRENT_TIMESTAMP');
345
+ } else if (status === 'waiting for approval') {
346
+ fields.push('waiting_approval_at = CURRENT_TIMESTAMP');
347
+ } else if (status === 'complete') {
348
+ fields.push('completed_at = CURRENT_TIMESTAMP');
349
+ } else if (status === 'failed') {
350
+ fields.push('failed_at = CURRENT_TIMESTAMP');
351
+ }
352
+
353
+ // Add any additional fields
354
+ Object.keys(additionalFields).forEach(key => {
355
+ fields.push(key + ' = ?');
356
+ values.push(additionalFields[key]);
357
+ });
358
+
359
+ values.push(taskId);
360
+
361
+ return new Promise((resolve, reject) => {
362
+ this.db.run(
363
+ 'UPDATE tasks SET ' + fields.join(', ') + ' WHERE id = ?',
364
+ values,
365
+ (err) => {
366
+ if (err) reject(err);
367
+ else resolve();
368
+ }
369
+ );
370
+ });
371
+ }
372
+
373
+ async logTaskMessage(taskId, level, message) {
374
+ return new Promise((resolve) => {
375
+ this.db.run(
376
+ 'INSERT INTO task_logs (task_id, level, message) VALUES (?, ?, ?)',
377
+ [taskId.toString(), level, message],
378
+ () => resolve() // Don't fail if logging fails
379
+ );
380
+ });
381
+ }
382
+
383
+ async processDraftBuild(task) {
384
+ this.logger.info('Processing draft build for task #' + task.id + ' (' + task.npm_package_id + '#' + task.version + ')');
385
+
386
+ try {
387
+ // Update status to building
388
+ await this.updateTaskStatus(task.id, 'building');
389
+ await this.logTaskMessage(task.id, 'info', 'Started draft build');
390
+
391
+ // Run actual build process
392
+ await this.runDraftBuild(task);
393
+
394
+ // Update status to waiting for approval
395
+ await this.updateTaskStatus(task.id, 'waiting for approval');
396
+ await this.logTaskMessage(task.id, 'info', 'Draft build completed - waiting for approval');
397
+
398
+ this.logger.info('Draft build completed for task #' + task.id);
399
+
400
+ } catch (error) {
401
+ this.logger.error('Draft build failed for task #' + task.id + ':', error);
402
+ await this.updateTaskStatus(task.id, 'failed', {
403
+ failure_reason: error.message
404
+ });
405
+ await this.logTaskMessage(task.id, 'error', 'Draft build failed: ' + error.message);
406
+ }
407
+ }
408
+
409
+ async processPublication(task) {
410
+ this.logger.info('Processing publication for task #' + task.id + ' (' + task.npm_package_id + '#' + task.version + ')');
411
+
412
+ try {
413
+ await this.logTaskMessage(task.id, 'info', 'Started publication process');
414
+
415
+ await this.runPublication(task);
416
+
417
+ // Update status to complete
418
+ await this.updateTaskStatus(task.id, 'complete');
419
+ await this.logTaskMessage(task.id, 'info', 'Publication completed successfully');
420
+
421
+ this.logger.info('Publication completed for task #' + task.id);
422
+
423
+ } catch (error) {
424
+ this.logger.error('Publication failed for task #' + task.id + ':', error);
425
+ await this.updateTaskStatus(task.id, 'failed', {
426
+ failure_reason: error.message
427
+ });
428
+ await this.logTaskMessage(task.id, 'error', 'Publication failed: ' + error.message);
429
+ }
430
+ }
431
+
432
+ async runDraftBuild(task) {
433
+ const workspaceRoot = path.isAbsolute(this.config.workspaceRoot)
434
+ ? this.config.workspaceRoot
435
+ : folders.filePath('publisher', this.config.workspaceRoot);
436
+
437
+ const taskDir = path.join(workspaceRoot, 'task-' + task.id);
438
+ const draftDir = path.join(taskDir, 'draft');
439
+ const logFile = path.join(taskDir, 'draft-build.log');
440
+
441
+ await this.logTaskMessage(task.id, 'info', 'Creating task directory: ' + taskDir);
442
+
443
+ // Step 1: Create/scrub task directory
444
+ await this.createTaskDirectory(taskDir);
445
+
446
+ // Step 2: Download latest publisher
447
+ const publisherJar = await this.downloadPublisher(taskDir, task.id);
448
+
449
+ // Step 3: Clone GitHub repository
450
+ await this.cloneRepository(task, draftDir);
451
+
452
+ // Step 4: Run IG publisher
453
+ await this.runIGPublisher(publisherJar, draftDir, logFile, task.id);
454
+
455
+ // Step 5: Verify package-id and version match the task
456
+ await this.verifyBuildOutput(task, draftDir);
457
+
458
+ // Update task with build output path
459
+ await this.updateTaskStatus(task.id, task.status, {
460
+ build_output_path: logFile,
461
+ local_folder: taskDir
462
+ });
463
+
464
+ this.logger.info('Draft build completed for ' + task.npm_package_id + '#' + task.version);
465
+ }
466
+
467
+ async createTaskDirectory(taskDir) {
468
+ const rimraf = require('rimraf');
469
+
470
+ // Remove existing directory if it exists
471
+ if (fs.existsSync(taskDir)) {
472
+ await new Promise((resolve, reject) => {
473
+ rimraf(taskDir, (err) => {
474
+ if (err) reject(err);
475
+ else resolve();
476
+ });
477
+ });
478
+ }
479
+
480
+ // Create fresh directory
481
+ fs.mkdirSync(taskDir, { recursive: true });
482
+ }
483
+
484
+ async downloadPublisher(taskDir, taskId) {
485
+ const axios = require('axios');
486
+ const publisherJar = path.join(taskDir, 'publisher.jar');
487
+
488
+ await this.logTaskMessage(taskId, 'info', 'Downloading latest FHIR IG Publisher...');
489
+
490
+ try {
491
+ // Get latest release info from GitHub API
492
+ const releaseResponse = await axios.get('https://api.github.com/repos/HL7/fhir-ig-publisher/releases/latest');
493
+ const downloadUrl = releaseResponse.data.assets.find(asset =>
494
+ asset.name === 'publisher.jar'
495
+ )?.browser_download_url;
496
+
497
+ if (!downloadUrl) {
498
+ throw new Error('Could not find publisher.jar in latest release');
499
+ }
500
+
501
+ await this.logTaskMessage(taskId, 'info', 'Downloading from: ' + downloadUrl);
502
+
503
+ // Download the file
504
+ const response = await axios({
505
+ method: 'GET',
506
+ url: downloadUrl,
507
+ responseType: 'stream'
508
+ });
509
+
510
+ const writer = fs.createWriteStream(publisherJar);
511
+ response.data.pipe(writer);
512
+
513
+ await new Promise((resolve, reject) => {
514
+ writer.on('finish', resolve);
515
+ writer.on('error', reject);
516
+ });
517
+
518
+ await this.logTaskMessage(taskId, 'info', 'Publisher downloaded successfully');
519
+ return publisherJar;
520
+
521
+ } catch (error) {
522
+ throw new Error('Failed to download publisher: ' + error.message);
523
+ }
524
+ }
525
+
526
+ async cloneRepository(task, draftDir) {
527
+ const { spawn } = require('child_process');
528
+ const gitUrl = 'https://github.com/' + task.github_org + '/' + task.github_repo + '.git';
529
+
530
+ await this.logTaskMessage(task.id, 'info', 'Cloning repository: ' + gitUrl + ' (branch: ' + task.git_branch + ')');
531
+
532
+ return new Promise((resolve, reject) => {
533
+ const git = spawn('git', [
534
+ 'clone',
535
+ '--branch', task.git_branch,
536
+ '--single-branch',
537
+ gitUrl,
538
+ draftDir
539
+ ], {
540
+ stdio: ['pipe', 'pipe', 'pipe']
541
+ });
542
+
543
+ let stderr = '';
544
+
545
+ git.stderr.on('data', (data) => {
546
+ stderr += data.toString();
547
+ });
548
+
549
+ git.on('close', async (code) => {
550
+ if (code === 0) {
551
+ await this.logTaskMessage(task.id, 'info', 'Repository cloned successfully');
552
+ resolve();
553
+ } else {
554
+ const error = 'Git clone failed with code ' + code + ': ' + stderr;
555
+ await this.logTaskMessage(task.id, 'error', error);
556
+ reject(new Error(error));
557
+ }
558
+ });
559
+
560
+ git.on('error', async (error) => {
561
+ await this.logTaskMessage(task.id, 'error', 'Git clone error: ' + error.message);
562
+ reject(error);
563
+ });
564
+ });
565
+ }
566
+
567
+ async runIGPublisher(publisherJar, draftDir, logFile, taskId) {
568
+ const { spawn } = require('child_process');
569
+
570
+ await this.logTaskMessage(taskId, 'info', 'Running FHIR IG Publisher...');
571
+
572
+ return new Promise((resolve, reject) => {
573
+ const java = spawn('java', [
574
+ '-jar',
575
+ '-Xmx20000m',
576
+ publisherJar,
577
+ '-ig',
578
+ '.'
579
+ ], {
580
+ cwd: draftDir,
581
+ stdio: ['pipe', 'pipe', 'pipe']
582
+ });
583
+
584
+ // Create log file stream
585
+ const logStream = fs.createWriteStream(logFile);
586
+
587
+ java.stdout.on('data', (data) => {
588
+ logStream.write(data);
589
+ });
590
+
591
+ java.stderr.on('data', (data) => {
592
+ logStream.write(data);
593
+ });
594
+
595
+ java.on('close', async (code) => {
596
+ logStream.end();
597
+
598
+ if (code === 0) {
599
+ await this.logTaskMessage(taskId, 'info', 'IG Publisher completed successfully');
600
+ resolve();
601
+ } else {
602
+ const error = 'IG Publisher failed with exit code: ' + code;
603
+ await this.logTaskMessage(taskId, 'error', error);
604
+ reject(new Error(error));
605
+ }
606
+ });
607
+
608
+ java.on('error', async (error) => {
609
+ logStream.end();
610
+ await this.logTaskMessage(taskId, 'error', 'IG Publisher error: ' + error.message);
611
+ reject(error);
612
+ });
613
+
614
+ // Timeout after 30 minutes
615
+ const timeout = setTimeout(async () => {
616
+ java.kill();
617
+ logStream.end();
618
+ await this.logTaskMessage(taskId, 'error', 'IG Publisher timed out after 30 minutes');
619
+ reject(new Error('IG Publisher timed out'));
620
+ }, 30 * 60 * 1000);
621
+
622
+ java.on('close', () => {
623
+ clearTimeout(timeout);
624
+ });
625
+ });
626
+ }
627
+
628
+ async verifyBuildOutput(task, draftDir) {
629
+ const qaJsonPath = path.join(draftDir, 'output', 'qa.json');
630
+
631
+ await this.logTaskMessage(task.id, 'info', 'Verifying build output against task parameters...');
632
+
633
+ if (!fs.existsSync(qaJsonPath)) {
634
+ throw new Error('Build verification failed: qa.json not found in output directory');
635
+ }
636
+
637
+ const qaData = JSON.parse(fs.readFileSync(qaJsonPath, 'utf8'));
638
+
639
+ const errors = [];
640
+ if (qaData['package-id'] !== task.npm_package_id) {
641
+ errors.push('Package ID mismatch: task specifies "' + task.npm_package_id + '" but build produced "' + qaData['package-id'] + '"');
642
+ }
643
+ if (qaData['ig-ver'] !== task.version) {
644
+ errors.push('Version mismatch: task specifies "' + task.version + '" but build produced "' + qaData['ig-ver'] + '"');
645
+ }
646
+
647
+ if (errors.length > 0) {
648
+ for (const err of errors) {
649
+ await this.logTaskMessage(task.id, 'error', err);
650
+ }
651
+ throw new Error('Build verification failed: ' + errors.join('; '));
652
+ }
653
+
654
+ await this.logTaskMessage(task.id, 'info', 'Build output verified: package-id=' + qaData['package-id'] + ', version=' + qaData['ig-ver']);
655
+ }
656
+
657
+ async runPublication(task) {
658
+ const website = await this.getWebsite(task.website_id);
659
+ if (!website) {
660
+ throw new Error('Website not found for task');
661
+ }
662
+
663
+ if (!task.local_folder) {
664
+ throw new Error('Task has no local folder - draft build may not have completed');
665
+ }
666
+
667
+ const taskDir = task.local_folder;
668
+ const draftDir = path.join(taskDir, 'draft');
669
+ const publishLogFile = path.join(taskDir, 'publication.log');
670
+
671
+ // Ensure the zips directory exists
672
+ const zipsDir = folders.filePath('publisher', 'zips');
673
+ if (!fs.existsSync(zipsDir)) {
674
+ fs.mkdirSync(zipsDir, { recursive: true });
675
+ }
676
+
677
+ // Step 1: Clone supporting repositories into the task directory
678
+ const registryDir = path.join(taskDir, 'ig-registry');
679
+ const historyDir = path.join(taskDir, 'fhir-ig-history-template');
680
+ const templatesDir = path.join(taskDir, 'fhir-web-templates');
681
+
682
+ await this.runCommand('git', ['clone', 'https://github.com/FHIR/ig-registry.git', registryDir],
683
+ {}, task.id, 'Cloning ig-registry');
684
+
685
+ await this.runCommand('git', ['clone', 'https://github.com/HL7/fhir-ig-history-template.git', historyDir],
686
+ {}, task.id, 'Cloning fhir-ig-history-template');
687
+
688
+ await this.runCommand('git', ['clone', 'https://github.com/HL7/fhir-web-templates.git', templatesDir],
689
+ {}, task.id, 'Cloning fhir-web-templates');
690
+
691
+ // Step 2: Reuse the publisher.jar from the draft build
692
+ const publisherJar = path.join(taskDir, 'publisher.jar');
693
+ if (!fs.existsSync(publisherJar)) {
694
+ throw new Error('publisher.jar not found in task directory - draft build may be corrupt');
695
+ }
696
+
697
+ // Step 3: Pull latest web folder before publishing into it
698
+ await this.runCommand('git', ['pull'], { cwd: website.local_folder }, task.id, 'Pulling latest web folder');
699
+
700
+ // Step 4: Run the IG publisher in go-publish mode
701
+ await this.runPublisherGoPublish(task.id, publisherJar, draftDir, website.local_folder,
702
+ registryDir, historyDir, templatesDir, zipsDir, publishLogFile);
703
+
704
+ // Step 5: Verify publication succeeded by checking for the log file
705
+ const pubLogName = task.npm_package_id + '#' + task.version + '.log';
706
+ const pubLogPath = path.join(zipsDir, pubLogName);
707
+ if (!fs.existsSync(pubLogPath)) {
708
+ throw new Error('Publication verification failed: ' + pubLogName + ' not found in zips directory');
709
+ }
710
+ await this.logTaskMessage(task.id, 'info', 'Publication run verified: ' + pubLogName + ' found');
711
+
712
+ // Step 6: Commit and push the web folder
713
+ await this.logTaskMessage(task.id, 'info', 'Committing changes to web folder...');
714
+ const gitUrl = 'https://github.com/' + task.github_org + '/' + task.github_repo + '.git';
715
+ const commitMsg = 'publish ' + task.npm_package_id + '#' + task.version + ' from ' + gitUrl + ' ' + task.git_branch;
716
+ await this.runCommand('git', ['add', '.'], { cwd: website.local_folder }, task.id, 'Staging web folder changes');
717
+ await this.runCommand('git', ['commit', '-m', commitMsg], { cwd: website.local_folder }, task.id, 'Committing web folder changes');
718
+ await this.runCommand('git', ['push'], { cwd: website.local_folder }, task.id, 'Pushing web folder changes');
719
+
720
+ // Step 7: Commit and push the ig-registry
721
+ await this.logTaskMessage(task.id, 'info', 'Committing changes to ig-registry...');
722
+ const registryCommitMsg = 'publish ' + task.npm_package_id + '#' + task.version;
723
+ await this.runCommand('git', ['commit', '-a', '-m', registryCommitMsg], { cwd: registryDir }, task.id, 'Committing ig-registry changes');
724
+ await this.runCommand('git', ['pull'], { cwd: registryDir }, task.id, 'Pulling latest ig-registry');
725
+ await this.runCommand('git', ['push'], { cwd: registryDir }, task.id, 'Pushing ig-registry changes');
726
+
727
+ // Step 8: Read the announcement text and store it in the database
728
+ const announcementPath = path.join(zipsDir, task.npm_package_id + '#' + task.version + '-announcement.txt');
729
+ if (fs.existsSync(announcementPath)) {
730
+ try {
731
+ const announcement = fs.readFileSync(announcementPath, 'utf8');
732
+ await this.updateTaskStatus(task.id, task.status, { announcement: announcement });
733
+ await this.logTaskMessage(task.id, 'info', 'Announcement text saved (' + announcement.length + ' chars)');
734
+ } catch (err) {
735
+ await this.logTaskMessage(task.id, 'warn', 'Failed to read announcement file: ' + err.message);
736
+ }
737
+ } else {
738
+ await this.logTaskMessage(task.id, 'warn', 'No announcement file found at ' + announcementPath);
739
+ }
740
+
741
+ // Step 9: Run the website update script
742
+ if (website.server_update_script) {
743
+ await this.logTaskMessage(task.id, 'info', 'Running website update script: ' + website.server_update_script);
744
+ await this.runCommand('bash', ['-c', website.server_update_script], {}, task.id, 'Running website update script');
745
+ }
746
+ }
747
+
748
+ async runPublisherGoPublish(taskId, publisherJar, sourceDir, webDir, registryDir, historyDir, templatesDir, zipsDir, logFile) {
749
+ const { spawn } = require('child_process');
750
+
751
+ const registryFile = path.join(registryDir, 'fhir-ig-list.json');
752
+
753
+ const args = [
754
+ '-jar', '-Xmx20000m', publisherJar,
755
+ '-go-publish',
756
+ '-source', sourceDir,
757
+ '-web', webDir,
758
+ '-registry', registryFile,
759
+ '-history', historyDir,
760
+ '-templates', templatesDir,
761
+ '-zips', zipsDir
762
+ ];
763
+
764
+ await this.logTaskMessage(taskId, 'info', 'java ' + args.join(' '));
765
+
766
+ return new Promise((resolve, reject) => {
767
+ const java = spawn('java', args, {
768
+ stdio: ['pipe', 'pipe', 'pipe']
769
+ });
770
+
771
+ const logStream = fs.createWriteStream(logFile);
772
+
773
+ java.stdout.on('data', (data) => {
774
+ logStream.write(data);
775
+ });
776
+
777
+ java.stderr.on('data', (data) => {
778
+ logStream.write(data);
779
+ });
780
+
781
+ java.on('close', async (code) => {
782
+ logStream.end();
783
+ if (code === 0) {
784
+ await this.logTaskMessage(taskId, 'info', 'IG Publisher go-publish completed successfully');
785
+ resolve();
786
+ } else {
787
+ const error = 'IG Publisher go-publish failed with exit code: ' + code;
788
+ await this.logTaskMessage(taskId, 'error', error);
789
+ reject(new Error(error));
790
+ }
791
+ });
792
+
793
+ java.on('error', async (error) => {
794
+ logStream.end();
795
+ await this.logTaskMessage(taskId, 'error', 'IG Publisher error: ' + error.message);
796
+ reject(error);
797
+ });
798
+
799
+ // Timeout after 60 minutes for publication (longer than draft build)
800
+ const timeout = setTimeout(async () => {
801
+ java.kill();
802
+ logStream.end();
803
+ await this.logTaskMessage(taskId, 'error', 'IG Publisher go-publish timed out after 60 minutes');
804
+ reject(new Error('IG Publisher go-publish timed out'));
805
+ }, 60 * 60 * 1000);
806
+
807
+ java.on('close', () => {
808
+ clearTimeout(timeout);
809
+ });
810
+ });
811
+ }
812
+
813
+ stopTaskProcessor() {
814
+ if (this.taskProcessor) {
815
+ clearInterval(this.taskProcessor);
816
+ this.taskProcessor = null;
817
+ this.logger.info('Task processor stopped');
818
+ }
819
+ }
820
+
821
+ // Middleware
822
+ requireAuth(req, res, next) {
823
+ if (!req.session.userId) {
824
+ return res.redirect('/publisher/login');
825
+ }
826
+ next();
827
+ }
828
+
829
+ requireAdmin(req, res, next) {
830
+ if (!req.session.userId || !req.session.isAdmin) {
831
+ return res.status(403).send('Admin access required');
832
+ }
833
+ next();
834
+ }
835
+
836
+ // Route handlers
837
+ async renderDashboard(req, res) {
838
+ const start = Date.now();
839
+ try {
840
+
841
+ try {
842
+ const htmlServer = require('../library/html-server');
843
+
844
+ // Get recent tasks
845
+ const tasks = await this.getTasks(10);
846
+
847
+ let content = '<div class="row mb-4">';
848
+ content += '<div class="col-12">';
849
+
850
+ if (req.session.userId) {
851
+ content += '<p>Welcome, ' + req.session.userName + '!</p>';
852
+ content += '<div class="mb-3">';
853
+ content += '<a href="/publisher/tasks" class="btn btn-primary me-2">View All Tasks</a>';
854
+ if (req.session.isAdmin) {
855
+ content += '<a href="/publisher/admin/websites" class="btn btn-secondary me-2">Manage Websites</a>';
856
+ content += '<a href="/publisher/admin/users" class="btn btn-secondary">Manage Users</a>';
857
+ }
858
+ content += '<form style="display: inline-block; margin-left: 10px;" method="post" action="/publisher/logout">';
859
+ content += '<button type="submit" class="btn btn-outline-secondary">Logout</button>';
860
+ content += '</form>';
861
+ content += '</div>';
862
+ } else {
863
+ content += '<p><a href="/publisher/login" class="btn btn-primary">Login</a> to create and manage publication tasks.</p>';
864
+ }
865
+
866
+ // Recent tasks
867
+ content += '<h3>Recent Tasks</h3>';
868
+ if (tasks.length === 0) {
869
+ content += '<p>No tasks found.</p>';
870
+ } else {
871
+ content += '<div class="table-responsive">';
872
+ content += '<table class="table table-striped">';
873
+ content += '<thead><tr><th>ID</th><th>Package</th><th>Version</th><th>Status</th><th>Queued</th><th>User</th></tr></thead>';
874
+ content += '<tbody>';
875
+
876
+ tasks.forEach(task => {
877
+ content += '<tr>';
878
+ content += '<td><strong>#' + task.id + '</strong></td>';
879
+ content += '<td>' + task.npm_package_id + '</td>';
880
+ content += '<td>' + task.version + '</td>';
881
+ content += '<td><span class="badge bg-' + this.getStatusColor(task.status) + '">' + task.status + '</span></td>';
882
+ content += '<td>' + new Date(task.queued_at).toLocaleString() + '</td>';
883
+ content += '<td>' + task.user_name + '</td>';
884
+ content += '</tr>';
885
+ });
886
+
887
+ content += '</tbody></table>';
888
+ content += '</div>';
889
+ }
890
+
891
+ content += '</div>';
892
+ content += '</div>';
893
+
894
+ const html = htmlServer.renderPage('publisher', 'FHIR Publisher', content, {
895
+ taskCount: tasks.length,
896
+ templateVars: {
897
+ loginTitle: req.session.userId ? "Logout" : 'Login',
898
+ loginPath: req.session.userId ? "logout" : 'login',
899
+ loginAction: req.session.userId ? "POST" : 'GET'
900
+ }
901
+ });
902
+
903
+ res.setHeader('Content-Type', 'text/html');
904
+ res.send(html);
905
+ } catch (error) {
906
+ this.logger.error('Error rendering dashboard:', error);
907
+ res.status(500).send('Internal server error');
908
+ }
909
+ } finally {
910
+ this.stats.countRequest('dashboard', Date.now() - start);
911
+ }
912
+ }
913
+
914
+ renderLogin(req, res) {
915
+ const start = Date.now();
916
+ try {
917
+
918
+ const htmlServer = require('../library/html-server');
919
+
920
+ let content = '<div class="row justify-content-center">';
921
+ content += '<div class="col-md-6">';
922
+ content += '<h3>Login</h3>';
923
+ content += '<form method="post" action="/publisher/login">';
924
+ content += '<div class="mb-3">';
925
+ content += '<label for="login" class="form-label">Username</label>';
926
+ content += '<input type="text" class="form-control" id="login" name="login" required>';
927
+ content += '</div>';
928
+ content += '<div class="mb-3">';
929
+ content += '<label for="password" class="form-label">Password</label>';
930
+ content += '<input type="password" class="form-control" id="password" name="password" required>';
931
+ content += '</div>';
932
+ content += '<button type="submit" class="btn btn-primary">Login</button>';
933
+ content += '</form>';
934
+ content += '</div>';
935
+ content += '</div>';
936
+
937
+ const html = htmlServer.renderPage('publisher', 'Login - FHIR Publisher', content, {
938
+ templateVars: {
939
+ loginTitle: req.session.userId ? "Logout" : 'Login',
940
+ loginPath: req.session.userId ? "logout" : 'login',
941
+ loginAction: req.session.userId ? "POST" : 'GET'
942
+ }});
943
+ res.setHeader('Content-Type', 'text/html');
944
+ res.send(html);
945
+ } finally {
946
+ this.stats.countRequest('login', Date.now() - start);
947
+ }
948
+ }
949
+
950
+ async handleLogin(req, res) {
951
+ const start = Date.now();
952
+ try {
953
+
954
+ try {
955
+ const {login, password} = req.body;
956
+
957
+ const user = await new Promise((resolve, reject) => {
958
+ this.db.get(
959
+ 'SELECT * FROM users WHERE login = ?',
960
+ [login],
961
+ (err, row) => {
962
+ if (err) reject(err);
963
+ else resolve(row);
964
+ }
965
+ );
966
+ });
967
+
968
+ if (!user) {
969
+ return res.redirect('/publisher/login?error=invalid');
970
+ }
971
+
972
+ const passwordMatch = await bcrypt.compare(password, user.password_hash);
973
+ if (!passwordMatch) {
974
+ return res.redirect('/publisher/login?error=invalid');
975
+ }
976
+
977
+ req.session.userId = user.id;
978
+ req.session.userName = user.name;
979
+ req.session.isAdmin = user.is_admin;
980
+
981
+ // Log the action
982
+ this.logUserAction(user.id, 'login', null, req.ip);
983
+
984
+ res.redirect('/publisher');
985
+ } catch (error) {
986
+ this.logger.error('Login error:', error);
987
+ res.redirect('/publisher/login?error=server');
988
+ }
989
+ } finally {
990
+ this.stats.countRequest('login', Date.now() - start);
991
+ }
992
+ }
993
+
994
+ handleLogout(req, res) {
995
+ const start = Date.now();
996
+ try {
997
+
998
+ req.session.destroy();
999
+ res.redirect('/publisher');
1000
+ } finally {
1001
+ this.stats.countRequest('logout', Date.now() - start);
1002
+ }
1003
+ }
1004
+
1005
+ async renderTasks(req, res) {
1006
+ const start = Date.now();
1007
+ try {
1008
+
1009
+ try {
1010
+ const htmlServer = require('../library/html-server');
1011
+ const tasks = await this.getTasks();
1012
+ const userWebsites = req.session.userId ? await this.getUserWebsites(req.session.userId) : [];
1013
+
1014
+ let content = '<div class="row mb-4">';
1015
+
1016
+ // Create task form for logged in users
1017
+ if (req.session.userId && userWebsites.length > 0) {
1018
+ content += '<div class="col-12 mb-4">';
1019
+ content += '<button class="btn btn-primary" onclick="document.getElementById(\'create-task-panel\').style.display = document.getElementById(\'create-task-panel\').style.display === \'none\' ? \'block\' : \'none\'">New Publication Task</button>';
1020
+ content += '<div id="create-task-panel" style="display: none;" class="mt-3">';
1021
+ content += '<h3>Create New Publication Task</h3>';
1022
+ content += '<form id="create-task-form" method="post" action="/publisher/tasks" class="row g-3">';
1023
+ content += '<div class="col-md-3">';
1024
+ content += '<label for="website_id" class="form-label">Target Website</label>';
1025
+ content += '<select class="form-select" id="website_id" name="website_id" required>';
1026
+ userWebsites.forEach(website => {
1027
+ content += '<option value="' + website.id + '">' + website.name + '</option>';
1028
+ });
1029
+ content += '</select>';
1030
+ content += '</div>';
1031
+ content += '<div class="col-md-3">';
1032
+ content += '<label for="github_org" class="form-label">GitHub Org</label>';
1033
+ content += '<input type="text" class="form-control" id="github_org" name="github_org" required placeholder="hl7">';
1034
+ content += '</div>';
1035
+ content += '<div class="col-md-3">';
1036
+ content += '<label for="github_repo" class="form-label">GitHub Repo</label>';
1037
+ content += '<input type="text" class="form-control" id="github_repo" name="github_repo" required placeholder="fhir-us-core">';
1038
+ content += '</div>';
1039
+ content += '<div class="col-md-3">';
1040
+ content += '<label for="git_branch" class="form-label">Branch</label>';
1041
+ content += '<input type="text" class="form-control" id="git_branch" name="git_branch" required placeholder="main">';
1042
+ content += '</div>';
1043
+ content += '<div class="col-md-4">';
1044
+ content += '<label for="npm_package_id" class="form-label">NPM Package ID</label>';
1045
+ content += '<input type="text" class="form-control" id="npm_package_id" name="npm_package_id" required placeholder="hl7.fhir.us.core">';
1046
+ content += '</div>';
1047
+ content += '<div class="col-md-4">';
1048
+ content += '<label for="version" class="form-label">Version</label>';
1049
+ content += '<input type="text" class="form-control" id="version" name="version" required placeholder="6.0.0">';
1050
+ content += '</div>';
1051
+ content += '<div class="col-md-4 d-flex align-items-end">';
1052
+ content += '<button type="submit" class="btn btn-primary">Create Task</button>';
1053
+ content += '</div>';
1054
+ content += '</form>';
1055
+ content += '</div>'; // create-task-panel
1056
+ content += '</div>';
1057
+ }
1058
+
1059
+ // Tasks list
1060
+ content += '<div class="col-12">';
1061
+ content += '<h3>Publication Tasks</h3>';
1062
+
1063
+ if (tasks.length === 0) {
1064
+ content += '<p>No tasks found.</p>';
1065
+ } else {
1066
+ content += '<div class="table-responsive">';
1067
+ content += '<table class="table table-striped">';
1068
+ content += '<thead><tr><th>ID</th><th>Package</th><th>Version</th><th>Website</th><th>Status</th><th>Queued</th><th>User</th><th>Actions</th></tr></thead>';
1069
+ content += '<tbody>';
1070
+
1071
+ for (const task of tasks) {
1072
+ const canApprove = req.session.userId && task.status === 'waiting for approval' &&
1073
+ await this.userCanApprove(req.session.userId, task.website_id);
1074
+
1075
+ content += '<tr>';
1076
+ content += '<td><strong>#' + task.id + '</strong></td>';
1077
+ content += '<td><code>' + task.npm_package_id + '</code></td>';
1078
+ content += '<td>' + task.version + '</td>';
1079
+ content += '<td>' + task.website_name + '</td>';
1080
+ content += '<td><span class="badge bg-' + this.getStatusColor(task.status) + '">' + task.status + '</span></td>';
1081
+ content += '<td>' + new Date(task.queued_at).toLocaleString() + '</td>';
1082
+ content += '<td>' + task.user_name + '</td>';
1083
+ content += '<td class="task-actions">';
1084
+ content += '<a href="/publisher/tasks/' + task.id + '/history" class="btn btn-sm btn-outline-secondary me-1">History</a>';
1085
+
1086
+ if (task.status === 'waiting for approval') {
1087
+ content += '<a href="/publisher/tasks/' + task.id + '/output" class="btn btn-sm btn-outline-info me-1">View Output</a>';
1088
+ content += '<a href="/publisher/tasks/' + task.id + '/qa" class="btn btn-sm btn-outline-secondary me-1">View QA</a>';
1089
+ if (canApprove) {
1090
+ content += '<form method="post" action="/publisher/tasks/' + task.id + '/approve" style="display: inline;">';
1091
+ content += '<button type="submit" name="approve" class="btn btn-sm btn-success me-1">Approve</button>';
1092
+ content += '</form>';
1093
+ }
1094
+ } else {
1095
+ if (task.build_output_path) {
1096
+ content += '<a href="/publisher/tasks/' + task.id + '/output" class="btn btn-sm btn-outline-info me-1">View Output</a>';
1097
+ }
1098
+ if (task.failure_reason) {
1099
+ content += '<span class="text-danger small me-1">' + this.escapeHtml(task.failure_reason) + '</span>';
1100
+ }
1101
+ }
1102
+
1103
+ if (req.session.isAdmin && (task.status === 'waiting for approval' || task.status === 'failed')) {
1104
+ 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.\')">';
1105
+ content += '<button type="submit" class="btn btn-sm btn-danger">Delete</button>';
1106
+ content += '</form>';
1107
+ }
1108
+
1109
+ content += '</td>';
1110
+ content += '</tr>';
1111
+ }
1112
+
1113
+ content += '</tbody></table>';
1114
+ content += '</div>';
1115
+ }
1116
+
1117
+ content += '</div>';
1118
+ content += '</div>';
1119
+
1120
+ const html = htmlServer.renderPage('publisher', 'Tasks - FHIR Publisher', content, {
1121
+ templateVars: {
1122
+ loginTitle: req.session.userId ? "Logout" : 'Login',
1123
+ loginPath: req.session.userId ? "logout" : 'login',
1124
+ loginAction: req.session.userId ? "POST" : 'GET'
1125
+ }});
1126
+ res.setHeader('Content-Type', 'text/html');
1127
+ res.send(html);
1128
+ } catch (error) {
1129
+ this.logger.error('Error rendering tasks:', error);
1130
+ res.status(500).send('Internal server error');
1131
+ }
1132
+
1133
+ } finally {
1134
+ this.stats.countRequest('tasks', Date.now() - start);
1135
+ }
1136
+ }
1137
+
1138
+ async createTask(req, res) {
1139
+ const start = Date.now();
1140
+ try {
1141
+
1142
+
1143
+ try {
1144
+ const {website_id, github_org, github_repo, git_branch, npm_package_id, version} = req.body;
1145
+
1146
+ // Verify user has permission for this website
1147
+ const canQueue = await this.userCanQueue(req.session.userId, website_id);
1148
+ if (!canQueue) {
1149
+ return res.status(403).send('You do not have permission to create tasks for this website');
1150
+ }
1151
+
1152
+ // Check for duplicate active tasks (only block if there's an active task for this package/version)
1153
+ const existingTask = await this.findActiveTask(npm_package_id, version);
1154
+ if (existingTask) {
1155
+ return res.status(400).send('An active task for this package and version is already in progress. Wait for it to complete or fail before resubmitting.');
1156
+ }
1157
+
1158
+ // Insert task (ID will be auto-generated)
1159
+ const result = await new Promise((resolve, reject) => {
1160
+ this.db.run(
1161
+ 'INSERT INTO tasks (user_id, website_id, github_org, github_repo, git_branch, npm_package_id, version) VALUES (?, ?, ?, ?, ?, ?, ?)',
1162
+ [req.session.userId, website_id, github_org, github_repo, git_branch, npm_package_id, version],
1163
+ function (err) {
1164
+ if (err) reject(err);
1165
+ else resolve(this.lastID);
1166
+ }
1167
+ );
1168
+ });
1169
+
1170
+ const taskId = result;
1171
+
1172
+ // Log the action
1173
+ this.logUserAction(req.session.userId, 'create_task', taskId.toString(), req.ip);
1174
+
1175
+ this.logger.info('Task created: ID=' + taskId + ' (' + npm_package_id + '#' + version + ') by user ' + req.session.userId);
1176
+ res.redirect('/publisher/tasks');
1177
+ } catch (error) {
1178
+ this.logger.error('Error creating task:', error);
1179
+ res.status(500).send('Failed to create task');
1180
+ }
1181
+ } finally {
1182
+ this.stats.countRequest('create-task', Date.now() - start);
1183
+ }
1184
+ }
1185
+
1186
+ async approveTask(req, res) {
1187
+ const start = Date.now();
1188
+ try {
1189
+ try {
1190
+ const taskId = req.params.id;
1191
+
1192
+ // Get task details
1193
+ const task = await this.getTask(taskId);
1194
+ if (!task) {
1195
+ return res.status(404).send('Task not found');
1196
+ }
1197
+
1198
+ if (task.status !== 'waiting for approval') {
1199
+ return res.status(400).send('Task is not waiting for approval');
1200
+ }
1201
+
1202
+ // Check user permissions
1203
+ const canApprove = await this.userCanApprove(req.session.userId, task.website_id);
1204
+ if (!canApprove) {
1205
+ return res.status(403).send('You do not have permission to approve tasks for this website');
1206
+ }
1207
+
1208
+ // Update task status
1209
+ await new Promise((resolve, reject) => {
1210
+ this.db.run(
1211
+ 'UPDATE tasks SET status = ?, approved_by = ?, publishing_at = CURRENT_TIMESTAMP WHERE id = ?',
1212
+ ['publishing', req.session.userId, taskId],
1213
+ (err) => {
1214
+ if (err) reject(err);
1215
+ else resolve();
1216
+ }
1217
+ );
1218
+ });
1219
+
1220
+ // Log the action
1221
+ this.logUserAction(req.session.userId, 'approve_task', taskId, req.ip);
1222
+
1223
+ this.logger.info('Task approved: ' + taskId + ' by user ' + req.session.userId);
1224
+ res.redirect('/publisher/tasks');
1225
+ } catch (error) {
1226
+ this.logger.error('Error approving task:', error);
1227
+ res.status(500).send('Failed to approve task');
1228
+ }
1229
+ } finally {
1230
+ this.stats.countRequest('approve-task', Date.now() - start);
1231
+ }
1232
+ }
1233
+
1234
+ async deleteTask(req, res) {
1235
+ const start = Date.now();
1236
+ try {
1237
+ try {
1238
+ const taskId = req.params.id;
1239
+ const task = await this.getTask(taskId);
1240
+
1241
+ if (!task) {
1242
+ return res.status(404).send('Task not found');
1243
+ }
1244
+
1245
+ if (task.status !== 'waiting for approval' && task.status !== 'failed') {
1246
+ return res.status(400).send('Only tasks waiting for approval or failed can be deleted');
1247
+ }
1248
+
1249
+ // Remove build output directory
1250
+ if (task.local_folder && fs.existsSync(task.local_folder)) {
1251
+ const rimraf = require('rimraf');
1252
+ await new Promise((resolve) => {
1253
+ rimraf(task.local_folder, (err) => {
1254
+ if (err) {
1255
+ this.logger.warn('Failed to remove task directory ' + task.local_folder + ': ' + err.message);
1256
+ }
1257
+ resolve(); // Continue even if directory removal fails
1258
+ });
1259
+ });
1260
+ }
1261
+
1262
+ // Delete task logs
1263
+ await new Promise((resolve, reject) => {
1264
+ this.db.run('DELETE FROM task_logs WHERE task_id = ?', [taskId.toString()], (err) => {
1265
+ if (err) reject(err);
1266
+ else resolve();
1267
+ });
1268
+ });
1269
+
1270
+ // Delete task record
1271
+ await new Promise((resolve, reject) => {
1272
+ this.db.run('DELETE FROM tasks WHERE id = ?', [taskId], (err) => {
1273
+ if (err) reject(err);
1274
+ else resolve();
1275
+ });
1276
+ });
1277
+
1278
+ this.logUserAction(req.session.userId, 'delete_task', taskId, req.ip);
1279
+ this.logger.info('Task deleted: ' + taskId + ' by admin ' + req.session.userId);
1280
+ res.redirect('/publisher/tasks');
1281
+ } catch (error) {
1282
+ this.logger.error('Error deleting task:', error);
1283
+ res.status(500).send('Failed to delete task');
1284
+ }
1285
+ } finally {
1286
+ this.stats.countRequest('delete-task', Date.now() - start);
1287
+ }
1288
+ }
1289
+
1290
+ async getTaskOutput(req, res) {
1291
+ const start = Date.now();
1292
+ try {
1293
+
1294
+ try {
1295
+ const taskId = req.params.id;
1296
+ const task = await this.getTask(taskId);
1297
+
1298
+ if (!task) {
1299
+ return res.status(404).send('Task not found');
1300
+ }
1301
+
1302
+ // Get task logs
1303
+ const logs = await this.getTaskLogs(taskId);
1304
+
1305
+ // Get build log if available
1306
+ let buildLog = '';
1307
+ if (task.build_output_path && fs.existsSync(task.build_output_path)) {
1308
+ try {
1309
+ buildLog = fs.readFileSync(task.build_output_path, 'utf8');
1310
+ } catch (error) {
1311
+ buildLog = 'Error reading build log: ' + error.message;
1312
+ }
1313
+ }
1314
+
1315
+ if (req.headers.accept && req.headers.accept.includes('text/html')) {
1316
+ const htmlServer = require('../library/html-server');
1317
+ let content = '<h3>Task Output: #' + task.id + ' - ' + task.npm_package_id + '#' + task.version + '</h3>';
1318
+ content += '<p><strong>Status:</strong> <span class="badge bg-' + this.getStatusColor(task.status) + '">' + task.status + '</span></p>';
1319
+ content += '<p><strong>GitHub:</strong> ' + task.github_org + '/' + task.github_repo + ' (' + task.git_branch + ')</p>';
1320
+
1321
+ if (task.local_folder) {
1322
+ content += '<p><strong>Local Folder:</strong> <code>' + task.local_folder + '</code></p>';
1323
+ }
1324
+
1325
+ if (task.failure_reason) {
1326
+ content += '<div class="alert alert-danger"><strong>Failure Reason:</strong> ' + task.failure_reason + '</div>';
1327
+ }
1328
+
1329
+ // Task logs section
1330
+ content += '<h4>Task Logs</h4>';
1331
+ if (logs.length === 0) {
1332
+ content += '<p>No task logs available yet.</p>';
1333
+ } else {
1334
+ content += '<div class="output-viewer" style="max-height: 300px;">';
1335
+ logs.forEach(log => {
1336
+ const timestamp = new Date(log.timestamp).toLocaleString();
1337
+ const levelClass = log.level === 'error' ? 'text-danger' : (log.level === 'warn' ? 'text-warning' : '');
1338
+ content += '<div class="' + levelClass + '">[' + timestamp + '] [' + log.level.toUpperCase() + '] ' + log.message + '</div>';
1339
+ });
1340
+ content += '</div>';
1341
+ }
1342
+
1343
+ // Build log section
1344
+ if (buildLog) {
1345
+ content += '<h4>Build Log</h4>';
1346
+ content += '<div class="output-viewer">' + this.escapeHtml(buildLog) + '</div>';
1347
+ } else if (task.status === 'building') {
1348
+ content += '<h4>Build Log</h4>';
1349
+ content += '<p><em>Build in progress... Log will appear when available.</em></p>';
1350
+ }
1351
+
1352
+ content += '<div class="mt-3"><a href="/publisher/tasks" class="btn btn-secondary">Back to Tasks</a></div>';
1353
+
1354
+ const html = htmlServer.renderPage('publisher', 'Task Output - FHIR Publisher', content, {
1355
+ templateVars: {
1356
+ loginTitle: req.session.userId ? "Logout" : 'Login',
1357
+ loginPath: req.session.userId ? "logout" : 'login',
1358
+ loginAction: req.session.userId ? "POST" : 'GET'
1359
+ }});
1360
+ res.setHeader('Content-Type', 'text/html');
1361
+ res.send(html);
1362
+ } else {
1363
+ // Return plain text logs
1364
+ let output = 'Task #' + task.id + ' - ' + task.npm_package_id + '#' + task.version + '\n';
1365
+ output += 'Status: ' + task.status + '\n';
1366
+ output += 'GitHub: ' + task.github_org + '/' + task.github_repo + ' (' + task.git_branch + ')\n';
1367
+
1368
+ if (task.local_folder) {
1369
+ output += 'Local Folder: ' + task.local_folder + '\n';
1370
+ }
1371
+
1372
+ output += '\n';
1373
+
1374
+ if (task.failure_reason) {
1375
+ output += 'Failure Reason: ' + task.failure_reason + '\n\n';
1376
+ }
1377
+
1378
+ output += 'Task Logs:\n';
1379
+ logs.forEach(log => {
1380
+ const timestamp = new Date(log.timestamp).toLocaleString();
1381
+ output += '[' + timestamp + '] [' + log.level.toUpperCase() + '] ' + log.message + '\n';
1382
+ });
1383
+
1384
+ if (buildLog) {
1385
+ output += '\n--- Build Log ---\n';
1386
+ output += buildLog;
1387
+ }
1388
+
1389
+ res.setHeader('Content-Type', 'text/plain');
1390
+ res.send(output);
1391
+ }
1392
+ } catch (error) {
1393
+ this.logger.error('Error getting task output:', error);
1394
+ res.status(500).send('Failed to get task output');
1395
+ }
1396
+ } finally {
1397
+ this.stats.countRequest('task-output', Date.now() - start);
1398
+ }
1399
+ }
1400
+
1401
+ async getTaskQA(req, res) {
1402
+ const start = Date.now();
1403
+ try {
1404
+ const taskId = req.params.id;
1405
+ res.redirect('/publisher/tasks/' + taskId + '/qa-files/qa.html');
1406
+ } finally {
1407
+ this.stats.countRequest('task-qa', Date.now() - start);
1408
+ }
1409
+ }
1410
+
1411
+ async getTaskHistory(req, res) {
1412
+ const start = Date.now();
1413
+ try {
1414
+ try {
1415
+ const taskId = req.params.id;
1416
+ const task = await this.getTask(taskId);
1417
+
1418
+ if (!task) {
1419
+ return res.status(404).send('Task not found');
1420
+ }
1421
+
1422
+ const logs = await this.getTaskLogs(taskId);
1423
+ const actions = await this.getTaskActions(taskId);
1424
+
1425
+ const htmlServer = require('../library/html-server');
1426
+
1427
+ let content = '<h3>Task History: #' + task.id + ' — ' + this.escapeHtml(task.npm_package_id) + '#' + this.escapeHtml(task.version) + '</h3>';
1428
+
1429
+ // Task details summary card
1430
+ content += '<div class="card mb-4"><div class="card-body">';
1431
+ content += '<div class="row">';
1432
+ content += '<div class="col-md-6">';
1433
+ content += '<p><strong>Status:</strong> <span class="badge bg-' + this.getStatusColor(task.status) + '">' + task.status + '</span></p>';
1434
+ content += '<p><strong>Package:</strong> <code>' + this.escapeHtml(task.npm_package_id) + '</code></p>';
1435
+ content += '<p><strong>Version:</strong> ' + this.escapeHtml(task.version) + '</p>';
1436
+ content += '<p><strong>Website:</strong> ' + this.escapeHtml(task.website_name) + '</p>';
1437
+ content += '</div>';
1438
+ content += '<div class="col-md-6">';
1439
+ content += '<p><strong>GitHub:</strong> ' + this.escapeHtml(task.github_org) + '/' + this.escapeHtml(task.github_repo) + ' (' + this.escapeHtml(task.git_branch) + ')</p>';
1440
+ content += '<p><strong>Created by:</strong> ' + this.escapeHtml(task.user_name) + ' (' + this.escapeHtml(task.user_login) + ')</p>';
1441
+ if (task.approved_by_name) {
1442
+ content += '<p><strong>Approved by:</strong> ' + this.escapeHtml(task.approved_by_name) + '</p>';
1443
+ }
1444
+ if (task.local_folder) {
1445
+ content += '<p><strong>Local folder:</strong> <code>' + this.escapeHtml(task.local_folder) + '</code></p>';
1446
+ }
1447
+ if (task.failure_reason) {
1448
+ content += '<p><strong>Failure reason:</strong> <span class="text-danger">' + this.escapeHtml(task.failure_reason) + '</span></p>';
1449
+ }
1450
+ content += '</div>';
1451
+ content += '</div>';
1452
+ content += '</div></div>';
1453
+
1454
+ // Announcement section (for completed publications)
1455
+ if (task.announcement) {
1456
+ content += '<div class="card mb-4"><div class="card-body">';
1457
+ content += '<h5>Announcement</h5>';
1458
+ content += '<pre class="mb-0" style="white-space: pre-wrap;">' + this.escapeHtml(task.announcement) + '</pre>';
1459
+ content += '</div></div>';
1460
+ }
1461
+
1462
+ // Build unified timeline from all sources
1463
+ const events = [];
1464
+
1465
+ // Status transition timestamps from the task record
1466
+ if (task.queued_at) {
1467
+ events.push({ timestamp: task.queued_at, type: 'status', icon: '📋', label: 'Task queued', detail: 'Created by ' + this.escapeHtml(task.user_name), css: '' });
1468
+ }
1469
+ if (task.building_at) {
1470
+ events.push({ timestamp: task.building_at, type: 'status', icon: '🔨', label: 'Draft build started', detail: '', css: '' });
1471
+ }
1472
+ if (task.waiting_approval_at) {
1473
+ events.push({ timestamp: task.waiting_approval_at, type: 'status', icon: '⏳', label: 'Waiting for approval', detail: 'Draft build completed', css: '' });
1474
+ }
1475
+ if (task.publishing_at) {
1476
+ const approver = task.approved_by_name ? 'Approved by ' + this.escapeHtml(task.approved_by_name) : '';
1477
+ events.push({ timestamp: task.publishing_at, type: 'status', icon: '🚀', label: 'Publishing started', detail: approver, css: '' });
1478
+ }
1479
+ if (task.completed_at) {
1480
+ events.push({ timestamp: task.completed_at, type: 'status', icon: '✅', label: 'Completed', detail: '', css: 'text-success' });
1481
+ }
1482
+ if (task.failed_at) {
1483
+ events.push({ timestamp: task.failed_at, type: 'status', icon: '❌', label: 'Failed', detail: task.failure_reason ? this.escapeHtml(task.failure_reason) : '', css: 'text-danger' });
1484
+ }
1485
+
1486
+ // Task log entries
1487
+ for (const log of logs) {
1488
+ events.push({
1489
+ timestamp: log.timestamp,
1490
+ type: 'log',
1491
+ icon: log.level === 'error' ? '🔴' : log.level === 'warn' ? '🟡' : '🔵',
1492
+ label: this.escapeHtml(log.message),
1493
+ detail: '',
1494
+ css: log.level === 'error' ? 'text-danger' : log.level === 'warn' ? 'text-warning' : 'text-muted'
1495
+ });
1496
+ }
1497
+
1498
+ // User actions
1499
+ for (const action of actions) {
1500
+ const who = action.user_name ? this.escapeHtml(action.user_name) + ' (' + this.escapeHtml(action.user_login) + ')' : 'Unknown';
1501
+ const ip = action.ip_address ? ' from ' + this.escapeHtml(action.ip_address) : '';
1502
+ let actionLabel = action.action;
1503
+ if (action.action === 'create_task') actionLabel = 'Created task';
1504
+ else if (action.action === 'approve_task') actionLabel = 'Approved task';
1505
+ else if (action.action === 'delete_task') actionLabel = 'Deleted task';
1506
+ else actionLabel = action.action.replace(/_/g, ' ');
1507
+
1508
+ events.push({
1509
+ timestamp: action.timestamp,
1510
+ type: 'action',
1511
+ icon: '👤',
1512
+ label: actionLabel,
1513
+ detail: who + ip,
1514
+ css: ''
1515
+ });
1516
+ }
1517
+
1518
+ // Sort all events chronologically
1519
+ events.sort((a, b) => {
1520
+ const ta = new Date(a.timestamp).getTime();
1521
+ const tb = new Date(b.timestamp).getTime();
1522
+ if (ta !== tb) return ta - tb;
1523
+ // Within the same timestamp, put status transitions first, then actions, then logs
1524
+ const order = { status: 0, action: 1, log: 2 };
1525
+ return (order[a.type] || 9) - (order[b.type] || 9);
1526
+ });
1527
+
1528
+ // Render timeline
1529
+ content += '<h4>Timeline</h4>';
1530
+ if (events.length === 0) {
1531
+ content += '<p>No history recorded yet.</p>';
1532
+ } else {
1533
+ content += '<table class="table table-sm">';
1534
+ content += '<thead><tr><th style="width: 180px;">Time</th><th style="width: 30px;"></th><th style="width: 100px;">Type</th><th>Event</th></tr></thead>';
1535
+ content += '<tbody>';
1536
+ for (const evt of events) {
1537
+ const ts = new Date(evt.timestamp).toLocaleString();
1538
+ const typeBadge = evt.type === 'status' ? '<span class="badge bg-primary">status</span>'
1539
+ : evt.type === 'action' ? '<span class="badge bg-info">user</span>'
1540
+ : '<span class="badge bg-secondary">log</span>';
1541
+ content += '<tr>';
1542
+ content += '<td class="text-nowrap"><small>' + ts + '</small></td>';
1543
+ content += '<td>' + evt.icon + '</td>';
1544
+ content += '<td>' + typeBadge + '</td>';
1545
+ content += '<td class="' + evt.css + '">' + evt.label;
1546
+ if (evt.detail) {
1547
+ content += ' <small class="text-muted">— ' + evt.detail + '</small>';
1548
+ }
1549
+ content += '</td>';
1550
+ content += '</tr>';
1551
+ }
1552
+ content += '</tbody></table>';
1553
+ }
1554
+
1555
+ // Links at the bottom
1556
+ content += '<div class="mt-3">';
1557
+ if (task.build_output_path) {
1558
+ content += '<a href="/publisher/tasks/' + task.id + '/output" class="btn btn-outline-info me-2">View Build Output</a>';
1559
+ }
1560
+ if (task.status === 'waiting for approval') {
1561
+ content += '<a href="/publisher/tasks/' + task.id + '/qa" class="btn btn-outline-secondary me-2">View QA Report</a>';
1562
+ }
1563
+ content += '<a href="/publisher/tasks" class="btn btn-secondary">Back to Tasks</a>';
1564
+ content += '</div>';
1565
+
1566
+ const html = htmlServer.renderPage('publisher', 'Task History - FHIR Publisher', content, {
1567
+ templateVars: {
1568
+ loginTitle: req.session.userId ? "Logout" : 'Login',
1569
+ loginPath: req.session.userId ? "logout" : 'login',
1570
+ loginAction: req.session.userId ? "POST" : 'GET'
1571
+ }});
1572
+ res.setHeader('Content-Type', 'text/html');
1573
+ res.send(html);
1574
+ } catch (error) {
1575
+ this.logger.error('Error rendering task history:', error);
1576
+ res.status(500).send('Internal server error');
1577
+ }
1578
+ } finally {
1579
+ this.stats.countRequest('task-history', Date.now() - start);
1580
+ }
1581
+ }
1582
+
1583
+ escapeHtml(text) {
1584
+ return text
1585
+ .replace(/&/g, '&amp;')
1586
+ .replace(/</g, '&lt;')
1587
+ .replace(/>/g, '&gt;')
1588
+ .replace(/"/g, '&quot;')
1589
+ .replace(/'/g, '&#039;');
1590
+ }
1591
+
1592
+ async renderWebsites(req, res) {
1593
+ const start = Date.now();
1594
+ try {
1595
+ try {
1596
+ const htmlServer = require('../library/html-server');
1597
+ const websites = await this.getWebsites();
1598
+
1599
+ let content = '<div class="row mb-4">';
1600
+
1601
+ // Add website form
1602
+ content += '<div class="col-12 mb-4">';
1603
+ content += '<button class="btn btn-primary" onclick="document.getElementById(\'add-website-panel\').style.display = document.getElementById(\'add-website-panel\').style.display === \'none\' ? \'block\' : \'none\'">Add New Website</button>';
1604
+ content += '<div id="add-website-panel" style="display: none;" class="mt-3">';
1605
+ content += '<h3>Add New Website</h3>';
1606
+ content += '<form method="post" action="/publisher/admin/websites" class="row g-3">';
1607
+ content += '<div class="col-md-4">';
1608
+ content += '<label for="name" class="form-label">Website Name</label>';
1609
+ content += '<input type="text" class="form-control" id="name" name="name" required>';
1610
+ content += '</div>';
1611
+ content += '<div class="col-md-4">';
1612
+ content += '<label for="local_folder" class="form-label">Local Folder</label>';
1613
+ content += '<input type="text" class="form-control" id="local_folder" name="local_folder" required>';
1614
+ content += '</div>';
1615
+ content += '<div class="col-md-4">';
1616
+ content += '<label for="server_update_script" class="form-label">Update Script</label>';
1617
+ content += '<input type="text" class="form-control" id="server_update_script" name="server_update_script" required>';
1618
+ content += '</div>';
1619
+ content += '<div class="col-12">';
1620
+ content += '<button type="submit" class="btn btn-primary">Add Website</button>';
1621
+ content += '</div>';
1622
+ content += '</form>';
1623
+ content += '</div>'; // add-website-panel
1624
+ content += '</div>';
1625
+
1626
+ // Websites list
1627
+ content += '<div class="col-12">';
1628
+ content += '<h3>Websites</h3>';
1629
+
1630
+ if (websites.length === 0) {
1631
+ content += '<p>No websites configured.</p>';
1632
+ } else {
1633
+ content += '<div class="table-responsive">';
1634
+ content += '<table class="table table-striped">';
1635
+ content += '<thead><tr><th>Name</th><th>Local Folder</th><th>Update Script</th><th>Active</th><th>Created</th></tr></thead>';
1636
+ content += '<tbody>';
1637
+
1638
+ websites.forEach(website => {
1639
+ content += '<tr>';
1640
+ content += '<td>' + website.name + '</td>';
1641
+ content += '<td><code>' + website.local_folder + '</code></td>';
1642
+ content += '<td><code>' + website.server_update_script + '</code></td>';
1643
+ content += '<td>' + (website.is_active ? '✓' : '✗') + '</td>';
1644
+ content += '<td>' + new Date(website.created_at).toLocaleString() + '</td>';
1645
+ content += '</tr>';
1646
+ });
1647
+
1648
+ content += '</tbody></table>';
1649
+ content += '</div>';
1650
+ }
1651
+
1652
+ content += '</div>';
1653
+ content += '</div>';
1654
+
1655
+ const html = htmlServer.renderPage('publisher', 'Websites - FHIR Publisher', content, {
1656
+ templateVars: {
1657
+ loginTitle: req.session.userId ? "Logout" : 'Login',
1658
+ loginPath: req.session.userId ? "logout" : 'login',
1659
+ loginAction: req.session.userId ? "POST" : 'GET'
1660
+ }});
1661
+ res.setHeader('Content-Type', 'text/html');
1662
+ res.send(html);
1663
+ } catch (error) {
1664
+ this.logger.error('Error rendering websites:', error);
1665
+ res.status(500).send('Internal server error');
1666
+ }
1667
+ } finally {
1668
+ this.stats.countRequest('websites', Date.now() - start);
1669
+ }
1670
+ }
1671
+
1672
+ async createWebsite(req, res) {
1673
+ const start = Date.now();
1674
+ try {
1675
+ try {
1676
+ const {name, local_folder, server_update_script} = req.body;
1677
+
1678
+ await new Promise((resolve, reject) => {
1679
+ this.db.run(
1680
+ 'INSERT INTO websites (name, local_folder, server_update_script) VALUES (?, ?, ?)',
1681
+ [name, local_folder, server_update_script],
1682
+ function (err) {
1683
+ if (err) reject(err);
1684
+ else resolve();
1685
+ }
1686
+ );
1687
+ });
1688
+
1689
+ this.logUserAction(req.session.userId, 'create_website', name, req.ip);
1690
+ this.logger.info('Website created: ' + name + ' by user ' + req.session.userId);
1691
+
1692
+ res.redirect('/publisher/admin/websites');
1693
+ } catch (error) {
1694
+ this.logger.error('Error creating website:', error);
1695
+ res.status(500).send('Failed to create website');
1696
+ }
1697
+ } finally {
1698
+ this.stats.countRequest('website', Date.now() - start);
1699
+ }
1700
+ }
1701
+
1702
+ async renderUsers(req, res) {
1703
+ const start = Date.now();
1704
+ try {
1705
+
1706
+
1707
+ try {
1708
+ const htmlServer = require('../library/html-server');
1709
+ const users = await this.getUsers();
1710
+ const websites = await this.getWebsites();
1711
+
1712
+ let content = '<div class="row mb-4">';
1713
+
1714
+ // Add user form
1715
+ content += '<div class="col-12 mb-4">';
1716
+ content += '<button class="btn btn-primary" onclick="document.getElementById(\'add-user-panel\').style.display = document.getElementById(\'add-user-panel\').style.display === \'none\' ? \'block\' : \'none\'">Add New User</button>';
1717
+ content += '<div id="add-user-panel" style="display: none;" class="mt-3">';
1718
+ content += '<h3>Add New User</h3>';
1719
+ content += '<form method="post" action="/publisher/admin/users" class="row g-3">';
1720
+ content += '<div class="col-md-3">';
1721
+ content += '<label for="name" class="form-label">Full Name</label>';
1722
+ content += '<input type="text" class="form-control" id="name" name="name" required>';
1723
+ content += '</div>';
1724
+ content += '<div class="col-md-3">';
1725
+ content += '<label for="login" class="form-label">Username</label>';
1726
+ content += '<input type="text" class="form-control" id="login" name="login" required>';
1727
+ content += '</div>';
1728
+ content += '<div class="col-md-3">';
1729
+ content += '<label for="password" class="form-label">Password</label>';
1730
+ content += '<input type="password" class="form-control" id="password" name="password" required>';
1731
+ content += '</div>';
1732
+ content += '<div class="col-md-3 d-flex align-items-end">';
1733
+ content += '<div class="form-check">';
1734
+ content += '<input class="form-check-input" type="checkbox" id="is_admin" name="is_admin">';
1735
+ content += '<label class="form-check-label" for="is_admin">Administrator</label>';
1736
+ content += '</div>';
1737
+ content += '</div>';
1738
+ content += '<div class="col-12">';
1739
+ content += '<button type="submit" class="btn btn-primary">Add User</button>';
1740
+ content += '</div>';
1741
+ content += '</form>';
1742
+ content += '</div>'; // add-user-panel
1743
+ content += '</div>';
1744
+
1745
+ // Users list
1746
+ content += '<div class="col-12">';
1747
+ content += '<h3>Users & Permissions</h3>';
1748
+
1749
+ if (users.length === 0) {
1750
+ content += '<p>No users found.</p>';
1751
+ } else {
1752
+ for (const user of users) {
1753
+ const permissions = await this.getUserPermissions(user.id);
1754
+
1755
+ content += '<div class="card mb-3">';
1756
+ content += '<div class="card-header">';
1757
+ content += '<h5>' + user.name + ' (' + user.login + ') ' + (user.is_admin ? '<span class="badge bg-warning">Admin</span>' : '') + '</h5>';
1758
+ content += '</div>';
1759
+ content += '<div class="card-body">';
1760
+
1761
+ if (websites.length === 0) {
1762
+ content += '<p>No websites available for permission assignment.</p>';
1763
+ } else {
1764
+ content += '<form method="post" action="/publisher/admin/permissions">';
1765
+ content += '<input type="hidden" name="user_id" value="' + user.id + '">';
1766
+ content += '<div class="permission-grid">';
1767
+ content += '<div><strong>Website</strong></div>';
1768
+ content += '<div><strong>Can Queue</strong></div>';
1769
+ content += '<div><strong>Can Approve</strong></div>';
1770
+
1771
+ websites.forEach(website => {
1772
+ const perm = permissions.find(p => p.website_id === website.id) || {};
1773
+ content += '<div>' + website.name + '</div>';
1774
+ content += '<div>';
1775
+ content += '<input type="checkbox" name="queue_' + website.id + '"' + (perm.can_queue ? ' checked' : '') + '>';
1776
+ content += '</div>';
1777
+ content += '<div>';
1778
+ content += '<input type="checkbox" name="approve_' + website.id + '"' + (perm.can_approve ? ' checked' : '') + '>';
1779
+ content += '</div>';
1780
+ });
1781
+
1782
+ content += '</div>';
1783
+ content += '<div class="mt-3">';
1784
+ content += '<button type="submit" class="btn btn-secondary btn-sm">Update Permissions</button>';
1785
+ content += '</div>';
1786
+ content += '</form>';
1787
+ }
1788
+
1789
+ content += '</div>';
1790
+ content += '</div>';
1791
+ }
1792
+ }
1793
+
1794
+ content += '</div>';
1795
+ content += '</div>';
1796
+
1797
+ const html = htmlServer.renderPage('publisher', 'Users - FHIR Publisher', content, {
1798
+ templateVars: {
1799
+ loginTitle: req.session.userId ? "Logout" : 'Login',
1800
+ loginPath: req.session.userId ? "logout" : 'login',
1801
+ loginAction: req.session.userId ? "POST" : 'GET'
1802
+ }});
1803
+ res.setHeader('Content-Type', 'text/html');
1804
+ res.send(html);
1805
+ } catch (error) {
1806
+ this.logger.error('Error rendering users:', error);
1807
+ res.status(500).send('Internal server error');
1808
+ }
1809
+ } finally {
1810
+ this.stats.countRequest('users', Date.now() - start);
1811
+ }
1812
+ }
1813
+
1814
+ async createUser(req, res) {
1815
+ const start = Date.now();
1816
+ try {
1817
+ try {
1818
+ const {name, login, password, is_admin} = req.body;
1819
+
1820
+ const passwordHash = await bcrypt.hash(password, 10);
1821
+
1822
+ await new Promise((resolve, reject) => {
1823
+ this.db.run(
1824
+ 'INSERT INTO users (name, login, password_hash, is_admin) VALUES (?, ?, ?, ?)',
1825
+ [name, login, passwordHash, is_admin ? 1 : 0],
1826
+ function (err) {
1827
+ if (err) reject(err);
1828
+ else resolve();
1829
+ }
1830
+ );
1831
+ });
1832
+
1833
+ this.logUserAction(req.session.userId, 'create_user', login, req.ip);
1834
+ this.logger.info('User created: ' + login + ' by user ' + req.session.userId);
1835
+
1836
+ res.redirect('/publisher/admin/users');
1837
+ } catch (error) {
1838
+ this.logger.error('Error creating user:', error);
1839
+ if (error.code === 'SQLITE_CONSTRAINT_UNIQUE') {
1840
+ res.status(400).send('Username already exists');
1841
+ } else {
1842
+ res.status(500).send('Failed to create user');
1843
+ }
1844
+ }
1845
+ } finally {
1846
+ this.stats.countRequest('createUser', Date.now() - start);
1847
+ }
1848
+ }
1849
+
1850
+ async updatePermissions(req, res) {
1851
+ const start = Date.now();
1852
+ try {
1853
+
1854
+ try {
1855
+ const {user_id} = req.body;
1856
+ const websites = await this.getWebsites();
1857
+
1858
+ // Clear existing permissions
1859
+ await new Promise((resolve, reject) => {
1860
+ this.db.run('DELETE FROM user_website_permissions WHERE user_id = ?', [user_id], (err) => {
1861
+ if (err) reject(err);
1862
+ else resolve();
1863
+ });
1864
+ });
1865
+
1866
+ // Add new permissions
1867
+ for (const website of websites) {
1868
+ const canQueue = req.body['queue_' + website.id] === 'on';
1869
+ const canApprove = req.body['approve_' + website.id] === 'on';
1870
+
1871
+ if (canQueue || canApprove) {
1872
+ await new Promise((resolve, reject) => {
1873
+ this.db.run(
1874
+ 'INSERT INTO user_website_permissions (user_id, website_id, can_queue, can_approve) VALUES (?, ?, ?, ?)',
1875
+ [user_id, website.id, canQueue ? 1 : 0, canApprove ? 1 : 0],
1876
+ (err) => {
1877
+ if (err) reject(err);
1878
+ else resolve();
1879
+ }
1880
+ );
1881
+ });
1882
+ }
1883
+ }
1884
+
1885
+ this.logUserAction(req.session.userId, 'update_permissions', user_id, req.ip);
1886
+ res.redirect('/publisher/admin/users');
1887
+ } catch (error) {
1888
+ this.logger.error('Error updating permissions:', error);
1889
+ res.status(500).send('Failed to update permissions');
1890
+ }
1891
+ } finally {
1892
+ this.stats.countRequest('update', Date.now() - start);
1893
+ }
1894
+ }
1895
+
1896
+ // Helper methods
1897
+ async getTasks(limit = null) {
1898
+ return new Promise((resolve, reject) => {
1899
+ let sql = 'SELECT t.*, u.name as user_name, 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 ORDER BY t.queued_at DESC';
1900
+
1901
+ if (limit) {
1902
+ sql += ' LIMIT ' + limit;
1903
+ }
1904
+
1905
+ this.db.all(sql, (err, rows) => {
1906
+ if (err) reject(err);
1907
+ else resolve(rows || []);
1908
+ });
1909
+ });
1910
+ }
1911
+
1912
+ async getTask(taskId) {
1913
+ return new Promise((resolve, reject) => {
1914
+ this.db.get(
1915
+ '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 = ?',
1916
+ [taskId],
1917
+ (err, row) => {
1918
+ if (err) reject(err);
1919
+ else resolve(row);
1920
+ }
1921
+ );
1922
+ });
1923
+ }
1924
+
1925
+ async getTaskLogs(taskId) {
1926
+ return new Promise((resolve, reject) => {
1927
+ this.db.all(
1928
+ 'SELECT * FROM task_logs WHERE task_id = ? ORDER BY timestamp ASC',
1929
+ [taskId.toString()],
1930
+ (err, rows) => {
1931
+ if (err) reject(err);
1932
+ else resolve(rows || []);
1933
+ }
1934
+ );
1935
+ });
1936
+ }
1937
+
1938
+ async getTaskActions(taskId) {
1939
+ return new Promise((resolve, reject) => {
1940
+ this.db.all(
1941
+ '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',
1942
+ [taskId.toString()],
1943
+ (err, rows) => {
1944
+ if (err) reject(err);
1945
+ else resolve(rows || []);
1946
+ }
1947
+ );
1948
+ });
1949
+ }
1950
+
1951
+ async getUserWebsites(userId) {
1952
+ return new Promise((resolve, reject) => {
1953
+ this.db.all(
1954
+ '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',
1955
+ [userId],
1956
+ (err, rows) => {
1957
+ if (err) reject(err);
1958
+ else resolve(rows || []);
1959
+ }
1960
+ );
1961
+ });
1962
+ }
1963
+
1964
+ async userCanQueue(userId, websiteId) {
1965
+ return new Promise((resolve, reject) => {
1966
+ this.db.get(
1967
+ 'SELECT can_queue FROM user_website_permissions WHERE user_id = ? AND website_id = ?',
1968
+ [userId, websiteId],
1969
+ (err, row) => {
1970
+ if (err) reject(err);
1971
+ else resolve(row && row.can_queue);
1972
+ }
1973
+ );
1974
+ });
1975
+ }
1976
+
1977
+ async userCanApprove(userId, websiteId) {
1978
+ return new Promise((resolve, reject) => {
1979
+ this.db.get(
1980
+ 'SELECT can_approve FROM user_website_permissions WHERE user_id = ? AND website_id = ?',
1981
+ [userId, websiteId],
1982
+ (err, row) => {
1983
+ if (err) reject(err);
1984
+ else resolve(row && row.can_approve);
1985
+ }
1986
+ );
1987
+ });
1988
+ }
1989
+
1990
+ async findActiveTask(packageId, version) {
1991
+ return new Promise((resolve, reject) => {
1992
+ this.db.get(
1993
+ 'SELECT * FROM tasks WHERE npm_package_id = ? AND version = ? AND status NOT IN (?, ?) ORDER BY queued_at DESC LIMIT 1',
1994
+ [packageId, version, 'complete', 'failed'],
1995
+ (err, row) => {
1996
+ if (err) reject(err);
1997
+ else resolve(row);
1998
+ }
1999
+ );
2000
+ });
2001
+ }
2002
+
2003
+ async findExistingTask(packageId, version) {
2004
+ return new Promise((resolve, reject) => {
2005
+ this.db.get(
2006
+ 'SELECT * FROM tasks WHERE npm_package_id = ? AND version = ? ORDER BY queued_at DESC LIMIT 1',
2007
+ [packageId, version],
2008
+ (err, row) => {
2009
+ if (err) reject(err);
2010
+ else resolve(row);
2011
+ }
2012
+ );
2013
+ });
2014
+ }
2015
+
2016
+ async getWebsites() {
2017
+ return new Promise((resolve, reject) => {
2018
+ this.db.all('SELECT * FROM websites ORDER BY name', (err, rows) => {
2019
+ if (err) reject(err);
2020
+ else resolve(rows || []);
2021
+ });
2022
+ });
2023
+ }
2024
+
2025
+ async getWebsite(websiteId) {
2026
+ return new Promise((resolve, reject) => {
2027
+ this.db.get('SELECT * FROM websites WHERE id = ?', [websiteId], (err, row) => {
2028
+ if (err) reject(err);
2029
+ else resolve(row);
2030
+ });
2031
+ });
2032
+ }
2033
+
2034
+ async runCommand(command, args, options, taskId, description) {
2035
+ const { spawn } = require('child_process');
2036
+
2037
+ await this.logTaskMessage(taskId, 'info', description);
2038
+
2039
+ return new Promise((resolve, reject) => {
2040
+ const proc = spawn(command, args, {
2041
+ stdio: ['pipe', 'pipe', 'pipe'],
2042
+ ...options
2043
+ });
2044
+
2045
+ let stdout = '';
2046
+ let stderr = '';
2047
+
2048
+ proc.stdout.on('data', (data) => {
2049
+ stdout += data.toString();
2050
+ });
2051
+
2052
+ proc.stderr.on('data', (data) => {
2053
+ stderr += data.toString();
2054
+ });
2055
+
2056
+ proc.on('close', async (code) => {
2057
+ if (code === 0) {
2058
+ resolve(stdout);
2059
+ } else {
2060
+ const error = description + ' failed with code ' + code + ': ' + (stderr || stdout);
2061
+ await this.logTaskMessage(taskId, 'error', error);
2062
+ reject(new Error(error));
2063
+ }
2064
+ });
2065
+
2066
+ proc.on('error', async (error) => {
2067
+ await this.logTaskMessage(taskId, 'error', description + ' error: ' + error.message);
2068
+ reject(error);
2069
+ });
2070
+ });
2071
+ }
2072
+
2073
+ async getUsers() {
2074
+ return new Promise((resolve, reject) => {
2075
+ this.db.all('SELECT id, name, login, is_admin, created_at FROM users ORDER BY name', (err, rows) => {
2076
+ if (err) reject(err);
2077
+ else resolve(rows || []);
2078
+ });
2079
+ });
2080
+ }
2081
+
2082
+ async getUserPermissions(userId) {
2083
+ return new Promise((resolve, reject) => {
2084
+ this.db.all(
2085
+ 'SELECT * FROM user_website_permissions WHERE user_id = ?',
2086
+ [userId],
2087
+ (err, rows) => {
2088
+ if (err) reject(err);
2089
+ else resolve(rows || []);
2090
+ }
2091
+ );
2092
+ });
2093
+ }
2094
+
2095
+ async shutdown() {
2096
+ this.logger.info('Shutting down publisher module...');
2097
+
2098
+ // Stop accepting new tasks
2099
+ this.shutdownRequested = true;
2100
+
2101
+ // Stop the task processor
2102
+ this.stopTaskProcessor();
2103
+
2104
+ // Wait for current task to finish (with timeout)
2105
+ const maxWaitTime = 30000; // 30 seconds
2106
+ const startTime = Date.now();
2107
+
2108
+ while (this.isProcessing && (Date.now() - startTime) < maxWaitTime) {
2109
+ await new Promise(resolve => setTimeout(resolve, 100));
2110
+ }
2111
+
2112
+ if (this.isProcessing) {
2113
+ this.logger.warn('Task processor did not finish within timeout period');
2114
+ }
2115
+
2116
+ // Close database
2117
+ if (this.db) {
2118
+ return new Promise((resolve) => {
2119
+ this.db.close((err) => {
2120
+ if (err) {
2121
+ this.logger.error('Error closing database:', err);
2122
+ } else {
2123
+ this.logger.info('Database closed');
2124
+ }
2125
+ resolve();
2126
+ });
2127
+ });
2128
+ }
2129
+ }
2130
+
2131
+ getStatusColor(status) {
2132
+ const colors = {
2133
+ 'queued': 'secondary',
2134
+ 'building': 'warning',
2135
+ 'waiting for approval': 'info',
2136
+ 'publishing': 'warning',
2137
+ 'complete': 'success',
2138
+ 'failed': 'danger'
2139
+ };
2140
+ return colors[status] || 'secondary';
2141
+ }
2142
+
2143
+ async logUserAction(userId, action, targetId, ipAddress) {
2144
+ return new Promise((resolve) => {
2145
+ this.db.run(
2146
+ 'INSERT INTO user_actions (user_id, action, target_id, ip_address) VALUES (?, ?, ?, ?)',
2147
+ [userId, action, targetId, ipAddress],
2148
+ () => resolve() // Don't fail if logging fails
2149
+ );
2150
+ });
2151
+ }
2152
+
2153
+ getStatus() {
2154
+ return {
2155
+ enabled: true,
2156
+ status: this.db ? 'Running' : 'Database not connected',
2157
+ taskProcessor: {
2158
+ running: this.taskProcessor !== null,
2159
+ processing: this.isProcessing,
2160
+ shutdownRequested: this.shutdownRequested
2161
+ }
2162
+ };
2163
+ }
2164
+
2165
+ }
2166
+
2167
+ module.exports = PublisherModule;