pediatric-ai-scribe-v3/src/db/database.js
Daniel Onyejesi c6cc9ece71 v3.1: Add Learning Hub with CMS, quizzes, and 3-role user system
- 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
2026-03-23 19:32:47 -04:00

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;