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:
2026-07-05 10:16:34 -05:00
parent 20d3fbf594
commit b0c0d1fe9b
19 changed files with 1436 additions and 7 deletions

View File

@@ -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,
)