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