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>
491 lines
17 KiB
JavaScript
491 lines
17 KiB
JavaScript
// Admin · Teams — the staff surface (TEAMS.md §2.11).
|
|
//
|
|
// The role split inside this file is the §2.9 gate, and it is enforced HERE
|
|
// rather than in the router, because it is not a matter of which routes a role
|
|
// may call: a moderator may call all of them, and three of them mean something
|
|
// different when they do. `requestOrApply` is what decides, from the caller's
|
|
// live role, whether an action applies or is filed for approval.
|
|
|
|
const teams = require('../../../model/teams/teams.model')
|
|
const moderation = require('../../../model/teams/teamModeration.model')
|
|
const access = require('../../../model/teams/teamAccess.model')
|
|
const teamSync = require('../../../model/teams/teamSync.model')
|
|
const teamsDb = require('../../../model/teams/teams.db')
|
|
const activity = require('../../../model/activity/activity.model')
|
|
const forum = require('../../../model/teams/teamForum.model')
|
|
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')
|
|
|
|
const fail = (res, err, what) => {
|
|
log.error(`admin teams: ${what} failed`, { message: err.message })
|
|
return res.status(500).json({ message: 'Internal Server Error' })
|
|
}
|
|
|
|
/** Translate a model result's { ok, status, error } into a response. */
|
|
const send = (res, result, body = { ok: true }) =>
|
|
(result.ok ? res.json({ ...body, ...result }) : res.status(result.status || 400).json({ message: result.error }))
|
|
|
|
async function listTeams(req, res) {
|
|
try {
|
|
return res.json(await teams.listAdmin({ includeArchived: req.query.archived === '1' }))
|
|
} catch (err) {
|
|
return fail(res, err, 'list')
|
|
}
|
|
}
|
|
|
|
async function getTeam(req, res) {
|
|
try {
|
|
const team = await teams.getAdmin(Number(req.params.id))
|
|
if (!team) return res.status(404).json({ message: 'Team not found' })
|
|
return res.json(team)
|
|
} catch (err) {
|
|
return fail(res, err, 'get')
|
|
}
|
|
}
|
|
|
|
/**
|
|
* The operator's escape hatch.
|
|
*
|
|
* Awaited rather than fire-and-forget: someone who pressed a button is owed the
|
|
* outcome, including the provider's error when it refused. `ctx.teams.reconcile()`
|
|
* is the debounced, unawaited path — this is not that.
|
|
*/
|
|
async function resync(req, res) {
|
|
try {
|
|
const result = await teamSync.reconcileNow('admin')
|
|
await activity.log({ req, action: 'team.resync', detail: `${req.user.username} (#${req.user.id}) ran a Team resync` })
|
|
return res.json(result)
|
|
} catch (err) {
|
|
return fail(res, err, 'resync')
|
|
}
|
|
}
|
|
|
|
async function archive(req, res) {
|
|
try {
|
|
const id = Number(req.params.id)
|
|
const team = await teamsDb.findById(id)
|
|
if (!team) return res.status(404).json({ message: 'Team not found' })
|
|
await teamsDb.archiveTeam(id, 'staff')
|
|
await activity.log({
|
|
req,
|
|
action: 'team.archive',
|
|
detail: `${req.user.username} (#${req.user.id}) archived team "${team.name}" (#${id})`
|
|
+ `${req.body.reason ? `: "${req.body.reason}"` : ''}`,
|
|
})
|
|
return res.json({ ok: true })
|
|
} catch (err) {
|
|
return fail(res, err, 'archive')
|
|
}
|
|
}
|
|
|
|
async function grants(req, res) {
|
|
try {
|
|
return res.json({ grants: await access.grantLedger(Number(req.params.id)) })
|
|
} catch (err) {
|
|
return fail(res, err, 'grants')
|
|
}
|
|
}
|
|
|
|
// ── Forum: the ledger and the upload attribution view (§5.4) ──────────────
|
|
|
|
/**
|
|
* A Team's forum moderation ledger.
|
|
*
|
|
* Served whether or not the forum is switched on, unlike every /player forum
|
|
* route. The switch guards the forum as a FEATURE — what members can read and
|
|
* write — and an operator who turned it off to deal with a problem is precisely
|
|
* the operator who needs to see what was moderated (§5.5.1: no data is deleted).
|
|
*/
|
|
async function forumModeration(req, res) {
|
|
try {
|
|
const id = Number(req.params.id)
|
|
const team = await teamsDb.findById(id)
|
|
if (!team) return res.status(404).json({ message: 'Team not found' })
|
|
return res.json({ entries: await forum.moderationLedger(id, { limit: 200 }) })
|
|
} catch (err) {
|
|
return fail(res, err, 'forum moderation')
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Who uploaded what, when, and how much — across every Team.
|
|
*
|
|
* This view is the reason §5.5.4 added an attribution table at all: the
|
|
* acknowledgement an operator gives before enabling uploads is meaningless if the
|
|
* question it makes them responsible for cannot be answered afterwards.
|
|
*/
|
|
async function forumUploads(req, res) {
|
|
try {
|
|
return res.json({
|
|
uploads: await forumDb.listUploads({
|
|
limit: Number(req.query.limit) || 100,
|
|
offset: Number(req.query.offset) || 0,
|
|
includeDeleted: req.query.deleted === '1',
|
|
}),
|
|
quota: {
|
|
dailyBytes: forumUploadsModel.DAILY_QUOTA_BYTES,
|
|
retentionDays: forumUploadsModel.RETENTION_DAYS,
|
|
},
|
|
})
|
|
} catch (err) {
|
|
return fail(res, err, 'forum uploads')
|
|
}
|
|
}
|
|
|
|
/** The forum settings' own state — the acknowledgement, which is not a public key. */
|
|
async function forumSettingsState(req, res) {
|
|
try {
|
|
return res.json({
|
|
enabled: await forumSettings.forumsEnabled(),
|
|
imageMode: await forumSettings.imageMode(),
|
|
// Served here rather than published as a public setting: the client that
|
|
// needs the NUMBER is the settings screen, and the client that needs the
|
|
// DECISION already gets it per post as `canEdit`/`editableUntil`. Publishing
|
|
// the window would invite a client to compute the permission itself, which
|
|
// is the one thing a time-bounded permission must not let the bounded party
|
|
// do.
|
|
editWindowMinutes: await forumSettings.editWindowMinutes(),
|
|
editWindowMax: forumSettings.EDIT_WINDOW_MAX,
|
|
acknowledgement: await forumSettings.ackState(),
|
|
})
|
|
} catch (err) {
|
|
return fail(res, err, 'forum settings')
|
|
}
|
|
}
|
|
|
|
// ── Leadership overrides (§2.5.1) — NOT gated ─────────────────────────────
|
|
|
|
async function setLeaderOverride(req, res) {
|
|
try {
|
|
const id = Number(req.params.id)
|
|
const team = await teamsDb.findById(id)
|
|
if (!team) return res.status(404).json({ message: 'Team not found' })
|
|
|
|
const { memberKey, effect, reason } = req.body
|
|
await access.setLeaderOverride({
|
|
teamId: id,
|
|
memberKey,
|
|
effect,
|
|
actorUserId: req.user.id,
|
|
actorUsername: req.user.username,
|
|
reason: reason || null,
|
|
})
|
|
await activity.log({
|
|
req,
|
|
action: 'team.leader.override',
|
|
detail: `${req.user.username} (#${req.user.id}) set a "${effect}" leadership override on `
|
|
+ `${memberKey} in team "${team.name}" (#${id})${reason ? `: "${reason}"` : ''}`,
|
|
})
|
|
return res.json({ ok: true })
|
|
} catch (err) {
|
|
return fail(res, err, 'leader-override')
|
|
}
|
|
}
|
|
|
|
async function clearLeaderOverride(req, res) {
|
|
try {
|
|
const id = Number(req.params.id)
|
|
const removed = await access.clearLeaderOverride(id, req.params.memberKey)
|
|
if (!removed) return res.status(404).json({ message: 'No such override' })
|
|
await activity.log({
|
|
req,
|
|
action: 'team.leader.override',
|
|
detail: `${req.user.username} (#${req.user.id}) cleared the leadership override on `
|
|
+ `${req.params.memberKey} in team #${id}`,
|
|
})
|
|
return res.json({ ok: true })
|
|
} catch (err) {
|
|
return fail(res, err, 'leader-override')
|
|
}
|
|
}
|
|
|
|
// ── The three gated actions, plus the ungated hide (§2.9) ─────────────────
|
|
|
|
async function unhide(req, res) {
|
|
try {
|
|
return send(res, await moderation.requestOrApply({
|
|
req, actor: req.user, teamId: Number(req.params.id), action: 'unhide', reason: req.body.reason,
|
|
}))
|
|
} catch (err) {
|
|
return fail(res, err, 'unhide')
|
|
}
|
|
}
|
|
|
|
async function hide(req, res) {
|
|
try {
|
|
return send(res, await moderation.hide({
|
|
req, actor: req.user, teamId: Number(req.params.id), reason: req.body.reason,
|
|
}))
|
|
} catch (err) {
|
|
return fail(res, err, 'hide')
|
|
}
|
|
}
|
|
|
|
async function displayName(req, res) {
|
|
try {
|
|
const { displayName: value, reason } = req.body
|
|
// An empty string is how a UI says "clear it", and clearing is its own gated
|
|
// action rather than an override set to nothing — otherwise the audit line
|
|
// would read as though someone published a blank name.
|
|
const action = value ? 'display_name_override' : 'clear_display_name_override'
|
|
return send(res, await moderation.requestOrApply({
|
|
req, actor: req.user, teamId: Number(req.params.id), action, payload: { displayName: value || null }, reason,
|
|
}))
|
|
} catch (err) {
|
|
return fail(res, err, 'display-name')
|
|
}
|
|
}
|
|
|
|
async function reviewQueue(req, res) {
|
|
try {
|
|
return res.json({ teams: await moderation.reviewQueue() })
|
|
} catch (err) {
|
|
return fail(res, err, 'review queue')
|
|
}
|
|
}
|
|
|
|
async function listRequests(req, res) {
|
|
try {
|
|
return res.json({ requests: await moderation.listRequests({ status: req.query.status || 'pending' }) })
|
|
} catch (err) {
|
|
return fail(res, err, 'requests')
|
|
}
|
|
}
|
|
|
|
async function decideRequest(req, res) {
|
|
try {
|
|
return send(res, await moderation.decide({
|
|
req, actor: req.user, requestId: Number(req.params.id), status: req.body.status, note: req.body.note,
|
|
}))
|
|
} catch (err) {
|
|
return fail(res, err, 'decide')
|
|
}
|
|
}
|
|
|
|
// ── The integration bridge (§7.2, phase 8) — admin only ───────────────────
|
|
//
|
|
// Admin-only at the ROUTER, unlike everything above it. The §2.9 gate exists
|
|
// because a moderator's action publishes untrusted game strings to the public
|
|
// site; this is a different risk in the other direction — it decides that
|
|
// members-only forum text leaves the site altogether, for a destination core
|
|
// cannot see. That is a deployment-configuration decision, and it sits with the
|
|
// role that holds the bot token rather than with the queue.
|
|
|
|
async function integrationConfig(req, res) {
|
|
try {
|
|
return res.json({
|
|
platform: integration.DISCORD,
|
|
events: integration.BRIDGEABLE.map((id) => ({ id, membersOnly: integration.isMembersOnly(id) })),
|
|
rows: await integration.list(integration.DISCORD),
|
|
})
|
|
} catch (err) {
|
|
return fail(res, err, 'integration config')
|
|
}
|
|
}
|
|
|
|
async function saveIntegrationConfig(req, res) {
|
|
try {
|
|
// `teamId` null is the deployment default and is a legitimate body, so the
|
|
// absent-vs-null distinction matters: a PUT with no teamId edits the default.
|
|
const teamId = req.body.teamId === undefined || req.body.teamId === null ? null : Number(req.body.teamId)
|
|
if (teamId !== null && !(await teamsDb.findById(teamId))) {
|
|
return res.status(404).json({ message: 'Team not found' })
|
|
}
|
|
|
|
const row = await integration.save(
|
|
{
|
|
platform: integration.DISCORD,
|
|
teamId,
|
|
events: req.body.events,
|
|
channelRef: req.body.channelRef,
|
|
enabled: req.body.enabled,
|
|
membersAck: req.body.membersAck,
|
|
},
|
|
req.user.id,
|
|
)
|
|
|
|
await activity.log({
|
|
req,
|
|
action: 'team.integration.save',
|
|
detail:
|
|
`${req.user.username} (#${req.user.id}) saved the ${integration.DISCORD} bridge for ` +
|
|
`${teamId === null ? 'all Teams (default)' : `Team #${teamId}`}: ` +
|
|
`${row.enabled ? 'enabled' : 'disabled'}, events [${row.events.join(', ')}]` +
|
|
`${row.members_ack ? ', members-only destination acknowledged' : ''}`,
|
|
})
|
|
|
|
return res.json(row)
|
|
} catch (err) {
|
|
// A validation refusal carries its own status and its own wording — the
|
|
// acknowledgement message in particular is the whole explanation of why the
|
|
// save was refused, and collapsing it into a 500 would leave the operator
|
|
// with a screen that will not save and no reason given.
|
|
if (err.status) return res.status(err.status).json({ message: err.message, code: err.code })
|
|
return fail(res, err, 'save integration config')
|
|
}
|
|
}
|
|
|
|
async function deleteIntegrationConfig(req, res) {
|
|
try {
|
|
const teamId = req.params.teamId === 'default' ? null : Number(req.params.teamId)
|
|
const removed = await integration.remove(integration.DISCORD, teamId)
|
|
if (removed === 0) return res.status(404).json({ message: 'No configuration for that Team' })
|
|
await activity.log({
|
|
req,
|
|
action: 'team.integration.delete',
|
|
detail:
|
|
`${req.user.username} (#${req.user.id}) removed the ${integration.DISCORD} bridge for ` +
|
|
`${teamId === null ? 'all Teams (default)' : `Team #${teamId}`}`,
|
|
})
|
|
return res.json({ ok: true })
|
|
} catch (err) {
|
|
return fail(res, err, 'delete integration config')
|
|
}
|
|
}
|
|
|
|
// ── 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,
|
|
forumModeration,
|
|
forumUploads,
|
|
forumSettingsState,
|
|
listTeams,
|
|
getTeam,
|
|
resync,
|
|
archive,
|
|
grants,
|
|
setLeaderOverride,
|
|
clearLeaderOverride,
|
|
unhide,
|
|
hide,
|
|
displayName,
|
|
reviewQueue,
|
|
listRequests,
|
|
decideRequest,
|
|
}
|