-- ═══════════════════════════════════════════════════════════════ -- 009_superadmin.sql -- -- Adds a third role above 'admin'. A CHECK constraint can't be -- altered in place, so the table is rebuilt — the recipe from the -- SQLite docs, in the order it has to happen. -- -- Foreign keys are OFF for the duration on purpose. `sessions` -- references admin_users(id), and: -- -- * with FKs ON, DROP TABLE admin_users fires the ON DELETE -- CASCADE and empties `sessions` — everyone signed out; -- * with FKs ON, the RENAME afterwards tries to rewrite the -- REFERENCES clause in `sessions` and fails, because the table -- it points at no longer exists. -- -- With them OFF neither happens: `sessions` keeps pointing at the -- name "admin_users", which the rename puts back underneath it. -- -- ⚠ PRAGMA foreign_keys is a no-op inside a transaction. If the -- migration runner wraps each file in BEGIN/COMMIT, this file will -- appear to work and then fail at the rename. Check the runner -- before applying, or run this one by hand: -- -- sudo systemctl stop ngu-api -- sudo sqlite3 /var/lib/ngu/ngu.db < 009_superadmin.sql -- sudo systemctl start ngu-api -- -- Verify after: -- -- PRAGMA user_version; -- 9 -- PRAGMA foreign_key_check; -- no rows -- SELECT email, role FROM admin_users; -- ═══════════════════════════════════════════════════════════════ PRAGMA foreign_keys = OFF; CREATE TABLE admin_users_new ( id INTEGER PRIMARY KEY AUTOINCREMENT, created_at TEXT NOT NULL DEFAULT (datetime('now')), -- Stored lowercased. The application lowercases on every read -- and write, so the UNIQUE index is genuinely case-insensitive -- without depending on a collation. email TEXT NOT NULL UNIQUE, name TEXT, -- Nullable so a Google-only account can exist later with no -- password at all. A row with both can use either route in. password_hash TEXT, -- Google's stable subject id. Nullable, unique when present — -- SQLite allows any number of NULLs in a unique index. google_sub TEXT UNIQUE, -- Listed low to high. The application treats these as a ladder, -- not a set: 'superadmin' passes every check 'admin' passes. -- The default stays 'admin' — a new account should never arrive -- at the top of the ladder by accident. role TEXT NOT NULL DEFAULT 'admin' CHECK (role IN ('viewer', 'editor', 'admin', 'superadmin')), is_active INTEGER NOT NULL DEFAULT 1 CHECK (is_active IN (0, 1)), last_login_at TEXT ) STRICT; -- Columns listed explicitly rather than SELECT *, so this breaks -- loudly if the old shape isn't what this file assumes. INSERT INTO admin_users_new (id, created_at, email, name, password_hash, google_sub, role, is_active, last_login_at) SELECT id, created_at, email, name, password_hash, google_sub, role, is_active, last_login_at FROM admin_users; DROP TABLE admin_users; ALTER TABLE admin_users_new RENAME TO admin_users; -- Informational: prints offending rows and returns nothing if the -- rebuild left the graph intact. PRAGMA foreign_key_check; PRAGMA foreign_keys = ON; PRAGMA user_version = 9; -- ← set to this migration's number