Capture member/filter/spam events for the dashboard (Phase 6b)
Light up the moderation dashboard's previously-empty widgets by persisting the
event streams the bot only reacted to in-memory before.
Schema (bot-owned)
- member_events: join/leave, with invite_code/inviter_* for best-effort invite
attribution on joins
- filter_hits: word / foreign-invite filter deletions (matched + action_taken)
- spam_hits: rate_limit / mass_mention / mass_emoji detections
Bot
- new models memberEvents/filterHits/spamHits
- guildMemberAdd records the join with invite attribution; new inviteTracker.js
keeps an invite-use cache (GuildInvites intent + inviteCreate/inviteDelete) and
diffs it on join to find which invite was used — best-effort, never blocks
auto-role
- new guildMemberRemove records leaves
- messageFilter records filter/spam hits alongside the existing warn/mute;
inviteFilter now returns the offending code; detectSpam identifies which spam
rule tripped (preserving the rate-limit-first side-effect order)
- mod_actions still logs the resulting warn/mute — the new tables are additive
Server
- summary extended with joins/leaves/invite_joins/filter_hits/spam_hits per window
- new feeds: /api/v1/admin/moderation/{members,filter-hits,spam-hits}
Client
- overview now shows 8 tiles (mod actions + joins/leaves/filter/spam, joins tile
notes "N via invite") plus an Events panel with Members/Filter/Spam tabs;
removed the coming-soon note
Verified: 119 server unit tests, client build, 14-check DB-backed smoke, and a
browser click-through of every tile and events tab (incl. invite attribution).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019rao86n5cXpwAyjdBFEshV
This commit is contained in:
@@ -131,6 +131,28 @@ export const api = {
|
||||
return req(`/admin/moderation/recent${s ? `?${s}` : ''}`)
|
||||
},
|
||||
modSearch: (q) => req(`/admin/moderation/search?q=${encodeURIComponent(q)}`),
|
||||
modMembers: (params = {}) => {
|
||||
const qs = new URLSearchParams()
|
||||
if (params.type) qs.set('type', params.type)
|
||||
if (params.limit) qs.set('limit', params.limit)
|
||||
if (params.offset) qs.set('offset', params.offset)
|
||||
const s = qs.toString()
|
||||
return req(`/admin/moderation/members${s ? `?${s}` : ''}`)
|
||||
},
|
||||
modFilterHits: (params = {}) => {
|
||||
const qs = new URLSearchParams()
|
||||
if (params.limit) qs.set('limit', params.limit)
|
||||
if (params.offset) qs.set('offset', params.offset)
|
||||
const s = qs.toString()
|
||||
return req(`/admin/moderation/filter-hits${s ? `?${s}` : ''}`)
|
||||
},
|
||||
modSpamHits: (params = {}) => {
|
||||
const qs = new URLSearchParams()
|
||||
if (params.limit) qs.set('limit', params.limit)
|
||||
if (params.offset) qs.set('offset', params.offset)
|
||||
const s = qs.toString()
|
||||
return req(`/admin/moderation/spam-hits${s ? `?${s}` : ''}`)
|
||||
},
|
||||
modUser: (discordId) => req(`/admin/moderation/user/${discordId}`),
|
||||
modUserActions: (discordId, params = {}) => {
|
||||
const qs = new URLSearchParams()
|
||||
|
||||
@@ -17,98 +17,106 @@ const TYPES = [
|
||||
{ key: 'mute', label: 'Mutes' },
|
||||
{ key: 'warn', label: 'Warnings' },
|
||||
]
|
||||
const TILE_TYPES = [
|
||||
const MOD_TILES = [
|
||||
{ key: 'ban', label: 'Bans' },
|
||||
{ key: 'kick', label: 'Kicks' },
|
||||
{ key: 'mute', label: 'Mutes' },
|
||||
{ key: 'warn', label: 'Warnings' },
|
||||
]
|
||||
// Second tile row → jumps the events panel to the matching stream.
|
||||
const EVENT_TILES = [
|
||||
{ key: 'joins', label: 'Joins', tab: 'members' },
|
||||
{ key: 'leaves', label: 'Leaves', tab: 'members' },
|
||||
{ key: 'filter_hits', label: 'Filter hits', tab: 'filter' },
|
||||
{ key: 'spam_hits', label: 'Spam hits', tab: 'spam' },
|
||||
]
|
||||
const EVENT_TABS = [
|
||||
{ key: 'members', label: 'Members' },
|
||||
{ key: 'filter', label: 'Filter hits' },
|
||||
{ key: 'spam', label: 'Spam hits' },
|
||||
]
|
||||
|
||||
export default function Moderation() {
|
||||
const navigate = useNavigate()
|
||||
const [win, setWin] = useState('24h')
|
||||
const [typeFilter, setTypeFilter] = useState(null)
|
||||
const [eventTab, setEventTab] = useState('members')
|
||||
|
||||
const { loading, error, data } = useAsync(
|
||||
() => Promise.all([api.admin.modSummary(), api.admin.modRecent({ limit: 100 })]),
|
||||
() =>
|
||||
Promise.all([
|
||||
api.admin.modSummary(),
|
||||
api.admin.modRecent({ limit: 100 }),
|
||||
api.admin.modMembers({ limit: 50 }),
|
||||
api.admin.modFilterHits({ limit: 50 }),
|
||||
api.admin.modSpamHits({ limit: 50 }),
|
||||
]),
|
||||
[],
|
||||
)
|
||||
|
||||
if (loading) return <Loading />
|
||||
if (error) return <ErrorState message="Could not load moderation data." />
|
||||
|
||||
const [summary, recent] = data
|
||||
const counts = summary.windows?.[win] || { ban: 0, kick: 0, mute: 0, warn: 0 }
|
||||
const [summary, recent, members, filterHits, spamHits] = data
|
||||
const counts = summary.windows?.[win] || {}
|
||||
const feed = typeFilter ? recent.filter((r) => r.action_type === typeFilter) : recent
|
||||
const goUser = (id) => navigate(`/admin/moderation/user/${id}`)
|
||||
|
||||
return (
|
||||
<section>
|
||||
<UserSearch onPick={(id) => navigate(`/admin/moderation/user/${id}`)} />
|
||||
<UserSearch onPick={goUser} />
|
||||
|
||||
{/* Window selector */}
|
||||
<div style={{ display: 'flex', gap: 8, margin: '4px 0 14px' }}>
|
||||
{WINDOWS.map((w) => (
|
||||
<button
|
||||
key={w.key}
|
||||
onClick={() => setWin(w.key)}
|
||||
className="pill"
|
||||
style={win === w.key ? activePill : undefined}
|
||||
>
|
||||
<button key={w.key} onClick={() => setWin(w.key)} className="pill" style={win === w.key ? activePill : undefined}>
|
||||
{w.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Stat tiles */}
|
||||
<div className="grid-4" style={{ gap: 14, marginBottom: 12 }}>
|
||||
{TILE_TYPES.map((t) => (
|
||||
<button
|
||||
{/* Moderation-action tiles (click filters the recent-actions feed) */}
|
||||
<div className="grid-4" style={{ gap: 14, marginBottom: 14 }}>
|
||||
{MOD_TILES.map((t) => (
|
||||
<Tile
|
||||
key={t.key}
|
||||
value={counts[t.key] ?? 0}
|
||||
label={t.label}
|
||||
active={typeFilter === t.key}
|
||||
onClick={() => setTypeFilter(typeFilter === t.key ? null : t.key)}
|
||||
style={{
|
||||
textAlign: 'left',
|
||||
padding: 20,
|
||||
border: `1px solid ${typeFilter === t.key ? 'var(--accent)' : 'var(--line)'}`,
|
||||
borderRadius: 12,
|
||||
background: 'var(--panel-grad)',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
<div className="display" style={{ fontSize: '2rem', color: 'var(--head)', lineHeight: 1 }}>
|
||||
{counts[t.key] ?? 0}
|
||||
</div>
|
||||
<div className="card-kicker" style={{ marginTop: 8, marginBottom: 0 }}>
|
||||
{t.label}
|
||||
</div>
|
||||
</button>
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Event tiles (click jumps the events panel to that stream) */}
|
||||
<div className="grid-4" style={{ gap: 14, marginBottom: 8 }}>
|
||||
{EVENT_TILES.map((t) => (
|
||||
<Tile
|
||||
key={t.key}
|
||||
value={counts[t.key] ?? 0}
|
||||
label={t.label}
|
||||
sub={t.key === 'joins' && counts.invite_joins ? `${counts.invite_joins} via invite` : null}
|
||||
active={eventTab === t.tab}
|
||||
onClick={() => setEventTab(t.tab)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<p className="sans dim" style={{ fontSize: '0.78rem', margin: '0 0 24px' }}>
|
||||
Joins / leaves, filter hits, spam hits, and invite usage aren’t tracked yet — they arrive
|
||||
when bot event capture lands (Phase 6b).
|
||||
Counts are for the selected window. Member, filter, and spam events are captured live by the bot.
|
||||
</p>
|
||||
|
||||
{/* Recent activity feed */}
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12, flexWrap: 'wrap', marginBottom: 12 }}>
|
||||
<h2 className="display" style={{ margin: 0, fontSize: '1.25rem', color: 'var(--head)' }}>
|
||||
Recent actions
|
||||
</h2>
|
||||
{/* Recent moderation actions */}
|
||||
<div style={rowHead}>
|
||||
<h2 className="display" style={h2}>Recent actions</h2>
|
||||
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap' }}>
|
||||
{TYPES.map((t) => (
|
||||
<button
|
||||
key={t.label}
|
||||
onClick={() => setTypeFilter(t.key)}
|
||||
className="pill"
|
||||
style={typeFilter === t.key ? activePill : undefined}
|
||||
>
|
||||
<button key={t.label} onClick={() => setTypeFilter(t.key)} className="pill" style={typeFilter === t.key ? activePill : undefined}>
|
||||
{t.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="panel-flat">
|
||||
<div className="panel-flat" style={{ marginBottom: 30 }}>
|
||||
<table className="adm-table">
|
||||
<thead>
|
||||
<tr>
|
||||
@@ -121,52 +129,154 @@ export default function Moderation() {
|
||||
</thead>
|
||||
<tbody>
|
||||
{feed.length === 0 && (
|
||||
<tr>
|
||||
<td className="adm-td" colSpan={5} style={{ color: 'var(--muted)' }}>
|
||||
No matching actions.
|
||||
</td>
|
||||
</tr>
|
||||
<tr><td className="adm-td" colSpan={5} style={muted}>No matching actions.</td></tr>
|
||||
)}
|
||||
{feed.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">
|
||||
<span className={`badge badge-${a.action_type}`}>{a.action_type}</span>
|
||||
<span className="link-accent" onClick={() => goUser(a.target_user_id)}>{a.target_tag || a.target_user_id}</span>
|
||||
{a.linked_account && <span className="badge badge-editor" style={{ marginLeft: 8 }}>site: {a.linked_account.username}</span>}
|
||||
</td>
|
||||
<td className="adm-td">
|
||||
<span
|
||||
className="link-accent"
|
||||
onClick={() => navigate(`/admin/moderation/user/${a.target_user_id}`)}
|
||||
>
|
||||
{a.target_tag || a.target_user_id}
|
||||
</span>
|
||||
{a.linked_account && (
|
||||
<span className="badge badge-editor" style={{ marginLeft: 8 }}>
|
||||
site: {a.linked_account.username}
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="adm-td">
|
||||
{a.is_automated ? (
|
||||
<span className="badge badge-auto">Automated</span>
|
||||
) : (
|
||||
<span style={{ color: 'var(--text)' }}>{a.staff_tag || a.staff_user_id}</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="adm-td" style={{ color: 'var(--muted)', maxWidth: 280 }}>
|
||||
{a.reason || '—'}
|
||||
</td>
|
||||
<td className="adm-td dim" title={dateTime(a.created_at)}>
|
||||
{ago(a.created_at)}
|
||||
{a.is_automated ? <span className="badge badge-auto">Automated</span> : <span style={{ color: 'var(--text)' }}>{a.staff_tag || a.staff_user_id}</span>}
|
||||
</td>
|
||||
<td className="adm-td" style={{ color: 'var(--muted)', maxWidth: 280 }}>{a.reason || '—'}</td>
|
||||
<td className="adm-td dim" title={dateTime(a.created_at)}>{ago(a.created_at)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Event streams panel */}
|
||||
<div style={rowHead}>
|
||||
<h2 className="display" style={h2}>Events</h2>
|
||||
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap' }}>
|
||||
{EVENT_TABS.map((t) => (
|
||||
<button key={t.key} onClick={() => setEventTab(t.key)} className="pill" style={eventTab === t.key ? activePill : undefined}>
|
||||
{t.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
{eventTab === 'members' && <MembersTable rows={members} onUser={goUser} />}
|
||||
{eventTab === 'filter' && <FilterTable rows={filterHits} onUser={goUser} />}
|
||||
{eventTab === 'spam' && <SpamTable rows={spamHits} onUser={goUser} />}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
function Tile({ value, label, sub, active, onClick }) {
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
style={{
|
||||
textAlign: 'left',
|
||||
padding: 20,
|
||||
border: `1px solid ${active ? 'var(--accent)' : 'var(--line)'}`,
|
||||
borderRadius: 12,
|
||||
background: 'var(--panel-grad)',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
<div className="display" style={{ fontSize: '2rem', color: 'var(--head)', lineHeight: 1 }}>{value}</div>
|
||||
<div className="card-kicker" style={{ marginTop: 8, marginBottom: 0 }}>{label}</div>
|
||||
{sub && <div className="sans dim" style={{ fontSize: '0.68rem', marginTop: 4 }}>{sub}</div>}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
function MembersTable({ rows, onUser }) {
|
||||
return (
|
||||
<div className="panel-flat">
|
||||
<table className="adm-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th className="adm-th">Event</th>
|
||||
<th className="adm-th">User</th>
|
||||
<th className="adm-th">Invite</th>
|
||||
<th className="adm-th">When</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.length === 0 && <tr><td className="adm-td" colSpan={4} style={muted}>No member events yet.</td></tr>}
|
||||
{rows.map((m) => (
|
||||
<tr key={m.id}>
|
||||
<td className="adm-td"><span className={`badge ${m.event_type === 'join' ? 'badge-pub' : 'badge-ban'}`}>{m.event_type}</span></td>
|
||||
<td className="adm-td"><span className="link-accent" onClick={() => onUser(m.discord_user_id)}>{m.username || m.discord_user_id}</span></td>
|
||||
<td className="adm-td dim">
|
||||
{m.invite_code ? (
|
||||
<span>{m.invite_code}{m.inviter_tag ? ` · by ${m.inviter_tag}` : ''}</span>
|
||||
) : '—'}
|
||||
</td>
|
||||
<td className="adm-td dim" title={dateTime(m.created_at)}>{ago(m.created_at)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function FilterTable({ rows, onUser }) {
|
||||
return (
|
||||
<div className="panel-flat">
|
||||
<table className="adm-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th className="adm-th">Type</th>
|
||||
<th className="adm-th">User</th>
|
||||
<th className="adm-th">Matched</th>
|
||||
<th className="adm-th">Action</th>
|
||||
<th className="adm-th">When</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.length === 0 && <tr><td className="adm-td" colSpan={5} style={muted}>No filter hits yet.</td></tr>}
|
||||
{rows.map((f) => (
|
||||
<tr key={f.id}>
|
||||
<td className="adm-td"><span className={`badge ${f.hit_type === 'invite' ? 'badge-ban' : 'badge-warn'}`}>{f.hit_type}</span></td>
|
||||
<td className="adm-td"><span className="link-accent" onClick={() => onUser(f.discord_user_id)}>{f.username || f.discord_user_id}</span></td>
|
||||
<td className="adm-td" style={{ color: 'var(--text)', maxWidth: 240 }}>{f.matched || '—'}</td>
|
||||
<td className="adm-td"><span className={`badge badge-${f.action_taken === 'delete' ? 'auto' : f.action_taken}`}>{f.action_taken}</span></td>
|
||||
<td className="adm-td dim" title={dateTime(f.created_at)}>{ago(f.created_at)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const SPAM_LABEL = { rate_limit: 'Rate limit', mass_mention: 'Mass mention', mass_emoji: 'Mass emoji' }
|
||||
|
||||
function SpamTable({ rows, onUser }) {
|
||||
return (
|
||||
<div className="panel-flat">
|
||||
<table className="adm-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th className="adm-th">Type</th>
|
||||
<th className="adm-th">User</th>
|
||||
<th className="adm-th">When</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.length === 0 && <tr><td className="adm-td" colSpan={3} style={muted}>No spam hits yet.</td></tr>}
|
||||
{rows.map((s) => (
|
||||
<tr key={s.id}>
|
||||
<td className="adm-td"><span className="badge badge-warn">{SPAM_LABEL[s.spam_type] || s.spam_type}</span></td>
|
||||
<td className="adm-td"><span className="link-accent" onClick={() => onUser(s.discord_user_id)}>{s.username || s.discord_user_id}</span></td>
|
||||
<td className="adm-td dim" title={dateTime(s.created_at)}>{ago(s.created_at)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// User lookup: search by Discord id or a historical username snapshot.
|
||||
function UserSearch({ onPick }) {
|
||||
const [term, setTerm] = useState('')
|
||||
@@ -179,8 +289,7 @@ function UserSearch({ onPick }) {
|
||||
if (!q) return
|
||||
setBusy(true)
|
||||
try {
|
||||
const rows = await api.admin.modSearch(q)
|
||||
setResults(rows)
|
||||
setResults(await api.admin.modSearch(q))
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
@@ -189,21 +298,11 @@ function UserSearch({ onPick }) {
|
||||
return (
|
||||
<div style={{ marginBottom: 22 }}>
|
||||
<form onSubmit={run} style={{ display: 'flex', gap: 8 }}>
|
||||
<input
|
||||
className="input"
|
||||
placeholder="Search by Discord ID or username…"
|
||||
value={term}
|
||||
onChange={(e) => setTerm(e.target.value)}
|
||||
style={{ maxWidth: 360 }}
|
||||
/>
|
||||
<button type="submit" className="btn btn-primary btn-sq" disabled={busy}>
|
||||
{busy ? 'Searching…' : 'Look up'}
|
||||
</button>
|
||||
<input className="input" placeholder="Search by Discord ID or username…" value={term} onChange={(e) => setTerm(e.target.value)} style={{ maxWidth: 360 }} />
|
||||
<button type="submit" className="btn btn-primary btn-sq" disabled={busy}>{busy ? 'Searching…' : 'Look up'}</button>
|
||||
</form>
|
||||
{results && results.length === 0 && (
|
||||
<p className="sans dim" style={{ fontSize: '0.82rem', marginTop: 10 }}>
|
||||
No moderated users match “{term}”.
|
||||
</p>
|
||||
<p className="sans dim" style={{ fontSize: '0.82rem', marginTop: 10 }}>No moderated users match “{term}”.</p>
|
||||
)}
|
||||
{results && results.length > 0 && (
|
||||
<div className="panel-flat" style={{ marginTop: 10 }}>
|
||||
@@ -212,9 +311,7 @@ function UserSearch({ onPick }) {
|
||||
{results.map((r) => (
|
||||
<tr key={r.target_user_id} style={{ cursor: 'pointer' }} onClick={() => onPick(r.target_user_id)}>
|
||||
<td className="adm-td" style={{ color: 'var(--head)' }}>{r.target_tag || '(unknown tag)'}</td>
|
||||
<td className="adm-td dim" style={{ fontFamily: 'ui-monospace,Menlo,monospace', fontSize: '0.8rem' }}>
|
||||
{r.target_user_id}
|
||||
</td>
|
||||
<td className="adm-td dim" style={{ fontFamily: 'ui-monospace,Menlo,monospace', fontSize: '0.8rem' }}>{r.target_user_id}</td>
|
||||
<td className="adm-td dim">{r.action_count} action{Number(r.action_count) === 1 ? '' : 's'}</td>
|
||||
<td className="adm-td dim">last {ago(r.last_seen)}</td>
|
||||
</tr>
|
||||
@@ -227,8 +324,7 @@ function UserSearch({ onPick }) {
|
||||
)
|
||||
}
|
||||
|
||||
const activePill = {
|
||||
background: 'var(--blue)',
|
||||
color: 'var(--ink)',
|
||||
borderColor: 'var(--accent)',
|
||||
}
|
||||
const activePill = { background: 'var(--blue)', color: 'var(--ink)', borderColor: 'var(--accent)' }
|
||||
const rowHead = { display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12, flexWrap: 'wrap', marginBottom: 12 }
|
||||
const h2 = { margin: 0, fontSize: '1.25rem', color: 'var(--head)' }
|
||||
const muted = { color: 'var(--muted)' }
|
||||
|
||||
Reference in New Issue
Block a user