BillTracker/db/database.js

1612 lines
65 KiB
JavaScript

const Database = require('better-sqlite3');
const path = require('path');
const fs = require('fs');
// Lazy import for auditService — cannot require at top level due to circular dependency
// (auditService -> database.js -> auditService). Use getLogAudit() instead of logAudit directly.
let _logAudit = null;
function getLogAudit() {
if (!_logAudit) {
try { _logAudit = require('../services/auditService').logAudit; } catch { _logAudit = () => {}; }
}
return _logAudit;
}
const DB_PATH = process.env.DB_PATH || path.join(__dirname, 'bills.db');
const SCHEMA_PATH = path.join(__dirname, 'schema.sql');
const DEFAULT_CATEGORIES = [
'Housing',
'Utilities',
'Credit Cards',
'Loans',
'Insurance',
'Subscriptions',
'Phone & Internet',
'Transportation',
'Medical',
'Other',
];
// ── SQL Whitelist Mappings ────────────────────────────────────────────────────
// Security FIX (2026-05-08): Whitelist all allowed column names to prevent SQL injection
// in migrations that use dynamic ALTER TABLE statements.
const COLUMN_WHITELIST = new Set([
// users table columns
'active', 'is_default_admin', 'notification_email', 'notifications_enabled',
'notify_3d', 'notify_1d', 'notify_due', 'notify_overdue',
'display_name', 'last_password_change_at', 'auth_provider', 'external_subject',
'email', 'last_login_at',
// payments table columns
'deleted_at',
// monthly_starting_amounts table columns
'other_amount',
// bills table columns
'history_visibility', 'interest_rate', 'user_id',
// sessions table columns
'created_at',
]);
// Security validation function for column names
function isValidColumnName(col) {
if (!col || typeof col !== 'string') return false;
// Must be in whitelist AND match valid SQL identifier pattern
return COLUMN_WHITELIST.has(col) && /^[a-z0-9_]+$/i.test(col);
}
// Security validation function for SQL definition fragments
function isValidSqlDefinition(def) {
if (!def || typeof def !== 'string') return false;
// Allow standard column definitions but reject any user input
// This is safe because all definitions are hardcoded here
return /^[\w\s\(\)\',!@#$%^&*+=\[\]<>\-.]+$/i.test(def);
}
fs.mkdirSync(path.dirname(DB_PATH), { recursive: true });
let db = null;
let initializing = false;
function assertWritableDbPath() {
const dir = path.dirname(DB_PATH);
const probe = path.join(dir, `.write-test-${process.pid}-${Date.now()}`);
try {
fs.mkdirSync(dir, { recursive: true });
fs.writeFileSync(probe, 'ok');
fs.unlinkSync(probe);
if (fs.existsSync(DB_PATH)) {
fs.accessSync(DB_PATH, fs.constants.R_OK | fs.constants.W_OK);
}
} catch (err) {
const message = [
`Database path is not writable: ${DB_PATH}`,
`Ensure the DB directory is writable by the app user. In Docker, rebuild the image and recreate the container so the entrypoint can chown /data.`,
`Original error: ${err.message}`,
].join('\n');
const wrapped = new Error(message);
wrapped.code = err.code;
throw wrapped;
} finally {
try {
if (fs.existsSync(probe)) fs.unlinkSync(probe);
} catch {}
}
}
function sleep(ms) {
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
}
function getDb() {
// already ready
if (db) return db;
// wait if another init is happening
while (initializing) {
sleep(50);
}
// check again after wait
if (db) return db;
initializing = true;
try {
console.log('Opening DB at:', path.basename(DB_PATH));
assertWritableDbPath();
db = new Database(DB_PATH, {
timeout: 5000
});
db.pragma('busy_timeout = 5000');
try {
db.pragma('journal_mode = WAL');
} catch (e) {
console.warn('WAL failed:', e.message);
}
db.pragma('foreign_keys = ON');
initSchema();
seedDefaults();
console.log('DB initialized successfully');
return db;
} catch (err) {
console.error('DB init failed:', err);
throw err;
} finally {
initializing = false;
}
}
function initSchema() {
const schema = fs.readFileSync(SCHEMA_PATH, 'utf8');
db.exec(schema);
// Create schema_migrations table for tracking applied migrations
db.exec(`
CREATE TABLE IF NOT EXISTS schema_migrations (
id INTEGER PRIMARY KEY AUTOINCREMENT,
version TEXT NOT NULL UNIQUE,
description TEXT NOT NULL,
applied_at TEXT NOT NULL DEFAULT (datetime('now'))
)
`);
// Check if this is a legacy database (tables exist but no migration tracking)
handleLegacyDatabase();
// After legacy reconciliation and user seeding, reset the default admin password
// when INIT_ADMIN_PASS is set. This ensures legacy DBs can be accessed after migration.
// The must_change_password flag forces the admin to pick a new password on first login.
if (process.env.INIT_ADMIN_PASS) {
const initUser = process.env.INIT_ADMIN_USER || 'admin';
const initPass = process.env.INIT_ADMIN_PASS;
const bcrypt = require('bcryptjs');
const newPasswordHash = bcrypt.hashSync(initPass, 12);
// Reset password for the default admin user if INIT_ADMIN_PASS is set
const result = db.prepare(`
UPDATE users SET password_hash = ?, first_login = 0, must_change_password = 0
WHERE username = ? AND is_default_admin = 1
`).run(newPasswordHash, initUser);
if (result.changes > 0) {
console.log('[init] Reset password and flags for default admin user');
}
}
runMigrations();
}
function hasMigrationBeenApplied(version) {
const stmt = db.prepare('SELECT 1 FROM schema_migrations WHERE version = ?');
return !!stmt.get(version);
}
function handleLegacyDatabase() {
// Check if schema_migrations table exists but is empty
// This indicates a legacy database that predates migration tracking
const migrationCount = db.prepare('SELECT COUNT(*) as count FROM schema_migrations').get().count;
if (migrationCount === 0) {
// This might be a legacy database. Check if core tables exist.
const tableCheck = db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name IN ('users', 'bills', 'payments', 'categories', 'settings')").all();
// If we have core tables but no migrations tracked, this is likely a legacy DB
if (tableCheck.length >= 3) { // At least some core tables exist
console.log('[migration] Detected legacy database, reconciling schema migrations...');
// For each migration, check if its changes are already present and mark as applied if so
reconcileLegacyMigrations();
}
}
}
function reconcileLegacyMigrations() {
// Define all migrations with explicit version tracking
const migrations = [
{
version: 'v0.2',
description: 'payments: soft-delete column',
check: function() {
const paymentCols = db.prepare('PRAGMA table_info(payments)').all().map(c => c.name);
return paymentCols.includes('deleted_at');
},
run: function() {
const paymentCols = db.prepare('PRAGMA table_info(payments)').all().map(c => c.name);
if (!paymentCols.includes('deleted_at')) {
db.exec('ALTER TABLE payments ADD COLUMN deleted_at TEXT');
// Index for fast filtering of live payments
db.exec('CREATE INDEX IF NOT EXISTS idx_payments_deleted ON payments(deleted_at)');
console.log('[migration] payments.deleted_at column added');
}
}
},
{
version: 'v0.3',
description: 'payments: compound index for tracker query',
check: function() {
// Check if the index exists
const indexes = db.prepare("SELECT name FROM sqlite_master WHERE type='index' AND name='idx_payments_bill_date_del'").all();
return indexes.length > 0;
},
run: function() {
// Supports: WHERE bill_id = ? AND paid_date BETWEEN ? AND ? AND deleted_at IS NULL
db.exec('CREATE INDEX IF NOT EXISTS idx_payments_bill_date_del ON payments(bill_id, paid_date, deleted_at)');
}
},
{
version: 'v0.4',
description: 'monthly_bill_state: per-bill per-month overrides',
check: function() {
const tables = db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='monthly_bill_state'").all();
return tables.length > 0;
},
run: function() {
db.exec(`
CREATE TABLE IF NOT EXISTS monthly_bill_state (
id INTEGER PRIMARY KEY AUTOINCREMENT,
bill_id INTEGER NOT NULL REFERENCES bills(id) ON DELETE CASCADE,
year INTEGER NOT NULL CHECK(year BETWEEN 2000 AND 2100),
month INTEGER NOT NULL CHECK(month BETWEEN 1 AND 12),
actual_amount REAL,
notes TEXT,
is_skipped INTEGER NOT NULL DEFAULT 0,
created_at TEXT DEFAULT (datetime('now')),
updated_at TEXT DEFAULT (datetime('now')),
UNIQUE(bill_id, year, month)
)
`);
db.exec('CREATE INDEX IF NOT EXISTS idx_monthly_bill_state_lookup ON monthly_bill_state(bill_id, year, month)');
console.log('[migration] monthly_bill_state table ensured');
}
},
{
version: 'v0.13',
description: 'users: profile columns',
check: function() {
const userColsNow = db.prepare('PRAGMA table_info(users)').all().map(c => c.name);
const profileCols = ['display_name', 'last_password_change_at'];
return profileCols.every(col => userColsNow.includes(col));
},
run: function() {
const userColsNow = db.prepare('PRAGMA table_info(users)').all().map(c => c.name);
const profileCols = [
['display_name', 'TEXT'],
['last_password_change_at','TEXT'],
];
for (const [col, def] of profileCols) {
if (!userColsNow.includes(col)) {
// Security FIX (2026-05-08): Validate column name and definition to prevent SQL injection
if (!isValidColumnName(col) || !isValidSqlDefinition(def)) {
throw new Error(`Invalid migration: column '${col}' not in whitelist`);
}
db.exec(`ALTER TABLE users ADD COLUMN ${col} ${def}`);
}
}
}
},
{
version: 'v0.14',
description: 'bills: history visibility mode',
check: function() {
const billColsHist = db.prepare('PRAGMA table_info(bills)').all().map(c => c.name);
return billColsHist.includes('history_visibility');
},
run: function() {
const billColsHist = db.prepare('PRAGMA table_info(bills)').all().map(c => c.name);
if (!billColsHist.includes('history_visibility')) {
db.exec("ALTER TABLE bills ADD COLUMN history_visibility TEXT NOT NULL DEFAULT 'default'");
console.log('[migration] bills.history_visibility column added');
}
}
},
{
version: 'v0.14.4',
description: 'bills: optional credit-card APR / interest rate',
check: function() {
const billColsInterest = db.prepare('PRAGMA table_info(bills)').all().map(c => c.name);
return billColsInterest.includes('interest_rate');
},
run: function() {
const billColsInterest = db.prepare('PRAGMA table_info(bills)').all().map(c => c.name);
if (!billColsInterest.includes('interest_rate')) {
db.exec('ALTER TABLE bills ADD COLUMN interest_rate REAL');
console.log('[migration] bills.interest_rate column added');
}
}
},
{
version: 'v0.15',
description: 'import_sessions and import_history tables',
check: function() {
const tables = db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name IN ('import_sessions', 'import_history')").all();
return tables.length >= 2;
},
run: function() {
db.exec(`
CREATE TABLE IF NOT EXISTS import_sessions (
id TEXT PRIMARY KEY,
user_id INTEGER NOT NULL,
created_at TEXT NOT NULL,
expires_at TEXT NOT NULL,
preview_json TEXT NOT NULL
)
`);
db.exec('CREATE INDEX IF NOT EXISTS idx_import_sessions_user ON import_sessions(user_id)');
db.exec('CREATE INDEX IF NOT EXISTS idx_import_sessions_expires ON import_sessions(expires_at)');
// ── import_history: per-user audit log (v0.38) ────────────────────────────
db.exec(`
CREATE TABLE IF NOT EXISTS import_history (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
imported_at TEXT NOT NULL,
source_filename TEXT,
file_type TEXT DEFAULT 'xlsx',
sheet_name TEXT,
rows_parsed INTEGER DEFAULT 0,
rows_created INTEGER DEFAULT 0,
rows_updated INTEGER DEFAULT 0,
rows_skipped INTEGER DEFAULT 0,
rows_ambiguous INTEGER DEFAULT 0,
rows_errored INTEGER DEFAULT 0,
options_json TEXT,
summary_json TEXT
)
`);
db.exec('CREATE INDEX IF NOT EXISTS idx_import_history_user ON import_history(user_id)');
}
},
{
version: 'v0.17',
description: 'users: external identity / OIDC columns',
check: function() {
const userColsOidc = db.prepare('PRAGMA table_info(users)').all().map(c => c.name);
const oidcUserCols = ['auth_provider', 'external_subject', 'email', 'last_login_at'];
return oidcUserCols.every(col => userColsOidc.includes(col));
},
run: function() {
const userColsOidc = db.prepare('PRAGMA table_info(users)').all().map(c => c.name);
const oidcUserCols = [
['auth_provider', "TEXT NOT NULL DEFAULT 'local'"],
['external_subject', 'TEXT'],
['email', 'TEXT'],
['last_login_at', 'TEXT'],
];
for (const [col, def] of oidcUserCols) {
if (!userColsOidc.includes(col)) {
// Security FIX (2026-05-08): Validate column name and definition to prevent SQL injection
if (!isValidColumnName(col) || !isValidSqlDefinition(def)) {
throw new Error(`Invalid migration: column '${col}' not in whitelist`);
}
db.exec(`ALTER TABLE users ADD COLUMN ${col} ${def}`);
}
}
// ── oidc_states: short-lived PKCE + nonce state for OIDC login (v0.17) ───
db.exec(`
CREATE TABLE IF NOT EXISTS oidc_states (
id TEXT PRIMARY KEY,
nonce TEXT NOT NULL,
code_verifier TEXT NOT NULL,
redirect_to TEXT,
created_at TEXT NOT NULL,
expires_at TEXT NOT NULL
)
`);
db.exec('CREATE INDEX IF NOT EXISTS idx_oidc_states_expires ON oidc_states(expires_at)');
}
},
{
version: 'v0.18.1',
description: 'monthly_income: per-user monthly income for Summary planning',
check: function() {
const tables = db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='monthly_income'").all();
return tables.length > 0;
},
run: function() {
db.exec(`
CREATE TABLE IF NOT EXISTS monthly_income (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
year INTEGER NOT NULL CHECK(year BETWEEN 2000 AND 2100),
month INTEGER NOT NULL CHECK(month BETWEEN 1 AND 12),
label TEXT NOT NULL DEFAULT 'Salary',
amount REAL NOT NULL DEFAULT 0,
created_at TEXT DEFAULT (datetime('now')),
updated_at TEXT DEFAULT (datetime('now')),
UNIQUE(user_id, year, month)
)
`);
db.exec('CREATE INDEX IF NOT EXISTS idx_monthly_income_user_month ON monthly_income(user_id, year, month)');
}
},
{
version: 'v0.18.2',
description: 'monthly_starting_amounts: per-user monthly starting amounts for 1st and 15th',
check: function() {
const tables = db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='monthly_starting_amounts'").all();
return tables.length > 0;
},
run: function() {
db.exec(`
CREATE TABLE IF NOT EXISTS monthly_starting_amounts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
year INTEGER NOT NULL CHECK(year BETWEEN 2000 AND 2100),
month INTEGER NOT NULL CHECK(month BETWEEN 1 AND 12),
first_amount REAL NOT NULL DEFAULT 0 CHECK(first_amount >= 0),
fifteenth_amount REAL NOT NULL DEFAULT 0 CHECK(fifteenth_amount >= 0),
other_amount REAL NOT NULL DEFAULT 0 CHECK(other_amount >= 0),
notes TEXT,
created_at TEXT DEFAULT (datetime('now')),
updated_at TEXT DEFAULT (datetime('now')),
UNIQUE(user_id, year, month)
)
`);
db.exec('CREATE INDEX IF NOT EXISTS idx_monthly_starting_amounts_user_month ON monthly_starting_amounts(user_id, year, month)');
}
},
{
version: 'v0.18.3',
description: 'monthly_starting_amounts: add other_amount column',
check: function() {
const startingCols = db.prepare('PRAGMA table_info(monthly_starting_amounts)').all().map(c => c.name);
return startingCols.includes('other_amount');
},
run: function() {
const startingCols = db.prepare('PRAGMA table_info(monthly_starting_amounts)').all().map(c => c.name);
if (!startingCols.includes('other_amount')) {
// Security FIX (2026-05-08): Validate column name to prevent SQL injection
if (!isValidColumnName('other_amount')) {
throw new Error('Invalid migration: column other_amount not in whitelist');
}
db.exec('ALTER TABLE monthly_starting_amounts ADD COLUMN other_amount REAL NOT NULL DEFAULT 0 CHECK(other_amount >= 0)');
console.log('[migration] monthly_starting_amounts.other_amount column added');
}
}
},
{
version: 'v0.38',
description: 'import_history: per-user audit log',
check: function() {
// Already handled in v0.15
return true;
},
run: function() {
// This was already handled in v0.15, but keeping for completeness
}
},
{
version: 'v0.40',
description: 'ownership: user-scoped bills/categories',
check: function() {
const billCols = db.prepare('PRAGMA table_info(bills)').all().map(c => c.name);
const categoryCols = db.prepare('PRAGMA table_info(categories)').all().map(c => c.name);
return billCols.includes('user_id') && categoryCols.includes('user_id');
},
run: function() {
const billCols = db.prepare('PRAGMA table_info(bills)').all().map(c => c.name);
if (!billCols.includes('user_id')) {
db.exec('ALTER TABLE bills ADD COLUMN user_id INTEGER REFERENCES users(id) ON DELETE CASCADE');
}
const categoryCols = db.prepare('PRAGMA table_info(categories)').all().map(c => c.name);
if (!categoryCols.includes('user_id')) {
db.exec('ALTER TABLE categories ADD COLUMN user_id INTEGER REFERENCES users(id) ON DELETE CASCADE');
}
const categorySql = db.prepare("SELECT sql FROM sqlite_master WHERE type='table' AND name='categories'").get()?.sql || '';
if (/name\s+TEXT\s+NOT\s+NULL\s+UNIQUE/i.test(categorySql)) {
db.exec('PRAGMA foreign_keys = OFF');
db.exec(`
CREATE TABLE IF NOT EXISTS categories_v040 (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER REFERENCES users(id) ON DELETE CASCADE,
name TEXT NOT NULL,
created_at TEXT DEFAULT (datetime('now')),
updated_at TEXT DEFAULT (datetime('now'))
)
`);
db.exec('INSERT INTO categories_v040 (id, user_id, name, created_at, updated_at) SELECT id, user_id, name, created_at, updated_at FROM categories');
db.exec('DROP TABLE categories');
db.exec('ALTER TABLE categories_v040 RENAME TO categories');
db.exec('PRAGMA foreign_keys = ON');
}
const firstAdmin = db.prepare("SELECT id FROM users WHERE role = 'admin' ORDER BY id LIMIT 1").get();
if (firstAdmin) {
db.prepare('UPDATE bills SET user_id = ? WHERE user_id IS NULL').run(firstAdmin.id);
// Drop any NULL-owner categories whose name already exists for this admin (case-insensitive)
// to prevent a UNIQUE(user_id, name) violation when we assign them below.
db.prepare(`
DELETE FROM categories
WHERE user_id IS NULL
AND LOWER(name) IN (
SELECT LOWER(name) FROM categories WHERE user_id = ?
)
`).run(firstAdmin.id);
db.prepare('UPDATE categories SET user_id = ? WHERE user_id IS NULL').run(firstAdmin.id);
}
db.exec('CREATE INDEX IF NOT EXISTS idx_bills_user_active ON bills(user_id, active)');
db.exec('CREATE INDEX IF NOT EXISTS idx_categories_user_name ON categories(user_id, name)');
db.exec('CREATE UNIQUE INDEX IF NOT EXISTS idx_categories_user_name_unique ON categories(user_id, name COLLATE NOCASE)');
}
},
{
version: 'v0.41',
description: 'bills and categories: is_seeded flag for demo data cleanup',
check: function() {
const billColsSeeded = db.prepare('PRAGMA table_info(bills)').all().map(c => c.name);
const categoryColsSeeded = db.prepare('PRAGMA table_info(categories)').all().map(c => c.name);
return billColsSeeded.includes('is_seeded') && categoryColsSeeded.includes('is_seeded');
},
run: function() {
// ── bills: is_seeded flag for demo data cleanup (v0.41) ───────────────────
const billColsSeeded = db.prepare('PRAGMA table_info(bills)').all().map(c => c.name);
if (!billColsSeeded.includes('is_seeded')) {
db.exec('ALTER TABLE bills ADD COLUMN is_seeded INTEGER NOT NULL DEFAULT 0');
console.log('[migration] bills.is_seeded column added');
}
// ── categories: is_seeded flag for demo data cleanup (v0.41) ──────────────
const categoryColsSeeded = db.prepare('PRAGMA table_info(categories)').all().map(c => c.name);
if (!categoryColsSeeded.includes('is_seeded')) {
db.exec('ALTER TABLE categories ADD COLUMN is_seeded INTEGER NOT NULL DEFAULT 0');
console.log('[migration] categories.is_seeded column added');
}
}
},
{
version: 'v0.42',
description: 'bill_history_ranges: per-bill date ranges for history visibility',
check: function() {
const tables = db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='bill_history_ranges'").all();
return tables.length > 0;
},
run: function() {
db.exec(`
CREATE TABLE IF NOT EXISTS bill_history_ranges (
id INTEGER PRIMARY KEY AUTOINCREMENT,
bill_id INTEGER NOT NULL REFERENCES bills(id) ON DELETE CASCADE,
start_year INTEGER NOT NULL,
start_month INTEGER NOT NULL,
end_year INTEGER,
end_month INTEGER,
label TEXT,
created_at TEXT DEFAULT (datetime('now')),
updated_at TEXT DEFAULT (datetime('now'))
)
`);
db.exec('CREATE INDEX IF NOT EXISTS idx_bill_history_ranges_bill ON bill_history_ranges(bill_id)');
}
},
{
version: 'v0.43',
description: 'sessions: add created_at column',
check: function() {
const sessionCols = db.prepare('PRAGMA table_info(sessions)').all().map(c => c.name);
return sessionCols.includes('created_at');
},
run: function() {
const sessionCols = db.prepare('PRAGMA table_info(sessions)').all().map(c => c.name);
if (!sessionCols.includes('created_at')) {
// Security FIX (2026-05-09): Validate column name to prevent SQL injection
if (!isValidColumnName('created_at')) {
throw new Error('Invalid migration: column created_at not in whitelist');
}
db.exec("ALTER TABLE sessions ADD COLUMN created_at TEXT DEFAULT (datetime('now'))");
console.log('[migration] sessions.created_at column added');
}
}
},
{
version: 'v0.44',
description: 'performance: add missing indexes for frequently queried columns',
check: function() {
return !!db.prepare("SELECT name FROM sqlite_master WHERE type='index' AND name='idx_bills_user_name'").get();
},
run: function() {
db.exec('CREATE INDEX IF NOT EXISTS idx_bills_user_name ON bills(user_id, name)');
db.exec('CREATE INDEX IF NOT EXISTS idx_payments_method ON payments(method)');
db.exec('CREATE INDEX IF NOT EXISTS idx_monthly_starting_amounts_user ON monthly_starting_amounts(user_id)');
db.exec('CREATE INDEX IF NOT EXISTS idx_import_history_imported_at ON import_history(imported_at)');
}
},
{
version: 'v0.45',
description: 'audit: add audit_log table for security event tracking',
check: function() {
return !!db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='audit_log'").get();
},
run: function() {
db.exec(`CREATE TABLE IF NOT EXISTS audit_log (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER,
action TEXT NOT NULL,
entity_type TEXT,
entity_id INTEGER,
details_json TEXT,
ip_address TEXT,
user_agent TEXT,
created_at TEXT DEFAULT (datetime('now'))
)`);
db.exec('CREATE INDEX IF NOT EXISTS idx_audit_log_user ON audit_log(user_id, created_at)');
db.exec('CREATE INDEX IF NOT EXISTS idx_audit_log_action ON audit_log(action, created_at)');
}
},
{
version: 'v0.46',
description: 'billing: add cycle_type and cycle_day columns to bills',
check: function() {
const cols = db.prepare('PRAGMA table_info(bills)').all().map(c => c.name);
return cols.includes('cycle_type') && cols.includes('cycle_day');
},
run: function() {
const cols = db.prepare('PRAGMA table_info(bills)').all().map(c => c.name);
if (!cols.includes('cycle_type')) {
db.exec(`ALTER TABLE bills ADD COLUMN cycle_type TEXT NOT NULL DEFAULT 'monthly'`);
}
if (!cols.includes('cycle_day')) {
db.exec(`ALTER TABLE bills ADD COLUMN cycle_day TEXT`);
}
}
},
{
version: 'v0.47',
description: 'settings: reset backup_schedule_retention_count default from 14 to 2',
check: function() {
const row = db.prepare("SELECT value FROM settings WHERE key = 'backup_schedule_retention_count'").get();
return !row || row.value !== '14';
},
run: function() {
db.prepare("UPDATE settings SET value = '2' WHERE key = 'backup_schedule_retention_count' AND value = '14'").run();
console.log('[migration] backup_schedule_retention_count updated from 14 to 2');
}
}
];
// Check for legacy notification columns
const userCols = db.prepare('PRAGMA table_info(users)').all().map(c => c.name);
const newUserCols = [
'active', 'is_default_admin', 'notification_email', 'notifications_enabled',
'notify_3d', 'notify_1d', 'notify_due', 'notify_overdue'
];
const hasNotificationColumns = newUserCols.every(col => userCols.includes(col));
// If notification columns exist, mark that migration as applied
if (hasNotificationColumns) {
try {
recordMigration('legacy-notification-columns', 'users: notification columns');
console.log('[migration] Recorded legacy notification columns migration');
} catch (e) {
// Ignore if already recorded
}
}
// Process all versioned migrations
for (const migration of migrations) {
if (migration.check()) {
try {
recordMigration(migration.version, migration.description);
console.log(`[migration] Recorded legacy migration ${migration.version}: ${migration.description}`);
} catch (e) {
// Ignore if already recorded
}
} else {
// Migration changes are NOT present - run the migration to apply them
try {
console.log(`[migration] Running legacy migration ${migration.version}: ${migration.description}`);
// Wrap legacy migration in transaction
db.exec('BEGIN');
console.log(`[migration] Transaction BEGIN for legacy ${migration.version}`);
migration.run();
recordMigration(migration.version, migration.description);
db.exec('COMMIT');
console.log(`[migration] Transaction COMMIT for legacy ${migration.version}`);
} catch (err) {
db.exec('ROLLBACK');
console.error(`[migration-error] Failed to apply legacy migration ${migration.version}: ${err.message}. Rolled back.`);
throw err;
}
}
}
console.log('[migration] Legacy database reconciliation complete');
}
function recordMigration(version, description) {
const stmt = db.prepare('INSERT INTO schema_migrations (version, description) VALUES (?, ?)');
stmt.run(version, description);
console.log(`[migration] Applied ${version}: ${description}`);
}
function validateMigrationDependencies(migration, appliedVersions) {
// Validate that all dependencies for a migration have been applied
const deps = migration.dependsOn || [];
const missing = deps.filter(dep => !appliedVersions.has(dep));
if (missing.length === 0) {
return { valid: true };
}
return { valid: false, missing };
}
function runMigrations() {
console.log('[migration] Starting database migrations');
const startTime = Date.now();
// Log start of migrations to audit log
try {
getLogAudit()({
action: 'migration.start',
entity_type: 'migration',
entity_id: null,
details: { message: 'Starting database migrations' }
});
} catch (auditErr) {
console.error(`[audit-error] Failed to log migration start to audit log: ${auditErr.message}`);
}
// Define all migrations with explicit version tracking and dependency chains
const migrations = [
{
version: 'v0.2',
dependsOn: [],
description: 'payments: soft-delete column',
run: function() {
const paymentCols = db.prepare('PRAGMA table_info(payments)').all().map(c => c.name);
if (!paymentCols.includes('deleted_at')) {
db.exec('ALTER TABLE payments ADD COLUMN deleted_at TEXT');
// Index for fast filtering of live payments
db.exec('CREATE INDEX IF NOT EXISTS idx_payments_deleted ON payments(deleted_at)');
console.log('[migration] payments.deleted_at column added');
}
}
},
{
version: 'v0.3',
dependsOn: ['v0.2'],
description: 'payments: compound index for tracker query',
run: function() {
// Supports: WHERE bill_id = ? AND paid_date BETWEEN ? AND ? AND deleted_at IS NULL
db.exec('CREATE INDEX IF NOT EXISTS idx_payments_bill_date_del ON payments(bill_id, paid_date, deleted_at)');
}
},
{
version: 'v0.4',
dependsOn: ['v0.3'],
description: 'monthly_bill_state: per-bill per-month overrides',
run: function() {
db.exec(`
CREATE TABLE IF NOT EXISTS monthly_bill_state (
id INTEGER PRIMARY KEY AUTOINCREMENT,
bill_id INTEGER NOT NULL REFERENCES bills(id) ON DELETE CASCADE,
year INTEGER NOT NULL CHECK(year BETWEEN 2000 AND 2100),
month INTEGER NOT NULL CHECK(month BETWEEN 1 AND 12),
actual_amount REAL,
notes TEXT,
is_skipped INTEGER NOT NULL DEFAULT 0,
created_at TEXT DEFAULT (datetime('now')),
updated_at TEXT DEFAULT (datetime('now')),
UNIQUE(bill_id, year, month)
)
`);
db.exec('CREATE INDEX IF NOT EXISTS idx_monthly_bill_state_lookup ON monthly_bill_state(bill_id, year, month)');
console.log('[migration] monthly_bill_state table ensured');
}
},
{
version: 'v0.13',
dependsOn: ['v0.4'],
description: 'users: profile columns',
run: function() {
const userColsNow = db.prepare('PRAGMA table_info(users)').all().map(c => c.name);
const profileCols = [
['display_name', 'TEXT'],
['last_password_change_at','TEXT'],
];
for (const [col, def] of profileCols) {
if (!userColsNow.includes(col)) {
// Security FIX (2026-05-08): Validate column name and definition to prevent SQL injection
if (!isValidColumnName(col) || !isValidSqlDefinition(def)) {
throw new Error(`Invalid migration: column '${col}' not in whitelist`);
}
db.exec(`ALTER TABLE users ADD COLUMN ${col} ${def}`);
}
}
}
},
{
version: 'v0.14',
dependsOn: ['v0.13'],
description: 'bills: history visibility mode',
run: function() {
const billColsHist = db.prepare('PRAGMA table_info(bills)').all().map(c => c.name);
if (!billColsHist.includes('history_visibility')) {
db.exec("ALTER TABLE bills ADD COLUMN history_visibility TEXT NOT NULL DEFAULT 'default'");
console.log('[migration] bills.history_visibility column added');
}
}
},
{
version: 'v0.14.4',
dependsOn: ['v0.14'],
description: 'bills: optional credit-card APR / interest rate',
run: function() {
const billColsInterest = db.prepare('PRAGMA table_info(bills)').all().map(c => c.name);
if (!billColsInterest.includes('interest_rate')) {
db.exec('ALTER TABLE bills ADD COLUMN interest_rate REAL');
console.log('[migration] bills.interest_rate column added');
}
}
},
{
version: 'v0.15',
dependsOn: ['v0.14.4'],
description: 'import_sessions and import_history tables',
run: function() {
db.exec(`
CREATE TABLE IF NOT EXISTS import_sessions (
id TEXT PRIMARY KEY,
user_id INTEGER NOT NULL,
created_at TEXT NOT NULL,
expires_at TEXT NOT NULL,
preview_json TEXT NOT NULL
)
`);
db.exec('CREATE INDEX IF NOT EXISTS idx_import_sessions_user ON import_sessions(user_id)');
db.exec('CREATE INDEX IF NOT EXISTS idx_import_sessions_expires ON import_sessions(expires_at)');
// ── import_history: per-user audit log (v0.38) ────────────────────────────
db.exec(`
CREATE TABLE IF NOT EXISTS import_history (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
imported_at TEXT NOT NULL,
source_filename TEXT,
file_type TEXT DEFAULT 'xlsx',
sheet_name TEXT,
rows_parsed INTEGER DEFAULT 0,
rows_created INTEGER DEFAULT 0,
rows_updated INTEGER DEFAULT 0,
rows_skipped INTEGER DEFAULT 0,
rows_ambiguous INTEGER DEFAULT 0,
rows_errored INTEGER DEFAULT 0,
options_json TEXT,
summary_json TEXT
)
`);
db.exec('CREATE INDEX IF NOT EXISTS idx_import_history_user ON import_history(user_id)');
}
},
{
version: 'v0.17',
dependsOn: ['v0.15'],
description: 'users: external identity / OIDC columns',
run: function() {
const userColsOidc = db.prepare('PRAGMA table_info(users)').all().map(c => c.name);
const oidcUserCols = [
['auth_provider', "TEXT NOT NULL DEFAULT 'local'"],
['external_subject', 'TEXT'],
['email', 'TEXT'],
['last_login_at', 'TEXT'],
];
for (const [col, def] of oidcUserCols) {
if (!userColsOidc.includes(col)) {
// Security FIX (2026-05-08): Validate column name and definition to prevent SQL injection
if (!isValidColumnName(col) || !isValidSqlDefinition(def)) {
throw new Error(`Invalid migration: column '${col}' not in whitelist`);
}
db.exec(`ALTER TABLE users ADD COLUMN ${col} ${def}`);
}
}
// ── oidc_states: short-lived PKCE + nonce state for OIDC login (v0.17) ───
db.exec(`
CREATE TABLE IF NOT EXISTS oidc_states (
id TEXT PRIMARY KEY,
nonce TEXT NOT NULL,
code_verifier TEXT NOT NULL,
redirect_to TEXT,
created_at TEXT NOT NULL,
expires_at TEXT NOT NULL
)
`);
db.exec('CREATE INDEX IF NOT EXISTS idx_oidc_states_expires ON oidc_states(expires_at)');
}
},
{
version: 'v0.18.1',
dependsOn: ['v0.17'],
description: 'monthly_income: per-user monthly income for Summary planning',
run: function() {
db.exec(`
CREATE TABLE IF NOT EXISTS monthly_income (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
year INTEGER NOT NULL CHECK(year BETWEEN 2000 AND 2100),
month INTEGER NOT NULL CHECK(month BETWEEN 1 AND 12),
label TEXT NOT NULL DEFAULT 'Salary',
amount REAL NOT NULL DEFAULT 0,
created_at TEXT DEFAULT (datetime('now')),
updated_at TEXT DEFAULT (datetime('now')),
UNIQUE(user_id, year, month)
)
`);
db.exec('CREATE INDEX IF NOT EXISTS idx_monthly_income_user_month ON monthly_income(user_id, year, month)');
}
},
{
version: 'v0.18.2',
dependsOn: ['v0.18.1'],
description: 'monthly_starting_amounts: per-user monthly starting amounts for 1st and 15th',
run: function() {
db.exec(`
CREATE TABLE IF NOT EXISTS monthly_starting_amounts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
year INTEGER NOT NULL CHECK(year BETWEEN 2000 AND 2100),
month INTEGER NOT NULL CHECK(month BETWEEN 1 AND 12),
first_amount REAL NOT NULL DEFAULT 0 CHECK(first_amount >= 0),
fifteenth_amount REAL NOT NULL DEFAULT 0 CHECK(fifteenth_amount >= 0),
other_amount REAL NOT NULL DEFAULT 0 CHECK(other_amount >= 0),
notes TEXT,
created_at TEXT DEFAULT (datetime('now')),
updated_at TEXT DEFAULT (datetime('now')),
UNIQUE(user_id, year, month)
)
`);
db.exec('CREATE INDEX IF NOT EXISTS idx_monthly_starting_amounts_user_month ON monthly_starting_amounts(user_id, year, month)');
}
},
{
version: 'v0.18.3',
dependsOn: ['v0.18.2'],
description: 'monthly_starting_amounts: add other_amount column',
run: function() {
const startingCols = db.prepare('PRAGMA table_info(monthly_starting_amounts)').all().map(c => c.name);
if (!startingCols.includes('other_amount')) {
// Security FIX (2026-05-08): Validate column name to prevent SQL injection
if (!isValidColumnName('other_amount')) {
throw new Error('Invalid migration: column other_amount not in whitelist');
}
db.exec('ALTER TABLE monthly_starting_amounts ADD COLUMN other_amount REAL NOT NULL DEFAULT 0 CHECK(other_amount >= 0)');
console.log('[migration] monthly_starting_amounts.other_amount column added');
}
}
},
{
version: 'v0.38',
dependsOn: ['v0.18.3'],
description: 'import_history: per-user audit log',
run: function() {
// This was already handled in v0.15, but keeping for completeness
}
},
{
version: 'v0.40',
dependsOn: ['v0.38'],
description: 'ownership: user-scoped bills/categories',
run: function() {
const billCols = db.prepare('PRAGMA table_info(bills)').all().map(c => c.name);
if (!billCols.includes('user_id')) {
db.exec('ALTER TABLE bills ADD COLUMN user_id INTEGER REFERENCES users(id) ON DELETE CASCADE');
}
const categoryCols = db.prepare('PRAGMA table_info(categories)').all().map(c => c.name);
if (!categoryCols.includes('user_id')) {
db.exec('ALTER TABLE categories ADD COLUMN user_id INTEGER REFERENCES users(id) ON DELETE CASCADE');
}
const categorySql = db.prepare("SELECT sql FROM sqlite_master WHERE type='table' AND name='categories'").get()?.sql || '';
if (/name\s+TEXT\s+NOT\s+NULL\s+UNIQUE/i.test(categorySql)) {
db.exec('PRAGMA foreign_keys = OFF');
db.exec(`
CREATE TABLE IF NOT EXISTS categories_v040 (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER REFERENCES users(id) ON DELETE CASCADE,
name TEXT NOT NULL,
created_at TEXT DEFAULT (datetime('now')),
updated_at TEXT DEFAULT (datetime('now'))
)
`);
db.exec('INSERT INTO categories_v040 (id, user_id, name, created_at, updated_at) SELECT id, user_id, name, created_at, updated_at FROM categories');
db.exec('DROP TABLE categories');
db.exec('ALTER TABLE categories_v040 RENAME TO categories');
db.exec('PRAGMA foreign_keys = ON');
}
const firstAdmin = db.prepare("SELECT id FROM users WHERE role = 'admin' ORDER BY id LIMIT 1").get();
if (firstAdmin) {
db.prepare('UPDATE bills SET user_id = ? WHERE user_id IS NULL').run(firstAdmin.id);
// Drop any NULL-owner categories whose name already exists for this admin (case-insensitive)
// to prevent a UNIQUE(user_id, name) violation when we assign them below.
db.prepare(`
DELETE FROM categories
WHERE user_id IS NULL
AND LOWER(name) IN (
SELECT LOWER(name) FROM categories WHERE user_id = ?
)
`).run(firstAdmin.id);
db.prepare('UPDATE categories SET user_id = ? WHERE user_id IS NULL').run(firstAdmin.id);
}
db.exec('CREATE INDEX IF NOT EXISTS idx_bills_user_active ON bills(user_id, active)');
db.exec('CREATE INDEX IF NOT EXISTS idx_categories_user_name ON categories(user_id, name)');
db.exec('CREATE UNIQUE INDEX IF NOT EXISTS idx_categories_user_name_unique ON categories(user_id, name COLLATE NOCASE)');
}
},
{
version: 'v0.41',
dependsOn: ['v0.40'],
description: 'bills and categories: is_seeded flag for demo data cleanup',
run: function() {
// ── bills: is_seeded flag for demo data cleanup (v0.41) ───────────────────
const billColsSeeded = db.prepare('PRAGMA table_info(bills)').all().map(c => c.name);
if (!billColsSeeded.includes('is_seeded')) {
db.exec('ALTER TABLE bills ADD COLUMN is_seeded INTEGER NOT NULL DEFAULT 0');
console.log('[migration] bills.is_seeded column added');
}
// ── categories: is_seeded flag for demo data cleanup (v0.41) ──────────────
const categoryColsSeeded = db.prepare('PRAGMA table_info(categories)').all().map(c => c.name);
if (!categoryColsSeeded.includes('is_seeded')) {
db.exec('ALTER TABLE categories ADD COLUMN is_seeded INTEGER NOT NULL DEFAULT 0');
console.log('[migration] categories.is_seeded column added');
}
}
},
{
version: 'v0.42',
dependsOn: ['v0.41'],
description: 'bill_history_ranges: per-bill date ranges for history visibility',
run: function() {
db.exec(`
CREATE TABLE IF NOT EXISTS bill_history_ranges (
id INTEGER PRIMARY KEY AUTOINCREMENT,
bill_id INTEGER NOT NULL REFERENCES bills(id) ON DELETE CASCADE,
start_year INTEGER NOT NULL,
start_month INTEGER NOT NULL,
end_year INTEGER,
end_month INTEGER,
label TEXT,
created_at TEXT DEFAULT (datetime('now')),
updated_at TEXT DEFAULT (datetime('now'))
)
`);
db.exec('CREATE INDEX IF NOT EXISTS idx_bill_history_ranges_bill ON bill_history_ranges(bill_id)');
}
},
{
version: 'v0.43',
dependsOn: ['v0.42'],
description: 'sessions: add created_at column',
run: function() {
const sessionCols = db.prepare('PRAGMA table_info(sessions)').all().map(c => c.name);
if (!sessionCols.includes('created_at')) {
// Security FIX (2026-05-09): Validate column name to prevent SQL injection
if (!isValidColumnName('created_at')) {
throw new Error('Invalid migration: column created_at not in whitelist');
}
db.exec("ALTER TABLE sessions ADD COLUMN created_at TEXT DEFAULT (datetime('now'))");
console.log('[migration] sessions.created_at column added');
}
}
},
{
version: 'v0.44',
dependsOn: ['v0.43'],
description: 'performance: add missing indexes for frequently queried columns',
run: function() {
db.exec('CREATE INDEX IF NOT EXISTS idx_bills_user_name ON bills(user_id, name)');
db.exec('CREATE INDEX IF NOT EXISTS idx_payments_method ON payments(method)');
db.exec('CREATE INDEX IF NOT EXISTS idx_monthly_starting_amounts_user ON monthly_starting_amounts(user_id)');
db.exec('CREATE INDEX IF NOT EXISTS idx_import_history_imported_at ON import_history(imported_at)');
console.log('[migration] Added indexes for frequently queried columns');
}
},
{
version: 'v0.45',
dependsOn: ['v0.44'],
description: 'audit: add audit_log table for security event tracking',
run: function() {
db.exec(`
CREATE TABLE IF NOT EXISTS audit_log (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER,
action TEXT NOT NULL,
entity_type TEXT,
entity_id INTEGER,
details_json TEXT,
ip_address TEXT,
user_agent TEXT,
created_at TEXT DEFAULT (datetime('now'))
);
CREATE INDEX IF NOT EXISTS idx_audit_log_user ON audit_log(user_id, created_at);
CREATE INDEX IF NOT EXISTS idx_audit_log_action ON audit_log(action, created_at);
`);
}
},
{
version: 'v0.46',
description: 'billing: add cycle_type and cycle_day columns to bills',
dependsOn: ['v0.45'],
run: function() {
const cols = db.prepare('PRAGMA table_info(bills)').all().map(c => c.name);
if (!cols.includes('cycle_type')) {
db.exec(`ALTER TABLE bills ADD COLUMN cycle_type TEXT NOT NULL DEFAULT 'monthly'`);
}
if (!cols.includes('cycle_day')) {
db.exec(`ALTER TABLE bills ADD COLUMN cycle_day TEXT`);
}
}
},
{
version: 'v0.47',
description: 'settings: reset backup_schedule_retention_count default from 14 to 2',
dependsOn: ['v0.46'],
run: function() {
db.prepare("UPDATE settings SET value = '2' WHERE key = 'backup_schedule_retention_count' AND value = '14'").run();
console.log('[migration] backup_schedule_retention_count updated from 14 to 2');
}
}
];
// ── users: notification columns ───────────────────────────────────────────
// This migration needs to run first since it's not versioned in the schema
console.log('[migration] Applying unversioned user notification columns');
const unversionedStartTime = Date.now();
try {
db.exec('BEGIN');
console.log('[migration] Transaction BEGIN for unversioned user notification columns');
const userCols = db.prepare('PRAGMA table_info(users)').all().map(c => c.name);
const newUserCols = [
['active', 'INTEGER NOT NULL DEFAULT 1'],
['is_default_admin', 'INTEGER NOT NULL DEFAULT 0'],
['notification_email', 'TEXT'],
['notifications_enabled', 'INTEGER NOT NULL DEFAULT 0'],
['notify_3d', 'INTEGER NOT NULL DEFAULT 1'],
['notify_1d', 'INTEGER NOT NULL DEFAULT 1'],
['notify_due', 'INTEGER NOT NULL DEFAULT 1'],
['notify_overdue', 'INTEGER NOT NULL DEFAULT 1'],
];
for (const [col, def] of newUserCols) {
if (!userCols.includes(col)) {
// Security FIX (2026-05-08): Validate column name and definition to prevent SQL injection
if (!isValidColumnName(col) || !isValidSqlDefinition(def)) {
throw new Error(`Invalid migration: column '${col}' not in whitelist`);
}
db.exec(`ALTER TABLE users ADD COLUMN ${col} ${def}`);
}
}
const defaultAdminName = process.env.INIT_ADMIN_USER || 'admin';
db.prepare(`
UPDATE users
SET is_default_admin = 1
WHERE role = 'admin'
AND username = ?
AND NOT EXISTS (SELECT 1 FROM users WHERE is_default_admin = 1)
`).run(defaultAdminName);
db.exec(`
UPDATE users
SET is_default_admin = 1
WHERE id = (
SELECT id FROM users
WHERE role = 'admin'
ORDER BY id
LIMIT 1
)
AND NOT EXISTS (SELECT 1 FROM users WHERE is_default_admin = 1)
`);
db.exec('COMMIT');
console.log('[migration] Transaction COMMIT for unversioned user notification columns');
// Log successful completion with timing
const elapsed = Date.now() - unversionedStartTime;
console.log(`[migration] Unversioned user notification columns completed in ${elapsed}ms`);
} catch (err) {
db.exec('ROLLBACK');
const elapsed = Date.now() - unversionedStartTime;
console.error(`[migration-error] Failed to apply unversioned user notification columns after ${elapsed}ms: ${err.message}. Rolled back.`);
// Log migration failure to audit log (only safe after initSchema completes)
try {
getLogAudit()({
action: 'migration.failure',
entity_type: 'migration',
entity_id: null,
details: {
version: 'unversioned-user-notification-columns',
description: 'users: notification columns',
error: err.message,
elapsed_ms: elapsed
}
});
} catch (auditErr) {
console.error(`[audit-error] Failed to log migration failure to audit log: ${auditErr.message}`);
}
throw err;
}
// Build set of already-applied versions for dependency checking
const appliedVersions = new Set(
db.prepare('SELECT version FROM schema_migrations').all().map(r => r.version)
);
// Process all versioned migrations
for (const migration of migrations) {
if (!hasMigrationBeenApplied(migration.version)) {
// Validate dependencies before applying
const depCheck = validateMigrationDependencies(migration, appliedVersions);
console.log(`[migration] Applying ${migration.version}: ${migration.description}`);
if (!depCheck.valid) {
console.error(`[migration-error] ${migration.version} depends on [${depCheck.missing.join(', ')}] which have not been applied. Skipping.`);
continue;
}
console.log(`[migration] Applying ${migration.version}: ${migration.description}`);
if (migration.dependsOn && migration.dependsOn.length > 0) {
console.log(`[migration] ${migration.version} depends on [${migration.dependsOn.join(', ')}] — satisfied`);
}
// Timing for migration execution
const migrationStartTime = Date.now();
try {
// Special handling for v0.40 migration which uses PRAGMA statements
if (migration.version === 'v0.40') {
// PRAGMA foreign_keys cannot run inside a transaction, so we
// disable FK checks before BEGIN and re-enable in a finally block
// to guarantee FK is always restored even on failure.
const billCols = db.prepare('PRAGMA table_info(bills)').all().map(c => c.name);
const categoryCols = db.prepare('PRAGMA table_info(categories)').all().map(c => c.name);
const needsForeignKeyOff = !billCols.includes('user_id') || !categoryCols.includes('user_id');
if (needsForeignKeyOff) {
db.exec('PRAGMA foreign_keys = OFF');
}
try {
db.exec('BEGIN');
console.log(`[migration] Transaction BEGIN for ${migration.version}`);
migration.run();
recordMigration(migration.version, migration.description);
db.exec('COMMIT');
console.log(`[migration] Transaction COMMIT for ${migration.version}`);
// Log successful completion with timing
const elapsed = Date.now() - migrationStartTime;
console.log(`[migration] ${migration.version} completed in ${elapsed}ms`);
appliedVersions.add(migration.version);
} catch (innerErr) {
db.exec('ROLLBACK');
const elapsed = Date.now() - migrationStartTime;
console.error(`[migration-error] ${migration.version} failed after ${elapsed}ms: ${innerErr.message}. Rolled back.`);
// Log migration failure to audit log (only safe after initSchema completes)
try {
getLogAudit()({
action: 'migration.failure',
entity_type: 'migration',
entity_id: null,
details: {
version: migration.version,
description: migration.description,
error: innerErr.message,
elapsed_ms: elapsed
}
});
} catch (auditErr) {
console.error(`[audit-error] Failed to log migration failure to audit log: ${auditErr.message}`);
}
throw innerErr;
} finally {
// Always restore FK checks — even on failure path
if (needsForeignKeyOff) {
db.exec('PRAGMA foreign_keys = ON');
}
}
} else {
// Standard transaction wrapping for other migrations
db.exec('BEGIN');
console.log(`[migration] Transaction BEGIN for ${migration.version}`);
migration.run();
recordMigration(migration.version, migration.description);
db.exec('COMMIT');
console.log(`[migration] Transaction COMMIT for ${migration.version}`);
// Log successful completion with timing
const elapsed = Date.now() - migrationStartTime;
console.log(`[migration] ${migration.version} completed in ${elapsed}ms`);
appliedVersions.add(migration.version);
}
} catch (err) {
db.exec('ROLLBACK');
const elapsed = Date.now() - migrationStartTime;
console.error(`[migration-error] Failed to apply ${migration.version} after ${elapsed}ms: ${err.message}. Rolled back.`);
// Log migration failure to audit log (only safe after initSchema completes)
try {
getLogAudit()({
action: 'migration.failure',
entity_type: 'migration',
entity_id: null,
details: {
version: migration.version,
description: migration.description,
error: err.message,
elapsed_ms: elapsed
}
});
} catch (auditErr) {
console.error(`[audit-error] Failed to log migration failure to audit log: ${auditErr.message}`);
}
throw err;
}
} else {
console.log(`[migration] Skipping already applied ${migration.version}: ${migration.description}`);
}
}
// Log total migration time
// Log completion of all migrations to audit log
try {
getLogAudit()({
action: 'migration.complete',
entity_type: 'migration',
entity_id: null,
details: {
total_time_ms: Date.now() - startTime,
message: 'All migrations completed successfully'
}
});
} catch (auditErr) {
console.error(`[audit-error] Failed to log migration completion to audit log: ${auditErr.message}`);
}
const totalTime = Date.now() - startTime;
console.log(`[migration] All migrations completed in ${totalTime}ms`);
// All migrations are now versioned
}
function seedDefaults() {
const defaults = [
['currency', 'USD'],
['date_format', 'MM/DD/YYYY'],
['grace_period_days', '5'],
['notify_days_before', '3'],
['backup_enabled', 'false'],
['backup_frequency_days', '1'],
['backup_keep_count', '14'],
['backup_path', process.env.BACKUP_PATH || path.join(__dirname, '..', 'backups')],
['backup_schedule_enabled', 'false'],
['backup_schedule_frequency', 'daily'],
['backup_schedule_time', '02:00'],
['backup_schedule_retention_count', '2'],
['backup_schedule_last_run_at', ''],
['backup_schedule_last_error', ''],
['auth_mode', 'multi'],
['default_user_id', ''],
['notify_smtp_enabled', 'false'],
['notify_sender_name', 'Bill Tracker'],
['notify_sender_address', ''],
['notify_smtp_host', ''],
['notify_smtp_port', '587'],
['notify_smtp_encryption', 'starttls'],
['notify_smtp_self_signed', 'false'],
['notify_smtp_username', ''],
['notify_smtp_password', ''],
['notify_allow_user_config', 'false'],
['notify_global_recipient', ''],
// Cleanup worker settings (v0.15)
['cleanup_import_sessions_enabled', 'true'],
['cleanup_temp_exports_enabled', 'true'],
['cleanup_temp_export_max_age_hours', '2'],
['cleanup_backup_partials_enabled', 'true'],
['cleanup_import_history_enabled', 'false'],
['cleanup_import_history_max_age_days', '365'],
['cleanup_last_run_at', ''],
['cleanup_last_result', ''],
// Auth method settings (v0.18)
['local_login_enabled', 'true'],
['oidc_login_enabled', 'false'],
['oidc_provider_name', 'authentik'],
['oidc_issuer_url', ''],
['oidc_client_id', ''],
['oidc_client_secret', ''],
['oidc_token_auth_method', 'client_secret_basic'],
['oidc_redirect_uri', ''],
['oidc_scopes', 'openid email profile groups'],
['oidc_auto_provision', 'true'],
['oidc_admin_group', ''],
['oidc_default_role', 'user'],
];
const insert = db.prepare(
'INSERT OR IGNORE INTO settings (key, value) VALUES (?, ?)'
);
for (const [key, value] of defaults) {
insert.run(key, value);
}
// Category defaults are user-scoped. They are applied by
// ensureUserDefaultCategories(userId) when user-owned category/bill data is read.
// ── Create initial admin user if none exists ─────────────────────────────
const userCount = db.prepare('SELECT COUNT(*) as cnt FROM users').get().cnt;
if (userCount === 0) {
const initUser = process.env.INIT_ADMIN_USER || 'admin';
const initPass = process.env.INIT_ADMIN_PASS || 'admin123';
// Use bcryptjs sync for database init (safe, runs once at startup)
const bcrypt = require('bcryptjs');
const password_hash = bcrypt.hashSync(initPass, 12);
db.prepare(`
INSERT INTO users (username, password_hash, role, is_default_admin, active, email, created_at, updated_at)
VALUES (?, ?, 'admin', 1, 1, ?, datetime('now'), datetime('now'))
`).run(initUser, password_hash, initUser + '@local');
console.log(`[seed] Created initial admin user: ${initUser}`);
}
}
function ensureUserDefaultCategories(userId) {
const db = getDb();
const insert = db.prepare('INSERT INTO categories (user_id, name) VALUES (?, ?)');
for (const name of DEFAULT_CATEGORIES) {
const existing = db.prepare('SELECT id FROM categories WHERE user_id = ? AND name = ? COLLATE NOCASE')
.get(userId, name);
if (!existing) insert.run(userId, name);
}
}
function getSetting(key) {
const db = getDb();
const row = db.prepare('SELECT value FROM settings WHERE key = ?').get(key);
return row ? row.value : null;
}
function setSetting(key, value) {
const db = getDb();
db.prepare(
"INSERT OR REPLACE INTO settings (key, value, updated_at) VALUES (?, ?, datetime('now'))"
).run(key, String(value));
}
function closeDb() {
if (!db) return;
db.close();
db = null;
}
function getDbPath() {
return DB_PATH;
}
// Rollback SQL definitions
const ROLLBACK_SQL_MAP = {
'v0.44': {
description: 'performance: add missing indexes for frequently queried columns',
sql: [
'DROP INDEX IF EXISTS idx_bills_user_name',
'DROP INDEX IF EXISTS idx_payments_method',
'DROP INDEX IF EXISTS idx_monthly_starting_amounts_user',
'DROP INDEX IF EXISTS idx_import_history_imported_at'
]
},
'v0.45': {
description: 'audit: add audit_log table for security event tracking',
sql: [
'DROP INDEX IF EXISTS idx_audit_log_user',
'DROP INDEX IF EXISTS idx_audit_log_action',
'DROP TABLE IF EXISTS audit_log'
]
},
'v0.46': {
description: 'billing: add cycle_type and cycle_day columns to bills',
sql: [
'ALTER TABLE bills DROP COLUMN cycle_day',
'ALTER TABLE bills DROP COLUMN cycle_type'
]
},
'v0.47': {
description: 'settings: reset backup_schedule_retention_count default from 14 to 2',
sql: [
"UPDATE settings SET value = '14' WHERE key = 'backup_schedule_retention_count' AND value = '2'"
]
}
};
function rollbackMigration(version) {
if (!db) throw new Error('Database not initialized');
// Check the migration was actually applied
const applied = db.prepare('SELECT 1 FROM schema_migrations WHERE version = ?').get(version);
if (!applied) {
const err = new Error(`Migration ${version} has not been applied — cannot rollback`);
err.code = 'NOT_APPLIED';
throw err;
}
const rollback = ROLLBACK_SQL_MAP[version];
if (!rollback) {
const err = new Error(`Migration ${version} does not support rollback`);
err.code = 'ROLLBACK_NOT_SUPPORTED';
throw err;
}
console.log(`[rollback] Rolling back ${version}: ${rollback.description}`);
const startTime = Date.now();
try {
db.exec('BEGIN');
console.log(`[rollback] Transaction BEGIN for ${version}`);
for (const stmt of rollback.sql) {
console.log(`[rollback] Executing: ${stmt}`);
db.exec(stmt);
}
// Remove migration record
db.prepare('DELETE FROM schema_migrations WHERE version = ?').run(version);
console.log(`[rollback] Removed ${version} from schema_migrations`);
db.exec('COMMIT');
console.log(`[rollback] Transaction COMMIT for ${version}`);
const elapsed = Date.now() - startTime;
console.log(`[rollback] ${version} rolled back in ${elapsed}ms`);
// Audit log
try {
getLogAudit()({
action: 'migration.rollback',
entity_type: 'migration',
entity_id: null,
details: { version, description: rollback.description, elapsed_ms: elapsed }
});
} catch (auditErr) {
console.error(`[audit-error] Failed to log rollback to audit log: ${auditErr.message}`);
}
return { success: true, version, description: rollback.description, elapsed_ms: elapsed };
} catch (err) {
db.exec('ROLLBACK');
const elapsed = Date.now() - startTime;
console.error(`[rollback-error] ${version} failed after ${elapsed}ms: ${err.message}`);
// Audit log
try {
getLogAudit()({
action: 'migration.rollback.failure',
entity_type: 'migration',
entity_id: null,
details: { version, description: rollback.description, error: err.message, elapsed_ms: elapsed }
});
} catch (auditErr) {
console.error(`[audit-error] Failed to log rollback failure to audit log: ${auditErr.message}`);
}
throw err;
}
}
/**
* Cleanup expired sessions from the database
* @returns {Object} Result object with changes count
*/
function cleanupExpiredSessions() {
const result = db.prepare("DELETE FROM sessions WHERE expires_at < datetime('now')").run();
console.log(`[cleanup] Purged ${result.changes} expired sessions`);
return result;
}
module.exports = { getDb, getSetting, setSetting, closeDb, getDbPath, ensureUserDefaultCategories, cleanupExpiredSessions, rollbackMigration };