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:
82
server/src/model/appeals/appeals.model.js
Normal file
82
server/src/model/appeals/appeals.model.js
Normal 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,
|
||||
}
|
||||
Reference in New Issue
Block a user