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

@@ -1353,6 +1353,53 @@ CREATE TABLE IF NOT EXISTS team_integration_config (
CONSTRAINT fk_tic_ack_by FOREIGN KEY (members_ack_by) REFERENCES users(id) ON DELETE SET NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- ── Per-Team external resources: the voice channel (TEAMS.md §7.3, phase 9) ─
--
-- One row per (Team, platform, resource). Today the only resource is 'voice',
-- and the column exists because the NEXT one — a text channel, a Matrix room —
-- is the same lifecycle with a different noun, and phase 10's capability
-- registry needs somewhere to say which resources a platform declares.
--
-- **Access is a per-Team ROLE, not per-member overwrites.** §7.3 designed
-- overwrites-by-default with escalation to a role above ~90 members; the org lead
-- settled on roles always (2026-08-18). That deletes `voice_overwrite_max` and the
-- mode transition, and it moves the ceiling: the binding limit is no longer ~100
-- overwrites on one channel but Discord's guild-wide cap of 250 roles, which the
-- admin panel surfaces rather than letting a create fail into `state='error'`.
-- `role_ref` is therefore NOT the escalation artefact it was in §7.3 — it is the
-- grant itself, and a row with a channel and no role is a broken row.
--
-- **Two external refs, two lifetimes, and the pair is why this is a table rather
-- than two columns on `teams`.** A channel can be deleted in Discord while the
-- role survives, and vice versa; the reconciler has to be able to say "the role is
-- there, the channel is not" and repair one without touching the other.
--
-- `state` is core's belief about Discord, never Discord's own answer: the
-- reconciler writes what it just did, and the next pass re-derives the truth. A
-- Team dropping below the threshold goes to 'pending_removal' with `remove_after`
-- set rather than being deleted at once (§7.3's grace window) — a Team hovering
-- around the threshold would otherwise delete-and-recreate, changing the channel
-- id and breaking every pinned link to it, and a voice channel holds no message
-- history, so the window costs nothing to keep.
CREATE TABLE IF NOT EXISTS team_integrations (
id INT AUTO_INCREMENT PRIMARY KEY,
team_id INT NOT NULL,
platform VARCHAR(32) NOT NULL, -- 'discord'; opaque here, a registry key in phase 10
resource VARCHAR(32) NOT NULL, -- 'voice'
external_ref VARCHAR(64) NULL, -- the channel id
role_ref VARCHAR(64) NULL, -- the Team's own role; the grant itself, not an escalation
state ENUM('none','active','pending_removal','error') NOT NULL DEFAULT 'none',
remove_after DATETIME NULL, -- set with 'pending_removal'; the grace window's expiry
last_error VARCHAR(500) NULL,
synced_at DATETIME NULL, -- last pass that reached Discord and was believed
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
UNIQUE KEY uq_team_integration (team_id, platform, resource),
-- Expiry is swept across every Team, so the index is on the pair the sweep
-- filters by rather than on the Team the unique key already covers.
INDEX idx_ti_pending (state, remove_after),
CONSTRAINT fk_ti_team FOREIGN KEY (team_id) REFERENCES teams(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- Migrations for databases created before the wiki upgrade. Each statement uses
-- IF NOT EXISTS so re-running on every boot is a harmless no-op. New installs get
-- these columns from the CREATE TABLE above; existing installs get them here.