feat(moderation): appeals (6c) + Discord reversal on approve (6d)
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:
@@ -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,
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user