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
83 lines
2.6 KiB
JavaScript
83 lines
2.6 KiB
JavaScript
// 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,
|
|
}
|