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:
156
server/src/model/appeals/appeals.db.js
Normal file
156
server/src/model/appeals/appeals.db.js
Normal file
@@ -0,0 +1,156 @@
|
||||
// Data-access for the server-owned `appeals` table (Phase 6c). Mirrors the
|
||||
// modNotes/moderation split: this module is the only place that touches the
|
||||
// table's SQL. Reads LEFT JOIN the bot-owned mod_actions row (no hard FK — the
|
||||
// join is by the plain mod_action_id column) to surface the original action's
|
||||
// target/reason/created_at, and LEFT JOIN users to surface the submitter's
|
||||
// username. All writes belong to the site (the bot never touches this table).
|
||||
const { query } = require('../../utils/db')
|
||||
|
||||
// Shared SELECT for a single appeal enriched with the originating action + the
|
||||
// submitting account. ma.* columns are null when the mod_action was purged.
|
||||
const APPEAL_SELECT = `
|
||||
SELECT a.id, a.mod_action_id, a.discord_user_id, a.action_type, a.user_id,
|
||||
a.status, a.submitted_text, a.staff_response,
|
||||
a.handled_by_user_id, a.handled_by_tag, a.reversal_status,
|
||||
a.submitted_at, a.resolved_at,
|
||||
ma.target_tag AS action_target_tag,
|
||||
ma.reason AS action_reason,
|
||||
ma.created_at AS action_created_at,
|
||||
ma.duration_seconds AS action_duration_seconds,
|
||||
submitter.username AS submitter_username
|
||||
FROM appeals a
|
||||
LEFT JOIN mod_actions ma ON ma.id = a.mod_action_id
|
||||
LEFT JOIN users submitter ON submitter.id = a.user_id`
|
||||
|
||||
async function insert({ modActionId, discordUserId, actionType, userId = null, submittedText }) {
|
||||
const res = await query(
|
||||
`INSERT INTO appeals (mod_action_id, discord_user_id, action_type, user_id, submitted_text)
|
||||
VALUES (?, ?, ?, ?, ?)`,
|
||||
[modActionId, discordUserId, actionType, userId, submittedText],
|
||||
)
|
||||
return res.insertId
|
||||
}
|
||||
|
||||
async function getById(id) {
|
||||
const rows = await query(`${APPEAL_SELECT} WHERE a.id = ? LIMIT 1`, [id])
|
||||
return rows[0] || null
|
||||
}
|
||||
|
||||
// The caller's own appeals, newest first (My Appeals page).
|
||||
async function listForUser(userId) {
|
||||
return query(`${APPEAL_SELECT} WHERE a.user_id = ? ORDER BY a.id DESC`, [userId])
|
||||
}
|
||||
|
||||
// All appeals for a Discord id (admin per-user view), newest first.
|
||||
async function listForDiscordUser(discordUserId) {
|
||||
return query(`${APPEAL_SELECT} WHERE a.discord_user_id = ? ORDER BY a.id DESC`, [discordUserId])
|
||||
}
|
||||
|
||||
// The staff queue: filtered to a set of statuses (array), newest first, paged.
|
||||
// An empty `statuses` returns nothing rather than the whole table.
|
||||
async function listQueue({ statuses = [], limit = 50, offset = 0 } = {}) {
|
||||
if (!statuses.length) return []
|
||||
const placeholders = statuses.map(() => '?').join(', ')
|
||||
return query(
|
||||
`${APPEAL_SELECT} WHERE a.status IN (${placeholders})
|
||||
ORDER BY a.submitted_at ASC, a.id ASC
|
||||
LIMIT ? OFFSET ?`,
|
||||
[...statuses, limit, offset],
|
||||
)
|
||||
}
|
||||
|
||||
// The active (pending/under_review) appeal for a mod_action, or null. Used to
|
||||
// enforce one-active-appeal-per-action.
|
||||
async function activeForAction(modActionId) {
|
||||
const rows = await query(
|
||||
`SELECT id, status FROM appeals
|
||||
WHERE mod_action_id = ? AND status IN ('pending','under_review')
|
||||
LIMIT 1`,
|
||||
[modActionId],
|
||||
)
|
||||
return rows[0] || null
|
||||
}
|
||||
|
||||
// The caller's ban/mute mod_actions that have NO active appeal — the set of
|
||||
// actions the player is allowed to open an appeal against. Left-anti-join
|
||||
// against active appeals for the same action id.
|
||||
async function eligibleActions(discordUserId) {
|
||||
return query(
|
||||
`SELECT ma.id, ma.action_type, ma.target_tag, ma.reason,
|
||||
ma.duration_seconds, ma.created_at
|
||||
FROM mod_actions ma
|
||||
LEFT JOIN appeals a
|
||||
ON a.mod_action_id = ma.id AND a.status IN ('pending','under_review')
|
||||
WHERE ma.target_user_id = ?
|
||||
AND ma.action_type IN ('ban','mute')
|
||||
AND a.id IS NULL
|
||||
ORDER BY ma.created_at DESC`,
|
||||
[discordUserId],
|
||||
)
|
||||
}
|
||||
|
||||
// Read a single bot-owned mod_action by id, for submit-time validation (does it
|
||||
// exist? is it the caller's? is it appealable?). Read-only — the site never
|
||||
// writes mod_actions. Returns null when the id is unknown.
|
||||
async function getModAction(modActionId) {
|
||||
const rows = await query(
|
||||
`SELECT id, action_type, target_user_id, target_tag, reason, duration_seconds, created_at
|
||||
FROM mod_actions WHERE id = ? LIMIT 1`,
|
||||
[modActionId],
|
||||
)
|
||||
return rows[0] || null
|
||||
}
|
||||
|
||||
// pending -> under_review, stamping the claiming staffer.
|
||||
async function setUnderReview(id, { handlerUserId, handlerTag }) {
|
||||
await query(
|
||||
`UPDATE appeals
|
||||
SET status = 'under_review', handled_by_user_id = ?, handled_by_tag = ?
|
||||
WHERE id = ?`,
|
||||
[handlerUserId, handlerTag, id],
|
||||
)
|
||||
}
|
||||
|
||||
// Resolve to a terminal status (approved/denied), recording the staff response,
|
||||
// handler, reversal outcome, and resolution timestamp.
|
||||
async function resolve(id, { status, staffResponse, handlerUserId, handlerTag, reversalStatus }) {
|
||||
await query(
|
||||
`UPDATE appeals
|
||||
SET status = ?, staff_response = ?, handled_by_user_id = ?, handled_by_tag = ?,
|
||||
reversal_status = ?, resolved_at = NOW()
|
||||
WHERE id = ?`,
|
||||
[status, staffResponse ?? null, handlerUserId, handlerTag, reversalStatus, id],
|
||||
)
|
||||
}
|
||||
|
||||
// Straight status flip (used for withdraw). Stamps resolved_at when moving to a
|
||||
// terminal status so the row shows when it closed.
|
||||
async function setStatus(id, status) {
|
||||
await query(
|
||||
`UPDATE appeals
|
||||
SET status = ?,
|
||||
resolved_at = CASE WHEN ? IN ('approved','denied','withdrawn') THEN NOW() ELSE resolved_at END
|
||||
WHERE id = ?`,
|
||||
[status, status, id],
|
||||
)
|
||||
}
|
||||
|
||||
// Count of appeals grouped by status, for the queue's tab badges.
|
||||
async function countByStatus() {
|
||||
return query('SELECT status, COUNT(*) AS c FROM appeals GROUP BY status')
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
insert,
|
||||
getById,
|
||||
listForUser,
|
||||
listForDiscordUser,
|
||||
listQueue,
|
||||
activeForAction,
|
||||
eligibleActions,
|
||||
getModAction,
|
||||
setUnderReview,
|
||||
resolve,
|
||||
setStatus,
|
||||
countByStatus,
|
||||
}
|
||||
82
server/src/model/appeals/appeals.model.js
Normal file
82
server/src/model/appeals/appeals.model.js
Normal file
@@ -0,0 +1,82 @@
|
||||
// Business layer for moderation appeals (Phase 6c). A thin wrapper over
|
||||
// appeals.db that returns the freshly-read row after each mutation, so callers
|
||||
// always hand the client the enriched (joined) shape rather than a bare
|
||||
// insert/update result. Ownership/type/duplicate validation lives in the
|
||||
// controllers (they hold the request context — caller identity, the mod_action
|
||||
// being appealed); this layer just performs the persistence.
|
||||
const appealsDb = require('./appeals.db')
|
||||
|
||||
async function submit({ modActionId, discordUserId, actionType, userId, submittedText }) {
|
||||
const id = await appealsDb.insert({ modActionId, discordUserId, actionType, userId, submittedText })
|
||||
return appealsDb.getById(id)
|
||||
}
|
||||
|
||||
async function getById(id) {
|
||||
return appealsDb.getById(id)
|
||||
}
|
||||
|
||||
// The caller's own appeals, newest first.
|
||||
async function listMine(userId) {
|
||||
return appealsDb.listForUser(userId)
|
||||
}
|
||||
|
||||
// The caller's appealable actions (ban/mute with no active appeal).
|
||||
async function eligibleActions(discordUserId) {
|
||||
return appealsDb.eligibleActions(discordUserId)
|
||||
}
|
||||
|
||||
// The active (pending/under_review) appeal for an action, or null.
|
||||
async function activeForAction(modActionId) {
|
||||
return appealsDb.activeForAction(modActionId)
|
||||
}
|
||||
|
||||
// Owner-initiated withdraw: flip to 'withdrawn' and return the updated row.
|
||||
async function withdraw(id) {
|
||||
await appealsDb.setStatus(id, 'withdrawn')
|
||||
return appealsDb.getById(id)
|
||||
}
|
||||
|
||||
// Staff queue read.
|
||||
async function queue({ statuses, limit, offset }) {
|
||||
return appealsDb.listQueue({ statuses, limit, offset })
|
||||
}
|
||||
|
||||
// Staff claim: pending -> under_review, stamping the handler.
|
||||
async function claim(id, { handlerUserId, handlerTag }) {
|
||||
await appealsDb.setUnderReview(id, { handlerUserId, handlerTag })
|
||||
return appealsDb.getById(id)
|
||||
}
|
||||
|
||||
// Staff resolution (approved/denied) with the reversal outcome already decided
|
||||
// by the controller (which owns the best-effort bot call).
|
||||
async function resolve(id, opts) {
|
||||
await appealsDb.resolve(id, opts)
|
||||
return appealsDb.getById(id)
|
||||
}
|
||||
|
||||
// Appeals for a Discord id (admin per-user view).
|
||||
async function listForDiscordUser(discordUserId) {
|
||||
return appealsDb.listForDiscordUser(discordUserId)
|
||||
}
|
||||
|
||||
// Number of appeals still awaiting first triage (status = 'pending'), for the
|
||||
// dashboard badge.
|
||||
async function pendingCount() {
|
||||
const rows = await appealsDb.countByStatus()
|
||||
const row = rows.find((r) => r.status === 'pending')
|
||||
return row ? Number(row.c) : 0
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
submit,
|
||||
getById,
|
||||
listMine,
|
||||
eligibleActions,
|
||||
activeForAction,
|
||||
withdraw,
|
||||
queue,
|
||||
claim,
|
||||
resolve,
|
||||
listForDiscordUser,
|
||||
pendingCount,
|
||||
}
|
||||
48
server/src/model/appeals/appeals.pure.js
Normal file
48
server/src/model/appeals/appeals.pure.js
Normal file
@@ -0,0 +1,48 @@
|
||||
// Pure helpers for the moderation-appeals feature (Phase 6c). No DB access —
|
||||
// just the status/type vocabularies and row-shaping so both the model layer and
|
||||
// the tests can reason about appeal state without a database.
|
||||
|
||||
// Terminal statuses: an appeal that has reached one of these is closed and can
|
||||
// no longer be withdrawn, claimed, or resolved.
|
||||
const TERMINAL = new Set(['approved', 'denied', 'withdrawn'])
|
||||
|
||||
// Active statuses: an appeal that still occupies the "one active appeal per
|
||||
// action" slot. A player cannot open a second appeal for a mod_action while one
|
||||
// of these is outstanding.
|
||||
const ACTIVE = new Set(['pending', 'under_review'])
|
||||
|
||||
// Only bans and mutes are appealable (kicks/warns are not — a kick is not a
|
||||
// standing state, and a warn carries no access restriction to reverse).
|
||||
const APPEALABLE_TYPES = new Set(['ban', 'mute'])
|
||||
|
||||
// True if a status is one an appeal can still transition away from.
|
||||
function isTerminal(status) {
|
||||
return TERMINAL.has(status)
|
||||
}
|
||||
|
||||
function isActive(status) {
|
||||
return ACTIVE.has(status)
|
||||
}
|
||||
|
||||
function isAppealableType(actionType) {
|
||||
return APPEALABLE_TYPES.has(actionType)
|
||||
}
|
||||
|
||||
// Map an approve/deny resolution to the reversal_status the row should carry.
|
||||
// A denial never reverses; an approval only reverses when the underlying action
|
||||
// is appealable (ban/mute) and the bot call succeeded.
|
||||
function reversalStatusFor({ status, actionType, botOk }) {
|
||||
if (status !== 'approved') return 'none'
|
||||
if (!APPEALABLE_TYPES.has(actionType)) return 'none'
|
||||
return botOk ? 'done' : 'failed'
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
TERMINAL,
|
||||
ACTIVE,
|
||||
APPEALABLE_TYPES,
|
||||
isTerminal,
|
||||
isActive,
|
||||
isAppealableType,
|
||||
reversalStatusFor,
|
||||
}
|
||||
Reference in New Issue
Block a user