TEAMS.md §5.6. **Core has had no user-facing report flow of any kind** — the `moderation`, `mod_notes` and `appeals` tables are all either staff-initiated or Discord-sanction-shaped, and nothing anywhere let a member say "this is a problem". That was survivable while every piece of content on the site came from staff; phase 5 lets players write to each other, so it stops being. The gap has a specific shape: leaders moderate their own Team's forum, and a Team's leaders are exactly the people who will not report their own Team. So the whole point of this queue is a path that routes AROUND a Team's own leadership. Org lead settled it on 2026-08-18: **reports are site administration only** — there is no leader-facing view of this queue, not even a read-only one scoped to their own Team. §5.6's "a leader may also see and act on reports for their own Team" is not implemented and is not deferred. `content_reports` is deliberately generic — `target_type` is a VARCHAR so a wiki page or a news comment becomes a value rather than a table — and the queue is mounted beside appeals under /admin/moderation rather than under Teams, because a staffer working a queue should have one place to work. **§5.6's literal unique key has a defect and this does not copy it.** Written as (target_type, target_id, reporter_user_id, status) it makes CLOSED rows collide with each other too: reporter reports a post, staff dismiss it, the behaviour recurs, they report again — and the second dismissal is an UPDATE into a tuple that already exists, so working the queue starts throwing duplicate-key errors on the first repeat reporter. The key is on a generated `open_marker` instead, the same trick `team_forum_grants.active_marker` uses: 1 while open, NULL once closed, and NULLs are distinct — which is what §5.6's prose asks for, "one open report per (target, reporter)". Two other departures from the doc, both small and both flagged in the docs PR: `handled_note`, because a queue whose resolution reason lives only in an activity_log line is one where the next staffer to see a repeat report cannot find out why the last was dismissed; and a CASCADE on `team_id`, so a deleted Team does not leave a queue full of reports about content that no longer exists. Also here: a report is filed against a target the model verifies really belongs to the Team the request came through, or the queue's per-Team filter would quietly be lying; the queue resolves every row's target in three batched reads rather than N+1, which is §5.6's fourth rule (uploader, size and sniffed type without hunting) actually paying for §5.5.4's attribution table; a target that has since been hard-deleted comes back null and the report still lists, because "somebody reported this and by the time we looked it was gone" is a fact a moderator needs; and every transition writes activity_log, `dismissed` included — a queue where acting is audited and declining to act is not is one where the cheapest way to make a report vanish leaves no trace. `teams_forum_edit_window_minutes` gains its range validation on the admin settings PUT and is seeded at 15, so the value on the settings screen is the value in force. Route manifest and OpenAPI regenerated: 6 operations added, 0 lost. Co-Authored-By: Claude <noreply@anthropic.com>
380 lines
14 KiB
JavaScript
380 lines
14 KiB
JavaScript
// Admin moderation dashboard (Phase 6). Read-only views over the bot's
|
|
// mod_actions log plus server-owned staff notes. Mounted behind the
|
|
// admin+moderator RBAC gate (see moderation.router.js). The only mutation here is
|
|
// adding a staff note; admin_only notes are further restricted to the admin role.
|
|
const moderation = require('../../../model/moderation/moderation.model')
|
|
const modNotes = require('../../../model/modNotes/modNotes.model')
|
|
const modNotesDb = require('../../../model/modNotes/modNotes.db')
|
|
const appeals = require('../../../model/appeals/appeals.model')
|
|
const contentReports = require('../../../model/reports/contentReports.model')
|
|
const { isTerminal, isAppealableType, reversalStatusFor } = require('../../../model/appeals/appeals.pure')
|
|
const botInternalClient = require('../../../utils/botInternalClient')
|
|
const activity = require('../../../model/activity/activity.model')
|
|
|
|
const log = require('../../../utils/logger')('moderation')
|
|
|
|
// The statuses the appeals queue can be filtered to. ?status=all expands to all
|
|
// of them; a specific ?status=<value> narrows to one; the default is the open
|
|
// set (pending + under_review) that still needs staff attention.
|
|
const APPEAL_STATUSES = ['pending', 'under_review', 'approved', 'denied', 'withdrawn']
|
|
const DEFAULT_APPEAL_STATUSES = ['pending', 'under_review']
|
|
|
|
const VALID_TYPES = new Set(['ban', 'kick', 'mute', 'warn'])
|
|
const MAX_LIMIT = 200
|
|
const DEFAULT_LIMIT = 50
|
|
|
|
// Parse ?limit/&offset the same way the activity log does: numeric, capped.
|
|
function pageParams(req) {
|
|
const limit = Math.min(Number(req.query.limit) || DEFAULT_LIMIT, MAX_LIMIT)
|
|
const offset = Number(req.query.offset) || 0
|
|
return { limit, offset }
|
|
}
|
|
|
|
// Optional ?type filter — ignored unless it is a known action type.
|
|
function typeParam(req) {
|
|
const t = req.query.type
|
|
return VALID_TYPES.has(t) ? t : null
|
|
}
|
|
|
|
function isAdmin(req) {
|
|
return req.user && req.user.role === 'admin'
|
|
}
|
|
|
|
async function getSummary(req, res) {
|
|
try {
|
|
return res.json(await moderation.summary())
|
|
} catch (err) {
|
|
log.error('summary failed', { error: err.message })
|
|
return res.status(500).json({ message: 'Internal Server Error' })
|
|
}
|
|
}
|
|
|
|
async function getRecent(req, res) {
|
|
try {
|
|
const { limit, offset } = pageParams(req)
|
|
return res.json(await moderation.recent({ type: typeParam(req), limit, offset }))
|
|
} catch (err) {
|
|
log.error('recent failed', { error: err.message })
|
|
return res.status(500).json({ message: 'Internal Server Error' })
|
|
}
|
|
}
|
|
|
|
async function search(req, res) {
|
|
try {
|
|
const term = (req.query.q || '').trim()
|
|
if (!term) return res.json([])
|
|
return res.json(await moderation.search(term, { limit: 20 }))
|
|
} catch (err) {
|
|
log.error('search failed', { error: err.message })
|
|
return res.status(500).json({ message: 'Internal Server Error' })
|
|
}
|
|
}
|
|
|
|
// ── Phase 6b event feeds ──────────────────────────────────────────────
|
|
const MEMBER_TYPES = new Set(['join', 'leave'])
|
|
|
|
async function getMembers(req, res) {
|
|
try {
|
|
const { limit, offset } = pageParams(req)
|
|
const t = MEMBER_TYPES.has(req.query.type) ? req.query.type : null
|
|
return res.json(await moderation.members({ type: t, limit, offset }))
|
|
} catch (err) {
|
|
log.error('members failed', { error: err.message })
|
|
return res.status(500).json({ message: 'Internal Server Error' })
|
|
}
|
|
}
|
|
|
|
async function getFilterHits(req, res) {
|
|
try {
|
|
const { limit, offset } = pageParams(req)
|
|
return res.json(await moderation.filterHits({ limit, offset }))
|
|
} catch (err) {
|
|
log.error('filterHits failed', { error: err.message })
|
|
return res.status(500).json({ message: 'Internal Server Error' })
|
|
}
|
|
}
|
|
|
|
async function getSpamHits(req, res) {
|
|
try {
|
|
const { limit, offset } = pageParams(req)
|
|
return res.json(await moderation.spamHits({ limit, offset }))
|
|
} catch (err) {
|
|
log.error('spamHits failed', { error: err.message })
|
|
return res.status(500).json({ message: 'Internal Server Error' })
|
|
}
|
|
}
|
|
|
|
async function getUser(req, res) {
|
|
try {
|
|
const summary = await moderation.userSummary(req.params.discordId)
|
|
const notesCount = await modNotesDb.countForUser(req.params.discordId, {
|
|
includeAdminOnly: isAdmin(req),
|
|
})
|
|
return res.json({ ...summary, notes_count: notesCount })
|
|
} catch (err) {
|
|
log.error('getUser failed', { error: err.message })
|
|
return res.status(500).json({ message: 'Internal Server Error' })
|
|
}
|
|
}
|
|
|
|
async function getUserActions(req, res) {
|
|
try {
|
|
const { limit, offset } = pageParams(req)
|
|
return res.json(
|
|
await moderation.userActions(req.params.discordId, { type: typeParam(req), limit, offset }),
|
|
)
|
|
} catch (err) {
|
|
log.error('getUserActions failed', { error: err.message })
|
|
return res.status(500).json({ message: 'Internal Server Error' })
|
|
}
|
|
}
|
|
|
|
async function getUserNotes(req, res) {
|
|
try {
|
|
const notes = await modNotes.listForUser(req.params.discordId, {
|
|
includeAdminOnly: isAdmin(req),
|
|
})
|
|
return res.json(notes)
|
|
} catch (err) {
|
|
log.error('getUserNotes failed', { error: err.message })
|
|
return res.status(500).json({ message: 'Internal Server Error' })
|
|
}
|
|
}
|
|
|
|
async function addUserNote(req, res) {
|
|
try {
|
|
const visibility = req.body.visibility === 'admin_only' ? 'admin_only' : 'staff_only'
|
|
// admin_only notes can carry sensitive judgement calls — restrict to admins.
|
|
if (visibility === 'admin_only' && !isAdmin(req)) {
|
|
return res.status(403).json({ message: 'Only admins can add admin-only notes' })
|
|
}
|
|
const note = await modNotes.add({
|
|
discordUserId: req.params.discordId,
|
|
author: req.user,
|
|
body: req.body.body,
|
|
visibility,
|
|
})
|
|
await activity.log({
|
|
req,
|
|
action: 'moderation.note.add',
|
|
detail: { discordUserId: req.params.discordId, visibility },
|
|
})
|
|
return res.status(201).json(note)
|
|
} catch (err) {
|
|
log.error('addUserNote failed', { error: err.message })
|
|
return res.status(500).json({ message: 'Internal Server Error' })
|
|
}
|
|
}
|
|
|
|
// ── Phase 6c: appeals staff queue ─────────────────────────────────────
|
|
// The status filter for the queue: ?status=all → every status, ?status=<one> →
|
|
// just that one (if valid), otherwise the default open set.
|
|
function appealStatusFilter(req) {
|
|
const s = req.query.status
|
|
if (s === 'all') return APPEAL_STATUSES
|
|
if (APPEAL_STATUSES.includes(s)) return [s]
|
|
return DEFAULT_APPEAL_STATUSES
|
|
}
|
|
|
|
async function getAppeals(req, res) {
|
|
try {
|
|
const { limit, offset } = pageParams(req)
|
|
const statuses = appealStatusFilter(req)
|
|
return res.json(await appeals.queue({ statuses, limit, offset }))
|
|
} catch (err) {
|
|
log.error('getAppeals failed', { error: err.message })
|
|
return res.status(500).json({ message: 'Internal Server Error' })
|
|
}
|
|
}
|
|
|
|
async function getAppeal(req, res) {
|
|
try {
|
|
const appeal = await appeals.getById(Number(req.params.id))
|
|
if (!appeal) return res.status(404).json({ message: 'Appeal not found' })
|
|
return res.json(appeal)
|
|
} catch (err) {
|
|
log.error('getAppeal failed', { error: err.message })
|
|
return res.status(500).json({ message: 'Internal Server Error' })
|
|
}
|
|
}
|
|
|
|
// Claim a pending appeal → under_review, stamping the claiming staffer. Only a
|
|
// still-pending appeal can be claimed (a second claim, or claiming a resolved
|
|
// one, is a 409).
|
|
async function claimAppeal(req, res) {
|
|
try {
|
|
const appeal = await appeals.getById(Number(req.params.id))
|
|
if (!appeal) return res.status(404).json({ message: 'Appeal not found' })
|
|
if (appeal.status !== 'pending') {
|
|
return res.status(409).json({ message: 'Appeal is not open for claiming' })
|
|
}
|
|
const updated = await appeals.claim(appeal.id, {
|
|
handlerUserId: req.user.id,
|
|
handlerTag: req.user.username,
|
|
})
|
|
await activity.log({
|
|
req,
|
|
action: 'moderation.appeal.claim',
|
|
detail: { appealId: appeal.id, discordUserId: appeal.discord_user_id },
|
|
})
|
|
return res.json(updated)
|
|
} catch (err) {
|
|
log.error('claimAppeal failed', { error: err.message })
|
|
return res.status(500).json({ message: 'Internal Server Error' })
|
|
}
|
|
}
|
|
|
|
// Resolve an appeal (approved | denied). On an APPROVED ban/mute we best-effort
|
|
// ask the bot to reverse the Discord action (unban / clear timeout). The bot
|
|
// being down never fails the resolution — we record reversal_status='failed'
|
|
// and still close the appeal. The response echoes the updated appeal plus a
|
|
// `reversal` object describing what was attempted.
|
|
async function resolveAppeal(req, res) {
|
|
try {
|
|
const status = req.body.status
|
|
const staffResponse = req.body.staff_response ?? null
|
|
|
|
const appeal = await appeals.getById(Number(req.params.id))
|
|
if (!appeal) return res.status(404).json({ message: 'Appeal not found' })
|
|
if (isTerminal(appeal.status)) {
|
|
return res.status(409).json({ message: 'Appeal is already resolved' })
|
|
}
|
|
|
|
// Best-effort Discord reversal only for an approved, appealable action.
|
|
const shouldReverse = status === 'approved' && isAppealableType(appeal.action_type)
|
|
let botResult = null
|
|
if (shouldReverse) {
|
|
botResult = await botInternalClient.reverseModAction({
|
|
discordUserId: appeal.discord_user_id,
|
|
actionType: appeal.action_type,
|
|
appealId: appeal.id,
|
|
})
|
|
}
|
|
|
|
const reversalStatus = reversalStatusFor({
|
|
status,
|
|
actionType: appeal.action_type,
|
|
botOk: botResult ? botResult.ok : false,
|
|
})
|
|
|
|
const updated = await appeals.resolve(appeal.id, {
|
|
status,
|
|
staffResponse,
|
|
handlerUserId: req.user.id,
|
|
handlerTag: req.user.username,
|
|
reversalStatus,
|
|
})
|
|
|
|
await activity.log({
|
|
req,
|
|
action: 'moderation.appeal.resolve',
|
|
detail: { appealId: appeal.id, status, reversalStatus },
|
|
})
|
|
|
|
// Describe the reversal so the UI can show "unban succeeded / failed / n/a".
|
|
const reversal = {
|
|
attempted: shouldReverse,
|
|
ok: botResult ? botResult.ok : false,
|
|
reversal_status: reversalStatus,
|
|
bot_status: botResult ? botResult.status : null,
|
|
error: botResult && !botResult.ok ? botResult.error || null : null,
|
|
}
|
|
|
|
return res.json({ ...updated, reversal })
|
|
} catch (err) {
|
|
log.error('resolveAppeal failed', { error: err.message })
|
|
return res.status(500).json({ message: 'Internal Server Error' })
|
|
}
|
|
}
|
|
|
|
async function getUserAppeals(req, res) {
|
|
try {
|
|
return res.json(await appeals.listForDiscordUser(req.params.discordId))
|
|
} catch (err) {
|
|
log.error('getUserAppeals failed', { error: err.message })
|
|
return res.status(500).json({ message: 'Internal Server Error' })
|
|
}
|
|
}
|
|
|
|
// ── Content reports (TEAMS.md §5.6) ───────────────────────────────────────
|
|
//
|
|
// Mounted here rather than under Teams, and that placement is the design: a
|
|
// staffer working a queue should have one place to work, and a report about a
|
|
// forum post is the same job as a report about anything else. `target_type` is a
|
|
// VARCHAR precisely so the next consumer — a wiki page, a news comment — arrives
|
|
// as a value in this same queue and not as a second screen.
|
|
//
|
|
// **This is the only view of the queue that exists.** Team leaders have no
|
|
// report-facing surface at all, because the gap §5.6 closes is that a Team's
|
|
// leaders are exactly the people who will not report their own Team. Org lead,
|
|
// 2026-08-18: reports are site administration only.
|
|
|
|
async function getContentReports(req, res) {
|
|
try {
|
|
const { limit, offset } = pageParams(req)
|
|
const status = typeof req.query.status === 'string' ? req.query.status : undefined
|
|
if (status && status !== 'all' && !contentReports.STATUSES.includes(status)) {
|
|
return res.status(400).json({ message: 'Unknown report status' })
|
|
}
|
|
const teamId = Number(req.query.teamId) || undefined
|
|
return res.json({
|
|
reports: await contentReports.queue({ status, teamId, limit, offset }),
|
|
openCount: await contentReports.openCount(),
|
|
})
|
|
} catch (err) {
|
|
log.error('getContentReports failed', { error: err.message })
|
|
return res.status(500).json({ message: 'Internal Server Error' })
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Move a report along the queue.
|
|
*
|
|
* Every transition writes `activity_log`, including `dismissed` — especially
|
|
* `dismissed`. A queue where acting is audited and declining to act is not is one
|
|
* where the cheapest way to make a report disappear leaves no trace, and the
|
|
* reports most worth auditing are exactly the ones somebody wanted gone.
|
|
*/
|
|
async function handleContentReport(req, res) {
|
|
try {
|
|
const result = await contentReports.handle({
|
|
id: Number(req.params.id),
|
|
actor: req.user,
|
|
status: req.body.status,
|
|
note: req.body.note,
|
|
})
|
|
if (!result.ok) return res.status(result.status || 400).json({ message: result.error })
|
|
|
|
await activity.log({
|
|
req,
|
|
action: 'moderation.report.handle',
|
|
detail: `${req.user.username} (#${req.user.id}) set report #${req.params.id} to ${req.body.status}`
|
|
+ `${req.body.note ? `: "${req.body.note}"` : ''}`,
|
|
})
|
|
return res.json(result.report)
|
|
} catch (err) {
|
|
log.error('handleContentReport failed', { error: err.message })
|
|
return res.status(500).json({ message: 'Internal Server Error' })
|
|
}
|
|
}
|
|
|
|
module.exports = {
|
|
getSummary,
|
|
getRecent,
|
|
search,
|
|
getMembers,
|
|
getFilterHits,
|
|
getSpamHits,
|
|
getUser,
|
|
getUserActions,
|
|
getUserNotes,
|
|
addUserNote,
|
|
getAppeals,
|
|
getAppeal,
|
|
claimAppeal,
|
|
resolveAppeal,
|
|
getUserAppeals,
|
|
getContentReports,
|
|
handleContentReport,
|
|
}
|