PR 4 of the in-place admin router split (docs/website/API_V2_PLAN.md § Phase 2),
and the last admin one: it moves the entire residual 33 and DELETES
admin.routes.js. Every one of the 110 admin routes is now declared in a
capability router. No URL, gate or handler changes.
shard.router.js (16) /admin/shard
uoLink.router.js ( 5) /admin/uo-link
email.router.js ( 6) /admin/email
discordBot.router.js ( 2) /admin/discord-bot
settings.router.js ( 2) /admin/settings
dashboard.router.js ( 2) GET /dashboard + PUT /site-mode, at the group root
admin.routes.js deleted, was 33
No gate moved to router level. Every adminOnly in the residual file was
per-route, and modAccess on /shard must stay per-route because half that router
must not have it — which keeps the per-route handler count intact, the one
number routes.guards.json can actually check.
/shard is the first prefix where two tiers share one router: 7 self-service
account-linking routes (no extra gate, served by the same player/shard
controller handlers, tagged `Admin · Account`) alongside 9 in-game staff ops on
modAccess. Prefix ownership beats tag grouping — splitting by tag would put two
routers under one prefix for no gain. The tag mismatch stays; retagging is a
real spec diff and belongs in a PR about tags.
dashboard.router.js is the one router mounted at the group root rather than a
prefix: GET /dashboard and PUT /site-mode share no path segment. That is safe
only because the file declares no router-level middleware — a bare use(gate) in
a root-mounted router would run for every request passing through toward
another mount. The file carries a comment saying so.
Acceptance — all four gates zero-diff:
routes.manifest.json unchanged (200 public + 2 internal)
routes.guards.json unchanged (no route lost or gained a gate)
swagger-output.json unchanged (198 operations)
api-route-inventory.json already in sync
plus 434 server tests green.
Verified separately, because no gate can catch it: introspecting the built
stack, all 59 literal admin paths still dispatch to their own layer — nothing
is captured first by a /:param sibling. The manifest sorts its entries, so
declaration order is invisible to it.
Also repoints the comments that referenced admin.routes.js by name
(botActivity/moderation controllers, the town-crier cap mirror in
announceJobs.logic.js) and generalizes the "the path is on the line after
router.get(" rationale in routeManifest.js, README.md and pr-checks.yml, which
was never about that one file.
Co-Authored-By: Claude <noreply@anthropic.com>
315 lines
11 KiB
JavaScript
315 lines
11 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 { 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' })
|
|
}
|
|
}
|
|
|
|
module.exports = {
|
|
getSummary,
|
|
getRecent,
|
|
search,
|
|
getMembers,
|
|
getFilterHits,
|
|
getSpamHits,
|
|
getUser,
|
|
getUserActions,
|
|
getUserNotes,
|
|
addUserNote,
|
|
getAppeals,
|
|
getAppeal,
|
|
claimAppeal,
|
|
resolveAppeal,
|
|
getUserAppeals,
|
|
}
|