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:
@@ -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'],
|
||||
|
||||
Reference in New Issue
Block a user