Merge branch 'main' into feat/password-reset
All checks were successful
PR Checks / server-tests (pull_request) Successful in 9m59s
PR Checks / client-build (pull_request) Successful in 9m26s
PR Checks / bot-install (pull_request) Successful in 9m31s

This commit is contained in:
2026-07-19 09:04:43 +00:00
23 changed files with 3083 additions and 8 deletions

View File

@@ -594,6 +594,35 @@ CREATE TABLE IF NOT EXISTS mod_actions (
INDEX idx_mod_actions_target (guild_id, target_user_id, created_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- Player-submitted moderation appeals (Phase 6c). Unlike mod_actions above, this
-- table is SERVER-owned — written and read only by the main site (the player
-- appeals controller and the admin moderation queue), never by the bot. A player
-- appeals one of their own ban/mute mod_actions; staff triage the queue, and an
-- approval optionally triggers an automatic Discord reversal (Phase 6d) whose
-- outcome is recorded in reversal_status. mod_action_id is a plain column with NO
-- hard FK to the bot-owned mod_actions table (cross-owner FK avoided on purpose,
-- matching posts.announce_job_id) — existence is validated in app code. user_id
-- is the appealing site account; discord_user_id is the snowflake the appeal is
-- for (snapshotted from mod_actions.target_user_id at submit time).
CREATE TABLE IF NOT EXISTS appeals (
id INT AUTO_INCREMENT PRIMARY KEY,
mod_action_id INT NOT NULL,
discord_user_id VARCHAR(32) NOT NULL,
action_type ENUM('ban','mute') NOT NULL,
user_id INT NULL,
status ENUM('pending','under_review','approved','denied','withdrawn') NOT NULL DEFAULT 'pending',
submitted_text TEXT NOT NULL,
staff_response TEXT NULL,
handled_by_user_id INT NULL,
handled_by_tag VARCHAR(120) NULL,
reversal_status ENUM('none','done','failed') NOT NULL DEFAULT 'none',
submitted_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
resolved_at DATETIME NULL,
CONSTRAINT fk_appeal_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE SET NULL,
INDEX idx_appeals_status (status, submitted_at),
INDEX idx_appeals_action (mod_action_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- Standing warnings, separate from mod_actions so /warnings can list active
-- warnings per user. expires_at is unused in Phase 2 (no decay/escalation
-- yet — deferred, see mute/warn command comments) but the column is cheap to

View 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,
}

View 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,
}

View 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,
}

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,
}

View File

@@ -0,0 +1,101 @@
// Player self-service moderation appeals (Phase 6c). A player appeals one of
// their OWN ban/mute mod_actions: they see the actions they're allowed to appeal
// (ban/mute with no active appeal), submit one, view their appeals, and withdraw
// one that hasn't been resolved yet. Ownership is proven by matching the
// action's target_user_id against the caller's linked Discord identity — the
// same (provider='discord', subject=<snowflake>) link the SSO flow writes.
// Mounted behind the player-role gate (see player.routes.js).
const appeals = require('../../../model/appeals/appeals.model')
const appealsDb = require('../../../model/appeals/appeals.db')
const { isAppealableType, isTerminal } = require('../../../model/appeals/appeals.pure')
const userIdentities = require('../../../model/userIdentities/userIdentities.model')
const log = require('../../../utils/logger')('player-appeals')
// The caller's linked Discord snowflake, or null if they have no Discord
// identity linked. mod_actions are keyed by this snowflake.
async function callerDiscordSubject(userId) {
const identities = await userIdentities.listForUser(userId)
const discord = identities.find((i) => i.provider === 'discord')
return discord ? discord.subject : null
}
// GET /player/appeals — the caller's own appeals, newest first.
async function listMine(req, res) {
try {
return res.json(await appeals.listMine(req.user.id))
} catch (err) {
log.error('listMine failed', { error: err.message })
return res.status(500).json({ message: 'Internal Server Error' })
}
}
// GET /player/appeals/eligible — the caller's ban/mute actions with no active
// appeal. If the caller has no linked Discord account we return [] (not an
// error) so the UI can show a "link your Discord account" hint instead.
async function listEligible(req, res) {
try {
const subject = await callerDiscordSubject(req.user.id)
if (!subject) return res.json([])
return res.json(await appealsDb.eligibleActions(subject))
} catch (err) {
log.error('listEligible failed', { error: err.message })
return res.status(500).json({ message: 'Internal Server Error' })
}
}
// POST /player/appeals — open an appeal for one of the caller's own actions.
async function create(req, res) {
try {
const modActionId = Number(req.body.mod_action_id)
const submittedText = req.body.submitted_text
const action = await appealsDb.getModAction(modActionId)
if (!action) return res.status(404).json({ message: 'Mod action not found' })
const subject = await callerDiscordSubject(req.user.id)
if (!subject || String(action.target_user_id) !== String(subject)) {
return res.status(403).json({ message: 'This action is not yours to appeal' })
}
if (!isAppealableType(action.action_type)) {
return res.status(400).json({ message: 'Only bans and mutes can be appealed' })
}
const active = await appeals.activeForAction(modActionId)
if (active) return res.status(409).json({ message: 'An appeal for this action is already open' })
const appeal = await appeals.submit({
modActionId,
discordUserId: subject,
actionType: action.action_type,
userId: req.user.id,
submittedText,
})
return res.status(201).json(appeal)
} catch (err) {
log.error('create failed', { error: err.message })
return res.status(500).json({ message: 'Internal Server Error' })
}
}
// POST /player/appeals/:id/withdraw — the owner withdraws a non-resolved appeal.
async function withdraw(req, res) {
try {
const appeal = await appeals.getById(Number(req.params.id))
// 404 for both "no such appeal" and "not the caller's" — never confirm the
// existence of another player's appeal.
if (!appeal || appeal.user_id !== req.user.id) {
return res.status(404).json({ message: 'Appeal not found' })
}
if (isTerminal(appeal.status)) {
return res.status(409).json({ message: 'This appeal is already resolved' })
}
return res.json(await appeals.withdraw(appeal.id))
} catch (err) {
log.error('withdraw failed', { error: err.message })
return res.status(500).json({ message: 'Internal Server Error' })
}
}
module.exports = { listMine, listEligible, create, withdraw }

View File

@@ -11,6 +11,7 @@ const { body, param } = require('express-validator')
const account = require('../admin/account.controller')
const shard = require('./shard.controller')
const appeals = require('./appeals.controller')
const { requireAuth, requireRole } = require('../../../auth/session.middleware')
const noindex = require('../../../middleware/noindex')
const validate = require('../../../middleware/validate')
@@ -232,4 +233,58 @@ playerRouter.get(
shard.getHouses,
)
// ── Moderation appeals (uo-link / Discord moderation) ──────────────────────
// A player appeals one of their own ban/mute mod_actions. Ownership is proven by
// matching the action against the caller's linked Discord identity.
playerRouter.get(
'/appeals',
// #swagger.tags = ['Player · Appeals']
// #swagger.summary = 'List the callers moderation appeals'
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
/* #swagger.responses[200] = { description: 'The callers appeals', content: { "application/json": { schema: { type: "array", items: { $ref: "#/components/schemas/Appeal" } } } } } */
/* #swagger.responses[403] = { description: 'Player role required, or account not active', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
appeals.listMine,
)
playerRouter.get(
'/appeals/eligible',
// #swagger.tags = ['Player · Appeals']
// #swagger.summary = 'List the callers ban/mute actions eligible for appeal'
// #swagger.description = 'The callers ban/mute mod_actions that have no active appeal. Returns an empty array when the caller has no linked Discord account (the UI shows a “link Discord” hint).'
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
/* #swagger.responses[200] = { description: 'Appealable actions', content: { "application/json": { schema: { type: "array", items: { $ref: "#/components/schemas/AppealEligibleAction" } } } } } */
/* #swagger.responses[403] = { description: 'Player role required, or account not active', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
appeals.listEligible,
)
playerRouter.post(
'/appeals',
// #swagger.tags = ['Player · Appeals']
// #swagger.summary = 'Submit a moderation appeal for one of the callers actions'
// #swagger.description = 'Opens an appeal for a ban/mute mod_action that belongs to the caller (its target matches the callers linked Discord identity) and has no active appeal.'
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
/* #swagger.requestBody = { required: true, content: { "application/json": { schema: { $ref: "#/components/schemas/CreateAppealRequest" } } } } */
/* #swagger.responses[201] = { description: 'Appeal created', content: { "application/json": { schema: { $ref: "#/components/schemas/Appeal" } } } } */
/* #swagger.responses[400] = { description: 'Validation error, or the action type is not appealable', content: { "application/json": { schema: { $ref: "#/components/schemas/ValidationError" } } } } */
/* #swagger.responses[403] = { description: 'The action does not belong to the caller', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
/* #swagger.responses[404] = { description: 'Mod action not found', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
/* #swagger.responses[409] = { description: 'An appeal for this action is already open', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
accountChangeLimiter,
body('mod_action_id').isInt({ min: 1 }).toInt(),
body('submitted_text').isString().trim().isLength({ min: 1, max: 4000 }),
validate,
appeals.create,
)
playerRouter.post(
'/appeals/:id/withdraw',
// #swagger.tags = ['Player · Appeals']
// #swagger.summary = 'Withdraw one of the callers pending appeals'
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
// #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'Appeal id (must belong to the caller).' }
/* #swagger.responses[200] = { description: 'The withdrawn appeal', content: { "application/json": { schema: { $ref: "#/components/schemas/Appeal" } } } } */
/* #swagger.responses[404] = { description: 'No such appeal for the caller', 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 }),
validate,
appeals.withdraw,
)
module.exports = playerRouter

View File

@@ -61,4 +61,15 @@ function announce({ title, excerpt, url, imageUrl }) {
return call('/internal/announce', { method: 'POST', body: { title, excerpt, url, imageUrl } })
}
module.exports = { pushConfig, getStatus, announce }
// Site -> bot: an approved appeal wants the underlying Discord action reversed
// (unban for a 'ban', clear the timeout for a 'mute'). Best-effort like every
// call here — never throws, so an approved appeal still resolves when the bot is
// down (the caller records reversal_status='failed' from `ok:false`).
function reverseModAction({ discordUserId, actionType, appealId }) {
return call('/internal/mod-reverse', {
method: 'POST',
body: { discord_user_id: discordUserId, action_type: actionType, appeal_id: appealId },
})
}
module.exports = { pushConfig, getStatus, announce, reverseModAction }

File diff suppressed because it is too large Load Diff

View File

@@ -55,6 +55,7 @@ const doc = {
{ name: 'Admin · Account', description: 'Self-service account security (2FA, linked identities)' },
{ name: 'Player', description: 'Self-service player accounts (register, credentials, 2FA, linked identities)' },
{ name: 'Player · Shard', description: 'Link an in-game account and read its roster / vendors (uo-link)' },
{ name: 'Player · Appeals', description: 'Player-submitted moderation appeals' },
{ name: 'Admin · Dashboard', description: 'Dashboard summary and site mode' },
{ name: 'Admin · Posts', description: 'News / five-on-friday / newsletter / screenshots + uploads' },
{ name: 'Admin · Wiki', description: 'Wiki pages, categories, tags and revisions' },
@@ -421,6 +422,93 @@ const doc = {
type: 'object',
properties: { ok: { type: 'boolean', example: true } },
},
// ── Moderation appeals (Phase 6c/6d) ────────────────────────────────────
Appeal: {
type: 'object',
description: 'A player-submitted moderation appeal (as returned to the player and in the staff queue).',
properties: {
id: { type: 'integer', example: 12 },
mod_action_id: { type: 'integer', example: 340 },
discord_user_id: { type: 'string', example: '216734083584917504' },
action_type: { type: 'string', enum: ['ban', 'mute'], example: 'ban' },
user_id: { type: 'integer', nullable: true, example: 42 },
status: {
type: 'string',
enum: ['pending', 'under_review', 'approved', 'denied', 'withdrawn'],
example: 'pending',
},
submitted_text: { type: 'string', example: 'I was banned by mistake — please review.' },
staff_response: { type: 'string', nullable: true, example: null },
handled_by_user_id: { type: 'integer', nullable: true, example: null },
handled_by_tag: { type: 'string', nullable: true, example: null },
reversal_status: {
type: 'string',
enum: ['none', 'done', 'failed'],
description: 'Discord-reversal outcome. done/failed only after an approval; none otherwise.',
example: 'none',
},
submitted_at: { type: 'string', format: 'date-time' },
resolved_at: { type: 'string', format: 'date-time', nullable: true, example: null },
action_target_tag: { type: 'string', nullable: true, example: 'Rogue#1234', description: 'Snapshot of the original action target tag (from mod_actions).' },
action_reason: { type: 'string', nullable: true, example: 'Spam' },
action_created_at: { type: 'string', format: 'date-time', nullable: true },
action_duration_seconds: { type: 'integer', nullable: true, example: 86400 },
submitter_username: { type: 'string', nullable: true, example: 'newplayer' },
},
},
AppealQueueItem: {
allOf: [{ $ref: '#/components/schemas/Appeal' }],
description: 'A staff-queue appeal row — identical shape to Appeal, with the joined action/submitter columns populated.',
},
AppealResolveResult: {
allOf: [
{ $ref: '#/components/schemas/Appeal' },
{
type: 'object',
properties: {
reversal: {
type: 'object',
description: 'What the approval attempted against Discord.',
properties: {
attempted: { type: 'boolean', example: true },
ok: { type: 'boolean', example: true },
reversal_status: { type: 'string', enum: ['none', 'done', 'failed'], example: 'done' },
bot_status: { type: 'integer', nullable: true, example: 200, description: 'HTTP status from the bot internal call, or null when no call was made.' },
error: { type: 'string', nullable: true, example: null },
},
},
},
},
],
},
AppealEligibleAction: {
type: 'object',
description: 'A ban/mute mod_action the caller may appeal (no active appeal outstanding).',
properties: {
id: { type: 'integer', example: 340, description: 'mod_action id — pass as mod_action_id when submitting.' },
action_type: { type: 'string', enum: ['ban', 'mute'], example: 'ban' },
target_tag: { type: 'string', nullable: true, example: 'Rogue#1234' },
reason: { type: 'string', nullable: true, example: 'Spam' },
duration_seconds: { type: 'integer', nullable: true, example: 86400 },
created_at: { type: 'string', format: 'date-time' },
},
},
CreateAppealRequest: {
type: 'object',
required: ['mod_action_id', 'submitted_text'],
properties: {
mod_action_id: { type: 'integer', example: 340, description: 'The ban/mute mod_action to appeal (must belong to the caller).' },
submitted_text: { type: 'string', minLength: 1, maxLength: 4000, example: 'I was banned by mistake — please review.' },
},
},
ResolveAppealRequest: {
type: 'object',
required: ['status'],
properties: {
status: { type: 'string', enum: ['approved', 'denied'], example: 'approved' },
staff_response: { type: 'string', maxLength: 4000, nullable: true, example: 'Reviewed — reversing the ban.' },
},
},
TotpCodeRequest: {
type: 'object',
required: ['code'],

View File

@@ -0,0 +1,47 @@
// Unit tests for the pure appeals helpers (status/type vocabularies + the
// reversal-status derivation). DB-free, like the rest of this suite.
const { test } = require('node:test')
const assert = require('node:assert/strict')
const pure = require('../src/model/appeals/appeals.pure')
test('TERMINAL / ACTIVE partition the status space', () => {
assert.deepEqual([...pure.TERMINAL].sort(), ['approved', 'denied', 'withdrawn'])
assert.deepEqual([...pure.ACTIVE].sort(), ['pending', 'under_review'])
})
test('isTerminal is true only for closed statuses', () => {
assert.equal(pure.isTerminal('approved'), true)
assert.equal(pure.isTerminal('denied'), true)
assert.equal(pure.isTerminal('withdrawn'), true)
assert.equal(pure.isTerminal('pending'), false)
assert.equal(pure.isTerminal('under_review'), false)
})
test('isActive is true only for the open statuses that hold the appeal slot', () => {
assert.equal(pure.isActive('pending'), true)
assert.equal(pure.isActive('under_review'), true)
assert.equal(pure.isActive('approved'), false)
assert.equal(pure.isActive('withdrawn'), false)
})
test('isAppealableType allows only ban and mute', () => {
assert.equal(pure.isAppealableType('ban'), true)
assert.equal(pure.isAppealableType('mute'), true)
assert.equal(pure.isAppealableType('kick'), false)
assert.equal(pure.isAppealableType('warn'), false)
})
test('reversalStatusFor: approved + appealable maps bot success to done/failed', () => {
assert.equal(pure.reversalStatusFor({ status: 'approved', actionType: 'ban', botOk: true }), 'done')
assert.equal(pure.reversalStatusFor({ status: 'approved', actionType: 'ban', botOk: false }), 'failed')
assert.equal(pure.reversalStatusFor({ status: 'approved', actionType: 'mute', botOk: true }), 'done')
})
test('reversalStatusFor: denial never reverses', () => {
assert.equal(pure.reversalStatusFor({ status: 'denied', actionType: 'ban', botOk: true }), 'none')
})
test('reversalStatusFor: non-appealable action never reverses even when approved', () => {
assert.equal(pure.reversalStatusFor({ status: 'approved', actionType: 'warn', botOk: true }), 'none')
})

305
server/test/appeals.test.js Normal file
View File

@@ -0,0 +1,305 @@
// Controller-level tests for moderation appeals (Phase 6c/6d). Following the
// existing suite's convention (see playerAccounts.test.js / moderation.test.js),
// these are DB-free: the DB is pointed at a closed port before anything builds
// the pool, and the model/db/bot-client seams are stubbed per-test so the
// controllers' branching logic (ownership, duplicate, type, reversal wiring) is
// exercised without a live database or bot. The SQL itself is verified manually
// against a dev DB per the plan's verification steps.
process.env.DB_HOST = '127.0.0.1'
process.env.DB_PORT = '59999'
const { test, afterEach, after } = require('node:test')
const assert = require('node:assert/strict')
const playerAppeals = require('../src/router/v1/player/appeals.controller')
const modCtrl = require('../src/router/v1/admin/moderation.controller')
const appealsModel = require('../src/model/appeals/appeals.model')
const appealsDb = require('../src/model/appeals/appeals.db')
const userIdentities = require('../src/model/userIdentities/userIdentities.model')
const botInternalClient = require('../src/utils/botInternalClient')
const activity = require('../src/model/activity/activity.model')
const db = require('../src/utils/db')
after(() => db.close())
// ── tiny stub harness (restore all patched methods after each test) ──────────
const saved = new Map()
function stub(obj, prop, fn) {
if (!saved.has(obj)) saved.set(obj, {})
const bag = saved.get(obj)
if (!(prop in bag)) bag[prop] = obj[prop]
obj[prop] = fn
}
afterEach(() => {
for (const [obj, bag] of saved) for (const k of Object.keys(bag)) obj[k] = bag[k]
saved.clear()
})
function mockRes() {
return {
statusCode: 200,
body: null,
status(c) {
this.statusCode = c
return this
},
json(b) {
this.body = b
return this
},
}
}
// activity.log already swallows its own errors, but stub it everywhere so a
// resolve/claim never reaches the (closed) DB.
function silenceActivity() {
stub(activity, 'log', async () => {})
}
// ── POST /player/appeals ─────────────────────────────────────────────────────
test('player submit: happy path returns 201 with the created appeal', async () => {
stub(appealsDb, 'getModAction', async () => ({ id: 340, action_type: 'ban', target_user_id: '123' }))
stub(userIdentities, 'listForUser', async () => [{ provider: 'discord', subject: '123' }])
stub(appealsModel, 'activeForAction', async () => null)
let submitted = null
stub(appealsModel, 'submit', async (arg) => {
submitted = arg
return { id: 12, status: 'pending', action_type: 'ban' }
})
const req = { user: { id: 42 }, body: { mod_action_id: 340, submitted_text: 'please review' } }
const res = mockRes()
await playerAppeals.create(req, res)
assert.equal(res.statusCode, 201)
assert.equal(res.body.id, 12)
assert.equal(submitted.discordUserId, '123')
assert.equal(submitted.actionType, 'ban')
assert.equal(submitted.userId, 42)
})
test('player submit: action not belonging to the caller is 403', async () => {
stub(appealsDb, 'getModAction', async () => ({ id: 340, action_type: 'ban', target_user_id: '999' }))
stub(userIdentities, 'listForUser', async () => [{ provider: 'discord', subject: '123' }])
const req = { user: { id: 42 }, body: { mod_action_id: 340, submitted_text: 'x' } }
const res = mockRes()
await playerAppeals.create(req, res)
assert.equal(res.statusCode, 403)
})
test('player submit: an existing active appeal is 409', async () => {
stub(appealsDb, 'getModAction', async () => ({ id: 340, action_type: 'mute', target_user_id: '123' }))
stub(userIdentities, 'listForUser', async () => [{ provider: 'discord', subject: '123' }])
stub(appealsModel, 'activeForAction', async () => ({ id: 5, status: 'pending' }))
const req = { user: { id: 42 }, body: { mod_action_id: 340, submitted_text: 'x' } }
const res = mockRes()
await playerAppeals.create(req, res)
assert.equal(res.statusCode, 409)
})
test('player submit: a non-ban/mute action is 400', async () => {
stub(appealsDb, 'getModAction', async () => ({ id: 340, action_type: 'warn', target_user_id: '123' }))
stub(userIdentities, 'listForUser', async () => [{ provider: 'discord', subject: '123' }])
const req = { user: { id: 42 }, body: { mod_action_id: 340, submitted_text: 'x' } }
const res = mockRes()
await playerAppeals.create(req, res)
assert.equal(res.statusCode, 400)
})
test('player submit: unknown mod_action is 404', async () => {
stub(appealsDb, 'getModAction', async () => null)
const req = { user: { id: 42 }, body: { mod_action_id: 9999, submitted_text: 'x' } }
const res = mockRes()
await playerAppeals.create(req, res)
assert.equal(res.statusCode, 404)
})
// ── GET /player/appeals/eligible ─────────────────────────────────────────────
test('player eligible: no linked Discord returns [] (not an error)', async () => {
stub(userIdentities, 'listForUser', async () => [{ provider: 'google', subject: 'g1' }])
const req = { user: { id: 42 } }
const res = mockRes()
await playerAppeals.listEligible(req, res)
assert.equal(res.statusCode, 200)
assert.deepEqual(res.body, [])
})
test('player eligible: linked Discord returns the eligible actions', async () => {
stub(userIdentities, 'listForUser', async () => [{ provider: 'discord', subject: '123' }])
stub(appealsDb, 'eligibleActions', async (subject) => {
assert.equal(subject, '123')
return [{ id: 340, action_type: 'ban' }]
})
const req = { user: { id: 42 } }
const res = mockRes()
await playerAppeals.listEligible(req, res)
assert.equal(res.statusCode, 200)
assert.equal(res.body.length, 1)
})
// ── POST /player/appeals/:id/withdraw ────────────────────────────────────────
test('player withdraw: owner + non-terminal flips to withdrawn', async () => {
stub(appealsModel, 'getById', async () => ({ id: 12, user_id: 42, status: 'pending' }))
stub(appealsModel, 'withdraw', async (id) => ({ id, status: 'withdrawn' }))
const req = { user: { id: 42 }, params: { id: '12' } }
const res = mockRes()
await playerAppeals.withdraw(req, res)
assert.equal(res.statusCode, 200)
assert.equal(res.body.status, 'withdrawn')
})
test('player withdraw: another players appeal is 404 (never confirmed)', async () => {
stub(appealsModel, 'getById', async () => ({ id: 12, user_id: 99, status: 'pending' }))
const req = { user: { id: 42 }, params: { id: '12' } }
const res = mockRes()
await playerAppeals.withdraw(req, res)
assert.equal(res.statusCode, 404)
})
test('player withdraw: an already-resolved appeal is 409', async () => {
stub(appealsModel, 'getById', async () => ({ id: 12, user_id: 42, status: 'approved' }))
const req = { user: { id: 42 }, params: { id: '12' } }
const res = mockRes()
await playerAppeals.withdraw(req, res)
assert.equal(res.statusCode, 409)
})
// ── GET /admin/moderation/appeals ────────────────────────────────────────────
test('staff queue: default status filter is pending + under_review', async () => {
let passed = null
stub(appealsModel, 'queue', async (opts) => {
passed = opts
return []
})
const req = { query: {} }
const res = mockRes()
await modCtrl.getAppeals(req, res)
assert.deepEqual(passed.statuses, ['pending', 'under_review'])
})
test('staff queue: ?status=all expands to every status', async () => {
let passed = null
stub(appealsModel, 'queue', async (opts) => {
passed = opts
return []
})
const req = { query: { status: 'all' } }
const res = mockRes()
await modCtrl.getAppeals(req, res)
assert.deepEqual(passed.statuses, ['pending', 'under_review', 'approved', 'denied', 'withdrawn'])
})
// ── POST /admin/moderation/appeals/:id/claim ─────────────────────────────────
test('staff claim: a pending appeal moves to under_review and stamps the handler', async () => {
silenceActivity()
stub(appealsModel, 'getById', async () => ({ id: 12, status: 'pending', discord_user_id: '123' }))
let claimArgs = null
stub(appealsModel, 'claim', async (id, args) => {
claimArgs = { id, ...args }
return { id, status: 'under_review' }
})
const req = { user: { id: 7, username: 'modperson' }, params: { id: '12' } }
const res = mockRes()
await modCtrl.claimAppeal(req, res)
assert.equal(res.statusCode, 200)
assert.equal(res.body.status, 'under_review')
assert.equal(claimArgs.handlerUserId, 7)
assert.equal(claimArgs.handlerTag, 'modperson')
})
test('staff claim: a non-pending appeal is 409', async () => {
stub(appealsModel, 'getById', async () => ({ id: 12, status: 'under_review' }))
const req = { user: { id: 7, username: 'm' }, params: { id: '12' } }
const res = mockRes()
await modCtrl.claimAppeal(req, res)
assert.equal(res.statusCode, 409)
})
// ── POST /admin/moderation/appeals/:id/resolve ───────────────────────────────
test('staff resolve: approving a ban calls the bot and records reversal_status=done on ok', async () => {
silenceActivity()
stub(appealsModel, 'getById', async () => ({ id: 12, status: 'under_review', action_type: 'ban', discord_user_id: '123' }))
let reverseArgs = null
stub(botInternalClient, 'reverseModAction', async (arg) => {
reverseArgs = arg
return { ok: true, status: 200 }
})
let resolveOpts = null
stub(appealsModel, 'resolve', async (id, opts) => {
resolveOpts = opts
return { id, status: 'approved', reversal_status: opts.reversalStatus }
})
const req = { user: { id: 7, username: 'm' }, params: { id: '12' }, body: { status: 'approved' } }
const res = mockRes()
await modCtrl.resolveAppeal(req, res)
assert.equal(res.statusCode, 200)
assert.equal(reverseArgs.actionType, 'ban')
assert.equal(reverseArgs.discordUserId, '123')
assert.equal(resolveOpts.reversalStatus, 'done')
assert.equal(res.body.reversal_status, 'done')
assert.equal(res.body.reversal.attempted, true)
assert.equal(res.body.reversal.ok, true)
assert.equal(res.body.reversal.reversal_status, 'done')
})
test('staff resolve: approving a mute with a bot failure records reversal_status=failed', async () => {
silenceActivity()
stub(appealsModel, 'getById', async () => ({ id: 13, status: 'under_review', action_type: 'mute', discord_user_id: '123' }))
stub(botInternalClient, 'reverseModAction', async () => ({ ok: false, status: 503, error: 'bot responded 503' }))
let resolveOpts = null
stub(appealsModel, 'resolve', async (id, opts) => {
resolveOpts = opts
return { id, status: 'approved', reversal_status: opts.reversalStatus }
})
const req = { user: { id: 7, username: 'm' }, params: { id: '13' }, body: { status: 'approved' } }
const res = mockRes()
await modCtrl.resolveAppeal(req, res)
assert.equal(res.statusCode, 200)
assert.equal(resolveOpts.reversalStatus, 'failed')
assert.equal(res.body.reversal.ok, false)
assert.equal(res.body.reversal.error, 'bot responded 503')
})
test('staff resolve: denying never calls the bot and leaves reversal_status=none', async () => {
silenceActivity()
stub(appealsModel, 'getById', async () => ({ id: 14, status: 'pending', action_type: 'ban', discord_user_id: '123' }))
let botCalled = false
stub(botInternalClient, 'reverseModAction', async () => {
botCalled = true
return { ok: true, status: 200 }
})
let resolveOpts = null
stub(appealsModel, 'resolve', async (id, opts) => {
resolveOpts = opts
return { id, status: 'denied', reversal_status: opts.reversalStatus }
})
const req = { user: { id: 7, username: 'm' }, params: { id: '14' }, body: { status: 'denied' } }
const res = mockRes()
await modCtrl.resolveAppeal(req, res)
assert.equal(res.statusCode, 200)
assert.equal(botCalled, false)
assert.equal(resolveOpts.reversalStatus, 'none')
assert.equal(res.body.reversal.attempted, false)
})
test('staff resolve: an already-resolved appeal is 409', async () => {
stub(appealsModel, 'getById', async () => ({ id: 15, status: 'approved', action_type: 'ban' }))
const req = { user: { id: 7, username: 'm' }, params: { id: '15' }, body: { status: 'denied' } }
const res = mockRes()
await modCtrl.resolveAppeal(req, res)
assert.equal(res.statusCode, 409)
})
test('staff resolve: an unknown appeal is 404', async () => {
stub(appealsModel, 'getById', async () => null)
const req = { user: { id: 7, username: 'm' }, params: { id: '999' }, body: { status: 'denied' } }
const res = mockRes()
await modCtrl.resolveAppeal(req, res)
assert.equal(res.statusCode, 404)
})