Files
website/server/src/model/appeals/appeals.model.js
Claude 028ba8c5e4
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
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
2026-07-18 22:01:06 -05:00

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