/** * Database initialization and migrations module. * * Punkt 4: Supports both SQLite and PostgreSQL. * Migrations are tracked in a _migrations table to avoid re-running. * * Note: SQLite mode is synchronous, PostgreSQL mode is async. * The initDatabase function handles both cases. */ const db = require('./db'); const isPostgres = db._type === 'postgres'; // Helper: convert SQLite SQL to PostgreSQL-compatible SQL function toPg(sql) { return sql .replace(/INTEGER PRIMARY KEY AUTOINCREMENT/g, 'SERIAL PRIMARY KEY') .replace(/TIMESTAMP DEFAULT CURRENT_TIMESTAMP/g, 'TIMESTAMP DEFAULT NOW()') .replace(/`/g, '"'); } function execSql(sql) { if (isPostgres) { return db.exec(toPg(sql)); } return db.exec(sql); } async function initDatabase() { // Create migrations tracking table const migrationsTableSql = isPostgres ? `CREATE TABLE IF NOT EXISTS _migrations (id SERIAL PRIMARY KEY, name TEXT UNIQUE NOT NULL, applied_at TIMESTAMP DEFAULT NOW())` : `CREATE TABLE IF NOT EXISTS _migrations (id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT UNIQUE NOT NULL, applied_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP)`; await execSql(migrationsTableSql); const appliedRows = await db.prepare('SELECT name FROM _migrations').all(); const applied = new Set(appliedRows.map(r => r.name)); async function migrate(name, sql) { if (applied.has(name)) return; console.log(`[Migration] ${name}...`); await execSql(sql); await db.prepare('INSERT INTO _migrations (name) VALUES (?)').run(name); console.log(`[Migration] ${name} done.`); } // Base schema const baseSchema = isPostgres ? ` CREATE TABLE IF NOT EXISTS users ( id SERIAL PRIMARY KEY, email TEXT UNIQUE NOT NULL, password TEXT NOT NULL, name TEXT NOT NULL DEFAULT '', role TEXT CHECK(role IN ('admin', 'user')) DEFAULT 'user', status TEXT CHECK(status IN ('aktiv', 'inaktiv')) DEFAULT 'inaktiv', source TEXT CHECK(source IN ('local', 'ad')) DEFAULT 'local', username TEXT, failed_login_attempts INTEGER DEFAULT 0, locked_until TIMESTAMP DEFAULT NULL ); CREATE TABLE IF NOT EXISTS templates ( id SERIAL PRIMARY KEY, name TEXT NOT NULL, description TEXT, is_assignable INTEGER DEFAULT 0, allows_file_upload INTEGER DEFAULT 0, ad_create INTEGER DEFAULT 0 ); CREATE TABLE IF NOT EXISTS template_steps ( id SERIAL PRIMARY KEY, template_id INTEGER NOT NULL, page_num INTEGER NOT NULL, label TEXT NOT NULL, type TEXT CHECK(type IN ('checkbox','text_input','file_upload','email','dropdown','ad_password','ad_displayname')) NOT NULL, step_order INTEGER NOT NULL, email_domain TEXT, email_source_fields TEXT, dropdown_options TEXT, ad_field TEXT, ad_prefix TEXT, hidden INTEGER DEFAULT 0, FOREIGN KEY (template_id) REFERENCES templates(id) ON DELETE CASCADE ); CREATE TABLE IF NOT EXISTS tasks ( id SERIAL PRIMARY KEY, template_id INTEGER NOT NULL, user_id INTEGER NOT NULL, title TEXT NOT NULL, status TEXT CHECK(status IN ('offen','erledigt')) DEFAULT 'offen', file_path TEXT, created_at TIMESTAMP DEFAULT NOW(), FOREIGN KEY (template_id) REFERENCES templates(id) ON DELETE CASCADE, FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE ); CREATE TABLE IF NOT EXISTS task_values ( id SERIAL PRIMARY KEY, task_id INTEGER NOT NULL, step_id INTEGER, value TEXT, is_checked INTEGER DEFAULT 0, file_path TEXT, custom_label TEXT, custom_type TEXT DEFAULT 'text_input', custom_dropdown_options TEXT, custom_ad_field TEXT, custom_hidden INTEGER DEFAULT 0, custom_email_source_fields TEXT, snap_label TEXT, snap_type TEXT, snap_page_num INTEGER, snap_ad_field TEXT, snap_ad_prefix TEXT, snap_dropdown_options TEXT, snap_email_source_fields TEXT, snap_hidden INTEGER DEFAULT 0, FOREIGN KEY (task_id) REFERENCES tasks(id) ON DELETE CASCADE, FOREIGN KEY (step_id) REFERENCES template_steps(id) ON DELETE CASCADE ); CREATE TABLE IF NOT EXISTS sessions ( id SERIAL PRIMARY KEY, user_id INTEGER NOT NULL, token TEXT UNIQUE NOT NULL, created_at TIMESTAMP DEFAULT NOW(), expires_at TIMESTAMP, FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE ); CREATE TABLE IF NOT EXISTS audit_log ( id SERIAL PRIMARY KEY, user_id INTEGER, action TEXT NOT NULL, entity_type TEXT, entity_id INTEGER, details TEXT, ip_address TEXT, user_agent TEXT, created_at TIMESTAMP DEFAULT NOW(), FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE SET NULL ); ` : ` CREATE TABLE IF NOT EXISTS users ( id INTEGER PRIMARY KEY AUTOINCREMENT, email TEXT UNIQUE NOT NULL, password TEXT NOT NULL, name TEXT NOT NULL DEFAULT '', role TEXT CHECK(role IN ('admin','user')) DEFAULT 'user', status TEXT CHECK(status IN ('aktiv','inaktiv')) DEFAULT 'inaktiv', source TEXT CHECK(source IN ('local','ad')) DEFAULT 'local', username TEXT, failed_login_attempts INTEGER DEFAULT 0, locked_until TIMESTAMP DEFAULT NULL ); CREATE TABLE IF NOT EXISTS templates ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, description TEXT, is_assignable INTEGER DEFAULT 0, allows_file_upload INTEGER DEFAULT 0, ad_create INTEGER DEFAULT 0 ); CREATE TABLE IF NOT EXISTS template_steps ( id INTEGER PRIMARY KEY AUTOINCREMENT, template_id INTEGER NOT NULL, page_num INTEGER NOT NULL, label TEXT NOT NULL, type TEXT CHECK(type IN ('checkbox','text_input','file_upload','email','dropdown','ad_password','ad_displayname')) NOT NULL, step_order INTEGER NOT NULL, email_domain TEXT, email_source_fields TEXT, dropdown_options TEXT, ad_field TEXT, ad_prefix TEXT, hidden INTEGER DEFAULT 0, FOREIGN KEY (template_id) REFERENCES templates(id) ON DELETE CASCADE ); CREATE TABLE IF NOT EXISTS tasks ( id INTEGER PRIMARY KEY AUTOINCREMENT, template_id INTEGER NOT NULL, user_id INTEGER NOT NULL, title TEXT NOT NULL, status TEXT CHECK(status IN ('offen','erledigt')) DEFAULT 'offen', file_path TEXT, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY (template_id) REFERENCES templates(id) ON DELETE CASCADE, FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE ); CREATE TABLE IF NOT EXISTS task_values ( id INTEGER PRIMARY KEY AUTOINCREMENT, task_id INTEGER NOT NULL, step_id INTEGER, value TEXT, is_checked INTEGER DEFAULT 0, file_path TEXT, custom_label TEXT, custom_type TEXT DEFAULT 'text_input', custom_dropdown_options TEXT, custom_ad_field TEXT, custom_hidden INTEGER DEFAULT 0, custom_email_source_fields TEXT, snap_label TEXT, snap_type TEXT, snap_page_num INTEGER, snap_ad_field TEXT, snap_ad_prefix TEXT, snap_dropdown_options TEXT, snap_email_source_fields TEXT, snap_hidden INTEGER DEFAULT 0, FOREIGN KEY (task_id) REFERENCES tasks(id) ON DELETE CASCADE, FOREIGN KEY (step_id) REFERENCES template_steps(id) ON DELETE CASCADE ); CREATE TABLE IF NOT EXISTS sessions ( id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER NOT NULL, token TEXT UNIQUE NOT NULL, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, expires_at TIMESTAMP, FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE ); 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 TEXT, ip_address TEXT, user_agent TEXT, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE SET NULL ); `; await execSql(baseSchema); // Indexes const indexes = [ 'idx_sessions_token', 'idx_sessions_user_id', 'idx_sessions_expires', 'idx_tasks_user_id', 'idx_tasks_template_id', 'idx_tasks_status', 'idx_task_values_task_id', 'idx_task_values_step_id', 'idx_audit_log_created', 'idx_audit_log_user', 'idx_users_email', 'idx_users_source', ]; for (const idx of indexes) { const table = idx.replace('idx_', '').replace('_id', '').replace('_at', '_created').replace('_token', '_token'); // Build CREATE INDEX statement let col; if (idx === 'idx_sessions_token') col = 'sessions(token)'; else if (idx === 'idx_sessions_user_id') col = 'sessions(user_id)'; else if (idx === 'idx_sessions_expires') col = 'sessions(expires_at)'; else if (idx === 'idx_tasks_user_id') col = 'tasks(user_id)'; else if (idx === 'idx_tasks_template_id') col = 'tasks(template_id)'; else if (idx === 'idx_tasks_status') col = 'tasks(status)'; else if (idx === 'idx_task_values_task_id') col = 'task_values(task_id)'; else if (idx === 'idx_task_values_step_id') col = 'task_values(step_id)'; else if (idx === 'idx_audit_log_created') col = 'audit_log(created_at)'; else if (idx === 'idx_audit_log_user') col = 'audit_log(user_id)'; else if (idx === 'idx_users_email') col = 'users(email)'; else if (idx === 'idx_users_source') col = 'users(source)'; await migrate(idx, `CREATE INDEX IF NOT EXISTS ${idx} ON ${col}`); } // Seed admin user const bcrypt = require('bcryptjs'); const ADMIN_EMAIL = process.env.ADMIN_EMAIL || 'admin@workflow.local'; const ADMIN_INIT_PASSWORD = process.env.ADMIN_INIT_PASSWORD || ''; const existingAdmin = await db.prepare('SELECT id FROM users WHERE email = ?').get(ADMIN_EMAIL); if (!existingAdmin) { if (!ADMIN_INIT_PASSWORD) { console.warn('WARNUNG: Kein ADMIN_INIT_PASSWORD gesetzt - kein Admin-Account erstellt.'); } else { const hash = bcrypt.hashSync(ADMIN_INIT_PASSWORD, 12); await db.prepare('INSERT INTO users (email, password, name, role, status, source) VALUES (?, ?, ?, \'admin\', \'aktiv\', \'local\')').run(ADMIN_EMAIL, hash, 'Superadmin'); console.log('Superadmin erstellt: ' + ADMIN_EMAIL); } } // Periodic session cleanup setInterval(async () => { try { const cleanupSql = isPostgres ? "DELETE FROM sessions WHERE expires_at < NOW()" : "DELETE FROM sessions WHERE expires_at < datetime('now')"; await db.prepare(cleanupSql).run(); } catch (err) { console.error('Session cleanup error:', err.message); } }, 60 * 60 * 1000); // H4: uploads table — track file ownership for authorization on download await migrate('add_uploads_table', isPostgres ? `CREATE TABLE IF NOT EXISTS uploads ( id SERIAL PRIMARY KEY, filename TEXT UNIQUE NOT NULL, user_id INTEGER NOT NULL, original_name TEXT, size INTEGER, created_at TIMESTAMP DEFAULT NOW(), FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE )` : `CREATE TABLE IF NOT EXISTS uploads ( id INTEGER PRIMARY KEY AUTOINCREMENT, filename TEXT UNIQUE NOT NULL, user_id INTEGER NOT NULL, original_name TEXT, size INTEGER, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE )` ); await migrate('idx_uploads_filename', isPostgres ? `CREATE INDEX IF NOT EXISTS idx_uploads_filename ON uploads(filename)` : `CREATE INDEX IF NOT EXISTS idx_uploads_filename ON uploads(filename)` ); await migrate('idx_uploads_user_id', isPostgres ? `CREATE INDEX IF NOT EXISTS idx_uploads_user_id ON uploads(user_id)` : `CREATE INDEX IF NOT EXISTS idx_uploads_user_id ON uploads(user_id)` ); console.log('Datenbanktabellen initialisiert.'); } module.exports = { initDatabase };