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

@@ -678,6 +678,27 @@ adminRouter.get(
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
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(
'/moderation/user/:discordId',
// #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) {
try {
const summary = await moderation.userSummary(req.params.discordId)
@@ -126,6 +160,9 @@ module.exports = {
getSummary,
getRecent,
search,
getMembers,
getFilterHits,
getSpamHits,
getUser,
getUserActions,
getUserNotes,