PR 2 of the domain split (docs/website/API_V2_PLAN.md § Phase 2). Carves 18 more
routes out of admin.routes.js into one router file per business capability,
in place, with every URL unchanged:
moderation.router.js (15) /admin/moderation modAccess at router level
botActivity.router.js (2) /admin/bot-activity adminOnly per route
activity.router.js (1) /admin/activity staff-wide, no extra gate
The residual admin.routes.js drops from 82 routes to 64.
Moderation was already gated by a prefix mount (adminRouter.use('/moderation',
modAccess)), so moderationRouter.use(modAccess) is the exact equivalent now that
the router is mounted at a prefix. Bot-activity's adminOnly was per-route and is
deliberately kept per-route: that is what holds the per-route handler count in
routes.guards.json, the only signal that would catch a dropped gate, since
requireRole(...) returns an anonymous arrow and never appears by name.
/activity gets its own file rather than waiting for dashboard.router.js in PR 4
— it is the staff audit log, a different capability from the dashboard's stats
overview and from the botScore middleware's in-memory ban state.
Acceptance:
- routes.manifest.json zero-diff (200 public + 2 internal)
- routes.guards.json zero-diff
- swagger-output.json zero-diff (198 operations)
- api-route-inventory.json already in sync
- 434 server tests green
- role gates verified identical to main by reading the requireRole role sets
off the live Express stack for every moved route plus untouched controls
Co-Authored-By: Claude <noreply@anthropic.com>
175 lines
8.8 KiB
JavaScript
175 lines
8.8 KiB
JavaScript
// Admin · Moderation — the moderation dashboard and the appeals queue.
|
|
//
|
|
// Mounted at /api/v1/admin/moderation by admin/index.js, which already applied
|
|
// `noindex, isLoggedIn, staffOnly`. Read-only views over the Discord bot's
|
|
// mod_actions log, plus staff notes and staff triage of player-submitted
|
|
// ban/mute appeals.
|
|
//
|
|
// The whole capability is gated for the moderator role (admins included), so the
|
|
// gate is a router-level `use` — exactly equivalent to the old
|
|
// `adminRouter.use('/moderation', modAccess)` now that this router is mounted at
|
|
// a prefix. Editors get 403 here.
|
|
//
|
|
// Handlers still live in moderation.controller.js; this re-wires routes, not logic.
|
|
|
|
const express = require('express')
|
|
const { body, param } = require('express-validator')
|
|
|
|
const moderation = require('./moderation.controller')
|
|
const { requireRole } = require('../../../utils/auth')
|
|
const validate = require('../../../middleware/validate')
|
|
|
|
const moderationRouter = express.Router()
|
|
const modAccess = requireRole('admin', 'moderator')
|
|
|
|
moderationRouter.use(modAccess)
|
|
moderationRouter.get(
|
|
'/stats/summary',
|
|
// #swagger.tags = ['Admin · Moderation']
|
|
// #swagger.summary = 'Moderation action counts for 24h/7d/30d (admin or moderator)'
|
|
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
|
moderation.getSummary,
|
|
)
|
|
moderationRouter.get(
|
|
'/recent',
|
|
// #swagger.tags = ['Admin · Moderation']
|
|
// #swagger.summary = 'Recent moderation actions, optionally filtered by type'
|
|
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
|
moderation.getRecent,
|
|
)
|
|
moderationRouter.get(
|
|
'/search',
|
|
// #swagger.tags = ['Admin · Moderation']
|
|
// #swagger.summary = 'Look up moderated users by Discord id or username snapshot'
|
|
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
|
moderation.search,
|
|
)
|
|
moderationRouter.get(
|
|
'/members',
|
|
// #swagger.tags = ['Admin · Moderation']
|
|
// #swagger.summary = 'Recent member join/leave events (optionally filtered by type)'
|
|
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
|
moderation.getMembers,
|
|
)
|
|
moderationRouter.get(
|
|
'/filter-hits',
|
|
// #swagger.tags = ['Admin · Moderation']
|
|
// #swagger.summary = 'Recent automated content-filter hits'
|
|
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
|
moderation.getFilterHits,
|
|
)
|
|
moderationRouter.get(
|
|
'/spam-hits',
|
|
// #swagger.tags = ['Admin · Moderation']
|
|
// #swagger.summary = 'Recent automated spam-detection hits'
|
|
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
|
moderation.getSpamHits,
|
|
)
|
|
moderationRouter.get(
|
|
'/user/:discordId',
|
|
// #swagger.tags = ['Admin · Moderation']
|
|
// #swagger.summary = 'Per-user moderation summary (counts, latest tag, linked account)'
|
|
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
|
param('discordId').matches(/^\d{1,32}$/),
|
|
validate,
|
|
moderation.getUser,
|
|
)
|
|
moderationRouter.get(
|
|
'/user/:discordId/actions',
|
|
// #swagger.tags = ['Admin · Moderation']
|
|
// #swagger.summary = 'Full moderation action history for a user'
|
|
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
|
param('discordId').matches(/^\d{1,32}$/),
|
|
validate,
|
|
moderation.getUserActions,
|
|
)
|
|
moderationRouter.get(
|
|
'/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(/^\d{1,32}$/),
|
|
validate,
|
|
moderation.getUserNotes,
|
|
)
|
|
moderationRouter.post(
|
|
'/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(/^\d{1,32}$/),
|
|
body('body').isString().trim().isLength({ min: 1, max: 4000 }),
|
|
body('visibility').optional().isIn(['staff_only', 'admin_only']),
|
|
validate,
|
|
moderation.addUserNote,
|
|
)
|
|
|
|
// ── Appeals queue (Phase 6c) ──────────────────────────────────────────
|
|
// Approving an appeal can trigger an automatic Discord reversal (Phase 6d) —
|
|
// see resolveAppeal.
|
|
moderationRouter.get(
|
|
'/appeals',
|
|
// #swagger.tags = ['Admin · Moderation']
|
|
// #swagger.summary = 'List moderation appeals (default: pending + under_review)'
|
|
// #swagger.description = 'Filter with ?status=<pending|under_review|approved|denied|withdrawn> or ?status=all. Paginated with ?limit&offset.'
|
|
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
|
/* #swagger.responses[200] = { description: 'Appeals queue', content: { "application/json": { schema: { type: "array", items: { $ref: "#/components/schemas/AppealQueueItem" } } } } } */
|
|
moderation.getAppeals,
|
|
)
|
|
moderationRouter.get(
|
|
'/appeals/:id',
|
|
// #swagger.tags = ['Admin · Moderation']
|
|
// #swagger.summary = 'Get a single moderation appeal'
|
|
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
|
// #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'Appeal id.' }
|
|
/* #swagger.responses[200] = { description: 'The appeal', content: { "application/json": { schema: { $ref: "#/components/schemas/AppealQueueItem" } } } } */
|
|
/* #swagger.responses[404] = { description: 'Appeal not found', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
|
param('id').isInt({ min: 1 }),
|
|
validate,
|
|
moderation.getAppeal,
|
|
)
|
|
moderationRouter.post(
|
|
'/appeals/:id/claim',
|
|
// #swagger.tags = ['Admin · Moderation']
|
|
// #swagger.summary = 'Claim a pending appeal (→ under_review)'
|
|
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
|
// #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'Appeal id.' }
|
|
/* #swagger.responses[200] = { description: 'The claimed appeal', content: { "application/json": { schema: { $ref: "#/components/schemas/AppealQueueItem" } } } } */
|
|
/* #swagger.responses[404] = { description: 'Appeal not found', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
|
/* #swagger.responses[409] = { description: 'Appeal is not open for claiming', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
|
param('id').isInt({ min: 1 }),
|
|
validate,
|
|
moderation.claimAppeal,
|
|
)
|
|
moderationRouter.post(
|
|
'/appeals/:id/resolve',
|
|
// #swagger.tags = ['Admin · Moderation']
|
|
// #swagger.summary = 'Resolve an appeal (approved | denied); approval may auto-reverse the Discord action'
|
|
// #swagger.description = 'Approving a ban/mute appeal best-effort asks the bot to reverse the Discord action (unban / clear timeout). The bot being down never fails the resolution — reversal_status is recorded as failed. The response echoes the updated appeal plus a `reversal` object.'
|
|
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
|
// #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'Appeal id.' }
|
|
/* #swagger.requestBody = { required: true, content: { "application/json": { schema: { $ref: "#/components/schemas/ResolveAppealRequest" } } } } */
|
|
/* #swagger.responses[200] = { description: 'The resolved appeal (with reversal outcome)', content: { "application/json": { schema: { $ref: "#/components/schemas/AppealResolveResult" } } } } */
|
|
/* #swagger.responses[400] = { description: 'Validation error (status must be approved or denied)', content: { "application/json": { schema: { $ref: "#/components/schemas/ValidationError" } } } } */
|
|
/* #swagger.responses[404] = { description: 'Appeal not found', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
|
/* #swagger.responses[409] = { description: 'Appeal is already resolved', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
|
param('id').isInt({ min: 1 }),
|
|
body('status').isIn(['approved', 'denied']),
|
|
body('staff_response').optional({ values: 'falsy' }).isString().trim().isLength({ max: 4000 }),
|
|
validate,
|
|
moderation.resolveAppeal,
|
|
)
|
|
moderationRouter.get(
|
|
'/user/:discordId/appeals',
|
|
// #swagger.tags = ['Admin · Moderation']
|
|
// #swagger.summary = 'Appeals submitted for a Discord user'
|
|
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
|
// #swagger.parameters['discordId'] = { in: 'path', required: true, schema: { type: 'string' }, description: 'Discord snowflake.' }
|
|
/* #swagger.responses[200] = { description: 'Appeals for the user', content: { "application/json": { schema: { type: "array", items: { $ref: "#/components/schemas/AppealQueueItem" } } } } } */
|
|
param('discordId').matches(/^\d{1,32}$/),
|
|
validate,
|
|
moderation.getUserAppeals,
|
|
)
|
|
|
|
module.exports = moderationRouter
|