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

@@ -6,7 +6,7 @@ CREATE TABLE IF NOT EXISTS users (
id INT AUTO_INCREMENT PRIMARY KEY,
username VARCHAR(32) NOT NULL UNIQUE,
password_hash VARCHAR(72) NOT NULL,
role ENUM('admin','editor') NOT NULL DEFAULT 'admin',
role ENUM('admin','editor','moderator') NOT NULL DEFAULT 'admin',
totp_secret VARCHAR(64) NULL, -- base32 TOTP secret (opt-in 2FA)
totp_enabled TINYINT(1) NOT NULL DEFAULT 0,
-- Any session token issued before this instant is rejected (see requireAuth).
@@ -368,6 +368,25 @@ CREATE TABLE IF NOT EXISTS invite_log (
INDEX idx_invite_log_guild (guild_id, created_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- Staff notes on a Discord user, surfaced in the admin moderation dashboard
-- (Phase 6). Unlike the tables above, this one is SERVER-owned — it is written
-- and read only by the main site (moderation.controller), never by the bot.
-- Keyed by discord_user_id (a snowflake, matching mod_actions.target_user_id) so
-- notes attach to a Discord identity even when it has no linked site account.
-- Notes are never user-visible; admin_only notes are further restricted to the
-- admin role (moderators see staff_only only) — enforced in the query layer.
CREATE TABLE IF NOT EXISTS mod_notes (
id INT AUTO_INCREMENT PRIMARY KEY,
discord_user_id VARCHAR(32) NOT NULL,
author_user_id INT NULL,
author_tag VARCHAR(120) NULL,
body TEXT NOT NULL,
visibility ENUM('staff_only','admin_only') NOT NULL DEFAULT 'staff_only',
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT fk_mod_notes_author FOREIGN KEY (author_user_id) REFERENCES users(id) ON DELETE SET NULL,
INDEX idx_mod_notes_user (discord_user_id, created_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- Migrations for databases created before the wiki upgrade. Each statement uses
-- IF NOT EXISTS so re-running on every boot is a harmless no-op. New installs get
-- these columns from the CREATE TABLE above; existing installs get them here.
@@ -378,6 +397,10 @@ ALTER TABLE users ADD COLUMN IF NOT EXISTS totp_secret VARCHAR(64) NULL;
ALTER TABLE users ADD COLUMN IF NOT EXISTS totp_enabled TINYINT(1) NOT NULL DEFAULT 0;
-- Session-revocation cutoff for databases created before token revocation landed.
ALTER TABLE users ADD COLUMN IF NOT EXISTS tokens_valid_after DATETIME NULL;
-- Moderation dashboard (Phase 6): add the 'moderator' role to databases created
-- before it. MODIFY has no IF NOT EXISTS form, but re-declaring the same ENUM is
-- an idempotent no-op, so it is safe to run on every boot.
ALTER TABLE users MODIFY COLUMN role ENUM('admin','editor','moderator') NOT NULL DEFAULT 'admin';
ALTER TABLE wiki_pages ADD COLUMN IF NOT EXISTS excerpt VARCHAR(400) NULL;
ALTER TABLE wiki_pages ADD COLUMN IF NOT EXISTS category_id INT NULL;

View File

@@ -0,0 +1,51 @@
// Staff notes on a Discord user (server-owned, see db/schema.sql mod_notes).
// Notes are never user-visible; admin_only notes are filtered out for non-admin
// callers at this layer via includeAdminOnly.
const { query } = require('../../utils/db')
async function listForUser(discordId, { includeAdminOnly = false } = {}) {
const visClause = includeAdminOnly ? '' : "AND n.visibility = 'staff_only'"
return query(
`SELECT n.id, n.discord_user_id, n.author_user_id, n.author_tag,
n.body, n.visibility, n.created_at,
u.username AS author_username
FROM mod_notes n
LEFT JOIN users u ON u.id = n.author_user_id
WHERE n.discord_user_id = ? ${visClause}
ORDER BY n.id DESC`,
[discordId],
)
}
async function insert({ discordUserId, authorUserId = null, authorTag = null, body, visibility = 'staff_only' }) {
const res = await query(
`INSERT INTO mod_notes (discord_user_id, author_user_id, author_tag, body, visibility)
VALUES (?, ?, ?, ?, ?)`,
[discordUserId, authorUserId, authorTag, body, visibility],
)
return res.insertId
}
async function getById(id) {
const rows = await query(
`SELECT n.id, n.discord_user_id, n.author_user_id, n.author_tag,
n.body, n.visibility, n.created_at,
u.username AS author_username
FROM mod_notes n
LEFT JOIN users u ON u.id = n.author_user_id
WHERE n.id = ? LIMIT 1`,
[id],
)
return rows[0] || null
}
async function countForUser(discordId, { includeAdminOnly = false } = {}) {
const visClause = includeAdminOnly ? '' : "AND visibility = 'staff_only'"
const rows = await query(
`SELECT COUNT(*) AS c FROM mod_notes WHERE discord_user_id = ? ${visClause}`,
[discordId],
)
return Number(rows[0].c)
}
module.exports = { listForUser, insert, getById, countForUser }

View File

@@ -0,0 +1,18 @@
const modNotesDb = require('./modNotes.db')
async function listForUser(discordId, { includeAdminOnly = false } = {}) {
return modNotesDb.listForUser(discordId, { includeAdminOnly })
}
async function add({ discordUserId, author, body, visibility = 'staff_only' }) {
const id = await modNotesDb.insert({
discordUserId,
authorUserId: author ? author.id : null,
authorTag: author ? author.username : null,
body,
visibility,
})
return modNotesDb.getById(id)
}
module.exports = { listForUser, add }

View File

@@ -0,0 +1,117 @@
// Read-only access to the bot-owned moderation tables (mod_actions) for the
// admin moderation dashboard (Phase 6). These tables are normally owned by the
// bot process (bot/src/db.js) — see the comment in db/schema.sql — but they live
// in the same physical database, so the site reads them directly through the
// shared pool rather than round-tripping the bot over the internal API. This
// module NEVER writes them; all writes still belong to the bot.
//
// mod_actions is the single source of truth for ban/kick/mute/warn (every warn
// command also mirrors into `warnings`, so counting mod_actions avoids double
// counting). Accounts are correlated to Discord ids via user_identities
// (provider='discord', subject=<snowflake>), the same link the SSO flow writes.
const { query } = require('../../utils/db')
const TYPES = ['ban', 'kick', 'mute', 'warn']
// Per-type counts across three nested windows in a single scan. Boolean
// comparisons yield 1/0 in MariaDB, so SUM(created_at >= cutoff) counts the
// rows inside each window. Returns raw rows: [{ action_type, d1, d7, d30 }].
async function countsByWindow({ cutoff24h, cutoff7d, cutoff30d }) {
return query(
`SELECT action_type,
SUM(created_at >= ?) AS d1,
SUM(created_at >= ?) AS d7,
SUM(created_at >= ?) AS d30
FROM mod_actions
WHERE created_at >= ?
GROUP BY action_type`,
[cutoff24h, cutoff7d, cutoff30d, cutoff30d],
)
}
const ACTION_SELECT = `
SELECT ma.id, ma.guild_id, ma.action_type,
ma.target_user_id, ma.target_tag,
ma.staff_user_id, ma.staff_tag,
ma.reason, ma.duration_seconds, ma.created_at,
ui.user_id AS target_site_user_id,
u.username AS target_site_username
FROM mod_actions ma
LEFT JOIN user_identities ui
ON ui.provider = 'discord' AND ui.subject = ma.target_user_id
LEFT JOIN users u ON u.id = ui.user_id`
// Most-recent-first action feed, optionally filtered by type. limit/offset
// pagination matching the activity-log convention.
async function recentActions({ type = null, limit = 50, offset = 0 } = {}) {
const where = type ? 'WHERE ma.action_type = ?' : ''
const params = type ? [type, limit, offset] : [limit, offset]
return query(`${ACTION_SELECT} ${where} ORDER BY ma.id DESC LIMIT ? OFFSET ?`, params)
}
// Full action history for one Discord user, optionally filtered by type.
async function userActions(discordId, { type = null, limit = 50, offset = 0 } = {}) {
const where = type
? 'WHERE ma.target_user_id = ? AND ma.action_type = ?'
: 'WHERE ma.target_user_id = ?'
const params = type ? [discordId, type, limit, offset] : [discordId, limit, offset]
return query(`${ACTION_SELECT} ${where} ORDER BY ma.id DESC LIMIT ? OFFSET ?`, params)
}
// All-time per-type counts for one user.
async function userCounts(discordId) {
return query(
`SELECT action_type, COUNT(*) AS c FROM mod_actions
WHERE target_user_id = ? GROUP BY action_type`,
[discordId],
)
}
// Latest username snapshot the bot recorded for this Discord id (usernames drift).
async function latestTag(discordId) {
const rows = await query(
'SELECT target_tag FROM mod_actions WHERE target_user_id = ? ORDER BY id DESC LIMIT 1',
[discordId],
)
return rows[0] ? rows[0].target_tag : null
}
// Linked site account for a Discord id, if any (via user_identities).
async function linkedAccount(discordId) {
const rows = await query(
`SELECT u.id, u.username, u.role
FROM user_identities ui
JOIN users u ON u.id = ui.user_id
WHERE ui.provider = 'discord' AND ui.subject = ?
LIMIT 1`,
[discordId],
)
return rows[0] || null
}
// User-lookup: match a Discord id exactly, or a username snapshot (target_tag)
// by prefix, returning the most recently seen distinct targets. Powers the
// dashboard search box (usernames drift, so we search historical snapshots too).
async function searchTargets(term, { limit = 20 } = {}) {
return query(
`SELECT ma.target_user_id, MAX(ma.target_tag) AS target_tag,
COUNT(*) AS action_count, MAX(ma.created_at) AS last_seen
FROM mod_actions ma
WHERE ma.target_user_id = ? OR ma.target_tag LIKE ?
GROUP BY ma.target_user_id
ORDER BY last_seen DESC
LIMIT ?`,
[term, `${term}%`, limit],
)
}
module.exports = {
TYPES,
countsByWindow,
recentActions,
userActions,
userCounts,
latestTag,
linkedAccount,
searchTargets,
}

View File

@@ -0,0 +1,72 @@
// Business logic for the moderation dashboard: reshapes the raw mod_actions
// reads into the shapes the admin UI consumes, and annotates each action with
// whether it was an automated (bot) action. For a Discord bot the application_id
// IS the bot's user id, and the filter/spam pipeline records automated actions
// with staff_user_id = the bot user (see bot/src/discord/messageFilter.js), so
// staff_user_id === bot_config.application_id reliably flags automated actions
// without needing new columns on mod_actions.
const moderationDb = require('./moderation.db')
const botConfigDb = require('../botConfig/botConfig.db')
const { zeroCounts, annotate, reshapeWindows } = require('./moderation.pure')
const DAY_MS = 24 * 60 * 60 * 1000
async function botApplicationId() {
try {
const cfg = await botConfigDb.get()
return cfg ? cfg.application_id : null
} catch {
return null
}
}
// Counts by type across 24h / 7d / 30d windows for the overview tiles.
async function summary() {
const now = Date.now()
const cutoff24h = new Date(now - DAY_MS)
const cutoff7d = new Date(now - 7 * DAY_MS)
const cutoff30d = new Date(now - 30 * DAY_MS)
const rows = await moderationDb.countsByWindow({ cutoff24h, cutoff7d, cutoff30d })
return reshapeWindows(rows)
}
async function recent(opts) {
const appId = await botApplicationId()
return annotate(await moderationDb.recentActions(opts), appId)
}
async function userActions(discordId, opts) {
const appId = await botApplicationId()
return annotate(await moderationDb.userActions(discordId, opts), appId)
}
// Header data for the per-user history page: latest known tag, linked site
// account (if any), and all-time counts per action type.
async function userSummary(discordId) {
const [countRows, tag, linked] = await Promise.all([
moderationDb.userCounts(discordId),
moderationDb.latestTag(discordId),
moderationDb.linkedAccount(discordId),
])
const counts = zeroCounts()
let total = 0
for (const row of countRows) {
const c = Number(row.c) || 0
if (counts[row.action_type] !== undefined) counts[row.action_type] = c
total += c
}
return {
discord_user_id: discordId,
tag,
linked_account: linked,
counts,
total_actions: total,
}
}
async function search(term, opts) {
return moderationDb.searchTargets(term, opts)
}
module.exports = { summary, recent, userActions, userSummary, search }

View File

@@ -0,0 +1,39 @@
// Pure reshaping/annotation helpers for the moderation dashboard, deliberately
// free of any DB (or other side-effecting) imports so they can be unit-tested
// without opening a database pool. moderation.model re-exports these.
function zeroCounts() {
return { ban: 0, kick: 0, mute: 0, warn: 0 }
}
// Tag each action as automated (staff is the bot) and fold the joined
// user_identities columns into a linked_account object. The string coercion
// matters — snowflakes can arrive as number or string from different columns.
function annotate(rows, appId) {
return rows.map((r) => {
const isAutomated = appId != null && String(r.staff_user_id) === String(appId)
return {
...r,
is_automated: isAutomated,
linked_account: r.target_site_user_id
? { id: r.target_site_user_id, username: r.target_site_username }
: null,
}
})
}
// Fold the per-type window rows into the { windows: { '24h', '7d', '30d' } }
// shape the dashboard tiles consume, zero-filling any type with no rows.
function reshapeWindows(rows) {
const windows = { '24h': zeroCounts(), '7d': zeroCounts(), '30d': zeroCounts() }
for (const row of rows) {
const t = row.action_type
if (windows['24h'][t] === undefined) continue
windows['24h'][t] = Number(row.d1) || 0
windows['7d'][t] = Number(row.d7) || 0
windows['30d'][t] = Number(row.d30) || 0
}
return { windows }
}
module.exports = { zeroCounts, annotate, reshapeWindows }

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

View 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,
}

View File

@@ -3856,6 +3856,260 @@
]
}
},
"/api/v1/admin/moderation/stats/summary": {
"get": {
"tags": [
"Admin · Moderation"
],
"summary": "Moderation action counts for 24h/7d/30d (admin or moderator)",
"description": "",
"responses": {
"200": {
"description": "OK"
},
"500": {
"description": "Internal Server Error"
}
},
"security": [
{
"cookieAuth": []
},
{
"bearerAuth": []
}
]
}
},
"/api/v1/admin/moderation/recent": {
"get": {
"tags": [
"Admin · Moderation"
],
"summary": "Recent moderation actions, optionally filtered by type",
"description": "",
"responses": {
"200": {
"description": "OK"
},
"500": {
"description": "Internal Server Error"
}
},
"security": [
{
"cookieAuth": []
},
{
"bearerAuth": []
}
]
}
},
"/api/v1/admin/moderation/search": {
"get": {
"tags": [
"Admin · Moderation"
],
"summary": "Look up moderated users by Discord id or username snapshot",
"description": "",
"parameters": [
{
"name": "q",
"in": "query",
"schema": {
"type": "string"
}
}
],
"responses": {
"200": {
"description": "OK"
},
"500": {
"description": "Internal Server Error"
}
},
"security": [
{
"cookieAuth": []
},
{
"bearerAuth": []
}
]
}
},
"/api/v1/admin/moderation/user/{discordId}": {
"get": {
"tags": [
"Admin · Moderation"
],
"summary": "Per-user moderation summary (counts, latest tag, linked account)",
"description": "",
"parameters": [
{
"name": "discordId",
"in": "path",
"required": true,
"schema": {
"type": "string"
}
}
],
"responses": {
"200": {
"description": "OK"
},
"400": {
"description": "Bad Request"
},
"500": {
"description": "Internal Server Error"
}
},
"security": [
{
"cookieAuth": []
},
{
"bearerAuth": []
}
]
}
},
"/api/v1/admin/moderation/user/{discordId}/actions": {
"get": {
"tags": [
"Admin · Moderation"
],
"summary": "Full moderation action history for a user",
"description": "",
"parameters": [
{
"name": "discordId",
"in": "path",
"required": true,
"schema": {
"type": "string"
}
}
],
"responses": {
"200": {
"description": "OK"
},
"400": {
"description": "Bad Request"
},
"500": {
"description": "Internal Server Error"
}
},
"security": [
{
"cookieAuth": []
},
{
"bearerAuth": []
}
]
}
},
"/api/v1/admin/moderation/user/{discordId}/notes": {
"get": {
"tags": [
"Admin · Moderation"
],
"summary": "Staff notes for a user (admin_only notes hidden from moderators)",
"description": "",
"parameters": [
{
"name": "discordId",
"in": "path",
"required": true,
"schema": {
"type": "string"
}
}
],
"responses": {
"200": {
"description": "OK"
},
"400": {
"description": "Bad Request"
},
"500": {
"description": "Internal Server Error"
}
},
"security": [
{
"cookieAuth": []
},
{
"bearerAuth": []
}
]
},
"post": {
"tags": [
"Admin · Moderation"
],
"summary": "Add a staff note (admin_only visibility requires the admin role)",
"description": "",
"parameters": [
{
"name": "discordId",
"in": "path",
"required": true,
"schema": {
"type": "string"
}
}
],
"responses": {
"201": {
"description": "Created"
},
"400": {
"description": "Bad Request"
},
"403": {
"description": "Forbidden"
},
"500": {
"description": "Internal Server Error"
}
},
"security": [
{
"cookieAuth": []
},
{
"bearerAuth": []
}
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"visibility": {
"example": "any"
},
"body": {
"example": "any"
}
}
}
}
}
}
}
},
"/api/v1/admin/users": {
"get": {
"tags": [

View File

@@ -0,0 +1,73 @@
// Unit tests for the moderation dashboard's pure reshaping/annotation logic.
// DB-free (like the rest of this suite) — the SQL layer is exercised manually
// against a dev database per the plan's verification steps.
const { test } = require('node:test')
const assert = require('node:assert/strict')
const moderation = require('../src/model/moderation/moderation.pure')
test('reshapeWindows: folds rows into windows and zero-fills missing types', () => {
const rows = [
{ action_type: 'ban', d1: 1, d7: 3, d30: 5 },
{ action_type: 'warn', d1: 0, d7: 2, d30: 9 },
]
const { windows } = moderation.reshapeWindows(rows)
assert.deepEqual(windows['24h'], { ban: 1, kick: 0, mute: 0, warn: 0 })
assert.deepEqual(windows['7d'], { ban: 3, kick: 0, mute: 0, warn: 2 })
assert.deepEqual(windows['30d'], { ban: 5, kick: 0, mute: 0, warn: 9 })
})
test('reshapeWindows: coerces string/decimal SUM results to numbers', () => {
const { windows } = moderation.reshapeWindows([{ action_type: 'mute', d1: '2', d7: '2', d30: '4' }])
assert.strictEqual(windows['24h'].mute, 2)
assert.strictEqual(windows['30d'].mute, 4)
})
test('reshapeWindows: ignores unknown action types (e.g. future enum values)', () => {
const { windows } = moderation.reshapeWindows([{ action_type: 'filter_hit', d1: 9, d7: 9, d30: 9 }])
assert.deepEqual(windows['24h'], { ban: 0, kick: 0, mute: 0, warn: 0 })
})
test('reshapeWindows: empty input yields all-zero windows', () => {
const { windows } = moderation.reshapeWindows([])
assert.deepEqual(windows, {
'24h': { ban: 0, kick: 0, mute: 0, warn: 0 },
'7d': { ban: 0, kick: 0, mute: 0, warn: 0 },
'30d': { ban: 0, kick: 0, mute: 0, warn: 0 },
})
})
test('annotate: flags automated when staff id matches the bot application id', () => {
const [row] = moderation.annotate([{ staff_user_id: '999', target_site_user_id: null }], '999')
assert.equal(row.is_automated, true)
})
test('annotate: string/number snowflake mismatch still matches (coerced)', () => {
// mod_actions stores staff_user_id as VARCHAR, but bot_config.application_id
// could arrive as a number — the compare must coerce both sides.
const [row] = moderation.annotate([{ staff_user_id: 999, target_site_user_id: null }], '999')
assert.equal(row.is_automated, true)
})
test('annotate: staff action (id differs from bot) is not automated', () => {
const [row] = moderation.annotate([{ staff_user_id: '111', target_site_user_id: null }], '999')
assert.equal(row.is_automated, false)
})
test('annotate: no bot application id configured means nothing is automated', () => {
const [row] = moderation.annotate([{ staff_user_id: '999', target_site_user_id: null }], null)
assert.equal(row.is_automated, false)
})
test('annotate: folds joined identity columns into linked_account', () => {
const [row] = moderation.annotate(
[{ staff_user_id: '1', target_site_user_id: 7, target_site_username: 'perry' }],
null,
)
assert.deepEqual(row.linked_account, { id: 7, username: 'perry' })
})
test('annotate: no linked identity yields null linked_account', () => {
const [row] = moderation.annotate([{ staff_user_id: '1', target_site_user_id: null }], null)
assert.equal(row.linked_account, null)
})