Files
website/server/src/router/v1/admin/teams.controller.js
wtclaude 3f7e61af1c feat(teams): the phase 5 surface — discussion, replies, reports, and two admin screens
241 client tests pass (224 before).

**The forum panel becomes a forum.** It was "Announcements" with one composer;
it now has two, because phase 5 split one server capability into two: `canPost`
means "may open a discussion" and every participant may — a granted guest with no
game character included, which is path 3 doing its job — while `canAnnounce` is
the leader-only half `canPost` used to carry alone. Threads gain replies, an edit
control, per-post moderation and a report control, all still inside the one slot
the module declares, still navigating by `?thread=`.

**Almost nothing here is the client's decision, and the file says so.** `canPost`,
`canAnnounce`, `canReply` and each post's `canEdit`/`editableUntil` are read, not
computed. The one local judgement is a ticking clock that WITHDRAWS an edit offer
whose deadline passed while the page sat open — it can never grant one, because a
time-bounded permission must not take its clock from the party it bounds. That
asymmetry is the first thing client/test/teamForum.test.js asserts.

The panel's pure parts moved to `lib/teamForum.js` so they can be tested without a
browser, following teamActivity.js and teamAdmin.js. Two of them are subtler than
they look:

  * `stripToText` decodes entities AFTER stripping tags, and `&` last of all.
    Decoding first turns an author's literal "<script>" into a real tag the
    strip pass then deletes — silently losing text that was never dangerous.
  * `threadSummary` counts REPLIES, which is one fewer than `postCount`. Showing
    the raw count tells a reader a brand-new thread already has one reply.

**Three admin surfaces.** The forum settings screen gains the edit-window field
(0 = posts permanent once written). The reports queue is a new screen beside
Appeals — under moderation rather than under Teams, because a staffer working a
queue should have one place to work and `target_type` is deliberately open-ended,
so the next reportable thing arrives as a row rather than as another nav entry.
Its copy tells a member where a report lands and that reporting changes nothing,
because a member who expects a post to vanish and watches it stay reports it
again. There is no leader-facing view and there is not meant to be.

And the per-Team forum moderation ledger finally renders: the route and
`api.admin.teamForumModeration()` have both existed since phase 4 with nothing
calling them, which made `actor_role` — the column that keeps a leader's ordinary
housekeeping distinguishable from a staff intervention — readable only from a DB
client.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-18 13:08:59 -05:00

286 lines
9.5 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 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,
}