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:
@@ -10,6 +10,7 @@ const account = require('./account.controller')
|
||||
const botActivity = require('./botActivity.controller')
|
||||
const authProviders = require('./authProviders.controller')
|
||||
const discordBot = require('./discordBot.controller')
|
||||
const moderation = require('./moderation.controller')
|
||||
const { isLoggedIn, requireRole } = require('../../../utils/auth')
|
||||
const noindex = require('../../../middleware/noindex')
|
||||
const validate = require('../../../middleware/validate')
|
||||
@@ -23,6 +24,11 @@ adminRouter.use(noindex, isLoggedIn)
|
||||
// management, site mode, and settings are restricted to the admin role.
|
||||
const adminOnly = requireRole('admin')
|
||||
|
||||
// Moderation-dashboard gate. Moderators get the moderation views; admins can do
|
||||
// everything a moderator can. Sensitive writes (admin_only notes) add an extra
|
||||
// admin check inside the controller.
|
||||
const modAccess = requireRole('admin', 'moderator')
|
||||
|
||||
// ── Account security (self-service, any logged-in role) ───────────────
|
||||
// Not behind adminOnly: an editor manages their own 2FA too.
|
||||
adminRouter.get(
|
||||
@@ -647,6 +653,70 @@ adminRouter.delete(
|
||||
authProviders.remove,
|
||||
)
|
||||
|
||||
// ── Moderation dashboard (admin + moderator) ──────────────────────────
|
||||
// Read-only views over the bot's mod_actions log, plus staff notes. The whole
|
||||
// sub-path is gated for the moderator role (admins included).
|
||||
adminRouter.use('/moderation', modAccess)
|
||||
adminRouter.get(
|
||||
'/moderation/stats/summary',
|
||||
// #swagger.tags = ['Admin · Moderation']
|
||||
// #swagger.summary = 'Moderation action counts for 24h/7d/30d (admin or moderator)'
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
moderation.getSummary,
|
||||
)
|
||||
adminRouter.get(
|
||||
'/moderation/recent',
|
||||
// #swagger.tags = ['Admin · Moderation']
|
||||
// #swagger.summary = 'Recent moderation actions, optionally filtered by type'
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
moderation.getRecent,
|
||||
)
|
||||
adminRouter.get(
|
||||
'/moderation/search',
|
||||
// #swagger.tags = ['Admin · Moderation']
|
||||
// #swagger.summary = 'Look up moderated users by Discord id or username snapshot'
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
moderation.search,
|
||||
)
|
||||
adminRouter.get(
|
||||
'/moderation/user/:discordId',
|
||||
// #swagger.tags = ['Admin · Moderation']
|
||||
// #swagger.summary = 'Per-user moderation summary (counts, latest tag, linked account)'
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
param('discordId').matches(/^[0-9]{1,32}$/),
|
||||
validate,
|
||||
moderation.getUser,
|
||||
)
|
||||
adminRouter.get(
|
||||
'/moderation/user/:discordId/actions',
|
||||
// #swagger.tags = ['Admin · Moderation']
|
||||
// #swagger.summary = 'Full moderation action history for a user'
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
param('discordId').matches(/^[0-9]{1,32}$/),
|
||||
validate,
|
||||
moderation.getUserActions,
|
||||
)
|
||||
adminRouter.get(
|
||||
'/moderation/user/:discordId/notes',
|
||||
// #swagger.tags = ['Admin · Moderation']
|
||||
// #swagger.summary = 'Staff notes for a user (admin_only notes hidden from moderators)'
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
param('discordId').matches(/^[0-9]{1,32}$/),
|
||||
validate,
|
||||
moderation.getUserNotes,
|
||||
)
|
||||
adminRouter.post(
|
||||
'/moderation/user/:discordId/notes',
|
||||
// #swagger.tags = ['Admin · Moderation']
|
||||
// #swagger.summary = 'Add a staff note (admin_only visibility requires the admin role)'
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
param('discordId').matches(/^[0-9]{1,32}$/),
|
||||
body('body').isString().trim().isLength({ min: 1, max: 4000 }),
|
||||
body('visibility').optional().isIn(['staff_only', 'admin_only']),
|
||||
validate,
|
||||
moderation.addUserNote,
|
||||
)
|
||||
|
||||
// ── User management (admin only) ──────────────────────────────────────
|
||||
adminRouter.use('/users', adminOnly)
|
||||
adminRouter.get(
|
||||
@@ -672,7 +742,7 @@ adminRouter.post(
|
||||
/* #swagger.responses[409] = { description: 'Username already taken', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
body('username').isString().trim().isLength({ min: 3, max: 32 }),
|
||||
body('password').isString().isLength({ min: 8, max: 64 }),
|
||||
body('role').optional().isIn(['admin', 'editor']),
|
||||
body('role').optional().isIn(['admin', 'editor', 'moderator']),
|
||||
validate,
|
||||
ctrl.createUser,
|
||||
)
|
||||
@@ -692,7 +762,7 @@ adminRouter.put(
|
||||
param('id').isInt(),
|
||||
body('username').optional().isString().trim().isLength({ min: 3, max: 32 }),
|
||||
body('password').optional().isString().isLength({ min: 8, max: 64 }),
|
||||
body('role').optional().isIn(['admin', 'editor']),
|
||||
body('role').optional().isIn(['admin', 'editor', 'moderator']),
|
||||
validate,
|
||||
ctrl.updateUser,
|
||||
)
|
||||
|
||||
133
server/src/router/v1/admin/moderation.controller.js
Normal file
133
server/src/router/v1/admin/moderation.controller.js
Normal file
@@ -0,0 +1,133 @@
|
||||
// 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' })
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
getUser,
|
||||
getUserActions,
|
||||
getUserNotes,
|
||||
addUserNote,
|
||||
}
|
||||
Reference in New Issue
Block a user