Infrastructure only — no existing data or tables modified.
src/db/migrate.js — programmatic runner, fires at boot after
the existing idempotent initDatabase()
migrations/1744600000000... — intentionally empty example, documents
the file shape. Registered in the new
pgmigrations tracking table so it won't
rerun.
.node-pg-migraterc.json — CLI config (migrations-dir, utc naming)
docs/migrations.md — workflow + conventions
package.json — migrate:up/down/new/status npm scripts
(status is a direct pgmigrations query
since node-pg-migrate v7 lacks a status
subcommand)
src/utils/sessions.js:
- parseUserAgent now recognizes the Capacitor wrapper (UA suffix
"PedScribe-Android" / "PedScribe-iOS") and labels sessions
"PedScribe (Android)" instead of "Chrome on Android".
Going forward: schema changes go in /migrations as versioned files
with up() + down(); the inline init in database.js is the implicit
baseline for everything already in production.
499 lines
23 KiB
JavaScript
499 lines
23 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 {
|
|
// Enable pgvector extension for embeddings
|
|
try {
|
|
await client.query('CREATE EXTENSION IF NOT EXISTS vector');
|
|
console.log('✅ pgvector extension: enabled');
|
|
} catch (err) {
|
|
console.warn('⚠️ pgvector extension not available. Vector search disabled. Install: apt-get install postgresql-16-pgvector');
|
|
}
|
|
|
|
// Collation-drift check — btree indexes on text become silently corrupt
|
|
// when the ICU / glibc library version bundled with the postgres image
|
|
// changes. pg_database tracks the collation version used when the DB
|
|
// was created/last refreshed; if it differs from what the library now
|
|
// reports, REINDEX and REFRESH to clear the flag.
|
|
try {
|
|
var dbDrift = await client.query(
|
|
"SELECT datcollversion AS recorded, " +
|
|
"pg_database_collation_actual_version(oid) AS actual " +
|
|
"FROM pg_database WHERE datname = current_database()"
|
|
);
|
|
var row = dbDrift.rows[0] || {};
|
|
if (row.recorded && row.actual && row.recorded !== row.actual) {
|
|
console.warn('⚠️ Collation drift: DB recorded ' + row.recorded + ', library reports ' + row.actual + '. REINDEX-ing to prevent silent index corruption...');
|
|
await client.query('REINDEX DATABASE pedscribe');
|
|
await client.query('ALTER DATABASE pedscribe REFRESH COLLATION VERSION').catch(function() {});
|
|
console.log('✅ REINDEX complete. Collation version refreshed.');
|
|
} else {
|
|
console.log('✅ Collation versions: aligned');
|
|
}
|
|
} catch (e) {
|
|
console.warn('⚠️ Collation check skipped:', e.message);
|
|
}
|
|
|
|
// Convert lookup-only text indexes to COLLATE "C" so a future ICU/glibc
|
|
// version bump cannot silently corrupt them. Idempotent — driven by a
|
|
// flag in app_settings so we only do the DROP/CREATE once.
|
|
try {
|
|
var done = await client.query(
|
|
"SELECT value FROM app_settings WHERE key = 'migration.text_indexes_c'"
|
|
).catch(function() { return { rows: [] }; });
|
|
if (!done.rows.length || done.rows[0].value !== 'true') {
|
|
console.log('Migrating text lookup indexes to COLLATE "C"...');
|
|
var indexMigrations = [
|
|
// users.email — primary auth lookup
|
|
'DROP INDEX IF EXISTS idx_users_email',
|
|
'CREATE INDEX idx_users_email ON users (email COLLATE "C")',
|
|
// user_sessions.token_hash — session auth
|
|
'DROP INDEX IF EXISTS idx_sessions_token_hash',
|
|
'CREATE INDEX idx_sessions_token_hash ON user_sessions (token_hash COLLATE "C")'
|
|
];
|
|
for (var i = 0; i < indexMigrations.length; i++) {
|
|
await client.query(indexMigrations[i]);
|
|
}
|
|
// Mark complete — create the setting row if the table exists.
|
|
await client.query(
|
|
"INSERT INTO app_settings (key, value) VALUES ('migration.text_indexes_c', 'true') " +
|
|
"ON CONFLICT (key) DO UPDATE SET value = 'true'"
|
|
).catch(function() {});
|
|
console.log('✅ Text indexes: migrated to COLLATE "C" (ICU-drift immune)');
|
|
}
|
|
} catch (e) {
|
|
console.warn('⚠️ Index migration skipped:', e.message);
|
|
}
|
|
|
|
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);
|
|
CREATE INDEX IF NOT EXISTS idx_memories_user_cat ON user_memories(user_id, category);
|
|
|
|
-- 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",
|
|
"ALTER TABLE users ADD COLUMN IF NOT EXISTS oidc_sub TEXT",
|
|
"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) {}
|
|
// Add webdav_learning_path to users for Learning Hub file browser
|
|
try { await client.query("ALTER TABLE users ADD COLUMN IF NOT EXISTS webdav_learning_path TEXT DEFAULT NULL"); } catch(e) {}
|
|
// Add user preferences for STT model and TTS voice
|
|
try { await client.query("ALTER TABLE users ADD COLUMN IF NOT EXISTS stt_model TEXT DEFAULT NULL"); } catch(e) {}
|
|
try { await client.query("ALTER TABLE users ADD COLUMN IF NOT EXISTS tts_voice TEXT DEFAULT NULL"); } catch(e) {}
|
|
// 2FA backup codes — JSON array of bcrypt hashes; entries consumed on use
|
|
try { await client.query("ALTER TABLE users ADD COLUMN IF NOT EXISTS totp_backup_codes TEXT DEFAULT NULL"); } catch(e) {}
|
|
// Add idempotency_key for duplicate prevention
|
|
try { await client.query("ALTER TABLE saved_encounters ADD COLUMN IF NOT EXISTS idempotency_key TEXT"); } catch(e) {}
|
|
try { await client.query("CREATE UNIQUE INDEX IF NOT EXISTS idx_saved_enc_idemp ON saved_encounters(user_id, idempotency_key) WHERE idempotency_key IS NOT NULL"); } catch(e) {}
|
|
// Audio backups table — server-side, auto-deleted after 24 hours
|
|
try { await client.query(`
|
|
CREATE TABLE IF NOT EXISTS audio_backups (
|
|
id SERIAL PRIMARY KEY,
|
|
user_id INTEGER REFERENCES users(id) ON DELETE CASCADE,
|
|
module TEXT NOT NULL DEFAULT 'encounter',
|
|
mime_type TEXT DEFAULT 'audio/webm',
|
|
size_bytes INTEGER DEFAULT 0,
|
|
compressed_bytes INTEGER DEFAULT 0,
|
|
audio_data BYTEA NOT NULL,
|
|
created_at TIMESTAMPTZ DEFAULT NOW(),
|
|
expires_at TIMESTAMPTZ DEFAULT NOW() + INTERVAL '24 hours'
|
|
);
|
|
CREATE INDEX IF NOT EXISTS idx_audio_backups_user ON audio_backups(user_id);
|
|
CREATE INDEX IF NOT EXISTS idx_audio_backups_expires ON audio_backups(expires_at);
|
|
`); } catch(e) {}
|
|
|
|
// User documents table for S3 storage
|
|
try { await client.query(`
|
|
CREATE TABLE IF NOT EXISTS user_documents (
|
|
id SERIAL PRIMARY KEY,
|
|
user_id INTEGER REFERENCES users(id) ON DELETE CASCADE,
|
|
s3_key TEXT NOT NULL,
|
|
filename TEXT NOT NULL,
|
|
mime_type TEXT DEFAULT 'application/octet-stream',
|
|
size_bytes INTEGER DEFAULT 0,
|
|
description TEXT DEFAULT '',
|
|
created_at TIMESTAMPTZ DEFAULT NOW()
|
|
);
|
|
CREATE INDEX IF NOT EXISTS idx_user_docs_user ON user_documents(user_id);
|
|
`); } catch(e) {}
|
|
|
|
// User sessions for session management (revocable login sessions)
|
|
try { await client.query(`
|
|
CREATE TABLE IF NOT EXISTS user_sessions (
|
|
id TEXT PRIMARY KEY,
|
|
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
|
token_hash TEXT NOT NULL,
|
|
ip_address TEXT,
|
|
user_agent TEXT,
|
|
device_label TEXT,
|
|
created_at TIMESTAMPTZ DEFAULT NOW(),
|
|
last_activity TIMESTAMPTZ DEFAULT NOW()
|
|
);
|
|
CREATE INDEX IF NOT EXISTS idx_sessions_user ON user_sessions(user_id);
|
|
CREATE INDEX IF NOT EXISTS idx_sessions_token_hash ON user_sessions(token_hash);
|
|
`); } catch(e) {}
|
|
|
|
// Add embedding column to learning_content for vector search (768 dims = Vertex AI text-embedding-005)
|
|
try {
|
|
await client.query('ALTER TABLE learning_content ADD COLUMN IF NOT EXISTS embedding vector(768)');
|
|
console.log('✅ learning_content.embedding: added');
|
|
} catch(e) {
|
|
console.warn('⚠️ Could not add embedding column:', e.message);
|
|
}
|
|
|
|
// Developmental milestones table
|
|
try { await client.query(`
|
|
CREATE TABLE IF NOT EXISTS developmental_milestones (
|
|
id SERIAL PRIMARY KEY,
|
|
age_group TEXT NOT NULL,
|
|
domain TEXT NOT NULL,
|
|
milestone_text TEXT NOT NULL,
|
|
sort_order INTEGER DEFAULT 0,
|
|
created_at TIMESTAMPTZ DEFAULT NOW(),
|
|
updated_at TIMESTAMPTZ DEFAULT NOW()
|
|
);
|
|
CREATE INDEX IF NOT EXISTS idx_milestones_age_domain ON developmental_milestones(age_group, domain);
|
|
`);
|
|
console.log('✅ developmental_milestones: table ready');
|
|
} catch(e) {
|
|
console.warn('⚠️ Could not create milestones table:', e.message);
|
|
}
|
|
|
|
// Create IVFFLAT index for fast similarity search (after data is populated)
|
|
try {
|
|
var indexExists = await client.query(
|
|
"SELECT 1 FROM pg_indexes WHERE indexname = 'idx_learning_content_embedding'"
|
|
);
|
|
if (indexExists.rows.length === 0) {
|
|
// Check if we have at least some embeddings before creating index
|
|
var embCount = await client.query('SELECT COUNT(*) as count FROM learning_content WHERE embedding IS NOT NULL');
|
|
if (parseInt(embCount.rows[0]?.count || 0) >= 10) {
|
|
// IVFFLAT requires lists parameter — use sqrt(rows) as heuristic
|
|
var lists = Math.max(10, Math.floor(Math.sqrt(embCount.rows[0].count)));
|
|
await client.query(
|
|
`CREATE INDEX idx_learning_content_embedding ON learning_content
|
|
USING ivfflat (embedding vector_cosine_ops) WITH (lists = ${lists})`
|
|
);
|
|
console.log('✅ Vector search index: created');
|
|
}
|
|
}
|
|
} catch(e) {
|
|
// Index creation can fail if not enough data or pgvector not installed — safe to ignore
|
|
}
|
|
|
|
// 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();
|
|
}
|
|
}
|
|
|
|
// Boot sequence: inline init (idempotent baseline) → node-pg-migrate for
|
|
// incremental changes. Migrations live in /app/migrations/.
|
|
(async function() {
|
|
await initDatabase();
|
|
try {
|
|
var { runMigrations } = require('./migrate');
|
|
await runMigrations();
|
|
} catch (e) {
|
|
// runMigrations already logs the fatal; don't hide it but don't crash
|
|
// the whole process here either — server.js health check catches a
|
|
// broken DB separately.
|
|
console.error('[DB] Migration runner failed:', e.message);
|
|
}
|
|
})();
|
|
|
|
// Clean up expired saved encounters and audio backups
|
|
async function cleanupExpired() {
|
|
try {
|
|
var enc = await pool.query('DELETE FROM saved_encounters WHERE expires_at < NOW()');
|
|
if (enc.rowCount > 0) console.log('[DB] Cleaned up ' + enc.rowCount + ' expired encounters');
|
|
} catch(e) { console.error('[DB] Encounter cleanup error:', e.message); }
|
|
try {
|
|
var audio = await pool.query('DELETE FROM audio_backups WHERE expires_at < NOW()');
|
|
if (audio.rowCount > 0) console.log('[DB] Cleaned up ' + audio.rowCount + ' expired audio backups');
|
|
} catch(e) { /* table may not exist yet */ }
|
|
try {
|
|
var sess = await pool.query("DELETE FROM user_sessions WHERE created_at < NOW() - INTERVAL '7 days'");
|
|
if (sess.rowCount > 0) console.log('[DB] Cleaned up ' + sess.rowCount + ' expired sessions');
|
|
} catch(e) { /* table may not exist yet */ }
|
|
}
|
|
setInterval(cleanupExpired, 60 * 60 * 1000); // every hour (audio backups are 24h)
|
|
setTimeout(cleanupExpired, 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;
|