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:
@@ -35,6 +35,7 @@ export default function ModerationUser() {
|
||||
api.admin.modUser(discordId),
|
||||
api.admin.modUserActions(discordId, { limit: 200 }),
|
||||
api.admin.modUserNotes(discordId),
|
||||
api.admin.getUserAppeals(discordId),
|
||||
]),
|
||||
[discordId, tick],
|
||||
)
|
||||
@@ -42,7 +43,7 @@ export default function ModerationUser() {
|
||||
if (loading) return <Loading />
|
||||
if (error) return <ErrorState message="Could not load this user’s history." />
|
||||
|
||||
const [summary, actions, notes] = data
|
||||
const [summary, actions, notes, appeals] = data
|
||||
const counts = summary.counts || {}
|
||||
const tabActions = actions.filter((a) => a.action_type === tab)
|
||||
|
||||
@@ -83,10 +84,15 @@ export default function ModerationUser() {
|
||||
<TabButton active={tab === 'notes'} onClick={() => setTab('notes')}>
|
||||
Notes ({summary.notes_count || 0})
|
||||
</TabButton>
|
||||
<TabButton active={tab === 'appeals'} onClick={() => setTab('appeals')}>
|
||||
Appeals ({appeals.length})
|
||||
</TabButton>
|
||||
</div>
|
||||
|
||||
{tab === 'notes' ? (
|
||||
<NotesTab discordId={discordId} notes={notes} isAdmin={isAdmin} onAdded={reload} />
|
||||
) : tab === 'appeals' ? (
|
||||
<AppealsTab rows={appeals} />
|
||||
) : (
|
||||
<ActionTable rows={tabActions} showDuration={tab === 'mute'} />
|
||||
)}
|
||||
@@ -164,6 +170,63 @@ function ActionTable({ rows, showDuration }) {
|
||||
)
|
||||
}
|
||||
|
||||
const APPEAL_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 APPEAL_STATUS_LABEL = {
|
||||
pending: 'Pending',
|
||||
under_review: 'Under review',
|
||||
approved: 'Approved',
|
||||
denied: 'Denied',
|
||||
withdrawn: 'Withdrawn',
|
||||
}
|
||||
|
||||
function AppealsTab({ rows }) {
|
||||
return (
|
||||
<div className="panel-flat">
|
||||
<table className="adm-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th className="adm-th">Action</th>
|
||||
<th className="adm-th">Appeal</th>
|
||||
<th className="adm-th">Staff response</th>
|
||||
<th className="adm-th">Status</th>
|
||||
<th className="adm-th">Reversal</th>
|
||||
<th className="adm-th">When</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.length === 0 && (
|
||||
<tr>
|
||||
<td className="adm-td" colSpan={6} style={{ color: 'var(--muted)' }}>No appeals from this user.</td>
|
||||
</tr>
|
||||
)}
|
||||
{rows.map((a) => (
|
||||
<tr key={a.id}>
|
||||
<td className="adm-td"><span className={`badge badge-${a.action_type}`}>{a.action_type}</span></td>
|
||||
<td className="adm-td" style={{ color: 'var(--text)', maxWidth: 260, whiteSpace: 'pre-wrap' }}>{a.submitted_text}</td>
|
||||
<td className="adm-td dim" style={{ maxWidth: 220, whiteSpace: 'pre-wrap' }}>{a.staff_response || '—'}</td>
|
||||
<td className="adm-td">
|
||||
<span className="badge" style={APPEAL_STATUS_STYLE[a.status]}>{APPEAL_STATUS_LABEL[a.status] || a.status}</span>
|
||||
</td>
|
||||
<td className="adm-td dim">
|
||||
{a.reversal_status === 'done' && <span style={{ color: '#7fd0a4' }}>Lifted</span>}
|
||||
{a.reversal_status === 'failed' && <span style={{ color: '#d98b84' }}>Failed</span>}
|
||||
{(!a.reversal_status || a.reversal_status === 'none') && '—'}
|
||||
</td>
|
||||
<td className="adm-td dim" title={dateTime(a.submitted_at)}>{ago(a.submitted_at)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function NotesTab({ discordId, notes, isAdmin, onAdded }) {
|
||||
const [body, setBody] = useState('')
|
||||
const [visibility, setVisibility] = useState('staff_only')
|
||||
|
||||
Reference in New Issue
Block a user