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

@@ -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 }