diff --git a/src/api/index.js b/src/api/index.js index 9c423ebe..d7d59757 100644 --- a/src/api/index.js +++ b/src/api/index.js @@ -9,6 +9,7 @@ import authRouter from './routes/auth.js'; import configRouter from './routes/config.js'; import guildsRouter from './routes/guilds.js'; import healthRouter from './routes/health.js'; +import moderationRouter from './routes/moderation.js'; import webhooksRouter from './routes/webhooks.js'; const router = Router(); @@ -25,6 +26,9 @@ router.use('/config', requireAuth(), configRouter); // Guild routes — require API secret or OAuth2 JWT router.use('/guilds', requireAuth(), guildsRouter); +// Moderation routes — require API secret or OAuth2 JWT +router.use('/moderation', requireAuth(), moderationRouter); + // Webhook routes — require API secret or OAuth2 JWT (endpoint further restricts to api-secret) router.use('/webhooks', requireAuth(), webhooksRouter); diff --git a/src/api/routes/health.js b/src/api/routes/health.js index b83128fd..1b00391b 100644 --- a/src/api/routes/health.js +++ b/src/api/routes/health.js @@ -105,8 +105,9 @@ router.get('/', async (req, res) => { const pool = getRestartPool(); if (pool) { const rows = await getRestarts(pool, 20); - body.restarts = rows.map(r => ({ - timestamp: r.timestamp instanceof Date ? r.timestamp.toISOString() : String(r.timestamp), + body.restarts = rows.map((r) => ({ + timestamp: + r.timestamp instanceof Date ? r.timestamp.toISOString() : String(r.timestamp), reason: r.reason || 'unknown', version: r.version ?? null, uptimeBefore: r.uptime_seconds ?? null, diff --git a/src/api/routes/moderation.js b/src/api/routes/moderation.js new file mode 100644 index 00000000..e9d3c1b2 --- /dev/null +++ b/src/api/routes/moderation.js @@ -0,0 +1,329 @@ +/** + * Moderation API Routes + * Exposes mod case data for the web dashboard. + */ + +import { Router } from 'express'; +import { getPool } from '../../db.js'; +import { info, error as logError } from '../../logger.js'; + +const router = Router(); + +// ─── GET /cases ─────────────────────────────────────────────────────────────── + +/** + * List mod cases for a guild with optional filters and pagination. + * + * Query params: + * guildId (required) — Discord guild ID + * targetId — Filter by target user ID + * action — Filter by action type (warn, kick, ban, …) + * page (default 1) + * limit (default 25, max 100) + */ +router.get('/cases', async (req, res) => { + const { guildId, targetId, action } = req.query; + + if (!guildId) { + return res.status(400).json({ error: 'guildId is required' }); + } + + const page = Math.max(1, parseInt(req.query.page, 10) || 1); + const limit = Math.min(100, Math.max(1, parseInt(req.query.limit, 10) || 25)); + const offset = (page - 1) * limit; + + try { + const pool = getPool(); + + // Build dynamic WHERE clause + const conditions = ['guild_id = $1']; + const values = [guildId]; + let paramIdx = 2; + + if (targetId) { + conditions.push(`target_id = $${paramIdx++}`); + values.push(targetId); + } + + if (action) { + conditions.push(`action = $${paramIdx++}`); + values.push(action); + } + + const where = conditions.join(' AND '); + + const [casesResult, countResult] = await Promise.all([ + pool.query( + `SELECT + id, + case_number, + action, + target_id, + target_tag, + moderator_id, + moderator_tag, + reason, + duration, + expires_at, + log_message_id, + created_at + FROM mod_cases + WHERE ${where} + ORDER BY created_at DESC + LIMIT $${paramIdx} OFFSET $${paramIdx + 1}`, + [...values, limit, offset], + ), + pool.query(`SELECT COUNT(*)::integer AS total FROM mod_cases WHERE ${where}`, values), + ]); + + const total = countResult.rows[0]?.total ?? 0; + const pages = Math.ceil(total / limit); + + info('Mod cases listed', { guildId, page, limit, total }); + + return res.json({ + cases: casesResult.rows, + total, + page, + pages, + }); + } catch (err) { + logError('Failed to list mod cases', { error: err.message, guildId }); + return res.status(500).json({ error: 'Failed to fetch mod cases' }); + } +}); + +// ─── GET /cases/:caseNumber ──────────────────────────────────────────────────── + +/** + * Get a single mod case by case_number + guild, including any scheduled actions. + * + * Query params: + * guildId (required) — scoped to prevent cross-guild data exposure + */ +router.get('/cases/:caseNumber', async (req, res) => { + const caseNumber = parseInt(req.params.caseNumber, 10); + if (isNaN(caseNumber)) { + return res.status(400).json({ error: 'Invalid case number' }); + } + + const { guildId } = req.query; + if (!guildId) { + return res.status(400).json({ error: 'guildId is required' }); + } + + try { + const pool = getPool(); + + const caseResult = await pool.query( + `SELECT + id, + guild_id, + case_number, + action, + target_id, + target_tag, + moderator_id, + moderator_tag, + reason, + duration, + expires_at, + log_message_id, + created_at + FROM mod_cases + WHERE case_number = $1 AND guild_id = $2`, + [caseNumber, guildId], + ); + + if (caseResult.rows.length === 0) { + return res.status(404).json({ error: 'Case not found' }); + } + + const caseRow = caseResult.rows[0]; + + const scheduledResult = await pool.query( + `SELECT id, action, target_id, execute_at, executed, created_at + FROM mod_scheduled_actions + WHERE case_id = $1 + ORDER BY execute_at ASC`, + [caseRow.id], + ); + + return res.json({ + ...caseRow, + scheduledActions: scheduledResult.rows, + }); + } catch (err) { + logError('Failed to fetch mod case', { error: err.message, caseNumber, guildId }); + return res.status(500).json({ error: 'Failed to fetch mod case' }); + } +}); + +// ─── GET /stats ─────────────────────────────────────────────────────────────── + +/** + * Get moderation stats summary for a guild. + * + * Query params: + * guildId (required) + */ +router.get('/stats', async (req, res) => { + const { guildId } = req.query; + + if (!guildId) { + return res.status(400).json({ error: 'guildId is required' }); + } + + try { + const pool = getPool(); + + const [totalResult, last24hResult, last7dResult, byActionResult, topTargetsResult] = + await Promise.all([ + // Total cases + pool.query('SELECT COUNT(*)::integer AS total FROM mod_cases WHERE guild_id = $1', [ + guildId, + ]), + + // Last 24 hours + pool.query( + `SELECT COUNT(*)::integer AS total FROM mod_cases + WHERE guild_id = $1 AND created_at > NOW() - INTERVAL '24 hours'`, + [guildId], + ), + + // Last 7 days + pool.query( + `SELECT COUNT(*)::integer AS total FROM mod_cases + WHERE guild_id = $1 AND created_at > NOW() - INTERVAL '7 days'`, + [guildId], + ), + + // Breakdown by action + pool.query( + `SELECT action, COUNT(*)::integer AS count + FROM mod_cases + WHERE guild_id = $1 + GROUP BY action`, + [guildId], + ), + + // Top targets (most cases in last 30 days) + pool.query( + `SELECT target_id AS "userId", target_tag AS tag, COUNT(*)::integer AS count + FROM mod_cases + WHERE guild_id = $1 AND created_at > NOW() - INTERVAL '30 days' + GROUP BY target_id, target_tag + ORDER BY count DESC + LIMIT 10`, + [guildId], + ), + ]); + + // Convert byAction rows to a flat object + const byAction = {}; + for (const row of byActionResult.rows) { + byAction[row.action] = row.count; + } + + return res.json({ + totalCases: totalResult.rows[0]?.total ?? 0, + last24h: last24hResult.rows[0]?.total ?? 0, + last7d: last7dResult.rows[0]?.total ?? 0, + byAction, + topTargets: topTargetsResult.rows, + }); + } catch (err) { + logError('Failed to fetch mod stats', { error: err.message, guildId }); + return res.status(500).json({ error: 'Failed to fetch mod stats' }); + } +}); + +// ─── GET /user/:userId/history ──────────────────────────────────────────────── + +/** + * Get full moderation history for a specific user in a guild. + * + * Query params: + * guildId (required) — Discord guild ID + * page (default 1) + * limit (default 25, max 100) + */ +router.get('/user/:userId/history', async (req, res) => { + const { userId } = req.params; + const { guildId } = req.query; + + if (!guildId) { + return res.status(400).json({ error: 'guildId is required' }); + } + + if (!userId) { + return res.status(400).json({ error: 'userId is required' }); + } + + const page = Math.max(1, parseInt(req.query.page, 10) || 1); + const limit = Math.min(100, Math.max(1, parseInt(req.query.limit, 10) || 25)); + const offset = (page - 1) * limit; + + try { + const pool = getPool(); + + const [casesResult, countResult, summaryResult] = await Promise.all([ + pool.query( + `SELECT + id, + case_number, + action, + target_id, + target_tag, + moderator_id, + moderator_tag, + reason, + duration, + expires_at, + log_message_id, + created_at + FROM mod_cases + WHERE guild_id = $1 AND target_id = $2 + ORDER BY created_at DESC + LIMIT $3 OFFSET $4`, + [guildId, userId, limit, offset], + ), + pool.query( + `SELECT COUNT(*)::integer AS total FROM mod_cases + WHERE guild_id = $1 AND target_id = $2`, + [guildId, userId], + ), + pool.query( + `SELECT action, COUNT(*)::integer AS count + FROM mod_cases + WHERE guild_id = $1 AND target_id = $2 + GROUP BY action`, + [guildId, userId], + ), + ]); + + const total = countResult.rows[0]?.total ?? 0; + const pages = Math.ceil(total / limit); + + const byAction = {}; + for (const row of summaryResult.rows) { + byAction[row.action] = row.count; + } + + info('User mod history fetched', { guildId, userId, page, limit, total }); + + return res.json({ + userId, + cases: casesResult.rows, + total, + page, + pages, + byAction, + }); + } catch (err) { + logError('Failed to fetch user mod history', { error: err.message, guildId, userId }); + return res.status(500).json({ error: 'Failed to fetch user mod history' }); + } +}); + +export default router; diff --git a/src/api/ws/logStream.js b/src/api/ws/logStream.js index 966c5b14..0ed0d272 100644 --- a/src/api/ws/logStream.js +++ b/src/api/ws/logStream.js @@ -182,9 +182,7 @@ function validateTicket(ticket, secret) { if (!Number.isFinite(expiryNum) || expiryNum <= Date.now()) return false; // Re-derive HMAC and compare with timing-safe equality - const expected = createHmac('sha256', secret) - .update(`${nonce}.${expiry}`) - .digest('hex'); + const expected = createHmac('sha256', secret).update(`${nonce}.${expiry}`).digest('hex'); try { return timingSafeEqual(Buffer.from(expected, 'hex'), Buffer.from(hmac, 'hex')); diff --git a/src/db.js b/src/db.js index dccbd9ac..8b965afe 100644 --- a/src/db.js +++ b/src/db.js @@ -3,10 +3,10 @@ * PostgreSQL connection pool and migration runner */ -import { fileURLToPath } from 'node:url'; import path from 'node:path'; -import pg from 'pg'; +import { fileURLToPath } from 'node:url'; import { runner } from 'node-pg-migrate'; +import pg from 'pg'; import { info, error as logError } from './logger.js'; const { Pool } = pg; diff --git a/src/index.js b/src/index.js index 7222f413..c3f8fd63 100644 --- a/src/index.js +++ b/src/index.js @@ -26,7 +26,15 @@ import { setInitialTransport, } from './config-listeners.js'; import { closeDb, getPool, initDb } from './db.js'; -import { addPostgresTransport, addWebSocketTransport, removeWebSocketTransport, debug, error, info, warn } from './logger.js'; +import { + addPostgresTransport, + addWebSocketTransport, + debug, + error, + info, + removeWebSocketTransport, + warn, +} from './logger.js'; import { getConversationHistory, initConversationHistory, @@ -221,7 +229,12 @@ client.on('interactionCreate', async (interaction) => { await command.execute(interaction); info('Command executed', { command: commandName, user: interaction.user.tag }); } catch (err) { - error('Command error', { command: commandName, error: err.message, stack: err.stack, source: 'slash_command' }); + error('Command error', { + command: commandName, + error: err.message, + stack: err.stack, + source: 'slash_command', + }); const errorMessage = { content: '❌ An error occurred while executing this command.', @@ -435,13 +448,17 @@ async function startup() { await client.login(token); // Set Sentry context now that we know the bot identity (no-op if disabled) - import('./sentry.js').then(({ Sentry, sentryEnabled }) => { - if (sentryEnabled) { - Sentry.setTag('bot.username', client.user?.tag || 'unknown'); - Sentry.setTag('bot.version', BOT_VERSION); - info('Sentry error monitoring enabled', { environment: process.env.SENTRY_ENVIRONMENT || process.env.NODE_ENV || 'production' }); - } - }).catch(() => {}); + import('./sentry.js') + .then(({ Sentry, sentryEnabled }) => { + if (sentryEnabled) { + Sentry.setTag('bot.username', client.user?.tag || 'unknown'); + Sentry.setTag('bot.version', BOT_VERSION); + info('Sentry error monitoring enabled', { + environment: process.env.SENTRY_ENVIRONMENT || process.env.NODE_ENV || 'production', + }); + } + }) + .catch(() => {}); // Start REST API server with WebSocket log streaming (non-fatal — bot continues without it) { diff --git a/src/logger.js b/src/logger.js index dd3407a7..30cca2ee 100644 --- a/src/logger.js +++ b/src/logger.js @@ -13,8 +13,8 @@ import { dirname, join } from 'node:path'; import { fileURLToPath } from 'node:url'; import winston from 'winston'; import DailyRotateFile from 'winston-daily-rotate-file'; -import { PostgresTransport } from './transports/postgres.js'; import { sentryEnabled } from './sentry.js'; +import { PostgresTransport } from './transports/postgres.js'; import { SentryTransport } from './transports/sentry.js'; import { WebSocketTransport } from './transports/websocket.js'; diff --git a/src/modules/triage.js b/src/modules/triage.js index 26b1d345..46992dee 100644 --- a/src/modules/triage.js +++ b/src/modules/triage.js @@ -189,7 +189,10 @@ async function runResponder( try { await safeSend(ch, '\uD83D\uDD0D Searching the web for that \u2014 one moment...'); } catch (notifyErr) { - warn('Failed to send WebSearch notification', { channelId, error: notifyErr?.message }); + warn('Failed to send WebSearch notification', { + channelId, + error: notifyErr?.message, + }); } } } diff --git a/src/transports/sentry.js b/src/transports/sentry.js index 764cc18c..a4b0669c 100644 --- a/src/transports/sentry.js +++ b/src/transports/sentry.js @@ -44,7 +44,10 @@ export class SentryTransport extends Transport { const tags = {}; const extra = {}; for (const [key, value] of Object.entries(meta)) { - if (SentryTransport.TAG_KEYS.has(key) && (typeof value === 'string' || typeof value === 'number')) { + if ( + SentryTransport.TAG_KEYS.has(key) && + (typeof value === 'string' || typeof value === 'number') + ) { tags[key] = String(value); } else if (key !== 'originalLevel' && key !== 'splat') { extra[key] = value; diff --git a/src/transports/websocket.js b/src/transports/websocket.js index 2537f551..cec63cbc 100644 --- a/src/transports/websocket.js +++ b/src/transports/websocket.js @@ -5,8 +5,8 @@ * WebSocket clients in real-time. Zero overhead when no clients are connected. */ -import WebSocket from 'ws'; import Transport from 'winston-transport'; +import WebSocket from 'ws'; /** * Log level severity ordering (lower = more severe). diff --git a/tests/api/routes/config.test.js b/tests/api/routes/config.test.js index b09f09d2..d68ba1bf 100644 --- a/tests/api/routes/config.test.js +++ b/tests/api/routes/config.test.js @@ -650,5 +650,3 @@ describe('validateSingleValue', () => { expect(errors[0]).toContain('must not be null'); }); }); - - diff --git a/tests/api/routes/guilds.test.js b/tests/api/routes/guilds.test.js index ed31e97e..3bae071e 100644 --- a/tests/api/routes/guilds.test.js +++ b/tests/api/routes/guilds.test.js @@ -314,9 +314,7 @@ describe('guilds routes', () => { describe('GET /:id/roles', () => { it('should return guild roles', async () => { - const res = await request(app) - .get('/api/v1/guilds/guild1/roles') - .set('x-api-secret', SECRET); + const res = await request(app).get('/api/v1/guilds/guild1/roles').set('x-api-secret', SECRET); expect(res.status).toBe(200); expect(res.body).toBeInstanceOf(Array); diff --git a/tests/api/utils/configAllowlist.test.js b/tests/api/utils/configAllowlist.test.js index 9d46d0be..51fc2be1 100644 --- a/tests/api/utils/configAllowlist.test.js +++ b/tests/api/utils/configAllowlist.test.js @@ -211,9 +211,7 @@ describe('configAllowlist', () => { }); it('should not strip mask sentinel from non-sensitive fields', () => { - const writes = [ - { path: 'ai.model', value: '••••••••' }, - ]; + const writes = [{ path: 'ai.model', value: '••••••••' }]; const result = stripMaskedWrites(writes); @@ -231,4 +229,4 @@ describe('configAllowlist', () => { expect(result).toEqual([]); }); }); -}); \ No newline at end of file +}); diff --git a/tests/api/utils/validateConfigPatch.test.js b/tests/api/utils/validateConfigPatch.test.js index a4b5f4c3..570e0003 100644 --- a/tests/api/utils/validateConfigPatch.test.js +++ b/tests/api/utils/validateConfigPatch.test.js @@ -277,4 +277,4 @@ describe('validateConfigPatch', () => { expect(result.status).toBe(400); }); }); -}); \ No newline at end of file +}); diff --git a/tests/api/ws/logStream.test.js b/tests/api/ws/logStream.test.js index ce9460a9..311d0f8c 100644 --- a/tests/api/ws/logStream.test.js +++ b/tests/api/ws/logStream.test.js @@ -2,8 +2,12 @@ import { createHmac, randomBytes } from 'node:crypto'; import http from 'node:http'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import WebSocket from 'ws'; +import { + getAuthenticatedClientCount, + setupLogStream, + stopLogStream, +} from '../../../src/api/ws/logStream.js'; import { WebSocketTransport } from '../../../src/transports/websocket.js'; -import { setupLogStream, stopLogStream, getAuthenticatedClientCount } from '../../../src/api/ws/logStream.js'; const TEST_SECRET = 'test-api-secret-for-ws'; @@ -199,7 +203,12 @@ describe('WebSocket Log Stream', () => { await authenticate(ws, mq); transport.log( - { level: 'info', message: 'real-time log', timestamp: '2026-01-01T00:00:00Z', module: 'test' }, + { + level: 'info', + message: 'real-time log', + timestamp: '2026-01-01T00:00:00Z', + module: 'test', + }, vi.fn(), ); @@ -230,7 +239,10 @@ describe('WebSocket Log Stream', () => { expect(filterOk.type).toBe('filter_ok'); expect(filterOk.filter.level).toBe('error'); - transport.log({ level: 'error', message: 'error log', timestamp: '2026-01-01T00:00:00Z' }, vi.fn()); + transport.log( + { level: 'error', message: 'error log', timestamp: '2026-01-01T00:00:00Z' }, + vi.fn(), + ); const logMsg = await mq.next(); expect(logMsg.level).toBe('error'); expect(logMsg.message).toBe('error log'); @@ -244,8 +256,14 @@ describe('WebSocket Log Stream', () => { await mq.next(); // filter_ok // Info log should be filtered; send error right after to prove it works - transport.log({ level: 'info', message: 'filtered', timestamp: '2026-01-01T00:00:00Z' }, vi.fn()); - transport.log({ level: 'error', message: 'arrives', timestamp: '2026-01-01T00:00:00Z' }, vi.fn()); + transport.log( + { level: 'info', message: 'filtered', timestamp: '2026-01-01T00:00:00Z' }, + vi.fn(), + ); + transport.log( + { level: 'error', message: 'arrives', timestamp: '2026-01-01T00:00:00Z' }, + vi.fn(), + ); const logMsg = await mq.next(); expect(logMsg.message).toBe('arrives'); diff --git a/tests/modules/triage-prompt.test.js b/tests/modules/triage-prompt.test.js index e68bd565..60a3425d 100644 --- a/tests/modules/triage-prompt.test.js +++ b/tests/modules/triage-prompt.test.js @@ -371,4 +371,4 @@ describe('triage-prompt', () => { expect(result).toContain('Targets: ["msg1","msg2"]'); }); }); -}); \ No newline at end of file +}); diff --git a/tests/modules/triage-respond.test.js b/tests/modules/triage-respond.test.js index db9599b6..23f77f81 100644 --- a/tests/modules/triage-respond.test.js +++ b/tests/modules/triage-respond.test.js @@ -1,4 +1,4 @@ -import { describe, expect, it, vi, beforeEach } from 'vitest'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; import { buildStatsAndLog, fetchChannelContext, @@ -557,9 +557,18 @@ describe('triage-respond', () => { }, }; - const result = await buildStatsAndLog({}, {}, {}, snapshot, classification, 0, mockClient, 'channel1'); + const result = await buildStatsAndLog( + {}, + {}, + {}, + snapshot, + classification, + 0, + mockClient, + 'channel1', + ); expect(result.stats.userId).toBe(null); }); }); -}); \ No newline at end of file +}); diff --git a/tests/sentry.test.js b/tests/sentry.test.js index 569e9cf5..8ee74f71 100644 --- a/tests/sentry.test.js +++ b/tests/sentry.test.js @@ -2,7 +2,7 @@ * Tests for Sentry integration module */ -import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; describe('sentry module', () => { beforeEach(() => { diff --git a/tests/transports/websocket.test.js b/tests/transports/websocket.test.js index 422bd4cc..fb3daddf 100644 --- a/tests/transports/websocket.test.js +++ b/tests/transports/websocket.test.js @@ -61,7 +61,10 @@ describe('WebSocketTransport', () => { transport.addClient(ws); const callback = vi.fn(); - transport.log({ level: 'info', message: 'hello world', timestamp: '2026-01-01T00:00:00Z' }, callback); + transport.log( + { level: 'info', message: 'hello world', timestamp: '2026-01-01T00:00:00Z' }, + callback, + ); expect(ws.send).toHaveBeenCalledOnce(); const sent = JSON.parse(ws.send.mock.calls[0][0]); @@ -96,7 +99,9 @@ describe('WebSocketTransport', () => { it('should handle send errors gracefully', () => { const ws = createMockWs(); - ws.send.mockImplementation(() => { throw new Error('send failed'); }); + ws.send.mockImplementation(() => { + throw new Error('send failed'); + }); transport.addClient(ws); const callback = vi.fn(); @@ -108,13 +113,16 @@ describe('WebSocketTransport', () => { const ws = createMockWs(); transport.addClient(ws); - transport.log({ - level: 'info', - message: 'test', - timestamp: '2026-01-01T00:00:00Z', - module: 'api', - userId: '123', - }, vi.fn()); + transport.log( + { + level: 'info', + message: 'test', + timestamp: '2026-01-01T00:00:00Z', + module: 'api', + userId: '123', + }, + vi.fn(), + ); const sent = JSON.parse(ws.send.mock.calls[0][0]); expect(sent.metadata.module).toBe('api'); @@ -129,12 +137,15 @@ describe('WebSocketTransport', () => { const circular = {}; circular.self = circular; - transport.log({ - level: 'info', - message: 'test', - timestamp: '2026-01-01T00:00:00Z', - data: circular, - }, vi.fn()); + transport.log( + { + level: 'info', + message: 'test', + timestamp: '2026-01-01T00:00:00Z', + data: circular, + }, + vi.fn(), + ); // Should still send — falls back to empty metadata expect(ws.send).toHaveBeenCalledOnce(); @@ -161,14 +172,20 @@ describe('WebSocketTransport', () => { it('should filter by module', () => { const filter = { module: 'api' }; - expect(transport.passesFilter({ level: 'info', message: 'test', module: 'api' }, filter)).toBe(true); - expect(transport.passesFilter({ level: 'info', message: 'test', module: 'bot' }, filter)).toBe(false); + expect( + transport.passesFilter({ level: 'info', message: 'test', module: 'api' }, filter), + ).toBe(true); + expect( + transport.passesFilter({ level: 'info', message: 'test', module: 'bot' }, filter), + ).toBe(false); }); it('should filter by search (case-insensitive)', () => { const filter = { search: 'ERROR' }; - expect(transport.passesFilter({ level: 'info', message: 'An error occurred' }, filter)).toBe(true); + expect(transport.passesFilter({ level: 'info', message: 'An error occurred' }, filter)).toBe( + true, + ); expect(transport.passesFilter({ level: 'info', message: 'All good' }, filter)).toBe(false); }); @@ -176,11 +193,17 @@ describe('WebSocketTransport', () => { const filter = { level: 'warn', module: 'api' }; // Passes both - expect(transport.passesFilter({ level: 'error', message: 'test', module: 'api' }, filter)).toBe(true); + expect( + transport.passesFilter({ level: 'error', message: 'test', module: 'api' }, filter), + ).toBe(true); // Fails level - expect(transport.passesFilter({ level: 'info', message: 'test', module: 'api' }, filter)).toBe(false); + expect( + transport.passesFilter({ level: 'info', message: 'test', module: 'api' }, filter), + ).toBe(false); // Fails module - expect(transport.passesFilter({ level: 'error', message: 'test', module: 'bot' }, filter)).toBe(false); + expect( + transport.passesFilter({ level: 'error', message: 'test', module: 'bot' }, filter), + ).toBe(false); }); it('should apply per-client filters during broadcast', () => { @@ -194,13 +217,19 @@ describe('WebSocketTransport', () => { transport.addClient(wsErrorOnly); // Send an info-level log - transport.log({ level: 'info', message: 'info msg', timestamp: '2026-01-01T00:00:00Z' }, vi.fn()); + transport.log( + { level: 'info', message: 'info msg', timestamp: '2026-01-01T00:00:00Z' }, + vi.fn(), + ); expect(wsAll.send).toHaveBeenCalledOnce(); expect(wsErrorOnly.send).not.toHaveBeenCalled(); // Send an error-level log - transport.log({ level: 'error', message: 'error msg', timestamp: '2026-01-01T00:00:00Z' }, vi.fn()); + transport.log( + { level: 'error', message: 'error msg', timestamp: '2026-01-01T00:00:00Z' }, + vi.fn(), + ); expect(wsAll.send).toHaveBeenCalledTimes(2); expect(wsErrorOnly.send).toHaveBeenCalledOnce(); diff --git a/web/src/app/api/moderation/cases/[id]/route.ts b/web/src/app/api/moderation/cases/[id]/route.ts new file mode 100644 index 00000000..d4d9b98e --- /dev/null +++ b/web/src/app/api/moderation/cases/[id]/route.ts @@ -0,0 +1,45 @@ +import { NextResponse } from "next/server"; +import type { NextRequest } from "next/server"; +import { + authorizeGuildAdmin, + getBotApiConfig, + buildUpstreamUrl, + proxyToBotApi, +} from "@/lib/bot-api-proxy"; + +export const dynamic = "force-dynamic"; + +const LOG_PREFIX = "[api/moderation/cases/:id]"; + +/** + * GET /api/moderation/cases/:id + * Proxies to bot API GET /api/v1/moderation/cases/:id + * Requires guildId query param for authorization. + */ +export async function GET( + request: NextRequest, + { params }: { params: Promise<{ id: string }> }, +): Promise { + const guildId = request.nextUrl.searchParams.get("guildId"); + if (!guildId) { + return NextResponse.json({ error: "guildId is required" }, { status: 400 }); + } + + const authError = await authorizeGuildAdmin(request, guildId, LOG_PREFIX); + if (authError) return authError; + + const config = getBotApiConfig(LOG_PREFIX); + if (config instanceof NextResponse) return config; + + const { id } = await params; + const upstream = buildUpstreamUrl( + config.baseUrl, + `/moderation/cases/${encodeURIComponent(id)}`, + LOG_PREFIX, + ); + if (upstream instanceof NextResponse) return upstream; + + upstream.searchParams.set("guildId", guildId); + + return proxyToBotApi(upstream, config.secret, LOG_PREFIX, "Failed to fetch mod case"); +} diff --git a/web/src/app/api/moderation/cases/route.ts b/web/src/app/api/moderation/cases/route.ts new file mode 100644 index 00000000..ef295e19 --- /dev/null +++ b/web/src/app/api/moderation/cases/route.ts @@ -0,0 +1,41 @@ +import { NextResponse } from "next/server"; +import type { NextRequest } from "next/server"; +import { + authorizeGuildAdmin, + getBotApiConfig, + buildUpstreamUrl, + proxyToBotApi, +} from "@/lib/bot-api-proxy"; + +export const dynamic = "force-dynamic"; + +const LOG_PREFIX = "[api/moderation/cases]"; +const ALLOWED_PARAMS = ["guildId", "targetId", "action", "page", "limit"]; + +/** + * GET /api/moderation/cases + * Proxies to bot API GET /api/v1/moderation/cases + * Requires guildId query param and admin authorization. + */ +export async function GET(request: NextRequest): Promise { + const guildId = request.nextUrl.searchParams.get("guildId"); + if (!guildId) { + return NextResponse.json({ error: "guildId is required" }, { status: 400 }); + } + + const authError = await authorizeGuildAdmin(request, guildId, LOG_PREFIX); + if (authError) return authError; + + const config = getBotApiConfig(LOG_PREFIX); + if (config instanceof NextResponse) return config; + + const upstream = buildUpstreamUrl(config.baseUrl, "/moderation/cases", LOG_PREFIX); + if (upstream instanceof NextResponse) return upstream; + + for (const key of ALLOWED_PARAMS) { + const value = request.nextUrl.searchParams.get(key); + if (value !== null) upstream.searchParams.set(key, value); + } + + return proxyToBotApi(upstream, config.secret, LOG_PREFIX, "Failed to fetch mod cases"); +} diff --git a/web/src/app/api/moderation/stats/route.ts b/web/src/app/api/moderation/stats/route.ts new file mode 100644 index 00000000..67aa1a50 --- /dev/null +++ b/web/src/app/api/moderation/stats/route.ts @@ -0,0 +1,37 @@ +import { NextResponse } from "next/server"; +import type { NextRequest } from "next/server"; +import { + authorizeGuildAdmin, + getBotApiConfig, + buildUpstreamUrl, + proxyToBotApi, +} from "@/lib/bot-api-proxy"; + +export const dynamic = "force-dynamic"; + +const LOG_PREFIX = "[api/moderation/stats]"; + +/** + * GET /api/moderation/stats + * Proxies to bot API GET /api/v1/moderation/stats + * Requires guildId query param and admin authorization. + */ +export async function GET(request: NextRequest): Promise { + const guildId = request.nextUrl.searchParams.get("guildId"); + if (!guildId) { + return NextResponse.json({ error: "guildId is required" }, { status: 400 }); + } + + const authError = await authorizeGuildAdmin(request, guildId, LOG_PREFIX); + if (authError) return authError; + + const config = getBotApiConfig(LOG_PREFIX); + if (config instanceof NextResponse) return config; + + const upstream = buildUpstreamUrl(config.baseUrl, "/moderation/stats", LOG_PREFIX); + if (upstream instanceof NextResponse) return upstream; + + upstream.searchParams.set("guildId", guildId); + + return proxyToBotApi(upstream, config.secret, LOG_PREFIX, "Failed to fetch mod stats"); +} diff --git a/web/src/app/api/moderation/user/[userId]/history/route.ts b/web/src/app/api/moderation/user/[userId]/history/route.ts new file mode 100644 index 00000000..ae0c8b23 --- /dev/null +++ b/web/src/app/api/moderation/user/[userId]/history/route.ts @@ -0,0 +1,56 @@ +import { NextResponse } from "next/server"; +import type { NextRequest } from "next/server"; +import { + authorizeGuildAdmin, + getBotApiConfig, + buildUpstreamUrl, + proxyToBotApi, +} from "@/lib/bot-api-proxy"; + +export const dynamic = "force-dynamic"; + +const LOG_PREFIX = "[api/moderation/user/:userId/history]"; + +/** + * GET /api/moderation/user/[userId]/history + * Proxies to bot API GET /api/v1/moderation/user/:userId/history + * Requires guildId query param for authorization. + */ +export async function GET( + request: NextRequest, + { params }: { params: Promise<{ userId: string }> }, +): Promise { + const guildId = request.nextUrl.searchParams.get("guildId"); + if (!guildId) { + return NextResponse.json({ error: "guildId is required" }, { status: 400 }); + } + + const authError = await authorizeGuildAdmin(request, guildId, LOG_PREFIX); + if (authError) return authError; + + const config = getBotApiConfig(LOG_PREFIX); + if (config instanceof NextResponse) return config; + + const { userId } = await params; + const upstream = buildUpstreamUrl( + config.baseUrl, + `/moderation/user/${encodeURIComponent(userId)}/history`, + LOG_PREFIX, + ); + if (upstream instanceof NextResponse) return upstream; + + upstream.searchParams.set("guildId", guildId); + + const page = request.nextUrl.searchParams.get("page"); + if (page !== null) upstream.searchParams.set("page", page); + + const limit = request.nextUrl.searchParams.get("limit"); + if (limit !== null) upstream.searchParams.set("limit", limit); + + return proxyToBotApi( + upstream, + config.secret, + LOG_PREFIX, + "Failed to fetch user mod history", + ); +} diff --git a/web/src/app/dashboard/moderation/page.tsx b/web/src/app/dashboard/moderation/page.tsx new file mode 100644 index 00000000..10e04b06 --- /dev/null +++ b/web/src/app/dashboard/moderation/page.tsx @@ -0,0 +1,458 @@ +"use client"; + +import { useCallback, useEffect, useRef, useState } from "react"; +import { useRouter } from "next/navigation"; +import { RefreshCw, Search, Shield, X } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { CaseTable } from "@/components/dashboard/case-table"; +import { ModerationStats } from "@/components/dashboard/moderation-stats"; +import { + GUILD_SELECTED_EVENT, + SELECTED_GUILD_KEY, +} from "@/lib/guild-selection"; +import type { CaseListResponse, ModStats } from "@/components/dashboard/moderation-types"; + +const PAGE_LIMIT = 25; + +export default function ModerationPage() { + const router = useRouter(); + + // Guild selection (mirrors pattern from analytics-dashboard) + const [guildId, setGuildId] = useState(null); + + // Stats state + const [stats, setStats] = useState(null); + const [statsLoading, setStatsLoading] = useState(false); + const [statsError, setStatsError] = useState(null); + + // Cases state + const [casesData, setCasesData] = useState(null); + const [casesLoading, setCasesLoading] = useState(false); + const [casesError, setCasesError] = useState(null); + + // Filters & pagination + const [page, setPage] = useState(1); + const [sortDesc, setSortDesc] = useState(true); + const [actionFilter, setActionFilter] = useState("all"); + const [userSearch, setUserSearch] = useState(""); + + // User history lookup + const [userHistoryInput, setUserHistoryInput] = useState(""); + const [lookupUserId, setLookupUserId] = useState(null); + const [userHistoryData, setUserHistoryData] = useState(null); + const [userHistoryPage, setUserHistoryPage] = useState(1); + const [userHistoryLoading, setUserHistoryLoading] = useState(false); + const [userHistoryError, setUserHistoryError] = useState(null); + + const statsAbortRef = useRef(null); + const casesAbortRef = useRef(null); + const historyAbortRef = useRef(null); + + // ── Guild selection from localStorage ────────────────────────────────────── + useEffect(() => { + if (typeof window === "undefined") return; + + try { + const saved = window.localStorage.getItem(SELECTED_GUILD_KEY); + if (saved) setGuildId(saved); + } catch { + // localStorage unavailable + } + + const handleGuildSelect = (event: Event) => { + const selected = (event as CustomEvent).detail; + if (selected) { + setGuildId(selected); + setPage(1); + setLookupUserId(null); + setUserHistoryData(null); + } + }; + + const handleStorage = (event: StorageEvent) => { + if (event.key !== SELECTED_GUILD_KEY || !event.newValue) return; + setGuildId(event.newValue); + setPage(1); + }; + + window.addEventListener(GUILD_SELECTED_EVENT, handleGuildSelect as EventListener); + window.addEventListener("storage", handleStorage); + + return () => { + window.removeEventListener(GUILD_SELECTED_EVENT, handleGuildSelect as EventListener); + window.removeEventListener("storage", handleStorage); + }; + }, []); + + // ── Fetch stats ────────────────────────────────────────────────────────────── + const fetchStats = useCallback( + async (id: string) => { + statsAbortRef.current?.abort(); + const controller = new AbortController(); + statsAbortRef.current = controller; + + setStatsLoading(true); + setStatsError(null); + + try { + const res = await fetch( + `/api/moderation/stats?guildId=${encodeURIComponent(id)}`, + { cache: "no-store", signal: controller.signal }, + ); + + if (res.status === 401) { + router.replace("/login"); + return; + } + + const payload: unknown = await res.json(); + if (!res.ok) { + const msg = + typeof payload === "object" && + payload !== null && + "error" in payload && + typeof (payload as Record).error === "string" + ? (payload as Record).error + : "Failed to fetch stats"; + throw new Error(msg); + } + + setStats(payload as ModStats); + } catch (err) { + if (err instanceof DOMException && err.name === "AbortError") return; + setStatsError(err instanceof Error ? err.message : "Failed to fetch stats"); + } finally { + setStatsLoading(false); + } + }, + [router], + ); + + // ── Fetch cases ────────────────────────────────────────────────────────────── + const fetchCases = useCallback( + async (id: string, currentPage: number, desc: boolean, action: string, search: string) => { + casesAbortRef.current?.abort(); + const controller = new AbortController(); + casesAbortRef.current = controller; + + setCasesLoading(true); + setCasesError(null); + + try { + const params = new URLSearchParams({ + guildId: id, + page: String(currentPage), + limit: String(PAGE_LIMIT), + }); + if (action !== "all") params.set("action", action); + if (search.trim()) params.set("targetId", search.trim()); + + const res = await fetch(`/api/moderation/cases?${params.toString()}`, { + cache: "no-store", + signal: controller.signal, + }); + + if (res.status === 401) { + router.replace("/login"); + return; + } + + const payload: unknown = await res.json(); + if (!res.ok) { + const msg = + typeof payload === "object" && + payload !== null && + "error" in payload && + typeof (payload as Record).error === "string" + ? (payload as Record).error + : "Failed to fetch cases"; + throw new Error(msg); + } + + const data = payload as CaseListResponse; + // API always returns DESC; reverse if user wants ASC + if (!desc) { + data.cases = [...data.cases].reverse(); + } + setCasesData(data); + } catch (err) { + if (err instanceof DOMException && err.name === "AbortError") return; + setCasesError(err instanceof Error ? err.message : "Failed to fetch cases"); + } finally { + setCasesLoading(false); + } + }, + [router], + ); + + // ── Fetch user history ─────────────────────────────────────────────────────── + const fetchUserHistory = useCallback( + async (id: string, userId: string, histPage: number) => { + historyAbortRef.current?.abort(); + const controller = new AbortController(); + historyAbortRef.current = controller; + + setUserHistoryLoading(true); + setUserHistoryError(null); + + try { + const params = new URLSearchParams({ + guildId: id, + page: String(histPage), + limit: String(PAGE_LIMIT), + }); + + const res = await fetch( + `/api/moderation/user/${encodeURIComponent(userId)}/history?${params.toString()}`, + { cache: "no-store", signal: controller.signal }, + ); + + if (res.status === 401) { + router.replace("/login"); + return; + } + + const payload: unknown = await res.json(); + if (!res.ok) { + const msg = + typeof payload === "object" && + payload !== null && + "error" in payload && + typeof (payload as Record).error === "string" + ? (payload as Record).error + : "Failed to fetch user history"; + throw new Error(msg); + } + + // The user history response has the same shape as CaseListResponse + setUserHistoryData(payload as CaseListResponse); + } catch (err) { + if (err instanceof DOMException && err.name === "AbortError") return; + setUserHistoryError( + err instanceof Error ? err.message : "Failed to fetch user history", + ); + } finally { + setUserHistoryLoading(false); + } + }, + [router], + ); + + // Trigger fetches when guildId or filter params change + useEffect(() => { + if (!guildId) return; + void fetchStats(guildId); + }, [guildId, fetchStats]); + + useEffect(() => { + if (!guildId) return; + void fetchCases(guildId, page, sortDesc, actionFilter, userSearch); + }, [guildId, page, actionFilter, userSearch, fetchCases]); // sortDesc excluded — handled client-side + + // Client-side sort toggle — no re-fetch needed since API always returns DESC. + // Only `sortDesc` is a dep: the functional updater reads prev state directly, + // and we intentionally skip `casesData` to avoid re-running on every fetch. + useEffect(() => { + setCasesData((prev) => { + if (!prev) return prev; + return { ...prev, cases: [...prev.cases].reverse() }; + }); + // eslint-disable-next-line react-hooks/exhaustive-deps -- intentional: only react to sortDesc toggle + }, [sortDesc]); + + // Re-fetch user history when page or lookup target changes + useEffect(() => { + if (!guildId || !lookupUserId) return; + void fetchUserHistory(guildId, lookupUserId, userHistoryPage); + }, [guildId, lookupUserId, userHistoryPage, fetchUserHistory]); + + // Cleanup on unmount + useEffect(() => { + return () => { + statsAbortRef.current?.abort(); + casesAbortRef.current?.abort(); + historyAbortRef.current?.abort(); + }; + }, []); + + const handleRefresh = useCallback(() => { + if (!guildId) return; + void fetchStats(guildId); + void fetchCases(guildId, page, sortDesc, actionFilter, userSearch); + if (lookupUserId) void fetchUserHistory(guildId, lookupUserId, userHistoryPage); + }, [guildId, page, sortDesc, actionFilter, userSearch, lookupUserId, userHistoryPage, fetchStats, fetchCases, fetchUserHistory]); + + const handleClearFilters = useCallback(() => { + setActionFilter("all"); + setUserSearch(""); + setPage(1); + }, []); + + const handleUserHistorySearch = useCallback( + (e: React.FormEvent) => { + e.preventDefault(); + const trimmed = userHistoryInput.trim(); + if (!trimmed || !guildId) return; + setLookupUserId(trimmed); + setUserHistoryPage(1); + setUserHistoryData(null); + void fetchUserHistory(guildId, trimmed, 1); + }, + [guildId, userHistoryInput, fetchUserHistory], + ); + + const handleClearUserHistory = useCallback(() => { + setLookupUserId(null); + setUserHistoryData(null); + setUserHistoryError(null); + setUserHistoryInput(""); + }, []); + + // ── Render ─────────────────────────────────────────────────────────────────── + return ( +
+ {/* Header */} +
+
+

+ + Moderation +

+

+ Review cases, track activity, and audit your moderation team. +

+
+ + +
+ + {/* No guild selected */} + {!guildId && ( +
+

+ Select a server from the sidebar to view moderation data. +

+
+ )} + + {/* Content */} + {guildId && ( + <> + {/* Stats */} + + + {/* Cases */} +
+

Cases

+ setSortDesc((d) => !d)} + onActionFilterChange={setActionFilter} + onUserSearchChange={setUserSearch} + onClearFilters={handleClearFilters} + /> +
+ + {/* User History Lookup */} +
+

User History Lookup

+

+ Search for a user's complete moderation history by their Discord user ID. +

+ +
+
+ + setUserHistoryInput(e.target.value)} + aria-label="User ID for history lookup" + /> +
+ + {lookupUserId && ( + + )} +
+ + {lookupUserId && ( +
+

+ History for{" "} + + {lookupUserId} + + {userHistoryData && ( + <> + {" "} + —{" "} + {userHistoryData.total}{" "} + {userHistoryData.total === 1 ? "case" : "cases"} total + + )} +

+ + setUserHistoryPage(pg)} + onSortToggle={() => {}} + onActionFilterChange={() => {}} + onUserSearchChange={() => {}} + onClearFilters={() => {}} + /> +
+ )} +
+ + )} +
+ ); +} diff --git a/web/src/components/dashboard/case-detail.tsx b/web/src/components/dashboard/case-detail.tsx new file mode 100644 index 00000000..d4876dcc --- /dev/null +++ b/web/src/components/dashboard/case-detail.tsx @@ -0,0 +1,141 @@ +"use client"; + +import { Calendar, Clock, Hash, MessageSquare, Shield, User } from "lucide-react"; +import { Badge } from "@/components/ui/badge"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { ACTION_META } from "./moderation-types"; +import type { ModCase, ModAction } from "./moderation-types"; + +function formatDate(iso: string): string { + return new Intl.DateTimeFormat(undefined, { + dateStyle: "medium", + timeStyle: "short", + }).format(new Date(iso)); +} + +function ActionBadge({ action }: { action: ModAction }) { + const meta = ACTION_META[action]; + return ( + + {meta?.label ?? action} + + ); +} + +interface FieldRowProps { + icon: React.ReactNode; + label: string; + value: React.ReactNode; +} + +function FieldRow({ icon, label, value }: FieldRowProps) { + return ( +
+
{icon}
+
+

{label}

+
{value}
+
+
+ ); +} + +interface CaseDetailProps { + modCase: ModCase; +} + +export function CaseDetail({ modCase }: CaseDetailProps) { + const pendingScheduled = modCase.scheduledActions?.filter((a) => !a.executed) ?? []; + const executedScheduled = modCase.scheduledActions?.filter((a) => a.executed) ?? []; + + return ( + + + + + Case #{modCase.case_number} + + + + + } + label="Target" + value={ + + {modCase.target_tag}{" "} + ({modCase.target_id}) + + } + /> + + } + label="Moderator" + value={ + + {modCase.moderator_tag}{" "} + ({modCase.moderator_id}) + + } + /> + + } + label="Reason" + value={ + + {modCase.reason ?? "No reason provided"} + + } + /> + + } + label="Created" + value={formatDate(modCase.created_at)} + /> + + {modCase.duration && ( + } + label="Duration" + value={modCase.duration} + /> + )} + + {modCase.expires_at && ( + } + label="Expires" + value={formatDate(modCase.expires_at)} + /> + )} + + {/* Scheduled actions */} + {(pendingScheduled.length > 0 || executedScheduled.length > 0) && ( +
+

Scheduled Actions

+
    + {[...pendingScheduled, ...executedScheduled].map((sa) => ( +
  • + + + {formatDate(sa.execute_at)} + + + {sa.executed ? "Executed" : "Pending"} + +
  • + ))} +
+
+ )} +
+
+ ); +} diff --git a/web/src/components/dashboard/case-table.tsx b/web/src/components/dashboard/case-table.tsx new file mode 100644 index 00000000..f97fbc12 --- /dev/null +++ b/web/src/components/dashboard/case-table.tsx @@ -0,0 +1,331 @@ +"use client"; + +import { ChevronDown, ChevronLeft, ChevronRight, ChevronUp, Loader2, Search, X } from "lucide-react"; +import { Fragment, useState, useCallback } from "react"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import { CaseDetail } from "./case-detail"; +import { ACTION_META } from "./moderation-types"; +import type { ModCase, ModAction, CaseListResponse } from "./moderation-types"; + +// ─── Helpers ───────────────────────────────────────────────────────────────── + +function formatDate(iso: string): string { + return new Intl.DateTimeFormat(undefined, { + dateStyle: "short", + timeStyle: "short", + }).format(new Date(iso)); +} + +function ActionBadge({ action }: { action: ModAction }) { + const meta = ACTION_META[action]; + return ( + + {meta?.label ?? action} + + ); +} + +// ─── Filter Bar ─────────────────────────────────────────────────────────────── + +interface FilterBarProps { + actionFilter: string; + userSearch: string; + onActionChange: (val: string) => void; + onUserSearchChange: (val: string) => void; + onClear: () => void; +} + +const ACTION_OPTIONS: Array<{ value: string; label: string }> = [ + { value: "all", label: "All actions" }, + ...Object.entries(ACTION_META).map(([value, meta]) => ({ + value, + label: meta.label, + })), +]; + +function FilterBar({ + actionFilter, + userSearch, + onActionChange, + onUserSearchChange, + onClear, +}: FilterBarProps) { + const hasFilters = actionFilter !== "all" || userSearch.trim().length > 0; + + return ( +
+ {/* Action filter */} + + + {/* User search */} +
+ + onUserSearchChange(e.target.value)} + /> +
+ + {/* Clear filters */} + {hasFilters && ( + + )} +
+ ); +} + +// ─── Main component ─────────────────────────────────────────────────────────── + +interface CaseTableProps { + data: CaseListResponse | null; + loading: boolean; + error: string | null; + page: number; + sortDesc: boolean; + actionFilter: string; + userSearch: string; + guildId: string; + onPageChange: (page: number) => void; + onSortToggle: () => void; + onActionFilterChange: (val: string) => void; + onUserSearchChange: (val: string) => void; + onClearFilters: () => void; +} + +export function CaseTable({ + data, + loading, + error, + page, + sortDesc, + actionFilter, + userSearch, + guildId, + onPageChange, + onSortToggle, + onActionFilterChange, + onUserSearchChange, + onClearFilters, +}: CaseTableProps) { + const [expandedId, setExpandedId] = useState(null); + const [expandedCase, setExpandedCase] = useState(null); + const [expandLoading, setExpandLoading] = useState(false); + + const toggleExpand = useCallback(async (c: ModCase) => { + if (expandedId === c.id) { + setExpandedId(null); + setExpandedCase(null); + return; + } + setExpandedId(c.id); + setExpandedCase(null); + setExpandLoading(true); + try { + const res = await fetch(`/api/moderation/cases/${c.case_number}?guildId=${encodeURIComponent(guildId)}`); + if (res.ok) { + const fullCase = await res.json() as ModCase; + setExpandedCase(fullCase); + } else { + // Non-OK response — fall back to list data so CaseDetail still renders + setExpandedCase(c); + } + } catch { + // Network error — fall back to list data (no scheduledActions) + setExpandedCase(c); + } finally { + setExpandLoading(false); + } + }, [expandedId, guildId]); + + if (error) { + return ( +
+ Failed to load cases: {error} +
+ ); + } + + const cases: ModCase[] = data?.cases ?? []; + const totalPages = data?.pages ?? 1; + const total = data?.total ?? 0; + + return ( +
+ {/* Filters */} + { + onActionFilterChange(val); + onPageChange(1); + }} + onUserSearchChange={(val) => { + onUserSearchChange(val); + onPageChange(1); + }} + onClear={onClearFilters} + /> + + {/* Table */} +
+ + + + Case # + Action + Target + Moderator + Reason + {/* NOTE: Sort toggle only reverses the current page client-side. + The API always returns DESC; a full server-side sort would + require a backend ORDER param — not worth it right now. */} + + + + + + + + {loading ? ( + Array.from({ length: 5 }).map((_, i) => ( + + {[1, 2, 3, 4, 5, 6].map((j) => ( + +
+ + ))} + + )) + ) : cases.length === 0 ? ( + + + No cases found. + + + ) : ( + cases.map((c) => ( + + toggleExpand(c)} + > + + #{c.case_number} + + + + + + {c.target_tag} + + + {c.moderator_tag} + + + {c.reason ?? } + + + {formatDate(c.created_at)} + + + + {expandedId === c.id && ( + + + {expandLoading ? ( +
+ +
+ ) : ( + + )} +
+
+ )} +
+ )) + )} + +
+
+ + {/* Pagination */} +
+ {total} total cases +
+ + + Page {page} of {totalPages || 1} + + +
+
+
+ ); +} diff --git a/web/src/components/dashboard/moderation-stats.tsx b/web/src/components/dashboard/moderation-stats.tsx new file mode 100644 index 00000000..9bd09081 --- /dev/null +++ b/web/src/components/dashboard/moderation-stats.tsx @@ -0,0 +1,171 @@ +"use client"; + +import { AlertTriangle, Ban, Clock, Shield, TrendingUp, UserX } from "lucide-react"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Skeleton } from "@/components/ui/skeleton"; +import { ACTION_META } from "./moderation-types"; +import type { ModStats } from "./moderation-types"; + +interface StatCardProps { + title: string; + value: number | string; + icon: React.ReactNode; + description?: string; + loading?: boolean; +} + +function StatCard({ title, value, icon, description, loading }: StatCardProps) { + return ( + + + {title} +
{icon}
+
+ + {loading ? ( + + ) : ( +
{value}
+ )} + {description && ( +

{description}

+ )} +
+
+ ); +} + +interface ModerationStatsProps { + stats: ModStats | null; + loading: boolean; + error: string | null; +} + +export function ModerationStats({ stats, loading, error }: ModerationStatsProps) { + if (error) { + return ( +
+ Failed to load stats: {error} +
+ ); + } + + const topActions = stats + ? Object.entries(stats.byAction) + .sort(([, a], [, b]) => b - a) + .slice(0, 4) + : []; + + return ( +
+ {/* Summary cards */} +
+ } + description="All time" + loading={loading} + /> + } + description="Recent activity" + loading={loading} + /> + } + description="This week" + loading={loading} + /> + } + description="Action types used" + loading={loading} + /> +
+ +
+ {/* By action breakdown */} + + + By Action Type + + + {loading ? ( +
+ {[1, 2, 3].map((i) => ( + + ))} +
+ ) : topActions.length === 0 ? ( +

No cases yet.

+ ) : ( +
    + {topActions.map(([action, count]) => { + const meta = ACTION_META[action as keyof typeof ACTION_META]; + const label = meta?.label ?? action; + const badgeCls = meta?.badge ?? "bg-muted text-muted-foreground"; + return ( +
  • + + {label} + + {count} +
  • + ); + })} +
+ )} +
+
+ + {/* Top targets */} + + + + + Top Targets + (last 30 days) + + + + {loading ? ( +
+ {[1, 2, 3].map((i) => ( + + ))} +
+ ) : !stats?.topTargets?.length ? ( +

No repeat offenders. 🎉

+ ) : ( +
    + {stats.topTargets.map(({ userId, tag, count }) => ( +
  • + + {tag} + + + + {count} + +
  • + ))} +
+ )} +
+
+
+
+ ); +} diff --git a/web/src/components/dashboard/moderation-types.ts b/web/src/components/dashboard/moderation-types.ts new file mode 100644 index 00000000..15364c25 --- /dev/null +++ b/web/src/components/dashboard/moderation-types.ts @@ -0,0 +1,130 @@ +/** A single moderation case row from the bot database. */ +export interface ModCase { + id: number; + guild_id: string; + case_number: number; + action: ModAction; + target_id: string; + target_tag: string; + moderator_id: string; + moderator_tag: string; + reason: string | null; + duration: string | null; + expires_at: string | null; + log_message_id: string | null; + created_at: string; + scheduledActions?: ScheduledAction[]; +} + +/** Supported moderation action types. */ +export type ModAction = + | "warn" + | "kick" + | "ban" + | "tempban" + | "unban" + | "softban" + | "timeout" + | "untimeout" + | "purge" + | "lock" + | "unlock" + | "slowmode"; + +/** A scheduled action linked to a mod case (e.g. scheduled unban for tempban). */ +export interface ScheduledAction { + id: number; + action: ModAction; + target_id: string; + execute_at: string; + executed: boolean; + created_at: string; +} + +/** Paginated response from GET /api/moderation/cases. */ +export interface CaseListResponse { + cases: ModCase[]; + total: number; + page: number; + pages: number; +} + +/** Stats summary from GET /api/moderation/stats. */ +export interface ModStats { + totalCases: number; + last24h: number; + last7d: number; + byAction: Partial>; + topTargets: Array<{ + userId: string; + tag: string; + count: number; + }>; +} + +/** Color and display metadata for each action type. */ +export const ACTION_META: Record< + ModAction, + { label: string; badge: string; color: string } +> = { + warn: { + label: "Warn", + badge: "bg-yellow-500/20 text-yellow-400 border-yellow-500/30", + color: "#EAB308", + }, + kick: { + label: "Kick", + badge: "bg-orange-500/20 text-orange-400 border-orange-500/30", + color: "#F97316", + }, + ban: { + label: "Ban", + badge: "bg-red-500/20 text-red-400 border-red-500/30", + color: "#EF4444", + }, + tempban: { + label: "Tempban", + badge: "bg-red-600/20 text-red-300 border-red-600/30", + color: "#DC2626", + }, + unban: { + label: "Unban", + badge: "bg-green-500/20 text-green-400 border-green-500/30", + color: "#22C55E", + }, + softban: { + label: "Softban", + badge: "bg-rose-500/20 text-rose-400 border-rose-500/30", + color: "#F43F5E", + }, + timeout: { + label: "Timeout", + badge: "bg-purple-500/20 text-purple-400 border-purple-500/30", + color: "#A855F7", + }, + untimeout: { + label: "Untimeout", + badge: "bg-violet-500/20 text-violet-400 border-violet-500/30", + color: "#8B5CF6", + }, + purge: { + label: "Purge", + badge: "bg-blue-500/20 text-blue-400 border-blue-500/30", + color: "#3B82F6", + }, + lock: { + label: "Lock", + badge: "bg-amber-500/20 text-amber-400 border-amber-500/30", + color: "#F59E0B", + }, + unlock: { + label: "Unlock", + badge: "bg-teal-500/20 text-teal-400 border-teal-500/30", + color: "#14B8A6", + }, + slowmode: { + label: "Slowmode", + badge: "bg-indigo-500/20 text-indigo-400 border-indigo-500/30", + color: "#6366F1", + }, +}; diff --git a/web/src/components/ui/badge.tsx b/web/src/components/ui/badge.tsx new file mode 100644 index 00000000..beb56ed1 --- /dev/null +++ b/web/src/components/ui/badge.tsx @@ -0,0 +1,48 @@ +import * as React from "react" +import { cva, type VariantProps } from "class-variance-authority" +import { Slot } from "radix-ui" + +import { cn } from "@/lib/utils" + +const badgeVariants = cva( + "inline-flex items-center justify-center rounded-full border border-transparent px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden", + { + variants: { + variant: { + default: "bg-primary text-primary-foreground [a&]:hover:bg-primary/90", + secondary: + "bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90", + destructive: + "bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60", + outline: + "border-border text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground", + ghost: "[a&]:hover:bg-accent [a&]:hover:text-accent-foreground", + link: "text-primary underline-offset-4 [a&]:hover:underline", + }, + }, + defaultVariants: { + variant: "default", + }, + } +) + +function Badge({ + className, + variant = "default", + asChild = false, + ...props +}: React.ComponentProps<"span"> & + VariantProps & { asChild?: boolean }) { + const Comp = asChild ? Slot.Root : "span" + + return ( + + ) +} + +export { Badge, badgeVariants } diff --git a/web/src/components/ui/input.tsx b/web/src/components/ui/input.tsx new file mode 100644 index 00000000..89169058 --- /dev/null +++ b/web/src/components/ui/input.tsx @@ -0,0 +1,21 @@ +import * as React from "react" + +import { cn } from "@/lib/utils" + +function Input({ className, type, ...props }: React.ComponentProps<"input">) { + return ( + + ) +} + +export { Input } diff --git a/web/src/components/ui/select.tsx b/web/src/components/ui/select.tsx new file mode 100644 index 00000000..fd01b746 --- /dev/null +++ b/web/src/components/ui/select.tsx @@ -0,0 +1,190 @@ +"use client" + +import * as React from "react" +import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react" +import { Select as SelectPrimitive } from "radix-ui" + +import { cn } from "@/lib/utils" + +function Select({ + ...props +}: React.ComponentProps) { + return +} + +function SelectGroup({ + ...props +}: React.ComponentProps) { + return +} + +function SelectValue({ + ...props +}: React.ComponentProps) { + return +} + +function SelectTrigger({ + className, + size = "default", + children, + ...props +}: React.ComponentProps & { + size?: "sm" | "default" +}) { + return ( + + {children} + + + + + ) +} + +function SelectContent({ + className, + children, + position = "item-aligned", + align = "center", + ...props +}: React.ComponentProps) { + return ( + + + + + {children} + + + + + ) +} + +function SelectLabel({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function SelectItem({ + className, + children, + ...props +}: React.ComponentProps) { + return ( + + + + + + + {children} + + ) +} + +function SelectSeparator({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function SelectScrollUpButton({ + className, + ...props +}: React.ComponentProps) { + return ( + + + + ) +} + +function SelectScrollDownButton({ + className, + ...props +}: React.ComponentProps) { + return ( + + + + ) +} + +export { + Select, + SelectContent, + SelectGroup, + SelectItem, + SelectLabel, + SelectScrollDownButton, + SelectScrollUpButton, + SelectSeparator, + SelectTrigger, + SelectValue, +} diff --git a/web/src/components/ui/table.tsx b/web/src/components/ui/table.tsx new file mode 100644 index 00000000..51b74dd5 --- /dev/null +++ b/web/src/components/ui/table.tsx @@ -0,0 +1,116 @@ +"use client" + +import * as React from "react" + +import { cn } from "@/lib/utils" + +function Table({ className, ...props }: React.ComponentProps<"table">) { + return ( +
+ + + ) +} + +function TableHeader({ className, ...props }: React.ComponentProps<"thead">) { + return ( + + ) +} + +function TableBody({ className, ...props }: React.ComponentProps<"tbody">) { + return ( + + ) +} + +function TableFooter({ className, ...props }: React.ComponentProps<"tfoot">) { + return ( + tr]:last:border-b-0", + className + )} + {...props} + /> + ) +} + +function TableRow({ className, ...props }: React.ComponentProps<"tr">) { + return ( + + ) +} + +function TableHead({ className, ...props }: React.ComponentProps<"th">) { + return ( +
[role=checkbox]]:translate-y-[2px]", + className + )} + {...props} + /> + ) +} + +function TableCell({ className, ...props }: React.ComponentProps<"td">) { + return ( + [role=checkbox]]:translate-y-[2px]", + className + )} + {...props} + /> + ) +} + +function TableCaption({ + className, + ...props +}: React.ComponentProps<"caption">) { + return ( +
+ ) +} + +export { + Table, + TableHeader, + TableBody, + TableFooter, + TableHead, + TableRow, + TableCell, + TableCaption, +}