Files
website/server/src/router/v1/admin/moderation.controller.js
wtclaude 41a1e84b51
All checks were successful
PR Checks / bot-tests (pull_request) Successful in 36s
PR Checks / server-tests (pull_request) Successful in 6m11s
PR Checks / client-build (pull_request) Successful in 49s
feat(safety): report, block and hide, MODULE_API 1.21.0
USER_SAFETY.md step 2 (core), with S11-S14 decided while building it.

- Reports: POST /player/reports takes any target type, core's forum
  types or a module's registered ones. One resolver
  (model/safety/targets.js) answers "what is this item, for this
  reader" and 404s an item the reader cannot see. Every report stores
  content_reports.snapshot; the queue shows a module item from it.
- Hide for everyone: POST /admin/moderation/reports/:id/hide hides the
  item through its own moderation (forum hide as staff, or the module's
  hide) and actions the report with actioned_with = 'hide'.
- Blocks (user_blocks) name an item, never a person, and land on the
  author's account first (S12); an unlinked game identity is blocked as
  itself and becomes an account block once linked (S14). Staff cannot
  be blocked; a block on someone later promoted stops applying (S8).
- Hide for me (user_hidden_items), /auth/me/blocks and /auth/me/hidden.
- Forum reads filter blocked authors and hidden items on the server and
  carry canBlock per thread and post.
- MODULE_API 1.21.0: api.registerReportTargets,
  api.registerGameIdentities, ctx.safety.forViewer and
  ctx.safety.staffAmong, so a module filters its own per-viewer feeds on
  the server (S11) and marks lines canBlock (S13).
- Web: Hide for me / Block author in the forum menus, a Blocked section
  on both account pages, Hide for everyone in the Reports queue.
- Tests, OpenAPI, route/guard/engagement manifests regenerated.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
2026-10-30 05:41:00 -05:00

409 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, viewer: req.user }),
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' })
}
}
/**
* Hide the reported item for everyone, and action the report (USER_SAFETY.md
* §4.1). The item goes through its own hide — the forum's moderation, or the
* module's — so this is a staff moderation of the content and is audited as one.
*/
async function hideReportedItem(req, res) {
try {
const result = await contentReports.hideForEveryone({
id: Number(req.params.id),
actor: req.user,
note: req.body.note,
})
if (!result.ok) return res.status(result.status || 400).json({ message: result.error })
const r = result.report
await activity.log({
req,
action: 'moderation.report.hide',
detail: `${req.user.username} (#${req.user.id}) hid ${r.targetType} #${r.targetId} for everyone `
+ `from report #${req.params.id}${req.body.note ? `: "${req.body.note}"` : ''}`,
})
return res.json(r)
} catch (err) {
log.error('hideReportedItem 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,
hideReportedItem,
}