// 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 moderation.router.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 appeals = require('../../../model/appeals/appeals.model') const { isTerminal, isAppealableType, reversalStatusFor } = require('../../../model/appeals/appeals.pure') const botInternalClient = require('../../../utils/botInternalClient') const activity = require('../../../model/activity/activity.model') const log = require('../../../utils/logger')('moderation') // The statuses the appeals queue can be filtered to. ?status=all expands to all // of them; a specific ?status= narrows to one; the default is the open // set (pending + under_review) that still needs staff attention. const APPEAL_STATUSES = ['pending', 'under_review', 'approved', 'denied', 'withdrawn'] const DEFAULT_APPEAL_STATUSES = ['pending', 'under_review'] 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' }) } } // ── Phase 6b event feeds ────────────────────────────────────────────── const MEMBER_TYPES = new Set(['join', 'leave']) async function getMembers(req, res) { try { const { limit, offset } = pageParams(req) const t = MEMBER_TYPES.has(req.query.type) ? req.query.type : null return res.json(await moderation.members({ type: t, limit, offset })) } catch (err) { log.error('members failed', { error: err.message }) return res.status(500).json({ message: 'Internal Server Error' }) } } async function getFilterHits(req, res) { try { const { limit, offset } = pageParams(req) return res.json(await moderation.filterHits({ limit, offset })) } catch (err) { log.error('filterHits failed', { error: err.message }) return res.status(500).json({ message: 'Internal Server Error' }) } } async function getSpamHits(req, res) { try { const { limit, offset } = pageParams(req) return res.json(await moderation.spamHits({ limit, offset })) } catch (err) { log.error('spamHits 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' }) } } // ── Phase 6c: appeals staff queue ───────────────────────────────────── // The status filter for the queue: ?status=all → every status, ?status= → // just that one (if valid), otherwise the default open set. function appealStatusFilter(req) { const s = req.query.status if (s === 'all') return APPEAL_STATUSES if (APPEAL_STATUSES.includes(s)) return [s] return DEFAULT_APPEAL_STATUSES } async function getAppeals(req, res) { try { const { limit, offset } = pageParams(req) const statuses = appealStatusFilter(req) return res.json(await appeals.queue({ statuses, limit, offset })) } catch (err) { log.error('getAppeals failed', { error: err.message }) return res.status(500).json({ message: 'Internal Server Error' }) } } async function getAppeal(req, res) { try { const appeal = await appeals.getById(Number(req.params.id)) if (!appeal) return res.status(404).json({ message: 'Appeal not found' }) return res.json(appeal) } catch (err) { log.error('getAppeal failed', { error: err.message }) return res.status(500).json({ message: 'Internal Server Error' }) } } // Claim a pending appeal → under_review, stamping the claiming staffer. Only a // still-pending appeal can be claimed (a second claim, or claiming a resolved // one, is a 409). async function claimAppeal(req, res) { try { const appeal = await appeals.getById(Number(req.params.id)) if (!appeal) return res.status(404).json({ message: 'Appeal not found' }) if (appeal.status !== 'pending') { return res.status(409).json({ message: 'Appeal is not open for claiming' }) } const updated = await appeals.claim(appeal.id, { handlerUserId: req.user.id, handlerTag: req.user.username, }) await activity.log({ req, action: 'moderation.appeal.claim', detail: { appealId: appeal.id, discordUserId: appeal.discord_user_id }, }) return res.json(updated) } catch (err) { log.error('claimAppeal failed', { error: err.message }) return res.status(500).json({ message: 'Internal Server Error' }) } } // Resolve an appeal (approved | denied). On an APPROVED ban/mute we best-effort // ask the bot to reverse the Discord action (unban / clear timeout). The bot // being down never fails the resolution — we record reversal_status='failed' // and still close the appeal. The response echoes the updated appeal plus a // `reversal` object describing what was attempted. async function resolveAppeal(req, res) { try { const status = req.body.status const staffResponse = req.body.staff_response ?? null const appeal = await appeals.getById(Number(req.params.id)) if (!appeal) return res.status(404).json({ message: 'Appeal not found' }) if (isTerminal(appeal.status)) { return res.status(409).json({ message: 'Appeal is already resolved' }) } // Best-effort Discord reversal only for an approved, appealable action. const shouldReverse = status === 'approved' && isAppealableType(appeal.action_type) let botResult = null if (shouldReverse) { botResult = await botInternalClient.reverseModAction({ discordUserId: appeal.discord_user_id, actionType: appeal.action_type, appealId: appeal.id, }) } const reversalStatus = reversalStatusFor({ status, actionType: appeal.action_type, botOk: botResult ? botResult.ok : false, }) const updated = await appeals.resolve(appeal.id, { status, staffResponse, handlerUserId: req.user.id, handlerTag: req.user.username, reversalStatus, }) await activity.log({ req, action: 'moderation.appeal.resolve', detail: { appealId: appeal.id, status, reversalStatus }, }) // Describe the reversal so the UI can show "unban succeeded / failed / n/a". const reversal = { attempted: shouldReverse, ok: botResult ? botResult.ok : false, reversal_status: reversalStatus, bot_status: botResult ? botResult.status : null, error: botResult && !botResult.ok ? botResult.error || null : null, } return res.json({ ...updated, reversal }) } catch (err) { log.error('resolveAppeal failed', { error: err.message }) return res.status(500).json({ message: 'Internal Server Error' }) } } async function getUserAppeals(req, res) { try { return res.json(await appeals.listForDiscordUser(req.params.discordId)) } catch (err) { log.error('getUserAppeals failed', { error: err.message }) return res.status(500).json({ message: 'Internal Server Error' }) } } module.exports = { getSummary, getRecent, search, getMembers, getFilterHits, getSpamHits, getUser, getUserActions, getUserNotes, addUserNote, getAppeals, getAppeal, claimAppeal, resolveAppeal, getUserAppeals, }