From 028ba8c5e4c186b2de5945a06e318393135ecd1f Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 18 Jul 2026 22:01:06 -0500 Subject: [PATCH] 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 Claude-Session: https://claude.ai/code/session_01XmHdsbnLzDMAVQkAoTQSBe --- bot/src/discord/modLog.js | 30 +- bot/src/internal/internal.controller.js | 54 +- bot/src/internal/internal.routes.js | 1 + client/src/App.jsx | 4 + client/src/api/client.js | 21 + client/src/routes/admin/AdminLayout.jsx | 4 +- client/src/routes/admin/views/Appeals.jsx | 286 ++++ .../src/routes/admin/views/ModerationUser.jsx | 65 +- client/src/routes/player/PlayerAppeals.jsx | 221 +++ .../src/routes/player/PlayerPortalLayout.jsx | 5 +- server/db/schema.sql | 29 + server/src/model/appeals/appeals.db.js | 156 ++ server/src/model/appeals/appeals.model.js | 82 ++ server/src/model/appeals/appeals.pure.js | 48 + server/src/router/v1/admin/admin.routes.js | 67 + .../router/v1/admin/moderation.controller.js | 144 ++ .../router/v1/player/appeals.controller.js | 101 ++ server/src/router/v1/player/player.routes.js | 55 + server/src/utils/botInternalClient.js | 13 +- server/swagger/swagger-output.json | 1267 ++++++++++++++++- server/swagger/swagger.js | 88 ++ server/test/appeals.pure.test.js | 47 + server/test/appeals.test.js | 305 ++++ 23 files changed, 3084 insertions(+), 9 deletions(-) create mode 100644 client/src/routes/admin/views/Appeals.jsx create mode 100644 client/src/routes/player/PlayerAppeals.jsx create mode 100644 server/src/model/appeals/appeals.db.js create mode 100644 server/src/model/appeals/appeals.model.js create mode 100644 server/src/model/appeals/appeals.pure.js create mode 100644 server/src/router/v1/player/appeals.controller.js create mode 100644 server/test/appeals.pure.test.js create mode 100644 server/test/appeals.test.js diff --git a/bot/src/discord/modLog.js b/bot/src/discord/modLog.js index 0551e80..6d5ef6a 100644 --- a/bot/src/discord/modLog.js +++ b/bot/src/discord/modLog.js @@ -43,6 +43,34 @@ async function record({ client, guildId, actionType, target, staffUser, reason, } } +// Post an "appeal approved → action reversed" embed to the mod-log channel. +// Unlike record() this NEVER inserts a mod_actions row — the reversal is an +// out-of-band correction driven by the site's appeals flow, not a new staff +// action. Best-effort: a missing channel or send failure is logged, not thrown. +async function postReversal({ client, guildId, actionType, discordUserId, appealId }) { + try { + const channelId = await guildConfig.getModLogChannelId(guildId) + if (!channelId) return + const channel = await client.channels.fetch(channelId) + if (!channel || !channel.isTextBased()) return + + const reversed = actionType === 'ban' ? 'Ban lifted (unbanned)' : 'Mute cleared (timeout removed)' + const embed = new EmbedBuilder() + .setColor(0x88c0a0) + .setTitle('APPEAL APPROVED') + .addFields( + { name: 'Action reversed', value: reversed, inline: true }, + { name: 'Target id', value: `${discordUserId}`, inline: true }, + { name: 'Appeal', value: `#${appealId}`, inline: true }, + ) + .setTimestamp() + + await channel.send({ embeds: [embed] }) + } catch (err) { + log.warn('failed to post appeal-reversal embed', { message: err.message }) + } +} + function formatDuration(seconds) { if (seconds % 86400 === 0) return `${seconds / 86400}d` if (seconds % 3600 === 0) return `${seconds / 3600}h` @@ -50,4 +78,4 @@ function formatDuration(seconds) { return `${seconds}s` } -module.exports = { record } +module.exports = { record, postReversal } diff --git a/bot/src/internal/internal.controller.js b/bot/src/internal/internal.controller.js index f36c8c9..01fe2dd 100644 --- a/bot/src/internal/internal.controller.js +++ b/bot/src/internal/internal.controller.js @@ -1,9 +1,14 @@ 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 @@ -47,4 +52,51 @@ async function announce(req, res) { } } -module.exports = { setConfig, getStatus: getStatusHandler, announce } +// 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 } diff --git a/bot/src/internal/internal.routes.js b/bot/src/internal/internal.routes.js index 25efa3f..49891e4 100644 --- a/bot/src/internal/internal.routes.js +++ b/bot/src/internal/internal.routes.js @@ -10,5 +10,6 @@ router.use(requireInternalKey) router.post('/config', ctrl.setConfig) router.get('/status', ctrl.getStatus) router.post('/announce', ctrl.announce) +router.post('/mod-reverse', ctrl.reverseModAction) module.exports = router diff --git a/client/src/App.jsx b/client/src/App.jsx index ac320d1..78b1d9c 100644 --- a/client/src/App.jsx +++ b/client/src/App.jsx @@ -51,6 +51,7 @@ import HousesAdmin from './routes/admin/views/HousesAdmin.jsx' import AccountAdmin from './routes/admin/views/AccountAdmin.jsx' import Moderation from './routes/admin/views/Moderation.jsx' import ModerationUser from './routes/admin/views/ModerationUser.jsx' +import Appeals from './routes/admin/views/Appeals.jsx' // Player portal import PlayerLogin from './routes/player/PlayerLogin.jsx' @@ -60,6 +61,7 @@ import PlayerPortalLayout from './routes/player/PlayerPortalLayout.jsx' import PlayerCharacters from './routes/player/PlayerCharacters.jsx' import PlayerCharacter from './routes/player/PlayerCharacter.jsx' import PlayerAccount from './routes/player/PlayerAccount.jsx' +import PlayerAppeals from './routes/player/PlayerAppeals.jsx' export default function App() { return ( @@ -132,6 +134,7 @@ export default function App() { > } /> } /> + } /> } /> } /> @@ -177,6 +180,7 @@ export default function App() { } /> } /> } /> + } /> } /> diff --git a/client/src/api/client.js b/client/src/api/client.js index d2d3f7e..eb67dea 100644 --- a/client/src/api/client.js +++ b/client/src/api/client.js @@ -242,6 +242,21 @@ export const api = { addModNote: (discordId, data) => req(`/admin/moderation/user/${discordId}/notes`, { method: 'POST', body: data }), + // ----- moderation appeals (admin + moderator) ----- + getAppeals: (params = {}) => { + const qs = new URLSearchParams() + if (params.status) qs.set('status', params.status) + if (params.limit) qs.set('limit', params.limit) + if (params.offset) qs.set('offset', params.offset) + const s = qs.toString() + return req(`/admin/moderation/appeals${s ? `?${s}` : ''}`) + }, + getAppeal: (id) => req(`/admin/moderation/appeals/${id}`), + claimAppeal: (id) => req(`/admin/moderation/appeals/${id}/claim`, { method: 'POST' }), + resolveAppeal: (id, data) => + req(`/admin/moderation/appeals/${id}/resolve`, { method: 'POST', body: data }), + getUserAppeals: (discordId) => req(`/admin/moderation/user/${discordId}/appeals`), + // ----- account security (self-service 2FA) ----- getAccount: () => req('/admin/account'), totpSetup: () => req('/admin/account/totp/setup', { method: 'POST' }), @@ -330,6 +345,12 @@ export const api = { createAccount: (account, password) => req('/player/shard/account', { method: 'POST', body: { account, password } }), }, + + // ----- moderation appeals (self-service) ----- + getMyAppeals: () => req('/player/appeals'), + getEligibleAppeals: () => req('/player/appeals/eligible'), + submitAppeal: (data) => req('/player/appeals', { method: 'POST', body: data }), + withdrawAppeal: (id) => req(`/player/appeals/${id}/withdraw`, { method: 'POST' }), }, } diff --git a/client/src/routes/admin/AdminLayout.jsx b/client/src/routes/admin/AdminLayout.jsx index 4d1a16a..6c3826f 100644 --- a/client/src/routes/admin/AdminLayout.jsx +++ b/client/src/routes/admin/AdminLayout.jsx @@ -63,6 +63,7 @@ const NAV = [ title: 'Moderation', items: [ { to: '/admin/moderation', label: 'Moderation', icon: IconShield, roles: ['admin', 'moderator'] }, + { to: '/admin/moderation/appeals', label: 'Appeals', icon: IconShield, roles: ['admin', 'moderator'] }, { to: '/admin/shard-ops', label: 'In-Game Ops', icon: IconShard, roles: ['admin', 'moderator'] }, { to: '/admin/houses', label: 'Houses', icon: IconShard, roles: ['admin', 'moderator'] }, ], @@ -97,6 +98,7 @@ const TITLES = { '/admin/wiki': 'Wiki Pages', '/admin/hero': 'Hero Editor', '/admin/moderation': 'Moderation', + '/admin/moderation/appeals': 'Appeals', '/admin/shard-ops': 'In-Game Ops', '/admin/houses': 'House Registry', '/admin/settings': 'Site Settings', @@ -145,7 +147,7 @@ export default function AdminLayout() { // Moderators only get the moderation section (Discord + in-game ops) + their // own account security. const isModerator = user?.role === 'moderator' - const MOD_PATHS = ['/admin/moderation', '/admin/shard-ops', '/admin/houses', '/admin/account'] + const MOD_PATHS = ['/admin/moderation', '/admin/moderation/appeals', '/admin/shard-ops', '/admin/houses', '/admin/account'] const visible = (item) => { if (item.roles && !item.roles.includes(user?.role)) return false if (isModerator) return MOD_PATHS.includes(item.to) diff --git a/client/src/routes/admin/views/Appeals.jsx b/client/src/routes/admin/views/Appeals.jsx new file mode 100644 index 0000000..95f780d --- /dev/null +++ b/client/src/routes/admin/views/Appeals.jsx @@ -0,0 +1,286 @@ +import { useCallback, useState } from 'react' +import { useNavigate } from 'react-router-dom' +import Modal from '../../../components/Modal.jsx' +import { Loading, ErrorState } from '../../../components/PageState.jsx' +import { useAsync } from '../../../lib/useAsync.js' +import { ago, dateTime } from '../../../lib/format.js' +import { api } from '../../../api/client.js' + +// Staff queue for moderation appeals (bans/mutes appealed by players). Mirrors +// the Moderation.jsx tile/feed layout: a status-filter segmented control over a +// flat table, with per-row Claim / Resolve actions. Resolve opens a modal — no +// browser confirm()/alert() anywhere here. + +const STATUS_TABS = [ + { key: 'open', label: 'Open', param: undefined }, + { key: 'pending', label: 'Pending', param: 'pending' }, + { key: 'under_review', label: 'Under review', param: 'under_review' }, + { key: 'approved', label: 'Approved', param: 'approved' }, + { key: 'denied', label: 'Denied', param: 'denied' }, + { key: 'withdrawn', label: 'Withdrawn', param: 'withdrawn' }, + { key: 'all', label: 'All', param: 'all' }, +] + +const STATUS_STYLE = { + pending: { color: '#e0b070', background: 'rgba(224,176,112,0.12)', border: '1px solid rgba(224,176,112,0.4)' }, + under_review: { color: '#7fa8d0', background: 'rgba(127,168,208,0.14)', border: '1px solid rgba(127,168,208,0.4)' }, + approved: { color: '#7fd0a4', background: 'rgba(95,185,138,0.16)', border: '1px solid rgba(95,185,138,0.4)' }, + denied: { color: '#d98b84', background: 'rgba(217,139,132,0.16)', border: '1px solid rgba(217,139,132,0.4)' }, + withdrawn: { color: '#9fb0c6', background: 'rgba(127,153,189,0.14)', border: '1px solid var(--line)' }, +} +const STATUS_LABEL = { + pending: 'Pending', + under_review: 'Under review', + approved: 'Approved', + denied: 'Denied', + withdrawn: 'Withdrawn', +} + +function excerpt(text, n = 90) { + if (!text) return '' + return text.length > n ? `${text.slice(0, n)}…` : text +} + +export default function Appeals() { + const navigate = useNavigate() + const [tab, setTab] = useState('open') + const [tick, setTick] = useState(0) + const reload = useCallback(() => setTick((t) => t + 1), []) + const [busyId, setBusyId] = useState('') + const [resolving, setResolving] = useState(null) // the appeal being resolved + const [notice, setNotice] = useState(null) // { text, tone } + + const activeTab = STATUS_TABS.find((t) => t.key === tab) || STATUS_TABS[0] + const { loading, error, data } = useAsync( + () => api.admin.getAppeals({ status: activeTab.param, limit: 100 }), + [tab, tick], + ) + + const goUser = (id) => navigate(`/admin/moderation/user/${id}`) + + async function claim(appeal) { + setBusyId(appeal.id) + setNotice(null) + try { + await api.admin.claimAppeal(appeal.id) + reload() + } catch (err) { + setNotice({ text: err.message || 'Could not claim this appeal.', tone: 'error' }) + } finally { + setBusyId('') + } + } + + function onResolved(appeal, result) { + setResolving(null) + const { reversal } = result + if (reversal?.attempted && reversal.ok) { + setNotice({ text: `Discord ${appeal.action_type} lifted.`, tone: 'ok' }) + } else if (reversal?.attempted && !reversal.ok) { + setNotice({ text: 'Reversal failed — reverse manually in Discord.', tone: 'error' }) + } else { + setNotice(null) + } + reload() + } + + if (loading) return + if (error) return + + const rows = data || [] + + return ( +
+ {/* Status filter */} +
+ {STATUS_TABS.map((t) => ( + + ))} +
+ + {notice && ( +

+ {notice.text} +

+ )} + +
+ + + + + + + + + + + + + + {rows.length === 0 && ( + + + + )} + {rows.map((a) => ( + + + + + + + + + + + ))} + +
TargetActionAppealSubmitted byAgeStatusReversal +
+ No appeals match this filter. +
+ goUser(a.discord_user_id)}> + {a.action_target_tag || a.discord_user_id} + + + {a.action_type} + + {excerpt(a.submitted_text)} + {a.submitter_username || '—'}{ago(a.submitted_at)} + {STATUS_LABEL[a.status] || a.status} + + {a.reversal_status === 'done' && Lifted} + {a.reversal_status === 'failed' && Failed} + {(!a.reversal_status || a.reversal_status === 'none') && '—'} + + {a.status === 'pending' && ( + + )} + {(a.status === 'pending' || a.status === 'under_review') && ( + + )} +
+
+ + {resolving && ( + setResolving(null)} onResolved={onResolved} /> + )} +
+ ) +} + +function ResolveModal({ appeal, onClose, onResolved }) { + const [status, setStatus] = useState('approved') + const [staffResponse, setStaffResponse] = useState('') + const [busy, setBusy] = useState(false) + const [error, setError] = useState('') + + async function submit() { + setBusy(true) + setError('') + try { + const result = await api.admin.resolveAppeal(appeal.id, { + status, + staff_response: staffResponse.trim() || undefined, + }) + onResolved(appeal, result) + } catch (err) { + setError(err.message || 'Could not resolve this appeal.') + setBusy(false) + } + } + + return ( + + + + + } + > +
+ {error &&

{error}

} + +
+ Submitted appeal +
+ {appeal.submitted_text} +
+
+ +
+ + +
+ +