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:
@@ -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 caller’s moderation appeals'
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
/* #swagger.responses[200] = { description: 'The caller’s 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 caller’s ban/mute actions eligible for appeal'
|
||||
// #swagger.description = 'The caller’s 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 caller’s actions'
|
||||
// #swagger.description = 'Opens an appeal for a ban/mute mod_action that belongs to the caller (its target matches the caller’s 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 caller’s 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
|
||||
|
||||
Reference in New Issue
Block a user