pediatric-ai-scribe-v3/src/db/database.js
ifedan-ed 04252c3015 v3.0.0: Auth, admin panel, security fixes, per-tab model selector
- Add authMiddleware to all AI/transcribe routes (were unauthenticated)
- Add full admin panel: user management, registration toggle, stats
- Fix XSS in email verification (escape user.name in HTML)
- Fix missing APP_URL fallback in password reset email
- Add per-tab model selector (respects OpenRouter/Bedrock/Azure lists)
- Fix transcribeAudio to send Authorization header
- Fix labs input: textarea instead of single-line input
- Add structured logging: audit_log, api_log, access_log tables
- Add admin CLI (admin-cli.js) for Docker exec management
- Fix duplicate var duration declaration in ai.js catch block
- Fix RETURNING check case-sensitivity in database.js
2026-03-21 19:25:51 -04:00

173 lines
5.3 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);
`);
// 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"
];
for (var i = 0; i < migrations.length; i++) {
try { await client.query(migrations[i]); } catch(e) {}
}
// Initialize default settings
await client.query(`
INSERT INTO app_settings (key, value) VALUES ('registration_enabled', 'true')
ON CONFLICT (key) DO NOTHING
`);
console.log('✅ Database tables: ready');
} catch (err) {
console.error('❌ Database init error:', err.message);
} finally {
client.release();
}
}
initDatabase();
// 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;