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
103 lines
3.9 KiB
JavaScript
103 lines
3.9 KiB
JavaScript
const discordManager = require('../discord/discordManager')
|
|
const newsAnnounce = require('../discord/newsAnnounce')
|
|
const modLog = require('../discord/modLog')
|
|
const createLogger = require('../utils/logger')
|
|
|
|
const log = createLogger('internal')
|
|
|
|
const REVERSIBLE = new Set(['ban', 'mute'])
|
|
// discord.js REST error code for removing a ban that no longer exists.
|
|
const UNKNOWN_BAN = 10026
|
|
|
|
// POST /internal/config — called by the main server right after an admin
|
|
// saves the Discord Bot panel, and by the bot's own bootstrap on startup
|
|
// (via a GET to the server for the current config, then this same start/stop
|
|
// logic locally). Body: { token, guildId, enabled }.
|
|
async function setConfig(req, res) {
|
|
const { token, guildId, enabled } = req.body || {}
|
|
try {
|
|
if (enabled) {
|
|
if (!token || !guildId) {
|
|
return res.status(400).json({ message: 'token and guildId are required when enabled' })
|
|
}
|
|
await discordManager.start({ token, guildId })
|
|
} else {
|
|
await discordManager.stop()
|
|
}
|
|
return res.json(discordManager.getStatus())
|
|
} catch (err) {
|
|
log.error('setConfig failed', { message: err.message })
|
|
// Still 200 with an error status — the caller (admin panel) should surface
|
|
// discordManager's status/statusDetail rather than treat this as a 5xx.
|
|
return res.json(discordManager.getStatus())
|
|
}
|
|
}
|
|
|
|
// GET /internal/status — live connection state, polled by the admin panel.
|
|
function getStatusHandler(req, res) {
|
|
return res.json(discordManager.getStatus())
|
|
}
|
|
|
|
// POST /internal/announce — called by the main server right after a news
|
|
// post is published. Body: { title, excerpt, url, imageUrl }.
|
|
async function announce(req, res) {
|
|
const connection = discordManager.getConnection()
|
|
if (!connection) return res.status(503).json({ message: 'Bot is not connected' })
|
|
try {
|
|
await newsAnnounce.postAnnounce(connection.client, connection.guildId, req.body || {})
|
|
return res.json({ posted: true })
|
|
} catch (err) {
|
|
log.warn('announce failed', { message: err.message })
|
|
return res.status(400).json({ message: err.message })
|
|
}
|
|
}
|
|
|
|
// POST /internal/mod-reverse — called by the main server when a staffer APPROVES
|
|
// a moderation appeal (Phase 6d). Body: { discord_user_id, action_type, appeal_id }.
|
|
// Reverses the Discord action: 'ban' → lift the ban, 'mute' → clear the timeout.
|
|
// Idempotent-friendly: an already-lifted ban ("Unknown Ban") or a member who has
|
|
// left the guild is treated as success (the desired end state already holds).
|
|
async function reverseModAction(req, res) {
|
|
const { discord_user_id: discordUserId, action_type: actionType, appeal_id: appealId } = req.body || {}
|
|
|
|
if (!REVERSIBLE.has(actionType)) {
|
|
return res.status(400).json({ message: 'action_type must be ban or mute' })
|
|
}
|
|
|
|
const connection = discordManager.getConnection()
|
|
if (!connection) return res.status(503).json({ message: 'Bot is not connected' })
|
|
|
|
const reason = `Appeal #${appealId} approved`
|
|
try {
|
|
const guild = await connection.client.guilds.fetch(connection.guildId)
|
|
|
|
if (actionType === 'ban') {
|
|
try {
|
|
await guild.bans.remove(discordUserId, reason)
|
|
} catch (err) {
|
|
// Unknown Ban → already unbanned; anything else is a real failure.
|
|
if (err.code !== UNKNOWN_BAN) throw err
|
|
}
|
|
} else {
|
|
// mute: clear the timeout. If the member has left, there's nothing to clear.
|
|
const member = await guild.members.fetch(discordUserId).catch(() => null)
|
|
if (member) await member.timeout(null, reason)
|
|
}
|
|
|
|
await modLog.postReversal({
|
|
client: connection.client,
|
|
guildId: connection.guildId,
|
|
actionType,
|
|
discordUserId,
|
|
appealId,
|
|
})
|
|
|
|
return res.json({ reversed: true })
|
|
} catch (err) {
|
|
log.error('mod-reverse failed', { message: err.message, actionType, discordUserId })
|
|
return res.status(500).json({ message: err.message })
|
|
}
|
|
}
|
|
|
|
module.exports = { setConfig, getStatus: getStatusHandler, announce, reverseModAction }
|