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
This commit is contained in:
@@ -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;
|
||||||
@@ -75,5 +75,9 @@ BEGIN;
|
|||||||
|
|
||||||
\echo '=== Running 019_allow_null_start_time.sql (Allow Null Start Time) ==='
|
\echo '=== Running 019_allow_null_start_time.sql (Allow Null Start Time) ==='
|
||||||
\i 019_allow_null_start_time.sql
|
\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 ==='
|
\echo '=== Migration Complete ==='
|
||||||
COMMIT;
|
COMMIT;
|
||||||
|
|||||||
+1
-1
@@ -9,7 +9,7 @@
|
|||||||
"dev:repair": "node scripts/code-repair-agent.mjs --watch",
|
"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: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: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:ollama": "node scripts/ensure-ollama.mjs",
|
||||||
"dev:rust": "node ai-server/index.mjs",
|
"dev:rust": "node ai-server/index.mjs",
|
||||||
"dev:browser-use": "cd browser-use-service && node ../scripts/run-python.mjs main.py",
|
"dev:browser-use": "cd browser-use-service && node ../scripts/run-python.mjs main.py",
|
||||||
|
|||||||
@@ -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()
|
||||||
|
}
|
||||||
+11
-2
@@ -202,7 +202,16 @@ if (!existsSync(resolve(ROOT, ".env.local"))) {
|
|||||||
console.log("\n── .env.local already exists, skipping ──")
|
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) {
|
if (doSelfHeal) {
|
||||||
console.log("\n── Self-Healing Build Check ──")
|
console.log("\n── Self-Healing Build Check ──")
|
||||||
const { generateModule } = loadRegistry()
|
const { generateModule } = loadRegistry()
|
||||||
@@ -252,7 +261,7 @@ if (doSelfHeal) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 6. Final summary
|
// 7. Final summary
|
||||||
console.log("\n── Final Steps ──")
|
console.log("\n── Final Steps ──")
|
||||||
console.log(" 1. Make sure PostgreSQL is running with database 'crm'")
|
console.log(" 1. Make sure PostgreSQL is running with database 'crm'")
|
||||||
console.log(" 2. Pull the Ollama model: ollama pull dolphin-llama3:8b")
|
console.log(" 2. Pull the Ollama model: ollama pull dolphin-llama3:8b")
|
||||||
|
|||||||
Reference in New Issue
Block a user