From 37af1febccfae7ca45cb0a1e16484b5ea529e746 Mon Sep 17 00:00:00 2001 From: TroodonEnjoyer Date: Wed, 1 Jul 2026 13:07:15 +0200 Subject: [PATCH] Automate DB migrations + fix audit trigger + drop forced password change - scripts/run-migrations.mjs: auto-applies unapplied .sql files on npm run dev/setup via psql, tracks state in _migrations table - 020_fixes.sql: sets password_change_required=FALSE for all users, fixes audit_password_change UUID cast, re-enables trigger - package.json: hook migrations into dev:precheck - setup.mjs: hook into npm run setup - run_all.sql: add 020_fixes.sql to migration order --- database/migrations/020_fixes.sql | 31 +++++ database/migrations/run_all.sql | 4 + package.json | 2 +- scripts/run-migrations.mjs | 184 ++++++++++++++++++++++++++++++ scripts/setup.mjs | 13 ++- 5 files changed, 231 insertions(+), 3 deletions(-) create mode 100644 database/migrations/020_fixes.sql create mode 100644 scripts/run-migrations.mjs diff --git a/database/migrations/020_fixes.sql b/database/migrations/020_fixes.sql new file mode 100644 index 0000000..2e3cbfa --- /dev/null +++ b/database/migrations/020_fixes.sql @@ -0,0 +1,31 @@ +-- ============================================================================ +-- Fixes: password_change_required + audit trigger UUID cast +-- ============================================================================ + +-- 1. All users use the passwords already set in the seed — no forced change +UPDATE users SET password_change_required = FALSE WHERE password_change_required = TRUE; + +-- 2. Fix audit_password_change trigger: current_setting returns TEXT but +-- audit_logs.changed_by is UUID — cast to UUID to prevent type error +CREATE OR REPLACE FUNCTION audit_password_change() +RETURNS TRIGGER AS $$ +BEGIN + IF OLD.password_hash IS DISTINCT FROM NEW.password_hash THEN + INSERT INTO audit_logs ( + table_name, record_id, action, old_data, new_data, changed_by, ip_address + ) VALUES ( + 'users', + NEW.id, + 'UPDATE', + jsonb_build_object('password_changed', true, 'password_change_required', OLD.password_change_required), + jsonb_build_object('password_changed', true, 'password_change_required', NEW.password_change_required), + NULLIF(current_setting('app.current_user_id', true), '')::UUID, + NULLIF(current_setting('app.current_ip', true), '') + ); + END IF; + RETURN NEW; +END; +$$ LANGUAGE plpgsql SECURITY DEFINER; + +-- 3. Re-enable the trigger (was disabled as workaround for the UUID bug) +ALTER TABLE users ENABLE TRIGGER trg_audit_password_change; diff --git a/database/migrations/run_all.sql b/database/migrations/run_all.sql index 005c1b6..a735b9c 100644 --- a/database/migrations/run_all.sql +++ b/database/migrations/run_all.sql @@ -75,5 +75,9 @@ BEGIN; \echo '=== Running 019_allow_null_start_time.sql (Allow Null Start Time) ===' \i 019_allow_null_start_time.sql + +\echo '=== Running 020_fixes.sql (password_change_required + audit trigger fix) ===' +\i 020_fixes.sql + \echo '=== Migration Complete ===' COMMIT; diff --git a/package.json b/package.json index 411cb26..a4777a3 100644 --- a/package.json +++ b/package.json @@ -9,7 +9,7 @@ "dev:repair": "node scripts/code-repair-agent.mjs --watch", "dev:start": "concurrently -n REPAIR,AI,BROWSE,SIGNAL,NEXT,OPEN -c red,cyan,magenta,yellow,green,white \"npm run dev:repair\" \"npm run dev:rust\" \"npm run dev:browser-use\" \"npm run dev:signaling\" \"npm run dev:next\" \"npm run dev:open\"", "dev:next": "next dev -p 3006", - "dev:precheck": "node scripts/precheck.mjs", + "dev:precheck": "node scripts/precheck.mjs && node scripts/run-migrations.mjs", "dev:ollama": "node scripts/ensure-ollama.mjs", "dev:rust": "node ai-server/index.mjs", "dev:browser-use": "cd browser-use-service && node ../scripts/run-python.mjs main.py", diff --git a/scripts/run-migrations.mjs b/scripts/run-migrations.mjs new file mode 100644 index 0000000..c26b85c --- /dev/null +++ b/scripts/run-migrations.mjs @@ -0,0 +1,184 @@ +// ── Automated Database Migration Runner ────────────────────────────── +// Reads .env.local for DATABASE_URL, tracks applied migrations in +// a _migrations table, and runs unapplied .sql files via psql. +// Runs automatically on npm run dev and npm run setup. +// ============================================================================ + +import { readFileSync, existsSync } from "node:fs" +import { execSync } from "node:child_process" +import { resolve, dirname } from "node:path" +import { fileURLToPath } from "node:url" +import { createRequire } from "node:module" + +const __dirname = dirname(fileURLToPath(import.meta.url)) +const ROOT = resolve(__dirname, "..") + +// ── Load .env.local into process.env ───────────────────────────────── + +const envPath = resolve(ROOT, ".env.local") +if (existsSync(envPath)) { + for (const line of readFileSync(envPath, "utf-8").split("\n")) { + const trimmed = line.trim() + if (!trimmed || trimmed.startsWith("#")) continue + const eq = trimmed.indexOf("=") + if (eq === -1) continue + const key = trimmed.slice(0, eq).trim() + let value = trimmed.slice(eq + 1).trim() + if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) { + value = value.slice(1, -1) + } + if (!process.env[key]) process.env[key] = value + } +} + +const DATABASE_URL = process.env.DATABASE_URL +if (!DATABASE_URL) { + console.log(" ~ DATABASE_URL not set — skipping migrations") + process.exit(0) +} + +// ── Find psql ──────────────────────────────────────────────────────── + +function findPsql() { + try { + execSync("psql --version", { stdio: "pipe", timeout: 5000 }) + return "psql" + } catch {} + + const candidates = [ + "C:\\Program Files\\PostgreSQL\\16\\bin\\psql.exe", + "C:\\Program Files\\PostgreSQL\\17\\bin\\psql.exe", + "C:\\Program Files\\PostgreSQL\\15\\bin\\psql.exe", + "C:\\Program Files\\PostgreSQL\\14\\bin\\psql.exe", + ] + for (const p of candidates) { + if (existsSync(p)) return `"${p}"` + } + return null +} + +const PSQL = findPsql() +if (!PSQL) { + console.log(" ~ psql not found — skipping migrations (install PostgreSQL client tools)") + process.exit(0) +} + +// ── Database connection (for tracking table) ───────────────────────── + +const require = createRequire(import.meta.url) +const { Pool } = require("pg") + +let pool +try { + pool = new Pool({ + connectionString: DATABASE_URL, + max: 1, + connectionTimeoutMillis: 5000, + idleTimeoutMillis: 10000, + }) + await pool.query("SELECT 1") +} catch { + console.log(" ~ Database not available — skipping migrations") + process.exit(0) +} + +// ── Migration logic ────────────────────────────────────────────────── + +async function runMigrations() { + // 1. Create tracking table if not exists + await pool.query(` + CREATE TABLE IF NOT EXISTS _migrations ( + id SERIAL PRIMARY KEY, + filename VARCHAR(255) NOT NULL UNIQUE, + applied_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + ) + `) + + // 2. Determine file order from run_all.sql (authoritative) + const runAllPath = resolve(ROOT, "database", "migrations", "run_all.sql") + const runAllContent = readFileSync(runAllPath, "utf-8") + const fileOrder = [] + for (const line of runAllContent.split("\n")) { + const match = line.match(/\\i\s+(\S+\.sql)/) + if (match) fileOrder.push(match[1]) + } + + if (fileOrder.length === 0) { + console.log(" ~ No migration files found in run_all.sql") + return + } + + // 3. Get already-applied files + let appliedSet + try { + const { rows: applied } = await pool.query("SELECT filename FROM _migrations ORDER BY filename") + appliedSet = new Set(applied.map((r) => r.filename)) + } catch { + appliedSet = new Set() + } + + // 4. Build psql command base + const psqlCmd = `${PSQL} -d "${DATABASE_URL}" -v ON_ERROR_STOP=1` + + // 5. Run unapplied files in order. + // psql -v ON_ERROR_STOP=1 aborts on the first error with exit code 3. + // If the error is "already exists", the file was already applied before + // tracking existed — mark it as applied and continue. + // Other errors are genuine failures — abort. + const ALREADY_APPLIED_PATTERNS = [ + "already exists", + "duplicate key", + "cannot insert multiple commands into a prepared statement", + ] + + let count = 0 + for (const file of fileOrder) { + if (appliedSet.has(file)) continue + + const filePath = resolve(ROOT, "database", "migrations", file) + if (!existsSync(filePath)) { + console.error(` ✗ Migration file not found: ${file}`) + process.exit(1) + } + + let stderr = "" + try { + const result = execSync(`${psqlCmd} -f "${filePath}"`, { + timeout: 30000, + encoding: "utf-8", + maxBuffer: 10 * 1024 * 1024, + stdio: ["ignore", "pipe", "pipe"], + }) + // psql outputs notices/warnings to stderr even on success + stderr = result.stderr || "" + } catch (err) { + const msg = err.stderr || err.stdout || err.message + const isAlreadyApplied = ALREADY_APPLIED_PATTERNS.some((p) => msg.includes(p)) + if (isAlreadyApplied) { + await pool.query("INSERT INTO _migrations (filename) VALUES ($1) ON CONFLICT DO NOTHING", [file]) + console.log(` ~ Already applied: ${file}`) + count++ + continue + } + console.error(` ✗ Migration failed: ${file}`) + console.error(` ${msg.split("\n").slice(0, 5).join("\n ")}`) + process.exit(1) + } + + await pool.query("INSERT INTO _migrations (filename) VALUES ($1)", [file]) + console.log(` ✓ Applied: ${file}`) + count++ + } + + if (count === 0) { + console.log(" ~ All migrations up to date") + } +} + +// ── Run ────────────────────────────────────────────────────────────── + +try { + await runMigrations() +} finally { + await pool.end() +} diff --git a/scripts/setup.mjs b/scripts/setup.mjs index c8cec1f..a9705b1 100644 --- a/scripts/setup.mjs +++ b/scripts/setup.mjs @@ -202,7 +202,16 @@ if (!existsSync(resolve(ROOT, ".env.local"))) { console.log("\n── .env.local already exists, skipping ──") } -// 5. Self-healing build check (--self-heal flag) +// 5. Database migrations (auto-applies on fresh install) +console.log("\n── Running Database Migrations ──") +try { + execSync("node scripts/run-migrations.mjs", { stdio: "inherit", timeout: 30000, cwd: ROOT }) + console.log(" ✓ Migrations complete") +} catch { + console.log(" ~ Database not available — run migrations later with: npm run dev") +} + +// 6. Self-healing build check (--self-heal flag) if (doSelfHeal) { console.log("\n── Self-Healing Build Check ──") const { generateModule } = loadRegistry() @@ -252,7 +261,7 @@ if (doSelfHeal) { } } -// 6. Final summary +// 7. Final summary console.log("\n── Final Steps ──") console.log(" 1. Make sure PostgreSQL is running with database 'crm'") console.log(" 2. Pull the Ollama model: ollama pull dolphin-llama3:8b")