feat(moderation): appeals (6c) + Discord reversal on approve (6d)
All checks were successful
PR Checks / server-tests (pull_request) Successful in 9m59s
PR Checks / client-build (pull_request) Successful in 9m32s
PR Checks / bot-install (pull_request) Successful in 9m37s

Players whose linked Discord identity was banned or muted can now submit
an appeal from the portal and track it; staff get a queue in the admin
moderation section to claim and resolve (approve/deny) appeals. Approving
a ban/mute appeal best-effort asks the Discord bot to reverse the action
(unban / clear timeout) via the internal API and posts a mod-log embed; a
down bot never fails the resolution (reversal_status is recorded).

- Schema: new server-owned `appeals` table (no cross-owner FK to
  mod_actions; existence validated in app code).
- Server: model/appeals/* + player appeals controller (submit/mine/
  eligible/withdraw) and admin queue handlers (list/claim/resolve/
  per-user) under the existing admin+moderator gate; one-active-appeal
  enforced app-side; eligibility keyed on the caller's linked Discord id.
- 6d: bot POST /internal/mod-reverse (+ modLog.postReversal) and
  server botInternalClient.reverseModAction, wired into resolve().
- Client: admin Appeals queue + resolve modal, ModerationUser appeals
  tab, player Appeals page (submit/withdraw), nav + routes + api methods.
- Docs: swagger annotations + component schemas, regenerated output.
- Tests: appeals controller + pure suites (server npm test 224 green).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XmHdsbnLzDMAVQkAoTQSBe
This commit is contained in:
2026-07-18 22:01:06 -05:00
parent 5f09ab1146
commit 028ba8c5e4
23 changed files with 3084 additions and 9 deletions

View File

@@ -1147,6 +1147,73 @@ adminRouter.post(
moderation.addUserNote,
)
// ── Appeals queue (Phase 6c, admin + moderator) ───────────────────────
// Staff triage of player-submitted ban/mute appeals. Approving an appeal can
// trigger an automatic Discord reversal (Phase 6d) — see resolveAppeal.
adminRouter.get(
'/moderation/appeals',
// #swagger.tags = ['Admin · Moderation']
// #swagger.summary = 'List moderation appeals (default: pending + under_review)'
// #swagger.description = 'Filter with ?status=<pending|under_review|approved|denied|withdrawn> or ?status=all. Paginated with ?limit&offset.'
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
/* #swagger.responses[200] = { description: 'Appeals queue', content: { "application/json": { schema: { type: "array", items: { $ref: "#/components/schemas/AppealQueueItem" } } } } } */
moderation.getAppeals,
)
adminRouter.get(
'/moderation/appeals/:id',
// #swagger.tags = ['Admin · Moderation']
// #swagger.summary = 'Get a single moderation appeal'
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
// #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'Appeal id.' }
/* #swagger.responses[200] = { description: 'The appeal', content: { "application/json": { schema: { $ref: "#/components/schemas/AppealQueueItem" } } } } */
/* #swagger.responses[404] = { description: 'Appeal not found', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
param('id').isInt({ min: 1 }),
validate,
moderation.getAppeal,
)
adminRouter.post(
'/moderation/appeals/:id/claim',
// #swagger.tags = ['Admin · Moderation']
// #swagger.summary = 'Claim a pending appeal (→ under_review)'
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
// #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'Appeal id.' }
/* #swagger.responses[200] = { description: 'The claimed appeal', content: { "application/json": { schema: { $ref: "#/components/schemas/AppealQueueItem" } } } } */
/* #swagger.responses[404] = { description: 'Appeal not found', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
/* #swagger.responses[409] = { description: 'Appeal is not open for claiming', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
param('id').isInt({ min: 1 }),
validate,
moderation.claimAppeal,
)
adminRouter.post(
'/moderation/appeals/:id/resolve',
// #swagger.tags = ['Admin · Moderation']
// #swagger.summary = 'Resolve an appeal (approved | denied); approval may auto-reverse the Discord action'
// #swagger.description = 'Approving a ban/mute appeal best-effort asks the bot to reverse the Discord action (unban / clear timeout). The bot being down never fails the resolution — reversal_status is recorded as failed. The response echoes the updated appeal plus a `reversal` object.'
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
// #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'Appeal id.' }
/* #swagger.requestBody = { required: true, content: { "application/json": { schema: { $ref: "#/components/schemas/ResolveAppealRequest" } } } } */
/* #swagger.responses[200] = { description: 'The resolved appeal (with reversal outcome)', content: { "application/json": { schema: { $ref: "#/components/schemas/AppealResolveResult" } } } } */
/* #swagger.responses[400] = { description: 'Validation error (status must be approved or denied)', content: { "application/json": { schema: { $ref: "#/components/schemas/ValidationError" } } } } */
/* #swagger.responses[404] = { description: 'Appeal not found', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
/* #swagger.responses[409] = { description: 'Appeal is already resolved', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
param('id').isInt({ min: 1 }),
body('status').isIn(['approved', 'denied']),
body('staff_response').optional({ values: 'falsy' }).isString().trim().isLength({ max: 4000 }),
validate,
moderation.resolveAppeal,
)
adminRouter.get(
'/moderation/user/:discordId/appeals',
// #swagger.tags = ['Admin · Moderation']
// #swagger.summary = 'Appeals submitted for a Discord user'
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
// #swagger.parameters['discordId'] = { in: 'path', required: true, schema: { type: 'string' }, description: 'Discord snowflake.' }
/* #swagger.responses[200] = { description: 'Appeals for the user', content: { "application/json": { schema: { type: "array", items: { $ref: "#/components/schemas/AppealQueueItem" } } } } } */
param('discordId').matches(/^[0-9]{1,32}$/),
validate,
moderation.getUserAppeals,
)
// ── User management (admin only) ──────────────────────────────────────
adminRouter.use('/users', adminOnly)
adminRouter.get(

View File

@@ -5,10 +5,19 @@
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
@@ -156,6 +165,136 @@ async function addUserNote(req, res) {
}
}
// ── 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,
@@ -167,4 +306,9 @@ module.exports = {
getUserActions,
getUserNotes,
addUserNote,
getAppeals,
getAppeal,
claimAppeal,
resolveAppeal,
getUserAppeals,
}