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