feat(teams): phase 9 — one voice channel per Team, granted by a role

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>
This commit is contained in:
2026-08-18 23:49:28 -05:00
parent d1d56cf847
commit 61abb3ec89
26 changed files with 4214 additions and 4 deletions

View File

@@ -32,6 +32,7 @@ const discordBotRouter = require('./discordBot.router')
const settingsRouter = require('./settings.router')
const modulesRouter = require('./modules.router')
const teamsRouter = require('./teams.router')
const teamsVoiceRouter = require('./teamsVoice.router')
const dashboardRouter = require('./dashboard.router')
const adminRouter = express.Router()
@@ -84,6 +85,16 @@ adminRouter.use('/modules', modulesRouter)
// queue. The three actions that PUBLISH untrusted game-sourced strings are gated
// per request inside the controller, not per route — a moderator may call them,
// and calling them files a request rather than applying one (TEAMS.md §2.9).
// Voice channels (TEAMS.md §7.3, phase 9) are mounted at the more specific prefix
// FIRST, so /teams/voice/* never reaches the teams router's `/:id`.
//
// They live out here rather than inside `teams.router.js` beside the bridge they
// belong with, for a mechanical reason worth recording: that file sits exactly at
// swagger-autogen's per-file limit. At twenty `teamsRouter.*` statements
// `npm run swagger` dies with "invalid array length — heap out of memory"; at
// nineteen it generates. One more statement of any shape tips it, a mount
// included, so the mount is here and the file keeps its nineteen.
adminRouter.use('/teams/voice', teamsVoiceRouter)
adminRouter.use('/teams', teamsRouter)
// The two singletons that own no path segment of their own: GET /dashboard and

View File

@@ -17,6 +17,9 @@ const forumDb = require('../../../model/teams/teamForum.db')
const forumUploadsModel = require('../../../model/teams/teamForumUploads.model')
const forumSettings = require('../../../model/teams/teamForumSettings.model')
const integration = require('../../../model/teams/teamIntegration.model')
const voice = require('../../../model/teams/teamVoice.model')
const voiceSettings = require('../../../model/teams/teamVoiceSettings.model')
const voiceSync = require('../../../utils/teamVoiceSync')
const log = require('../../../utils/logger')('teams')
@@ -347,7 +350,124 @@ async function deleteIntegrationConfig(req, res) {
}
}
// ── Voice channels (§7.3, phase 9) — admin only ────────────────────────────
//
// Admin-only for the same reason the bridge is: this creates and destroys
// structure in somebody's Discord guild, which is deployment configuration and
// not the kind of decision §2.9 files a request for.
/**
* Everything the panel renders, in one call: the settings, the live rows, and
* the bot's own answer about whether it can do the job.
*
* The preflight is here rather than behind a separate endpoint the panel polls,
* because it is not a detail — an operator whose bot lacks Manage Roles has a
* screen full of controls that cannot work, and finding that out needs to be the
* first thing on the page rather than the result of pressing something.
*/
async function voiceConfig(req, res) {
try {
const [config, rows, flight] = await Promise.all([
voiceSettings.all(),
voice.list(),
// Never fatal: a bot container that is down must not take the settings
// screen with it, since fixing the settings may be exactly why the operator
// came. `preflight` already turns every failure into a `ready: false`.
voiceSync.preflight().catch((err) => ({ ready: false, connected: false, reason: err.message })),
])
return res.json({
platform: voice.PLATFORM,
settings: config,
preflight: flight,
rows,
lastPass: voiceSync.lastPass(),
})
} catch (err) {
return fail(res, err, 'voice config')
}
}
/**
* Save the settings, with one precondition.
*
* **Switching voice ON is refused 422 while the bot cannot act.** The same shape
* §7.2's acknowledgement takes, and for the same reason: a setting that saves and
* then quietly does nothing is worse than one that will not save. Turning it OFF
* is never gated — an operator disabling a feature because it is misbehaving must
* not be blocked by the misbehaviour.
*/
async function saveVoiceConfig(req, res) {
try {
const turningOn = req.body.enabled === true && !(await voiceSettings.enabled())
if (turningOn) {
const flight = await voiceSync.preflight()
if (!flight.ready) {
return res.status(422).json({
message: flight.reason || 'the bot cannot manage channels and roles in this guild yet',
code: 'voice_preflight_failed',
preflight: flight,
})
}
}
const config = await voiceSettings.save(req.body, req.user.id)
await activity.log({
req,
action: 'team.voice.settings',
detail:
`${req.user.username} (#${req.user.id}) saved the Team voice settings: `
+ `${config.enabled ? 'enabled' : 'disabled'}, minimum ${config.minMembers} members, `
+ `${config.graceDays}-day grace window, ${config.staffRoles.length} staff role(s)`,
})
// A save that just switched it on should not wait fifteen minutes for the
// first channel to appear.
if (config.enabled) voiceSync.request({ reason: 'settings saved' })
return res.json(config)
} catch (err) {
if (err.status) return res.status(err.status).json({ message: err.message, code: err.code })
return fail(res, err, 'save voice config')
}
}
/** Run a pass now, awaited, so the operator gets the outcome and not a promise. */
async function voicePass(req, res) {
try {
return res.json(await voiceSync.passNow('admin'))
} catch (err) {
return fail(res, err, 'voice pass')
}
}
/**
* Remove one Team's channel and role now, ignoring the grace window.
*
* The window exists to stop churn on a Team crossing the threshold twice in a
* week; an operator pressing remove is not churn. It is also the only way to
* clean up while voice is switched off, which is the one state where no pass will
* ever reach the row.
*/
async function removeVoice(req, res) {
try {
const teamId = Number(req.params.teamId)
const result = await voiceSync.removeNow(teamId)
if (!result.ok) return res.status(result.status || 400).json({ message: result.message })
await activity.log({
req,
action: 'team.voice.remove',
detail: `${req.user.username} (#${req.user.id}) removed the voice channel and role for Team #${teamId}`,
})
return res.json({ ok: true })
} catch (err) {
return fail(res, err, 'remove voice')
}
}
module.exports = {
voiceConfig,
saveVoiceConfig,
voicePass,
removeVoice,
integrationConfig,
saveIntegrationConfig,
deleteIntegrationConfig,

View File

@@ -27,6 +27,14 @@ const teamsRouter = express.Router()
// where a Team's events leave the site for is not the §2.9 kind of decision a
// moderator files a request for; it is deployment configuration, and it sits with
// the role that already holds the bot token.
// Hoisted rather than written inline, and it has to stay that way: a regex
// LITERAL followed directly by `.test(` makes swagger-autogen's static parser run
// away, and `npm run swagger` dies with "invalid array length — heap out of
// memory" instead of generating a spec. Phase 8 shipped it inline and left the
// generator unable to run at all; the same regex reached through a const (the
// idiom `modules.router.js` already uses) parses fine.
const TEAM_ID = /^[0-9]+$/
const adminOnly = requireRole('admin')
// ── Literal paths, first ───────────────────────────────────────────────────
@@ -173,7 +181,7 @@ teamsRouter.delete(
/* #swagger.responses[200] = { description: 'Removed', content: { "application/json": { schema: { $ref: "#/components/schemas/OkResponse" } } } } */
/* #swagger.responses[404] = { description: 'Nothing configured for that Team', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
adminOnly,
param('teamId').custom((v) => v === 'default' || /^[0-9]+$/.test(v)),
param('teamId').custom((v) => v === 'default' || TEAM_ID.test(v)),
validate,
ctrl.deleteIntegrationConfig,
)

View File

@@ -0,0 +1,93 @@
// Admin · Teams · Voice channels (TEAMS.md §7.3, phase 9).
//
// Mounted at /api/v1/admin/teams/voice by `admin/index.js`, which has already
// applied `noindex, isLoggedIn, staffOnly` above it — and which mounts this
// prefix BEFORE `/teams`, so these paths never reach the teams router's `/:id`.
// Every route here adds `adminOnly` on top: this creates and destroys structure
// in somebody's Discord guild, which is deployment configuration and not the §2.9
// kind of decision a moderator files a request for.
//
// **Its own file for a mechanical reason, and the reason is worth recording.**
// These four routes belong beside the notification bridge's three in
// `teams.router.js`, and they started there. That file sits exactly at
// swagger-autogen's per-file limit: at twenty `teamsRouter.*` statements
// `npm run swagger` dies with "invalid array length — heap out of memory", and at
// nineteen it generates. ONE more statement of any shape tips it — a route with no
// annotations at all does, and so does a bare `use`, which is why the mount is in
// `admin/index.js` rather than here in the file it logically belongs to. The same
// probe route added to `discordBot.router.js` generates fine, so the limit is
// per-file and not tree-wide.
//
// So: if this file grows, split it again rather than moving it back.
const express = require('express')
const { body, param } = require('express-validator')
const ctrl = require('./teams.controller')
const validate = require('../../../middleware/validate')
const { requireRole } = require('../../../utils/auth')
const voiceRouter = express.Router()
const adminOnly = requireRole('admin')
// `/sync` before `/:teamId`, the same first-match-wins rule the parent file
// follows: a `:teamId` declared first would turn the pass into a lookup for a Team
// whose id is "sync".
voiceRouter.get(
'/',
// #swagger.tags = ['Admin · Teams']
// #swagger.summary = 'Team voice channel configuration and state (admin only)'
// #swagger.description = 'The settings, every provisioned channel with its state and last error, and the bots own preflight — whether it is connected, whether it holds Manage Channels and Manage Roles, and how close the guild is to Discords cap of 250 roles. Access is granted with a role per Team, so that cap is the ceiling on how many Teams can have voice at all.'
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
/* #swagger.responses[200] = { description: 'Voice configuration and state', content: { "application/json": { schema: { $ref: "#/components/schemas/TeamVoiceConfig" } } } } */
/* #swagger.responses[403] = { description: 'Admin role required', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
adminOnly,
ctrl.voiceConfig,
)
voiceRouter.put(
'/',
// #swagger.tags = ['Admin · Teams']
// #swagger.summary = 'Save the Team voice settings (admin only)'
// #swagger.description = 'Switching voice on is refused 422 while the bot cannot manage channels and roles in the guild — a setting that saves and then quietly does nothing is worse than one that will not save. Switching it off is never gated, and never tears anything down: existing channels stop being reconciled and are removed one at a time by an operator who means it.'
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
/* #swagger.responses[200] = { description: 'The saved settings', content: { "application/json": { schema: { $ref: "#/components/schemas/TeamVoiceSettings" } } } } */
/* #swagger.responses[422] = { description: 'The bot cannot manage channels or roles yet', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
adminOnly,
body('enabled').optional().isBoolean().toBoolean(),
body('minMembers').optional().isInt({ min: 1, max: 10000 }).toInt(),
body('graceDays').optional().isInt({ min: 0, max: 90 }).toInt(),
body('staffRoles').optional({ nullable: true }),
validate,
ctrl.saveVoiceConfig,
)
voiceRouter.post(
'/sync',
// #swagger.tags = ['Admin · Teams']
// #swagger.summary = 'Run a voice reconciliation now (admin only)'
// #swagger.description = 'Awaited, so the response carries the outcome. The three suspensions still apply — a manual pass will not run while voice is off, while the Team projection is stale, or while the bot cannot act — and the response says which one stopped it.'
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
/* #swagger.responses[200] = { description: 'The pass result', content: { "application/json": { schema: { $ref: "#/components/schemas/TeamVoicePassResult" } } } } */
adminOnly,
ctrl.voicePass,
)
voiceRouter.delete(
'/:teamId',
// #swagger.tags = ['Admin · Teams']
// #swagger.summary = 'Remove one Teams voice channel and role (admin only)'
// #swagger.description = 'Immediate, ignoring the grace window: the window exists to stop churn on a Team that crosses the threshold twice in a week, and an operator pressing remove is not churn. The channel and the role go together — a role for a channel that no longer exists is a badge for nowhere.'
// #swagger.parameters['teamId'] = { in: 'path', required: true, schema: { type: 'integer' } }
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
/* #swagger.responses[200] = { description: 'Removed', content: { "application/json": { schema: { $ref: "#/components/schemas/OkResponse" } } } } */
/* #swagger.responses[404] = { description: 'That Team has no voice channel', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
adminOnly,
param('teamId').isInt({ min: 1 }).toInt(),
validate,
ctrl.removeVoice,
)
module.exports = voiceRouter