// 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 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') } } module.exports = { forumModeration, forumUploads, forumSettingsState, listTeams, getTeam, resync, archive, grants, setLeaderOverride, clearLeaderOverride, unhide, hide, displayName, reviewQueue, listRequests, decideRequest, }