396 lines
15 KiB
JavaScript
396 lines
15 KiB
JavaScript
|
|
const Database = require('better-sqlite3');
|
||
|
|
const path = require('path');
|
||
|
|
const fs = require('fs');
|
||
|
|
|
||
|
|
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', 'Subscriptions', 'Insurance', 'Loans', 'Other'];
|
||
|
|
|
||
|
|
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:', 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);
|
||
|
|
runMigrations();
|
||
|
|
}
|
||
|
|
|
||
|
|
function runMigrations() {
|
||
|
|
// ── users: notification columns ───────────────────────────────────────────
|
||
|
|
const userCols = db.prepare('PRAGMA table_info(users)').all().map(c => c.name);
|
||
|
|
const newUserCols = [
|
||
|
|
['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)) db.exec(`ALTER TABLE users ADD COLUMN ${col} ${def}`);
|
||
|
|
}
|
||
|
|
|
||
|
|
// ── payments: soft-delete column (v0.2) ──────────────────────────────────
|
||
|
|
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');
|
||
|
|
}
|
||
|
|
|
||
|
|
// ── payments: compound index for tracker query (v0.3) ─────────────────────
|
||
|
|
// 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)');
|
||
|
|
|
||
|
|
// ── monthly_bill_state: per-bill per-month overrides (v0.4) ───────────────
|
||
|
|
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');
|
||
|
|
|
||
|
|
// ── import_sessions: temporary preview state (v0.38) ─────────────────────
|
||
|
|
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)');
|
||
|
|
|
||
|
|
// ── users: profile columns (v0.13) ───────────────────────────────────────
|
||
|
|
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)) db.exec(`ALTER TABLE users ADD COLUMN ${col} ${def}`);
|
||
|
|
}
|
||
|
|
|
||
|
|
// ── ownership: user-scoped bills/categories (v0.40) ──────────────────────
|
||
|
|
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 firstUser = db.prepare("SELECT id FROM users WHERE role = 'user' ORDER BY id LIMIT 1").get();
|
||
|
|
if (firstUser) {
|
||
|
|
db.prepare('UPDATE bills SET user_id = ? WHERE user_id IS NULL').run(firstUser.id);
|
||
|
|
// Drop any NULL-owner categories whose name already exists for this user (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(firstUser.id);
|
||
|
|
db.prepare('UPDATE categories SET user_id = ? WHERE user_id IS NULL').run(firstUser.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)');
|
||
|
|
|
||
|
|
// ── bills: history visibility mode (v0.14) ───────────────────────────────
|
||
|
|
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');
|
||
|
|
}
|
||
|
|
|
||
|
|
// ── bills: optional credit-card APR / interest rate (v0.14.4) ───────────
|
||
|
|
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');
|
||
|
|
}
|
||
|
|
|
||
|
|
// ── bill_history_ranges: per-bill date ranges for history visibility (v0.14)
|
||
|
|
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)');
|
||
|
|
|
||
|
|
// ── users: external identity / OIDC columns (v0.17) ──────────────────────
|
||
|
|
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)) {
|
||
|
|
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)');
|
||
|
|
}
|
||
|
|
|
||
|
|
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', '14'],
|
||
|
|
['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);
|
||
|
|
}
|
||
|
|
|
||
|
|
const insertCat = db.prepare(
|
||
|
|
'INSERT INTO categories (name) VALUES (?)'
|
||
|
|
);
|
||
|
|
|
||
|
|
for (const name of DEFAULT_CATEGORIES) {
|
||
|
|
const existing = db.prepare('SELECT id FROM categories WHERE user_id IS NULL AND name = ? COLLATE NOCASE').get(name);
|
||
|
|
if (!existing) insertCat.run(name);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
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;
|
||
|
|
}
|
||
|
|
|
||
|
|
module.exports = { getDb, getSetting, setSetting, closeDb, getDbPath, ensureUserDefaultCategories };
|