Files
website/server/src/model/teams/teamVoice.db.js
wtclaude f72c92ffbe
All checks were successful
PR Checks / client-build (pull_request) Successful in 30s
PR Checks / bot-tests (pull_request) Successful in 30s
PR Checks / server-tests (pull_request) Successful in 2m33s
fix(teams): the two defects the phase 9 rig walk found
Walked against real MariaDB, the real app, and a fake standing in for Discord
that mounts the bot's real internal routes — everything up to the Discord API
call was production code. 47 assertions, and it found two things every unit
test in the phase had passed over.

1. **Every query failed: two result columns named `team_id`.** `desiredTeams`
   and `holdersWithoutClaim` both select `t.id AS team_id`, and the shared
   column list added `i.team_id` beside it. The `mariadb` driver refuses a
   result set with a repeated field name outright, so the pass died at its
   first query with "Error in results, duplicate field name `team_id`" — on the
   one code path every unit test stubs.

   It was also the wrong column: `desiredTeams` LEFT JOINs, so `i.team_id` is
   NULL for exactly the Teams that have no channel yet, which is the create
   case. The two queries that do not join `teams` now ask for it by name.

   The regression test checks the INTERPOLATED sql captured from a fake
   `query`, not the source text — in the source the shared list is still a
   `${COLUMNS}` placeholder, and a first attempt that read the file passed
   happily with the bug reintroduced.

2. **"Sync now" said "Nothing was done" while it was doing it.** Saving the
   settings with voice switched on asks for a pass. An operator who then
   presses Sync now — the obvious next thing — hit `running` and got back
   `ran: false, reason: "a pass is already running"`, which the panel renders
   as nothing having happened, while the pass they triggered was busy creating
   their channels. A pass in flight is now JOINED and its real outcome
   returned, the same choice `teamSync.reconcileNow` makes for the same reason.

Tests: 1162 server (+2), 53 bot, 284 client.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-19 00:07:27 -05:00

214 lines
8.6 KiB
JavaScript

// 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,
}