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
48 lines
1.7 KiB
JavaScript
48 lines
1.7 KiB
JavaScript
// Pure reshaping/annotation helpers for the moderation dashboard, deliberately
|
|
// free of any DB (or other side-effecting) imports so they can be unit-tested
|
|
// without opening a database pool. moderation.model re-exports these.
|
|
|
|
function zeroCounts() {
|
|
return { ban: 0, kick: 0, mute: 0, warn: 0 }
|
|
}
|
|
|
|
// Tag each action as automated (staff is the bot) and fold the joined
|
|
// user_identities columns into a linked_account object. The string coercion
|
|
// matters — snowflakes can arrive as number or string from different columns.
|
|
function annotate(rows, appId) {
|
|
return rows.map((r) => {
|
|
const isAutomated = appId != null && String(r.staff_user_id) === String(appId)
|
|
return {
|
|
...r,
|
|
is_automated: isAutomated,
|
|
linked_account: r.target_site_user_id
|
|
? { id: r.target_site_user_id, username: r.target_site_username }
|
|
: null,
|
|
}
|
|
})
|
|
}
|
|
|
|
// Fold the per-type window rows into the { windows: { '24h', '7d', '30d' } }
|
|
// shape the dashboard tiles consume, zero-filling any type with no rows.
|
|
function reshapeWindows(rows) {
|
|
const windows = { '24h': zeroCounts(), '7d': zeroCounts(), '30d': zeroCounts() }
|
|
for (const row of rows) {
|
|
const t = row.action_type
|
|
if (windows['24h'][t] === undefined) continue
|
|
windows['24h'][t] = Number(row.d1) || 0
|
|
windows['7d'][t] = Number(row.d7) || 0
|
|
windows['30d'][t] = Number(row.d30) || 0
|
|
}
|
|
return { windows }
|
|
}
|
|
|
|
// 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 }
|