Merge branch 'main' into feat/password-reset
This commit is contained in:
@@ -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 }
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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'
|
||||
@@ -62,6 +63,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 (
|
||||
@@ -134,6 +136,7 @@ export default function App() {
|
||||
>
|
||||
<Route index element={<Moderation />} />
|
||||
<Route path="user/:discordId" element={<ModerationUser />} />
|
||||
<Route path="appeals" element={<Appeals />} />
|
||||
</Route>
|
||||
<Route path="activity" element={<ActivityAdmin />} />
|
||||
<Route path="bot-activity" element={<BotActivityAdmin />} />
|
||||
@@ -181,6 +184,7 @@ export default function App() {
|
||||
<Route path="/player" element={<PlayerCharacters />} />
|
||||
<Route path="/player/char/:serial" element={<PlayerCharacter />} />
|
||||
<Route path="/account" element={<PlayerAccount />} />
|
||||
<Route path="/account/appeals" element={<PlayerAppeals />} />
|
||||
</Route>
|
||||
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
|
||||
@@ -250,6 +250,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' }),
|
||||
@@ -338,6 +353,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' }),
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
286
client/src/routes/admin/views/Appeals.jsx
Normal file
286
client/src/routes/admin/views/Appeals.jsx
Normal file
@@ -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 <Loading />
|
||||
if (error) return <ErrorState message="Could not load appeals." />
|
||||
|
||||
const rows = data || []
|
||||
|
||||
return (
|
||||
<section>
|
||||
{/* Status filter */}
|
||||
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap', marginBottom: 16 }}>
|
||||
{STATUS_TABS.map((t) => (
|
||||
<button
|
||||
key={t.key}
|
||||
onClick={() => setTab(t.key)}
|
||||
className="pill"
|
||||
style={tab === t.key ? activePill : undefined}
|
||||
>
|
||||
{t.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{notice && (
|
||||
<p
|
||||
className="sans"
|
||||
style={{ margin: '0 0 14px', color: notice.tone === 'error' ? '#d98b84' : '#7fd0a4', fontSize: '0.85rem' }}
|
||||
>
|
||||
{notice.text}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="panel-flat">
|
||||
<table className="adm-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th className="adm-th">Target</th>
|
||||
<th className="adm-th">Action</th>
|
||||
<th className="adm-th">Appeal</th>
|
||||
<th className="adm-th">Submitted by</th>
|
||||
<th className="adm-th">Age</th>
|
||||
<th className="adm-th">Status</th>
|
||||
<th className="adm-th">Reversal</th>
|
||||
<th className="adm-th" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.length === 0 && (
|
||||
<tr>
|
||||
<td className="adm-td" colSpan={8} style={muted}>
|
||||
No appeals match this filter.
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
{rows.map((a) => (
|
||||
<tr key={a.id}>
|
||||
<td className="adm-td">
|
||||
<span className="link-accent" onClick={() => goUser(a.discord_user_id)}>
|
||||
{a.action_target_tag || a.discord_user_id}
|
||||
</span>
|
||||
</td>
|
||||
<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: 320 }}>
|
||||
{excerpt(a.submitted_text)}
|
||||
</td>
|
||||
<td className="adm-td dim">{a.submitter_username || '—'}</td>
|
||||
<td className="adm-td dim" title={dateTime(a.submitted_at)}>{ago(a.submitted_at)}</td>
|
||||
<td className="adm-td">
|
||||
<span className="badge" style={STATUS_STYLE[a.status]}>{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" style={{ textAlign: 'right', whiteSpace: 'nowrap' }}>
|
||||
{a.status === 'pending' && (
|
||||
<button
|
||||
onClick={() => claim(a)}
|
||||
disabled={busyId === a.id}
|
||||
className="pill"
|
||||
style={{ marginRight: 6 }}
|
||||
>
|
||||
{busyId === a.id ? 'Claiming…' : 'Claim'}
|
||||
</button>
|
||||
)}
|
||||
{(a.status === 'pending' || a.status === 'under_review') && (
|
||||
<button onClick={() => setResolving(a)} className="btn btn-primary btn-sq" style={{ padding: '5px 12px', fontSize: '0.82rem' }}>
|
||||
Resolve
|
||||
</button>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{resolving && (
|
||||
<ResolveModal appeal={resolving} onClose={() => setResolving(null)} onResolved={onResolved} />
|
||||
)}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
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 (
|
||||
<Modal
|
||||
title={`Resolve appeal — ${appeal.action_target_tag || appeal.discord_user_id}`}
|
||||
onClose={onClose}
|
||||
width={560}
|
||||
footer={
|
||||
<>
|
||||
<button onClick={onClose} disabled={busy} className="pill">
|
||||
Cancel
|
||||
</button>
|
||||
<button onClick={submit} disabled={busy} className="btn btn-primary btn-sq">
|
||||
{busy ? 'Saving…' : `Mark ${status === 'approved' ? 'approved' : 'denied'}`}
|
||||
</button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
|
||||
{error && <p className="sans" style={{ margin: 0, color: '#d98b84', fontSize: '0.85rem' }}>{error}</p>}
|
||||
|
||||
<div>
|
||||
<span className="field-label">Submitted appeal</span>
|
||||
<div
|
||||
className="sans"
|
||||
style={{
|
||||
marginTop: 6,
|
||||
padding: '10px 12px',
|
||||
border: '1px solid var(--line)',
|
||||
borderRadius: 8,
|
||||
color: 'var(--text)',
|
||||
fontSize: '0.86rem',
|
||||
whiteSpace: 'pre-wrap',
|
||||
maxHeight: 200,
|
||||
overflow: 'auto',
|
||||
}}
|
||||
>
|
||||
{appeal.submitted_text}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', gap: 10 }}>
|
||||
<button
|
||||
onClick={() => setStatus('approved')}
|
||||
className="pill"
|
||||
style={status === 'approved' ? { background: 'var(--blue)', color: 'var(--ink)', borderColor: '#7fd0a4' } : undefined}
|
||||
>
|
||||
Approve
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setStatus('denied')}
|
||||
className="pill"
|
||||
style={status === 'denied' ? { background: 'var(--blue)', color: 'var(--ink)', borderColor: '#d98b84' } : undefined}
|
||||
>
|
||||
Deny
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<label>
|
||||
<span className="field-label">Staff response (optional)</span>
|
||||
<textarea
|
||||
className="textarea"
|
||||
placeholder="Message shown to the player…"
|
||||
value={staffResponse}
|
||||
onChange={(e) => setStaffResponse(e.target.value)}
|
||||
rows={4}
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
||||
const activePill = { background: 'var(--blue)', color: 'var(--ink)', borderColor: 'var(--accent)' }
|
||||
const muted = { color: 'var(--muted)' }
|
||||
@@ -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')
|
||||
|
||||
221
client/src/routes/player/PlayerAppeals.jsx
Normal file
221
client/src/routes/player/PlayerAppeals.jsx
Normal file
@@ -0,0 +1,221 @@
|
||||
import { useCallback, useState } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
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'
|
||||
|
||||
// Player-facing appeals: eligible sanctions the player can appeal, plus the
|
||||
// status of appeals they've already submitted. Mirrors PlayerAccount's
|
||||
// Section layout.
|
||||
|
||||
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 fmtDuration(seconds) {
|
||||
if (!seconds) return null
|
||||
if (seconds % 86400 === 0) return `${seconds / 86400}d`
|
||||
if (seconds % 3600 === 0) return `${seconds / 3600}h`
|
||||
if (seconds % 60 === 0) return `${seconds / 60}m`
|
||||
return `${seconds}s`
|
||||
}
|
||||
|
||||
function Section({ title, children }) {
|
||||
return (
|
||||
<section style={{ borderTop: '1px solid var(--line-soft)', paddingTop: 26, marginTop: 26 }}>
|
||||
<h2 className="display" style={{ marginTop: 0, fontSize: '1.15rem', color: 'var(--head)' }}>{title}</h2>
|
||||
{children}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
function EligibleItem({ item, onSubmitted }) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const [text, setText] = useState('')
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
|
||||
async function submit() {
|
||||
if (!text.trim()) return
|
||||
setBusy(true)
|
||||
setError('')
|
||||
try {
|
||||
await api.player.submitAppeal({ mod_action_id: item.id, submitted_text: text.trim() })
|
||||
setText('')
|
||||
setOpen(false)
|
||||
onSubmitted()
|
||||
} catch (err) {
|
||||
setError(err.message || 'Could not submit your appeal.')
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
const duration = fmtDuration(item.duration_seconds)
|
||||
|
||||
return (
|
||||
<div className="panel" style={{ padding: '14px 16px' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12, flexWrap: 'wrap' }}>
|
||||
<span className={`badge badge-${item.action_type}`}>{item.action_type}</span>
|
||||
{duration && <span className="sans dim" style={{ fontSize: '0.78rem' }}>{duration}</span>}
|
||||
<span className="sans dim" style={{ fontSize: '0.78rem', marginLeft: 'auto' }} title={dateTime(item.created_at)}>
|
||||
{ago(item.created_at)}
|
||||
</span>
|
||||
</div>
|
||||
<p className="sans" style={{ margin: '10px 0 0', color: 'var(--text)', fontSize: '0.88rem' }}>
|
||||
{item.reason || 'No reason given.'}
|
||||
</p>
|
||||
|
||||
{!open ? (
|
||||
<div style={{ marginTop: 12 }}>
|
||||
<button onClick={() => setOpen(true)} className="btn btn-primary btn-sq">
|
||||
Appeal this
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ marginTop: 12, display: 'flex', flexDirection: 'column', gap: 10 }}>
|
||||
{error && <p className="sans" style={{ margin: 0, color: '#d98b84', fontSize: '0.85rem' }}>{error}</p>}
|
||||
<textarea
|
||||
className="textarea"
|
||||
placeholder="Explain why this action should be reversed…"
|
||||
value={text}
|
||||
onChange={(e) => setText(e.target.value)}
|
||||
rows={4}
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
<div style={{ display: 'flex', gap: 10 }}>
|
||||
<button onClick={submit} disabled={busy || !text.trim()} className="btn btn-primary btn-sq">
|
||||
{busy ? 'Submitting…' : 'Submit appeal'}
|
||||
</button>
|
||||
<button onClick={() => { setOpen(false); setError('') }} disabled={busy} className="pill">
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function EligibleAppeals({ items, onSubmitted }) {
|
||||
if (items.length === 0) {
|
||||
return (
|
||||
<div>
|
||||
<p className="sans dim" style={{ fontSize: '0.88rem' }}>You have no sanctions available to appeal right now.</p>
|
||||
<p className="sans dim" style={{ fontSize: '0.82rem' }}>
|
||||
If you were sanctioned on Discord, link your Discord account on the{' '}
|
||||
<Link to="/account" className="link-accent">Account</Link> page to appeal.
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||
{items.map((item) => (
|
||||
<EligibleItem key={item.id} item={item} onSubmitted={onSubmitted} />
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function MyAppealItem({ appeal, onWithdrawn }) {
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
const canWithdraw = appeal.status === 'pending' || appeal.status === 'under_review'
|
||||
|
||||
async function withdraw() {
|
||||
setBusy(true)
|
||||
setError('')
|
||||
try {
|
||||
await api.player.withdrawAppeal(appeal.id)
|
||||
onWithdrawn()
|
||||
} catch (err) {
|
||||
setError(err.message || 'Could not withdraw this appeal.')
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="panel" style={{ padding: '14px 16px' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12, flexWrap: 'wrap' }}>
|
||||
<span className={`badge badge-${appeal.action_type}`}>{appeal.action_type}</span>
|
||||
<span className="badge" style={STATUS_STYLE[appeal.status]}>{STATUS_LABEL[appeal.status] || appeal.status}</span>
|
||||
<span className="sans dim" style={{ fontSize: '0.78rem', marginLeft: 'auto' }} title={dateTime(appeal.submitted_at)}>
|
||||
{ago(appeal.submitted_at)}
|
||||
</span>
|
||||
</div>
|
||||
<p className="sans" style={{ margin: '10px 0 0', color: 'var(--text)', fontSize: '0.88rem', whiteSpace: 'pre-wrap' }}>
|
||||
{appeal.submitted_text}
|
||||
</p>
|
||||
{appeal.staff_response && (
|
||||
<div style={{ marginTop: 10, padding: '10px 12px', border: '1px solid var(--line)', borderRadius: 8 }}>
|
||||
<div className="field-label" style={{ marginBottom: 4 }}>Staff response</div>
|
||||
<p className="sans dim" style={{ margin: 0, fontSize: '0.86rem', whiteSpace: 'pre-wrap' }}>{appeal.staff_response}</p>
|
||||
</div>
|
||||
)}
|
||||
{error && <p className="sans" style={{ margin: '10px 0 0', color: '#d98b84', fontSize: '0.85rem' }}>{error}</p>}
|
||||
{canWithdraw && (
|
||||
<div style={{ marginTop: 12 }}>
|
||||
<button onClick={withdraw} disabled={busy} className="pill" style={{ color: '#d98b84', borderColor: '#d98b84' }}>
|
||||
{busy ? 'Withdrawing…' : 'Withdraw'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function MyAppeals({ appeals, onChange }) {
|
||||
if (appeals.length === 0) {
|
||||
return <p className="sans dim" style={{ fontSize: '0.88rem' }}>You haven't submitted any appeals yet.</p>
|
||||
}
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||
{appeals.map((a) => (
|
||||
<MyAppealItem key={a.id} appeal={a} onWithdrawn={onChange} />
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function PlayerAppeals() {
|
||||
const [tick, setTick] = useState(0)
|
||||
const reload = useCallback(() => setTick((t) => t + 1), [])
|
||||
const { loading, error, data } = useAsync(
|
||||
() => Promise.all([api.player.getEligibleAppeals(), api.player.getMyAppeals()]),
|
||||
[tick],
|
||||
)
|
||||
|
||||
if (loading) return <Loading />
|
||||
if (error) return <ErrorState message="Could not load your appeals." />
|
||||
|
||||
const [eligible, mine] = data
|
||||
|
||||
return (
|
||||
<div>
|
||||
<p className="sans" style={{ margin: 0, color: 'var(--muted)', fontSize: '0.9rem' }}>
|
||||
Appeal a Discord ban or mute, or check the status of an appeal you've already submitted.
|
||||
</p>
|
||||
|
||||
<Section title="Appealable sanctions">
|
||||
<EligibleAppeals items={eligible} onSubmitted={reload} />
|
||||
</Section>
|
||||
|
||||
<Section title="My appeals">
|
||||
<MyAppeals appeals={mine} onChange={reload} />
|
||||
</Section>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -28,10 +28,12 @@ function Icon({ children, size = 16 }) {
|
||||
}
|
||||
const IconUser = () => <Icon><circle cx="12" cy="8" r="4" /><path d="M4 21a8 8 0 0 1 16 0" /></Icon>
|
||||
const IconGear = () => <Icon><circle cx="12" cy="12" r="3" /><path d="M12 2v3M12 19v3M2 12h3M19 12h3M4.9 4.9l2.1 2.1M17 17l2.1 2.1M19.1 4.9L17 7M7 17l-2.1 2.1" /></Icon>
|
||||
const IconShield = () => <Icon><path d="M12 3l7 3v5c0 5-3.5 8-7 10-3.5-2-7-5-7-10V6z" /><path d="M9 12l2 2 4-4" /></Icon>
|
||||
|
||||
const NAV = [
|
||||
{ to: '/player', label: 'Characters', end: true, icon: IconUser },
|
||||
{ to: '/account', label: 'Account', icon: IconGear },
|
||||
{ to: '/account/appeals', label: 'Appeals', icon: IconShield },
|
||||
{ to: '/account', label: 'Account', end: true, icon: IconGear },
|
||||
]
|
||||
|
||||
// The sticky content header mirrors the active page. Character sheets live under
|
||||
@@ -39,6 +41,7 @@ const NAV = [
|
||||
const TITLES = {
|
||||
'/player': 'Characters',
|
||||
'/account': 'Account',
|
||||
'/account/appeals': 'Appeals',
|
||||
}
|
||||
|
||||
const navBtnBase = {
|
||||
|
||||
@@ -594,6 +594,35 @@ CREATE TABLE IF NOT EXISTS mod_actions (
|
||||
INDEX idx_mod_actions_target (guild_id, target_user_id, created_at)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
-- Player-submitted moderation appeals (Phase 6c). Unlike mod_actions above, this
|
||||
-- table is SERVER-owned — written and read only by the main site (the player
|
||||
-- appeals controller and the admin moderation queue), never by the bot. A player
|
||||
-- appeals one of their own ban/mute mod_actions; staff triage the queue, and an
|
||||
-- approval optionally triggers an automatic Discord reversal (Phase 6d) whose
|
||||
-- outcome is recorded in reversal_status. mod_action_id is a plain column with NO
|
||||
-- hard FK to the bot-owned mod_actions table (cross-owner FK avoided on purpose,
|
||||
-- matching posts.announce_job_id) — existence is validated in app code. user_id
|
||||
-- is the appealing site account; discord_user_id is the snowflake the appeal is
|
||||
-- for (snapshotted from mod_actions.target_user_id at submit time).
|
||||
CREATE TABLE IF NOT EXISTS appeals (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
mod_action_id INT NOT NULL,
|
||||
discord_user_id VARCHAR(32) NOT NULL,
|
||||
action_type ENUM('ban','mute') NOT NULL,
|
||||
user_id INT NULL,
|
||||
status ENUM('pending','under_review','approved','denied','withdrawn') NOT NULL DEFAULT 'pending',
|
||||
submitted_text TEXT NOT NULL,
|
||||
staff_response TEXT NULL,
|
||||
handled_by_user_id INT NULL,
|
||||
handled_by_tag VARCHAR(120) NULL,
|
||||
reversal_status ENUM('none','done','failed') NOT NULL DEFAULT 'none',
|
||||
submitted_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
resolved_at DATETIME NULL,
|
||||
CONSTRAINT fk_appeal_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE SET NULL,
|
||||
INDEX idx_appeals_status (status, submitted_at),
|
||||
INDEX idx_appeals_action (mod_action_id)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
-- Standing warnings, separate from mod_actions so /warnings can list active
|
||||
-- warnings per user. expires_at is unused in Phase 2 (no decay/escalation
|
||||
-- yet — deferred, see mute/warn command comments) but the column is cheap to
|
||||
|
||||
156
server/src/model/appeals/appeals.db.js
Normal file
156
server/src/model/appeals/appeals.db.js
Normal file
@@ -0,0 +1,156 @@
|
||||
// Data-access for the server-owned `appeals` table (Phase 6c). Mirrors the
|
||||
// modNotes/moderation split: this module is the only place that touches the
|
||||
// table's SQL. Reads LEFT JOIN the bot-owned mod_actions row (no hard FK — the
|
||||
// join is by the plain mod_action_id column) to surface the original action's
|
||||
// target/reason/created_at, and LEFT JOIN users to surface the submitter's
|
||||
// username. All writes belong to the site (the bot never touches this table).
|
||||
const { query } = require('../../utils/db')
|
||||
|
||||
// Shared SELECT for a single appeal enriched with the originating action + the
|
||||
// submitting account. ma.* columns are null when the mod_action was purged.
|
||||
const APPEAL_SELECT = `
|
||||
SELECT a.id, a.mod_action_id, a.discord_user_id, a.action_type, a.user_id,
|
||||
a.status, a.submitted_text, a.staff_response,
|
||||
a.handled_by_user_id, a.handled_by_tag, a.reversal_status,
|
||||
a.submitted_at, a.resolved_at,
|
||||
ma.target_tag AS action_target_tag,
|
||||
ma.reason AS action_reason,
|
||||
ma.created_at AS action_created_at,
|
||||
ma.duration_seconds AS action_duration_seconds,
|
||||
submitter.username AS submitter_username
|
||||
FROM appeals a
|
||||
LEFT JOIN mod_actions ma ON ma.id = a.mod_action_id
|
||||
LEFT JOIN users submitter ON submitter.id = a.user_id`
|
||||
|
||||
async function insert({ modActionId, discordUserId, actionType, userId = null, submittedText }) {
|
||||
const res = await query(
|
||||
`INSERT INTO appeals (mod_action_id, discord_user_id, action_type, user_id, submitted_text)
|
||||
VALUES (?, ?, ?, ?, ?)`,
|
||||
[modActionId, discordUserId, actionType, userId, submittedText],
|
||||
)
|
||||
return res.insertId
|
||||
}
|
||||
|
||||
async function getById(id) {
|
||||
const rows = await query(`${APPEAL_SELECT} WHERE a.id = ? LIMIT 1`, [id])
|
||||
return rows[0] || null
|
||||
}
|
||||
|
||||
// The caller's own appeals, newest first (My Appeals page).
|
||||
async function listForUser(userId) {
|
||||
return query(`${APPEAL_SELECT} WHERE a.user_id = ? ORDER BY a.id DESC`, [userId])
|
||||
}
|
||||
|
||||
// All appeals for a Discord id (admin per-user view), newest first.
|
||||
async function listForDiscordUser(discordUserId) {
|
||||
return query(`${APPEAL_SELECT} WHERE a.discord_user_id = ? ORDER BY a.id DESC`, [discordUserId])
|
||||
}
|
||||
|
||||
// The staff queue: filtered to a set of statuses (array), newest first, paged.
|
||||
// An empty `statuses` returns nothing rather than the whole table.
|
||||
async function listQueue({ statuses = [], limit = 50, offset = 0 } = {}) {
|
||||
if (!statuses.length) return []
|
||||
const placeholders = statuses.map(() => '?').join(', ')
|
||||
return query(
|
||||
`${APPEAL_SELECT} WHERE a.status IN (${placeholders})
|
||||
ORDER BY a.submitted_at ASC, a.id ASC
|
||||
LIMIT ? OFFSET ?`,
|
||||
[...statuses, limit, offset],
|
||||
)
|
||||
}
|
||||
|
||||
// The active (pending/under_review) appeal for a mod_action, or null. Used to
|
||||
// enforce one-active-appeal-per-action.
|
||||
async function activeForAction(modActionId) {
|
||||
const rows = await query(
|
||||
`SELECT id, status FROM appeals
|
||||
WHERE mod_action_id = ? AND status IN ('pending','under_review')
|
||||
LIMIT 1`,
|
||||
[modActionId],
|
||||
)
|
||||
return rows[0] || null
|
||||
}
|
||||
|
||||
// The caller's ban/mute mod_actions that have NO active appeal — the set of
|
||||
// actions the player is allowed to open an appeal against. Left-anti-join
|
||||
// against active appeals for the same action id.
|
||||
async function eligibleActions(discordUserId) {
|
||||
return query(
|
||||
`SELECT ma.id, ma.action_type, ma.target_tag, ma.reason,
|
||||
ma.duration_seconds, ma.created_at
|
||||
FROM mod_actions ma
|
||||
LEFT JOIN appeals a
|
||||
ON a.mod_action_id = ma.id AND a.status IN ('pending','under_review')
|
||||
WHERE ma.target_user_id = ?
|
||||
AND ma.action_type IN ('ban','mute')
|
||||
AND a.id IS NULL
|
||||
ORDER BY ma.created_at DESC`,
|
||||
[discordUserId],
|
||||
)
|
||||
}
|
||||
|
||||
// Read a single bot-owned mod_action by id, for submit-time validation (does it
|
||||
// exist? is it the caller's? is it appealable?). Read-only — the site never
|
||||
// writes mod_actions. Returns null when the id is unknown.
|
||||
async function getModAction(modActionId) {
|
||||
const rows = await query(
|
||||
`SELECT id, action_type, target_user_id, target_tag, reason, duration_seconds, created_at
|
||||
FROM mod_actions WHERE id = ? LIMIT 1`,
|
||||
[modActionId],
|
||||
)
|
||||
return rows[0] || null
|
||||
}
|
||||
|
||||
// pending -> under_review, stamping the claiming staffer.
|
||||
async function setUnderReview(id, { handlerUserId, handlerTag }) {
|
||||
await query(
|
||||
`UPDATE appeals
|
||||
SET status = 'under_review', handled_by_user_id = ?, handled_by_tag = ?
|
||||
WHERE id = ?`,
|
||||
[handlerUserId, handlerTag, id],
|
||||
)
|
||||
}
|
||||
|
||||
// Resolve to a terminal status (approved/denied), recording the staff response,
|
||||
// handler, reversal outcome, and resolution timestamp.
|
||||
async function resolve(id, { status, staffResponse, handlerUserId, handlerTag, reversalStatus }) {
|
||||
await query(
|
||||
`UPDATE appeals
|
||||
SET status = ?, staff_response = ?, handled_by_user_id = ?, handled_by_tag = ?,
|
||||
reversal_status = ?, resolved_at = NOW()
|
||||
WHERE id = ?`,
|
||||
[status, staffResponse ?? null, handlerUserId, handlerTag, reversalStatus, id],
|
||||
)
|
||||
}
|
||||
|
||||
// Straight status flip (used for withdraw). Stamps resolved_at when moving to a
|
||||
// terminal status so the row shows when it closed.
|
||||
async function setStatus(id, status) {
|
||||
await query(
|
||||
`UPDATE appeals
|
||||
SET status = ?,
|
||||
resolved_at = CASE WHEN ? IN ('approved','denied','withdrawn') THEN NOW() ELSE resolved_at END
|
||||
WHERE id = ?`,
|
||||
[status, status, id],
|
||||
)
|
||||
}
|
||||
|
||||
// Count of appeals grouped by status, for the queue's tab badges.
|
||||
async function countByStatus() {
|
||||
return query('SELECT status, COUNT(*) AS c FROM appeals GROUP BY status')
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
insert,
|
||||
getById,
|
||||
listForUser,
|
||||
listForDiscordUser,
|
||||
listQueue,
|
||||
activeForAction,
|
||||
eligibleActions,
|
||||
getModAction,
|
||||
setUnderReview,
|
||||
resolve,
|
||||
setStatus,
|
||||
countByStatus,
|
||||
}
|
||||
82
server/src/model/appeals/appeals.model.js
Normal file
82
server/src/model/appeals/appeals.model.js
Normal file
@@ -0,0 +1,82 @@
|
||||
// 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,
|
||||
}
|
||||
48
server/src/model/appeals/appeals.pure.js
Normal file
48
server/src/model/appeals/appeals.pure.js
Normal file
@@ -0,0 +1,48 @@
|
||||
// Pure helpers for the moderation-appeals feature (Phase 6c). No DB access —
|
||||
// just the status/type vocabularies and row-shaping so both the model layer and
|
||||
// the tests can reason about appeal state without a database.
|
||||
|
||||
// Terminal statuses: an appeal that has reached one of these is closed and can
|
||||
// no longer be withdrawn, claimed, or resolved.
|
||||
const TERMINAL = new Set(['approved', 'denied', 'withdrawn'])
|
||||
|
||||
// Active statuses: an appeal that still occupies the "one active appeal per
|
||||
// action" slot. A player cannot open a second appeal for a mod_action while one
|
||||
// of these is outstanding.
|
||||
const ACTIVE = new Set(['pending', 'under_review'])
|
||||
|
||||
// Only bans and mutes are appealable (kicks/warns are not — a kick is not a
|
||||
// standing state, and a warn carries no access restriction to reverse).
|
||||
const APPEALABLE_TYPES = new Set(['ban', 'mute'])
|
||||
|
||||
// True if a status is one an appeal can still transition away from.
|
||||
function isTerminal(status) {
|
||||
return TERMINAL.has(status)
|
||||
}
|
||||
|
||||
function isActive(status) {
|
||||
return ACTIVE.has(status)
|
||||
}
|
||||
|
||||
function isAppealableType(actionType) {
|
||||
return APPEALABLE_TYPES.has(actionType)
|
||||
}
|
||||
|
||||
// Map an approve/deny resolution to the reversal_status the row should carry.
|
||||
// A denial never reverses; an approval only reverses when the underlying action
|
||||
// is appealable (ban/mute) and the bot call succeeded.
|
||||
function reversalStatusFor({ status, actionType, botOk }) {
|
||||
if (status !== 'approved') return 'none'
|
||||
if (!APPEALABLE_TYPES.has(actionType)) return 'none'
|
||||
return botOk ? 'done' : 'failed'
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
TERMINAL,
|
||||
ACTIVE,
|
||||
APPEALABLE_TYPES,
|
||||
isTerminal,
|
||||
isActive,
|
||||
isAppealableType,
|
||||
reversalStatusFor,
|
||||
}
|
||||
@@ -1147,6 +1147,73 @@ adminRouter.post(
|
||||
moderation.addUserNote,
|
||||
)
|
||||
|
||||
// ── Appeals queue (Phase 6c, admin + moderator) ───────────────────────
|
||||
// Staff triage of player-submitted ban/mute appeals. Approving an appeal can
|
||||
// trigger an automatic Discord reversal (Phase 6d) — see resolveAppeal.
|
||||
adminRouter.get(
|
||||
'/moderation/appeals',
|
||||
// #swagger.tags = ['Admin · Moderation']
|
||||
// #swagger.summary = 'List moderation appeals (default: pending + under_review)'
|
||||
// #swagger.description = 'Filter with ?status=<pending|under_review|approved|denied|withdrawn> or ?status=all. Paginated with ?limit&offset.'
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
/* #swagger.responses[200] = { description: 'Appeals queue', content: { "application/json": { schema: { type: "array", items: { $ref: "#/components/schemas/AppealQueueItem" } } } } } */
|
||||
moderation.getAppeals,
|
||||
)
|
||||
adminRouter.get(
|
||||
'/moderation/appeals/:id',
|
||||
// #swagger.tags = ['Admin · Moderation']
|
||||
// #swagger.summary = 'Get a single moderation appeal'
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
// #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'Appeal id.' }
|
||||
/* #swagger.responses[200] = { description: 'The appeal', content: { "application/json": { schema: { $ref: "#/components/schemas/AppealQueueItem" } } } } */
|
||||
/* #swagger.responses[404] = { description: 'Appeal not found', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
param('id').isInt({ min: 1 }),
|
||||
validate,
|
||||
moderation.getAppeal,
|
||||
)
|
||||
adminRouter.post(
|
||||
'/moderation/appeals/:id/claim',
|
||||
// #swagger.tags = ['Admin · Moderation']
|
||||
// #swagger.summary = 'Claim a pending appeal (→ under_review)'
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
// #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'Appeal id.' }
|
||||
/* #swagger.responses[200] = { description: 'The claimed appeal', content: { "application/json": { schema: { $ref: "#/components/schemas/AppealQueueItem" } } } } */
|
||||
/* #swagger.responses[404] = { description: 'Appeal not found', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
/* #swagger.responses[409] = { description: 'Appeal is not open for claiming', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
param('id').isInt({ min: 1 }),
|
||||
validate,
|
||||
moderation.claimAppeal,
|
||||
)
|
||||
adminRouter.post(
|
||||
'/moderation/appeals/:id/resolve',
|
||||
// #swagger.tags = ['Admin · Moderation']
|
||||
// #swagger.summary = 'Resolve an appeal (approved | denied); approval may auto-reverse the Discord action'
|
||||
// #swagger.description = 'Approving a ban/mute appeal best-effort asks the bot to reverse the Discord action (unban / clear timeout). The bot being down never fails the resolution — reversal_status is recorded as failed. The response echoes the updated appeal plus a `reversal` object.'
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
// #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'Appeal id.' }
|
||||
/* #swagger.requestBody = { required: true, content: { "application/json": { schema: { $ref: "#/components/schemas/ResolveAppealRequest" } } } } */
|
||||
/* #swagger.responses[200] = { description: 'The resolved appeal (with reversal outcome)', content: { "application/json": { schema: { $ref: "#/components/schemas/AppealResolveResult" } } } } */
|
||||
/* #swagger.responses[400] = { description: 'Validation error (status must be approved or denied)', content: { "application/json": { schema: { $ref: "#/components/schemas/ValidationError" } } } } */
|
||||
/* #swagger.responses[404] = { description: 'Appeal not found', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
/* #swagger.responses[409] = { description: 'Appeal is already resolved', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
param('id').isInt({ min: 1 }),
|
||||
body('status').isIn(['approved', 'denied']),
|
||||
body('staff_response').optional({ values: 'falsy' }).isString().trim().isLength({ max: 4000 }),
|
||||
validate,
|
||||
moderation.resolveAppeal,
|
||||
)
|
||||
adminRouter.get(
|
||||
'/moderation/user/:discordId/appeals',
|
||||
// #swagger.tags = ['Admin · Moderation']
|
||||
// #swagger.summary = 'Appeals submitted for a Discord user'
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
// #swagger.parameters['discordId'] = { in: 'path', required: true, schema: { type: 'string' }, description: 'Discord snowflake.' }
|
||||
/* #swagger.responses[200] = { description: 'Appeals for the user', content: { "application/json": { schema: { type: "array", items: { $ref: "#/components/schemas/AppealQueueItem" } } } } } */
|
||||
param('discordId').matches(/^[0-9]{1,32}$/),
|
||||
validate,
|
||||
moderation.getUserAppeals,
|
||||
)
|
||||
|
||||
// ── User management (admin only) ──────────────────────────────────────
|
||||
adminRouter.use('/users', adminOnly)
|
||||
adminRouter.get(
|
||||
|
||||
@@ -5,10 +5,19 @@
|
||||
const moderation = require('../../../model/moderation/moderation.model')
|
||||
const modNotes = require('../../../model/modNotes/modNotes.model')
|
||||
const modNotesDb = require('../../../model/modNotes/modNotes.db')
|
||||
const appeals = require('../../../model/appeals/appeals.model')
|
||||
const { isTerminal, isAppealableType, reversalStatusFor } = require('../../../model/appeals/appeals.pure')
|
||||
const botInternalClient = require('../../../utils/botInternalClient')
|
||||
const activity = require('../../../model/activity/activity.model')
|
||||
|
||||
const log = require('../../../utils/logger')('moderation')
|
||||
|
||||
// The statuses the appeals queue can be filtered to. ?status=all expands to all
|
||||
// of them; a specific ?status=<value> narrows to one; the default is the open
|
||||
// set (pending + under_review) that still needs staff attention.
|
||||
const APPEAL_STATUSES = ['pending', 'under_review', 'approved', 'denied', 'withdrawn']
|
||||
const DEFAULT_APPEAL_STATUSES = ['pending', 'under_review']
|
||||
|
||||
const VALID_TYPES = new Set(['ban', 'kick', 'mute', 'warn'])
|
||||
const MAX_LIMIT = 200
|
||||
const DEFAULT_LIMIT = 50
|
||||
@@ -156,6 +165,136 @@ async function addUserNote(req, res) {
|
||||
}
|
||||
}
|
||||
|
||||
// ── Phase 6c: appeals staff queue ─────────────────────────────────────
|
||||
// The status filter for the queue: ?status=all → every status, ?status=<one> →
|
||||
// just that one (if valid), otherwise the default open set.
|
||||
function appealStatusFilter(req) {
|
||||
const s = req.query.status
|
||||
if (s === 'all') return APPEAL_STATUSES
|
||||
if (APPEAL_STATUSES.includes(s)) return [s]
|
||||
return DEFAULT_APPEAL_STATUSES
|
||||
}
|
||||
|
||||
async function getAppeals(req, res) {
|
||||
try {
|
||||
const { limit, offset } = pageParams(req)
|
||||
const statuses = appealStatusFilter(req)
|
||||
return res.json(await appeals.queue({ statuses, limit, offset }))
|
||||
} catch (err) {
|
||||
log.error('getAppeals failed', { error: err.message })
|
||||
return res.status(500).json({ message: 'Internal Server Error' })
|
||||
}
|
||||
}
|
||||
|
||||
async function getAppeal(req, res) {
|
||||
try {
|
||||
const appeal = await appeals.getById(Number(req.params.id))
|
||||
if (!appeal) return res.status(404).json({ message: 'Appeal not found' })
|
||||
return res.json(appeal)
|
||||
} catch (err) {
|
||||
log.error('getAppeal failed', { error: err.message })
|
||||
return res.status(500).json({ message: 'Internal Server Error' })
|
||||
}
|
||||
}
|
||||
|
||||
// Claim a pending appeal → under_review, stamping the claiming staffer. Only a
|
||||
// still-pending appeal can be claimed (a second claim, or claiming a resolved
|
||||
// one, is a 409).
|
||||
async function claimAppeal(req, res) {
|
||||
try {
|
||||
const appeal = await appeals.getById(Number(req.params.id))
|
||||
if (!appeal) return res.status(404).json({ message: 'Appeal not found' })
|
||||
if (appeal.status !== 'pending') {
|
||||
return res.status(409).json({ message: 'Appeal is not open for claiming' })
|
||||
}
|
||||
const updated = await appeals.claim(appeal.id, {
|
||||
handlerUserId: req.user.id,
|
||||
handlerTag: req.user.username,
|
||||
})
|
||||
await activity.log({
|
||||
req,
|
||||
action: 'moderation.appeal.claim',
|
||||
detail: { appealId: appeal.id, discordUserId: appeal.discord_user_id },
|
||||
})
|
||||
return res.json(updated)
|
||||
} catch (err) {
|
||||
log.error('claimAppeal failed', { error: err.message })
|
||||
return res.status(500).json({ message: 'Internal Server Error' })
|
||||
}
|
||||
}
|
||||
|
||||
// Resolve an appeal (approved | denied). On an APPROVED ban/mute we best-effort
|
||||
// ask the bot to reverse the Discord action (unban / clear timeout). The bot
|
||||
// being down never fails the resolution — we record reversal_status='failed'
|
||||
// and still close the appeal. The response echoes the updated appeal plus a
|
||||
// `reversal` object describing what was attempted.
|
||||
async function resolveAppeal(req, res) {
|
||||
try {
|
||||
const status = req.body.status
|
||||
const staffResponse = req.body.staff_response ?? null
|
||||
|
||||
const appeal = await appeals.getById(Number(req.params.id))
|
||||
if (!appeal) return res.status(404).json({ message: 'Appeal not found' })
|
||||
if (isTerminal(appeal.status)) {
|
||||
return res.status(409).json({ message: 'Appeal is already resolved' })
|
||||
}
|
||||
|
||||
// Best-effort Discord reversal only for an approved, appealable action.
|
||||
const shouldReverse = status === 'approved' && isAppealableType(appeal.action_type)
|
||||
let botResult = null
|
||||
if (shouldReverse) {
|
||||
botResult = await botInternalClient.reverseModAction({
|
||||
discordUserId: appeal.discord_user_id,
|
||||
actionType: appeal.action_type,
|
||||
appealId: appeal.id,
|
||||
})
|
||||
}
|
||||
|
||||
const reversalStatus = reversalStatusFor({
|
||||
status,
|
||||
actionType: appeal.action_type,
|
||||
botOk: botResult ? botResult.ok : false,
|
||||
})
|
||||
|
||||
const updated = await appeals.resolve(appeal.id, {
|
||||
status,
|
||||
staffResponse,
|
||||
handlerUserId: req.user.id,
|
||||
handlerTag: req.user.username,
|
||||
reversalStatus,
|
||||
})
|
||||
|
||||
await activity.log({
|
||||
req,
|
||||
action: 'moderation.appeal.resolve',
|
||||
detail: { appealId: appeal.id, status, reversalStatus },
|
||||
})
|
||||
|
||||
// Describe the reversal so the UI can show "unban succeeded / failed / n/a".
|
||||
const reversal = {
|
||||
attempted: shouldReverse,
|
||||
ok: botResult ? botResult.ok : false,
|
||||
reversal_status: reversalStatus,
|
||||
bot_status: botResult ? botResult.status : null,
|
||||
error: botResult && !botResult.ok ? botResult.error || null : null,
|
||||
}
|
||||
|
||||
return res.json({ ...updated, reversal })
|
||||
} catch (err) {
|
||||
log.error('resolveAppeal failed', { error: err.message })
|
||||
return res.status(500).json({ message: 'Internal Server Error' })
|
||||
}
|
||||
}
|
||||
|
||||
async function getUserAppeals(req, res) {
|
||||
try {
|
||||
return res.json(await appeals.listForDiscordUser(req.params.discordId))
|
||||
} catch (err) {
|
||||
log.error('getUserAppeals failed', { error: err.message })
|
||||
return res.status(500).json({ message: 'Internal Server Error' })
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
getSummary,
|
||||
getRecent,
|
||||
@@ -167,4 +306,9 @@ module.exports = {
|
||||
getUserActions,
|
||||
getUserNotes,
|
||||
addUserNote,
|
||||
getAppeals,
|
||||
getAppeal,
|
||||
claimAppeal,
|
||||
resolveAppeal,
|
||||
getUserAppeals,
|
||||
}
|
||||
|
||||
101
server/src/router/v1/player/appeals.controller.js
Normal file
101
server/src/router/v1/player/appeals.controller.js
Normal file
@@ -0,0 +1,101 @@
|
||||
// Player self-service moderation appeals (Phase 6c). A player appeals one of
|
||||
// their OWN ban/mute mod_actions: they see the actions they're allowed to appeal
|
||||
// (ban/mute with no active appeal), submit one, view their appeals, and withdraw
|
||||
// one that hasn't been resolved yet. Ownership is proven by matching the
|
||||
// action's target_user_id against the caller's linked Discord identity — the
|
||||
// same (provider='discord', subject=<snowflake>) link the SSO flow writes.
|
||||
// Mounted behind the player-role gate (see player.routes.js).
|
||||
const appeals = require('../../../model/appeals/appeals.model')
|
||||
const appealsDb = require('../../../model/appeals/appeals.db')
|
||||
const { isAppealableType, isTerminal } = require('../../../model/appeals/appeals.pure')
|
||||
const userIdentities = require('../../../model/userIdentities/userIdentities.model')
|
||||
|
||||
const log = require('../../../utils/logger')('player-appeals')
|
||||
|
||||
// The caller's linked Discord snowflake, or null if they have no Discord
|
||||
// identity linked. mod_actions are keyed by this snowflake.
|
||||
async function callerDiscordSubject(userId) {
|
||||
const identities = await userIdentities.listForUser(userId)
|
||||
const discord = identities.find((i) => i.provider === 'discord')
|
||||
return discord ? discord.subject : null
|
||||
}
|
||||
|
||||
// GET /player/appeals — the caller's own appeals, newest first.
|
||||
async function listMine(req, res) {
|
||||
try {
|
||||
return res.json(await appeals.listMine(req.user.id))
|
||||
} catch (err) {
|
||||
log.error('listMine failed', { error: err.message })
|
||||
return res.status(500).json({ message: 'Internal Server Error' })
|
||||
}
|
||||
}
|
||||
|
||||
// GET /player/appeals/eligible — the caller's ban/mute actions with no active
|
||||
// appeal. If the caller has no linked Discord account we return [] (not an
|
||||
// error) so the UI can show a "link your Discord account" hint instead.
|
||||
async function listEligible(req, res) {
|
||||
try {
|
||||
const subject = await callerDiscordSubject(req.user.id)
|
||||
if (!subject) return res.json([])
|
||||
return res.json(await appealsDb.eligibleActions(subject))
|
||||
} catch (err) {
|
||||
log.error('listEligible failed', { error: err.message })
|
||||
return res.status(500).json({ message: 'Internal Server Error' })
|
||||
}
|
||||
}
|
||||
|
||||
// POST /player/appeals — open an appeal for one of the caller's own actions.
|
||||
async function create(req, res) {
|
||||
try {
|
||||
const modActionId = Number(req.body.mod_action_id)
|
||||
const submittedText = req.body.submitted_text
|
||||
|
||||
const action = await appealsDb.getModAction(modActionId)
|
||||
if (!action) return res.status(404).json({ message: 'Mod action not found' })
|
||||
|
||||
const subject = await callerDiscordSubject(req.user.id)
|
||||
if (!subject || String(action.target_user_id) !== String(subject)) {
|
||||
return res.status(403).json({ message: 'This action is not yours to appeal' })
|
||||
}
|
||||
|
||||
if (!isAppealableType(action.action_type)) {
|
||||
return res.status(400).json({ message: 'Only bans and mutes can be appealed' })
|
||||
}
|
||||
|
||||
const active = await appeals.activeForAction(modActionId)
|
||||
if (active) return res.status(409).json({ message: 'An appeal for this action is already open' })
|
||||
|
||||
const appeal = await appeals.submit({
|
||||
modActionId,
|
||||
discordUserId: subject,
|
||||
actionType: action.action_type,
|
||||
userId: req.user.id,
|
||||
submittedText,
|
||||
})
|
||||
return res.status(201).json(appeal)
|
||||
} catch (err) {
|
||||
log.error('create failed', { error: err.message })
|
||||
return res.status(500).json({ message: 'Internal Server Error' })
|
||||
}
|
||||
}
|
||||
|
||||
// POST /player/appeals/:id/withdraw — the owner withdraws a non-resolved appeal.
|
||||
async function withdraw(req, res) {
|
||||
try {
|
||||
const appeal = await appeals.getById(Number(req.params.id))
|
||||
// 404 for both "no such appeal" and "not the caller's" — never confirm the
|
||||
// existence of another player's appeal.
|
||||
if (!appeal || appeal.user_id !== req.user.id) {
|
||||
return res.status(404).json({ message: 'Appeal not found' })
|
||||
}
|
||||
if (isTerminal(appeal.status)) {
|
||||
return res.status(409).json({ message: 'This appeal is already resolved' })
|
||||
}
|
||||
return res.json(await appeals.withdraw(appeal.id))
|
||||
} catch (err) {
|
||||
log.error('withdraw failed', { error: err.message })
|
||||
return res.status(500).json({ message: 'Internal Server Error' })
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { listMine, listEligible, create, withdraw }
|
||||
@@ -11,6 +11,7 @@ const { body, param } = require('express-validator')
|
||||
|
||||
const account = require('../admin/account.controller')
|
||||
const shard = require('./shard.controller')
|
||||
const appeals = require('./appeals.controller')
|
||||
const { requireAuth, requireRole } = require('../../../auth/session.middleware')
|
||||
const noindex = require('../../../middleware/noindex')
|
||||
const validate = require('../../../middleware/validate')
|
||||
@@ -232,4 +233,58 @@ playerRouter.get(
|
||||
shard.getHouses,
|
||||
)
|
||||
|
||||
// ── Moderation appeals (uo-link / Discord moderation) ──────────────────────
|
||||
// A player appeals one of their own ban/mute mod_actions. Ownership is proven by
|
||||
// matching the action against the caller's linked Discord identity.
|
||||
playerRouter.get(
|
||||
'/appeals',
|
||||
// #swagger.tags = ['Player · Appeals']
|
||||
// #swagger.summary = 'List the caller’s moderation appeals'
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
/* #swagger.responses[200] = { description: 'The caller’s appeals', content: { "application/json": { schema: { type: "array", items: { $ref: "#/components/schemas/Appeal" } } } } } */
|
||||
/* #swagger.responses[403] = { description: 'Player role required, or account not active', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
appeals.listMine,
|
||||
)
|
||||
playerRouter.get(
|
||||
'/appeals/eligible',
|
||||
// #swagger.tags = ['Player · Appeals']
|
||||
// #swagger.summary = 'List the caller’s ban/mute actions eligible for appeal'
|
||||
// #swagger.description = 'The caller’s ban/mute mod_actions that have no active appeal. Returns an empty array when the caller has no linked Discord account (the UI shows a “link Discord” hint).'
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
/* #swagger.responses[200] = { description: 'Appealable actions', content: { "application/json": { schema: { type: "array", items: { $ref: "#/components/schemas/AppealEligibleAction" } } } } } */
|
||||
/* #swagger.responses[403] = { description: 'Player role required, or account not active', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
appeals.listEligible,
|
||||
)
|
||||
playerRouter.post(
|
||||
'/appeals',
|
||||
// #swagger.tags = ['Player · Appeals']
|
||||
// #swagger.summary = 'Submit a moderation appeal for one of the caller’s actions'
|
||||
// #swagger.description = 'Opens an appeal for a ban/mute mod_action that belongs to the caller (its target matches the caller’s linked Discord identity) and has no active appeal.'
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
/* #swagger.requestBody = { required: true, content: { "application/json": { schema: { $ref: "#/components/schemas/CreateAppealRequest" } } } } */
|
||||
/* #swagger.responses[201] = { description: 'Appeal created', content: { "application/json": { schema: { $ref: "#/components/schemas/Appeal" } } } } */
|
||||
/* #swagger.responses[400] = { description: 'Validation error, or the action type is not appealable', content: { "application/json": { schema: { $ref: "#/components/schemas/ValidationError" } } } } */
|
||||
/* #swagger.responses[403] = { description: 'The action does not belong to the caller', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
/* #swagger.responses[404] = { description: 'Mod action not found', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
/* #swagger.responses[409] = { description: 'An appeal for this action is already open', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
accountChangeLimiter,
|
||||
body('mod_action_id').isInt({ min: 1 }).toInt(),
|
||||
body('submitted_text').isString().trim().isLength({ min: 1, max: 4000 }),
|
||||
validate,
|
||||
appeals.create,
|
||||
)
|
||||
playerRouter.post(
|
||||
'/appeals/:id/withdraw',
|
||||
// #swagger.tags = ['Player · Appeals']
|
||||
// #swagger.summary = 'Withdraw one of the caller’s pending appeals'
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
// #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'Appeal id (must belong to the caller).' }
|
||||
/* #swagger.responses[200] = { description: 'The withdrawn appeal', content: { "application/json": { schema: { $ref: "#/components/schemas/Appeal" } } } } */
|
||||
/* #swagger.responses[404] = { description: 'No such appeal for the caller', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
/* #swagger.responses[409] = { description: 'Appeal is already resolved', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
param('id').isInt({ min: 1 }),
|
||||
validate,
|
||||
appeals.withdraw,
|
||||
)
|
||||
|
||||
module.exports = playerRouter
|
||||
|
||||
@@ -61,4 +61,15 @@ function announce({ title, excerpt, url, imageUrl }) {
|
||||
return call('/internal/announce', { method: 'POST', body: { title, excerpt, url, imageUrl } })
|
||||
}
|
||||
|
||||
module.exports = { pushConfig, getStatus, announce }
|
||||
// Site -> bot: an approved appeal wants the underlying Discord action reversed
|
||||
// (unban for a 'ban', clear the timeout for a 'mute'). Best-effort like every
|
||||
// call here — never throws, so an approved appeal still resolves when the bot is
|
||||
// down (the caller records reversal_status='failed' from `ok:false`).
|
||||
function reverseModAction({ discordUserId, actionType, appealId }) {
|
||||
return call('/internal/mod-reverse', {
|
||||
method: 'POST',
|
||||
body: { discord_user_id: discordUserId, action_type: actionType, appeal_id: appealId },
|
||||
})
|
||||
}
|
||||
|
||||
module.exports = { pushConfig, getStatus, announce, reverseModAction }
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -55,6 +55,7 @@ const doc = {
|
||||
{ name: 'Admin · Account', description: 'Self-service account security (2FA, linked identities)' },
|
||||
{ name: 'Player', description: 'Self-service player accounts (register, credentials, 2FA, linked identities)' },
|
||||
{ name: 'Player · Shard', description: 'Link an in-game account and read its roster / vendors (uo-link)' },
|
||||
{ name: 'Player · Appeals', description: 'Player-submitted moderation appeals' },
|
||||
{ name: 'Admin · Dashboard', description: 'Dashboard summary and site mode' },
|
||||
{ name: 'Admin · Posts', description: 'News / five-on-friday / newsletter / screenshots + uploads' },
|
||||
{ name: 'Admin · Wiki', description: 'Wiki pages, categories, tags and revisions' },
|
||||
@@ -421,6 +422,93 @@ const doc = {
|
||||
type: 'object',
|
||||
properties: { ok: { type: 'boolean', example: true } },
|
||||
},
|
||||
// ── Moderation appeals (Phase 6c/6d) ────────────────────────────────────
|
||||
Appeal: {
|
||||
type: 'object',
|
||||
description: 'A player-submitted moderation appeal (as returned to the player and in the staff queue).',
|
||||
properties: {
|
||||
id: { type: 'integer', example: 12 },
|
||||
mod_action_id: { type: 'integer', example: 340 },
|
||||
discord_user_id: { type: 'string', example: '216734083584917504' },
|
||||
action_type: { type: 'string', enum: ['ban', 'mute'], example: 'ban' },
|
||||
user_id: { type: 'integer', nullable: true, example: 42 },
|
||||
status: {
|
||||
type: 'string',
|
||||
enum: ['pending', 'under_review', 'approved', 'denied', 'withdrawn'],
|
||||
example: 'pending',
|
||||
},
|
||||
submitted_text: { type: 'string', example: 'I was banned by mistake — please review.' },
|
||||
staff_response: { type: 'string', nullable: true, example: null },
|
||||
handled_by_user_id: { type: 'integer', nullable: true, example: null },
|
||||
handled_by_tag: { type: 'string', nullable: true, example: null },
|
||||
reversal_status: {
|
||||
type: 'string',
|
||||
enum: ['none', 'done', 'failed'],
|
||||
description: 'Discord-reversal outcome. done/failed only after an approval; none otherwise.',
|
||||
example: 'none',
|
||||
},
|
||||
submitted_at: { type: 'string', format: 'date-time' },
|
||||
resolved_at: { type: 'string', format: 'date-time', nullable: true, example: null },
|
||||
action_target_tag: { type: 'string', nullable: true, example: 'Rogue#1234', description: 'Snapshot of the original action target tag (from mod_actions).' },
|
||||
action_reason: { type: 'string', nullable: true, example: 'Spam' },
|
||||
action_created_at: { type: 'string', format: 'date-time', nullable: true },
|
||||
action_duration_seconds: { type: 'integer', nullable: true, example: 86400 },
|
||||
submitter_username: { type: 'string', nullable: true, example: 'newplayer' },
|
||||
},
|
||||
},
|
||||
AppealQueueItem: {
|
||||
allOf: [{ $ref: '#/components/schemas/Appeal' }],
|
||||
description: 'A staff-queue appeal row — identical shape to Appeal, with the joined action/submitter columns populated.',
|
||||
},
|
||||
AppealResolveResult: {
|
||||
allOf: [
|
||||
{ $ref: '#/components/schemas/Appeal' },
|
||||
{
|
||||
type: 'object',
|
||||
properties: {
|
||||
reversal: {
|
||||
type: 'object',
|
||||
description: 'What the approval attempted against Discord.',
|
||||
properties: {
|
||||
attempted: { type: 'boolean', example: true },
|
||||
ok: { type: 'boolean', example: true },
|
||||
reversal_status: { type: 'string', enum: ['none', 'done', 'failed'], example: 'done' },
|
||||
bot_status: { type: 'integer', nullable: true, example: 200, description: 'HTTP status from the bot internal call, or null when no call was made.' },
|
||||
error: { type: 'string', nullable: true, example: null },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
AppealEligibleAction: {
|
||||
type: 'object',
|
||||
description: 'A ban/mute mod_action the caller may appeal (no active appeal outstanding).',
|
||||
properties: {
|
||||
id: { type: 'integer', example: 340, description: 'mod_action id — pass as mod_action_id when submitting.' },
|
||||
action_type: { type: 'string', enum: ['ban', 'mute'], example: 'ban' },
|
||||
target_tag: { type: 'string', nullable: true, example: 'Rogue#1234' },
|
||||
reason: { type: 'string', nullable: true, example: 'Spam' },
|
||||
duration_seconds: { type: 'integer', nullable: true, example: 86400 },
|
||||
created_at: { type: 'string', format: 'date-time' },
|
||||
},
|
||||
},
|
||||
CreateAppealRequest: {
|
||||
type: 'object',
|
||||
required: ['mod_action_id', 'submitted_text'],
|
||||
properties: {
|
||||
mod_action_id: { type: 'integer', example: 340, description: 'The ban/mute mod_action to appeal (must belong to the caller).' },
|
||||
submitted_text: { type: 'string', minLength: 1, maxLength: 4000, example: 'I was banned by mistake — please review.' },
|
||||
},
|
||||
},
|
||||
ResolveAppealRequest: {
|
||||
type: 'object',
|
||||
required: ['status'],
|
||||
properties: {
|
||||
status: { type: 'string', enum: ['approved', 'denied'], example: 'approved' },
|
||||
staff_response: { type: 'string', maxLength: 4000, nullable: true, example: 'Reviewed — reversing the ban.' },
|
||||
},
|
||||
},
|
||||
TotpCodeRequest: {
|
||||
type: 'object',
|
||||
required: ['code'],
|
||||
|
||||
47
server/test/appeals.pure.test.js
Normal file
47
server/test/appeals.pure.test.js
Normal file
@@ -0,0 +1,47 @@
|
||||
// Unit tests for the pure appeals helpers (status/type vocabularies + the
|
||||
// reversal-status derivation). DB-free, like the rest of this suite.
|
||||
const { test } = require('node:test')
|
||||
const assert = require('node:assert/strict')
|
||||
|
||||
const pure = require('../src/model/appeals/appeals.pure')
|
||||
|
||||
test('TERMINAL / ACTIVE partition the status space', () => {
|
||||
assert.deepEqual([...pure.TERMINAL].sort(), ['approved', 'denied', 'withdrawn'])
|
||||
assert.deepEqual([...pure.ACTIVE].sort(), ['pending', 'under_review'])
|
||||
})
|
||||
|
||||
test('isTerminal is true only for closed statuses', () => {
|
||||
assert.equal(pure.isTerminal('approved'), true)
|
||||
assert.equal(pure.isTerminal('denied'), true)
|
||||
assert.equal(pure.isTerminal('withdrawn'), true)
|
||||
assert.equal(pure.isTerminal('pending'), false)
|
||||
assert.equal(pure.isTerminal('under_review'), false)
|
||||
})
|
||||
|
||||
test('isActive is true only for the open statuses that hold the appeal slot', () => {
|
||||
assert.equal(pure.isActive('pending'), true)
|
||||
assert.equal(pure.isActive('under_review'), true)
|
||||
assert.equal(pure.isActive('approved'), false)
|
||||
assert.equal(pure.isActive('withdrawn'), false)
|
||||
})
|
||||
|
||||
test('isAppealableType allows only ban and mute', () => {
|
||||
assert.equal(pure.isAppealableType('ban'), true)
|
||||
assert.equal(pure.isAppealableType('mute'), true)
|
||||
assert.equal(pure.isAppealableType('kick'), false)
|
||||
assert.equal(pure.isAppealableType('warn'), false)
|
||||
})
|
||||
|
||||
test('reversalStatusFor: approved + appealable maps bot success to done/failed', () => {
|
||||
assert.equal(pure.reversalStatusFor({ status: 'approved', actionType: 'ban', botOk: true }), 'done')
|
||||
assert.equal(pure.reversalStatusFor({ status: 'approved', actionType: 'ban', botOk: false }), 'failed')
|
||||
assert.equal(pure.reversalStatusFor({ status: 'approved', actionType: 'mute', botOk: true }), 'done')
|
||||
})
|
||||
|
||||
test('reversalStatusFor: denial never reverses', () => {
|
||||
assert.equal(pure.reversalStatusFor({ status: 'denied', actionType: 'ban', botOk: true }), 'none')
|
||||
})
|
||||
|
||||
test('reversalStatusFor: non-appealable action never reverses even when approved', () => {
|
||||
assert.equal(pure.reversalStatusFor({ status: 'approved', actionType: 'warn', botOk: true }), 'none')
|
||||
})
|
||||
305
server/test/appeals.test.js
Normal file
305
server/test/appeals.test.js
Normal file
@@ -0,0 +1,305 @@
|
||||
// Controller-level tests for moderation appeals (Phase 6c/6d). Following the
|
||||
// existing suite's convention (see playerAccounts.test.js / moderation.test.js),
|
||||
// these are DB-free: the DB is pointed at a closed port before anything builds
|
||||
// the pool, and the model/db/bot-client seams are stubbed per-test so the
|
||||
// controllers' branching logic (ownership, duplicate, type, reversal wiring) is
|
||||
// exercised without a live database or bot. The SQL itself is verified manually
|
||||
// against a dev DB per the plan's verification steps.
|
||||
process.env.DB_HOST = '127.0.0.1'
|
||||
process.env.DB_PORT = '59999'
|
||||
|
||||
const { test, afterEach, after } = require('node:test')
|
||||
const assert = require('node:assert/strict')
|
||||
|
||||
const playerAppeals = require('../src/router/v1/player/appeals.controller')
|
||||
const modCtrl = require('../src/router/v1/admin/moderation.controller')
|
||||
const appealsModel = require('../src/model/appeals/appeals.model')
|
||||
const appealsDb = require('../src/model/appeals/appeals.db')
|
||||
const userIdentities = require('../src/model/userIdentities/userIdentities.model')
|
||||
const botInternalClient = require('../src/utils/botInternalClient')
|
||||
const activity = require('../src/model/activity/activity.model')
|
||||
const db = require('../src/utils/db')
|
||||
|
||||
after(() => db.close())
|
||||
|
||||
// ── tiny stub harness (restore all patched methods after each test) ──────────
|
||||
const saved = new Map()
|
||||
function stub(obj, prop, fn) {
|
||||
if (!saved.has(obj)) saved.set(obj, {})
|
||||
const bag = saved.get(obj)
|
||||
if (!(prop in bag)) bag[prop] = obj[prop]
|
||||
obj[prop] = fn
|
||||
}
|
||||
afterEach(() => {
|
||||
for (const [obj, bag] of saved) for (const k of Object.keys(bag)) obj[k] = bag[k]
|
||||
saved.clear()
|
||||
})
|
||||
|
||||
function mockRes() {
|
||||
return {
|
||||
statusCode: 200,
|
||||
body: null,
|
||||
status(c) {
|
||||
this.statusCode = c
|
||||
return this
|
||||
},
|
||||
json(b) {
|
||||
this.body = b
|
||||
return this
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// activity.log already swallows its own errors, but stub it everywhere so a
|
||||
// resolve/claim never reaches the (closed) DB.
|
||||
function silenceActivity() {
|
||||
stub(activity, 'log', async () => {})
|
||||
}
|
||||
|
||||
// ── POST /player/appeals ─────────────────────────────────────────────────────
|
||||
test('player submit: happy path returns 201 with the created appeal', async () => {
|
||||
stub(appealsDb, 'getModAction', async () => ({ id: 340, action_type: 'ban', target_user_id: '123' }))
|
||||
stub(userIdentities, 'listForUser', async () => [{ provider: 'discord', subject: '123' }])
|
||||
stub(appealsModel, 'activeForAction', async () => null)
|
||||
let submitted = null
|
||||
stub(appealsModel, 'submit', async (arg) => {
|
||||
submitted = arg
|
||||
return { id: 12, status: 'pending', action_type: 'ban' }
|
||||
})
|
||||
|
||||
const req = { user: { id: 42 }, body: { mod_action_id: 340, submitted_text: 'please review' } }
|
||||
const res = mockRes()
|
||||
await playerAppeals.create(req, res)
|
||||
|
||||
assert.equal(res.statusCode, 201)
|
||||
assert.equal(res.body.id, 12)
|
||||
assert.equal(submitted.discordUserId, '123')
|
||||
assert.equal(submitted.actionType, 'ban')
|
||||
assert.equal(submitted.userId, 42)
|
||||
})
|
||||
|
||||
test('player submit: action not belonging to the caller is 403', async () => {
|
||||
stub(appealsDb, 'getModAction', async () => ({ id: 340, action_type: 'ban', target_user_id: '999' }))
|
||||
stub(userIdentities, 'listForUser', async () => [{ provider: 'discord', subject: '123' }])
|
||||
|
||||
const req = { user: { id: 42 }, body: { mod_action_id: 340, submitted_text: 'x' } }
|
||||
const res = mockRes()
|
||||
await playerAppeals.create(req, res)
|
||||
assert.equal(res.statusCode, 403)
|
||||
})
|
||||
|
||||
test('player submit: an existing active appeal is 409', async () => {
|
||||
stub(appealsDb, 'getModAction', async () => ({ id: 340, action_type: 'mute', target_user_id: '123' }))
|
||||
stub(userIdentities, 'listForUser', async () => [{ provider: 'discord', subject: '123' }])
|
||||
stub(appealsModel, 'activeForAction', async () => ({ id: 5, status: 'pending' }))
|
||||
|
||||
const req = { user: { id: 42 }, body: { mod_action_id: 340, submitted_text: 'x' } }
|
||||
const res = mockRes()
|
||||
await playerAppeals.create(req, res)
|
||||
assert.equal(res.statusCode, 409)
|
||||
})
|
||||
|
||||
test('player submit: a non-ban/mute action is 400', async () => {
|
||||
stub(appealsDb, 'getModAction', async () => ({ id: 340, action_type: 'warn', target_user_id: '123' }))
|
||||
stub(userIdentities, 'listForUser', async () => [{ provider: 'discord', subject: '123' }])
|
||||
|
||||
const req = { user: { id: 42 }, body: { mod_action_id: 340, submitted_text: 'x' } }
|
||||
const res = mockRes()
|
||||
await playerAppeals.create(req, res)
|
||||
assert.equal(res.statusCode, 400)
|
||||
})
|
||||
|
||||
test('player submit: unknown mod_action is 404', async () => {
|
||||
stub(appealsDb, 'getModAction', async () => null)
|
||||
const req = { user: { id: 42 }, body: { mod_action_id: 9999, submitted_text: 'x' } }
|
||||
const res = mockRes()
|
||||
await playerAppeals.create(req, res)
|
||||
assert.equal(res.statusCode, 404)
|
||||
})
|
||||
|
||||
// ── GET /player/appeals/eligible ─────────────────────────────────────────────
|
||||
test('player eligible: no linked Discord returns [] (not an error)', async () => {
|
||||
stub(userIdentities, 'listForUser', async () => [{ provider: 'google', subject: 'g1' }])
|
||||
const req = { user: { id: 42 } }
|
||||
const res = mockRes()
|
||||
await playerAppeals.listEligible(req, res)
|
||||
assert.equal(res.statusCode, 200)
|
||||
assert.deepEqual(res.body, [])
|
||||
})
|
||||
|
||||
test('player eligible: linked Discord returns the eligible actions', async () => {
|
||||
stub(userIdentities, 'listForUser', async () => [{ provider: 'discord', subject: '123' }])
|
||||
stub(appealsDb, 'eligibleActions', async (subject) => {
|
||||
assert.equal(subject, '123')
|
||||
return [{ id: 340, action_type: 'ban' }]
|
||||
})
|
||||
const req = { user: { id: 42 } }
|
||||
const res = mockRes()
|
||||
await playerAppeals.listEligible(req, res)
|
||||
assert.equal(res.statusCode, 200)
|
||||
assert.equal(res.body.length, 1)
|
||||
})
|
||||
|
||||
// ── POST /player/appeals/:id/withdraw ────────────────────────────────────────
|
||||
test('player withdraw: owner + non-terminal flips to withdrawn', async () => {
|
||||
stub(appealsModel, 'getById', async () => ({ id: 12, user_id: 42, status: 'pending' }))
|
||||
stub(appealsModel, 'withdraw', async (id) => ({ id, status: 'withdrawn' }))
|
||||
const req = { user: { id: 42 }, params: { id: '12' } }
|
||||
const res = mockRes()
|
||||
await playerAppeals.withdraw(req, res)
|
||||
assert.equal(res.statusCode, 200)
|
||||
assert.equal(res.body.status, 'withdrawn')
|
||||
})
|
||||
|
||||
test('player withdraw: another player’s appeal is 404 (never confirmed)', async () => {
|
||||
stub(appealsModel, 'getById', async () => ({ id: 12, user_id: 99, status: 'pending' }))
|
||||
const req = { user: { id: 42 }, params: { id: '12' } }
|
||||
const res = mockRes()
|
||||
await playerAppeals.withdraw(req, res)
|
||||
assert.equal(res.statusCode, 404)
|
||||
})
|
||||
|
||||
test('player withdraw: an already-resolved appeal is 409', async () => {
|
||||
stub(appealsModel, 'getById', async () => ({ id: 12, user_id: 42, status: 'approved' }))
|
||||
const req = { user: { id: 42 }, params: { id: '12' } }
|
||||
const res = mockRes()
|
||||
await playerAppeals.withdraw(req, res)
|
||||
assert.equal(res.statusCode, 409)
|
||||
})
|
||||
|
||||
// ── GET /admin/moderation/appeals ────────────────────────────────────────────
|
||||
test('staff queue: default status filter is pending + under_review', async () => {
|
||||
let passed = null
|
||||
stub(appealsModel, 'queue', async (opts) => {
|
||||
passed = opts
|
||||
return []
|
||||
})
|
||||
const req = { query: {} }
|
||||
const res = mockRes()
|
||||
await modCtrl.getAppeals(req, res)
|
||||
assert.deepEqual(passed.statuses, ['pending', 'under_review'])
|
||||
})
|
||||
|
||||
test('staff queue: ?status=all expands to every status', async () => {
|
||||
let passed = null
|
||||
stub(appealsModel, 'queue', async (opts) => {
|
||||
passed = opts
|
||||
return []
|
||||
})
|
||||
const req = { query: { status: 'all' } }
|
||||
const res = mockRes()
|
||||
await modCtrl.getAppeals(req, res)
|
||||
assert.deepEqual(passed.statuses, ['pending', 'under_review', 'approved', 'denied', 'withdrawn'])
|
||||
})
|
||||
|
||||
// ── POST /admin/moderation/appeals/:id/claim ─────────────────────────────────
|
||||
test('staff claim: a pending appeal moves to under_review and stamps the handler', async () => {
|
||||
silenceActivity()
|
||||
stub(appealsModel, 'getById', async () => ({ id: 12, status: 'pending', discord_user_id: '123' }))
|
||||
let claimArgs = null
|
||||
stub(appealsModel, 'claim', async (id, args) => {
|
||||
claimArgs = { id, ...args }
|
||||
return { id, status: 'under_review' }
|
||||
})
|
||||
const req = { user: { id: 7, username: 'modperson' }, params: { id: '12' } }
|
||||
const res = mockRes()
|
||||
await modCtrl.claimAppeal(req, res)
|
||||
assert.equal(res.statusCode, 200)
|
||||
assert.equal(res.body.status, 'under_review')
|
||||
assert.equal(claimArgs.handlerUserId, 7)
|
||||
assert.equal(claimArgs.handlerTag, 'modperson')
|
||||
})
|
||||
|
||||
test('staff claim: a non-pending appeal is 409', async () => {
|
||||
stub(appealsModel, 'getById', async () => ({ id: 12, status: 'under_review' }))
|
||||
const req = { user: { id: 7, username: 'm' }, params: { id: '12' } }
|
||||
const res = mockRes()
|
||||
await modCtrl.claimAppeal(req, res)
|
||||
assert.equal(res.statusCode, 409)
|
||||
})
|
||||
|
||||
// ── POST /admin/moderation/appeals/:id/resolve ───────────────────────────────
|
||||
test('staff resolve: approving a ban calls the bot and records reversal_status=done on ok', async () => {
|
||||
silenceActivity()
|
||||
stub(appealsModel, 'getById', async () => ({ id: 12, status: 'under_review', action_type: 'ban', discord_user_id: '123' }))
|
||||
let reverseArgs = null
|
||||
stub(botInternalClient, 'reverseModAction', async (arg) => {
|
||||
reverseArgs = arg
|
||||
return { ok: true, status: 200 }
|
||||
})
|
||||
let resolveOpts = null
|
||||
stub(appealsModel, 'resolve', async (id, opts) => {
|
||||
resolveOpts = opts
|
||||
return { id, status: 'approved', reversal_status: opts.reversalStatus }
|
||||
})
|
||||
const req = { user: { id: 7, username: 'm' }, params: { id: '12' }, body: { status: 'approved' } }
|
||||
const res = mockRes()
|
||||
await modCtrl.resolveAppeal(req, res)
|
||||
|
||||
assert.equal(res.statusCode, 200)
|
||||
assert.equal(reverseArgs.actionType, 'ban')
|
||||
assert.equal(reverseArgs.discordUserId, '123')
|
||||
assert.equal(resolveOpts.reversalStatus, 'done')
|
||||
assert.equal(res.body.reversal_status, 'done')
|
||||
assert.equal(res.body.reversal.attempted, true)
|
||||
assert.equal(res.body.reversal.ok, true)
|
||||
assert.equal(res.body.reversal.reversal_status, 'done')
|
||||
})
|
||||
|
||||
test('staff resolve: approving a mute with a bot failure records reversal_status=failed', async () => {
|
||||
silenceActivity()
|
||||
stub(appealsModel, 'getById', async () => ({ id: 13, status: 'under_review', action_type: 'mute', discord_user_id: '123' }))
|
||||
stub(botInternalClient, 'reverseModAction', async () => ({ ok: false, status: 503, error: 'bot responded 503' }))
|
||||
let resolveOpts = null
|
||||
stub(appealsModel, 'resolve', async (id, opts) => {
|
||||
resolveOpts = opts
|
||||
return { id, status: 'approved', reversal_status: opts.reversalStatus }
|
||||
})
|
||||
const req = { user: { id: 7, username: 'm' }, params: { id: '13' }, body: { status: 'approved' } }
|
||||
const res = mockRes()
|
||||
await modCtrl.resolveAppeal(req, res)
|
||||
|
||||
assert.equal(res.statusCode, 200)
|
||||
assert.equal(resolveOpts.reversalStatus, 'failed')
|
||||
assert.equal(res.body.reversal.ok, false)
|
||||
assert.equal(res.body.reversal.error, 'bot responded 503')
|
||||
})
|
||||
|
||||
test('staff resolve: denying never calls the bot and leaves reversal_status=none', async () => {
|
||||
silenceActivity()
|
||||
stub(appealsModel, 'getById', async () => ({ id: 14, status: 'pending', action_type: 'ban', discord_user_id: '123' }))
|
||||
let botCalled = false
|
||||
stub(botInternalClient, 'reverseModAction', async () => {
|
||||
botCalled = true
|
||||
return { ok: true, status: 200 }
|
||||
})
|
||||
let resolveOpts = null
|
||||
stub(appealsModel, 'resolve', async (id, opts) => {
|
||||
resolveOpts = opts
|
||||
return { id, status: 'denied', reversal_status: opts.reversalStatus }
|
||||
})
|
||||
const req = { user: { id: 7, username: 'm' }, params: { id: '14' }, body: { status: 'denied' } }
|
||||
const res = mockRes()
|
||||
await modCtrl.resolveAppeal(req, res)
|
||||
|
||||
assert.equal(res.statusCode, 200)
|
||||
assert.equal(botCalled, false)
|
||||
assert.equal(resolveOpts.reversalStatus, 'none')
|
||||
assert.equal(res.body.reversal.attempted, false)
|
||||
})
|
||||
|
||||
test('staff resolve: an already-resolved appeal is 409', async () => {
|
||||
stub(appealsModel, 'getById', async () => ({ id: 15, status: 'approved', action_type: 'ban' }))
|
||||
const req = { user: { id: 7, username: 'm' }, params: { id: '15' }, body: { status: 'denied' } }
|
||||
const res = mockRes()
|
||||
await modCtrl.resolveAppeal(req, res)
|
||||
assert.equal(res.statusCode, 409)
|
||||
})
|
||||
|
||||
test('staff resolve: an unknown appeal is 404', async () => {
|
||||
stub(appealsModel, 'getById', async () => null)
|
||||
const req = { user: { id: 7, username: 'm' }, params: { id: '999' }, body: { status: 'denied' } }
|
||||
const res = mockRes()
|
||||
await modCtrl.resolveAppeal(req, res)
|
||||
assert.equal(res.statusCode, 404)
|
||||
})
|
||||
Reference in New Issue
Block a user