diff --git a/.gitignore b/.gitignore index 99c7bb34..7dc09422 100644 --- a/.gitignore +++ b/.gitignore @@ -42,3 +42,4 @@ web/tsconfig.tsbuildinfo openclaw-studio/ .codex/ .turbo/ +worktrees/ diff --git a/biome.json b/biome.json index 97484828..fd986430 100644 --- a/biome.json +++ b/biome.json @@ -9,7 +9,8 @@ "!coverage", "!logs", "!data", - "!feat-issue-164" + "!feat-issue-164", + "!worktrees" ] }, "linter": { diff --git a/config.json b/config.json index dba530d7..2d10a13e 100644 --- a/config.json +++ b/config.json @@ -116,6 +116,14 @@ "includeAdmins": true, "includeModerators": true, "includeServerOwner": true + }, + "warnings": { + "expiryDays": 90, + "severityPoints": { + "low": 1, + "medium": 2, + "high": 3 + } } }, "memory": { @@ -152,7 +160,11 @@ "ping": "everyone", "memory": "everyone", "config": "admin", - "warn": "admin", + "warn": "moderator", + "warnings": "moderator", + "editwarn": "moderator", + "removewarn": "moderator", + "clearwarnings": "moderator", "kick": "admin", "timeout": "admin", "untimeout": "admin", diff --git a/migrations/011_warnings.cjs b/migrations/011_warnings.cjs new file mode 100644 index 00000000..82c875f8 --- /dev/null +++ b/migrations/011_warnings.cjs @@ -0,0 +1,60 @@ +/** + * Migration: Comprehensive Warning System + * + * Adds a dedicated `warnings` table that tracks individual warnings with + * severity, points, expiry (auto-removal after a configurable period), + * and decay (points reduce over time). Warnings reference the parent + * mod_case for traceability. + * + * Also adds an index on mod_cases for active-warning escalation queries. + * + * @see https://github.com/VolvoxLLC/volvox-bot/issues/250 + */ + +'use strict'; + +/** @param {import('node-pg-migrate').MigrationBuilder} pgm */ +exports.up = (pgm) => { + // ── warnings table ───────────────────────────────────────────────── + pgm.sql(` + CREATE TABLE IF NOT EXISTS warnings ( + id SERIAL PRIMARY KEY, + guild_id TEXT NOT NULL, + user_id TEXT NOT NULL, + moderator_id TEXT NOT NULL, + moderator_tag TEXT NOT NULL, + reason TEXT, + severity TEXT NOT NULL DEFAULT 'low' + CHECK (severity IN ('low', 'medium', 'high')), + points INTEGER NOT NULL DEFAULT 1, + active BOOLEAN NOT NULL DEFAULT TRUE, + expires_at TIMESTAMPTZ, + removed_at TIMESTAMPTZ, + removed_by TEXT, + removal_reason TEXT, + case_id INTEGER REFERENCES mod_cases(id) ON DELETE SET NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + ) + `); + + // Fast lookup: all warnings for a user in a guild + pgm.sql( + 'CREATE INDEX IF NOT EXISTS idx_warnings_guild_user ON warnings(guild_id, user_id, created_at DESC)', + ); + + // Fast lookup: active warnings only (for escalation + /warnings display) + pgm.sql( + 'CREATE INDEX IF NOT EXISTS idx_warnings_active ON warnings(guild_id, user_id) WHERE active = TRUE', + ); + + // Expiry polling: find warnings that need to be deactivated + pgm.sql( + 'CREATE INDEX IF NOT EXISTS idx_warnings_expires ON warnings(expires_at) WHERE active = TRUE AND expires_at IS NOT NULL', + ); +}; + +/** @param {import('node-pg-migrate').MigrationBuilder} pgm */ +exports.down = (pgm) => { + pgm.sql('DROP TABLE IF EXISTS warnings CASCADE'); +}; diff --git a/src/api/index.js b/src/api/index.js index 04b7eefb..c98e6597 100644 --- a/src/api/index.js +++ b/src/api/index.js @@ -21,6 +21,7 @@ import notificationsRouter from './routes/notifications.js'; import performanceRouter from './routes/performance.js'; import tempRolesRouter from './routes/tempRoles.js'; import ticketsRouter from './routes/tickets.js'; +import warningsRouter from './routes/warnings.js'; import webhooksRouter from './routes/webhooks.js'; import welcomeRouter from './routes/welcome.js'; @@ -58,6 +59,9 @@ router.use('/guilds', requireAuth(), auditLogMiddleware(), guildsRouter); // Moderation routes — require API secret or OAuth2 JWT router.use('/moderation', requireAuth(), auditLogMiddleware(), moderationRouter); + +// Warning routes — require API secret or OAuth2 JWT +router.use('/warnings', requireAuth(), auditLogMiddleware(), warningsRouter); // Temp role routes — require API secret or OAuth2 JWT router.use('/temp-roles', requireAuth(), auditLogMiddleware(), tempRolesRouter); diff --git a/src/api/routes/warnings.js b/src/api/routes/warnings.js new file mode 100644 index 00000000..04685b65 --- /dev/null +++ b/src/api/routes/warnings.js @@ -0,0 +1,316 @@ +/** + * Warnings API Routes + * Exposes warning data and management endpoints for the web dashboard. + * + * @see https://github.com/VolvoxLLC/volvox-bot/issues/250 + */ + +import { Router } from 'express'; +import { getPool } from '../../db.js'; +import { info, error as logError } from '../../logger.js'; +import { rateLimit } from '../middleware/rateLimit.js'; +import { requireGuildModerator } from './guilds.js'; + +const router = Router(); + +/** Rate limiter for warning API endpoints — 120 requests / 15 min per IP. */ +const warningsRateLimit = rateLimit({ windowMs: 15 * 60 * 1000, max: 120 }); + +/** + * Make a guild-scoped route parameter available to downstream middleware by copying `req.query.guildId` to `req.params.id` when present. + * + * @param {import('express').Request} req - Express request object. + * @param {import('express').Response} _res - Express response object (unused). + * @param {import('express').NextFunction} next - Callback to pass control to the next middleware. + */ +function adaptGuildIdParam(req, _res, next) { + if (req.query.guildId) { + req.params.id = req.query.guildId; + } + next(); +} + +// Apply rate limiter and guild-scoped authorization +router.use(warningsRateLimit); +router.use(adaptGuildIdParam, requireGuildModerator); + +// ─── GET / ──────────────────────────────────────────────────────────────────── + +/** + * @openapi + * /warnings: + * get: + * tags: + * - Warnings + * summary: List warnings + * description: Returns paginated warnings for a guild with optional filters. + * security: + * - ApiKeyAuth: [] + * - BearerAuth: [] + * parameters: + * - in: query + * name: guildId + * required: true + * schema: + * type: string + * - in: query + * name: userId + * schema: + * type: string + * description: Filter by target user ID + * - in: query + * name: active + * schema: + * type: boolean + * description: Filter by active status + * - in: query + * name: severity + * schema: + * type: string + * enum: [low, medium, high] + * - in: query + * name: page + * schema: + * type: integer + * default: 1 + * - in: query + * name: limit + * schema: + * type: integer + * default: 25 + * maximum: 100 + * responses: + * "200": + * description: Paginated warnings + * "400": + * description: Missing guildId + * "401": + * $ref: "#/components/responses/Unauthorized" + * "403": + * $ref: "#/components/responses/Forbidden" + */ +router.get('/', async (req, res) => { + const { guildId, userId, active, severity } = 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(); + + const conditions = ['guild_id = $1']; + const values = [guildId]; + let paramIdx = 2; + + if (userId) { + conditions.push(`user_id = $${paramIdx++}`); + values.push(userId); + } + + if (active !== undefined) { + conditions.push(`active = $${paramIdx++}`); + values.push(active === 'true'); + } + + if (severity) { + conditions.push(`severity = $${paramIdx++}`); + values.push(severity); + } + + const where = conditions.join(' AND '); + + const [warningsResult, countResult] = await Promise.all([ + pool.query( + `SELECT * FROM warnings + WHERE ${where} + ORDER BY created_at DESC + LIMIT $${paramIdx} OFFSET $${paramIdx + 1}`, + [...values, limit, offset], + ), + pool.query(`SELECT COUNT(*)::integer AS total FROM warnings WHERE ${where}`, values), + ]); + + const total = countResult.rows[0]?.total ?? 0; + const pages = Math.ceil(total / limit); + + info('Warnings listed via API', { guildId, page, limit, total }); + + return res.json({ + warnings: warningsResult.rows, + total, + page, + limit, + pages, + }); + } catch (err) { + logError('Failed to list warnings', { error: err.message, guildId }); + return res.status(500).json({ error: 'Failed to fetch warnings' }); + } +}); + +// ─── GET /user/:userId ──────────────────────────────────────────────────────── + +/** + * @openapi + * /warnings/user/{userId}: + * get: + * tags: + * - Warnings + * summary: User warning summary + * description: Returns warning summary and history for a specific user. + * security: + * - ApiKeyAuth: [] + * - BearerAuth: [] + * parameters: + * - in: path + * name: userId + * required: true + * schema: + * type: string + * - in: query + * name: guildId + * required: true + * schema: + * type: string + * responses: + * "200": + * description: User warning summary with history + * "400": + * description: Missing guildId + */ +router.get('/user/:userId', async (req, res) => { + const { userId } = req.params; + const { guildId } = req.query; + + if (!guildId) { + return res.status(400).json({ error: 'guildId is required' }); + } + + try { + const pool = getPool(); + + const [warningsResult, statsResult, bySeverityResult] = await Promise.all([ + pool.query( + `SELECT * FROM warnings + WHERE guild_id = $1 AND user_id = $2 + ORDER BY created_at DESC + LIMIT 50`, + [guildId, userId], + ), + pool.query( + `SELECT + COUNT(*)::integer AS active_count, + COALESCE(SUM(points), 0)::integer AS active_points + FROM warnings + WHERE guild_id = $1 AND user_id = $2 AND active = TRUE`, + [guildId, userId], + ), + pool.query( + `SELECT severity, COUNT(*)::integer AS count + FROM warnings + WHERE guild_id = $1 AND user_id = $2 AND active = TRUE + GROUP BY severity`, + [guildId, userId], + ), + ]); + + const bySeverity = {}; + for (const row of bySeverityResult.rows) { + bySeverity[row.severity] = row.count; + } + + info('User warning summary fetched via API', { guildId, userId }); + + return res.json({ + userId, + activeCount: statsResult.rows[0]?.active_count ?? 0, + activePoints: statsResult.rows[0]?.active_points ?? 0, + bySeverity, + warnings: warningsResult.rows, + }); + } catch (err) { + logError('Failed to fetch user warnings', { error: err.message, guildId, userId }); + return res.status(500).json({ error: 'Failed to fetch user warnings' }); + } +}); + +// ─── GET /stats ─────────────────────────────────────────────────────────────── + +/** + * @openapi + * /warnings/stats: + * get: + * tags: + * - Warnings + * summary: Warning statistics + * description: Returns aggregate warning statistics for a guild. + * security: + * - ApiKeyAuth: [] + * - BearerAuth: [] + * parameters: + * - in: query + * name: guildId + * required: true + * schema: + * type: string + * responses: + * "200": + * description: Warning stats + */ +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, activeResult, bySeverityResult, topUsersResult] = await Promise.all([ + pool.query('SELECT COUNT(*)::integer AS total FROM warnings WHERE guild_id = $1', [guildId]), + pool.query( + 'SELECT COUNT(*)::integer AS total FROM warnings WHERE guild_id = $1 AND active = TRUE', + [guildId], + ), + pool.query( + `SELECT severity, COUNT(*)::integer AS count + FROM warnings + WHERE guild_id = $1 AND active = TRUE + GROUP BY severity`, + [guildId], + ), + pool.query( + `SELECT user_id, COUNT(*)::integer AS count, SUM(points)::integer AS points + FROM warnings + WHERE guild_id = $1 AND active = TRUE + GROUP BY user_id + ORDER BY points DESC + LIMIT 10`, + [guildId], + ), + ]); + + const bySeverity = {}; + for (const row of bySeverityResult.rows) { + bySeverity[row.severity] = row.count; + } + + return res.json({ + totalWarnings: totalResult.rows[0]?.total ?? 0, + activeWarnings: activeResult.rows[0]?.total ?? 0, + bySeverity, + topUsers: topUsersResult.rows, + }); + } catch (err) { + logError('Failed to fetch warning stats', { error: err.message, guildId }); + return res.status(500).json({ error: 'Failed to fetch warning stats' }); + } +}); + +export default router; diff --git a/src/commands/clearwarnings.js b/src/commands/clearwarnings.js new file mode 100644 index 00000000..d723bc64 --- /dev/null +++ b/src/commands/clearwarnings.js @@ -0,0 +1,57 @@ +/** + * Clear Warnings Command + * Deactivate all active warnings for a user in the guild. + * + * @see https://github.com/VolvoxLLC/volvox-bot/issues/250 + */ + +import { SlashCommandBuilder } from 'discord.js'; +import { info, error as logError } from '../logger.js'; +import { clearWarnings } from '../modules/warningEngine.js'; +import { safeEditReply } from '../utils/safeSend.js'; + +export const data = new SlashCommandBuilder() + .setName('clearwarnings') + .setDescription('Clear all active warnings for a user') + .addUserOption((opt) => opt.setName('user').setDescription('Target user').setRequired(true)) + .addStringOption((opt) => + opt.setName('reason').setDescription('Reason for clearing').setRequired(false), + ); + +export const moderatorOnly = true; + +/** + * Deactivates all active warnings for the specified guild member and responds to the invoking interaction. + * + * Edits the deferred reply to report whether no active warnings were found or how many were cleared, logs the successful action, and on error logs the failure and replies with a failure message. + * @param {import('discord.js').ChatInputCommandInteraction} interaction - The invoking command interaction. + */ +export async function execute(interaction) { + try { + await interaction.deferReply({ ephemeral: true }); + + const user = interaction.options.getUser('user'); + const reason = interaction.options.getString('reason'); + + const count = await clearWarnings(interaction.guild.id, user.id, interaction.user.id, reason); + + if (count === 0) { + return await safeEditReply(interaction, `No active warnings found for **${user.tag}**.`); + } + + info('Warnings cleared via command', { + guildId: interaction.guild.id, + target: user.tag, + moderator: interaction.user.tag, + count, + }); + + await safeEditReply( + interaction, + `✅ Cleared **${count}** active warning(s) for **${user.tag}**.`, + ); + } catch (err) { + logError('Command error', { error: err.message, command: 'clearwarnings' }); + await safeEditReply(interaction, '❌ Failed to clear warnings.').catch(() => {}); + } +} diff --git a/src/commands/editwarn.js b/src/commands/editwarn.js new file mode 100644 index 00000000..24857241 --- /dev/null +++ b/src/commands/editwarn.js @@ -0,0 +1,81 @@ +/** + * Edit Warning Command + * Edit the reason or severity of an existing warning. + * + * @see https://github.com/VolvoxLLC/volvox-bot/issues/250 + */ + +import { SlashCommandBuilder } from 'discord.js'; +import { info, error as logError } from '../logger.js'; +import { getConfig } from '../modules/config.js'; +import { editWarning } from '../modules/warningEngine.js'; +import { safeEditReply } from '../utils/safeSend.js'; + +export const data = new SlashCommandBuilder() + .setName('editwarn') + .setDescription('Edit a warning') + .addIntegerOption((opt) => + opt.setName('id').setDescription('Warning ID').setRequired(true).setMinValue(1), + ) + .addStringOption((opt) => opt.setName('reason').setDescription('New reason').setRequired(false)) + .addStringOption((opt) => + opt + .setName('severity') + .setDescription('New severity') + .setRequired(false) + .addChoices( + { name: 'Low', value: 'low' }, + { name: 'Medium', value: 'medium' }, + { name: 'High', value: 'high' }, + ), + ); + +export const moderatorOnly = true; + +/** + * Handle the /editwarn slash command to update an existing warning's reason and/or severity. + * @param {import('discord.js').ChatInputCommandInteraction} interaction - The command interaction invoking the edit. + */ +export async function execute(interaction) { + try { + await interaction.deferReply({ ephemeral: true }); + + const warningId = interaction.options.getInteger('id'); + const reason = interaction.options.getString('reason'); + const severity = interaction.options.getString('severity'); + + if (!reason && !severity) { + return await safeEditReply( + interaction, + '❌ You must provide at least a new reason or severity.', + ); + } + + const updates = {}; + if (reason) updates.reason = reason; + if (severity) updates.severity = severity; + + const config = getConfig(interaction.guildId); + const updated = await editWarning(interaction.guild.id, warningId, updates, config); + + if (!updated) { + return await safeEditReply(interaction, `❌ Warning #${warningId} not found in this server.`); + } + + const parts = []; + if (reason) parts.push(`reason`); + if (severity) parts.push(`severity → ${severity}`); + + info('Warning edited via command', { + guildId: interaction.guild.id, + warningId, + moderator: interaction.user.tag, + updates: Object.keys(updates), + }); + + await safeEditReply(interaction, `✅ Warning #${warningId} updated (${parts.join(', ')}).`); + } catch (err) { + logError('Command error', { error: err.message, command: 'editwarn' }); + await safeEditReply(interaction, '❌ Failed to edit warning.').catch(() => {}); + } +} diff --git a/src/commands/removewarn.js b/src/commands/removewarn.js new file mode 100644 index 00000000..fae17d4c --- /dev/null +++ b/src/commands/removewarn.js @@ -0,0 +1,69 @@ +/** + * Remove Warning Command + * Deactivate a specific warning by ID. + * + * @see https://github.com/VolvoxLLC/volvox-bot/issues/250 + */ + +import { SlashCommandBuilder } from 'discord.js'; +import { info, error as logError } from '../logger.js'; +import { removeWarning } from '../modules/warningEngine.js'; +import { safeEditReply } from '../utils/safeSend.js'; + +export const data = new SlashCommandBuilder() + .setName('removewarn') + .setDescription('Remove a warning') + .addIntegerOption((opt) => + opt.setName('id').setDescription('Warning ID').setRequired(true).setMinValue(1), + ) + .addStringOption((opt) => + opt.setName('reason').setDescription('Reason for removal').setRequired(false), + ); + +export const moderatorOnly = true; + +/** + * Deactivate a warning by ID and reply to the command issuer with the result. + * + * Attempts to deactivate the warning specified by the command's `id` option and optionally records a removal + * reason provided via the `reason` option. Edits the interaction reply to confirm removal including the affected + * user on success, or to indicate that the warning was not found or removal failed. + * @param {import('discord.js').ChatInputCommandInteraction} interaction - The command interaction for the removewarn command. + */ +export async function execute(interaction) { + try { + await interaction.deferReply({ ephemeral: true }); + + const warningId = interaction.options.getInteger('id'); + const reason = interaction.options.getString('reason'); + + const removed = await removeWarning( + interaction.guild.id, + warningId, + interaction.user.id, + reason, + ); + + if (!removed) { + return await safeEditReply( + interaction, + `❌ Warning #${warningId} not found or already inactive.`, + ); + } + + info('Warning removed via command', { + guildId: interaction.guild.id, + warningId, + moderator: interaction.user.tag, + targetUserId: removed.user_id, + }); + + await safeEditReply( + interaction, + `✅ Warning #${warningId} removed (was for <@${removed.user_id}>).`, + ); + } catch (err) { + logError('Command error', { error: err.message, command: 'removewarn' }); + await safeEditReply(interaction, '❌ Failed to remove warning.').catch(() => {}); + } +} diff --git a/src/commands/warn.js b/src/commands/warn.js index 2f572d08..af50d9ec 100644 --- a/src/commands/warn.js +++ b/src/commands/warn.js @@ -1,10 +1,14 @@ /** * Warn Command - * Issues a warning to a user and records a moderation case. + * Issues a warning to a user, records a moderation case, and creates a + * warning record with severity/points/expiry tracking. + * + * @see https://github.com/VolvoxLLC/volvox-bot/issues/250 */ import { SlashCommandBuilder } from 'discord.js'; import { checkEscalation } from '../modules/moderation.js'; +import { createWarning } from '../modules/warningEngine.js'; import { executeModAction } from '../utils/modAction.js'; export const data = new SlashCommandBuilder() @@ -13,13 +17,27 @@ export const data = new SlashCommandBuilder() .addUserOption((opt) => opt.setName('user').setDescription('Target user').setRequired(true)) .addStringOption((opt) => opt.setName('reason').setDescription('Reason for warning').setRequired(false), + ) + .addStringOption((opt) => + opt + .setName('severity') + .setDescription('Warning severity (affects point weight)') + .setRequired(false) + .addChoices( + { name: 'Low', value: 'low' }, + { name: 'Medium', value: 'medium' }, + { name: 'High', value: 'high' }, + ), ); -export const adminOnly = true; +export const moderatorOnly = true; /** - * Execute the warn command - * @param {import('discord.js').ChatInputCommandInteraction} interaction + * Issue a warning to a guild member, record a moderation case and warning, and trigger escalation checks. + * + * Creates a moderation case via the standardized moderation flow, persists a linked warning record (including severity), + * and invokes escalation logic based on active warnings. + * @param {import('discord.js').ChatInputCommandInteraction} interaction - The command interaction that invoked the warn command. */ export async function execute(interaction) { await executeModAction(interaction, { @@ -29,7 +47,28 @@ export async function execute(interaction) { if (!target) return { earlyReturn: '\u274C User is not in this server.' }; return { target, targetId: target.id, targetTag: target.user.tag }; }, + extractOptions: (inter) => ({ + reason: inter.options.getString('reason'), + _severity: inter.options.getString('severity') || 'low', + }), afterCase: async (caseData, inter, config) => { + const severity = inter.options.getString('severity') || 'low'; + + // Create the warning record linked to the mod case + await createWarning( + inter.guild.id, + { + userId: caseData.target_id, + moderatorId: inter.user.id, + moderatorTag: inter.user.tag, + reason: caseData.reason, + severity, + caseId: caseData.id, + }, + config, + ); + + // Check escalation (now uses active warnings only) await checkEscalation( inter.client, inter.guild.id, diff --git a/src/commands/warnings.js b/src/commands/warnings.js new file mode 100644 index 00000000..bbf72fe8 --- /dev/null +++ b/src/commands/warnings.js @@ -0,0 +1,118 @@ +/** + * Warnings Command + * View all warnings for a user, with active/expired status. + * + * @see https://github.com/VolvoxLLC/volvox-bot/issues/250 + */ + +import { EmbedBuilder, SlashCommandBuilder } from 'discord.js'; +import { info, error as logError } from '../logger.js'; +import { getActiveWarningStats, getWarnings } from '../modules/warningEngine.js'; +import { safeEditReply } from '../utils/safeSend.js'; + +export const data = new SlashCommandBuilder() + .setName('warnings') + .setDescription('View warnings for a user') + .addUserOption((opt) => opt.setName('user').setDescription('Target user').setRequired(true)) + .addBooleanOption((opt) => + opt + .setName('active_only') + .setDescription('Show only active warnings (default: all)') + .setRequired(false), + ) + .addIntegerOption((opt) => + opt + .setName('page') + .setDescription('Page number (default: 1)') + .setRequired(false) + .setMinValue(1), + ); + +export const moderatorOnly = true; + +/** + * Map a severity level to a labeled string that includes an emoji. + * @param {string} severity - Severity level; commonly 'low', 'medium', or 'high'. + * @returns {string} The labeled severity (e.g., '🟢 Low', '🟡 Medium', '🔴 High'), or the original `severity` string if unrecognized. + */ +function severityLabel(severity) { + const labels = { + low: '🟢 Low', + medium: '🟡 Medium', + high: '🔴 High', + }; + return labels[severity] || severity; +} + +/** + * Handle the /warnings slash command by fetching and presenting a user's warnings, including active/inactive status and aggregate stats, to the invoking moderator. + * @param {import('discord.js').ChatInputCommandInteraction} interaction - The command interaction for which to display warnings. + */ +export async function execute(interaction) { + try { + await interaction.deferReply({ ephemeral: true }); + + const user = interaction.options.getUser('user'); + const activeOnly = interaction.options.getBoolean('active_only') ?? false; + const page = interaction.options.getInteger('page') ?? 1; + const perPage = 10; + const offset = (page - 1) * perPage; + + const [warnings, stats] = await Promise.all([ + getWarnings(interaction.guild.id, user.id, { activeOnly, limit: perPage, offset }), + getActiveWarningStats(interaction.guild.id, user.id), + ]); + + if (warnings.length === 0) { + const msg = activeOnly + ? `No active warnings found for **${user.tag}**.` + : `No warnings found for **${user.tag}**.`; + return await safeEditReply(interaction, msg); + } + + const lines = warnings.map((w) => { + const timestamp = Math.floor(new Date(w.created_at).getTime() / 1000); + let status; + if (w.active) { + status = '✅ Active'; + } else if (w.removal_reason === 'Expired') { + status = '⏰ Expired'; + } else { + status = '🗑️ Removed'; + } + const reason = w.reason + ? w.reason.length > 40 + ? `${w.reason.slice(0, 37)}...` + : w.reason + : 'No reason'; + const caseRef = w.case_id ? ` (Case linked)` : ''; + return `**#${w.id}** — ${severityLabel(w.severity)} — ${w.points}pt — ${status} — \n↳ ${reason}${caseRef}`; + }); + + const embed = new EmbedBuilder() + .setColor(stats.points > 5 ? 0xed4245 : stats.points > 2 ? 0xfee75c : 0x57f287) + .setTitle(`Warnings — ${user.tag}`) + .setDescription(lines.join('\n\n')) + .addFields( + { name: 'Active Warnings', value: `${stats.count}`, inline: true }, + { name: 'Total Points', value: `${stats.points}`, inline: true }, + ) + .setThumbnail(user.displayAvatarURL()) + .setFooter({ + text: `Page ${page} · Showing ${warnings.length} warning(s)${activeOnly ? ' (active only)' : ''} · Use /warnings page:${page + 1} for more`, + }) + .setTimestamp(); + + info('Warnings viewed', { + guildId: interaction.guild.id, + target: user.tag, + moderator: interaction.user.tag, + count: warnings.length, + }); + + await safeEditReply(interaction, { embeds: [embed] }); + } catch (err) { + logError('Command error', { error: err.message, command: 'warnings' }); + await safeEditReply(interaction, '❌ Failed to fetch warnings.').catch(() => {}); + } +} diff --git a/src/index.js b/src/index.js index f73a0835..0b0ad695 100644 --- a/src/index.js +++ b/src/index.js @@ -53,6 +53,10 @@ import { loadOptOuts } from './modules/optout.js'; import { seedBuiltinTemplates } from './modules/roleMenuTemplates.js'; import { startScheduler, stopScheduler } from './modules/scheduler.js'; import { startTriage, stopTriage } from './modules/triage.js'; +import { + startWarningExpiryScheduler, + stopWarningExpiryScheduler, +} from './modules/warningEngine.js'; import { closeRedisClient as closeRedis, initRedis } from './redis.js'; import { pruneOldLogs } from './transports/postgres.js'; import { stopCacheCleanup } from './utils/cache.js'; @@ -275,8 +279,8 @@ client.on('interactionCreate', async (interaction) => { }); /** - * Perform an orderly shutdown: stop background services, persist in-memory state, remove logging transport, close the database pool, disconnect the Discord client, and exit the process. - * @param {string} signal - The signal name that initiated shutdown (e.g., "SIGINT", "SIGTERM"). + * Perform an orderly shutdown of the bot: stop background services, persist runtime state, close external resources, and exit the process. + * @param {string} signal - The OS signal that triggered shutdown (e.g., "SIGINT" or "SIGTERM"). */ async function gracefulShutdown(signal) { info('Shutdown initiated', { signal }); @@ -285,6 +289,7 @@ async function gracefulShutdown(signal) { stopTriage(); stopConversationCleanup(); stopTempbanScheduler(); + stopWarningExpiryScheduler(); stopScheduler(); stopGithubFeed(); @@ -480,6 +485,7 @@ async function startup() { // Start tempban scheduler for automatic unbans (DB required) if (dbPool) { startTempbanScheduler(client); + startWarningExpiryScheduler(); startScheduler(client); startGithubFeed(client); } diff --git a/src/modules/moderation.js b/src/modules/moderation.js index 558e55c6..cb25f367 100644 --- a/src/modules/moderation.js +++ b/src/modules/moderation.js @@ -214,11 +214,17 @@ export async function sendDmNotification(member, action, reason, guildName) { } /** - * Send a mod log embed to the configured channel. - * @param {import('discord.js').Client} client - Discord client - * @param {Object} config - Bot configuration - * @param {Object} caseData - Case data from createCase() - * @returns {Promise} Sent message or null + * Post a moderation log embed for a case to the configured logging channel. + * + * Attempts to send an embed describing the case to the channel determined by + * the moderation logging configuration. On successful send it records the + * sent message's ID on the case row (logging any storage failures) and returns + * the sent message; if sending or channel resolution fails, returns `null`. + * + * @param {import('discord.js').Client} client - Discord client instance used to resolve channels. + * @param {Object} config - Bot configuration object containing moderation.logging.channels. + * @param {Object} caseData - Case object returned by createCase(), including at least `id`, `case_number`, `action`, `target_id`, `target_tag`, `moderator_id`, `moderator_tag`, and optional `reason`, `duration`, `created_at`. + * @returns {import('discord.js').Message|null} The sent log message if delivered, `null` if no message was sent. */ export async function sendModLogEmbed(client, config, caseData) { const channels = config.moderation?.logging?.channels; @@ -276,15 +282,17 @@ export async function sendModLogEmbed(client, config, caseData) { } /** - * Check auto-escalation thresholds after a warn. - * Evaluates thresholds in order; first match triggers. - * @param {import('discord.js').Client} client - Discord client - * @param {string} guildId - Discord guild ID - * @param {string} targetId - Target user ID - * @param {string} moderatorId - Moderator user ID (bot for auto-escalation) - * @param {string} moderatorTag - Moderator tag - * @param {Object} config - Bot configuration - * @returns {Promise} Escalation result or null + * Evaluate configured escalation thresholds for a guild target and apply the first matching escalation. + * + * If a threshold is met, performs the configured action (e.g., timeout or ban), creates a moderation case, and posts the mod-log for the escalation. + * + * @param {import('discord.js').Client} client - Discord client instance. + * @param {string} guildId - ID of the guild where escalation is evaluated. + * @param {string} targetId - ID of the target user being evaluated. + * @param {string} moderatorId - ID used as the moderator for the escalation case (typically the bot). + * @param {string} moderatorTag - Tag to record for the moderator in the created case. + * @param {Object} config - Bot configuration containing moderation.escalation settings and thresholds. + * @returns {Object|null} The created escalation case object when an escalation is applied, `null` if no thresholds triggered or on failure. */ export async function checkEscalation( client, @@ -302,17 +310,43 @@ export async function checkEscalation( const pool = getPool(); for (const threshold of thresholds) { - const { rows } = await pool.query( - `SELECT COUNT(*)::integer AS count FROM mod_cases - WHERE guild_id = $1 AND target_id = $2 AND action = 'warn' - AND created_at > NOW() - INTERVAL '1 day' * $3`, - [guildId, targetId, threshold.withinDays], - ); + // Count only active warnings from the warnings table. + // Falls back to mod_cases if the warnings table has no rows yet (migration pending). + let warnCount = 0; + + try { + const { rows } = await pool.query( + `SELECT COUNT(*)::integer AS count FROM warnings + WHERE guild_id = $1 AND user_id = $2 AND active = TRUE + AND (expires_at IS NULL OR expires_at > NOW()) + AND created_at > NOW() - INTERVAL '1 day' * $3`, + [guildId, targetId, threshold.withinDays], + ); + warnCount = rows[0]?.count || 0; + } catch (err) { + // Only fall back to mod_cases if warnings table doesn't exist (migration pending) + if (err.code === '42P01') { + // 42P01 = undefined_table + const { rows } = await pool.query( + `SELECT COUNT(*)::integer AS count FROM mod_cases + WHERE guild_id = $1 AND target_id = $2 AND action = 'warn' + AND created_at > NOW() - INTERVAL '1 day' * $3`, + [guildId, targetId, threshold.withinDays], + ); + warnCount = rows[0]?.count || 0; + } else { + logError('Failed to count active warnings for escalation', { + error: err.message, + guildId, + targetId, + }); + throw err; + } + } - const warnCount = rows[0]?.count || 0; if (warnCount < threshold.warns) continue; - const reason = `Auto-escalation: ${warnCount} warns in ${threshold.withinDays} days`; + const reason = `Auto-escalation: ${warnCount} active warns in ${threshold.withinDays} days`; info('Escalation triggered', { guildId, targetId, warnCount, threshold }); try { diff --git a/src/modules/warningEngine.js b/src/modules/warningEngine.js new file mode 100644 index 00000000..06b13275 --- /dev/null +++ b/src/modules/warningEngine.js @@ -0,0 +1,387 @@ +/** + * Warning Engine + * Manages warning lifecycle: creation, expiry, decay, querying, and removal. + * Warnings are stored in the `warnings` table and linked to mod_cases via case_id. + * + * @see https://github.com/VolvoxLLC/volvox-bot/issues/250 + */ + +import { getPool } from '../db.js'; +import { info, error as logError } from '../logger.js'; + +/** + * Severity-to-points mapping. Configurable via config but these are sane defaults. + * @type {Record} + */ +const DEFAULT_SEVERITY_POINTS = { + low: 1, + medium: 2, + high: 3, +}; + +/** @type {ReturnType | null} */ +let expiryInterval = null; + +/** @type {boolean} */ +let expiryPollInFlight = false; + +/** + * Determine the points assigned to a given severity, honoring config overrides. + * @param {Object} [config] - Optional bot configuration object that may contain moderation.warnings.severityPoints. + * @param {string} severity - Severity level key (e.g., 'low', 'medium', 'high'). + * @returns {number} The point value for the severity; uses the configured override when present, otherwise falls back to the default mapping or `1` if unknown. + */ +export function getSeverityPoints(config, severity) { + const configPoints = config?.moderation?.warnings?.severityPoints; + if (configPoints && typeof configPoints[severity] === 'number') { + return configPoints[severity]; + } + return DEFAULT_SEVERITY_POINTS[severity] ?? 1; +} + +/** + * Compute the expiration Date for a warning based on configured expiry days. + * @param {Object} [config] - Bot configuration object; uses `config.moderation.warnings.expiryDays`. + * @returns {Date|null} The calculated expiry Date, or `null` if `expiryDays` is not a positive number (warnings do not expire). + */ +export function calculateExpiry(config) { + const expiryDays = config?.moderation?.warnings?.expiryDays; + if (typeof expiryDays !== 'number' || expiryDays <= 0) return null; + const expiry = new Date(); + expiry.setDate(expiry.getDate() + expiryDays); + return expiry; +} + +/** + * Create a warning record in the database. + * @param {string} guildId - Discord guild ID. + * @param {Object} data - Warning data. + * @param {string} data.userId - Target user ID. + * @param {string} data.moderatorId - Moderator user ID. + * @param {string} data.moderatorTag - Moderator display tag. + * @param {string} [data.reason] - Reason for the warning. + * @param {string} [data.severity='low'] - Severity level (`low`, `medium`, or `high`). + * @param {number} [data.caseId] - Linked mod_cases.id. + * @param {Object} [config] - Bot configuration used to determine points and expiry. + * @returns {Object} The created warning row. + */ +export async function createWarning(guildId, data, config) { + const pool = getPool(); + const severity = data.severity || 'low'; + const points = getSeverityPoints(config, severity); + const expiresAt = calculateExpiry(config); + + try { + const { rows } = await pool.query( + `INSERT INTO warnings + (guild_id, user_id, moderator_id, moderator_tag, reason, severity, points, expires_at, case_id) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) + RETURNING *`, + [ + guildId, + data.userId, + data.moderatorId, + data.moderatorTag, + data.reason || null, + severity, + points, + expiresAt, + data.caseId || null, + ], + ); + + const warning = rows[0]; + + info('Warning created', { + guildId, + warningId: warning.id, + userId: data.userId, + severity, + points, + expiresAt: expiresAt?.toISOString() || null, + }); + + return warning; + } catch (err) { + logError('Failed to create warning', { error: err.message, guildId, userId: data.userId }); + throw err; + } +} + +/** + * Retrieve warnings for a user in a guild. + * @param {string} guildId - Discord guild ID. + * @param {string} userId - Target user ID. + * @param {Object} [options] - Query options. + * @param {boolean} [options.activeOnly=false] - If true, only include active warnings. + * @param {number} [options.limit=50] - Maximum number of warnings to return. + * @returns {Object[]} Array of warning rows ordered by newest first. + */ +export async function getWarnings(guildId, userId, options = {}) { + const pool = getPool(); + const { activeOnly = false, limit = 50, offset = 0 } = options; + + const conditions = ['guild_id = $1', 'user_id = $2']; + const values = [guildId, userId]; + + if (activeOnly) { + // Also filter out rows that have expired but haven't been processed by the scheduler yet + conditions.push('active = TRUE'); + conditions.push('(expires_at IS NULL OR expires_at > NOW())'); + } + + try { + const { rows } = await pool.query( + `SELECT * FROM warnings + WHERE ${conditions.join(' AND ')} + ORDER BY created_at DESC + LIMIT $${values.length + 1} OFFSET $${values.length + 2}`, + [...values, limit, offset], + ); + + return rows; + } catch (err) { + logError('Failed to get warnings', { error: err.message, guildId, userId }); + throw err; + } +} + +/** + * Get the number of active warnings and the total active warning points for a user in a guild. + * @param {string} guildId - Guild identifier. + * @param {string} userId - User identifier to query. + * @returns {{count: number, points: number}} Object with `count` equal to the number of active warnings and `points` equal to the sum of their points. + */ +export async function getActiveWarningStats(guildId, userId) { + const pool = getPool(); + + try { + const { rows } = await pool.query( + `SELECT + COUNT(*)::integer AS count, + COALESCE(SUM(points), 0)::integer AS points + FROM warnings + WHERE guild_id = $1 AND user_id = $2 AND active = TRUE + AND (expires_at IS NULL OR expires_at > NOW())`, + [guildId, userId], + ); + + return { + count: rows[0]?.count ?? 0, + points: rows[0]?.points ?? 0, + }; + } catch (err) { + logError('Failed to get active warning stats', { error: err.message, guildId, userId }); + throw err; + } +} + +/** + * Edit a warning's reason and/or severity. + * Recalculates and updates the warning's points when the severity is changed. + * @param {string} guildId - Discord guild ID. + * @param {number} warningId - Warning ID. + * @param {Object} updates - Fields to update. + * @param {string} [updates.reason] - New reason text. + * @param {string} [updates.severity] - New severity level (e.g., 'low', 'medium', 'high'). + * @param {Object} [config] - Bot configuration used to recalculate severity points when severity changes. + * @returns {Object|null} The updated warning row, or `null` if no matching warning was found. + */ +export async function editWarning(guildId, warningId, updates, config) { + const pool = getPool(); + + try { + // Fetch original for audit trail + const { rows: origRows } = await pool.query( + 'SELECT reason, severity, points FROM warnings WHERE guild_id = $1 AND id = $2', + [guildId, warningId], + ); + const original = origRows[0] || null; + + // Build dynamic SET clause + const setClauses = ['updated_at = NOW()']; + const values = []; + let paramIdx = 1; + + if (updates.reason !== undefined) { + setClauses.push(`reason = $${paramIdx++}`); + values.push(updates.reason); + } + + if (updates.severity !== undefined) { + setClauses.push(`severity = $${paramIdx++}`); + values.push(updates.severity); + // Recalculate points when severity changes + const newPoints = getSeverityPoints(config, updates.severity); + setClauses.push(`points = $${paramIdx++}`); + values.push(newPoints); + } + + values.push(guildId, warningId); + + const { rows } = await pool.query( + `UPDATE warnings + SET ${setClauses.join(', ')} + WHERE guild_id = $${paramIdx++} AND id = $${paramIdx} + RETURNING *`, + values, + ); + + if (rows.length === 0) return null; + + info('Warning edited', { + guildId, + warningId, + updates: Object.keys(updates), + previous: original + ? { + reason: original.reason, + severity: original.severity, + points: original.points, + } + : null, + }); + + return rows[0]; + } catch (err) { + logError('Failed to edit warning', { error: err.message, guildId, warningId }); + throw err; + } +} + +/** + * Deactivate a specific active warning and record who removed it and why. + * @param {string} guildId - Guild identifier the warning belongs to. + * @param {number} warningId - ID of the warning to remove. + * @param {string} removedBy - Moderator user ID who performed the removal. + * @param {string} [removalReason] - Optional reason for the removal. + * @returns {Object|null} The updated warning row if a warning was deactivated, `null` if no active warning matched. + */ +export async function removeWarning(guildId, warningId, removedBy, removalReason) { + const pool = getPool(); + + try { + const { rows } = await pool.query( + `UPDATE warnings + SET active = FALSE, removed_at = NOW(), removed_by = $1, removal_reason = $2, updated_at = NOW() + WHERE guild_id = $3 AND id = $4 AND active = TRUE + RETURNING *`, + [removedBy, removalReason || null, guildId, warningId], + ); + + if (rows.length === 0) return null; + + info('Warning removed', { + guildId, + warningId, + removedBy, + }); + + return rows[0]; + } catch (err) { + logError('Failed to remove warning', { error: err.message, guildId, warningId }); + throw err; + } +} + +/** + * Clear all active warnings for a user in a guild. + * @param {string} guildId - Discord guild ID. + * @param {string} userId - Target user ID. + * @param {string} clearedBy - Moderator user ID who cleared the warnings. + * @param {string} [reason] - Reason for clearing; defaults to 'Bulk clear' when omitted. + * @returns {number} Number of warnings cleared. + */ +export async function clearWarnings(guildId, userId, clearedBy, reason) { + const pool = getPool(); + + try { + const { rowCount } = await pool.query( + `UPDATE warnings + SET active = FALSE, removed_at = NOW(), removed_by = $1, removal_reason = $2, updated_at = NOW() + WHERE guild_id = $3 AND user_id = $4 AND active = TRUE`, + [clearedBy, reason || 'Bulk clear', guildId, userId], + ); + + if (rowCount > 0) { + info('Warnings cleared', { + guildId, + userId, + clearedBy, + count: rowCount, + }); + } + + return rowCount; + } catch (err) { + logError('Failed to clear warnings', { error: err.message, guildId, userId }); + throw err; + } +} + +/** + * Deactivate active warnings whose expiry timestamp has passed. + * + * @returns {number} Number of warnings deactivated; returns 0 if none were expired or if processing failed. + */ +export async function processExpiredWarnings() { + const pool = getPool(); + + try { + const { rowCount } = await pool.query( + `UPDATE warnings + SET active = FALSE, removed_at = NOW(), removal_reason = 'Expired', updated_at = NOW() + WHERE active = TRUE AND expires_at IS NOT NULL AND expires_at <= NOW()`, + ); + + if (rowCount > 0) { + info('Expired warnings processed', { count: rowCount }); + } + + return rowCount; + } catch (err) { + logError('Failed to process expired warnings', { error: err.message }); + return 0; + } +} + +/** + * Start the warning expiry scheduler. + * + * Performs an immediate expiry check and then schedules a poll every 60 seconds to deactivate warnings past their expiry. + * If the scheduler is already running, the function returns without side effects. Each poll is guarded to prevent concurrent runs. + */ +export function startWarningExpiryScheduler() { + if (expiryInterval) return; + + // Immediate check on startup + processExpiredWarnings().catch((err) => { + logError('Initial warning expiry poll failed', { error: err.message }); + }); + + expiryInterval = setInterval(() => { + if (expiryPollInFlight) return; + expiryPollInFlight = true; + + processExpiredWarnings() + .catch((err) => { + logError('Warning expiry poll failed', { error: err.message }); + }) + .finally(() => { + expiryPollInFlight = false; + }); + }, 60_000); + + info('Warning expiry scheduler started'); +} + +/** + * Stop the warning expiry scheduler. + */ +export function stopWarningExpiryScheduler() { + if (expiryInterval) { + clearInterval(expiryInterval); + expiryInterval = null; + info('Warning expiry scheduler stopped'); + } +} diff --git a/tests/commands/clearwarnings.test.js b/tests/commands/clearwarnings.test.js new file mode 100644 index 00000000..5f14c715 --- /dev/null +++ b/tests/commands/clearwarnings.test.js @@ -0,0 +1,60 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('../../src/utils/safeSend.js', () => ({ + safeEditReply: (t, opts) => t.editReply(opts), +})); + +vi.mock('../../src/modules/warningEngine.js', () => ({ + clearWarnings: vi.fn().mockResolvedValue(3), +})); + +vi.mock('../../src/logger.js', () => ({ info: vi.fn(), error: vi.fn(), warn: vi.fn() })); + +import { data, execute, moderatorOnly } from '../../src/commands/clearwarnings.js'; +import { clearWarnings } from '../../src/modules/warningEngine.js'; + +describe('clearwarnings command', () => { + afterEach(() => vi.clearAllMocks()); + + const createInteraction = () => ({ + options: { + getUser: vi.fn().mockReturnValue({ id: 'user1', tag: 'User#0001' }), + getString: vi.fn().mockReturnValue('clean slate'), + }, + guild: { id: 'guild1' }, + user: { id: 'mod1', tag: 'Mod#0001' }, + deferReply: vi.fn().mockResolvedValue(undefined), + editReply: vi.fn().mockResolvedValue(undefined), + }); + + it('should export data with name "clearwarnings"', () => { + expect(data.name).toBe('clearwarnings'); + }); + + it('should export moderatorOnly as true', () => { + expect(moderatorOnly).toBe(true); + }); + + it('should clear warnings successfully', async () => { + const interaction = createInteraction(); + await execute(interaction); + expect(clearWarnings).toHaveBeenCalledWith('guild1', 'user1', 'mod1', 'clean slate'); + expect(interaction.editReply).toHaveBeenCalledWith(expect.stringContaining('Cleared **3**')); + }); + + it('should handle no active warnings', async () => { + clearWarnings.mockResolvedValueOnce(0); + const interaction = createInteraction(); + await execute(interaction); + expect(interaction.editReply).toHaveBeenCalledWith( + expect.stringContaining('No active warnings'), + ); + }); + + it('should handle errors gracefully', async () => { + clearWarnings.mockRejectedValueOnce(new Error('DB error')); + const interaction = createInteraction(); + await execute(interaction); + expect(interaction.editReply).toHaveBeenCalledWith(expect.stringContaining('Failed')); + }); +}); diff --git a/tests/commands/editwarn.test.js b/tests/commands/editwarn.test.js new file mode 100644 index 00000000..00308a90 --- /dev/null +++ b/tests/commands/editwarn.test.js @@ -0,0 +1,74 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('../../src/utils/safeSend.js', () => ({ + safeEditReply: (t, opts) => t.editReply(opts), +})); + +vi.mock('../../src/modules/warningEngine.js', () => ({ + editWarning: vi + .fn() + .mockResolvedValue({ id: 1, reason: 'updated', severity: 'medium', points: 2 }), +})); + +vi.mock('../../src/modules/config.js', () => ({ + getConfig: vi.fn().mockReturnValue({ moderation: { warnings: {} } }), +})); + +vi.mock('../../src/logger.js', () => ({ info: vi.fn(), error: vi.fn(), warn: vi.fn() })); + +import { data, execute, moderatorOnly } from '../../src/commands/editwarn.js'; +import { editWarning } from '../../src/modules/warningEngine.js'; + +describe('editwarn command', () => { + afterEach(() => vi.clearAllMocks()); + + const createInteraction = (opts = {}) => ({ + options: { + getInteger: vi.fn().mockReturnValue(opts.id ?? 1), + getString: vi.fn().mockImplementation((name) => { + if (name === 'reason') return opts.reason ?? 'new reason'; + if (name === 'severity') return opts.severity ?? null; + return null; + }), + }, + guild: { id: 'guild1' }, + guildId: 'guild1', + user: { id: 'mod1', tag: 'Mod#0001' }, + deferReply: vi.fn().mockResolvedValue(undefined), + editReply: vi.fn().mockResolvedValue(undefined), + }); + + it('should export data with name "editwarn"', () => { + expect(data.name).toBe('editwarn'); + }); + + it('should export moderatorOnly as true', () => { + expect(moderatorOnly).toBe(true); + }); + + it('should edit a warning successfully', async () => { + const interaction = createInteraction(); + await execute(interaction); + expect(editWarning).toHaveBeenCalledWith( + 'guild1', + 1, + { reason: 'new reason' }, + expect.any(Object), + ); + expect(interaction.editReply).toHaveBeenCalledWith(expect.stringContaining('updated')); + }); + + it('should reject when no updates provided', async () => { + const interaction = createInteraction({ reason: null, severity: null }); + interaction.options.getString.mockReturnValue(null); + await execute(interaction); + expect(interaction.editReply).toHaveBeenCalledWith(expect.stringContaining('must provide')); + }); + + it('should return error when warning not found', async () => { + editWarning.mockResolvedValueOnce(null); + const interaction = createInteraction(); + await execute(interaction); + expect(interaction.editReply).toHaveBeenCalledWith(expect.stringContaining('not found')); + }); +}); diff --git a/tests/commands/removewarn.test.js b/tests/commands/removewarn.test.js new file mode 100644 index 00000000..54116dc0 --- /dev/null +++ b/tests/commands/removewarn.test.js @@ -0,0 +1,58 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('../../src/utils/safeSend.js', () => ({ + safeEditReply: (t, opts) => t.editReply(opts), +})); + +vi.mock('../../src/modules/warningEngine.js', () => ({ + removeWarning: vi.fn().mockResolvedValue({ id: 1, user_id: 'user1', active: false }), +})); + +vi.mock('../../src/logger.js', () => ({ info: vi.fn(), error: vi.fn(), warn: vi.fn() })); + +import { data, execute, moderatorOnly } from '../../src/commands/removewarn.js'; +import { removeWarning } from '../../src/modules/warningEngine.js'; + +describe('removewarn command', () => { + afterEach(() => vi.clearAllMocks()); + + const createInteraction = () => ({ + options: { + getInteger: vi.fn().mockReturnValue(1), + getString: vi.fn().mockReturnValue('pardoned'), + }, + guild: { id: 'guild1' }, + user: { id: 'mod1', tag: 'Mod#0001' }, + deferReply: vi.fn().mockResolvedValue(undefined), + editReply: vi.fn().mockResolvedValue(undefined), + }); + + it('should export data with name "removewarn"', () => { + expect(data.name).toBe('removewarn'); + }); + + it('should export moderatorOnly as true', () => { + expect(moderatorOnly).toBe(true); + }); + + it('should remove a warning successfully', async () => { + const interaction = createInteraction(); + await execute(interaction); + expect(removeWarning).toHaveBeenCalledWith('guild1', 1, 'mod1', 'pardoned'); + expect(interaction.editReply).toHaveBeenCalledWith(expect.stringContaining('removed')); + }); + + it('should return error when warning not found', async () => { + removeWarning.mockResolvedValueOnce(null); + const interaction = createInteraction(); + await execute(interaction); + expect(interaction.editReply).toHaveBeenCalledWith(expect.stringContaining('not found')); + }); + + it('should handle errors gracefully', async () => { + removeWarning.mockRejectedValueOnce(new Error('DB error')); + const interaction = createInteraction(); + await execute(interaction); + expect(interaction.editReply).toHaveBeenCalledWith(expect.stringContaining('Failed')); + }); +}); diff --git a/tests/commands/warn.test.js b/tests/commands/warn.test.js index 1c6d35f9..fd428de0 100644 --- a/tests/commands/warn.test.js +++ b/tests/commands/warn.test.js @@ -7,9 +7,13 @@ vi.mock('../../src/utils/safeSend.js', () => ({ safeEditReply: (t, opts) => t.editReply(opts), })); vi.mock('../../src/modules/moderation.js', () => ({ - createCase: vi - .fn() - .mockResolvedValue({ case_number: 1, action: 'warn', id: 1, target_id: 'user1' }), + createCase: vi.fn().mockResolvedValue({ + case_number: 1, + action: 'warn', + id: 1, + target_id: 'user1', + reason: 'test reason', + }), sendDmNotification: vi.fn().mockResolvedValue(undefined), sendModLogEmbed: vi.fn().mockResolvedValue({ id: 'msg1' }), checkEscalation: vi.fn().mockResolvedValue(null), @@ -18,6 +22,10 @@ vi.mock('../../src/modules/moderation.js', () => ({ shouldSendDm: vi.fn().mockReturnValue(true), })); +vi.mock('../../src/modules/warningEngine.js', () => ({ + createWarning: vi.fn().mockResolvedValue({ id: 1, severity: 'low', points: 1 }), +})); + vi.mock('../../src/modules/config.js', () => ({ getConfig: vi.fn().mockReturnValue({ moderation: { @@ -29,7 +37,7 @@ vi.mock('../../src/modules/config.js', () => ({ vi.mock('../../src/logger.js', () => ({ info: vi.fn(), error: vi.fn(), warn: vi.fn() })); -import { adminOnly, data, execute } from '../../src/commands/warn.js'; +import { data, execute, moderatorOnly } from '../../src/commands/warn.js'; import { checkEscalation, checkHierarchy, @@ -37,6 +45,7 @@ import { sendDmNotification, sendModLogEmbed, } from '../../src/modules/moderation.js'; +import { createWarning } from '../../src/modules/warningEngine.js'; describe('warn command', () => { afterEach(() => { @@ -54,6 +63,7 @@ describe('warn command', () => { getMember: vi.fn().mockReturnValue(mockMember), getString: vi.fn().mockImplementation((name) => { if (name === 'reason') return 'test reason'; + if (name === 'severity') return 'low'; return null; }), }, @@ -62,6 +72,7 @@ describe('warn command', () => { name: 'Test Server', members: { me: { roles: { highest: { position: 10 } } } }, }, + guildId: 'guild1', member: { roles: { highest: { position: 10 } } }, user: { id: 'mod1', tag: 'Mod#0001' }, client: { user: { id: 'bot1', tag: 'Bot#0001' } }, @@ -75,11 +86,17 @@ describe('warn command', () => { expect(data.name).toBe('warn'); }); - it('should export adminOnly as true', () => { - expect(adminOnly).toBe(true); + it('should export moderatorOnly as true (not adminOnly)', () => { + expect(moderatorOnly).toBe(true); }); - it('should warn a user successfully', async () => { + it('should have severity option', () => { + const severityOption = data.options.find((o) => o.name === 'severity'); + expect(severityOption).toBeDefined(); + expect(severityOption.choices).toHaveLength(3); + }); + + it('should warn a user and create warning record', async () => { const interaction = createInteraction(); await execute(interaction); @@ -94,17 +111,18 @@ describe('warn command', () => { targetTag: 'User#0001', }), ); - expect(sendModLogEmbed).toHaveBeenCalled(); - expect(checkEscalation).toHaveBeenCalledWith( - interaction.client, + expect(createWarning).toHaveBeenCalledWith( 'guild1', - 'user1', - 'bot1', - 'Bot#0001', expect.objectContaining({ - moderation: expect.any(Object), + userId: 'user1', + moderatorId: 'mod1', + severity: 'low', + caseId: 1, }), + expect.any(Object), ); + expect(sendModLogEmbed).toHaveBeenCalled(); + expect(checkEscalation).toHaveBeenCalled(); expect(interaction.editReply).toHaveBeenCalledWith(expect.stringContaining('has been warned')); }); diff --git a/tests/commands/warnings.test.js b/tests/commands/warnings.test.js new file mode 100644 index 00000000..9f02803a --- /dev/null +++ b/tests/commands/warnings.test.js @@ -0,0 +1,94 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('../../src/utils/safeSend.js', () => ({ + safeSend: (ch, opts) => ch.send(opts), + safeReply: (t, opts) => t.reply(opts), + safeFollowUp: (t, opts) => t.followUp(opts), + safeEditReply: (t, opts) => t.editReply(opts), +})); + +vi.mock('../../src/modules/warningEngine.js', () => ({ + getWarnings: vi.fn().mockResolvedValue([]), + getActiveWarningStats: vi.fn().mockResolvedValue({ count: 0, points: 0 }), +})); + +vi.mock('../../src/logger.js', () => ({ info: vi.fn(), error: vi.fn(), warn: vi.fn() })); + +import { data, execute, moderatorOnly } from '../../src/commands/warnings.js'; +import { getActiveWarningStats, getWarnings } from '../../src/modules/warningEngine.js'; + +describe('warnings command', () => { + afterEach(() => vi.clearAllMocks()); + + const createInteraction = () => ({ + options: { + getUser: vi.fn().mockReturnValue({ + id: 'user1', + tag: 'User#0001', + displayAvatarURL: () => 'https://cdn.example.com/avatar.png', + }), + getBoolean: vi.fn().mockReturnValue(false), + getInteger: vi.fn().mockReturnValue(null), + }, + guild: { id: 'guild1' }, + guildId: 'guild1', + user: { id: 'mod1', tag: 'Mod#0001' }, + deferReply: vi.fn().mockResolvedValue(undefined), + editReply: vi.fn().mockResolvedValue(undefined), + }); + + it('should export data with name "warnings"', () => { + expect(data.name).toBe('warnings'); + }); + + it('should export moderatorOnly as true', () => { + expect(moderatorOnly).toBe(true); + }); + + it('should show no warnings message when empty', async () => { + const interaction = createInteraction(); + await execute(interaction); + expect(interaction.editReply).toHaveBeenCalledWith( + expect.stringContaining('No warnings found'), + ); + }); + + it('should display warnings as embed', async () => { + getWarnings.mockResolvedValueOnce([ + { + id: 1, + severity: 'low', + points: 1, + active: true, + reason: 'spam', + created_at: new Date(), + case_id: 5, + }, + { + id: 2, + severity: 'high', + points: 3, + active: false, + reason: 'toxic', + created_at: new Date(), + case_id: null, + removal_reason: 'Expired', + }, + ]); + getActiveWarningStats.mockResolvedValueOnce({ count: 1, points: 1 }); + + const interaction = createInteraction(); + await execute(interaction); + + expect(interaction.editReply).toHaveBeenCalledWith( + expect.objectContaining({ embeds: expect.any(Array) }), + ); + }); + + it('should handle errors gracefully', async () => { + getWarnings.mockRejectedValueOnce(new Error('DB error')); + const interaction = createInteraction(); + await execute(interaction); + expect(interaction.editReply).toHaveBeenCalledWith(expect.stringContaining('Failed to fetch')); + }); +}); diff --git a/tests/modules/warningEngine.test.js b/tests/modules/warningEngine.test.js new file mode 100644 index 00000000..e0035d09 --- /dev/null +++ b/tests/modules/warningEngine.test.js @@ -0,0 +1,454 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('../../src/db.js', () => ({ + getPool: vi.fn(), +})); + +vi.mock('../../src/logger.js', () => ({ + info: vi.fn(), + error: vi.fn(), + warn: vi.fn(), +})); + +import { getPool } from '../../src/db.js'; +import { + calculateExpiry, + clearWarnings, + createWarning, + editWarning, + getActiveWarningStats, + getSeverityPoints, + getWarnings, + processExpiredWarnings, + removeWarning, + startWarningExpiryScheduler, + stopWarningExpiryScheduler, +} from '../../src/modules/warningEngine.js'; + +describe('warningEngine module', () => { + let mockPool; + + beforeEach(() => { + vi.clearAllMocks(); + + mockPool = { + query: vi.fn(), + }; + + getPool.mockReturnValue(mockPool); + }); + + afterEach(() => { + stopWarningExpiryScheduler(); + vi.useRealTimers(); + }); + + // ── getSeverityPoints ─────────────────────────────────────────────── + describe('getSeverityPoints', () => { + it('should return default points when no config provided', () => { + expect(getSeverityPoints(null, 'low')).toBe(1); + expect(getSeverityPoints(null, 'medium')).toBe(2); + expect(getSeverityPoints(null, 'high')).toBe(3); + }); + + it('should return config-overridden points', () => { + const config = { + moderation: { + warnings: { + severityPoints: { low: 2, medium: 4, high: 6 }, + }, + }, + }; + expect(getSeverityPoints(config, 'low')).toBe(2); + expect(getSeverityPoints(config, 'medium')).toBe(4); + expect(getSeverityPoints(config, 'high')).toBe(6); + }); + + it('should fallback to 1 for unknown severity', () => { + expect(getSeverityPoints(null, 'unknown')).toBe(1); + }); + }); + + // ── calculateExpiry ───────────────────────────────────────────────── + describe('calculateExpiry', () => { + it('should return null when no expiryDays configured', () => { + expect(calculateExpiry(null)).toBeNull(); + expect(calculateExpiry({})).toBeNull(); + expect(calculateExpiry({ moderation: {} })).toBeNull(); + }); + + it('should return null when expiryDays is 0 or negative', () => { + expect(calculateExpiry({ moderation: { warnings: { expiryDays: 0 } } })).toBeNull(); + expect(calculateExpiry({ moderation: { warnings: { expiryDays: -1 } } })).toBeNull(); + }); + + it('should return a future date when expiryDays is positive', () => { + const config = { moderation: { warnings: { expiryDays: 90 } } }; + const result = calculateExpiry(config); + expect(result).toBeInstanceOf(Date); + expect(result.getTime()).toBeGreaterThan(Date.now()); + + // Should be roughly 90 days from now (within 2 hour tolerance for DST) + const expectedMs = Date.now() + 90 * 24 * 60 * 60 * 1000; + expect(Math.abs(result.getTime() - expectedMs)).toBeLessThan(2 * 60 * 60 * 1000); + }); + }); + + // ── createWarning ─────────────────────────────────────────────────── + describe('createWarning', () => { + it('should insert a warning and return it', async () => { + const mockWarning = { + id: 1, + guild_id: 'guild1', + user_id: 'user1', + moderator_id: 'mod1', + moderator_tag: 'Mod#0001', + reason: 'test reason', + severity: 'low', + points: 1, + active: true, + expires_at: null, + case_id: 5, + created_at: new Date(), + }; + + mockPool.query.mockResolvedValueOnce({ rows: [mockWarning] }); + + const result = await createWarning('guild1', { + userId: 'user1', + moderatorId: 'mod1', + moderatorTag: 'Mod#0001', + reason: 'test reason', + severity: 'low', + caseId: 5, + }); + + expect(result).toEqual(mockWarning); + expect(mockPool.query).toHaveBeenCalledWith( + expect.stringContaining('INSERT INTO warnings'), + expect.arrayContaining(['guild1', 'user1', 'mod1', 'Mod#0001', 'test reason', 'low', 1]), + ); + }); + + it('should use config severity points', async () => { + const config = { + moderation: { warnings: { severityPoints: { high: 10 }, expiryDays: 30 } }, + }; + + mockPool.query.mockResolvedValueOnce({ + rows: [{ id: 2, points: 10 }], + }); + + await createWarning( + 'guild1', + { + userId: 'user1', + moderatorId: 'mod1', + moderatorTag: 'Mod#0001', + severity: 'high', + }, + config, + ); + + // Points should be 10 (from config) + const queryArgs = mockPool.query.mock.calls[0][1]; + expect(queryArgs[6]).toBe(10); // points param + expect(queryArgs[7]).toBeInstanceOf(Date); // expires_at param + }); + + it('should default severity to low when not specified', async () => { + mockPool.query.mockResolvedValueOnce({ rows: [{ id: 3, severity: 'low', points: 1 }] }); + + await createWarning('guild1', { + userId: 'user1', + moderatorId: 'mod1', + moderatorTag: 'Mod#0001', + }); + + const queryArgs = mockPool.query.mock.calls[0][1]; + expect(queryArgs[5]).toBe('low'); // severity + expect(queryArgs[6]).toBe(1); // points + }); + }); + + // ── getWarnings ───────────────────────────────────────────────────── + describe('getWarnings', () => { + it('should return all warnings for a user', async () => { + const mockWarnings = [ + { id: 1, active: true }, + { id: 2, active: false }, + ]; + mockPool.query.mockResolvedValueOnce({ rows: mockWarnings }); + + const result = await getWarnings('guild1', 'user1'); + + expect(result).toEqual(mockWarnings); + expect(mockPool.query).toHaveBeenCalledWith( + expect.stringContaining('FROM warnings'), + expect.arrayContaining(['guild1', 'user1']), + ); + }); + + it('should filter active only when requested', async () => { + mockPool.query.mockResolvedValueOnce({ rows: [{ id: 1, active: true }] }); + + await getWarnings('guild1', 'user1', { activeOnly: true }); + + const query = mockPool.query.mock.calls[0][0]; + expect(query).toContain('active = TRUE'); + }); + + it('should respect limit option', async () => { + mockPool.query.mockResolvedValueOnce({ rows: [] }); + + await getWarnings('guild1', 'user1', { limit: 10 }); + + const queryArgs = mockPool.query.mock.calls[0][1]; + // limit is second-to-last, offset is last + expect(queryArgs[queryArgs.length - 2]).toBe(10); + expect(queryArgs[queryArgs.length - 1]).toBe(0); // default offset + }); + + it('should respect offset option', async () => { + mockPool.query.mockResolvedValueOnce({ rows: [] }); + + await getWarnings('guild1', 'user1', { limit: 10, offset: 20 }); + + const queryArgs = mockPool.query.mock.calls[0][1]; + expect(queryArgs[queryArgs.length - 2]).toBe(10); + expect(queryArgs[queryArgs.length - 1]).toBe(20); + }); + }); + + // ── getActiveWarningStats ─────────────────────────────────────────── + describe('getActiveWarningStats', () => { + it('should return count and points for active warnings', async () => { + mockPool.query.mockResolvedValueOnce({ + rows: [{ count: 3, points: 7 }], + }); + + const result = await getActiveWarningStats('guild1', 'user1'); + + expect(result).toEqual({ count: 3, points: 7 }); + }); + + it('should return zeros when no active warnings', async () => { + mockPool.query.mockResolvedValueOnce({ + rows: [{ count: 0, points: 0 }], + }); + + const result = await getActiveWarningStats('guild1', 'user1'); + + expect(result).toEqual({ count: 0, points: 0 }); + }); + + it('should handle empty result', async () => { + mockPool.query.mockResolvedValueOnce({ rows: [] }); + + const result = await getActiveWarningStats('guild1', 'user1'); + + expect(result).toEqual({ count: 0, points: 0 }); + }); + }); + + // ── editWarning ───────────────────────────────────────────────────── + describe('editWarning', () => { + it('should update reason', async () => { + // First query: SELECT for audit trail + mockPool.query.mockResolvedValueOnce({ + rows: [{ reason: 'old reason', severity: 'low', points: 1 }], + }); + // Second query: UPDATE + mockPool.query.mockResolvedValueOnce({ + rows: [{ id: 1, reason: 'updated reason' }], + }); + + const result = await editWarning('guild1', 1, { reason: 'updated reason' }); + + expect(result).toEqual({ id: 1, reason: 'updated reason' }); + const updateQuery = mockPool.query.mock.calls[1][0]; + expect(updateQuery).toContain('reason ='); + }); + + it('should update severity and recalculate points', async () => { + const config = { + moderation: { warnings: { severityPoints: { high: 5 } } }, + }; + + // First query: SELECT for audit trail + mockPool.query.mockResolvedValueOnce({ + rows: [{ reason: 'original', severity: 'low', points: 1 }], + }); + // Second query: UPDATE + mockPool.query.mockResolvedValueOnce({ + rows: [{ id: 1, severity: 'high', points: 5 }], + }); + + const result = await editWarning('guild1', 1, { severity: 'high' }, config); + + expect(result).toEqual({ id: 1, severity: 'high', points: 5 }); + const updateArgs = mockPool.query.mock.calls[1][1]; + // severity and points should both be in the params + expect(updateArgs).toContain('high'); + expect(updateArgs).toContain(5); + }); + + it('should return null when warning not found', async () => { + // SELECT returns original for audit + mockPool.query.mockResolvedValueOnce({ rows: [] }); + // UPDATE returns nothing + mockPool.query.mockResolvedValueOnce({ rows: [] }); + + const result = await editWarning('guild1', 999, { reason: 'test' }); + + expect(result).toBeNull(); + }); + + it('should update severity only', async () => { + const config = { + moderation: { warnings: { severityPoints: { medium: 2 } } }, + }; + + mockPool.query.mockResolvedValueOnce({ + rows: [{ reason: 'test', severity: 'low', points: 1 }], + }); + mockPool.query.mockResolvedValueOnce({ + rows: [{ id: 1, severity: 'medium', points: 2 }], + }); + + const result = await editWarning('guild1', 1, { severity: 'medium' }, config); + + expect(result).toEqual({ id: 1, severity: 'medium', points: 2 }); + }); + }); + + // ── removeWarning ─────────────────────────────────────────────────── + describe('removeWarning', () => { + it('should deactivate a warning', async () => { + mockPool.query.mockResolvedValueOnce({ + rows: [{ id: 1, active: false, removed_by: 'mod1', user_id: 'user1' }], + }); + + const result = await removeWarning('guild1', 1, 'mod1', 'pardoned'); + + expect(result).toEqual(expect.objectContaining({ id: 1, removed_by: 'mod1' })); + const query = mockPool.query.mock.calls[0][0]; + expect(query).toContain('active = FALSE'); + expect(query).toContain('AND active = TRUE'); + }); + + it('should return null when warning not found or already inactive', async () => { + mockPool.query.mockResolvedValueOnce({ rows: [] }); + + const result = await removeWarning('guild1', 999, 'mod1'); + + expect(result).toBeNull(); + }); + }); + + // ── clearWarnings ─────────────────────────────────────────────────── + describe('clearWarnings', () => { + it('should deactivate all active warnings for a user', async () => { + mockPool.query.mockResolvedValueOnce({ rowCount: 3 }); + + const result = await clearWarnings('guild1', 'user1', 'mod1', 'clean slate'); + + expect(result).toBe(3); + const query = mockPool.query.mock.calls[0][0]; + expect(query).toContain('active = FALSE'); + expect(query).toContain('AND active = TRUE'); + }); + + it('should return 0 when no active warnings exist', async () => { + mockPool.query.mockResolvedValueOnce({ rowCount: 0 }); + + const result = await clearWarnings('guild1', 'user1', 'mod1'); + + expect(result).toBe(0); + }); + + it('should use default reason when none provided', async () => { + mockPool.query.mockResolvedValueOnce({ rowCount: 1 }); + + await clearWarnings('guild1', 'user1', 'mod1'); + + const queryArgs = mockPool.query.mock.calls[0][1]; + expect(queryArgs[1]).toBe('Bulk clear'); + }); + }); + + // ── processExpiredWarnings ────────────────────────────────────────── + describe('processExpiredWarnings', () => { + it('should deactivate expired warnings', async () => { + mockPool.query.mockResolvedValueOnce({ rowCount: 5 }); + + const result = await processExpiredWarnings(); + + expect(result).toBe(5); + const query = mockPool.query.mock.calls[0][0]; + expect(query).toContain('active = FALSE'); + expect(query).toContain('expires_at <= NOW()'); + }); + + it('should return 0 when no expired warnings', async () => { + mockPool.query.mockResolvedValueOnce({ rowCount: 0 }); + + const result = await processExpiredWarnings(); + + expect(result).toBe(0); + }); + + it('should handle errors gracefully and return 0', async () => { + mockPool.query.mockRejectedValueOnce(new Error('DB error')); + + const result = await processExpiredWarnings(); + + expect(result).toBe(0); + }); + }); + + // ── scheduler ────────────────────────────────────────────────────── + describe('warning expiry scheduler', () => { + it('should start and stop without errors', () => { + mockPool.query.mockResolvedValue({ rowCount: 0 }); + + startWarningExpiryScheduler(); + // Starting again should be a no-op + startWarningExpiryScheduler(); + + stopWarningExpiryScheduler(); + // Stopping again should be a no-op + stopWarningExpiryScheduler(); + }); + + it('should run an immediate check on startup', async () => { + mockPool.query.mockResolvedValue({ rowCount: 0 }); + + startWarningExpiryScheduler(); + + // Wait for the initial poll to complete + await vi.waitFor(() => { + expect(mockPool.query).toHaveBeenCalled(); + }); + + stopWarningExpiryScheduler(); + }); + + it('should poll periodically', async () => { + vi.useFakeTimers(); + mockPool.query.mockResolvedValue({ rowCount: 0 }); + + startWarningExpiryScheduler(); + + // Initial call + await vi.advanceTimersByTimeAsync(0); + const initialCalls = mockPool.query.mock.calls.length; + + // Advance to trigger interval + await vi.advanceTimersByTimeAsync(60_000); + expect(mockPool.query.mock.calls.length).toBeGreaterThan(initialCalls); + + stopWarningExpiryScheduler(); + }); + }); +}); diff --git a/web/src/components/dashboard/config-editor.tsx b/web/src/components/dashboard/config-editor.tsx index f39f2101..32e040de 100644 --- a/web/src/components/dashboard/config-editor.tsx +++ b/web/src/components/dashboard/config-editor.tsx @@ -113,11 +113,11 @@ function isGuildConfig(data: unknown): data is GuildConfig { } /** - * Edit a guild's bot configuration through a multi-section UI. + * Render the configuration editor UI for the currently selected guild. * - * Loads the authoritative config for the selected guild, maintains a mutable draft for user edits, - * computes and applies per-section patches to persist changes, and provides controls to save, - * discard, and validate edits (including an unsaved-changes warning and keyboard shortcut). + * Manages loading the authoritative guild config, keeping a mutable draft for user edits, + * tracking and validating unsaved changes, and exposing controls to preview, save, discard, + * and undo edits across top-level configuration sections. * * @returns The editor UI as JSX when a guild is selected and a draft config exists; `null` otherwise. */ @@ -608,6 +608,24 @@ export function ConfigEditor() { [updateDraftConfig], ); + const updateEscalationThresholds = useCallback( + ( + thresholds: Array<{ warns: number; withinDays: number; action: string; duration?: string }>, + ) => { + updateDraftConfig((prev) => { + if (!prev) return prev; + return { + ...prev, + moderation: { + ...prev.moderation, + escalation: { ...prev.moderation?.escalation, thresholds }, + }, + } as GuildConfig; + }); + }, + [updateDraftConfig], + ); + const updateAiAutoModField = useCallback( (field: string, value: unknown) => { updateDraftConfig((prev) => { @@ -706,6 +724,28 @@ export function ConfigEditor() { [updateDraftConfig], ); + const updateWarningsField = useCallback( + (field: string, value: unknown) => { + updateDraftConfig((prev) => { + if (!prev) return prev; + const existingWarnings = prev.moderation?.warnings ?? { + expiryDays: 90, + severityPoints: { low: 1, medium: 2, high: 3 }, + dmNotification: true, + maxPerPage: 10, + }; + return { + ...prev, + moderation: { + ...prev.moderation, + warnings: { ...existingWarnings, [field]: value }, + }, + } as GuildConfig; + }); + }, + [updateDraftConfig], + ); + const updatePermissionsField = useCallback( (field: string, value: unknown) => { updateDraftConfig((prev) => { @@ -1150,15 +1190,170 @@ export function ConfigEditor() { ))} -
- Escalation Enabled - updateModerationEscalation(v)} - disabled={saving} - label="Escalation" - /> -
+ {/* Escalation sub-section */} +
+ Escalation +
+ Enabled + updateModerationEscalation(v)} + disabled={saving} + label="Escalation" + /> +
+ {(draftConfig.moderation?.escalation?.enabled ?? false) && + (() => { + const thresholds = [ + ...((draftConfig.moderation?.escalation?.thresholds as Array<{ + warns: number; + withinDays: number; + action: string; + duration?: string; + }>) ?? []), + ]; + return ( +
+ + Auto-escalation thresholds — when a user hits a warn count within a time + window, the bot takes action automatically. + + {thresholds.map((threshold, idx) => { + return ( +
+
+ + Rule {idx + 1} + + +
+
+ + + + {threshold.action === 'timeout' && ( + + )} +
+
+ ); + })} + +
+ ); + })()} +
{/* Rate Limiting sub-section */}
@@ -1338,6 +1533,81 @@ export function ConfigEditor() { />
+ + {/* Warning System sub-section */} +
+ Warning System +
+ + +
+
+ Severity Points +
+ {(['low', 'medium', 'high'] as const).map((level) => ( + + ))} +
+
+
)} diff --git a/web/src/components/dashboard/config-sections/ModerationSection.tsx b/web/src/components/dashboard/config-sections/ModerationSection.tsx index 22ef099d..86764362 100644 --- a/web/src/components/dashboard/config-sections/ModerationSection.tsx +++ b/web/src/components/dashboard/config-sections/ModerationSection.tsx @@ -2,6 +2,7 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; import { ChannelSelector } from '@/components/ui/channel-selector'; +import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; import { RoleSelector } from '@/components/ui/role-selector'; import { Switch } from '@/components/ui/switch'; @@ -16,18 +17,20 @@ interface ModerationSectionProps { onDmNotificationChange: (action: string, value: boolean) => void; onEscalationChange: (enabled: boolean) => void; onProtectRolesChange: (field: string, value: unknown) => void; + onWarningsChange?: (field: string, value: unknown) => void; } /** - * Render the Moderation settings section, including alert channel selection, auto-delete, - * DM notification toggles, and escalation controls. + * Render the Moderation settings card with controls for alert channel, auto-delete, DM notifications, escalation, protected roles, and the warning system. * - * @param draftConfig - The current draft guild configuration containing moderation settings. - * @param saving - Whether a save operation is in progress; when true, interactive controls are disabled. - * @param onEnabledChange - Callback invoked with the new enabled state when moderation is toggled. - * @param onFieldChange - Generic field update callback, called with field name and new value (e.g., 'alertChannelId', 'autoDelete'). - * @param onDmNotificationChange - Callback invoked with an action ('warn' | 'timeout' | 'kick' | 'ban') and boolean to toggle DM notifications for that action. - * @param onEscalationChange - Callback invoked with the new escalation enabled state. + * @param draftConfig - Current draft guild configuration containing moderation settings. + * @param saving - When true, interactive controls are disabled while a save is in progress. + * @param onEnabledChange - Called with the new moderation enabled state. + * @param onFieldChange - Generic field updater called with a field name (e.g., 'alertChannelId', 'autoDelete') and its new value. + * @param onDmNotificationChange - Called with an action ('warn' | 'timeout' | 'kick' | 'ban') and a boolean to toggle DM notifications for that action. + * @param onEscalationChange - Called with the new escalation enabled state. + * @param onProtectRolesChange - Field updater for protect-roles settings (fields include 'enabled', 'includeAdmins', 'includeModerators', 'includeServerOwner', 'roleIds'). + * @param onWarningsChange - Optional field updater for warning-system settings (fields include 'dmNotification', 'expiryDays', 'maxPerPage', 'severityPoints'). * @returns The rendered moderation Card element, or `null` if `draftConfig.moderation` is not present. */ export function ModerationSection({ @@ -38,6 +41,7 @@ export function ModerationSection({ onDmNotificationChange, onEscalationChange, onProtectRolesChange, + onWarningsChange, }: ModerationSectionProps) { const guildId = useGuildSelection(); if (!draftConfig.moderation) return null; @@ -196,6 +200,81 @@ export function ModerationSection({ )} + {/* Warning System Settings */} +
+ Warning System +
+
+ + { + const val = parseInt(e.target.value, 10); + onWarningsChange?.('expiryDays', Number.isNaN(val) || val <= 0 ? null : val); + }} + disabled={saving} + /> +
+
+ + { + const val = Math.max(1, Math.min(25, parseInt(e.target.value, 10) || 10)); + onWarningsChange?.('maxPerPage', val); + }} + disabled={saving} + /> +
+
+
+ +
+ {(['low', 'medium', 'high'] as const).map((level) => ( +
+ + { + const val = Math.max(1, parseInt(e.target.value, 10) || 1); + const current = draftConfig.moderation?.warnings?.severityPoints ?? { + low: 1, + medium: 2, + high: 3, + }; + onWarningsChange?.('severityPoints', { ...current, [level]: val }); + }} + disabled={saving} + /> +
+ ))} +
+
+
); diff --git a/web/src/types/config.ts b/web/src/types/config.ts index f943db8d..9297aeeb 100644 --- a/web/src/types/config.ts +++ b/web/src/types/config.ts @@ -147,6 +147,20 @@ export interface ModerationProtectRoles { includeServerOwner: boolean; } +/** Warning system severity point overrides. */ +export interface WarningSeverityPoints { + low: number; + medium: number; + high: number; +} + +/** Warning system configuration. */ +export interface WarningsConfig { + expiryDays: number | null; + severityPoints: WarningSeverityPoints; + maxPerPage: number; +} + /** Moderation configuration. */ export interface ModerationConfig { enabled: boolean; @@ -158,6 +172,7 @@ export interface ModerationConfig { protectRoles?: ModerationProtectRoles; rateLimit?: RateLimitConfig; linkFilter?: LinkFilterConfig; + warnings?: WarningsConfig; } /** Starboard configuration. */