feat(teams): phase 9 — one voice channel per Team, granted by a role #159
Reference in New Issue
Block a user
No description provided.
Delete Branch "feature/teams-phase9-voice-channels"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Phase 9 of
docs/website/TEAMS.md. Every Team above the operator's size threshold gets a voice channel of its own in Discord, visible and joinable by its members and nobody else, kept in step by a reconciler that rides the Team reconcile it already depends on.Pairs with docs#163.
ONE code repo, not the plan's
website+botbotis a workspace insidewebsite— the same correction phases 7 and 8 made. Nothing here belongs to a module: the resource is core's own and the grant set comes from core's own identity chain, somodule-uois untouched andMODULE_API_VERSIONdoes not move.Org-lead decisions taken before building
Roles-always moves the ceiling, and that is the part worth reading
A limit on the number of Teams is one an operator has to be told about before they hit it, so the panel reports the guild's role count against the cap and the pass refuses the create rather than letting Discord reject it. The count comes from the bot, not from core's own rows: the cap is shared with every role the operator made themselves.
voice_overwrite_max, the escalation and themodecolumn all leave the design.Three things §7.3 named that this codebase does not have
All settled by asking the operator, because nothing in the data model can answer.
guild_configknows a news channel, a modlog channel, an autorole and a filter allowlist, and none of them means staff. Nowteams_voice_staff_roles, a list of role ids, empty by default and a perfectly ordinary answer: guild administrators bypass channel overwrites already, so what was actually missing is a way to let non-admin staff in. A role the operator has since deleted is filtered by the bot rather than sent, because Discord rejects an entire overwrite set for one bad id — which would take the Team's own grant down with it.team_integrations.team_idisNOT NULL, so it cannot be a row in there. The bot creates it and the server stores the id in settings — written by the server after the fact, not typed by an admin into a field they could point at something that is not a category.Two more, decided rather than asked
teams.member_count, and deliberately notlinked_count— which counts hop 1 (has a site account) and is a third quantity again from §7.3's "linked". The grant set is hop 3: a site account and a Discord identity and present in the guild. A member missing the last is skipped silently — §2.6 hop 3 without hop 4 is an ordinary state, not a hundred log lines.utils/reservedNames.jsalready names "and eventually a Discord channel name" among the surfaces it protects. The screen that suppresses a Team's page suppresses its channel, and a Team that becomes hidden takes the grace window. The interlock costs onehidden = 0in one query rather than a second policy that could drift. The name published isdisplay_name_override || name, since §2.8.3 makes that the display surface's answer.Switching voice off tears nothing down. The pass suspends in both directions and existing channels are left standing, inert; the panel says how many remain and offers to remove them one at a time. A checkbox must not delete structure in somebody's guild, and per-row removal is also the only way to clean up while voice is off.
The live rig
Real MariaDB on a throwaway database, the real app with a real Team provider registered, and a fake standing in for Discord that mounts the bot's real internal routes — so everything from the internal-key middleware through the controller and
bot/src/discord/teamVoice.jswas production code. 47 assertions.It proved: the preflight refusing an enable three different ways, and the panel still rendering with a broken bot; a category, role and channel created with
@everyonedenied and the Team role allowed; the hidden Team and the below-threshold Team getting nothing; the role granted to the two members in the guild and not to the one who linked Discord without joining it; a drop below the threshold scheduling a removal with zero Discord calls; a recovery inside the window keeping the same channel id; an expired window deleting the channel and the role and forgetting the row; a stale projection suspending the pass in both directions with the row not even scheduled; voice switched off leaving channels standing; an admin removal working anyway, and 404 for a Team that has none.Unlike §7.2's DDL, this table applied to real MariaDB exactly as written.
It found two defects, both fixed with regression tests in the second commit.
desiredTeamsandholdersWithoutClaimboth selectt.id AS team_id, and the shared column list addedi.team_idbeside it — which themariadbdriver refuses outright. The pass died at its first query, on the one code path every unit test stubs. It was also the wrong column:desiredTeamsLEFT JOINs, soi.team_idis NULL for exactly the Teams that have no channel yet. The regression test checks the interpolated sql captured from a fakequery— a first attempt that read the source text passed happily with the bug put back, because in the source the shared list is still a${COLUMNS}placeholder.runningand got "a pass is already running", which the panel renders as nothing having happened, while the pass they just triggered created their channels. A pass in flight is now joined and its real outcome returned, asteamSync.reconcileNowdoes.Two things outside this phase that it had to work around
npm run swaggercould not run at all onedge— so this phase could not produce its own required artifact. Phase 8 shippedparam('teamId').custom((v) => … || /^[0-9]+$/.test(v)), and a regex literal followed directly by.test(makes swagger-autogen's parser run away until the process dies out of memory. Hoisted to a const, asmodules.router.jsalready does. Underneath it,teams.router.jssits exactly at that parser's per-file limit: at twentyteamsRouter.*statements it dies and at nineteen it generates, and one more statement of any shape tips it — an unannotated route does, and so does a bareuse. The same probe route indiscordBot.router.jsis fine, so the limit is per-file. HenceteamsVoice.router.js, mounted fromadmin/index.jsat the more specific prefix so/teams/voice/*never reaches/:id.last_success_atis written by MariaDB'sNOW()and compared against JSDate.now(). An app process and a database in different timezones therefore skew every staleness judgement by the offset — on this machine, five hours in the direction that makes a stale projection read as fresh. That moves §3's public freshness banner as much as this phase's suspension. Pre-existing and not fixed here; recorded because it is invisible until something depends on it.Also fixed, because this phase would otherwise have introduced it: the require cycle
teamSync → teamVoiceSync → teams.model → teamSync, which leftteams.modelholding the reconciler's exports object as it stood mid-load — the empty one, sincemodule.exports = {…}replaces rather than fills. The symptom is not in the new code: it isteamSync.intervalSeconds is not a functionthrown out ofsyncStatus(), the freshness banner on every public Team page.Tests
1162 server (+42), 53 bot (+21), 284 client (+21). Swagger,
routes.manifest.jsonandroutes.guards.jsonregenerated — the four new routes' guard shape is byte-identical to the existing admin-only ones.Not done here
No real Discord guild was involved. A live
guild.channels.create,roles.createandmembers.fetchare what this walk could not exercise — the same gap phases 7 and 8 recorded.🤖 Generated with Claude Code
https://claude.ai/code/session_01WnDSWzpUjw8t8C2hghysNz
TEAMS.md §7.3. Each qualifying Team gets a Discord voice channel of its own and a role that opens it, kept in step by a reconciler that rides the Team reconcile it already depends on. Access is a per-Team ROLE, always. §7.3 designed per-member overwrites with escalation to a role above ~90 members; the org lead settled on roles always (2026-08-18), which deletes `voice_overwrite_max`, the escalation and the `mode` column — and moves the ceiling. Overwrites are capped per channel, so the old shape's limit was "how big can one Team be"; roles are capped per guild at 250, so the new one is "how many Teams can have voice at all". That is a limit an operator must be told about before they hit it, so the panel reports it and the pass refuses the create rather than letting Discord do it. Three things §7.3 named that this codebase does not have, all settled by asking the operator because nothing in the data model can answer: - "the staff role" — there is no staff-role concept anywhere. Now a list of role ids the admin designates; empty is a normal answer, since guild administrators bypass overwrites and what is really missing is a way to let NON-admin staff in. - the parent category — §7.3 said the bot creates it and gave the id nowhere to live (`team_integrations.team_id` is NOT NULL). The bot creates it and the server stores the id in settings. - whether the bot can act at all — nothing has ever checked. The operator invites the bot by hand and no invite URL with a permission integer exists in the tree, so a deployment can be one unticked box from every call failing. A preflight is now a PRECONDITION to enabling (422), not a per-Team error discovered afterwards. Two more, decided rather than asked: - the threshold counts every active member, not linked ones. §7.3 wrote `voice_min_linked_members`; the operator is judging whether a Team is real, and link state answers a different question. - hidden Teams are never provisioned. A channel name is a game-sourced string published outside the site, which is exactly §2.8's concern — reservedNames.js already names "and eventually a Discord channel name" as a surface it protects — so the screen that suppresses a Team's page suppresses its channel, and a Team that becomes hidden takes the grace window. Turning voice OFF tears nothing down: the pass suspends in both directions and the panel offers per-row removal. A checkbox must not delete structure in somebody's guild. Fixes a phase 8 defect that blocks this phase's own artifact: `npm run swagger` has been unable to run on `edge` at all. `param('teamId').custom((v) => ... || /^[0-9]+$/.test(v))` makes swagger-autogen's parser run away — a regex literal followed directly by `.test(`. Hoisted to a const, as modules.router.js already does. Underneath it, `teams.router.js` sits exactly at that parser's per-file limit: at twenty `teamsRouter.*` statements it dies, at nineteen it generates, and one more statement of ANY shape tips it — an unannotated route does, and so does a bare `use`. So the voice routes are their own router file mounted from `admin/index.js`, and teams.router.js keeps its nineteen. Also breaks a require cycle this phase would have introduced: teamSync -> teamVoiceSync -> teams.model -> teamSync left `teams.model` holding the reconciler's exports object as it stood mid-load — the empty one, since `module.exports = {…}` replaces rather than fills. The symptom is not in the new code: it is `teamSync.intervalSeconds is not a function` thrown out of `syncStatus()`, the freshness banner on every public Team page. Tests: 1160 server (+40), 53 bot (+21), 284 client (+21). Swagger, routes manifest and guards regenerated; the guard shape of the four new routes is byte-identical to the existing admin-only ones. Co-Authored-By: Claude <noreply@anthropic.com>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>