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
171 lines
5.5 KiB
JavaScript
171 lines
5.5 KiB
JavaScript
// Admin moderation dashboard (Phase 6). Read-only views over the bot's
|
|
// mod_actions log plus server-owned staff notes. Mounted behind the
|
|
// admin+moderator RBAC gate (see admin.routes.js). The only mutation here is
|
|
// adding a staff note; admin_only notes are further restricted to the admin role.
|
|
const moderation = require('../../../model/moderation/moderation.model')
|
|
const modNotes = require('../../../model/modNotes/modNotes.model')
|
|
const modNotesDb = require('../../../model/modNotes/modNotes.db')
|
|
const activity = require('../../../model/activity/activity.model')
|
|
|
|
const log = require('../../../utils/logger')('moderation')
|
|
|
|
const VALID_TYPES = new Set(['ban', 'kick', 'mute', 'warn'])
|
|
const MAX_LIMIT = 200
|
|
const DEFAULT_LIMIT = 50
|
|
|
|
// Parse ?limit/&offset the same way the activity log does: numeric, capped.
|
|
function pageParams(req) {
|
|
const limit = Math.min(Number(req.query.limit) || DEFAULT_LIMIT, MAX_LIMIT)
|
|
const offset = Number(req.query.offset) || 0
|
|
return { limit, offset }
|
|
}
|
|
|
|
// Optional ?type filter — ignored unless it is a known action type.
|
|
function typeParam(req) {
|
|
const t = req.query.type
|
|
return VALID_TYPES.has(t) ? t : null
|
|
}
|
|
|
|
function isAdmin(req) {
|
|
return req.user && req.user.role === 'admin'
|
|
}
|
|
|
|
async function getSummary(req, res) {
|
|
try {
|
|
return res.json(await moderation.summary())
|
|
} catch (err) {
|
|
log.error('summary failed', { error: err.message })
|
|
return res.status(500).json({ message: 'Internal Server Error' })
|
|
}
|
|
}
|
|
|
|
async function getRecent(req, res) {
|
|
try {
|
|
const { limit, offset } = pageParams(req)
|
|
return res.json(await moderation.recent({ type: typeParam(req), limit, offset }))
|
|
} catch (err) {
|
|
log.error('recent failed', { error: err.message })
|
|
return res.status(500).json({ message: 'Internal Server Error' })
|
|
}
|
|
}
|
|
|
|
async function search(req, res) {
|
|
try {
|
|
const term = (req.query.q || '').trim()
|
|
if (!term) return res.json([])
|
|
return res.json(await moderation.search(term, { limit: 20 }))
|
|
} catch (err) {
|
|
log.error('search failed', { error: err.message })
|
|
return res.status(500).json({ message: 'Internal Server Error' })
|
|
}
|
|
}
|
|
|
|
// ── 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)
|
|
const notesCount = await modNotesDb.countForUser(req.params.discordId, {
|
|
includeAdminOnly: isAdmin(req),
|
|
})
|
|
return res.json({ ...summary, notes_count: notesCount })
|
|
} catch (err) {
|
|
log.error('getUser failed', { error: err.message })
|
|
return res.status(500).json({ message: 'Internal Server Error' })
|
|
}
|
|
}
|
|
|
|
async function getUserActions(req, res) {
|
|
try {
|
|
const { limit, offset } = pageParams(req)
|
|
return res.json(
|
|
await moderation.userActions(req.params.discordId, { type: typeParam(req), limit, offset }),
|
|
)
|
|
} catch (err) {
|
|
log.error('getUserActions failed', { error: err.message })
|
|
return res.status(500).json({ message: 'Internal Server Error' })
|
|
}
|
|
}
|
|
|
|
async function getUserNotes(req, res) {
|
|
try {
|
|
const notes = await modNotes.listForUser(req.params.discordId, {
|
|
includeAdminOnly: isAdmin(req),
|
|
})
|
|
return res.json(notes)
|
|
} catch (err) {
|
|
log.error('getUserNotes failed', { error: err.message })
|
|
return res.status(500).json({ message: 'Internal Server Error' })
|
|
}
|
|
}
|
|
|
|
async function addUserNote(req, res) {
|
|
try {
|
|
const visibility = req.body.visibility === 'admin_only' ? 'admin_only' : 'staff_only'
|
|
// admin_only notes can carry sensitive judgement calls — restrict to admins.
|
|
if (visibility === 'admin_only' && !isAdmin(req)) {
|
|
return res.status(403).json({ message: 'Only admins can add admin-only notes' })
|
|
}
|
|
const note = await modNotes.add({
|
|
discordUserId: req.params.discordId,
|
|
author: req.user,
|
|
body: req.body.body,
|
|
visibility,
|
|
})
|
|
await activity.log({
|
|
req,
|
|
action: 'moderation.note.add',
|
|
detail: { discordUserId: req.params.discordId, visibility },
|
|
})
|
|
return res.status(201).json(note)
|
|
} catch (err) {
|
|
log.error('addUserNote failed', { error: err.message })
|
|
return res.status(500).json({ message: 'Internal Server Error' })
|
|
}
|
|
}
|
|
|
|
module.exports = {
|
|
getSummary,
|
|
getRecent,
|
|
search,
|
|
getMembers,
|
|
getFilterHits,
|
|
getSpamHits,
|
|
getUser,
|
|
getUserActions,
|
|
getUserNotes,
|
|
addUserNote,
|
|
}
|