// SQL for per-Team external resources — today, the Discord voice channel // (TEAMS.md §7.3, phase 9). // // Two queries carry the phase. `desiredTeams` is what SHOULD have a channel and // `holdersWithoutClaim` is what HAS one and should not; the reconciler is the // difference between them, and keeping both as single queries is what stops a // pass from being one round trip per Team before it has made a single Discord // call. // // **`discordSubjectsFor` is the whole identity chain in one statement** (§2.6): // team_members → users → user_identities. A member with no site account has no // row to join, and a member with a site account but no Discord identity drops out // at the second join — which is exactly right, because a role can only be granted // to somebody Discord knows about. Nothing else in the phase is allowed to // shortcut this with `teams.linked_count`, which counts hop 1 and is always the // larger number. const { query } = require('../../utils/db') // The provider id a Discord identity is stored under. Matches `auth_providers.id` // and the built-in provider in `auth/providers/discord.provider.js`; a constant // rather than a literal because it appears in two queries and a typo in either // would silently return an empty grant set — a Team whose channel nobody can // enter, with no error anywhere. const DISCORD_PROVIDER = 'discord' // Deliberately WITHOUT `i.team_id`, and this is not tidiness. // // The two queries below join `teams` and already select `t.id AS team_id`, so // including the integration row's copy produces two result columns with the same // name — which the `mariadb` driver refuses outright: "Error in results, duplicate // field name `team_id`". Every caller sees the whole pass fail, and no unit test // can see it, because they stub this layer. // // It would also be the WRONG column even if the driver allowed it: `desiredTeams` // LEFT JOINs, so `i.team_id` is NULL for exactly the Teams that have no channel // yet — the create case, where knowing the Team's id matters most. The two queries // that do not join `teams` ask for it explicitly. const COLUMNS = ` i.id, i.platform, i.resource, i.external_ref, i.role_ref, i.state, i.remove_after, i.last_error, i.synced_at, i.updated_at` /** * Every Team that qualifies for a resource, with its integration row if it has * one. * * The three conditions are §7.3's provisioning gate and §2.8's publication rule * together: * * - `status = 'active'` — an archived Team is a record, not a place to talk. * - `hidden = 0` — the channel is NAMED after the Team, and a Discord channel * name is a game-sourced string published outside the site. A hidden Team's * name is suppressed on every public surface; a voice channel would be the * one place it still appeared. * - `member_count >= ?` — the operator's threshold, counting ALL active members * regardless of what they have linked (org lead, 2026-08-18). §7.3 wrote * `voice_min_linked_members`; the number an operator is actually judging is * "is this Team real", and link state answers a different question. * * LEFT JOIN rather than two queries: the reconciler needs "should have, and does * it" as one answer, and a Team with no row yet is the create case. */ async function desiredTeams({ platform, resource, minMembers }) { return query( `SELECT t.id AS team_id, t.name, t.display_name_override, t.slug, t.abbr, t.member_count, t.linked_count, ${COLUMNS} FROM teams t LEFT JOIN team_integrations i ON i.team_id = t.id AND i.platform = ? AND i.resource = ? WHERE t.status = 'active' AND t.hidden = 0 AND t.member_count >= ? ORDER BY t.id`, [platform, resource, Number(minMembers)], ) } /** * Rows that hold a resource for a Team that no longer qualifies. * * The mirror of `desiredTeams`, and deliberately not its negation in JavaScript: * a Team can stop qualifying by being archived, by being hidden, by losing * members, or by having its row deleted out from under core, and enumerating * those in a filter would mean re-deriving the gate in a second place that could * disagree with the first. * * Rows already in 'pending_removal' are included — the grace window is decided by * the caller, which needs to see them to know whether one has expired. */ async function holdersWithoutClaim({ platform, resource, minMembers }) { return query( `SELECT t.id AS team_id, t.name, t.display_name_override, t.status, t.hidden, t.member_count, ${COLUMNS} FROM team_integrations i JOIN teams t ON t.id = i.team_id WHERE i.platform = ? AND i.resource = ? AND (i.external_ref IS NOT NULL OR i.role_ref IS NOT NULL) AND (t.status <> 'active' OR t.hidden = 1 OR t.member_count < ?) ORDER BY t.id`, [platform, resource, Number(minMembers)], ) } /** * The Discord user ids of a Team's members — hop 3 of §2.6, and the only set a * role can be granted to. * * DISTINCT because a user could in principle hold two rows for the same provider * across a provider rename; the unique key prevents it for one (provider, * subject) pair, not for one user with two subjects. Two role-adds for the same * person is harmless and one duplicate in a diff is a phantom removal next pass, * which is not. */ async function discordSubjectsFor(teamId) { const rows = await query( `SELECT DISTINCT ui.subject FROM team_members m JOIN user_identities ui ON ui.user_id = m.user_id AND ui.provider = ? WHERE m.team_id = ? AND m.status = 'active' AND m.user_id IS NOT NULL ORDER BY ui.subject`, [DISCORD_PROVIDER, Number(teamId)], ) return rows.map((row) => String(row.subject)) } /** Every row for a platform, with the Team's name — the admin panel's listing. */ async function listForPlatform(platform, resource) { return query( `SELECT i.team_id, ${COLUMNS}, t.name AS team_name, t.slug AS team_slug, t.display_name_override, t.status AS team_status, t.hidden AS team_hidden, t.member_count, t.linked_count FROM team_integrations i JOIN teams t ON t.id = i.team_id WHERE i.platform = ? AND i.resource = ? ORDER BY t.name`, [platform, resource], ) } async function getForTeam(teamId, platform, resource) { const rows = await query( `SELECT i.team_id, ${COLUMNS} FROM team_integrations i WHERE i.team_id = ? AND i.platform = ? AND i.resource = ? LIMIT 1`, [Number(teamId), platform, resource], ) return rows[0] || null } /** * Write what the reconciler believes after a pass. * * A full upsert of the mutable columns rather than a patch, because every caller * has just decided all of them together: a pass that created a channel knows the * state, the refs, the error (none) and the stamp, and letting it write three of * the four would leave the fourth describing a previous pass. * * `remove_after` is written explicitly on every call, `NULL` included — a Team * that climbs back above the threshold inside its window has to have the window * cleared, and an upsert that skipped NULLs would leave it armed. */ async function upsert({ teamId, platform, resource, externalRef, roleRef, state, removeAfter, lastError, syncedAt }) { await query( `INSERT INTO team_integrations (team_id, platform, resource, external_ref, role_ref, state, remove_after, last_error, synced_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) ON DUPLICATE KEY UPDATE external_ref = VALUES(external_ref), role_ref = VALUES(role_ref), state = VALUES(state), remove_after = VALUES(remove_after), last_error = VALUES(last_error), synced_at = VALUES(synced_at)`, [ Number(teamId), platform, resource, externalRef || null, roleRef || null, state, removeAfter || null, lastError ? String(lastError).slice(0, 500) : null, syncedAt || null, ], ) return getForTeam(teamId, platform, resource) } async function remove(teamId, platform, resource) { const res = await query( 'DELETE FROM team_integrations WHERE team_id = ? AND platform = ? AND resource = ?', [Number(teamId), platform, resource], ) return Number(res && res.affectedRows) || 0 } /** How many rows currently hold a role — the input to the 250-role ceiling. */ async function roleCount(platform) { const rows = await query( 'SELECT COUNT(*) AS n FROM team_integrations WHERE platform = ? AND role_ref IS NOT NULL', [platform], ) return Number(rows[0] && rows[0].n) || 0 } module.exports = { DISCORD_PROVIDER, desiredTeams, holdersWithoutClaim, discordSubjectsFor, listForPlatform, getForTeam, upsert, remove, roleCount, }