// Idempotent migration from the single-user schema to the multi-user schema. // // Used both at server boot (server.js calls runMigration after opening the DB) // and by the standalone scripts/migrate-to-multiuser.js. Safe to run repeatedly: // every step guards itself with "already done?" checks. // // What it does, in order: // 1. Create the `users` + `sessions` tables. // 2. Ensure an `admin` user exists (password "admin", scrypt hash). Record its id. // 3. Add a `user_id` column to every per-user table (ALTER ADD COLUMN, nullable // for upgraded installs — fresh installs create it NOT NULL directly) and // backfill every existing row to the admin id. // 4. Recreate the tables whose PRIMARY KEY / UNIQUE must include user_id so the // constraint becomes per-user: app_state, settings, prompts, design, // jobangebote. Old rows are copied to the admin user. // 5. Move on-disk attachment files into a per-user subdirectory for the admin. // 6. One-time migration of any still-present .env values into the admin's cfg. // // After this, the app boots against a fully multi-user schema and all pre-existing // data is owned by the admin user. const fs = require('fs'); const path = require('path'); const password = require('./password'); const config = require('./config'); // Tables that need a `user_id` column added (legacy upgrades). Fresh installs // create these with user_id NOT NULL directly in initializeDatabase(). const USER_TABLES = [ 'bewerbungen', 'status_verlauf', 'anhaenge', 'interne_anhaenge', 'emails', 'email_anhaenge', 'basis_dokumente', 'basis_anhaenge', 'termine', 'chat_threads', 'chat_messages', 'jobangebote_blacklist', ]; // jobangebote is NOT in USER_TABLES: its user_id column + per-user UNIQUE // constraint are installed by the recreate in step 4 (which needs user_id to be // absent so the rebuild fires). Adding it here first would make step 4's // !hasColumn guard short-circuit and leave the legacy single-user UNIQUE in // place, breaking per-user isolation on ingest. const ADMIN_USERNAME = 'admin'; const ADMIN_DEFAULT_PASSWORD = 'admin'; async function runMigration({ db, dbAll, dbGet, dbRun }) { // Wire the config store to the same DB so config.setForUser works during the // one-time .env migration below. await config.init({ dbAll, dbRun }); const exec = (sql) => new Promise((resolve, reject) => db.run(sql, (err) => err ? reject(err) : resolve())); const tableExists = async (name) => { const row = await dbGet("SELECT name FROM sqlite_master WHERE type='table' AND name=?", [name]); return !!row; }; const columnsOf = async (table) => { const rows = await dbAll(`PRAGMA table_info(${table})`); return rows.map((r) => r.name); }; const hasColumn = async (table, col) => (await columnsOf(table)).includes(col); // 1. users + sessions ------------------------------------------------- await exec(` CREATE TABLE IF NOT EXISTS users ( id INTEGER PRIMARY KEY AUTOINCREMENT, username TEXT NOT NULL UNIQUE, password_hash TEXT NOT NULL, is_admin INTEGER NOT NULL DEFAULT 0, created_at DATETIME DEFAULT CURRENT_TIMESTAMP ) `); await exec(` CREATE TABLE IF NOT EXISTS sessions ( token TEXT PRIMARY KEY, user_id INTEGER NOT NULL, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, last_seen DATETIME DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE ) `); await exec('CREATE INDEX IF NOT EXISTS idx_sessions_user ON sessions(user_id)'); // Impersonation support: the admin's session keeps its token but switches // user_id to the target, storing the original admin in impersonator_id so the // admin can switch back. Added after the fact for existing installs. if (!(await hasColumn('sessions', 'impersonator_id'))) { await exec('ALTER TABLE sessions ADD COLUMN impersonator_id INTEGER'); } // "Angemeldet bleiben": vorher galten alle Sessions 30 Tage. Bestehende // Sessions behalten dieses Verhalten (Default 1) — niemand wird durch das // Update ausgeloggt. if (!(await hasColumn('sessions', 'dauerhaft'))) { await exec('ALTER TABLE sessions ADD COLUMN dauerhaft INTEGER NOT NULL DEFAULT 1'); } // 2. Ensure admin user (idempotent) ----------------------------------- let admin = await dbGet('SELECT id, password_hash FROM users WHERE username = ?', [ADMIN_USERNAME]); if (!admin) { const hash = password.hash(ADMIN_DEFAULT_PASSWORD); const res = await dbRun( 'INSERT INTO users (username, password_hash, is_admin) VALUES (?, ?, 1)', [ADMIN_USERNAME, hash] ); admin = { id: res.lastID }; console.log(`Multi-User-Migration: Admin-Benutzer „${ADMIN_USERNAME}“ angelegt (Passwort „${ADMIN_DEFAULT_PASSWORD}“). Bitte nach erstem Login ändern.`); } const adminId = admin.id; // 3. Add user_id column + backfill ------------------------------------ for (const tbl of USER_TABLES) { if (!(await tableExists(tbl))) continue; // table may not exist yet on a partial install if (!(await hasColumn(tbl, 'user_id'))) { await exec(`ALTER TABLE ${tbl} ADD COLUMN user_id INTEGER`); } // Backfill every row that is not yet assigned (NULL) to the admin. await dbRun(`UPDATE ${tbl} SET user_id = ? WHERE user_id IS NULL`, [adminId]); } // 4. Recreate tables whose PK/UNIQUE must be per-user ----------------- // app_state: PK(key) -> PK(user_id, key) if (await tableExists('app_state')) { if (!(await hasColumn('app_state', 'user_id'))) { await recreate(db, dbAll, dbRun, 'app_state', `CREATE TABLE app_state ( user_id INTEGER NOT NULL, key TEXT NOT NULL, value TEXT, PRIMARY KEY (user_id, key), FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE )`, `INSERT INTO app_state (user_id, key, value) SELECT ?, key, value FROM app_state_old`, [adminId] ); } else { await dbRun('UPDATE app_state SET user_id = ? WHERE user_id IS NULL', [adminId]); } } // prompts: PK(key) -> PK(user_id, key) if (await tableExists('prompts') && !(await hasColumn('prompts', 'user_id'))) { await recreate(db, dbAll, dbRun, 'prompts', `CREATE TABLE prompts ( user_id INTEGER NOT NULL, key TEXT NOT NULL, inhalt TEXT NOT NULL, updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (user_id, key), FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE )`, `INSERT INTO prompts (user_id, key, inhalt, updated_at) SELECT ?, key, inhalt, updated_at FROM prompts_old`, [adminId] ); } else if (await tableExists('prompts')) { await dbRun('UPDATE prompts SET user_id = ? WHERE user_id IS NULL', [adminId]); } // design: PK(key) -> PK(user_id, key) if (await tableExists('design') && !(await hasColumn('design', 'user_id'))) { await recreate(db, dbAll, dbRun, 'design', `CREATE TABLE design ( user_id INTEGER NOT NULL, key TEXT NOT NULL, value TEXT NOT NULL, updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (user_id, key), FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE )`, `INSERT INTO design (user_id, key, value, updated_at) SELECT ?, key, value, updated_at FROM design_old`, [adminId] ); } else if (await tableExists('design')) { await dbRun('UPDATE design SET user_id = ? WHERE user_id IS NULL', [adminId]); } // settings: single row CHECK(id=1) -> per-user row (user_id UNIQUE) if (await tableExists('settings') && !(await hasColumn('settings', 'user_id'))) { await recreate(db, dbAll, dbRun, 'settings', `CREATE TABLE settings ( id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER NOT NULL UNIQUE, name TEXT, adresse TEXT, kundennummer TEXT, ort TEXT, webseite TEXT, email TEXT, telefon TEXT, geburtsdatum TEXT, FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE )`, `INSERT INTO settings (user_id, name, adresse, kundennummer, ort, webseite, email, telefon, geburtsdatum) SELECT ?, name, adresse, kundennummer, ort, webseite, email, telefon, geburtsdatum FROM settings_old`, [adminId] ); } else if (await tableExists('settings')) { await dbRun('UPDATE settings SET user_id = ? WHERE user_id IS NULL', [adminId]); } // jobangebote: UNIQUE(quelle, external_id) -> UNIQUE(user_id, quelle, external_id) if (await tableExists('jobangebote') && !(await hasColumn('jobangebote', 'user_id'))) { // user_id was added in step 3 already; this branch only recreates for the // per-user UNIQUE constraint. Rebuild preserving all columns. const cols = (await columnsOf('jobangebote')).filter((c) => c !== 'user_id'); const colList = cols.join(', '); await recreate(db, dbAll, dbRun, 'jobangebote', `CREATE TABLE jobangebote ( id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER NOT NULL, external_id TEXT, quelle TEXT NOT NULL DEFAULT 'drittanbieter', firma TEXT NOT NULL, stelle TEXT NOT NULL, ort TEXT, adresse TEXT, ansprechpartner TEXT, gehalt TEXT, beschreibung TEXT, quelle_url TEXT, art TEXT, anzeige_datum DATE, kontakt_email TEXT, status TEXT NOT NULL DEFAULT 'offen', verknuepfte_bewerbung_id INTEGER, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, url_norm TEXT, firma_slug TEXT, labels TEXT, UNIQUE (user_id, quelle, external_id), FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, FOREIGN KEY (verknuepfte_bewerbung_id) REFERENCES bewerbungen(id) ON DELETE SET NULL )`, `INSERT INTO jobangebote (user_id, ${colList}) SELECT ?, ${colList} FROM jobangebote_old`, [adminId] ); await exec('CREATE INDEX IF NOT EXISTS idx_jobangebote_url_norm ON jobangebote(url_norm)'); await exec('CREATE INDEX IF NOT EXISTS idx_jobangebote_firma_slug ON jobangebote(firma_slug)'); } // Ensure a settings row exists for the admin (idempotent). On a fresh install // the settings table does not exist yet at this point (it is created by the // server's CREATE TABLE IF NOT EXISTS right after the migration), so skip — // the row is upserted on the admin's first /vorlagen save anyway. if (await tableExists('settings')) { const srow = await dbGet('SELECT id FROM settings WHERE user_id = ?', [adminId]); if (!srow) { await dbRun( `INSERT INTO settings (user_id, name, adresse, kundennummer, ort, webseite, email, telefon, geburtsdatum) VALUES (?, 'Max Mustermann', 'Musterstraße 1, 12345 Musterstadt', '', '', '', '', '', '')`, [adminId] ); } } // 5. Move on-disk files into a per-user subdirectory for the admin ----- moveFilesIntoUserSubdir(adminId); // 6. One-time env -> admin cfg import ---------------------------------- // On an upgraded install app_state already exists here, so this fills the // admin's rows right away. On a *fresh* install the table is only created // after this migration returns, so this is a no-op — server.js therefore // calls importEnvIntoAdmin() again once the schema is complete. Idempotent // either way (it only fills keys the admin has not stored). await migrateEnvForAdmin(dbAll, adminId); return { adminId }; } // Import any still-present env config into the admin's rows. Called at boot from // server.js, after initializeDatabase() has created app_state — this is what // makes a deployment that passes its config as container env vars (as ours does) // end up with those values owned by the admin, instead of leaking to every user // through a read-time process.env fallback (which config.get() no longer has). async function importEnvIntoAdmin({ dbAll, dbGet }) { const admin = await dbGet('SELECT id FROM users WHERE username = ?', [ADMIN_USERNAME]); if (!admin) return; await migrateEnvForAdmin(dbAll, admin.id); } // Rename `table` to `table_old`, create the new table from `newSchemaSql`, // copy rows via `copySql` (with `copyParams`), then drop `table_old`. async function recreate(db, dbAll, dbRun, table, newSchemaSql, copySql, copyParams) { const exec = (sql) => new Promise((resolve, reject) => db.run(sql, (err) => err ? reject(err) : resolve())); await exec(`ALTER TABLE ${table} RENAME TO ${table}_old`); await exec(newSchemaSql); await dbRun(copySql, copyParams || []); await exec(`DROP TABLE ${table}_old`); } // Move every file in each per-user storage directory into a `/` subdir. // Idempotent: if the subdir already contains files (already migrated), leave the // top-level files alone (they would be a re-run left-overs) — but on first run the // top level holds the legacy flat files, which we move in. const STORAGE_DIRS = ['anhaenge', 'basis_anhaenge', 'interne_anhaenge', 'email_anhaenge', 'signatur', 'bewerberfoto']; function moveFilesIntoUserSubdir(userId) { const dataDir = path.join(__dirname, '..', 'data'); for (const dir of STORAGE_DIRS) { const base = path.join(dataDir, dir); if (!fs.existsSync(base)) continue; const userDir = path.join(base, String(userId)); if (!fs.existsSync(userDir)) fs.mkdirSync(userDir, { recursive: true }); for (const name of fs.readdirSync(base)) { if (name === String(userId)) continue; const src = path.join(base, name); if (!fs.statSync(src).isFile()) continue; // skip subdirectories const dst = path.join(userDir, name); if (!fs.existsSync(dst)) fs.renameSync(src, dst); } } } // Move any still-present .env value for a DEFAULT key that the admin hasn't got // stored yet into the admin's cfg rows. Idempotent: only fills keys that are not // yet present for the admin. async function migrateEnvForAdmin(dbAll, adminId) { // On a fresh install app_state does not exist yet at this point (created by // the server right after the migration), so there is nothing to read from or // write to — skip. The .env values, if any, then fall back via config.get(). try { await dbAll('SELECT 1 FROM app_state LIMIT 1'); } catch (e) { return; } const storedRows = await dbAll('SELECT key FROM app_state WHERE user_id = ?', [adminId]); const stored = new Set(storedRows.map((r) => r.key.slice(config.PREFIX.length))); const migrated = []; for (const key of Object.keys(config.DEFAULTS)) { if (stored.has(key)) continue; // already in the DB, never overwrite from .env const envVal = process.env[key]; if (envVal && envVal.length) { await config.setForUser(adminId, key, envVal); migrated.push(key); } } if (migrated.length) { console.log(`Multi-User-Migration: ${migrated.length} Konfigurationswerte aus .env in den Admin-Benutzer migriert — .env wird nicht mehr benötigt.`); } } module.exports = { runMigration, importEnvIntoAdmin, moveFilesIntoUserSubdir, STORAGE_DIRS, ADMIN_USERNAME, ADMIN_DEFAULT_PASSWORD };