From bd53a0b8a4241d66936242d61889a19a44ab235b Mon Sep 17 00:00:00 2001 From: wtclaude Date: Mon, 27 Jul 2026 18:53:15 -0500 Subject: [PATCH] refactor(server): split admin moderation, bot-activity and activity into capability routers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- server/src/router/v1/admin/activity.router.js | 28 +++ server/src/router/v1/admin/admin.routes.js | 206 +----------------- .../src/router/v1/admin/botActivity.router.js | 47 ++++ server/src/router/v1/admin/index.js | 8 + .../src/router/v1/admin/moderation.router.js | 174 +++++++++++++++ 5 files changed, 263 insertions(+), 200 deletions(-) create mode 100644 server/src/router/v1/admin/activity.router.js create mode 100644 server/src/router/v1/admin/botActivity.router.js create mode 100644 server/src/router/v1/admin/moderation.router.js diff --git a/server/src/router/v1/admin/activity.router.js b/server/src/router/v1/admin/activity.router.js new file mode 100644 index 0000000..4de04da --- /dev/null +++ b/server/src/router/v1/admin/activity.router.js @@ -0,0 +1,28 @@ +// Admin · Activity — the staff audit log. +// +// Mounted at /api/v1/admin/activity by admin/index.js, which already applied +// `noindex, isLoggedIn, staffOnly`. No extra gate: any staff member may read the +// log, and every staff action is written to it regardless of who took it. +// +// A one-route capability, but a distinct one — this is the audit trail, not the +// dashboard's stats overview and not the bot-scoring state under /bot-activity. +// Handlers still live in admin.controller.js; this re-wires routes, not logic. + +const express = require('express') + +const ctrl = require('./admin.controller') + +const activityRouter = express.Router() + +activityRouter.get( + '/', + // #swagger.tags = ['Admin · Activity'] + // #swagger.summary = 'List recent admin activity' + // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }] + // #swagger.parameters['limit'] = { in: 'query', required: false, schema: { type: 'integer' }, description: 'Max rows to return.' } + /* #swagger.responses[200] = { description: 'Activity entries', content: { "application/json": { schema: { type: "array", items: { type: "object", additionalProperties: true } } } } } */ + /* #swagger.responses[401] = { description: 'Not authenticated', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ + ctrl.listActivity, +) + +module.exports = activityRouter diff --git a/server/src/router/v1/admin/admin.routes.js b/server/src/router/v1/admin/admin.routes.js index c954820..3bc18b3 100644 --- a/server/src/router/v1/admin/admin.routes.js +++ b/server/src/router/v1/admin/admin.routes.js @@ -4,9 +4,10 @@ // behind the shared `noindex, isLoggedIn, staffOnly` gate it owns, so the URLs // here are unchanged from when this file held all 110 admin routes. // -// Already extracted: users, account, invites, auth/providers. +// Already extracted: users, account, invites, auth/providers, moderation, +// bot-activity, activity. // Still here: shard, dashboard, site-mode, posts, uploads, wiki, pages, -// settings, activity, bot-activity, discord-bot, email, moderation, uo-link. +// settings, discord-bot, email, uo-link. // This file disappears when the last group moves. const express = require('express') @@ -17,13 +18,11 @@ const multer = require('multer') const { body, param } = require('express-validator') const ctrl = require('./admin.controller') -const botActivity = require('./botActivity.controller') const discordBot = require('./discordBot.controller') const emailConfig = require('./emailConfig.controller') const uoLink = require('./uoLink.controller') const shardOps = require('./shardOps.controller') const selfShard = require('../player/shard.controller') -const moderation = require('./moderation.controller') const pagesCtrl = require('./pages.controller') const { requireRole } = require('../../../utils/auth') const validate = require('../../../middleware/validate') @@ -34,9 +33,9 @@ const adminRouter = express.Router() // 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. +// Moderator gate. Admins can do everything a moderator can. Since the moderation +// dashboard moved to moderation.router.js this guards only the in-game staff +// operations below (/shard/*), which stay here until PR 4. const modAccess = requireRole('admin', 'moderator') // ── Game account linking (self-service, any staff role) ─────────────── @@ -751,48 +750,6 @@ adminRouter.put( ctrl.updateSettings, ) -// ── Activity log ────────────────────────────────────────────────────── -adminRouter.get( - '/activity', - // #swagger.tags = ['Admin · Activity'] - // #swagger.summary = 'List recent admin activity' - // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }] - // #swagger.parameters['limit'] = { in: 'query', required: false, schema: { type: 'integer' }, description: 'Max rows to return.' } - /* #swagger.responses[200] = { description: 'Activity entries', content: { "application/json": { schema: { type: "array", items: { type: "object", additionalProperties: true } } } } } */ - /* #swagger.responses[401] = { description: 'Not authenticated', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ - ctrl.listActivity, -) - -// ── Bot activity (admin only) ───────────────────────────────────────── -// Read-only view of the botScore middleware's in-memory scoring/ban state and -// recent events, plus an emergency unban for false positives. -adminRouter.get( - '/bot-activity', - // #swagger.tags = ['Admin · Bot Activity'] - // #swagger.summary = 'Bot-scoring / ban state and recent events (admin only)' - // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }] - /* #swagger.responses[200] = { description: 'Banned IPs, scores and recent events', content: { "application/json": { schema: { type: "object", additionalProperties: true } } } } */ - /* #swagger.responses[401] = { description: 'Not authenticated', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ - /* #swagger.responses[403] = { description: 'Admin role required', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ - adminOnly, - botActivity.getBotActivity, -) -adminRouter.post( - '/bot-activity/unban', - // #swagger.tags = ['Admin · Bot Activity'] - // #swagger.summary = 'Emergency unban an IP (admin only)' - // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }] - /* #swagger.requestBody = { required: true, content: { "application/json": { schema: { $ref: "#/components/schemas/UnbanRequest" } } } } */ - /* #swagger.responses[200] = { description: 'Unbanned (echoes the ip and whether an entry was cleared)', content: { "application/json": { schema: { $ref: "#/components/schemas/UnbanResult" } } } } */ - /* #swagger.responses[400] = { description: 'Invalid IP', content: { "application/json": { schema: { $ref: "#/components/schemas/ValidationError" } } } } */ - /* #swagger.responses[401] = { description: 'Not authenticated', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ - /* #swagger.responses[403] = { description: 'Admin role required', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ - adminOnly, - body('ip').isIP(), - validate, - botActivity.unbanIp, -) - // ── Discord bot control (admin only) ────────────────────────────────── // Phase 1: entering/enabling the bot token here — never an env var. The token // is write-only over this API (SECURITY note in discordBot.controller.js). @@ -904,157 +861,6 @@ adminRouter.post( emailConfig.disconnect, ) -// ── 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/members', - // #swagger.tags = ['Admin · Moderation'] - // #swagger.summary = 'Recent member join/leave events (optionally filtered by type)' - // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }] - moderation.getMembers, -) -adminRouter.get( - '/moderation/filter-hits', - // #swagger.tags = ['Admin · Moderation'] - // #swagger.summary = 'Recent automated content-filter hits' - // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }] - moderation.getFilterHits, -) -adminRouter.get( - '/moderation/spam-hits', - // #swagger.tags = ['Admin · Moderation'] - // #swagger.summary = 'Recent automated spam-detection hits' - // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }] - moderation.getSpamHits, -) -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(/^\d{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(/^\d{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(/^\d{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(/^\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, admin + moderator) ─────────────────────── -// Staff triage of player-submitted ban/mute appeals. Approving an appeal can -// trigger an automatic Discord reversal (Phase 6d) — see resolveAppeal. -adminRouter.get( - '/moderation/appeals', - // #swagger.tags = ['Admin · Moderation'] - // #swagger.summary = 'List moderation appeals (default: pending + under_review)' - // #swagger.description = 'Filter with ?status= 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, -) -adminRouter.get( - '/moderation/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, -) -adminRouter.post( - '/moderation/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, -) -adminRouter.post( - '/moderation/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, -) -adminRouter.get( - '/moderation/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, -) // ── uo-link sidecar control (admin only) ────────────────────────────────── // Connection config (base/ws URL + token + protocol + enabled) and the town diff --git a/server/src/router/v1/admin/botActivity.router.js b/server/src/router/v1/admin/botActivity.router.js new file mode 100644 index 0000000..a2ac3e9 --- /dev/null +++ b/server/src/router/v1/admin/botActivity.router.js @@ -0,0 +1,47 @@ +// Admin · Bot Activity — the botScore middleware's scoring/ban state. +// +// Mounted at /api/v1/admin/bot-activity by admin/index.js, which already applied +// `noindex, isLoggedIn, staffOnly`. Read-only view of the in-memory scores, +// banned IPs and recent events, plus an emergency unban for false positives. +// +// Admin-only, and kept as a per-route gate rather than a router-level `use` so +// the middleware chain each route carries is unchanged by the move. + +const express = require('express') +const { body } = require('express-validator') + +const botActivity = require('./botActivity.controller') +const { requireRole } = require('../../../utils/auth') +const validate = require('../../../middleware/validate') + +const botActivityRouter = express.Router() +const adminOnly = requireRole('admin') + +botActivityRouter.get( + '/', + // #swagger.tags = ['Admin · Bot Activity'] + // #swagger.summary = 'Bot-scoring / ban state and recent events (admin only)' + // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }] + /* #swagger.responses[200] = { description: 'Banned IPs, scores and recent events', content: { "application/json": { schema: { type: "object", additionalProperties: true } } } } */ + /* #swagger.responses[401] = { description: 'Not authenticated', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ + /* #swagger.responses[403] = { description: 'Admin role required', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ + adminOnly, + botActivity.getBotActivity, +) +botActivityRouter.post( + '/unban', + // #swagger.tags = ['Admin · Bot Activity'] + // #swagger.summary = 'Emergency unban an IP (admin only)' + // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }] + /* #swagger.requestBody = { required: true, content: { "application/json": { schema: { $ref: "#/components/schemas/UnbanRequest" } } } } */ + /* #swagger.responses[200] = { description: 'Unbanned (echoes the ip and whether an entry was cleared)', content: { "application/json": { schema: { $ref: "#/components/schemas/UnbanResult" } } } } */ + /* #swagger.responses[400] = { description: 'Invalid IP', content: { "application/json": { schema: { $ref: "#/components/schemas/ValidationError" } } } } */ + /* #swagger.responses[401] = { description: 'Not authenticated', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ + /* #swagger.responses[403] = { description: 'Admin role required', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ + adminOnly, + body('ip').isIP(), + validate, + botActivity.unbanIp, +) + +module.exports = botActivityRouter diff --git a/server/src/router/v1/admin/index.js b/server/src/router/v1/admin/index.js index 5887d88..2ac006a 100644 --- a/server/src/router/v1/admin/index.js +++ b/server/src/router/v1/admin/index.js @@ -17,6 +17,9 @@ const accountRouter = require('./account.router') const usersRouter = require('./users.router') const invitesRouter = require('./invites.router') const authProvidersRouter = require('./authProviders.router') +const moderationRouter = require('./moderation.router') +const botActivityRouter = require('./botActivity.router') +const activityRouter = require('./activity.router') const residualRouter = require('./admin.routes') const adminRouter = express.Router() @@ -38,6 +41,11 @@ adminRouter.use('/invites', invitesRouter) // Mounted at /auth, not /auth/providers: /admin/auth is the capability, and the // routes inside read as /providers[/:id]. adminRouter.use('/auth', authProvidersRouter) +// /moderation carries its own moderator gate; /bot-activity is admin-only per +// route. /activity is staff-wide — the audit log, not the bot-scoring state. +adminRouter.use('/moderation', moderationRouter) +adminRouter.use('/bot-activity', botActivityRouter) +adminRouter.use('/activity', activityRouter) // Everything not yet extracted, at the group root. Mounted last, but none of the // prefixes above appear in it, so nothing here depends on the ordering. diff --git a/server/src/router/v1/admin/moderation.router.js b/server/src/router/v1/admin/moderation.router.js new file mode 100644 index 0000000..ee659bd --- /dev/null +++ b/server/src/router/v1/admin/moderation.router.js @@ -0,0 +1,174 @@ +// 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= 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 -- 2.49.1