// SQL for the two tables the access resolver reads: forum grants (path 3) and // staff leadership overrides (§2.5.1). // // Kept separate from teams.db.js on purpose. The four authority paths are four // tables answering four questions, and the single most important structural rule // in TEAMS.md is that no resolver reads another path's table — a file boundary is // a cheap way to make crossing one visible in a diff. const { query } = require('../../utils/db') // ── team_forum_grants (path 3) ───────────────────────────────────────────── const GRANT_COLUMNS = ` id, team_id, user_id, username, granted_by, granted_username, granted_at, reason, revoked_by, revoked_username, revoked_at, revoke_reason` /** The caller's ACTIVE grant on a team, or undefined. At most one, by the unique key. */ async function activeGrant(teamId, userId) { const rows = await query( `SELECT ${GRANT_COLUMNS} FROM team_forum_grants WHERE team_id = ? AND user_id = ? AND revoked_at IS NULL`, [teamId, userId], ) return rows[0] } /** The whole ledger for a team, revoked rows included — the admin grant view. */ async function grantLedger(teamId) { return query( `SELECT ${GRANT_COLUMNS} FROM team_forum_grants WHERE team_id = ? ORDER BY granted_at DESC, id DESC`, [teamId], ) } /** Active grants only, for the "Forum guests" list and the per-team cap. */ async function activeGrants(teamId) { return query( `SELECT ${GRANT_COLUMNS} FROM team_forum_grants WHERE team_id = ? AND revoked_at IS NULL ORDER BY granted_at`, [teamId], ) } /** How many active grants a team currently holds — the §2.5 per-Team cap reads this. */ async function activeGrantCount(teamId) { const rows = await query( 'SELECT COUNT(*) AS n FROM team_forum_grants WHERE team_id = ? AND revoked_at IS NULL', [teamId], ) return Number(rows[0]?.n || 0) } /** * Issue a grant. * * Writes nothing but this table — that is the non-contamination invariant, and it * is a property of this function being the ONLY writer on the grant path rather * than of anyone remembering it at the call site. The username snapshots are * taken here so the ledger still reads after either account is deleted (§2.10). */ async function insertGrant({ teamId, userId, username, grantedBy, grantedUsername, reason }) { const res = await query( `INSERT INTO team_forum_grants (team_id, user_id, username, granted_by, granted_username, reason) VALUES (?, ?, ?, ?, ?, ?)`, [teamId, userId, username, grantedBy, grantedUsername, reason ?? null], ) return res.insertId } /** * Revoke the active grant, if there is one. * * An UPDATE of the existing row rather than a delete: the table is a ledger as * well as the current state, and `revoked_at` is what moves a row out of the * unique key (the generated `active_marker` goes NULL) while keeping the history. */ async function revokeGrant({ teamId, userId, revokedBy, revokedUsername, reason }) { const res = await query( `UPDATE team_forum_grants SET revoked_at = NOW(), revoked_by = ?, revoked_username = ?, revoke_reason = ? WHERE team_id = ? AND user_id = ? AND revoked_at IS NULL`, [revokedBy, revokedUsername, reason ?? null, teamId, userId], ) return res.affectedRows > 0 } // ── team_leader_overrides (§2.5.1) ───────────────────────────────────────── const OVERRIDE_COLUMNS = 'team_id, member_key, effect, actor_user_id, actor_username, reason, created_at' async function overridesForTeam(teamId) { return query(`SELECT ${OVERRIDE_COLUMNS} FROM team_leader_overrides WHERE team_id = ? ORDER BY member_key`, [teamId]) } async function overrideFor(teamId, memberKey) { const rows = await query( `SELECT ${OVERRIDE_COLUMNS} FROM team_leader_overrides WHERE team_id = ? AND member_key = ?`, [teamId, memberKey], ) return rows[0] } /** * Set or replace one override. * * The projection is never touched by this — `team_members.is_leader` keeps saying * what the game says and this keeps saying what staff decided, which is the entire * point (§2.5.1). An override applied INTO the projection would be clobbered by * the next sync, fifteen minutes later. */ async function setOverride({ teamId, memberKey, effect, actorUserId, actorUsername, reason }) { await query( `INSERT INTO team_leader_overrides (team_id, member_key, effect, actor_user_id, actor_username, reason) VALUES (?, ?, ?, ?, ?, ?) ON DUPLICATE KEY UPDATE effect = VALUES(effect), actor_user_id = VALUES(actor_user_id), actor_username = VALUES(actor_username), reason = VALUES(reason), created_at = NOW()`, [teamId, memberKey, effect, actorUserId, actorUsername, reason], ) } async function clearOverride(teamId, memberKey) { const res = await query('DELETE FROM team_leader_overrides WHERE team_id = ? AND member_key = ?', [teamId, memberKey]) return res.affectedRows > 0 } module.exports = { activeGrant, grantLedger, activeGrants, activeGrantCount, insertGrant, revokeGrant, overridesForTeam, overrideFor, setOverride, clearOverride, }