Add moderation dashboard, user history & notes (Phase 6a)
Surface the Discord bot's moderation data on the admin panel: a read-only
staff dashboard over the existing mod_actions log, per-user history, staff
notes, and a new moderator role. No bot changes.
Schema
- users.role ENUM gains 'moderator' (CREATE + idempotent ALTER for existing DBs)
- new server-owned mod_notes table (staff_only/admin_only visibility)
Server
- model/moderation: read mod_actions via the shared pool (documented read-only
cross of the bot/server ownership boundary), correlate accounts through
user_identities (provider='discord'), flag automated actions via
staff_user_id === bot_config.application_id; pure reshaping helpers isolated
in moderation.pure.js so they unit-test without opening a DB pool
- model/modNotes: list/add with role-gated admin_only visibility
- admin/moderation.controller + routes under /api/v1/admin/moderation/* gated by
requireRole('admin','moderator'); admin_only note writes require admin
- allow assigning 'moderator' in the user create/update validators
Client
- /admin/moderation overview (window tiles, type-filterable recent feed, user
lookup) and /user/:discordId history (tabs + notes with add-note)
- RoleGate; AdminLayout filters nav and confines moderators to their section
- moderator badge + action-type/auto badges
Deferred (see plan): 6b bot event capture (joins/leaves/filter/spam), 6c appeals
(needs public accounts), 6d /internal/mod-reverse bot reversal callback.
Verified: 116 server unit tests, client build, DB-backed model smoke, full
HTTP/RBAC e2e, and a browser click-through of the dashboard.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019rao86n5cXpwAyjdBFEshV
This commit is contained in:
72
server/src/model/moderation/moderation.model.js
Normal file
72
server/src/model/moderation/moderation.model.js
Normal file
@@ -0,0 +1,72 @@
|
||||
// Business logic for the moderation dashboard: reshapes the raw mod_actions
|
||||
// reads into the shapes the admin UI consumes, and annotates each action with
|
||||
// whether it was an automated (bot) action. For a Discord bot the application_id
|
||||
// IS the bot's user id, and the filter/spam pipeline records automated actions
|
||||
// with staff_user_id = the bot user (see bot/src/discord/messageFilter.js), so
|
||||
// staff_user_id === bot_config.application_id reliably flags automated actions
|
||||
// without needing new columns on mod_actions.
|
||||
const moderationDb = require('./moderation.db')
|
||||
const botConfigDb = require('../botConfig/botConfig.db')
|
||||
const { zeroCounts, annotate, reshapeWindows } = require('./moderation.pure')
|
||||
|
||||
const DAY_MS = 24 * 60 * 60 * 1000
|
||||
|
||||
async function botApplicationId() {
|
||||
try {
|
||||
const cfg = await botConfigDb.get()
|
||||
return cfg ? cfg.application_id : null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
// Counts by type across 24h / 7d / 30d windows for the overview tiles.
|
||||
async function summary() {
|
||||
const now = Date.now()
|
||||
const cutoff24h = new Date(now - DAY_MS)
|
||||
const cutoff7d = new Date(now - 7 * DAY_MS)
|
||||
const cutoff30d = new Date(now - 30 * DAY_MS)
|
||||
|
||||
const rows = await moderationDb.countsByWindow({ cutoff24h, cutoff7d, cutoff30d })
|
||||
return reshapeWindows(rows)
|
||||
}
|
||||
|
||||
async function recent(opts) {
|
||||
const appId = await botApplicationId()
|
||||
return annotate(await moderationDb.recentActions(opts), appId)
|
||||
}
|
||||
|
||||
async function userActions(discordId, opts) {
|
||||
const appId = await botApplicationId()
|
||||
return annotate(await moderationDb.userActions(discordId, opts), appId)
|
||||
}
|
||||
|
||||
// Header data for the per-user history page: latest known tag, linked site
|
||||
// account (if any), and all-time counts per action type.
|
||||
async function userSummary(discordId) {
|
||||
const [countRows, tag, linked] = await Promise.all([
|
||||
moderationDb.userCounts(discordId),
|
||||
moderationDb.latestTag(discordId),
|
||||
moderationDb.linkedAccount(discordId),
|
||||
])
|
||||
const counts = zeroCounts()
|
||||
let total = 0
|
||||
for (const row of countRows) {
|
||||
const c = Number(row.c) || 0
|
||||
if (counts[row.action_type] !== undefined) counts[row.action_type] = c
|
||||
total += c
|
||||
}
|
||||
return {
|
||||
discord_user_id: discordId,
|
||||
tag,
|
||||
linked_account: linked,
|
||||
counts,
|
||||
total_actions: total,
|
||||
}
|
||||
}
|
||||
|
||||
async function search(term, opts) {
|
||||
return moderationDb.searchTargets(term, opts)
|
||||
}
|
||||
|
||||
module.exports = { summary, recent, userActions, userSummary, search }
|
||||
Reference in New Issue
Block a user