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