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:
2026-07-05 10:36:09 -05:00
parent b0c0d1fe9b
commit 3027bb0400
19 changed files with 793 additions and 124 deletions

View File

@@ -9,6 +9,8 @@ const messageFilter = require('./messageFilter')
const scheduler = require('../scheduler/scheduler') const scheduler = require('../scheduler/scheduler')
const roleMenuHandler = require('./roleMenuHandler') const roleMenuHandler = require('./roleMenuHandler')
const { handleGuildMemberAdd } = require('./guildMemberAdd') const { handleGuildMemberAdd } = require('./guildMemberAdd')
const { handleGuildMemberRemove } = require('./guildMemberRemove')
const inviteTracker = require('./inviteTracker')
const tempRoleSweeper = require('../roles/tempRoleSweeper') const tempRoleSweeper = require('../roles/tempRoleSweeper')
const inviteScheduler = require('../invites/inviteScheduler') const inviteScheduler = require('../invites/inviteScheduler')
@@ -58,13 +60,15 @@ async function start({ token, guildId: gid }) {
// GuildMessages + MessageContent (Phase 3, filter) and GuildMembers // GuildMessages + MessageContent (Phase 3, filter) and GuildMembers
// (Phase 5, auto-role + bulk role ops) are all privileged — must be enabled // (Phase 5, auto-role + bulk role ops) are all privileged — must be enabled
// in the Discord Developer Portal, see the Phase 1 setup notes. // in the Discord Developer Portal, see the Phase 1 setup notes. GuildInvites
// (Phase 6b, invite-usage attribution) is NOT privileged — no portal toggle.
client = new Client({ client = new Client({
intents: [ intents: [
GatewayIntentBits.Guilds, GatewayIntentBits.Guilds,
GatewayIntentBits.GuildMessages, GatewayIntentBits.GuildMessages,
GatewayIntentBits.MessageContent, GatewayIntentBits.MessageContent,
GatewayIntentBits.GuildMembers, GatewayIntentBits.GuildMembers,
GatewayIntentBits.GuildInvites,
], ],
}) })
@@ -74,6 +78,7 @@ async function start({ token, guildId: gid }) {
await scheduler.start(client) await scheduler.start(client)
tempRoleSweeper.start(client) tempRoleSweeper.start(client)
inviteScheduler.start(client, guildId) inviteScheduler.start(client, guildId)
await inviteTracker.prime(client, guildId)
status = 'connected' status = 'connected'
statusDetail = null statusDetail = null
lastConnectedAt = new Date() lastConnectedAt = new Date()
@@ -102,6 +107,10 @@ async function start({ token, guildId: gid }) {
client.on('messageCreate', messageFilter.handleMessageCreate) client.on('messageCreate', messageFilter.handleMessageCreate)
client.on('guildMemberAdd', handleGuildMemberAdd) client.on('guildMemberAdd', handleGuildMemberAdd)
client.on('guildMemberRemove', handleGuildMemberRemove)
// Keep the invite-use cache fresh so guildMemberAdd can attribute joins.
client.on('inviteCreate', inviteTracker.onInviteCreate)
client.on('inviteDelete', inviteTracker.onInviteDelete)
client.on('error', (err) => { client.on('error', (err) => {
status = 'error' status = 'error'

View File

@@ -1,11 +1,37 @@
// Auto-role on join. Requires the Server Members privileged intent (already // Member join handling: record the join event (with best-effort invite
// enabled in the Discord Developer Portal per the Phase 1 setup notes). // attribution, Phase 6b) then apply the configured auto-role. Requires the
// Server Members privileged intent (already enabled per the Phase 1 setup notes)
// and, for invite attribution, the GuildInvites intent.
const guildConfig = require('../model/guildConfig') const guildConfig = require('../model/guildConfig')
const memberEvents = require('../model/memberEvents')
const inviteTracker = require('./inviteTracker')
const createLogger = require('../utils/logger') const createLogger = require('../utils/logger')
const log = createLogger('autorole') const log = createLogger('members')
async function handleGuildMemberAdd(member) { async function handleGuildMemberAdd(member) {
// Attribute the invite first (diffs the invite-use cache), then record the join.
// Both are best-effort — a failure here must never block the auto-role below.
let invite = { code: null, inviterId: null, inviterTag: null }
try {
invite = await inviteTracker.attribute(member)
} catch (err) {
log.warn('invite attribution threw', { userId: member.id, message: err.message })
}
try {
await memberEvents.record({
guildId: member.guild.id,
eventType: 'join',
discordUserId: member.id,
username: member.user?.tag,
inviteCode: invite.code,
inviterId: invite.inviterId,
inviterTag: invite.inviterTag,
})
} catch (err) {
log.warn('member join record failed', { userId: member.id, message: err.message })
}
try { try {
const roleId = await guildConfig.getAutoRoleId(member.guild.id) const roleId = await guildConfig.getAutoRoleId(member.guild.id)
if (!roleId) return if (!roleId) return

View File

@@ -0,0 +1,23 @@
// Member leave handling (Phase 6b): record a leave event for the dashboard's
// members feed. Fires on both voluntary leaves and kicks/bans — Discord doesn't
// distinguish them on this event, and the mod-action (if any) is logged
// separately via mod_actions, so a leave row here is purely the lifecycle fact.
const memberEvents = require('../model/memberEvents')
const createLogger = require('../utils/logger')
const log = createLogger('members')
async function handleGuildMemberRemove(member) {
try {
await memberEvents.record({
guildId: member.guild.id,
eventType: 'leave',
discordUserId: member.id,
username: member.user?.tag,
})
} catch (err) {
log.warn('member leave record failed', { userId: member.id, message: err.message })
}
}
module.exports = { handleGuildMemberRemove }

View File

@@ -0,0 +1,74 @@
// Best-effort invite-usage attribution (Phase 6b). Discord doesn't tell you
// which invite a member used, so the standard approach is to keep a cache of
// each invite's use-count and, on guildMemberAdd, re-fetch and find the one
// whose count went up. Requires the GuildInvites intent + Manage Guild (the bot
// already creates/deletes invites, so it has the permission). All calls are
// best-effort: any failure just yields a null attribution and the join is still
// recorded. Vanity-URL and bot-added joins are inherently unattributable.
const createLogger = require('../utils/logger')
const log = createLogger('invites')
// guildId -> Map<inviteCode, uses>
const cache = new Map()
async function snapshot(guild) {
const map = new Map()
const invites = await guild.invites.fetch()
for (const inv of invites.values()) map.set(inv.code, inv.uses || 0)
return map
}
// Populate the cache for a guild (call once the client is ready).
async function prime(client, guildId) {
try {
const guild = client.guilds.cache.get(guildId) || (await client.guilds.fetch(guildId))
cache.set(guildId, await snapshot(guild))
log.info('invite cache primed', { guildId, count: cache.get(guildId).size })
} catch (err) {
log.warn('invite cache prime failed (missing Manage Guild / GuildInvites?)', { message: err.message })
}
}
function onInviteCreate(invite) {
if (!invite.guild) return
const g = cache.get(invite.guild.id) || new Map()
g.set(invite.code, invite.uses || 0)
cache.set(invite.guild.id, g)
}
function onInviteDelete(invite) {
if (!invite.guild) return
const g = cache.get(invite.guild.id)
if (g) g.delete(invite.code)
}
// Diff current invite uses against the cached snapshot to find which invite the
// joining member used, then refresh the cache. Returns { code, inviterId,
// inviterTag } with nulls when it can't be determined.
async function attribute(member) {
const empty = { code: null, inviterId: null, inviterTag: null }
try {
const guild = member.guild
const before = cache.get(guild.id) || new Map()
const current = await guild.invites.fetch()
let found = empty
for (const inv of current.values()) {
const prev = before.get(inv.code) || 0
if ((inv.uses || 0) > prev && found === empty) {
found = { code: inv.code, inviterId: inv.inviter?.id || null, inviterTag: inv.inviter?.tag || null }
}
}
const next = new Map()
for (const inv of current.values()) next.set(inv.code, inv.uses || 0)
cache.set(guild.id, next)
return found
} catch (err) {
log.warn('invite attribution failed', { message: err.message })
return empty
}
}
module.exports = { prime, onInviteCreate, onInviteDelete, attribute }

View File

@@ -8,6 +8,8 @@ const { findMatch } = require('../filter/normalize')
const inviteFilter = require('../filter/inviteFilter') const inviteFilter = require('../filter/inviteFilter')
const spamFilter = require('../filter/spamFilter') const spamFilter = require('../filter/spamFilter')
const warnings = require('../model/warnings') const warnings = require('../model/warnings')
const filterHits = require('../model/filterHits')
const spamHits = require('../model/spamHits')
const modLog = require('./modLog') const modLog = require('./modLog')
const createLogger = require('../utils/logger') const createLogger = require('../utils/logger')
@@ -19,6 +21,48 @@ function botActor(client) {
return { id: client.user.id, tag: client.user.tag } return { id: client.user.id, tag: client.user.tag }
} }
// Dashboard event capture (Phase 6b). Best-effort — recording a hit must never
// break the moderation action it accompanies, so failures are swallowed+logged.
async function recordFilterHit(message, hitType, matched, actionTaken) {
try {
await filterHits.record({
guildId: message.guildId,
hitType,
discordUserId: message.author.id,
username: message.author.tag,
channelId: message.channelId,
matched,
actionTaken,
})
} catch (err) {
log.warn('filter hit record failed', { message: err.message })
}
}
async function recordSpamHit(message, spamType) {
try {
await spamHits.record({
guildId: message.guildId,
spamType,
discordUserId: message.author.id,
username: message.author.tag,
channelId: message.channelId,
})
} catch (err) {
log.warn('spam hit record failed', { message: err.message })
}
}
// Which spam rule tripped (for the spam_hits row). isRateLimited has a side
// effect (records this message's timestamp) so it must be evaluated first, and
// exactly once — mirroring the original OR-order.
function detectSpam(message) {
if (spamFilter.isRateLimited(message.guildId, message.author.id)) return 'rate_limit'
if (spamFilter.isMassMention(message)) return 'mass_mention'
if (spamFilter.isMassEmoji(message.content)) return 'mass_emoji'
return null
}
async function isBypassed(message, cache) { async function isBypassed(message, cache) {
if (cache.allowChannels.has(message.channelId)) return true if (cache.allowChannels.has(message.channelId)) return true
const memberRoles = message.member ? message.member.roles.cache : null const memberRoles = message.member ? message.member.roles.cache : null
@@ -62,8 +106,10 @@ async function handleMessageCreate(message) {
const cache = await filterCache.getOrLoad(message.guildId) const cache = await filterCache.getOrLoad(message.guildId)
if (await isBypassed(message, cache)) return if (await isBypassed(message, cache)) return
if (await inviteFilter.containsForeignInvite(message)) { const foreignCode = await inviteFilter.foreignInviteCode(message)
if (foreignCode) {
await message.delete().catch(() => {}) await message.delete().catch(() => {})
await recordFilterHit(message, 'invite', foreignCode, 'warn')
await applyWarnAction(message, 'Posted a Discord invite link') await applyWarnAction(message, 'Posted a Discord invite link')
return return
} }
@@ -71,17 +117,16 @@ async function handleMessageCreate(message) {
const match = findMatch(message.content, cache.words) const match = findMatch(message.content, cache.words)
if (match) { if (match) {
await message.delete().catch(() => {}) await message.delete().catch(() => {})
await recordFilterHit(message, 'word', match.word, match.severity)
if (match.severity === 'mute') await applyMuteAction(message, `Filtered word: ${match.word}`) if (match.severity === 'mute') await applyMuteAction(message, `Filtered word: ${match.word}`)
else if (match.severity === 'warn') await applyWarnAction(message, `Filtered word: ${match.word}`) else if (match.severity === 'warn') await applyWarnAction(message, `Filtered word: ${match.word}`)
return return
} }
if ( const spamType = detectSpam(message)
spamFilter.isRateLimited(message.guildId, message.author.id) || if (spamType) {
spamFilter.isMassMention(message) ||
spamFilter.isMassEmoji(message.content)
) {
await message.delete().catch(() => {}) await message.delete().catch(() => {})
await recordSpamHit(message, spamType)
await applyWarnAction(message, 'Automated spam detection (rate limit / mass mention / mass emoji)') await applyWarnAction(message, 'Automated spam detection (rate limit / mass mention / mass emoji)')
} }
} catch (err) { } catch (err) {

View File

@@ -4,20 +4,23 @@
// than silently letting an unresolvable link through. // than silently letting an unresolvable link through.
const INVITE_REGEX = /(?:discord\.gg|discord(?:app)?\.com\/invite)\/([a-zA-Z0-9-]+)/gi const INVITE_REGEX = /(?:discord\.gg|discord(?:app)?\.com\/invite)\/([a-zA-Z0-9-]+)/gi
async function containsForeignInvite(message) { // Returns the first foreign (or unresolvable) invite code found in the message,
// or null if the message contains no foreign invites. Returning the code (rather
// than a bare boolean) lets the caller record which invite was blocked.
async function foreignInviteCode(message) {
const matches = [...message.content.matchAll(INVITE_REGEX)] const matches = [...message.content.matchAll(INVITE_REGEX)]
if (matches.length === 0) return false if (matches.length === 0) return null
for (const match of matches) { for (const match of matches) {
const code = match[1] const code = match[1]
try { try {
const invite = await message.client.fetchInvite(code) const invite = await message.client.fetchInvite(code)
if (invite.guild?.id !== message.guildId) return true if (invite.guild?.id !== message.guildId) return code
} catch { } catch {
return true return code
} }
} }
return false return null
} }
module.exports = { containsForeignInvite } module.exports = { foreignInviteCode }

View File

@@ -0,0 +1,15 @@
// Automated content-filter hits (Phase 6b). Bot-owned; recorded whenever the
// word filter or foreign-invite filter deletes a message. mod_actions still
// records the resulting warn/mute separately. Schema: server/db/schema.sql
// (filter_hits).
const db = require('../db')
async function record({ guildId, hitType, discordUserId, username, channelId, matched, actionTaken }) {
await db.query(
`INSERT INTO filter_hits (guild_id, hit_type, discord_user_id, username, channel_id, matched, action_taken)
VALUES (?, ?, ?, ?, ?, ?, ?)`,
[guildId, hitType, discordUserId, username || null, channelId || null, matched || null, actionTaken],
)
}
module.exports = { record }

View File

@@ -0,0 +1,14 @@
// Guild member join/leave events (Phase 6b). Bot-owned; the site reads these for
// the moderation dashboard's members feed + invite-usage view. Schema in
// server/db/schema.sql (member_events).
const db = require('../db')
async function record({ guildId, eventType, discordUserId, username, inviteCode, inviterId, inviterTag }) {
await db.query(
`INSERT INTO member_events (guild_id, event_type, discord_user_id, username, invite_code, inviter_id, inviter_tag)
VALUES (?, ?, ?, ?, ?, ?, ?)`,
[guildId, eventType, discordUserId, username || null, inviteCode || null, inviterId || null, inviterTag || null],
)
}
module.exports = { record }

14
bot/src/model/spamHits.js Normal file
View File

@@ -0,0 +1,14 @@
// Automated spam-detection hits (Phase 6b). Bot-owned; recorded when the
// rate-limit / mass-mention / mass-emoji checks trip. mod_actions still logs the
// resulting warn separately. Schema: server/db/schema.sql (spam_hits).
const db = require('../db')
async function record({ guildId, spamType, discordUserId, username, channelId }) {
await db.query(
`INSERT INTO spam_hits (guild_id, spam_type, discord_user_id, username, channel_id)
VALUES (?, ?, ?, ?, ?)`,
[guildId, spamType, discordUserId, username || null, channelId || null],
)
}
module.exports = { record }

View File

@@ -131,6 +131,28 @@ export const api = {
return req(`/admin/moderation/recent${s ? `?${s}` : ''}`) return req(`/admin/moderation/recent${s ? `?${s}` : ''}`)
}, },
modSearch: (q) => req(`/admin/moderation/search?q=${encodeURIComponent(q)}`), 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}`), modUser: (discordId) => req(`/admin/moderation/user/${discordId}`),
modUserActions: (discordId, params = {}) => { modUserActions: (discordId, params = {}) => {
const qs = new URLSearchParams() const qs = new URLSearchParams()

View File

@@ -17,98 +17,106 @@ const TYPES = [
{ key: 'mute', label: 'Mutes' }, { key: 'mute', label: 'Mutes' },
{ key: 'warn', label: 'Warnings' }, { key: 'warn', label: 'Warnings' },
] ]
const TILE_TYPES = [ const MOD_TILES = [
{ key: 'ban', label: 'Bans' }, { key: 'ban', label: 'Bans' },
{ key: 'kick', label: 'Kicks' }, { key: 'kick', label: 'Kicks' },
{ key: 'mute', label: 'Mutes' }, { key: 'mute', label: 'Mutes' },
{ key: 'warn', label: 'Warnings' }, { 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() { export default function Moderation() {
const navigate = useNavigate() const navigate = useNavigate()
const [win, setWin] = useState('24h') const [win, setWin] = useState('24h')
const [typeFilter, setTypeFilter] = useState(null) const [typeFilter, setTypeFilter] = useState(null)
const [eventTab, setEventTab] = useState('members')
const { loading, error, data } = useAsync( 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 (loading) return <Loading />
if (error) return <ErrorState message="Could not load moderation data." /> if (error) return <ErrorState message="Could not load moderation data." />
const [summary, recent] = data const [summary, recent, members, filterHits, spamHits] = data
const counts = summary.windows?.[win] || { ban: 0, kick: 0, mute: 0, warn: 0 } const counts = summary.windows?.[win] || {}
const feed = typeFilter ? recent.filter((r) => r.action_type === typeFilter) : recent const feed = typeFilter ? recent.filter((r) => r.action_type === typeFilter) : recent
const goUser = (id) => navigate(`/admin/moderation/user/${id}`)
return ( return (
<section> <section>
<UserSearch onPick={(id) => navigate(`/admin/moderation/user/${id}`)} /> <UserSearch onPick={goUser} />
{/* Window selector */} {/* Window selector */}
<div style={{ display: 'flex', gap: 8, margin: '4px 0 14px' }}> <div style={{ display: 'flex', gap: 8, margin: '4px 0 14px' }}>
{WINDOWS.map((w) => ( {WINDOWS.map((w) => (
<button <button key={w.key} onClick={() => setWin(w.key)} className="pill" style={win === w.key ? activePill : undefined}>
key={w.key}
onClick={() => setWin(w.key)}
className="pill"
style={win === w.key ? activePill : undefined}
>
{w.label} {w.label}
</button> </button>
))} ))}
</div> </div>
{/* Stat tiles */} {/* Moderation-action tiles (click filters the recent-actions feed) */}
<div className="grid-4" style={{ gap: 14, marginBottom: 12 }}> <div className="grid-4" style={{ gap: 14, marginBottom: 14 }}>
{TILE_TYPES.map((t) => ( {MOD_TILES.map((t) => (
<button <Tile
key={t.key} key={t.key}
value={counts[t.key] ?? 0}
label={t.label}
active={typeFilter === t.key}
onClick={() => setTypeFilter(typeFilter === t.key ? null : 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> </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' }}> <p className="sans dim" style={{ fontSize: '0.78rem', margin: '0 0 24px' }}>
Joins / leaves, filter hits, spam hits, and invite usage arent tracked yet they arrive Counts are for the selected window. Member, filter, and spam events are captured live by the bot.
when bot event capture lands (Phase 6b).
</p> </p>
{/* Recent activity feed */} {/* Recent moderation actions */}
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12, flexWrap: 'wrap', marginBottom: 12 }}> <div style={rowHead}>
<h2 className="display" style={{ margin: 0, fontSize: '1.25rem', color: 'var(--head)' }}> <h2 className="display" style={h2}>Recent actions</h2>
Recent actions
</h2>
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap' }}> <div style={{ display: 'flex', gap: 6, flexWrap: 'wrap' }}>
{TYPES.map((t) => ( {TYPES.map((t) => (
<button <button key={t.label} onClick={() => setTypeFilter(t.key)} className="pill" style={typeFilter === t.key ? activePill : undefined}>
key={t.label}
onClick={() => setTypeFilter(t.key)}
className="pill"
style={typeFilter === t.key ? activePill : undefined}
>
{t.label} {t.label}
</button> </button>
))} ))}
</div> </div>
</div> </div>
<div className="panel-flat" style={{ marginBottom: 30 }}>
<div className="panel-flat">
<table className="adm-table"> <table className="adm-table">
<thead> <thead>
<tr> <tr>
@@ -121,52 +129,154 @@ export default function Moderation() {
</thead> </thead>
<tbody> <tbody>
{feed.length === 0 && ( {feed.length === 0 && (
<tr> <tr><td className="adm-td" colSpan={5} style={muted}>No matching actions.</td></tr>
<td className="adm-td" colSpan={5} style={{ color: 'var(--muted)' }}>
No matching actions.
</td>
</tr>
)} )}
{feed.map((a) => ( {feed.map((a) => (
<tr key={a.id}> <tr key={a.id}>
<td className="adm-td"><span className={`badge badge-${a.action_type}`}>{a.action_type}</span></td>
<td className="adm-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>
<td className="adm-td"> <td className="adm-td">
<span {a.is_automated ? <span className="badge badge-auto">Automated</span> : <span style={{ color: 'var(--text)' }}>{a.staff_tag || a.staff_user_id}</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)}
</td> </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> </tr>
))} ))}
</tbody> </tbody>
</table> </table>
</div> </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> </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. // User lookup: search by Discord id or a historical username snapshot.
function UserSearch({ onPick }) { function UserSearch({ onPick }) {
const [term, setTerm] = useState('') const [term, setTerm] = useState('')
@@ -179,8 +289,7 @@ function UserSearch({ onPick }) {
if (!q) return if (!q) return
setBusy(true) setBusy(true)
try { try {
const rows = await api.admin.modSearch(q) setResults(await api.admin.modSearch(q))
setResults(rows)
} finally { } finally {
setBusy(false) setBusy(false)
} }
@@ -189,21 +298,11 @@ function UserSearch({ onPick }) {
return ( return (
<div style={{ marginBottom: 22 }}> <div style={{ marginBottom: 22 }}>
<form onSubmit={run} style={{ display: 'flex', gap: 8 }}> <form onSubmit={run} style={{ display: 'flex', gap: 8 }}>
<input <input className="input" placeholder="Search by Discord ID or username…" value={term} onChange={(e) => setTerm(e.target.value)} style={{ maxWidth: 360 }} />
className="input" <button type="submit" className="btn btn-primary btn-sq" disabled={busy}>{busy ? 'Searching…' : 'Look up'}</button>
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> </form>
{results && results.length === 0 && ( {results && results.length === 0 && (
<p className="sans dim" style={{ fontSize: '0.82rem', marginTop: 10 }}> <p className="sans dim" style={{ fontSize: '0.82rem', marginTop: 10 }}>No moderated users match {term}.</p>
No moderated users match {term}.
</p>
)} )}
{results && results.length > 0 && ( {results && results.length > 0 && (
<div className="panel-flat" style={{ marginTop: 10 }}> <div className="panel-flat" style={{ marginTop: 10 }}>
@@ -212,9 +311,7 @@ function UserSearch({ onPick }) {
{results.map((r) => ( {results.map((r) => (
<tr key={r.target_user_id} style={{ cursor: 'pointer' }} onClick={() => onPick(r.target_user_id)}> <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" style={{ color: 'var(--head)' }}>{r.target_tag || '(unknown tag)'}</td>
<td className="adm-td dim" style={{ fontFamily: 'ui-monospace,Menlo,monospace', fontSize: '0.8rem' }}> <td className="adm-td dim" style={{ fontFamily: 'ui-monospace,Menlo,monospace', fontSize: '0.8rem' }}>{r.target_user_id}</td>
{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">{r.action_count} action{Number(r.action_count) === 1 ? '' : 's'}</td>
<td className="adm-td dim">last {ago(r.last_seen)}</td> <td className="adm-td dim">last {ago(r.last_seen)}</td>
</tr> </tr>
@@ -227,8 +324,7 @@ function UserSearch({ onPick }) {
) )
} }
const activePill = { const activePill = { background: 'var(--blue)', color: 'var(--ink)', borderColor: 'var(--accent)' }
background: 'var(--blue)', const rowHead = { display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12, flexWrap: 'wrap', marginBottom: 12 }
color: 'var(--ink)', const h2 = { margin: 0, fontSize: '1.25rem', color: 'var(--head)' }
borderColor: 'var(--accent)', const muted = { color: 'var(--muted)' }
}

View File

@@ -368,6 +368,64 @@ CREATE TABLE IF NOT EXISTS invite_log (
INDEX idx_invite_log_guild (guild_id, created_at) INDEX idx_invite_log_guild (guild_id, created_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- Guild member join/leave events (Phase 6b). Powers the dashboard's joins/leaves
-- feeds and the invite-usage view. Bot-owned (written by bot/src/discord/
-- guildMemberAdd.js + guildMemberRemove.js). For joins, invite_code/inviter_*
-- record which invite was used when the bot could attribute it (best-effort, see
-- bot/src/discord/inviteTracker.js) — NULL when undeterminable or for leaves.
-- These are member lifecycle events, not moderation actions, hence separate from
-- mod_actions.
CREATE TABLE IF NOT EXISTS member_events (
id INT AUTO_INCREMENT PRIMARY KEY,
guild_id VARCHAR(32) NOT NULL,
event_type ENUM('join','leave') NOT NULL,
discord_user_id VARCHAR(32) NOT NULL,
username VARCHAR(120) NULL,
invite_code VARCHAR(20) NULL,
inviter_id VARCHAR(32) NULL,
inviter_tag VARCHAR(120) NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
INDEX idx_member_events_guild (guild_id, created_at),
INDEX idx_member_events_user (guild_id, discord_user_id, created_at),
INDEX idx_member_events_invite (guild_id, invite_code)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- Automated content-filter hits (Phase 6b): one row per message the word filter
-- or the foreign-invite filter deleted. Separate from mod_actions (which still
-- records the resulting warn/mute) so the dashboard can show filter volume in
-- its own right. `matched` holds the offending word (word hits) or the blocked
-- invite code (invite hits); `action_taken` is what the pipeline did. Bot-owned
-- (bot/src/discord/messageFilter.js).
CREATE TABLE IF NOT EXISTS filter_hits (
id INT AUTO_INCREMENT PRIMARY KEY,
guild_id VARCHAR(32) NOT NULL,
hit_type ENUM('word','invite') NOT NULL,
discord_user_id VARCHAR(32) NOT NULL,
username VARCHAR(120) NULL,
channel_id VARCHAR(32) NULL,
matched VARCHAR(200) NULL,
action_taken ENUM('delete','warn','mute') NOT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
INDEX idx_filter_hits_guild (guild_id, created_at),
INDEX idx_filter_hits_user (guild_id, discord_user_id, created_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- Automated spam-detection hits (Phase 6b): rate-limit / mass-mention /
-- mass-emoji triggers. As with filter_hits, mod_actions still logs the resulting
-- warn; this records the detection itself for the dashboard's spam feed.
-- Bot-owned (bot/src/discord/messageFilter.js via bot/src/filter/spamFilter.js).
CREATE TABLE IF NOT EXISTS spam_hits (
id INT AUTO_INCREMENT PRIMARY KEY,
guild_id VARCHAR(32) NOT NULL,
spam_type ENUM('rate_limit','mass_mention','mass_emoji') NOT NULL,
discord_user_id VARCHAR(32) NOT NULL,
username VARCHAR(120) NULL,
channel_id VARCHAR(32) NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
INDEX idx_spam_hits_guild (guild_id, created_at),
INDEX idx_spam_hits_user (guild_id, discord_user_id, created_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- Staff notes on a Discord user, surfaced in the admin moderation dashboard -- Staff notes on a Discord user, surfaced in the admin moderation dashboard
-- (Phase 6). Unlike the tables above, this one is SERVER-owned — it is written -- (Phase 6). Unlike the tables above, this one is SERVER-owned — it is written
-- and read only by the main site (moderation.controller), never by the bot. -- and read only by the main site (moderation.controller), never by the bot.

View File

@@ -89,6 +89,70 @@ async function linkedAccount(discordId) {
return rows[0] || null return rows[0] || null
} }
// ── Phase 6b: member events + filter/spam hits (bot-owned, read-only) ──
// Join/leave counts per window (grouped by event_type).
async function memberCountsByWindow({ cutoff24h, cutoff7d, cutoff30d }) {
return query(
`SELECT event_type,
SUM(created_at >= ?) AS d1,
SUM(created_at >= ?) AS d7,
SUM(created_at >= ?) AS d30
FROM member_events
WHERE created_at >= ?
GROUP BY event_type`,
[cutoff24h, cutoff7d, cutoff30d, cutoff30d],
)
}
// Attributed-invite join counts per window (joins whose invite we identified).
async function inviteJoinCountsByWindow({ cutoff24h, cutoff7d, cutoff30d }) {
const rows = await query(
`SELECT SUM(created_at >= ?) AS d1, SUM(created_at >= ?) AS d7, SUM(created_at >= ?) AS d30
FROM member_events
WHERE event_type = 'join' AND invite_code IS NOT NULL AND created_at >= ?`,
[cutoff24h, cutoff7d, cutoff30d, cutoff30d],
)
return rows[0]
}
// Row-count per window for a simple event table. `table` is an internal literal
// ('filter_hits' | 'spam_hits'), never user input — see the caller allowlist.
async function tableCountsByWindow(table, { cutoff24h, cutoff7d, cutoff30d }) {
const rows = await query(
`SELECT SUM(created_at >= ?) AS d1, SUM(created_at >= ?) AS d7, SUM(created_at >= ?) AS d30
FROM ${table} WHERE created_at >= ?`,
[cutoff24h, cutoff7d, cutoff30d, cutoff30d],
)
return rows[0]
}
async function recentMemberEvents({ type = null, limit = 50, offset = 0 } = {}) {
const where = type ? 'WHERE event_type = ?' : ''
const params = type ? [type, limit, offset] : [limit, offset]
return query(
`SELECT id, guild_id, event_type, discord_user_id, username, invite_code, inviter_id, inviter_tag, created_at
FROM member_events ${where} ORDER BY id DESC LIMIT ? OFFSET ?`,
params,
)
}
async function recentFilterHits({ limit = 50, offset = 0 } = {}) {
return query(
`SELECT id, guild_id, hit_type, discord_user_id, username, channel_id, matched, action_taken, created_at
FROM filter_hits ORDER BY id DESC LIMIT ? OFFSET ?`,
[limit, offset],
)
}
async function recentSpamHits({ limit = 50, offset = 0 } = {}) {
return query(
`SELECT id, guild_id, spam_type, discord_user_id, username, channel_id, created_at
FROM spam_hits ORDER BY id DESC LIMIT ? OFFSET ?`,
[limit, offset],
)
}
// User-lookup: match a Discord id exactly, or a username snapshot (target_tag) // User-lookup: match a Discord id exactly, or a username snapshot (target_tag)
// by prefix, returning the most recently seen distinct targets. Powers the // by prefix, returning the most recently seen distinct targets. Powers the
// dashboard search box (usernames drift, so we search historical snapshots too). // dashboard search box (usernames drift, so we search historical snapshots too).
@@ -114,4 +178,11 @@ module.exports = {
latestTag, latestTag,
linkedAccount, linkedAccount,
searchTargets, searchTargets,
// Phase 6b
memberCountsByWindow,
inviteJoinCountsByWindow,
tableCountsByWindow,
recentMemberEvents,
recentFilterHits,
recentSpamHits,
} }

View File

@@ -7,9 +7,10 @@
// without needing new columns on mod_actions. // without needing new columns on mod_actions.
const moderationDb = require('./moderation.db') const moderationDb = require('./moderation.db')
const botConfigDb = require('../botConfig/botConfig.db') const botConfigDb = require('../botConfig/botConfig.db')
const { zeroCounts, annotate, reshapeWindows } = require('./moderation.pure') const { zeroCounts, annotate, reshapeWindows, windowValue } = require('./moderation.pure')
const DAY_MS = 24 * 60 * 60 * 1000 const DAY_MS = 24 * 60 * 60 * 1000
const WINDOW_KEYS = ['24h', '7d', '30d']
async function botApplicationId() { async function botApplicationId() {
try { try {
@@ -20,15 +21,47 @@ async function botApplicationId() {
} }
} }
// Counts by type across 24h / 7d / 30d windows for the overview tiles. // Counts by type across 24h / 7d / 30d windows for the overview tiles. Covers
// moderation actions (mod_actions) plus the Phase 6b event streams: member
// joins/leaves, attributed invite joins, and filter/spam hits.
async function summary() { async function summary() {
const now = Date.now() const now = Date.now()
const cutoff24h = new Date(now - DAY_MS) const cutoffs = {
const cutoff7d = new Date(now - 7 * DAY_MS) cutoff24h: new Date(now - DAY_MS),
const cutoff30d = new Date(now - 30 * DAY_MS) cutoff7d: new Date(now - 7 * DAY_MS),
cutoff30d: new Date(now - 30 * DAY_MS),
}
const rows = await moderationDb.countsByWindow({ cutoff24h, cutoff7d, cutoff30d }) const [modRows, memberRows, inviteRow, filterRow, spamRow] = await Promise.all([
return reshapeWindows(rows) moderationDb.countsByWindow(cutoffs),
moderationDb.memberCountsByWindow(cutoffs),
moderationDb.inviteJoinCountsByWindow(cutoffs),
moderationDb.tableCountsByWindow('filter_hits', cutoffs),
moderationDb.tableCountsByWindow('spam_hits', cutoffs),
])
const windows = reshapeWindows(modRows).windows
const joinRow = memberRows.find((r) => r.event_type === 'join')
const leaveRow = memberRows.find((r) => r.event_type === 'leave')
for (const w of WINDOW_KEYS) {
windows[w].joins = windowValue(joinRow, w)
windows[w].leaves = windowValue(leaveRow, w)
windows[w].invite_joins = windowValue(inviteRow, w)
windows[w].filter_hits = windowValue(filterRow, w)
windows[w].spam_hits = windowValue(spamRow, w)
}
return { windows }
}
// Recent event feeds for the overview's secondary panel (Phase 6b).
async function members(opts) {
return moderationDb.recentMemberEvents(opts)
}
async function filterHits(opts) {
return moderationDb.recentFilterHits(opts)
}
async function spamHits(opts) {
return moderationDb.recentSpamHits(opts)
} }
async function recent(opts) { async function recent(opts) {
@@ -69,4 +102,4 @@ async function search(term, opts) {
return moderationDb.searchTargets(term, opts) return moderationDb.searchTargets(term, opts)
} }
module.exports = { summary, recent, userActions, userSummary, search } module.exports = { summary, recent, userActions, userSummary, search, members, filterHits, spamHits }

View File

@@ -36,4 +36,12 @@ function reshapeWindows(rows) {
return { windows } return { windows }
} }
module.exports = { zeroCounts, annotate, reshapeWindows } // Pull the count for one window key ('24h'|'7d'|'30d') out of a
// { d1, d7, d30 } sum row, coercing to a number and tolerating a null row.
function windowValue(row, key) {
if (!row) return 0
const col = key === '24h' ? row.d1 : key === '7d' ? row.d7 : row.d30
return Number(col) || 0
}
module.exports = { zeroCounts, annotate, reshapeWindows, windowValue }

View File

@@ -678,6 +678,27 @@ adminRouter.get(
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }] // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
moderation.search, moderation.search,
) )
adminRouter.get(
'/moderation/members',
// #swagger.tags = ['Admin · Moderation']
// #swagger.summary = 'Recent member join/leave events (optionally filtered by type)'
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
moderation.getMembers,
)
adminRouter.get(
'/moderation/filter-hits',
// #swagger.tags = ['Admin · Moderation']
// #swagger.summary = 'Recent automated content-filter hits'
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
moderation.getFilterHits,
)
adminRouter.get(
'/moderation/spam-hits',
// #swagger.tags = ['Admin · Moderation']
// #swagger.summary = 'Recent automated spam-detection hits'
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
moderation.getSpamHits,
)
adminRouter.get( adminRouter.get(
'/moderation/user/:discordId', '/moderation/user/:discordId',
// #swagger.tags = ['Admin · Moderation'] // #swagger.tags = ['Admin · Moderation']

View File

@@ -60,6 +60,40 @@ async function search(req, res) {
} }
} }
// ── Phase 6b event feeds ──────────────────────────────────────────────
const MEMBER_TYPES = new Set(['join', 'leave'])
async function getMembers(req, res) {
try {
const { limit, offset } = pageParams(req)
const t = MEMBER_TYPES.has(req.query.type) ? req.query.type : null
return res.json(await moderation.members({ type: t, limit, offset }))
} catch (err) {
log.error('members failed', { error: err.message })
return res.status(500).json({ message: 'Internal Server Error' })
}
}
async function getFilterHits(req, res) {
try {
const { limit, offset } = pageParams(req)
return res.json(await moderation.filterHits({ limit, offset }))
} catch (err) {
log.error('filterHits failed', { error: err.message })
return res.status(500).json({ message: 'Internal Server Error' })
}
}
async function getSpamHits(req, res) {
try {
const { limit, offset } = pageParams(req)
return res.json(await moderation.spamHits({ limit, offset }))
} catch (err) {
log.error('spamHits failed', { error: err.message })
return res.status(500).json({ message: 'Internal Server Error' })
}
}
async function getUser(req, res) { async function getUser(req, res) {
try { try {
const summary = await moderation.userSummary(req.params.discordId) const summary = await moderation.userSummary(req.params.discordId)
@@ -126,6 +160,9 @@ module.exports = {
getSummary, getSummary,
getRecent, getRecent,
search, search,
getMembers,
getFilterHits,
getSpamHits,
getUser, getUser,
getUserActions, getUserActions,
getUserNotes, getUserNotes,

View File

@@ -3940,6 +3940,90 @@
] ]
} }
}, },
"/api/v1/admin/moderation/members": {
"get": {
"tags": [
"Admin · Moderation"
],
"summary": "Recent member join/leave events (optionally filtered by type)",
"description": "",
"parameters": [
{
"name": "type",
"in": "query",
"schema": {
"type": "string"
}
}
],
"responses": {
"200": {
"description": "OK"
},
"500": {
"description": "Internal Server Error"
}
},
"security": [
{
"cookieAuth": []
},
{
"bearerAuth": []
}
]
}
},
"/api/v1/admin/moderation/filter-hits": {
"get": {
"tags": [
"Admin · Moderation"
],
"summary": "Recent automated content-filter hits",
"description": "",
"responses": {
"200": {
"description": "OK"
},
"500": {
"description": "Internal Server Error"
}
},
"security": [
{
"cookieAuth": []
},
{
"bearerAuth": []
}
]
}
},
"/api/v1/admin/moderation/spam-hits": {
"get": {
"tags": [
"Admin · Moderation"
],
"summary": "Recent automated spam-detection hits",
"description": "",
"responses": {
"200": {
"description": "OK"
},
"500": {
"description": "Internal Server Error"
}
},
"security": [
{
"cookieAuth": []
},
{
"bearerAuth": []
}
]
}
},
"/api/v1/admin/moderation/user/{discordId}": { "/api/v1/admin/moderation/user/{discordId}": {
"get": { "get": {
"tags": [ "tags": [

View File

@@ -71,3 +71,19 @@ test('annotate: no linked identity yields null linked_account', () => {
const [row] = moderation.annotate([{ staff_user_id: '1', target_site_user_id: null }], null) const [row] = moderation.annotate([{ staff_user_id: '1', target_site_user_id: null }], null)
assert.equal(row.linked_account, null) assert.equal(row.linked_account, null)
}) })
test('windowValue: picks the right column per window key and coerces to number', () => {
const row = { d1: '2', d7: 5, d30: '11' }
assert.strictEqual(moderation.windowValue(row, '24h'), 2)
assert.strictEqual(moderation.windowValue(row, '7d'), 5)
assert.strictEqual(moderation.windowValue(row, '30d'), 11)
})
test('windowValue: null row (no rows in window) yields 0', () => {
assert.strictEqual(moderation.windowValue(null, '24h'), 0)
assert.strictEqual(moderation.windowValue(undefined, '30d'), 0)
})
test('windowValue: null sum column yields 0', () => {
assert.strictEqual(moderation.windowValue({ d1: null, d7: null, d30: null }, '7d'), 0)
})