const { query, transaction } = require('../../utils/db') const PUBLIC_COLS = 'id, username, role, status, email, email_verified, email_pending, totp_enabled, created_at, last_login_at' // passwordHash may be null (SSO-provisioned players who have not set one yet). // email/status/emailVerified are optional so existing admin-create callers are // unaffected. async function insertUser({ username, passwordHash = null, role = 'admin', email = null, status = 'active', emailVerified = false, }) { const res = await query( 'INSERT INTO users (username, password_hash, role, email, status, email_verified) VALUES (?, ?, ?, ?, ?, ?)', [username, passwordHash, role, email, status, emailVerified ? 1 : 0], ) return res.insertId } async function findByUsername(username) { const rows = await query('SELECT * FROM users WHERE username = ? LIMIT 1', [username]) return rows[0] || null } async function findById(id) { const rows = await query('SELECT * FROM users WHERE id = ? LIMIT 1', [id]) return rows[0] || null } // The ACTIVE account on an email address, as a list. Unique since engagement // Phase 1b, so this returns at most one row โ€” the array shape is kept because the // password-reset caller iterates and there is nothing to gain from making it care. // accounts; the caller issues one reset link per row. Case-insensitive to match // however the address was stored. Excludes disabled/banned accounts. // Active accounts on an address. Matches on email_norm, the same generated column // the UNIQUE index uses, so a lookup folds case exactly the way uniqueness does โ€” // LOWER() here and LOWER() there can never drift apart. Since Phase 1b this // returns at most one row; it still returns an array because the password-reset // caller iterates and there is no value in making that caller care. async function findActiveByEmail(email) { return query( "SELECT * FROM users WHERE email_norm = LOWER(?) AND status = 'active'", [email], ) } // Stage an address the user has asked for but not yet proved. Does not touch // `email`, so their current address keeps receiving mail until the link is used. async function setPendingEmail(id, email) { const res = await query('UPDATE users SET email_pending = ? WHERE id = ?', [email, id]) return res.affectedRows || 0 } // Promote a proved address into place. Guarded on email_pending still matching, so // a stale link (the user asked again for a different address) cannot install the // address it was minted for. Returns rows changed โ€” 0 means the guard rejected it. async function promotePendingEmail(id, email) { const res = await query( `UPDATE users SET email = ?, email_verified = 1, email_pending = NULL WHERE id = ? AND email_pending = ?`, [email, id, email], ) return res.affectedRows || 0 } async function clearPendingEmail(id) { const res = await query('UPDATE users SET email_pending = NULL WHERE id = ?', [id]) return res.affectedRows || 0 } async function listUsers() { return query(`SELECT ${PUBLIC_COLS} FROM users ORDER BY id ASC`) } async function updateUser(id, fields) { const cols = [] const params = [] for (const [key, val] of Object.entries(fields)) { cols.push(`${key} = ?`) params.push(val) } if (cols.length === 0) return params.push(id) await query(`UPDATE users SET ${cols.join(', ')} WHERE id = ?`, params) } async function deleteUser(id) { return query('DELETE FROM users WHERE id = ?', [id]) } // Delete the account and, when `reserveName` is given, reserve it, as ONE // transaction (ACCOUNT_DELETION.md ยง4.1 step 2): a staff name is never free for // a moment between the two. `INSERT IGNORE` because a name already reserved // (a second staff account renamed onto it cannot exist, but a hand-inserted row // can) is the state wanted, not an error. Returns the rows deleted, 0 or 1. async function deleteUserReserving(id, reserveName = null) { return transaction(async (conn) => { if (reserveName) { await conn.query( "INSERT IGNORE INTO reserved_usernames (username_norm, reason) VALUES (LOWER(?), 'deleted_staff')", [reserveName], ) } const result = await conn.query('DELETE FROM users WHERE id = ?', [id]) return Number(result.affectedRows || 0) }) } // True when the name can never be taken again (AD9). The column's collation is // users.username's, so this folds case exactly as the UNIQUE index does. async function isUsernameReserved(username) { const rows = await query('SELECT 1 FROM reserved_usernames WHERE username_norm = LOWER(?) LIMIT 1', [username]) return rows.length > 0 } async function countUsers() { const rows = await query('SELECT COUNT(*) AS c FROM users') return Number(rows[0].c) } async function countAdmins() { const rows = await query("SELECT COUNT(*) AS c FROM users WHERE role = 'admin'") return Number(rows[0].c) } async function touchLastLogin(id, ip = null) { return query('UPDATE users SET last_login_at = NOW(), last_login_ip = ? WHERE id = ?', [ip, id]) } // Move the "tokens valid after" cutoff to now, invalidating every session token // issued before this instant (password change / log out everywhere). requireAuth // compares each session's issued-at against this column. async function bumpTokensValidAfter(id) { return query('UPDATE users SET tokens_valid_after = NOW() WHERE id = ?', [id]) } // Set the cutoff to an explicit instant. Used when re-issuing the caller's own // session right after a password change: the bump above revokes everything at // NOW(), and requireAuth's cutoff test is inclusive (createdAt <= cutoff), so a // freshly-minted token sharing that same wall-clock second would be revoked too. // Rewinding the cutoff a hair below the new token's issued-at lets it survive // while still revoking every older session. async function setTokensValidAfter(id, when) { return query('UPDATE users SET tokens_valid_after = ? WHERE id = ?', [when, id]) } // Store a (not-yet-enabled) TOTP secret for a user. Enabling is a separate step // so a secret is never trusted until the user has confirmed one code. async function setTotpSecret(id, secret) { return query('UPDATE users SET totp_secret = ?, totp_enabled = 0 WHERE id = ?', [secret, id]) } async function enableTotp(id) { return query('UPDATE users SET totp_enabled = 1 WHERE id = ?', [id]) } async function disableTotp(id) { return query('UPDATE users SET totp_secret = NULL, totp_enabled = 0 WHERE id = ?', [id]) } module.exports = { insertUser, findByUsername, findById, findActiveByEmail, setPendingEmail, promotePendingEmail, clearPendingEmail, listUsers, updateUser, deleteUser, deleteUserReserving, isUsernameReserved, countUsers, countAdmins, touchLastLogin, bumpTokensValidAfter, setTokensValidAfter, setTotpSecret, enableTotp, disableTotp, }