Skip to content

feat(moderation): comprehensive warning system with severity, decay, and expiry#251

Merged
BillChirico merged 6 commits intomainfrom
feat/issue-250-warning-system
Mar 7, 2026
Merged

feat(moderation): comprehensive warning system with severity, decay, and expiry#251
BillChirico merged 6 commits intomainfrom
feat/issue-250-warning-system

Conversation

@BillChirico
Copy link
Collaborator

Summary

Comprehensive warning system for moderators and admins — full lifecycle management with severity levels, point-based escalation, automatic expiry/decay, and audit trail.

Closes #250

What changed

New Commands (all moderator-level)

Command Description
/warn Updated: now moderator-only (was admin-only), added severity option (low/medium/high)
/warnings <user> View paginated warning history with active/expired status
/editwarn <id> [reason] [severity] Edit a warning's reason or severity (audit trail preserved)
/removewarn <id> [reason] Soft-delete a warning (deactivates, doesn't destroy)
/clearwarnings <user> [reason] Bulk deactivate all active warnings for a user

Warning Engine (src/modules/warningEngine.js)

  • Severity-based point system (configurable: low=1, medium=2, high=3)
  • Automatic expiry after configurable days (default: 90)
  • Background scheduler polls every 60s for expired warnings
  • All operations return structured data for embed formatting

Database Migration (011_warnings.cjs)

  • New warnings table with severity, points, expiry, soft-delete support
  • Indexed for fast guild+user+active queries
  • Backward-compatible (existing mod_cases unchanged)

Escalation Updates (src/modules/moderation.js)

  • checkEscalation() now only counts active warnings (excludes expired/removed)

API Routes (src/api/routes/warnings.js)

  • GET /api/v1/guilds/:guildId/warnings — list warnings (paginated, filterable)
  • GET /api/v1/guilds/:guildId/warnings/user/:userId — user-specific warnings
  • GET /api/v1/guilds/:guildId/warnings/stats — guild warning statistics

Config Defaults

{
  "moderation": {
    "warnings": {
      "expiryDays": 90,
      "severityPoints": { "low": 1, "medium": 2, "high": 3 }
    }
  }
}

Tests

  • tests/modules/warningEngine.test.js — 29 tests (CRUD, expiry, scheduler, severity points)
  • tests/commands/warn.test.js — updated for moderatorOnly + severity
  • tests/commands/warnings.test.js — history display + error handling
  • tests/commands/editwarn.test.js — edit flow + validation
  • tests/commands/removewarn.test.js — soft-delete flow
  • tests/commands/clearwarnings.test.js — bulk clear flow

Validation

  • pnpm lint
  • pnpm test ✅ (3685 passed, 1 pre-existing failure in server.test.js)

…and expiry

- Add warnings table migration (011) with severity, points, expiry
- Create warningEngine module: create/edit/remove/clear/expire warnings
- Update /warn command: moderatorOnly, add severity option, link to warnings table
- Add /warnings command: view user warnings with active/expired status
- Add /editwarn command: edit warning reason/severity
- Add /removewarn command: deactivate specific warning
- Add /clearwarnings command: bulk deactivate all active warnings
- Update escalation to count only active warnings from warnings table
- Add warning expiry scheduler (60s poll for expired warnings)
- Add API routes: GET /warnings, /warnings/user/:userId, /warnings/stats
- Add config defaults: warnings.expiryDays=90, severityPoints
- Update permission defaults: all warning commands as moderator level
- Add comprehensive tests for warningEngine, all commands

Closes #250
Copilot AI review requested due to automatic review settings March 7, 2026 00:57
@BillChirico BillChirico added this to the v0.1.0 - "Big Boy MVP" milestone Mar 7, 2026
@BillChirico BillChirico added priority: high High priority type: feature New feature scope: moderation Moderation features labels Mar 7, 2026
@github-project-automation github-project-automation bot moved this to Backlog in Volvox.Bot Mar 7, 2026
@railway-app railway-app bot temporarily deployed to volvox-bot / volvox-bot-pr-251 March 7, 2026 00:57 Destroyed
@railway-app
Copy link

railway-app bot commented Mar 7, 2026

🚅 Deployed to the volvox-bot-pr-251 environment in volvox-bot

Service Status Web Updated (UTC)
docs ◻️ Removed (View Logs) Web Mar 7, 2026 at 2:04 am
web ◻️ Removed (View Logs) Mar 7, 2026 at 2:04 am
bot ◻️ Removed (View Logs) Web Mar 7, 2026 at 2:03 am

Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

router.use('/moderation', requireAuth(), auditLogMiddleware(), moderationRouter);

// Warning routes — require API secret or OAuth2 JWT
router.use('/warnings', requireAuth(), auditLogMiddleware(), warningsRouter);

Check failure

Code scanning / CodeQL

Missing rate limiting High

This route handler performs
authorization
, but is not rate-limited.
This route handler performs
authorization
, but is not rate-limited.

Copilot Autofix

AI 9 days ago

In general, the fix is to introduce a rate-limiting middleware (e.g., using express-rate-limit) and apply it to the affected routes so that even authenticated clients cannot make unbounded numbers of expensive requests. This middleware should be added without altering existing authentication or audit behavior.

The best single approach here is to (1) import express-rate-limit, (2) define a small set of limiter instances (for example, a “public” limiter and an “authenticated” limiter) in this file, and (3) apply the appropriate limiter to the router mounts. Specifically, for the issue CodeQL flagged on the /warnings route, we add the authenticated limiter into the middleware chain directly before requireAuth() or immediately after it. To keep behavior consistent and improve security more broadly, we can also rate-limit other authenticated routes in the same router, and optionally add a lighter limiter to public routes such as /auth and /community. All changes should be made inside src/api/index.js: add the import at the top, define the limiters near the router creation, and then update the router.use(...) lines to include those limiters in the middleware lists, keeping the rest of the logic and ordering intact.

Concretely:

  • At the top of src/api/index.js, add import rateLimit from 'express-rate-limit';.
  • After const router = Router();, define e.g.:
    • const publicLimiter = rateLimit({ windowMs: 60_000, max: 60 });
    • const authLimiter = rateLimit({ windowMs: 60_000, max: 120 });
  • Update router.use('/warnings', requireAuth(), auditLogMiddleware(), warningsRouter); to include authLimiter (e.g., router.use('/warnings', authLimiter, requireAuth(), auditLogMiddleware(), warningsRouter);).
  • Optionally but consistently, apply authLimiter to all the authenticated routes in this file and publicLimiter to public routes like /auth and /community.
Suggested changeset 2
src/api/index.js

Autofix patch

Autofix patch
Run the following command in your local git repository to apply this patch
cat << 'EOF' | git apply
diff --git a/src/api/index.js b/src/api/index.js
--- a/src/api/index.js
+++ b/src/api/index.js
@@ -4,6 +4,7 @@
  */
 
 import { Router } from 'express';
+import rateLimit from 'express-rate-limit';
 import { auditLogMiddleware } from './middleware/auditLog.js';
 import { requireAuth } from './middleware/auth.js';
 import aiFeedbackRouter from './routes/ai-feedback.js';
@@ -27,60 +28,72 @@
 
 const router = Router();
 
+// Public rate limiter (e.g., for unauthenticated endpoints)
+const publicLimiter = rateLimit({
+  windowMs: 60 * 1000, // 1 minute
+  max: 60, // 60 requests per minute per IP
+});
+
+// Authenticated rate limiter (for endpoints behind requireAuth)
+const authLimiter = rateLimit({
+  windowMs: 60 * 1000, // 1 minute
+  max: 120, // 120 requests per minute per IP/token
+});
+
 // Health check — public (no auth required)
 router.use('/health', healthRouter);
 
 // Community routes — public (no auth required, rate-limited)
-router.use('/community', communityRouter);
+router.use('/community', publicLimiter, communityRouter);
 
 // Auth routes — public (no auth required)
-router.use('/auth', authRouter);
+router.use('/auth', publicLimiter, authRouter);
 
 // Global config routes — require API secret or OAuth2 JWT
-router.use('/config', requireAuth(), auditLogMiddleware(), configRouter);
+router.use('/config', authLimiter, requireAuth(), auditLogMiddleware(), configRouter);
 
 // Member management routes — require API secret or OAuth2 JWT
 // (mounted before guilds to handle /:id/members/* before the basic guilds endpoint)
-router.use('/guilds', requireAuth(), auditLogMiddleware(), membersRouter);
+router.use('/guilds', authLimiter, requireAuth(), auditLogMiddleware(), membersRouter);
 
 // AI Feedback routes — require API secret or OAuth2 JWT
-router.use('/guilds/:id/ai-feedback', requireAuth(), auditLogMiddleware(), aiFeedbackRouter);
+router.use('/guilds/:id/ai-feedback', authLimiter, requireAuth(), auditLogMiddleware(), aiFeedbackRouter);
 
 // Conversation routes — require API secret or OAuth2 JWT
 // (mounted before guilds to handle /:id/conversations/* before the catch-all guild endpoint)
-router.use('/guilds/:id/conversations', requireAuth(), auditLogMiddleware(), conversationsRouter);
+router.use('/guilds/:id/conversations', authLimiter, requireAuth(), auditLogMiddleware(), conversationsRouter);
 
 // Ticket routes — require API secret or OAuth2 JWT
 // (mounted before guilds to handle /:id/tickets/* before the catch-all guild endpoint)
-router.use('/guilds', requireAuth(), auditLogMiddleware(), ticketsRouter);
+router.use('/guilds', authLimiter, requireAuth(), auditLogMiddleware(), ticketsRouter);
 
 // Guild routes — require API secret or OAuth2 JWT
-router.use('/guilds', requireAuth(), auditLogMiddleware(), guildsRouter);
+router.use('/guilds', authLimiter, requireAuth(), auditLogMiddleware(), guildsRouter);
 
 // Moderation routes — require API secret or OAuth2 JWT
-router.use('/moderation', requireAuth(), auditLogMiddleware(), moderationRouter);
+router.use('/moderation', authLimiter, requireAuth(), auditLogMiddleware(), moderationRouter);
 
 // Warning routes — require API secret or OAuth2 JWT
-router.use('/warnings', requireAuth(), auditLogMiddleware(), warningsRouter);
+router.use('/warnings', authLimiter, requireAuth(), auditLogMiddleware(), warningsRouter);
 // Temp role routes — require API secret or OAuth2 JWT
-router.use('/temp-roles', requireAuth(), auditLogMiddleware(), tempRolesRouter);
+router.use('/temp-roles', authLimiter, requireAuth(), auditLogMiddleware(), tempRolesRouter);
 
 // Audit log routes — require API secret or OAuth2 JWT
 // GET-only; no audit middleware needed (reads are not mutating actions)
-router.use('/guilds', requireAuth(), auditLogRouter);
+router.use('/guilds', authLimiter, requireAuth(), auditLogRouter);
 
 // Welcome routes — require API secret or OAuth2 JWT
-router.use('/guilds/:id/welcome', requireAuth(), auditLogMiddleware(), welcomeRouter);
+router.use('/guilds/:id/welcome', authLimiter, requireAuth(), auditLogMiddleware(), welcomeRouter);
 
 // Performance metrics — require x-api-secret (authenticated via route handler)
-router.use('/performance', performanceRouter);
+router.use('/performance', publicLimiter, performanceRouter);
 
 // Notification webhook management routes — require API secret or OAuth2 JWT
-router.use('/guilds', requireAuth(), auditLogMiddleware(), notificationsRouter);
+router.use('/guilds', authLimiter, requireAuth(), auditLogMiddleware(), notificationsRouter);
 // Webhook routes — require API secret or OAuth2 JWT (endpoint further restricts to api-secret)
-router.use('/webhooks', requireAuth(), webhooksRouter);
+router.use('/webhooks', authLimiter, requireAuth(), webhooksRouter);
 
 // Backup routes — require API secret or OAuth2 JWT
-router.use('/backups', requireAuth(), auditLogMiddleware(), backupRouter);
+router.use('/backups', authLimiter, requireAuth(), auditLogMiddleware(), backupRouter);
 
 export default router;
EOF
package.json
Outside changed files

Autofix patch

Autofix patch
Run the following command in your local git repository to apply this patch
cat << 'EOF' | git apply
diff --git a/package.json b/package.json
--- a/package.json
+++ b/package.json
@@ -47,7 +47,8 @@
     "winston": "^3.19.0",
     "winston-daily-rotate-file": "^5.0.0",
     "winston-transport": "^4.9.0",
-    "ws": "^8.19.0"
+    "ws": "^8.19.0",
+    "express-rate-limit": "^8.3.0"
   },
   "pnpm": {
     "overrides": {
EOF
@@ -47,7 +47,8 @@
"winston": "^3.19.0",
"winston-daily-rotate-file": "^5.0.0",
"winston-transport": "^4.9.0",
"ws": "^8.19.0"
"ws": "^8.19.0",
"express-rate-limit": "^8.3.0"
},
"pnpm": {
"overrides": {
This fix introduces these dependencies
Package Version Security advisories
express-rate-limit (npm) 8.3.0 None
Copilot is powered by AI and may make mistakes. Always verify output.

// Apply rate limiter and guild-scoped authorization
router.use(warningsRateLimit);
router.use(adaptGuildIdParam, requireGuildModerator);

Check failure

Code scanning / CodeQL

Missing rate limiting High

This route handler performs
authorization
, but is not rate-limited.

Copilot Autofix

AI 9 days ago

Copilot could not generate an autofix suggestion

Copilot could not generate an autofix suggestion for this alert. Try pushing a new commit or if the problem persists contact support.

Comment on lines +88 to +150
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' });
}
});

Check failure

Code scanning / CodeQL

Missing rate limiting High

This route handler performs
a database access
, but is not rate-limited.
This route handler performs
a database access
, but is not rate-limited.

Copilot Autofix

AI 9 days ago

Copilot could not generate an autofix suggestion

Copilot could not generate an autofix suggestion for this alert. Try pushing a new commit or if the problem persists contact support.

Comment on lines +182 to +236
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' });
}
});

Check failure

Code scanning / CodeQL

Missing rate limiting High

This route handler performs
a database access
, but is not rate-limited.
This route handler performs
a database access
, but is not rate-limited.
This route handler performs
a database access
, but is not rate-limited.

Copilot Autofix

AI 9 days ago

To fix the problem, the /warnings/user/:userId route should be protected with the existing warningsRateLimit middleware so that requests to this endpoint are rate-limited before any database access occurs. This aligns with the established pattern in the file (using warningsRateLimit for warning endpoints) and doesn’t change the route’s core functionality other than throttling excessive use.

Concretely, in src/api/routes/warnings.js, modify the route definition starting on line 186 from router.get('/user/:userId', async (req, res) => { to include the warningsRateLimit middleware, e.g. router.get('/user/:userId', warningsRateLimit, async (req, res) => {. No new imports are needed because warningsRateLimit is already defined in this file using the imported rateLimit helper. The rest of the handler logic remains unchanged. This single change ensures that all database access within this route is subject to the configured 120-requests-per-15-minutes limit, addressing all three CodeQL alert variants since they all reference database calls within the same handler.

Suggested changeset 1
src/api/routes/warnings.js

Autofix patch

Autofix patch
Run the following command in your local git repository to apply this patch
cat << 'EOF' | git apply
diff --git a/src/api/routes/warnings.js b/src/api/routes/warnings.js
--- a/src/api/routes/warnings.js
+++ b/src/api/routes/warnings.js
@@ -183,7 +183,7 @@
  *       "400":
  *         description: Missing guildId
  */
-router.get('/user/:userId', async (req, res) => {
+router.get('/user/:userId', warningsRateLimit, async (req, res) => {
   const { userId } = req.params;
   const { guildId } = req.query;
 
EOF
@@ -183,7 +183,7 @@
* "400":
* description: Missing guildId
*/
router.get('/user/:userId', async (req, res) => {
router.get('/user/:userId', warningsRateLimit, async (req, res) => {
const { userId } = req.params;
const { guildId } = req.query;

Copilot is powered by AI and may make mistakes. Always verify output.
Comment on lines +261 to +310
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' });
}
});

Check failure

Code scanning / CodeQL

Missing rate limiting High

This route handler performs
a database access
, but is not rate-limited.
This route handler performs
a database access
, but is not rate-limited.
This route handler performs
a database access
, but is not rate-limited.
This route handler performs
a database access
, but is not rate-limited.

Copilot Autofix

AI 9 days ago

To fix the problem, the /warnings/stats route should be protected with the existing warningsRateLimit middleware so that requests to this DB-heavy endpoint are throttled per client, reducing the risk of denial-of-service from excessive requests. Generally, this is done in Express by inserting a rate-limiting middleware function as an argument to router.get before the async handler.

The best minimal fix, without changing existing behavior, is to reuse the already-defined warningsRateLimit constant (line 17) for the /stats route, just like any other warning API route would. Concretely, in src/api/routes/warnings.js at line 265, change router.get('/stats', async (req, res) => { to router.get('/stats', warningsRateLimit, async (req, res) => {. This introduces no new imports, no configuration changes, and does not alter the business logic of the handler; it only adds rate limiting before the handler executes, addressing all four CodeQL alerts tied to the DB queries inside this route.

Suggested changeset 1
src/api/routes/warnings.js

Autofix patch

Autofix patch
Run the following command in your local git repository to apply this patch
cat << 'EOF' | git apply
diff --git a/src/api/routes/warnings.js b/src/api/routes/warnings.js
--- a/src/api/routes/warnings.js
+++ b/src/api/routes/warnings.js
@@ -262,7 +262,7 @@
  *       "200":
  *         description: Warning stats
  */
-router.get('/stats', async (req, res) => {
+router.get('/stats', warningsRateLimit, async (req, res) => {
   const { guildId } = req.query;
 
   if (!guildId) {
EOF
@@ -262,7 +262,7 @@
* "200":
* description: Warning stats
*/
router.get('/stats', async (req, res) => {
router.get('/stats', warningsRateLimit, async (req, res) => {
const { guildId } = req.query;

if (!guildId) {
Copilot is powered by AI and may make mistakes. Always verify output.
@coderabbitai
Copy link
Contributor

coderabbitai bot commented Mar 7, 2026

Caution

Review failed

Pull request was closed or merged during review

📝 Walkthrough

Summary by CodeRabbit

Release Notes

  • New Features

    • Introduced a comprehensive warning management system with commands to warn users, view warnings, edit details, and clear records.
    • Added automatic warning expiry based on configurable time periods.
    • Implemented escalation triggers that activate based on accumulated active warnings.
    • Added warning system configuration options in the dashboard for expiry settings and severity point customization.
  • Tests

    • Added comprehensive test coverage for the warning system.
  • Chores

    • Updated configuration and build tool settings.

Walkthrough

This PR implements a comprehensive warning system for Discord bot moderation, featuring manual warning commands with configurable severity levels, persistent storage, warning lifecycle management (edit/remove/clear), expiration policies, escalation triggers, API endpoints for querying warning history, and dashboard UI controls for configuration.

Changes

Cohort / File(s) Summary
Configuration & Versioning
.gitignore, biome.json, config.json
Adds worktrees/ ignore pattern, updates warning system configuration with severity-to-points mapping and moderator-scoped command permissions.
Database Schema
migrations/011_warnings.cjs
Creates warnings table with fields for guild, user, moderator, reason, severity, points, activity status, and expiry; includes indices for common query patterns and foreign key to mod_cases.
API Routes
src/api/index.js, src/api/routes/warnings.js
Mounts new warnings router with rate limiting; implements GET endpoints for paginated warnings list, user-specific warnings, and aggregated stats with filtering support.
Discord Commands
src/commands/warn.js, src/commands/warnings.js, src/commands/editwarn.js, src/commands/removewarn.js, src/commands/clearwarnings.js
Adds severity option to warn command; introduces new commands for viewing warnings, editing reason/severity, removing individual warnings, and bulk-clearing user warnings; all moderator-only with audit logging.
Core Warning Engine
src/modules/warningEngine.js, src/modules/moderation.js, src/index.js
Implements warning CRUD operations, expiry scheduler, and severity-to-points calculation; updates escalation logic to count active warnings from database; integrates expiry scheduler lifecycle into bot startup/shutdown.
Command Tests
tests/commands/clearwarnings.test.js, tests/commands/editwarn.test.js, tests/commands/removewarn.test.js, tests/commands/warn.test.js, tests/commands/warnings.test.js
Adds comprehensive test coverage for all warning commands, including success paths, edge cases (not-found, no results), and error handling.
Engine Tests
tests/modules/warningEngine.test.js
Covers all warningEngine exports: CRUD operations, expiry processing, scheduler lifecycle, severity point mapping, and edge cases with mocked database pool.
Web Dashboard
web/src/components/dashboard/config-editor.tsx, web/src/components/dashboard/config-sections/ModerationSection.tsx, web/src/types/config.ts
Adds UI controls for warning system configuration (expiryDays, severityPoints, maxPerPage); introduces TypeScript types for WarningSeverityPoints and WarningsConfig; updates escalation threshold editor.

Possibly related PRs

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed Title clearly summarizes the main feature: a comprehensive warning system with severity levels and expiry mechanisms.
Description check ✅ Passed Description is related to the changeset and provides detailed summaries of commands, engine, database changes, escalation updates, API routes, and tests.
Linked Issues check ✅ Passed PR comprehensively implements all core objectives from #250: manual warnings, structured records, per-user history, management actions (edit/remove/clear), escalation counting active warnings, configurable expiry, permission safeguards, and extensive tests.
Out of Scope Changes check ✅ Passed All changes directly support the warning system feature. Minor out-of-scope items: .gitignore worktrees pattern, biome.json exclusion update, and config editor UI enhancements are peripheral to core functionality.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

📋 Issue Planner

Let us write the prompt for your AI agent so you can ship faster (with fewer bugs).

View plan for ticket: #250

✨ Finishing Touches
  • 📝 Generate docstrings (stacked PR)
  • 📝 Generate docstrings (commit on current branch)
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch feat/issue-250-warning-system
  • 🛠️ Publish Changes: Commit on current branch
  • 🛠️ Publish Changes: Create PR

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@coveralls
Copy link

coveralls commented Mar 7, 2026

Coverage Status

coverage: 87.451% (-0.4%) from 87.898%
when pulling 6705986 on feat/issue-250-warning-system
into 308e330 on main.

@coderabbitai
Copy link
Contributor

coderabbitai bot commented Mar 7, 2026

Note

Docstrings generation - SUCCESS
Generated docstrings and committed to branch feat/issue-250-warning-system (commit: d1dffe0a020e2b5c3c74b9f2b88577df6f960954)

Docstrings generation was requested by @BillChirico.

The following files were modified:

* `src/api/routes/warnings.js`
* `src/commands/clearwarnings.js`
* `src/commands/editwarn.js`
* `src/commands/removewarn.js`
* `src/commands/warn.js`
* `src/commands/warnings.js`
* `src/modules/moderation.js`
* `src/modules/warningEngine.js`

These files were kept as they were:
* `src/index.js`

These files were ignored:
* `tests/commands/clearwarnings.test.js`
* `tests/commands/editwarn.test.js`
* `tests/commands/removewarn.test.js`
* `tests/commands/warn.test.js`
* `tests/commands/warnings.test.js`
* `tests/modules/warningEngine.test.js`

These file types are not supported:
* `.gitignore`
* `TASK.md`
* `biome.json`
* `config.json`
@railway-app railway-app bot temporarily deployed to volvox-bot / volvox-bot-pr-251 March 7, 2026 01:01 Destroyed
- Add WarningsConfig type (expiryDays, severityPoints, dmNotification, maxPerPage)
- Add warning system controls to moderation section in config editor
- Wire up updateWarningsField callback in config-editor.tsx
- Warning config saves via existing moderation SAFE_CONFIG_KEYS path
Copilot AI review requested due to automatic review settings March 7, 2026 01:04
@railway-app railway-app bot temporarily deployed to volvox-bot / volvox-bot-pr-251 March 7, 2026 01:04 Destroyed
Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@greptile-apps
Copy link

greptile-apps bot commented Mar 7, 2026

Greptile Summary

This PR introduces a comprehensive warning system with severity levels, point-based escalation, automatic expiry, and a full CRUD command surface — a well-scoped feature that integrates cleanly with the existing moderation pipeline. The migration, scheduler, and API routes are generally well-implemented with proper error handling and parameterized queries throughout.

Key findings:

  • maxPerPage config is a dead settingsrc/commands/warnings.js hardcodes const perPage = 10 instead of reading config.moderation.warnings.maxPerPage. The dashboard exposes this field, but the bot never reads it, so admins who configure it will see no change in behavior.
  • editWarning allows editing inactive warnings — the UPDATE in warningEngine.js has no AND active = TRUE guard, so moderators can silently update removed or expired warnings and receive a success response, which is misleading.
  • severity query param unvalidated in the APIGET /warnings accepts severity from query string without checking it's one of low | medium | high; an invalid value returns 0 results rather than a 400, making API debugging harder.
  • Redundant _severity in warn.js extractOptions_severity is extracted but never consumed; severity is re-read independently inside afterCase.

The core engine, migration, and command flow are solid. Two logic issues need attention before merging: editWarning can silently mutate inactive warnings, and a dashboard-configurable setting (maxPerPage) has no effect on the bot.

Confidence Score: 3/5

  • Safe to merge after fixing the editWarning inactive-warning guard and the maxPerPage config disconnect.
  • Two logic issues prevent a higher score: editWarning can silently mutate removed/expired warnings without validation, and a dashboard-configurable setting (maxPerPage) has no effect on the bot. The API severity validation gap and _severity redundancy are lower priority but worth addressing. Once the two logic fixes are applied, this is safe to merge.
  • src/modules/warningEngine.js (inactive-warning edit guard) and src/commands/warnings.js (hardcoded perPage) need the most attention before merge.

Important Files Changed

Filename Overview
src/modules/warningEngine.js Core warning lifecycle module — well-structured with try/catch on all CRUD functions, but editWarning allows editing inactive warnings due to a missing AND active = TRUE guard in the UPDATE query.
src/commands/warnings.js New /warnings display command — correctly paginated and embedded, but hardcodes perPage = 10 instead of reading config.moderation.warnings.maxPerPage, making the dashboard's per-page setting a dead control.
src/commands/warn.js Updated /warn command — severity, createWarning integration, and escalation wiring look correct; minor redundancy in extracting _severity inside extractOptions that is never consumed downstream.
src/api/routes/warnings.js New warnings API — proper auth, rate limiting, parameterized queries, and error handling; severity query param is not validated against the allowed enum before use, leading to silent empty results for invalid values.
migrations/011_warnings.cjs Clean migration — creates warnings table with appropriate CHECK constraint on severity, three targeted partial indexes for expiry polling, guild/user lookups, and active-only filtering; down safely cascades.
src/modules/moderation.js Escalation updated to count active warnings from the new warnings table with a graceful fallback to mod_cases if the table doesn't exist yet (error code 42P01); well-handled backward compatibility.
web/src/types/config.ts Adds WarningsConfig with expiryDays, severityPoints, and maxPerPage fields; maxPerPage is exposed in the dashboard but never read by the backend bot, making it a misleading dead field.

Sequence Diagram

sequenceDiagram
    participant Mod as Moderator
    participant Bot as Discord Bot
    participant WE as warningEngine.js
    participant DB as PostgreSQL
    participant Sched as ExpiryScheduler

    Mod->>Bot: /warn user reason severity
    Bot->>Bot: executeModAction → createCase()
    Bot->>WE: createWarning(guildId, data, config)
    WE->>DB: INSERT INTO warnings (severity, points, expires_at)
    DB-->>WE: warning row
    WE-->>Bot: warning row
    Bot->>Bot: checkEscalation() — queries active warnings only
    Bot-->>Mod: ✅ User warned (Case #N)

    Mod->>Bot: /editwarn id reason severity
    Bot->>WE: editWarning(guildId, id, updates, config)
    WE->>DB: SELECT for audit trail
    WE->>DB: UPDATE warnings SET reason/severity/points
    DB-->>WE: updated row
    WE-->>Bot: updated warning
    Bot-->>Mod: ✅ Warning #N updated

    Mod->>Bot: /removewarn id reason
    Bot->>WE: removeWarning(guildId, id, removedBy, reason)
    WE->>DB: UPDATE SET active=FALSE, removed_at=NOW() WHERE active=TRUE
    DB-->>WE: deactivated row
    Bot-->>Mod: ✅ Warning #N removed

    Sched->>WE: processExpiredWarnings() [every 60s]
    WE->>DB: UPDATE SET active=FALSE WHERE expires_at <= NOW()
    DB-->>WE: rowCount expired
Loading

Comments Outside Diff (3)

  1. src/commands/warnings.js, line 58 (link)

    perPage is hardcoded to 10 on line 58, but the dashboard exposes a configurable warnings.maxPerPage field (WarningsConfig.maxPerPage in web/src/types/config.ts, editable in config-editor.tsx). Admins who set this value via the dashboard will see no effect because the command never reads it from config.

    This also requires adding a getConfig import at the top of the file. Without this, the dashboard setting is a dead control.

  2. src/modules/warningEngine.js, line 222-228 (link)

    The UPDATE query in editWarning has no AND active = TRUE guard, so a moderator calling /editwarn on a previously removed or expired warning ID will silently update it without any indication that the warning is no longer active. The function will return the updated (but still inactive) row, and the command will respond with a success message — confusing the moderator into thinking the edit was meaningful.

    In contrast, both removeWarning() (line 267) and clearWarnings() (line 302) correctly check AND active = TRUE when deactivating warnings.

  3. src/api/routes/warnings.js, line 120-123 (link)

    The severity value from req.query is passed directly into a parameterized query without being validated against the allowed set ['low', 'medium', 'high']. While SQL injection is prevented by parameterization, an invalid value (e.g. severity=critical) will silently return 0 results instead of a 400 Bad Request, making API clients harder to debug.

Last reviewed commit: 6705986

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 14

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@config.json`:
- Around line 163-167: The entries "warnings", "editwarn", "removewarn", and
"clearwarnings" inside the allowedCommands object are mis-indented; update their
indentation to align with the other keys (e.g., "warn") so all entries in
allowedCommands have consistent whitespace/indent level; locate the
allowedCommands object and adjust the lines for the keys "warnings", "editwarn",
"removewarn", and "clearwarnings" to match the same indentation as its
neighboring entries.

In `@src/api/routes/warnings.js`:
- Around line 22-31: The request must validate presence of guildId before
running requireGuildModerator to avoid unnecessary permission lookups and
incorrect 403/502 responses; update adaptGuildIdParam (or add a small validator)
to check req.query.guildId and, if missing, respond with a 400 error
immediately, otherwise set req.params.id=req.query.guildId and call next(); then
ensure the router uses this validator/adaptor before requireGuildModerator
(router.use(adaptGuildIdParam, requireGuildModerator) or split into two
middlewares) so requireGuildModerator never executes with an undefined
req.params.id.

In `@src/commands/warn.js`:
- Around line 51-66: afterCase currently runs after createCase has committed,
which can leave an orphaned mod_cases row if createWarning fails; modify the
flow so the case and warning inserts are one atomic unit: either move the case
creation and warning creation into a single transaction at the DB layer (so both
inserts succeed or both rollback) or, if you cannot change createCase, add
compensating cleanup in afterCase by catching errors from createWarning and
deleting the created mod_cases row (use caseData.id) to avoid orphaned rows and
ensure checkEscalation’s fallback path won’t count stale cases; reference
afterCase, createCase, createWarning and checkEscalation when making the change.
- Around line 21-30: The choice labels in the .addStringOption builder for
setName('severity') currently hard-code point totals (e.g., "Low (1 point)");
remove those totals from the addChoices names so they are neutral (e.g., "Low",
"Medium", "High"), and rely on the configurable severityPoints mapping at
runtime to determine point values (refer to severityPoints and the command
handler that consumes the 'severity' option) — if you need to inform users of
actual points, format and display the resolved points in the command's
follow-up/response rather than embedding them in the choice labels.

In `@src/commands/warnings.js`:
- Around line 63-72: The current rendering in the warnings.map block always
shows inactive warnings as "❌ Inactive"; change the status derivation to inspect
w.active, w.expires_at and w.removed_at/removal_reason so you can display
distinct labels (e.g., '✅ Active' when w.active is true; '⌛ Expired' when not
active and expires_at is set and in the past; '🗑️ Removed' plus removal_reason
when removed_at is set). Update the status variable creation inside the
warnings.map callback (where timestamp, status, reason, caseRef are computed) to
prefer removed_at/removal_reason, then expires_at, then fallback to a generic
inactive label so moderators can tell expired vs manually cleared warnings.
- Around line 13-22: The command currently hard-caps warning results to limit:25
and provides no way to fetch older pages; add a page parameter to the
SlashCommandBuilder (e.g., addIntegerOption named "page" with default 1) and use
it to compute an offset or cursor when calling getWarnings() (e.g., offset =
(page - 1) * limit) so you pass pagination through instead of truncating; update
getWarnings() signature/call sites to accept and use offset or a cursor
parameter (or return a nextCursor) and wire that through the handler (see data,
the command execute handler around getWarnings(), and getWarnings() itself) so
moderators can request arbitrary pages.

In `@src/modules/moderation.js`:
- Around line 309-326: The catch currently treats any error from the pool.query
against the warnings table as a signal to use the legacy mod_cases fallback;
change it so the fallback is only used when the error indicates the warnings
table is missing (e.g., check err.code === '42P01' or err.message includes
'relation "warnings" does not exist'), otherwise log the unexpected error
(include the error object) and rethrow/abort so we don't silently use mod_cases;
keep references to pool.query, warnCount, and the mod_cases fallback query when
implementing this conditional handling.

In `@src/modules/warningEngine.js`:
- Around line 171-211: editWarning currently overwrites reason/severity and only
updates updated_at, losing who edited and previous values; update the DB schema
(or add a new warnings_edits/history table per migrations/011_warnings.cjs) to
store editor identity, timestamp, and previous fields, then change editWarning
to (1) SELECT the current warning row by guildId/warningId, (2) INSERT a new
audit row into the history table with editor_id, edited_at, previous_reason,
previous_severity, previous_points, and any other originals, (3) perform the
UPDATE of warnings (preserving your dynamic SET logic), and (4) wrap the
SELECT/INSERT/UPDATE in a transaction to ensure atomicity; reference
editWarning, the warnings table, and the new warnings_edits (or history) table
when implementing.
- Around line 122-123: The active-only filter currently only adds "active =
TRUE" which leaves rows with expires_at <= NOW() considered active; update the
predicate built when activeOnly is true (the branch that pushes into conditions)
to require active = TRUE AND (expires_at IS NULL OR expires_at > NOW()) so
expired warnings are excluded; make the same replacement wherever the
active-only condition is applied (the other occurrence that pushes into
conditions for escalations) to ensure /warnings and escalation totals ignore
expired warnings.
- Around line 115-132: getWarnings currently only accepts limit so callers can
only fetch the newest slice; add pagination by extending the options parameter
(e.g., accept offset or page) and apply it in the SQL query using an OFFSET
clause. Update the getWarnings signature to read an offset (or page + compute
offset from limit) alongside limit and activeOnly, validate/normalize the value,
then modify the query to append "OFFSET $N" and pass the offset in the values
array (ensuring the parameter index matches values.length + 1). Ensure callers
can request subsequent pages by using the new option and keep existing behavior
when offset is omitted.

In `@TASK.md`:
- Around line 19-98: TASK.md describes an outdated warning design (mentions
007_warnings.cjs, mod_cases, warningDecay.js, decayDays, and admin-only
commands) but the PR implements migrations/011_warnings.cjs, a separate warnings
table, src/modules/warningEngine.js, expiryDays, and moderator-level controls;
update TASK.md to reflect the actual implementation by replacing the outdated
migration/DB/engine/setting names and permission guidance with the real
artifacts (migrations/011_warnings.cjs, warnings table,
src/modules/warningEngine.js, config key expiryDays, and that warn commands are
moderator-level), remove or reconcile conflicting instructions about mod_cases
and admin-only routes, and ensure the config snippet and command permission note
match the current codebase.
- Around line 3-118: The TASK.md violates markdownlint rules MD022/MD031 due to
missing blank lines around headings and the fenced JSON block; fix by inserting
a single blank line before and after each top-level and secondary heading (e.g.,
"## Branch:", "## Closes:", "## Context", "## What Exists", "## Deliverables",
and each subheading like "### 1. Database Migration") and ensure there's a blank
line both above and below the ```json fenced block in the "Config Schema"
section so the code fence is isolated from surrounding text.

In `@tests/commands/editwarn.test.js`:
- Around line 49-73: Add a new test case in tests/commands/editwarn.test.js that
mirrors the reason-only test but supplies only a severity update: use
createInteraction({ reason: null, severity: 'high' }) (or mock
interaction.options.getInteger/ getString appropriately to return null for
reason and the severity value), call execute(interaction), assert editWarning
was called with 'guild1', 1, { severity: 'high' }, expect.any(Object), and
assert interaction.editReply was called with a message containing 'updated';
reference existing helpers createInteraction, execute, editWarning and
interaction.editReply to place the new test next to the other edit-warning
tests.

In `@tests/commands/warn.test.js`:
- Line 125: The test currently only asserts that the mock checkEscalation was
invoked; change that to assert the escalation handoff was called with the
correct guild and member identifiers by replacing
expect(checkEscalation).toHaveBeenCalled() with a call-argument assertion (e.g.
toHaveBeenCalledWith) that pins the expected guild ID and member ID (use the
test's expectedGuildId/expectedMemberId or the variables used to create the
warning) and include any remaining parameters using expect.any(...) as
appropriate so the mock verifies the correct escalation target.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: 887a0ad8-2e65-47c7-919b-52f157fe73d0

📥 Commits

Reviewing files that changed from the base of the PR and between 308e330 and ca4a580.

📒 Files selected for processing (21)
  • .gitignore
  • TASK.md
  • biome.json
  • config.json
  • migrations/011_warnings.cjs
  • src/api/index.js
  • src/api/routes/warnings.js
  • src/commands/clearwarnings.js
  • src/commands/editwarn.js
  • src/commands/removewarn.js
  • src/commands/warn.js
  • src/commands/warnings.js
  • src/index.js
  • src/modules/moderation.js
  • src/modules/warningEngine.js
  • tests/commands/clearwarnings.test.js
  • tests/commands/editwarn.test.js
  • tests/commands/removewarn.test.js
  • tests/commands/warn.test.js
  • tests/commands/warnings.test.js
  • tests/modules/warningEngine.test.js
📜 Review details
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (2)
  • GitHub Check: Greptile Review
  • GitHub Check: Docker Build Validation
🧰 Additional context used
📓 Path-based instructions (8)
**/*.{js,cjs,mjs}

📄 CodeRabbit inference engine (AGENTS.md)

Use ESM only with import/export syntax, never CommonJS except in migration files (.cjs)

Files:

  • src/commands/warn.js
  • src/commands/warnings.js
  • src/commands/editwarn.js
  • src/modules/moderation.js
  • tests/commands/editwarn.test.js
  • src/commands/clearwarnings.js
  • src/api/routes/warnings.js
  • tests/commands/removewarn.test.js
  • src/index.js
  • src/api/index.js
  • tests/modules/warningEngine.test.js
  • src/commands/removewarn.js
  • migrations/011_warnings.cjs
  • tests/commands/clearwarnings.test.js
  • src/modules/warningEngine.js
  • tests/commands/warnings.test.js
  • tests/commands/warn.test.js
**/*.{js,mjs,jsx,ts,tsx}

📄 CodeRabbit inference engine (AGENTS.md)

**/*.{js,mjs,jsx,ts,tsx}: Use single quotes for strings in code, double quotes only allowed in JSON files
Always end statements with semicolons
Use 2-space indentation, enforced by Biome

Files:

  • src/commands/warn.js
  • src/commands/warnings.js
  • src/commands/editwarn.js
  • src/modules/moderation.js
  • tests/commands/editwarn.test.js
  • src/commands/clearwarnings.js
  • src/api/routes/warnings.js
  • tests/commands/removewarn.test.js
  • src/index.js
  • src/api/index.js
  • tests/modules/warningEngine.test.js
  • src/commands/removewarn.js
  • tests/commands/clearwarnings.test.js
  • src/modules/warningEngine.js
  • tests/commands/warnings.test.js
  • tests/commands/warn.test.js
src/**/*.{js,mjs,jsx,ts,tsx}

📄 CodeRabbit inference engine (AGENTS.md)

src/**/*.{js,mjs,jsx,ts,tsx}: Use src/logger.js Winston logger singleton, never use console.* methods
Use safe Discord message methods: safeReply(), safeSend(), safeEditReply() instead of direct Discord.js methods
Use parameterized SQL queries, never string interpolation for database queries

Files:

  • src/commands/warn.js
  • src/commands/warnings.js
  • src/commands/editwarn.js
  • src/modules/moderation.js
  • src/commands/clearwarnings.js
  • src/api/routes/warnings.js
  • src/index.js
  • src/api/index.js
  • src/commands/removewarn.js
  • src/modules/warningEngine.js
src/commands/**/*.{js,mjs}

📄 CodeRabbit inference engine (AGENTS.md)

Export slash command builder and execute function from each command file

Files:

  • src/commands/warn.js
  • src/commands/warnings.js
  • src/commands/editwarn.js
  • src/commands/clearwarnings.js
  • src/commands/removewarn.js
src/modules/**/*.{js,mjs}

📄 CodeRabbit inference engine (AGENTS.md)

Create new modules for features with corresponding config sections in config.json and entries in SAFE_CONFIG_KEYS

Files:

  • src/modules/moderation.js
  • src/modules/warningEngine.js
tests/**/*.{js,mjs,jsx,ts,tsx}

📄 CodeRabbit inference engine (AGENTS.md)

Maintain 80% code coverage threshold minimum, never lower this threshold

Files:

  • tests/commands/editwarn.test.js
  • tests/commands/removewarn.test.js
  • tests/modules/warningEngine.test.js
  • tests/commands/clearwarnings.test.js
  • tests/commands/warnings.test.js
  • tests/commands/warn.test.js
src/api/routes/**/*.{js,mjs}

📄 CodeRabbit inference engine (AGENTS.md)

Create API routes with proper authentication middleware, mount in src/api/server.js, and document in OpenAPI spec

Files:

  • src/api/routes/warnings.js
migrations/**/*.cjs

📄 CodeRabbit inference engine (AGENTS.md)

Use .cjs file extension for database migrations, use sequential migration numbering (001, 002, etc.) with node-pg-migrate

Files:

  • migrations/011_warnings.cjs
🧠 Learnings (4)
📚 Learning: 2026-03-05T18:07:15.752Z
Learnt from: CR
Repo: VolvoxLLC/volvox-bot PR: 0
File: AGENTS.md:0-0
Timestamp: 2026-03-05T18:07:15.752Z
Learning: Applies to src/commands/**/*.{js,mjs} : Export slash command builder and execute function from each command file

Applied to files:

  • src/commands/warnings.js
  • src/commands/editwarn.js
  • src/commands/clearwarnings.js
  • src/commands/removewarn.js
  • tests/commands/clearwarnings.test.js
  • tests/commands/warnings.test.js
  • tests/commands/warn.test.js
📚 Learning: 2026-03-05T18:07:15.752Z
Learnt from: CR
Repo: VolvoxLLC/volvox-bot PR: 0
File: AGENTS.md:0-0
Timestamp: 2026-03-05T18:07:15.752Z
Learning: Applies to src/**/*.{js,mjs,jsx,ts,tsx} : Use safe Discord message methods: `safeReply()`, `safeSend()`, `safeEditReply()` instead of direct Discord.js methods

Applied to files:

  • src/commands/warnings.js
  • src/commands/editwarn.js
  • src/commands/clearwarnings.js
  • tests/commands/warn.test.js
📚 Learning: 2026-03-05T18:07:15.752Z
Learnt from: CR
Repo: VolvoxLLC/volvox-bot PR: 0
File: AGENTS.md:0-0
Timestamp: 2026-03-05T18:07:15.752Z
Learning: Applies to src/api/routes/**/*.{js,mjs} : Create API routes with proper authentication middleware, mount in `src/api/server.js`, and document in OpenAPI spec

Applied to files:

  • src/api/routes/warnings.js
  • src/api/index.js
📚 Learning: 2026-03-05T18:07:15.752Z
Learnt from: CR
Repo: VolvoxLLC/volvox-bot PR: 0
File: AGENTS.md:0-0
Timestamp: 2026-03-05T18:07:15.752Z
Learning: Applies to web/**/*.{ts,tsx} : Next.js 16 web dashboard uses App Router with Discord OAuth2 authentication, dark/light theme support, and mobile-responsive design

Applied to files:

  • src/api/index.js
🪛 GitHub Check: CodeQL
src/api/routes/warnings.js

[failure] 31-31: Missing rate limiting
This route handler performs authorization, but is not rate-limited.


[failure] 88-150: Missing rate limiting
This route handler performs a database access, but is not rate-limited.
This route handler performs a database access, but is not rate-limited.


[failure] 182-236: Missing rate limiting
This route handler performs a database access, but is not rate-limited.
This route handler performs a database access, but is not rate-limited.
This route handler performs a database access, but is not rate-limited.


[failure] 261-310: Missing rate limiting
This route handler performs a database access, but is not rate-limited.
This route handler performs a database access, but is not rate-limited.
This route handler performs a database access, but is not rate-limited.
This route handler performs a database access, but is not rate-limited.

src/api/index.js

[failure] 64-64: Missing rate limiting
This route handler performs authorization, but is not rate-limited.
This route handler performs authorization, but is not rate-limited.

🪛 markdownlint-cli2 (0.21.0)
TASK.md

[warning] 3-3: Headings should be surrounded by blank lines
Expected: 1; Actual: 0; Below

(MD022, blanks-around-headings)


[warning] 4-4: Headings should be surrounded by blank lines
Expected: 1; Actual: 0; Above

(MD022, blanks-around-headings)


[warning] 6-6: Headings should be surrounded by blank lines
Expected: 1; Actual: 0; Below

(MD022, blanks-around-headings)


[warning] 9-9: Headings should be surrounded by blank lines
Expected: 1; Actual: 0; Below

(MD022, blanks-around-headings)


[warning] 19-19: Headings should be surrounded by blank lines
Expected: 1; Actual: 0; Below

(MD022, blanks-around-headings)


[warning] 33-33: Headings should be surrounded by blank lines
Expected: 1; Actual: 0; Below

(MD022, blanks-around-headings)


[warning] 39-39: Headings should be surrounded by blank lines
Expected: 1; Actual: 0; Below

(MD022, blanks-around-headings)


[warning] 44-44: Headings should be surrounded by blank lines
Expected: 1; Actual: 0; Below

(MD022, blanks-around-headings)


[warning] 50-50: Headings should be surrounded by blank lines
Expected: 1; Actual: 0; Below

(MD022, blanks-around-headings)


[warning] 57-57: Headings should be surrounded by blank lines
Expected: 1; Actual: 0; Below

(MD022, blanks-around-headings)


[warning] 65-65: Headings should be surrounded by blank lines
Expected: 1; Actual: 0; Below

(MD022, blanks-around-headings)


[warning] 71-71: Headings should be surrounded by blank lines
Expected: 1; Actual: 0; Below

(MD022, blanks-around-headings)


[warning] 77-77: Headings should be surrounded by blank lines
Expected: 1; Actual: 0; Below

(MD022, blanks-around-headings)


[warning] 86-86: Headings should be surrounded by blank lines
Expected: 1; Actual: 0; Below

(MD022, blanks-around-headings)


[warning] 88-88: Fenced code blocks should be surrounded by blank lines

(MD031, blanks-around-fences)


[warning] 100-100: Headings should be surrounded by blank lines
Expected: 1; Actual: 0; Below

(MD022, blanks-around-headings)


[warning] 113-113: Headings should be surrounded by blank lines
Expected: 1; Actual: 0; Below

(MD022, blanks-around-headings)

🔇 Additional comments (16)
.gitignore (1)

45-45: LGTM — this keeps local worktree artifacts out of version control.

biome.json (1)

12-13: Looks good — this keeps Biome from traversing local worktree content.

src/api/index.js (1)

62-64: LGTM – Route mounting follows established patterns.

The warningsRouter is correctly mounted with requireAuth() and auditLogMiddleware(), consistent with other protected moderation routes.

Regarding rate limiting: this has already been flagged by static analysis. Consider adding rate limiting middleware to protect against abuse, similar to how other sensitive routes should be protected.

config.json (1)

119-127: LGTM – Warning configuration defaults are sensible.

The 90-day expiry window and severity point structure (low=1, medium=2, high=3) align with the PR objectives for configurable decay/expiry.

src/index.js (2)

56-59: LGTM – Scheduler lifecycle integration is correct.

The warning expiry scheduler is properly imported and integrated into both startup (line 488) and graceful shutdown (line 292) flows, consistent with other background schedulers like tempban and scheduled messages.

Also applies to: 292-292


488-488: Code already implements defensive error handling correctly.

The startWarningExpiryScheduler() function in src/modules/warningEngine.js (lines 307–329) already follows the defensive pattern you were concerned about. The initial expiry check (lines 311–313) and the recurring poll (lines 319–325) both wrap processExpiredWarnings() in .catch() to log errors gracefully without crashing the bot. No changes needed.

tests/commands/clearwarnings.test.js (1)

1-60: LGTM – Test coverage is solid.

The test suite covers the essential scenarios for the clearwarnings command:

  • Export validation (name, moderatorOnly)
  • Successful bulk clear with count verification
  • Zero active warnings case
  • Error handling path

The mock setup is appropriate and afterEach cleanup prevents test pollution.

tests/commands/removewarn.test.js (1)

1-58: LGTM – Test coverage is comprehensive.

The test suite properly validates the removewarn command:

  • Export metadata verification
  • Successful warning removal with correct parameters
  • Not-found scenario (null return)
  • Database error handling

Structure is consistent with other command test files.

tests/commands/warnings.test.js (1)

1-92: LGTM – Test coverage is well-structured.

The test suite covers the warnings view command effectively:

  • Export metadata checks
  • Empty state messaging
  • Embed rendering with warning data (including mixed active/expired warnings)
  • Error handling for database failures

The mock interaction includes displayAvatarURL which is needed for embed generation—good attention to detail.

src/commands/editwarn.js (2)

33-33: Clarify: moderatorOnly export is metadata only—enforcement relies on config.

Based on the relevant code snippets from src/utils/permissions.js and src/index.js, the moderatorOnly export is not read at runtime for permission enforcement. Access control is determined solely by config.permissions.allowedCommands['editwarn'].

The config.json correctly maps editwarn to 'moderator', so this works as intended. The export serves as documentation and potentially for future tooling (e.g., command help generation).


39-81: LGTM – Command implementation is solid.

The execute function correctly:

  • Defers with ephemeral reply for responsive UX
  • Validates that at least one update field is provided
  • Passes config to editWarning for severity point recalculation
  • Handles not-found case gracefully
  • Logs the edit action with relevant context
  • Catches and logs errors without exposing internals to users
migrations/011_warnings.cjs (2)

17-55: LGTM – Well-designed schema with proper indexing.

The migration is comprehensive:

  • Table includes all necessary fields for warning lifecycle management (severity, points, active, expiry, removal tracking)
  • CHECK constraint on severity ensures data integrity at the database level
  • Partial indices (WHERE active = TRUE) optimize the most common query patterns (escalation checks, expiry polling)
  • Foreign key to mod_cases with ON DELETE SET NULL correctly preserves warnings if a case is deleted
  • Audit trail fields (removed_at, removed_by, removal_reason) support the PR's immutable audit trail requirement

57-60: LGTM – Down migration is correct.

CASCADE ensures associated indices are also dropped with the table.

tests/modules/warningEngine.test.js (1)

28-412: Coverage here is strong.

This suite exercises severity mapping, expiry calculation, CRUD paths, expiry processing, and scheduler idempotency, which gives the new warning engine good regression protection.

src/commands/clearwarnings.js (1)

27-55: LGTM.

The command matches clearWarnings()'s row-count contract cleanly and handles the zero-result path without extra complexity.

src/commands/removewarn.js (1)

29-65: LGTM.

The success and not-found flows are straightforward, and the reply uses the deactivated warning row returned by the engine correctly.

@github-project-automation github-project-automation bot moved this from Backlog to In Review in Volvox.Bot Mar 7, 2026
- Add updateEscalationThresholds callback for managing threshold rules
- Threshold editor shows when escalation is enabled
- Each rule: warns count, time window (days), action (timeout/kick/ban)
- Timeout rules get duration picker (5m to 28d)
- Add/remove threshold rules with + button and remove link
- Conditionally hides duration field for non-timeout actions
@railway-app railway-app bot temporarily deployed to volvox-bot / volvox-bot-pr-251 March 7, 2026 01:14 Destroyed
Docstrings generation was requested by @BillChirico.

The following files were modified:

* `src/api/routes/warnings.js`
* `src/commands/clearwarnings.js`
* `src/commands/removewarn.js`
* `src/index.js`
* `src/modules/moderation.js`
* `src/modules/warningEngine.js`
* `web/src/components/dashboard/config-editor.tsx`
* `web/src/components/dashboard/config-sections/ModerationSection.tsx`

These files were kept as they were:
* `src/commands/editwarn.js`
* `src/commands/warn.js`
* `src/commands/warnings.js`

These files were ignored:
* `tests/commands/clearwarnings.test.js`
* `tests/commands/editwarn.test.js`
* `tests/commands/removewarn.test.js`
* `tests/commands/warn.test.js`
* `tests/commands/warnings.test.js`
* `tests/modules/warningEngine.test.js`

These file types are not supported:
* `.gitignore`
* `TASK.md`
* `biome.json`
* `config.json`
Copilot AI review requested due to automatic review settings March 7, 2026 01:21
@BillChirico BillChirico review requested due to automatic review settings March 7, 2026 01:21
@railway-app railway-app bot temporarily deployed to volvox-bot / volvox-bot-pr-251 March 7, 2026 01:21 Destroyed
Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 6

♻️ Duplicate comments (9)
src/commands/warn.js (2)

21-30: ⚠️ Potential issue | 🟡 Minor

Keep the severity choice labels neutral.

severityPoints is configurable, so labels like Low (1 point) and High (3 points) become wrong as soon as a guild overrides the defaults.

Suggested change
-        { name: 'Low (1 point)', value: 'low' },
-        { name: 'Medium (2 points)', value: 'medium' },
-        { name: 'High (3 points)', value: 'high' },
+        { name: 'Low', value: 'low' },
+        { name: 'Medium', value: 'medium' },
+        { name: 'High', value: 'high' },
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/commands/warn.js` around lines 21 - 30, The severity choice labels in the
command option currently hard-code point counts (e.g., "Low (1 point)"), which
can be incorrect when guilds override severityPoints; update the
.addStringOption chain in src/commands/warn.js (the builder that calls
.setName('severity')/.addChoices(...)) to use neutral labels such as "Low",
"Medium", "High" (or "Low severity", etc.) while keeping the same choice values
('low','medium','high'), so the UI remains accurate regardless of configurable
point weights.

54-69: ⚠️ Potential issue | 🟠 Major

Persist the case row and warning row atomically.

afterCase runs after createCase() has already committed. If createWarning() fails here, you leave a mod_cases warn behind without the linked warning lifecycle row, and checkEscalation() can still count that legacy case via its fallback path.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/commands/warn.js` around lines 54 - 69, afterCase currently creates the
warning after createCase has already committed, risking an orphaned mod_cases
row if createWarning fails; modify the flow so creation of the mod case and its
warning are executed inside a single database transaction (e.g., have createCase
accept an optional callback or parameter to create the warning within its
transaction or refactor createCase to create both records before committing),
use the same transaction context when calling createWarning (or move warning
creation into createCase), and ensure any failure rolls back both the case and
warning so checkEscalation cannot see a legacy case without a warning.
src/modules/moderation.js (1)

311-327: ⚠️ Potential issue | 🟠 Major

Only use the legacy fallback when the warnings table is actually missing.

This still catches every failure from the warnings query and silently counts mod_cases instead. A transient query error can therefore resurrect removed/expired legacy warns and trigger false escalations.

Suggested fix
-    } catch {
-      // Fallback: warnings table may not exist yet during migration rollout
+    } catch (err) {
+      const isMissingWarningsTable =
+        err?.code === '42P01' ||
+        /relation ["']warnings["'] does not exist/i.test(err?.message ?? '');
+      if (!isMissingWarningsTable) {
+        logError('Failed to query active warnings for escalation', {
+          error: err.message,
+          guildId,
+          targetId,
+          threshold,
+        });
+        throw err;
+      }
+
+      // Fallback: warnings table may not exist yet during migration rollout
       const { rows } = await pool.query(
         `SELECT COUNT(*)::integer AS count FROM mod_cases
          WHERE guild_id = $1 AND target_id = $2 AND action = 'warn'
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/modules/moderation.js` around lines 311 - 327, The current try/catch
around the pool.query for the warnings table silently falls back to querying
mod_cases for any error; change it so only a missing-table error triggers the
legacy fallback: in the catch for the warnings query (around the pool.query call
that sets warnCount), inspect the caught error (e.g., error.code === '42P01' or
error.message includes 'relation "warnings" does not exist') and only then run
the mod_cases fallback query and set warnCount, otherwise rethrow or propagate
the error so transient/query errors are not swallowed; reference the pool.query
call that queries warnings, the catch block, the mod_cases fallback query,
warnCount, and threshold.withinDays when making the change.
src/modules/warningEngine.js (3)

122-123: ⚠️ Potential issue | 🟠 Major

Exclude already-expired rows from “active” reads.

Both queries only check active = TRUE. Until the scheduler flips those rows, warnings with expires_at <= NOW() still show up as active history and still inflate active-point totals.

Also applies to: 146-152

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/modules/warningEngine.js` around lines 122 - 123, The active-only filter
currently pushes "active = TRUE" but doesn't exclude rows already expired;
update the condition logic where "conditions" is built (the branch checking
"activeOnly") to push a compound predicate that also requires "expires_at >
NOW()" (e.g. "active = TRUE AND expires_at > NOW()"); make the same change in
the second occurrence that builds conditions (the other block around lines
146-152) so both queries exclude rows with expires_at <= NOW().

115-132: ⚠️ Potential issue | 🟠 Major

getWarnings() still cannot serve page 2+.

The accessor only accepts limit, so callers can only fetch the newest slice. That leaves the command/API layer unable to implement real pagination once a user has more than one page of warnings.

Pagination shape that preserves current behavior
 export async function getWarnings(guildId, userId, options = {}) {
   const pool = getPool();
-  const { activeOnly = false, limit = 50 } = options;
+  const { activeOnly = false, limit = 50, offset = 0 } = options;
+  const safeLimit = Math.min(Math.max(Number(limit) || 50, 1), 100);
+  const safeOffset = Math.max(Number(offset) || 0, 0);

   const conditions = ['guild_id = $1', 'user_id = $2'];
   const values = [guildId, userId];
@@
   const { rows } = await pool.query(
     `SELECT * FROM warnings
      WHERE ${conditions.join(' AND ')}
      ORDER BY created_at DESC
-     LIMIT $${values.length + 1}`,
-    [...values, limit],
+     LIMIT $${values.length + 1}
+     OFFSET $${values.length + 2}`,
+    [...values, safeLimit, safeOffset],
   );
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/modules/warningEngine.js` around lines 115 - 132, getWarnings currently
only accepts a limit so callers cannot fetch pages beyond the first; add a
pagination parameter (e.g., options.offset = 0) to getWarnings and use it in the
SQL by appending "OFFSET $N" where $N is values.length+1, and pass the offset in
the values array after limit (or pass [ ...values, limit, offset ] if you add
offset after limit); update the function signature/defaults to const {
activeOnly = false, limit = 50, offset = 0 } = options and ensure the query uses
LIMIT $M and OFFSET $N with corresponding parameter placeholders so callers can
request page slices.

172-212: ⚠️ Potential issue | 🟠 Major

editWarning() still overwrites the only copy of the record.

This updates reason/severity in place and only bumps updated_at, so you lose who edited the warning and what the previous values were. That does not satisfy the immutable audit-trail requirement for /editwarn.

src/api/routes/warnings.js (1)

24-29: ⚠️ Potential issue | 🟡 Minor

Reject missing guildId before requireGuildModerator runs.

requireGuildModerator reads req.params.id via src/api/routes/guilds.js:289-293, but adaptGuildIdParam() currently calls next() even when guildId is absent. That means the request can hit authz with an undefined guild and return the wrong status before the documented 400 check in each handler.

Suggested middleware adjustment
-function adaptGuildIdParam(req, _res, next) {
-  if (req.query.guildId) {
-    req.params.id = req.query.guildId;
-  }
-  next();
+function adaptGuildIdParam(req, res, next) {
+  if (!req.query.guildId) {
+    return res.status(400).json({ error: 'guildId is required' });
+  }
+  req.params.id = req.query.guildId;
+  return next();
 }

Also applies to: 31-33

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/api/routes/warnings.js` around lines 24 - 29, adaptGuildIdParam currently
always calls next() even when req.query.guildId is missing, which allows
requireGuildModerator to read undefined req.params.id and return incorrect
status; update adaptGuildIdParam to validate presence of req.query.guildId and
when absent short-circuit with a 400 bad request (or call next with a validation
error) so the per-handler 400 check is enforced, and when present set
req.params.id = req.query.guildId before calling next(); apply the same change
to the duplicate at lines 31-33.
src/commands/warnings.js (2)

13-22: ⚠️ Potential issue | 🟠 Major

Expose paging on /warnings instead of truncating at 25.

The command still has no page option, and the handler always calls getWarnings(..., { activeOnly, limit: 25 }), so moderators can never reach older warning history from the command surface.

Also applies to: 51-53

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/commands/warnings.js` around lines 13 - 22, Add a paginated "page" option
to the SlashCommandBuilder and wire it into the handler so getWarnings can fetch
older pages instead of always limiting to 25; specifically, update the exported
data (the SlashCommandBuilder used in data) to add an IntegerOption named "page"
(default 1) and in the command handler compute offset = (page - 1) * pageSize
and call getWarnings(userId, { activeOnly, limit: pageSize, offset }) instead of
getWarnings(..., { activeOnly, limit: 25 }); apply the same change for the other
warnings command variant referenced around the getWarnings(...) calls (lines
with getWarnings and options).

63-72: ⚠️ Potential issue | 🟠 Major

Show expired and manually removed warnings as different statuses.

Every inactive record is rendered as ❌ Inactive, which hides whether it decayed naturally or was explicitly cleared by staff.

Possible rendering fix
-      const status = w.active ? '✅ Active' : '❌ Inactive';
+      const expired = w.expires_at && new Date(w.expires_at) <= new Date();
+      const status = w.active
+        ? '✅ Active'
+        : w.removed_at
+          ? '🗑️ Removed'
+          : expired
+            ? '⌛ Expired'
+            : '❌ Inactive';
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/commands/warnings.js` around lines 63 - 72, The current status
calculation in the warnings.map block always collapses inactive warnings into '❌
Inactive'; update the status logic in that mapping (where status is computed
from w.active) to distinguish inactive states: keep '✅ Active' for w.active ===
true, then check for an expiration indicator (e.g., w.expired === true or
w.expired_at !== null) and render something like '⏳ Expired' for decayed/expired
warnings, and check for a manual-cleared indicator (e.g., w.removed_by or
w.cleared_by) and render something like '🧑‍⚖️ Cleared' or '❌ Cleared by staff'
for staff-removed warnings; adjust the ternary/branch around status (and update
the template string using severityLabel, w.points, <t:...:R>, and reason) so
each inactive case shows a distinct label.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/commands/editwarn.js`:
- Around line 21-30: The choice labels in the addStringOption for
setName('severity') currently hard-code point counts (e.g., "Low (1 point)")
which can drift from configurable severityPoints; update the .addChoices entries
used in the addStringOption chain (the severity option) to neutral labels (e.g.,
"Low", "Medium", "High") or otherwise omit point counts so the UI doesn't
promise specific point values tied to guild settings.
- Around line 54-59: The edit path must record who made the change: update the
editWarning function signature to accept a modifiedBy (or modified_by)
parameter, update its DB update to set modified_by = modifiedBy (alongside
updated_at = NOW()), and ensure any internal typing/exports reflect the new
param; then update the caller in editwarn.js to pass the moderator's id (e.g.,
interaction.user.id) when calling editWarning(interaction.guild.id, warningId,
updates, config, modifiedBy). Also mirror how removeWarning()/clearWarnings()
record removed_by so the audit trail is consistent.

In `@web/src/components/dashboard/config-editor.tsx`:
- Around line 1377-1389: The input is turning a stored null (meaning "never")
into the default 90 via value={draftConfig.moderation?.warnings?.expiryDays ??
90}; change the rendering and change the onChange mapping so null is displayed
as 0 and entering 0 stores null: for the input with id "warn-expiry" use
something like value={draftConfig.moderation?.warnings?.expiryDays === null ? 0
: draftConfig.moderation?.warnings?.expiryDays ?? 90} and adjust the onChange
handler (the one calling updateWarningsField('expiryDays', ...)) to set null
only when the parsed number === 0 (and handle NaN appropriately), so 0 displays
but persists as null while undefined still falls back to 90.
- Around line 1367-1374: The new ToggleSwitch is writing to
moderation.warnings.dmNotification (via
draftConfig.moderation?.warnings?.dmNotification and
updateWarningsField('dmNotification', ...)) while an existing "DM Notifications
-> warn" control uses a different authoritative setting, causing contradictory
defaults; pick one source of truth: either switch this control to read/write the
same key the existing DM Notifications control uses, or remove/rename this
duplicate and clearly separate behaviors, and make the default value consistent
(align the ToggleSwitch default with the existing control's default rather than
always using true) so UI state is never conflicting.

In `@web/src/components/dashboard/config-sections/ModerationSection.tsx`:
- Around line 205-215: The new "DM user on warn" Switch in ModerationSection
currently reads/writes draftConfig.moderation?.warnings?.dmNotification and
defaults to true, which conflicts with the existing "DM Notifications"
fieldset's warn toggle (same behavior but different source). Consolidate to a
single source of truth by wiring this Switch to the same property the "DM
Notifications" fieldset uses (remove the duplicate dmNotification key) or, if
they must remain distinct, rename and document the distinct purpose and align
defaults; update the onCheckedChange handler (currently
onWarningsChange?.('dmNotification', v)) to use the unified property and ensure
the default value matches the existing control so the UI cannot show
contradictory states.
- Around line 223-230: The number input for expiryDays currently uses
draftConfig.moderation?.warnings?.expiryDays ?? 90 which hides an explicit null
by showing 90; change the input's value to preserve null (e.g., use an empty
string instead of 90) so a saved "never" (null) remains distinguishable and
entering 0 maps to null via onWarningsChange; update the value binding in
ModerationSection.tsx for the element with id "warn-expiry" to use
draftConfig.moderation?.warnings?.expiryDays ?? '' while keeping the existing
onChange handler and onWarningsChange logic.

---

Duplicate comments:
In `@src/api/routes/warnings.js`:
- Around line 24-29: adaptGuildIdParam currently always calls next() even when
req.query.guildId is missing, which allows requireGuildModerator to read
undefined req.params.id and return incorrect status; update adaptGuildIdParam to
validate presence of req.query.guildId and when absent short-circuit with a 400
bad request (or call next with a validation error) so the per-handler 400 check
is enforced, and when present set req.params.id = req.query.guildId before
calling next(); apply the same change to the duplicate at lines 31-33.

In `@src/commands/warn.js`:
- Around line 21-30: The severity choice labels in the command option currently
hard-code point counts (e.g., "Low (1 point)"), which can be incorrect when
guilds override severityPoints; update the .addStringOption chain in
src/commands/warn.js (the builder that calls
.setName('severity')/.addChoices(...)) to use neutral labels such as "Low",
"Medium", "High" (or "Low severity", etc.) while keeping the same choice values
('low','medium','high'), so the UI remains accurate regardless of configurable
point weights.
- Around line 54-69: afterCase currently creates the warning after createCase
has already committed, risking an orphaned mod_cases row if createWarning fails;
modify the flow so creation of the mod case and its warning are executed inside
a single database transaction (e.g., have createCase accept an optional callback
or parameter to create the warning within its transaction or refactor createCase
to create both records before committing), use the same transaction context when
calling createWarning (or move warning creation into createCase), and ensure any
failure rolls back both the case and warning so checkEscalation cannot see a
legacy case without a warning.

In `@src/commands/warnings.js`:
- Around line 13-22: Add a paginated "page" option to the SlashCommandBuilder
and wire it into the handler so getWarnings can fetch older pages instead of
always limiting to 25; specifically, update the exported data (the
SlashCommandBuilder used in data) to add an IntegerOption named "page" (default
1) and in the command handler compute offset = (page - 1) * pageSize and call
getWarnings(userId, { activeOnly, limit: pageSize, offset }) instead of
getWarnings(..., { activeOnly, limit: 25 }); apply the same change for the other
warnings command variant referenced around the getWarnings(...) calls (lines
with getWarnings and options).
- Around line 63-72: The current status calculation in the warnings.map block
always collapses inactive warnings into '❌ Inactive'; update the status logic in
that mapping (where status is computed from w.active) to distinguish inactive
states: keep '✅ Active' for w.active === true, then check for an expiration
indicator (e.g., w.expired === true or w.expired_at !== null) and render
something like '⏳ Expired' for decayed/expired warnings, and check for a
manual-cleared indicator (e.g., w.removed_by or w.cleared_by) and render
something like '🧑‍⚖️ Cleared' or '❌ Cleared by staff' for staff-removed
warnings; adjust the ternary/branch around status (and update the template
string using severityLabel, w.points, <t:...:R>, and reason) so each inactive
case shows a distinct label.

In `@src/modules/moderation.js`:
- Around line 311-327: The current try/catch around the pool.query for the
warnings table silently falls back to querying mod_cases for any error; change
it so only a missing-table error triggers the legacy fallback: in the catch for
the warnings query (around the pool.query call that sets warnCount), inspect the
caught error (e.g., error.code === '42P01' or error.message includes 'relation
"warnings" does not exist') and only then run the mod_cases fallback query and
set warnCount, otherwise rethrow or propagate the error so transient/query
errors are not swallowed; reference the pool.query call that queries warnings,
the catch block, the mod_cases fallback query, warnCount, and
threshold.withinDays when making the change.

In `@src/modules/warningEngine.js`:
- Around line 122-123: The active-only filter currently pushes "active = TRUE"
but doesn't exclude rows already expired; update the condition logic where
"conditions" is built (the branch checking "activeOnly") to push a compound
predicate that also requires "expires_at > NOW()" (e.g. "active = TRUE AND
expires_at > NOW()"); make the same change in the second occurrence that builds
conditions (the other block around lines 146-152) so both queries exclude rows
with expires_at <= NOW().
- Around line 115-132: getWarnings currently only accepts a limit so callers
cannot fetch pages beyond the first; add a pagination parameter (e.g.,
options.offset = 0) to getWarnings and use it in the SQL by appending "OFFSET
$N" where $N is values.length+1, and pass the offset in the values array after
limit (or pass [ ...values, limit, offset ] if you add offset after limit);
update the function signature/defaults to const { activeOnly = false, limit =
50, offset = 0 } = options and ensure the query uses LIMIT $M and OFFSET $N with
corresponding parameter placeholders so callers can request page slices.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: 51e1d8b6-6725-4c0d-80fd-7e1d3e1c03fd

📥 Commits

Reviewing files that changed from the base of the PR and between ca4a580 and e31417b.

📒 Files selected for processing (11)
  • src/api/routes/warnings.js
  • src/commands/clearwarnings.js
  • src/commands/editwarn.js
  • src/commands/removewarn.js
  • src/commands/warn.js
  • src/commands/warnings.js
  • src/modules/moderation.js
  • src/modules/warningEngine.js
  • web/src/components/dashboard/config-editor.tsx
  • web/src/components/dashboard/config-sections/ModerationSection.tsx
  • web/src/types/config.ts
📜 Review details
🧰 Additional context used
📓 Path-based instructions (7)
**/*.{js,cjs,mjs}

📄 CodeRabbit inference engine (AGENTS.md)

Use ESM only with import/export syntax, never CommonJS except in migration files (.cjs)

Files:

  • src/commands/clearwarnings.js
  • src/commands/warn.js
  • src/commands/editwarn.js
  • src/api/routes/warnings.js
  • src/modules/warningEngine.js
  • src/commands/warnings.js
  • src/commands/removewarn.js
  • src/modules/moderation.js
**/*.{js,mjs,jsx,ts,tsx}

📄 CodeRabbit inference engine (AGENTS.md)

**/*.{js,mjs,jsx,ts,tsx}: Use single quotes for strings in code, double quotes only allowed in JSON files
Always end statements with semicolons
Use 2-space indentation, enforced by Biome

Files:

  • src/commands/clearwarnings.js
  • src/commands/warn.js
  • src/commands/editwarn.js
  • src/api/routes/warnings.js
  • src/modules/warningEngine.js
  • web/src/types/config.ts
  • web/src/components/dashboard/config-sections/ModerationSection.tsx
  • src/commands/warnings.js
  • src/commands/removewarn.js
  • web/src/components/dashboard/config-editor.tsx
  • src/modules/moderation.js
src/**/*.{js,mjs,jsx,ts,tsx}

📄 CodeRabbit inference engine (AGENTS.md)

src/**/*.{js,mjs,jsx,ts,tsx}: Use src/logger.js Winston logger singleton, never use console.* methods
Use safe Discord message methods: safeReply(), safeSend(), safeEditReply() instead of direct Discord.js methods
Use parameterized SQL queries, never string interpolation for database queries

Files:

  • src/commands/clearwarnings.js
  • src/commands/warn.js
  • src/commands/editwarn.js
  • src/api/routes/warnings.js
  • src/modules/warningEngine.js
  • src/commands/warnings.js
  • src/commands/removewarn.js
  • src/modules/moderation.js
src/commands/**/*.{js,mjs}

📄 CodeRabbit inference engine (AGENTS.md)

Export slash command builder and execute function from each command file

Files:

  • src/commands/clearwarnings.js
  • src/commands/warn.js
  • src/commands/editwarn.js
  • src/commands/warnings.js
  • src/commands/removewarn.js
src/api/routes/**/*.{js,mjs}

📄 CodeRabbit inference engine (AGENTS.md)

Create API routes with proper authentication middleware, mount in src/api/server.js, and document in OpenAPI spec

Files:

  • src/api/routes/warnings.js
src/modules/**/*.{js,mjs}

📄 CodeRabbit inference engine (AGENTS.md)

Create new modules for features with corresponding config sections in config.json and entries in SAFE_CONFIG_KEYS

Files:

  • src/modules/warningEngine.js
  • src/modules/moderation.js
web/**/*.{ts,tsx}

📄 CodeRabbit inference engine (AGENTS.md)

Next.js 16 web dashboard uses App Router with Discord OAuth2 authentication, dark/light theme support, and mobile-responsive design

Files:

  • web/src/types/config.ts
  • web/src/components/dashboard/config-sections/ModerationSection.tsx
  • web/src/components/dashboard/config-editor.tsx
🧠 Learnings (3)
📚 Learning: 2026-03-05T18:07:15.752Z
Learnt from: CR
Repo: VolvoxLLC/volvox-bot PR: 0
File: AGENTS.md:0-0
Timestamp: 2026-03-05T18:07:15.752Z
Learning: Applies to src/commands/**/*.{js,mjs} : Export slash command builder and execute function from each command file

Applied to files:

  • src/commands/clearwarnings.js
  • src/commands/editwarn.js
  • src/commands/warnings.js
  • src/commands/removewarn.js
📚 Learning: 2026-03-05T18:07:15.752Z
Learnt from: CR
Repo: VolvoxLLC/volvox-bot PR: 0
File: AGENTS.md:0-0
Timestamp: 2026-03-05T18:07:15.752Z
Learning: Applies to src/**/*.{js,mjs,jsx,ts,tsx} : Use safe Discord message methods: `safeReply()`, `safeSend()`, `safeEditReply()` instead of direct Discord.js methods

Applied to files:

  • src/commands/clearwarnings.js
  • src/commands/editwarn.js
  • src/commands/warnings.js
  • src/commands/removewarn.js
📚 Learning: 2026-03-05T18:07:15.752Z
Learnt from: CR
Repo: VolvoxLLC/volvox-bot PR: 0
File: AGENTS.md:0-0
Timestamp: 2026-03-05T18:07:15.752Z
Learning: Applies to src/api/routes/**/*.{js,mjs} : Create API routes with proper authentication middleware, mount in `src/api/server.js`, and document in OpenAPI spec

Applied to files:

  • src/api/routes/warnings.js
🪛 GitHub Check: CodeQL
src/api/routes/warnings.js

[failure] 33-33: Missing rate limiting
This route handler performs authorization, but is not rate-limited.


[failure] 90-152: Missing rate limiting
This route handler performs a database access, but is not rate-limited.
This route handler performs a database access, but is not rate-limited.


[failure] 184-238: Missing rate limiting
This route handler performs a database access, but is not rate-limited.
This route handler performs a database access, but is not rate-limited.
This route handler performs a database access, but is not rate-limited.


[failure] 263-312: Missing rate limiting
This route handler performs a database access, but is not rate-limited.
This route handler performs a database access, but is not rate-limited.
This route handler performs a database access, but is not rate-limited.
This route handler performs a database access, but is not rate-limited.

🔇 Additional comments (2)
src/commands/warn.js (1)

33-33: Config entries are correct—warning commands are properly protected.

The warning commands (warn, editwarn, removewarn, clearwarnings) all have explicit allowedCommands entries in config.json with "moderator" permission level, so the dispatcher does enforce permissions correctly. The runtime permission check reads from config.permissions.allowedCommands[commandName], and all four commands are present there.

The moderatorOnly export is not used by the dispatcher (only in tests), but this does not affect permission enforcement—the config entries provide the actual protection. No action needed.

src/modules/warningEngine.js (1)

307-339: Scheduler start/stop guards look solid.

The duplicate-start guard, in-flight poll guard, and explicit stopWarningExpiryScheduler() cleanup are all sensible for the startup/shutdown flow wired in src/index.js.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

♻️ Duplicate comments (9)
web/src/components/dashboard/config-sections/ModerationSection.tsx (2)

203-217: ⚠️ Potential issue | 🟠 Major

Conflicting DM notification controls.

This new "DM user on warn" switch writes to moderation.warnings.dmNotification (defaulting to true), but the existing "DM Notifications" fieldset at lines 106-119 already has a warn toggle that writes to moderation.dmNotifications.warn (defaulting to false). The UI shows two separate controls for conceptually the same behavior with opposite defaults, creating contradictory state.

Consolidate to a single source of truth or clearly differentiate the behaviors.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@web/src/components/dashboard/config-sections/ModerationSection.tsx` around
lines 203 - 217, The two DM notification switches conflict: one uses
draftConfig.moderation?.warnings?.dmNotification with onWarningsChange and
defaults true, while the other uses
draftConfig.moderation?.dmNotifications?.warn with a different default; pick a
single source of truth (either warnings.dmNotification or dmNotifications.warn)
and update the UI and handlers to use that property consistently — e.g., remove
the duplicate "DM user on warn" Switch or repurpose it with a distinct name,
update the checked value and onChange to the chosen property (adjust
onWarningsChange or the dmNotifications handler accordingly), and ensure the
default value is consistent across the code paths so the two controls no longer
contradict each other.

223-234: ⚠️ Potential issue | 🟠 Major

null expiry (never expire) is indistinguishable from the default.

The input uses value={... ?? 90} which renders a saved null (meaning "never expire") as 90. Users cannot distinguish a configured "never expire" from an unconfigured default, and the UI cannot represent this state correctly.

Suggested fix
               <Input
                 id="warn-expiry"
                 type="number"
                 min={0}
                 placeholder="90 (0 = never)"
-                value={draftConfig.moderation?.warnings?.expiryDays ?? 90}
+                value={
+                  draftConfig.moderation?.warnings?.expiryDays === null
+                    ? 0
+                    : (draftConfig.moderation?.warnings?.expiryDays ?? 90)
+                }
                 onChange={(e) => {
                   const val = parseInt(e.target.value, 10);
                   onWarningsChange?.('expiryDays', Number.isNaN(val) || val <= 0 ? null : val);
                 }}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@web/src/components/dashboard/config-sections/ModerationSection.tsx` around
lines 223 - 234, The input currently displays null (meaning "never expire") as
the numeric default because value uses `... ?? 90`; change the value logic to
explicitly render an empty string for a saved null so users can see "never": use
something like `draftConfig.moderation?.warnings?.expiryDays === null ? '' :
(draftConfig.moderation?.warnings?.expiryDays ?? 90)`. Update the onChange
handler in ModerationSection (the Input with id "warn-expiry") to treat an empty
string as null (call onWarningsChange?.('expiryDays', null)) and otherwise parse
the number as before (keeping the existing <=0 -> null behavior).
web/src/components/dashboard/config-editor.tsx (2)

1539-1546: ⚠️ Potential issue | 🟠 Major

Conflicting DM notification controls.

Same issue as in ModerationSection.tsx: this "DM user on warn" toggle writes to moderation.warnings.dmNotification (default true), while the "DM Notifications" fieldset above (lines 1179-1192) has a warn toggle writing to moderation.dmNotifications.warn (default false). The two controls present contradictory defaults and may confuse both users and the save logic.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@web/src/components/dashboard/config-editor.tsx` around lines 1539 - 1546,
There are two conflicting controls for DM notifications: the ToggleSwitch in
config-editor.tsx currently reads/writes
draftConfig.moderation?.warnings?.dmNotification via updateWarningsField
(default true) while an earlier fieldset uses moderation.dmNotifications.warn
(default false); consolidate them to a single canonical field and default (pick
either moderation.warnings.dmNotification or moderation.dmNotifications.warn),
update the ToggleSwitch and the other control to read/write the same property,
adjust their default fallback to be consistent, and ensure the
save/serialization logic and any functions like updateWarningsField or the
handler that updates moderation.dmNotifications are updated to reference the
chosen property so the UI and persisted config are aligned.

1549-1563: ⚠️ Potential issue | 🟠 Major

null expiry (never expire) displays as 90, hiding the configured "never" state.

Line 1557 uses value={... ?? 90}, so a saved null (meaning "never expire") renders as 90. Users cannot see or edit this state correctly.

Suggested fix
                   <input
                     id="warn-expiry"
                     type="number"
                     min={0}
                     className="..."
                     placeholder="90 (0 = never)"
-                    value={draftConfig.moderation?.warnings?.expiryDays ?? 90}
+                    value={
+                      draftConfig.moderation?.warnings?.expiryDays === null
+                        ? 0
+                        : (draftConfig.moderation?.warnings?.expiryDays ?? 90)
+                    }
                     onChange={(e) => {
                       const val = parseInt(e.target.value, 10);
                       updateWarningsField('expiryDays', Number.isNaN(val) || val <= 0 ? null : val);
                     }}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@web/src/components/dashboard/config-editor.tsx` around lines 1549 - 1563, The
input for warning expiry currently uses
value={draftConfig.moderation?.warnings?.expiryDays ?? 90} so a saved null
(meaning "never") displays as 90; change the controlled value to reflect null by
mapping null to an empty string (e.g.,
value={draftConfig.moderation?.warnings?.expiryDays ?? ''}) and update the
onChange handler in the same component (the warn-expiry <input> that calls
updateWarningsField) to convert an empty string back to null while parsing
numbers (e.g., treat '' as null, otherwise parseInt and if NaN or <=0 use null),
keeping the placeholder "90 (0 = never)" and disabled={saving} logic intact.
src/modules/warningEngine.js (3)

143-159: ⚠️ Potential issue | 🟠 Major

Active stats include logically-expired warnings.

getActiveWarningStats only checks active = TRUE but ignores expires_at. Until the scheduler runs, warnings past their expiry inflate the count and points, affecting escalation decisions and UI display.

🛠️ Suggested fix
   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`,
+     WHERE guild_id = $1 AND user_id = $2 AND active = TRUE
+       AND (expires_at IS NULL OR expires_at > NOW())`,
     [guildId, userId],
   );
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/modules/warningEngine.js` around lines 143 - 159, getActiveWarningStats
currently counts any row with active = TRUE even if expires_at has passed;
update the SQL in getActiveWarningStats to also filter out logically-expired
warnings by adding a condition like (expires_at IS NULL OR expires_at > now())
to the WHERE clause so only currently valid warnings contribute to COUNT and
SUM(points), leaving the return shape unchanged.

115-135: ⚠️ Potential issue | 🟠 Major

Pagination and expired-warning filtering are missing.

  1. No offset parameter: Callers can only fetch the first page. The /warnings command and API need to request subsequent pages.

  2. Expired warnings included: When activeOnly=true, rows with expires_at <= NOW() are still returned until the scheduler flips active. This inflates counts and can cause incorrect escalations.

🛠️ Suggested fix
 export async function getWarnings(guildId, userId, options = {}) {
   const pool = getPool();
-  const { activeOnly = false, limit = 50 } = options;
+  const { activeOnly = false, limit = 50, offset = 0 } = options;
+  const safeLimit = Math.min(Math.max(Number(limit) || 50, 1), 100);
+  const safeOffset = Math.max(Number(offset) || 0, 0);

   const conditions = ['guild_id = $1', 'user_id = $2'];
   const values = [guildId, userId];

   if (activeOnly) {
     conditions.push('active = TRUE');
+    conditions.push('(expires_at IS NULL OR expires_at > NOW())');
   }

   const { rows } = await pool.query(
     `SELECT * FROM warnings
      WHERE ${conditions.join(' AND ')}
      ORDER BY created_at DESC
-     LIMIT $${values.length + 1}`,
-    [...values, limit],
+     LIMIT $${values.length + 1}
+     OFFSET $${values.length + 2}`,
+    [...values, safeLimit, safeOffset],
   );

   return rows;
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/modules/warningEngine.js` around lines 115 - 135, getWarnings currently
only supports limit and returns warnings whose expires_at may already be past;
add an offset parameter (e.g., options.offset = 0) and include it in the SQL
using LIMIT $N OFFSET $M so callers can page results, and when activeOnly is
true add an additional condition to filter out expired warnings (e.g.,
"expires_at > NOW()") in the WHERE clause before building the parameter list;
update the call site(s) that use getWarnings if needed and ensure the parameter
ordering passed to pool.query matches the $ placeholders (function: getWarnings,
symbols: activeOnly, expires_at, created_at, pool.query).

172-213: ⚠️ Potential issue | 🟠 Major

Edit audit trail is not preserved.

editWarning overwrites reason/severity and only updates updated_at. The PR objectives require "edit reason with immutable audit trail" and "tracked edits with actor/timestamp/original-value." Consider storing edit history (editor ID, timestamp, previous values) in a separate table or JSONB column.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/modules/warningEngine.js` around lines 172 - 213, editWarning currently
overwrites reason/severity and only updates updated_at so edit audit history is
lost; change editWarning to capture the previous row before updating and persist
an immutable audit record (either insert into a new warning_edits table with
columns like warning_id, editor_id, edited_at, previous_values JSONB,
field_names, or append a new object to a JSONB edits array column on the
warnings row) and make the update/insert inside a single transaction; add an
editorId parameter to editWarning (and pass it through callers), record previous
reason/severity/points and actor/timestamp/original-value into the audit
destination, then perform the existing UPDATE (still recalculating points via
getSeverityPoints when severity changes) and return the updated row.
src/modules/moderation.js (1)

313-334: ⚠️ Potential issue | 🟠 Major

Bare catch swallows all query failures, not just missing-table errors.

The catch block at line 325 silently falls back to mod_cases for any error from the warnings query—including transient network failures, connection exhaustion, or syntax errors. This can cause escalation to use stale mod_cases data (which includes removed/expired warnings), leading to incorrect auto-timeouts or bans.

Reserve the fallback for the specific PostgreSQL error indicating the table doesn't exist (42P01 / relation "warnings" does not exist); otherwise log and rethrow.

🛠️ Suggested fix
-    } catch {
-      // Fallback: warnings table may not exist yet during migration rollout
+    } catch (err) {
+      // Fallback only if warnings table doesn't exist (migration not yet applied)
+      const isTableMissing =
+        err.code === '42P01' || /relation "warnings" does not exist/i.test(err.message);
+      if (!isTableMissing) {
+        logError('Escalation query failed', { error: err.message, guildId, targetId });
+        return null;
+      }
       const { rows } = await pool.query(
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/modules/moderation.js` around lines 313 - 334, The current catch around
the warnings table query in src/modules/moderation.js (the block that sets
warnCount via pool.query for warnings with threshold.withinDays) swallows all
errors and blindly falls back to mod_cases; change it to only fall back when the
Postgres "relation does not exist" error is returned (error.code === '42P01' or
error.message includes 'relation \"warnings\" does not exist'), and for other
errors log the error (using your existing logger or console.error) and rethrow
so transient/query errors are not masked; keep the same fallback query to
mod_cases only in the specific missing-table branch.
src/api/routes/warnings.js (1)

26-35: ⚠️ Potential issue | 🟡 Minor

Validate guildId before authorization middleware runs.

When req.query.guildId is missing, adaptGuildIdParam sets req.params.id to undefined and calls next(). Then requireGuildModerator attempts authorization with an undefined guild ID, potentially causing a 403 or 502 instead of the documented 400.

Note: The CodeQL "missing rate limiting" warning on line 35 is a false positive—warningsRateLimit is applied via router.use() on line 34 before the authorization middleware.

🛠️ Suggested fix
-function adaptGuildIdParam(req, _res, next) {
-  if (req.query.guildId) {
-    req.params.id = req.query.guildId;
-  }
-  next();
+function adaptGuildIdParam(req, res, next) {
+  if (!req.query.guildId) {
+    return res.status(400).json({ error: 'guildId is required' });
+  }
+  req.params.id = req.query.guildId;
+  return next();
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/api/routes/warnings.js` around lines 26 - 35, adaptGuildIdParam currently
sets req.params.id to undefined when req.query.guildId is missing, which lets
requireGuildModerator run and produce a 403/502; update adaptGuildIdParam to
validate req.query.guildId and short-circuit with a 400 Bad Request if it's
absent (i.e., call next(new Error/HTTPError with status 400 and a clear "missing
guildId" message) or otherwise only set req.params.id when the query value
exists) so authorization middleware (requireGuildModerator) never runs with an
undefined guild ID.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/commands/removewarn.js`:
- Line 23: The exported metadata "moderatorOnly" in removewarn.js is only
decorative and the dispatcher enforces access via
guildConfig.permissions.allowedCommands; remove reliance on moderatorOnly and
instead ensure the command name used by removewarn.js is added to the guild's
permission config (guildConfig.permissions.allowedCommands) or update the
dispatcher to check moderatorOnly — specifically, either (A) add the removewarn
command key to guildConfig.permissions.allowedCommands for appropriate guilds so
permissions are enforced, or (B) modify the dispatcher logic that resolves
allowed commands to honor the exported moderatorOnly flag from removewarn.js
(adjust the permission check surrounding commandName resolution).

In `@web/src/components/dashboard/config-editor.tsx`:
- Around line 1218-1226: The code clones
draftConfig.moderation?.escalation?.thresholds inside the .map callback which
creates a new thresholds array per iteration and leads to
stale-closure/inefficient behavior in the inner onChange handlers; fix it by
hoisting the clone outside the .map (create one const thresholds =
[...(draftConfig.moderation?.escalation?.thresholds ?? [])] before mapping) or,
better, switch the handlers to a functional update that reads the latest state
(setDraftConfig(prev => { const thresholds =
[...(prev.moderation?.escalation?.thresholds ?? [])]; thresholds[idx] =
{...thresholds[idx], /* changed field */}; return {...prev, moderation:
{...prev.moderation, escalation: {...prev.moderation.escalation, thresholds}}};
})), and update each onChange handler (the ones mutating
warns/withinDays/action/duration) to use that single shared clone or the
functional update so closures always work with the current array.

---

Duplicate comments:
In `@src/api/routes/warnings.js`:
- Around line 26-35: adaptGuildIdParam currently sets req.params.id to undefined
when req.query.guildId is missing, which lets requireGuildModerator run and
produce a 403/502; update adaptGuildIdParam to validate req.query.guildId and
short-circuit with a 400 Bad Request if it's absent (i.e., call next(new
Error/HTTPError with status 400 and a clear "missing guildId" message) or
otherwise only set req.params.id when the query value exists) so authorization
middleware (requireGuildModerator) never runs with an undefined guild ID.

In `@src/modules/moderation.js`:
- Around line 313-334: The current catch around the warnings table query in
src/modules/moderation.js (the block that sets warnCount via pool.query for
warnings with threshold.withinDays) swallows all errors and blindly falls back
to mod_cases; change it to only fall back when the Postgres "relation does not
exist" error is returned (error.code === '42P01' or error.message includes
'relation \"warnings\" does not exist'), and for other errors log the error
(using your existing logger or console.error) and rethrow so transient/query
errors are not masked; keep the same fallback query to mod_cases only in the
specific missing-table branch.

In `@src/modules/warningEngine.js`:
- Around line 143-159: getActiveWarningStats currently counts any row with
active = TRUE even if expires_at has passed; update the SQL in
getActiveWarningStats to also filter out logically-expired warnings by adding a
condition like (expires_at IS NULL OR expires_at > now()) to the WHERE clause so
only currently valid warnings contribute to COUNT and SUM(points), leaving the
return shape unchanged.
- Around line 115-135: getWarnings currently only supports limit and returns
warnings whose expires_at may already be past; add an offset parameter (e.g.,
options.offset = 0) and include it in the SQL using LIMIT $N OFFSET $M so
callers can page results, and when activeOnly is true add an additional
condition to filter out expired warnings (e.g., "expires_at > NOW()") in the
WHERE clause before building the parameter list; update the call site(s) that
use getWarnings if needed and ensure the parameter ordering passed to pool.query
matches the $ placeholders (function: getWarnings, symbols: activeOnly,
expires_at, created_at, pool.query).
- Around line 172-213: editWarning currently overwrites reason/severity and only
updates updated_at so edit audit history is lost; change editWarning to capture
the previous row before updating and persist an immutable audit record (either
insert into a new warning_edits table with columns like warning_id, editor_id,
edited_at, previous_values JSONB, field_names, or append a new object to a JSONB
edits array column on the warnings row) and make the update/insert inside a
single transaction; add an editorId parameter to editWarning (and pass it
through callers), record previous reason/severity/points and
actor/timestamp/original-value into the audit destination, then perform the
existing UPDATE (still recalculating points via getSeverityPoints when severity
changes) and return the updated row.

In `@web/src/components/dashboard/config-editor.tsx`:
- Around line 1539-1546: There are two conflicting controls for DM
notifications: the ToggleSwitch in config-editor.tsx currently reads/writes
draftConfig.moderation?.warnings?.dmNotification via updateWarningsField
(default true) while an earlier fieldset uses moderation.dmNotifications.warn
(default false); consolidate them to a single canonical field and default (pick
either moderation.warnings.dmNotification or moderation.dmNotifications.warn),
update the ToggleSwitch and the other control to read/write the same property,
adjust their default fallback to be consistent, and ensure the
save/serialization logic and any functions like updateWarningsField or the
handler that updates moderation.dmNotifications are updated to reference the
chosen property so the UI and persisted config are aligned.
- Around line 1549-1563: The input for warning expiry currently uses
value={draftConfig.moderation?.warnings?.expiryDays ?? 90} so a saved null
(meaning "never") displays as 90; change the controlled value to reflect null by
mapping null to an empty string (e.g.,
value={draftConfig.moderation?.warnings?.expiryDays ?? ''}) and update the
onChange handler in the same component (the warn-expiry <input> that calls
updateWarningsField) to convert an empty string back to null while parsing
numbers (e.g., treat '' as null, otherwise parseInt and if NaN or <=0 use null),
keeping the placeholder "90 (0 = never)" and disabled={saving} logic intact.

In `@web/src/components/dashboard/config-sections/ModerationSection.tsx`:
- Around line 203-217: The two DM notification switches conflict: one uses
draftConfig.moderation?.warnings?.dmNotification with onWarningsChange and
defaults true, while the other uses
draftConfig.moderation?.dmNotifications?.warn with a different default; pick a
single source of truth (either warnings.dmNotification or dmNotifications.warn)
and update the UI and handlers to use that property consistently — e.g., remove
the duplicate "DM user on warn" Switch or repurpose it with a distinct name,
update the checked value and onChange to the chosen property (adjust
onWarningsChange or the dmNotifications handler accordingly), and ensure the
default value is consistent across the code paths so the two controls no longer
contradict each other.
- Around line 223-234: The input currently displays null (meaning "never
expire") as the numeric default because value uses `... ?? 90`; change the value
logic to explicitly render an empty string for a saved null so users can see
"never": use something like `draftConfig.moderation?.warnings?.expiryDays ===
null ? '' : (draftConfig.moderation?.warnings?.expiryDays ?? 90)`. Update the
onChange handler in ModerationSection (the Input with id "warn-expiry") to treat
an empty string as null (call onWarningsChange?.('expiryDays', null)) and
otherwise parse the number as before (keeping the existing <=0 -> null
behavior).

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: 49f7f51a-5099-42bb-b02a-c236487db0c9

📥 Commits

Reviewing files that changed from the base of the PR and between e31417b and d1dffe0.

📒 Files selected for processing (8)
  • src/api/routes/warnings.js
  • src/commands/clearwarnings.js
  • src/commands/removewarn.js
  • src/index.js
  • src/modules/moderation.js
  • src/modules/warningEngine.js
  • web/src/components/dashboard/config-editor.tsx
  • web/src/components/dashboard/config-sections/ModerationSection.tsx
📜 Review details
🧰 Additional context used
📓 Path-based instructions (7)
**/*.{js,cjs,mjs}

📄 CodeRabbit inference engine (AGENTS.md)

Use ESM only with import/export syntax, never CommonJS except in migration files (.cjs)

Files:

  • src/index.js
  • src/api/routes/warnings.js
  • src/modules/warningEngine.js
  • src/commands/removewarn.js
  • src/modules/moderation.js
  • src/commands/clearwarnings.js
**/*.{js,mjs,jsx,ts,tsx}

📄 CodeRabbit inference engine (AGENTS.md)

**/*.{js,mjs,jsx,ts,tsx}: Use single quotes for strings in code, double quotes only allowed in JSON files
Always end statements with semicolons
Use 2-space indentation, enforced by Biome

Files:

  • src/index.js
  • src/api/routes/warnings.js
  • src/modules/warningEngine.js
  • src/commands/removewarn.js
  • src/modules/moderation.js
  • web/src/components/dashboard/config-editor.tsx
  • web/src/components/dashboard/config-sections/ModerationSection.tsx
  • src/commands/clearwarnings.js
src/**/*.{js,mjs,jsx,ts,tsx}

📄 CodeRabbit inference engine (AGENTS.md)

src/**/*.{js,mjs,jsx,ts,tsx}: Use src/logger.js Winston logger singleton, never use console.* methods
Use safe Discord message methods: safeReply(), safeSend(), safeEditReply() instead of direct Discord.js methods
Use parameterized SQL queries, never string interpolation for database queries

Files:

  • src/index.js
  • src/api/routes/warnings.js
  • src/modules/warningEngine.js
  • src/commands/removewarn.js
  • src/modules/moderation.js
  • src/commands/clearwarnings.js
src/api/routes/**/*.{js,mjs}

📄 CodeRabbit inference engine (AGENTS.md)

Create API routes with proper authentication middleware, mount in src/api/server.js, and document in OpenAPI spec

Files:

  • src/api/routes/warnings.js
src/modules/**/*.{js,mjs}

📄 CodeRabbit inference engine (AGENTS.md)

Create new modules for features with corresponding config sections in config.json and entries in SAFE_CONFIG_KEYS

Files:

  • src/modules/warningEngine.js
  • src/modules/moderation.js
src/commands/**/*.{js,mjs}

📄 CodeRabbit inference engine (AGENTS.md)

Export slash command builder and execute function from each command file

Files:

  • src/commands/removewarn.js
  • src/commands/clearwarnings.js
web/**/*.{ts,tsx}

📄 CodeRabbit inference engine (AGENTS.md)

Next.js 16 web dashboard uses App Router with Discord OAuth2 authentication, dark/light theme support, and mobile-responsive design

Files:

  • web/src/components/dashboard/config-editor.tsx
  • web/src/components/dashboard/config-sections/ModerationSection.tsx
🧠 Learnings (4)
📚 Learning: 2026-03-05T18:07:15.752Z
Learnt from: CR
Repo: VolvoxLLC/volvox-bot PR: 0
File: AGENTS.md:0-0
Timestamp: 2026-03-05T18:07:15.752Z
Learning: Applies to src/api/routes/**/*.{js,mjs} : Create API routes with proper authentication middleware, mount in `src/api/server.js`, and document in OpenAPI spec

Applied to files:

  • src/api/routes/warnings.js
📚 Learning: 2026-03-05T18:07:15.752Z
Learnt from: CR
Repo: VolvoxLLC/volvox-bot PR: 0
File: AGENTS.md:0-0
Timestamp: 2026-03-05T18:07:15.752Z
Learning: Applies to src/commands/**/*.{js,mjs} : Export slash command builder and execute function from each command file

Applied to files:

  • src/commands/removewarn.js
  • src/commands/clearwarnings.js
📚 Learning: 2026-03-05T18:07:15.752Z
Learnt from: CR
Repo: VolvoxLLC/volvox-bot PR: 0
File: AGENTS.md:0-0
Timestamp: 2026-03-05T18:07:15.752Z
Learning: Applies to src/**/*.{js,mjs,jsx,ts,tsx} : Use safe Discord message methods: `safeReply()`, `safeSend()`, `safeEditReply()` instead of direct Discord.js methods

Applied to files:

  • src/commands/removewarn.js
  • src/commands/clearwarnings.js
📚 Learning: 2026-03-05T18:07:15.752Z
Learnt from: CR
Repo: VolvoxLLC/volvox-bot PR: 0
File: AGENTS.md:0-0
Timestamp: 2026-03-05T18:07:15.752Z
Learning: Applies to src/api/utils/configAllowlist.js : Maintain `SAFE_CONFIG_KEYS` for writable config sections via API and `READABLE_CONFIG_KEYS` for read-only sections, add new config sections to SAFE to enable saves

Applied to files:

  • web/src/components/dashboard/config-editor.tsx
🪛 GitHub Check: CodeQL
src/api/routes/warnings.js

[failure] 35-35: Missing rate limiting
This route handler performs authorization, but is not rate-limited.


[failure] 92-154: Missing rate limiting
This route handler performs a database access, but is not rate-limited.
This route handler performs a database access, but is not rate-limited.


[failure] 186-240: Missing rate limiting
This route handler performs a database access, but is not rate-limited.
This route handler performs a database access, but is not rate-limited.
This route handler performs a database access, but is not rate-limited.


[failure] 265-314: Missing rate limiting
This route handler performs a database access, but is not rate-limited.
This route handler performs a database access, but is not rate-limited.
This route handler performs a database access, but is not rate-limited.
This route handler performs a database access, but is not rate-limited.

🔇 Additional comments (28)
web/src/components/dashboard/config-sections/ModerationSection.tsx (2)

254-284: LGTM!

The severity points grid correctly reads from severityPoints with appropriate defaults, merges changes immutably, and clamps values to a minimum of 1. The implementation is clean and handles the three-level map consistently.


236-252: LGTM!

The warnings-per-page input correctly clamps values between 1 and 25, with a sensible default of 10. The implementation handles edge cases appropriately.

web/src/components/dashboard/config-editor.tsx (4)

727-747: LGTM!

The updateWarningsField callback correctly initializes default values when warnings config doesn't exist and uses immutable update patterns. The defaults align with the documented config schema.


611-627: LGTM!

The updateEscalationThresholds callback correctly updates the escalation thresholds array immutably, preserving other escalation fields.


1582-1611: LGTM!

The severity points grid correctly handles reading, defaults, and immutable updates with a minimum value of 1. Implementation is consistent with the ModerationSection.tsx version.


1334-1352: LGTM!

The "Add threshold" button correctly clones the existing thresholds array before pushing a new default threshold with sensible initial values. The implementation handles the empty-state case properly.

src/modules/moderation.js (3)

217-228: LGTM!

The expanded JSDoc clearly documents the function's behavior, including the new log_message_id storage and return semantics.


285-296: LGTM!

The JSDoc accurately describes the escalation evaluation, threshold matching, and return contract.


338-338: LGTM!

The escalation reason message now accurately reflects "active warns" terminology, aligning with the new warnings-based counting.

src/modules/warningEngine.js (7)

1-27: LGTM!

Module header, imports, default severity points mapping, and scheduler state variables are well-structured.


28-53: LGTM!

getSeverityPoints and calculateExpiry are clean utility functions with proper config fallback handling.


55-104: LGTM!

createWarning correctly inserts all required fields, calculates points/expiry from config, and logs the creation event.


215-243: LGTM!

removeWarning correctly soft-deletes by setting active = FALSE and records removal metadata.


245-273: LGTM!

clearWarnings bulk-deactivates with proper metadata and logging.


275-299: LGTM!

processExpiredWarnings uses a single UPDATE to deactivate all expired rows efficiently, with error handling that doesn't crash the scheduler.


301-340: LGTM!

Scheduler implementation follows the same pattern as tempban scheduler with concurrency guard, immediate startup check, and proper cleanup on stop.

src/commands/removewarn.js (2)

1-22: LGTM!

Command definition correctly exports the SlashCommandBuilder with appropriate options and validation (setMinValue(1) for ID).


33-68: LGTM!

Execute function correctly defers ephemerally, handles not-found cases, logs success with target user context, and has proper error handling with safe reply fallback.

src/index.js (3)

56-59: LGTM!

Import follows existing module import patterns and correctly references the new warning engine exports.


282-294: LGTM!

stopWarningExpiryScheduler is correctly placed in the shutdown sequence alongside other scheduler stops. The ordering (triage → cleanup → tempban → warnings → scheduler → github) is appropriate.


486-491: LGTM!

startWarningExpiryScheduler is correctly started inside the if (dbPool) block, ensuring it only runs when database is available, consistent with startTempbanScheduler.

src/api/routes/warnings.js (4)

16-17: LGTM!

Rate limiter is properly configured with reasonable limits (120 req/15 min).


92-154: LGTM!

The GET / route correctly implements pagination with bounds checking, dynamic WHERE clause building with parameterized queries, parallel count query, and comprehensive logging. The CodeQL warning is a false positive since rate limiting is applied at the router level.


186-240: LGTM!

The user warnings endpoint efficiently batches three parallel queries (warnings, stats, severity counts) and returns a well-structured response. CodeQL warning is a false positive.


265-314: LGTM!

The stats endpoint provides useful aggregate data with top users ranking. Four parallel queries are efficient. CodeQL warning is a false positive since rate limiting is applied at router level.

src/commands/clearwarnings.js (3)

1-21: LGTM!

Command definition correctly exports SlashCommandBuilder with required user option and optional reason.


29-57: LGTM!

Execute function correctly handles the zero-warnings case, logs success with count, and has proper error handling with safe reply fallback.


21-21: 🧹 Nitpick | 🔵 Trivial

Note: moderatorOnly is metadata only.

Same as removewarn.js—the dispatcher enforces permissions via guild config, not this flag. Ensure the command is configured in permissions.allowedCommands.

⛔ Skipped due to learnings
Learnt from: CR
Repo: VolvoxLLC/volvox-bot PR: 0
File: AGENTS.md:0-0
Timestamp: 2026-03-05T18:07:15.752Z
Learning: All community features must be gated behind `config.<feature>.enabled` flag, mod commands always available regardless of config state

- warningEngine: add try/catch with Winston logging to all CRUD functions
- warningEngine: add offset param to getWarnings for pagination
- warningEngine: filter expired-but-unprocessed warnings in active queries
- warningEngine: log previous values on edit for audit trail
- warningEngine: set removed_at in processExpiredWarnings
- moderation: only fall back to mod_cases on table-not-found (42P01)
- moderation: filter expired warnings in escalation count
- warn/editwarn: remove hardcoded point values from severity labels
- warnings: add page option, show expired vs removed status
- config.json: fix indentation on warning command permissions
- dashboard: remove duplicate DM warn toggle (uses existing dmNotifications.warn)
- dashboard: fix null expiry rendering (show 0 instead of 90)
- dashboard: fix stale closure in escalation threshold editor
- types: remove dmNotification from WarningsConfig (conflicts with existing)
- tests: fix editWarning mocks for audit trail SELECT, add offset test
- tests: add getInteger mock to warnings command, severity-only edit test
- Remove TASK.md from tracked files
Copilot AI review requested due to automatic review settings March 7, 2026 03:43
Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@BillChirico BillChirico merged commit 0914172 into main Mar 7, 2026
17 of 21 checks passed
@BillChirico BillChirico deleted the feat/issue-250-warning-system branch March 7, 2026 03:56
@github-project-automation github-project-automation bot moved this from In Review to Done in Volvox.Bot Mar 7, 2026
@github-actions
Copy link
Contributor

github-actions bot commented Mar 7, 2026

🧹 Preview Environment Cleaned Up

The Railway preview environment for this PR has been removed.

Environment: pr-251

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

priority: high High priority scope: moderation Moderation features type: feature New feature

Projects

Status: Done

Development

Successfully merging this pull request may close these issues.

feat(moderation): comprehensive manual warning system for mods/admins

3 participants