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

@@ -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