- New Learning Hub under Pediatric menu with content feed, category browsing, search, content viewer, and interactive quizzes (MCQ, true/false) - Quiz system with per-option wrong-answer explanations and general explanations - Admin CMS for creating/editing categories, content (articles, pearls, quizzes), and inline question builder with answer options - 3-role system: admin (full access), moderator (can manage Learning Hub content), user (all clinical features + learning hub read access) - 5 new database tables: learning_categories, learning_content, learning_questions, learning_options, learning_progress - User progress tracking with score history per quiz - Moderators see "Content Manager" tab instead of full Admin panel
308 lines
14 KiB
JavaScript
308 lines
14 KiB
JavaScript
const { Pool } = require('pg');
|
|
const fs = require('fs');
|
|
const path = require('path');
|
|
|
|
const DATABASE_URL = process.env.DATABASE_URL || 'postgresql://pedscribe:pedscribe_secret_change_me@localhost:5432/pedscribe';
|
|
|
|
const pool = new Pool({
|
|
connectionString: DATABASE_URL,
|
|
max: 20,
|
|
idleTimeoutMillis: 30000,
|
|
connectionTimeoutMillis: 5000
|
|
});
|
|
|
|
pool.query('SELECT NOW()')
|
|
.then(function() { console.log('✅ PostgreSQL: connected'); })
|
|
.catch(function(err) { console.error('❌ PostgreSQL: connection failed:', err.message); });
|
|
|
|
async function initDatabase() {
|
|
var client = await pool.connect();
|
|
try {
|
|
await client.query(`
|
|
CREATE TABLE IF NOT EXISTS users (
|
|
id SERIAL PRIMARY KEY,
|
|
email TEXT UNIQUE NOT NULL,
|
|
password TEXT NOT NULL,
|
|
name TEXT NOT NULL,
|
|
role TEXT DEFAULT 'user',
|
|
email_verified BOOLEAN DEFAULT false,
|
|
verify_token TEXT,
|
|
verify_expires BIGINT,
|
|
totp_secret TEXT,
|
|
totp_enabled BOOLEAN DEFAULT false,
|
|
passkey_credentials TEXT,
|
|
nextcloud_url TEXT,
|
|
nextcloud_user TEXT,
|
|
nextcloud_token TEXT,
|
|
nextcloud_folder TEXT DEFAULT '/PediatricScribe',
|
|
reset_token TEXT,
|
|
reset_expires BIGINT,
|
|
disabled BOOLEAN DEFAULT false,
|
|
created_at TIMESTAMPTZ DEFAULT NOW(),
|
|
updated_at TIMESTAMPTZ DEFAULT NOW()
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS app_settings (
|
|
key TEXT PRIMARY KEY,
|
|
value TEXT NOT NULL,
|
|
updated_at TIMESTAMPTZ DEFAULT NOW()
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS audit_log (
|
|
id SERIAL PRIMARY KEY,
|
|
user_id INTEGER REFERENCES users(id) ON DELETE SET NULL,
|
|
action TEXT NOT NULL,
|
|
category TEXT DEFAULT 'general',
|
|
details TEXT,
|
|
ip_address TEXT,
|
|
user_agent TEXT,
|
|
model_used TEXT,
|
|
tokens_used INTEGER,
|
|
duration_ms INTEGER,
|
|
status TEXT DEFAULT 'success',
|
|
timestamp TIMESTAMPTZ DEFAULT NOW()
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS api_log (
|
|
id SERIAL PRIMARY KEY,
|
|
user_id INTEGER REFERENCES users(id) ON DELETE SET NULL,
|
|
endpoint TEXT NOT NULL,
|
|
method TEXT,
|
|
status_code INTEGER,
|
|
request_size INTEGER,
|
|
response_size INTEGER,
|
|
model_used TEXT,
|
|
tokens_input INTEGER,
|
|
tokens_output INTEGER,
|
|
cost_estimate REAL,
|
|
duration_ms INTEGER,
|
|
ip_address TEXT,
|
|
error TEXT,
|
|
timestamp TIMESTAMPTZ DEFAULT NOW()
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS access_log (
|
|
id SERIAL PRIMARY KEY,
|
|
user_id INTEGER REFERENCES users(id) ON DELETE SET NULL,
|
|
action TEXT NOT NULL,
|
|
ip_address TEXT,
|
|
user_agent TEXT,
|
|
success BOOLEAN DEFAULT true,
|
|
timestamp TIMESTAMPTZ DEFAULT NOW()
|
|
);
|
|
|
|
CREATE INDEX IF NOT EXISTS idx_audit_user ON audit_log(user_id);
|
|
CREATE INDEX IF NOT EXISTS idx_audit_ts ON audit_log(timestamp);
|
|
CREATE INDEX IF NOT EXISTS idx_api_user ON api_log(user_id);
|
|
CREATE INDEX IF NOT EXISTS idx_api_ts ON api_log(timestamp);
|
|
CREATE INDEX IF NOT EXISTS idx_access_ts ON access_log(timestamp);
|
|
CREATE INDEX IF NOT EXISTS idx_users_email ON users(email);
|
|
|
|
CREATE TABLE IF NOT EXISTS saved_encounters (
|
|
id SERIAL PRIMARY KEY,
|
|
user_id INTEGER REFERENCES users(id) ON DELETE CASCADE,
|
|
label TEXT NOT NULL DEFAULT 'Untitled',
|
|
enc_type TEXT NOT NULL DEFAULT 'encounter',
|
|
transcript TEXT DEFAULT '',
|
|
generated_note TEXT DEFAULT '',
|
|
partial_data TEXT DEFAULT '{}',
|
|
status TEXT DEFAULT 'active',
|
|
created_at TIMESTAMPTZ DEFAULT NOW(),
|
|
updated_at TIMESTAMPTZ DEFAULT NOW(),
|
|
expires_at TIMESTAMPTZ DEFAULT NOW() + INTERVAL '7 days'
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS user_memories (
|
|
id SERIAL PRIMARY KEY,
|
|
user_id INTEGER REFERENCES users(id) ON DELETE CASCADE,
|
|
category TEXT NOT NULL DEFAULT 'custom',
|
|
name TEXT NOT NULL,
|
|
content TEXT NOT NULL,
|
|
created_at TIMESTAMPTZ DEFAULT NOW(),
|
|
updated_at TIMESTAMPTZ DEFAULT NOW()
|
|
);
|
|
|
|
CREATE INDEX IF NOT EXISTS idx_saved_enc_user ON saved_encounters(user_id);
|
|
CREATE INDEX IF NOT EXISTS idx_saved_enc_expires ON saved_encounters(expires_at);
|
|
CREATE INDEX IF NOT EXISTS idx_memories_user ON user_memories(user_id);
|
|
|
|
-- Learning Hub tables
|
|
CREATE TABLE IF NOT EXISTS learning_categories (
|
|
id SERIAL PRIMARY KEY,
|
|
name TEXT NOT NULL,
|
|
slug TEXT UNIQUE NOT NULL,
|
|
description TEXT DEFAULT '',
|
|
sort_order INTEGER DEFAULT 0,
|
|
created_at TIMESTAMPTZ DEFAULT NOW()
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS learning_content (
|
|
id SERIAL PRIMARY KEY,
|
|
title TEXT NOT NULL,
|
|
slug TEXT UNIQUE NOT NULL,
|
|
body TEXT NOT NULL DEFAULT '',
|
|
category_id INTEGER REFERENCES learning_categories(id) ON DELETE SET NULL,
|
|
subject TEXT DEFAULT '',
|
|
content_type TEXT DEFAULT 'article',
|
|
published BOOLEAN DEFAULT false,
|
|
author_id INTEGER REFERENCES users(id) ON DELETE SET NULL,
|
|
created_at TIMESTAMPTZ DEFAULT NOW(),
|
|
updated_at TIMESTAMPTZ DEFAULT NOW()
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS learning_questions (
|
|
id SERIAL PRIMARY KEY,
|
|
content_id INTEGER REFERENCES learning_content(id) ON DELETE CASCADE,
|
|
question_text TEXT NOT NULL,
|
|
question_type TEXT NOT NULL DEFAULT 'mcq',
|
|
explanation TEXT DEFAULT '',
|
|
sort_order INTEGER DEFAULT 0
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS learning_options (
|
|
id SERIAL PRIMARY KEY,
|
|
question_id INTEGER REFERENCES learning_questions(id) ON DELETE CASCADE,
|
|
option_text TEXT NOT NULL,
|
|
is_correct BOOLEAN DEFAULT false,
|
|
explanation TEXT DEFAULT '',
|
|
sort_order INTEGER DEFAULT 0
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS learning_progress (
|
|
id SERIAL PRIMARY KEY,
|
|
user_id INTEGER REFERENCES users(id) ON DELETE CASCADE,
|
|
content_id INTEGER REFERENCES learning_content(id) ON DELETE CASCADE,
|
|
score INTEGER,
|
|
total INTEGER,
|
|
completed_at TIMESTAMPTZ DEFAULT NOW()
|
|
);
|
|
|
|
CREATE INDEX IF NOT EXISTS idx_learning_content_cat ON learning_content(category_id);
|
|
CREATE INDEX IF NOT EXISTS idx_learning_content_pub ON learning_content(published);
|
|
CREATE INDEX IF NOT EXISTS idx_learning_questions_content ON learning_questions(content_id);
|
|
CREATE INDEX IF NOT EXISTS idx_learning_options_question ON learning_options(question_id);
|
|
CREATE INDEX IF NOT EXISTS idx_learning_progress_user ON learning_progress(user_id);
|
|
`);
|
|
|
|
// Add columns if upgrading
|
|
var migrations = [
|
|
"ALTER TABLE users ADD COLUMN IF NOT EXISTS role TEXT DEFAULT 'user'",
|
|
"ALTER TABLE users ADD COLUMN IF NOT EXISTS disabled BOOLEAN DEFAULT false",
|
|
"CREATE TABLE IF NOT EXISTS saved_encounters (id SERIAL PRIMARY KEY, user_id INTEGER REFERENCES users(id) ON DELETE CASCADE, label TEXT NOT NULL DEFAULT 'Untitled', enc_type TEXT NOT NULL DEFAULT 'encounter', transcript TEXT DEFAULT '', generated_note TEXT DEFAULT '', partial_data TEXT DEFAULT '{}', status TEXT DEFAULT 'active', created_at TIMESTAMPTZ DEFAULT NOW(), updated_at TIMESTAMPTZ DEFAULT NOW(), expires_at TIMESTAMPTZ DEFAULT NOW() + INTERVAL '7 days')",
|
|
"CREATE TABLE IF NOT EXISTS user_memories (id SERIAL PRIMARY KEY, user_id INTEGER REFERENCES users(id) ON DELETE CASCADE, category TEXT NOT NULL DEFAULT 'custom', name TEXT NOT NULL, content TEXT NOT NULL, created_at TIMESTAMPTZ DEFAULT NOW(), updated_at TIMESTAMPTZ DEFAULT NOW())",
|
|
"CREATE INDEX IF NOT EXISTS idx_saved_enc_user ON saved_encounters(user_id)",
|
|
"CREATE INDEX IF NOT EXISTS idx_saved_enc_expires ON saved_encounters(expires_at)",
|
|
"CREATE INDEX IF NOT EXISTS idx_memories_user ON user_memories(user_id)",
|
|
"CREATE TABLE IF NOT EXISTS learning_categories (id SERIAL PRIMARY KEY, name TEXT NOT NULL, slug TEXT UNIQUE NOT NULL, description TEXT DEFAULT '', sort_order INTEGER DEFAULT 0, created_at TIMESTAMPTZ DEFAULT NOW())",
|
|
"CREATE TABLE IF NOT EXISTS learning_content (id SERIAL PRIMARY KEY, title TEXT NOT NULL, slug TEXT UNIQUE NOT NULL, body TEXT NOT NULL DEFAULT '', category_id INTEGER REFERENCES learning_categories(id) ON DELETE SET NULL, subject TEXT DEFAULT '', content_type TEXT DEFAULT 'article', published BOOLEAN DEFAULT false, author_id INTEGER REFERENCES users(id) ON DELETE SET NULL, created_at TIMESTAMPTZ DEFAULT NOW(), updated_at TIMESTAMPTZ DEFAULT NOW())",
|
|
"CREATE TABLE IF NOT EXISTS learning_questions (id SERIAL PRIMARY KEY, content_id INTEGER REFERENCES learning_content(id) ON DELETE CASCADE, question_text TEXT NOT NULL, question_type TEXT NOT NULL DEFAULT 'mcq', explanation TEXT DEFAULT '', sort_order INTEGER DEFAULT 0)",
|
|
"CREATE TABLE IF NOT EXISTS learning_options (id SERIAL PRIMARY KEY, question_id INTEGER REFERENCES learning_questions(id) ON DELETE CASCADE, option_text TEXT NOT NULL, is_correct BOOLEAN DEFAULT false, explanation TEXT DEFAULT '', sort_order INTEGER DEFAULT 0)",
|
|
"CREATE TABLE IF NOT EXISTS learning_progress (id SERIAL PRIMARY KEY, user_id INTEGER REFERENCES users(id) ON DELETE CASCADE, content_id INTEGER REFERENCES learning_content(id) ON DELETE CASCADE, score INTEGER, total INTEGER, completed_at TIMESTAMPTZ DEFAULT NOW())",
|
|
"CREATE INDEX IF NOT EXISTS idx_learning_content_cat ON learning_content(category_id)",
|
|
"CREATE INDEX IF NOT EXISTS idx_learning_content_pub ON learning_content(published)",
|
|
"CREATE INDEX IF NOT EXISTS idx_learning_questions_content ON learning_questions(content_id)",
|
|
"CREATE INDEX IF NOT EXISTS idx_learning_options_question ON learning_options(question_id)",
|
|
"CREATE INDEX IF NOT EXISTS idx_learning_progress_user ON learning_progress(user_id)"
|
|
];
|
|
for (var i = 0; i < migrations.length; i++) {
|
|
try { await client.query(migrations[i]); } catch(e) {}
|
|
}
|
|
|
|
// Add updated_by column to app_settings if upgrading
|
|
try { await client.query("ALTER TABLE app_settings ADD COLUMN IF NOT EXISTS updated_by INTEGER REFERENCES users(id) ON DELETE SET NULL"); } catch(e) {}
|
|
|
|
// Seed all default config values (ON CONFLICT DO NOTHING — never overwrites admin changes)
|
|
var defaults = [
|
|
// Core
|
|
['registration_enabled', 'true'],
|
|
// Announcements
|
|
['announcement.enabled', 'false'],
|
|
['announcement.text', ''],
|
|
['announcement.type', 'info'],
|
|
// Feature flags
|
|
['feature.read_aloud', 'true'],
|
|
['feature.nextcloud', 'true'],
|
|
// Email — verify
|
|
['email.verify.subject', 'Verify your Pediatric AI Scribe account'],
|
|
['email.verify.body', 'Thank you for signing up! Click the button below to verify your email address. This link expires in 24 hours.'],
|
|
// Email — reset
|
|
['email.reset.subject', 'Reset your password — Pediatric AI Scribe'],
|
|
['email.reset.body', 'Someone requested a password reset for your Pediatric AI Scribe account. If that was you, click the button below to choose a new password. This link expires in 1 hour. If you did not request a password reset, no action is needed — your password has not been changed.'],
|
|
['feature.memories', 'true'],
|
|
['site.name', 'Pediatric AI Scribe'],
|
|
['site.auto_delete_days', '7'],
|
|
];
|
|
for (var d = 0; d < defaults.length; d++) {
|
|
await client.query(
|
|
"INSERT INTO app_settings (key, value) VALUES ($1, $2) ON CONFLICT (key) DO NOTHING",
|
|
defaults[d]
|
|
);
|
|
}
|
|
|
|
console.log('✅ Database tables: ready');
|
|
} catch (err) {
|
|
console.error('❌ Database init error:', err.message);
|
|
} finally {
|
|
client.release();
|
|
}
|
|
}
|
|
|
|
initDatabase();
|
|
|
|
// Clean up expired saved encounters daily
|
|
async function cleanupExpiredEncounters() {
|
|
try {
|
|
var result = await pool.query('DELETE FROM saved_encounters WHERE expires_at < NOW()');
|
|
if (result.rowCount > 0) console.log('[DB] Cleaned up ' + result.rowCount + ' expired encounters');
|
|
} catch(e) { console.error('[DB] Cleanup error:', e.message); }
|
|
}
|
|
setInterval(cleanupExpiredEncounters, 6 * 60 * 60 * 1000); // every 6 hours
|
|
setTimeout(cleanupExpiredEncounters, 10000); // also run 10s after startup
|
|
|
|
// Query helpers
|
|
var db = {
|
|
get: async function(sql, params) {
|
|
var result = await pool.query(convertPlaceholders(sql), params);
|
|
return result.rows[0] || null;
|
|
},
|
|
all: async function(sql, params) {
|
|
var result = await pool.query(convertPlaceholders(sql), params);
|
|
return result.rows;
|
|
},
|
|
run: async function(sql, params) {
|
|
var pgSql = convertPlaceholders(sql);
|
|
if (sql.trim().toUpperCase().startsWith('INSERT') && pgSql.toUpperCase().indexOf('RETURNING') === -1) {
|
|
pgSql += ' RETURNING id';
|
|
}
|
|
var result = await pool.query(pgSql, params);
|
|
return {
|
|
lastInsertRowid: result.rows[0] ? result.rows[0].id : null,
|
|
changes: result.rowCount
|
|
};
|
|
},
|
|
query: async function(sql, params) {
|
|
return pool.query(convertPlaceholders(sql), params);
|
|
},
|
|
// Settings helpers
|
|
getSetting: async function(key) {
|
|
var row = await pool.query('SELECT value FROM app_settings WHERE key = $1', [key]);
|
|
return row.rows[0] ? row.rows[0].value : null;
|
|
},
|
|
setSetting: async function(key, value) {
|
|
await pool.query(
|
|
'INSERT INTO app_settings (key, value, updated_at) VALUES ($1, $2, NOW()) ON CONFLICT (key) DO UPDATE SET value = $2, updated_at = NOW()',
|
|
[key, value]
|
|
);
|
|
},
|
|
pool: pool
|
|
};
|
|
|
|
function convertPlaceholders(sql) {
|
|
var index = 0;
|
|
return sql.replace(/\?/g, function() {
|
|
index++;
|
|
return '$' + index;
|
|
});
|
|
}
|
|
|
|
module.exports = db;
|