• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

VolvoxLLC / volvox-bot / 22791334984

07 Mar 2026 03:44AM UTC coverage: 87.451% (-0.4%) from 87.898%
22791334984

Pull #251

github

web-flow
Merge 670598672 into 308e3303b
Pull Request #251: feat(moderation): comprehensive warning system with severity, decay, and expiry

5923 of 7180 branches covered (82.49%)

Branch coverage included in aggregate %.

195 of 273 new or added lines in 10 files covered. (71.43%)

4 existing lines in 1 file now uncovered.

10182 of 11236 relevant lines covered (90.62%)

230.2 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

90.38
/src/modules/warningEngine.js
1
/**
2
 * Warning Engine
3
 * Manages warning lifecycle: creation, expiry, decay, querying, and removal.
4
 * Warnings are stored in the `warnings` table and linked to mod_cases via case_id.
5
 *
6
 * @see https://github.com/VolvoxLLC/volvox-bot/issues/250
7
 */
8

9
import { getPool } from '../db.js';
10
import { info, error as logError } from '../logger.js';
11

12
/**
13
 * Severity-to-points mapping. Configurable via config but these are sane defaults.
14
 * @type {Record<string, number>}
15
 */
16
const DEFAULT_SEVERITY_POINTS = {
35✔
17
  low: 1,
18
  medium: 2,
19
  high: 3,
20
};
21

22
/** @type {ReturnType<typeof setInterval> | null} */
23
let expiryInterval = null;
35✔
24

25
/** @type {boolean} */
26
let expiryPollInFlight = false;
35✔
27

28
/**
29
 * Determine the points assigned to a given severity, honoring config overrides.
30
 * @param {Object} [config] - Optional bot configuration object that may contain moderation.warnings.severityPoints.
31
 * @param {string} severity - Severity level key (e.g., 'low', 'medium', 'high').
32
 * @returns {number} The point value for the severity; uses the configured override when present, otherwise falls back to the default mapping or `1` if unknown.
33
 */
34
export function getSeverityPoints(config, severity) {
35
  const configPoints = config?.moderation?.warnings?.severityPoints;
12✔
36
  if (configPoints && typeof configPoints[severity] === 'number') {
12✔
37
    return configPoints[severity];
6✔
38
  }
39
  return DEFAULT_SEVERITY_POINTS[severity] ?? 1;
6✔
40
}
41

42
/**
43
 * Compute the expiration Date for a warning based on configured expiry days.
44
 * @param {Object} [config] - Bot configuration object; uses `config.moderation.warnings.expiryDays`.
45
 * @returns {Date|null} The calculated expiry Date, or `null` if `expiryDays` is not a positive number (warnings do not expire).
46
 */
47
export function calculateExpiry(config) {
48
  const expiryDays = config?.moderation?.warnings?.expiryDays;
9✔
49
  if (typeof expiryDays !== 'number' || expiryDays <= 0) return null;
9✔
50
  const expiry = new Date();
2✔
51
  expiry.setDate(expiry.getDate() + expiryDays);
2✔
52
  return expiry;
2✔
53
}
54

55
/**
56
 * Create a warning record in the database.
57
 * @param {string} guildId - Discord guild ID.
58
 * @param {Object} data - Warning data.
59
 * @param {string} data.userId - Target user ID.
60
 * @param {string} data.moderatorId - Moderator user ID.
61
 * @param {string} data.moderatorTag - Moderator display tag.
62
 * @param {string} [data.reason] - Reason for the warning.
63
 * @param {string} [data.severity='low'] - Severity level (`low`, `medium`, or `high`).
64
 * @param {number} [data.caseId] - Linked mod_cases.id.
65
 * @param {Object} [config] - Bot configuration used to determine points and expiry.
66
 * @returns {Object} The created warning row.
67
 */
68
export async function createWarning(guildId, data, config) {
69
  const pool = getPool();
3✔
70
  const severity = data.severity || 'low';
3✔
71
  const points = getSeverityPoints(config, severity);
3✔
72
  const expiresAt = calculateExpiry(config);
3✔
73

74
  try {
3✔
75
    const { rows } = await pool.query(
3✔
76
      `INSERT INTO warnings
77
        (guild_id, user_id, moderator_id, moderator_tag, reason, severity, points, expires_at, case_id)
78
      VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
79
      RETURNING *`,
80
      [
81
        guildId,
82
        data.userId,
83
        data.moderatorId,
84
        data.moderatorTag,
85
        data.reason || null,
5✔
86
        severity,
87
        points,
88
        expiresAt,
89
        data.caseId || null,
5✔
90
      ],
91
    );
92

93
    const warning = rows[0];
3✔
94

95
    info('Warning created', {
3✔
96
      guildId,
97
      warningId: warning.id,
98
      userId: data.userId,
99
      severity,
100
      points,
101
      expiresAt: expiresAt?.toISOString() || null,
5✔
102
    });
103

104
    return warning;
3✔
105
  } catch (err) {
NEW
106
    logError('Failed to create warning', { error: err.message, guildId, userId: data.userId });
×
NEW
107
    throw err;
×
108
  }
109
}
110

111
/**
112
 * Retrieve warnings for a user in a guild.
113
 * @param {string} guildId - Discord guild ID.
114
 * @param {string} userId - Target user ID.
115
 * @param {Object} [options] - Query options.
116
 * @param {boolean} [options.activeOnly=false] - If true, only include active warnings.
117
 * @param {number} [options.limit=50] - Maximum number of warnings to return.
118
 * @returns {Object[]} Array of warning rows ordered by newest first.
119
 */
120
export async function getWarnings(guildId, userId, options = {}) {
4✔
121
  const pool = getPool();
4✔
122
  const { activeOnly = false, limit = 50, offset = 0 } = options;
4✔
123

124
  const conditions = ['guild_id = $1', 'user_id = $2'];
4✔
125
  const values = [guildId, userId];
4✔
126

127
  if (activeOnly) {
4✔
128
    // Also filter out rows that have expired but haven't been processed by the scheduler yet
129
    conditions.push('active = TRUE');
1✔
130
    conditions.push('(expires_at IS NULL OR expires_at > NOW())');
1✔
131
  }
132

133
  try {
4✔
134
    const { rows } = await pool.query(
4✔
135
      `SELECT * FROM warnings
136
       WHERE ${conditions.join(' AND ')}
137
       ORDER BY created_at DESC
138
       LIMIT $${values.length + 1} OFFSET $${values.length + 2}`,
139
      [...values, limit, offset],
140
    );
141

142
    return rows;
4✔
143
  } catch (err) {
NEW
144
    logError('Failed to get warnings', { error: err.message, guildId, userId });
×
NEW
145
    throw err;
×
146
  }
147
}
148

149
/**
150
 * Get the number of active warnings and the total active warning points for a user in a guild.
151
 * @param {string} guildId - Guild identifier.
152
 * @param {string} userId - User identifier to query.
153
 * @returns {{count: number, points: number}} Object with `count` equal to the number of active warnings and `points` equal to the sum of their points.
154
 */
155
export async function getActiveWarningStats(guildId, userId) {
156
  const pool = getPool();
3✔
157

158
  try {
3✔
159
    const { rows } = await pool.query(
3✔
160
      `SELECT
161
         COUNT(*)::integer AS count,
162
         COALESCE(SUM(points), 0)::integer AS points
163
       FROM warnings
164
       WHERE guild_id = $1 AND user_id = $2 AND active = TRUE
165
         AND (expires_at IS NULL OR expires_at > NOW())`,
166
      [guildId, userId],
167
    );
168

169
    return {
3✔
170
      count: rows[0]?.count ?? 0,
4✔
171
      points: rows[0]?.points ?? 0,
4✔
172
    };
173
  } catch (err) {
NEW
174
    logError('Failed to get active warning stats', { error: err.message, guildId, userId });
×
NEW
175
    throw err;
×
176
  }
177
}
178

179
/**
180
 * Edit a warning's reason and/or severity.
181
 * Recalculates and updates the warning's points when the severity is changed.
182
 * @param {string} guildId - Discord guild ID.
183
 * @param {number} warningId - Warning ID.
184
 * @param {Object} updates - Fields to update.
185
 * @param {string} [updates.reason] - New reason text.
186
 * @param {string} [updates.severity] - New severity level (e.g., 'low', 'medium', 'high').
187
 * @param {Object} [config] - Bot configuration used to recalculate severity points when severity changes.
188
 * @returns {Object|null} The updated warning row, or `null` if no matching warning was found.
189
 */
190
export async function editWarning(guildId, warningId, updates, config) {
191
  const pool = getPool();
4✔
192

193
  try {
4✔
194
    // Fetch original for audit trail
195
    const { rows: origRows } = await pool.query(
4✔
196
      'SELECT reason, severity, points FROM warnings WHERE guild_id = $1 AND id = $2',
197
      [guildId, warningId],
198
    );
199
    const original = origRows[0] || null;
4✔
200

201
    // Build dynamic SET clause
202
    const setClauses = ['updated_at = NOW()'];
4✔
203
    const values = [];
4✔
204
    let paramIdx = 1;
4✔
205

206
    if (updates.reason !== undefined) {
4✔
207
      setClauses.push(`reason = $${paramIdx++}`);
2✔
208
      values.push(updates.reason);
2✔
209
    }
210

211
    if (updates.severity !== undefined) {
4✔
212
      setClauses.push(`severity = $${paramIdx++}`);
2✔
213
      values.push(updates.severity);
2✔
214
      // Recalculate points when severity changes
215
      const newPoints = getSeverityPoints(config, updates.severity);
2✔
216
      setClauses.push(`points = $${paramIdx++}`);
2✔
217
      values.push(newPoints);
2✔
218
    }
219

220
    values.push(guildId, warningId);
4✔
221

222
    const { rows } = await pool.query(
4✔
223
      `UPDATE warnings
224
       SET ${setClauses.join(', ')}
225
       WHERE guild_id = $${paramIdx++} AND id = $${paramIdx}
226
       RETURNING *`,
227
      values,
228
    );
229

230
    if (rows.length === 0) return null;
4✔
231

232
    info('Warning edited', {
3✔
233
      guildId,
234
      warningId,
235
      updates: Object.keys(updates),
236
      previous: original
3!
237
        ? {
238
            reason: original.reason,
239
            severity: original.severity,
240
            points: original.points,
241
          }
242
        : null,
243
    });
244

245
    return rows[0];
4✔
246
  } catch (err) {
NEW
247
    logError('Failed to edit warning', { error: err.message, guildId, warningId });
×
NEW
248
    throw err;
×
249
  }
250
}
251

252
/**
253
 * Deactivate a specific active warning and record who removed it and why.
254
 * @param {string} guildId - Guild identifier the warning belongs to.
255
 * @param {number} warningId - ID of the warning to remove.
256
 * @param {string} removedBy - Moderator user ID who performed the removal.
257
 * @param {string} [removalReason] - Optional reason for the removal.
258
 * @returns {Object|null} The updated warning row if a warning was deactivated, `null` if no active warning matched.
259
 */
260
export async function removeWarning(guildId, warningId, removedBy, removalReason) {
261
  const pool = getPool();
2✔
262

263
  try {
2✔
264
    const { rows } = await pool.query(
2✔
265
      `UPDATE warnings
266
       SET active = FALSE, removed_at = NOW(), removed_by = $1, removal_reason = $2, updated_at = NOW()
267
       WHERE guild_id = $3 AND id = $4 AND active = TRUE
268
       RETURNING *`,
269
      [removedBy, removalReason || null, guildId, warningId],
3✔
270
    );
271

272
    if (rows.length === 0) return null;
2✔
273

274
    info('Warning removed', {
1✔
275
      guildId,
276
      warningId,
277
      removedBy,
278
    });
279

280
    return rows[0];
1✔
281
  } catch (err) {
NEW
282
    logError('Failed to remove warning', { error: err.message, guildId, warningId });
×
NEW
283
    throw err;
×
284
  }
285
}
286

287
/**
288
 * Clear all active warnings for a user in a guild.
289
 * @param {string} guildId - Discord guild ID.
290
 * @param {string} userId - Target user ID.
291
 * @param {string} clearedBy - Moderator user ID who cleared the warnings.
292
 * @param {string} [reason] - Reason for clearing; defaults to 'Bulk clear' when omitted.
293
 * @returns {number} Number of warnings cleared.
294
 */
295
export async function clearWarnings(guildId, userId, clearedBy, reason) {
296
  const pool = getPool();
3✔
297

298
  try {
3✔
299
    const { rowCount } = await pool.query(
3✔
300
      `UPDATE warnings
301
       SET active = FALSE, removed_at = NOW(), removed_by = $1, removal_reason = $2, updated_at = NOW()
302
       WHERE guild_id = $3 AND user_id = $4 AND active = TRUE`,
303
      [clearedBy, reason || 'Bulk clear', guildId, userId],
5✔
304
    );
305

306
    if (rowCount > 0) {
3✔
307
      info('Warnings cleared', {
2✔
308
        guildId,
309
        userId,
310
        clearedBy,
311
        count: rowCount,
312
      });
313
    }
314

315
    return rowCount;
3✔
316
  } catch (err) {
NEW
317
    logError('Failed to clear warnings', { error: err.message, guildId, userId });
×
NEW
318
    throw err;
×
319
  }
320
}
321

322
/**
323
 * Deactivate active warnings whose expiry timestamp has passed.
324
 *
325
 * @returns {number} Number of warnings deactivated; returns 0 if none were expired or if processing failed.
326
 */
327
export async function processExpiredWarnings() {
328
  const pool = getPool();
18✔
329

330
  try {
18✔
331
    const { rowCount } = await pool.query(
18✔
332
      `UPDATE warnings
333
       SET active = FALSE, removed_at = NOW(), removal_reason = 'Expired', updated_at = NOW()
334
       WHERE active = TRUE AND expires_at IS NOT NULL AND expires_at <= NOW()`,
335
    );
336

337
    if (rowCount > 0) {
6✔
338
      info('Expired warnings processed', { count: rowCount });
1✔
339
    }
340

341
    return rowCount;
6✔
342
  } catch (err) {
343
    logError('Failed to process expired warnings', { error: err.message });
1✔
344
    return 0;
1✔
345
  }
346
}
347

348
/**
349
 * Start the warning expiry scheduler.
350
 *
351
 * Performs an immediate expiry check and then schedules a poll every 60 seconds to deactivate warnings past their expiry.
352
 * If the scheduler is already running, the function returns without side effects. Each poll is guarded to prevent concurrent runs.
353
 */
354
export function startWarningExpiryScheduler() {
355
  if (expiryInterval) return;
15✔
356

357
  // Immediate check on startup
358
  processExpiredWarnings().catch((err) => {
14✔
359
    logError('Initial warning expiry poll failed', { error: err.message });
11✔
360
  });
361

362
  expiryInterval = setInterval(() => {
14✔
363
    if (expiryPollInFlight) return;
1!
364
    expiryPollInFlight = true;
1✔
365

366
    processExpiredWarnings()
1✔
367
      .catch((err) => {
NEW
368
        logError('Warning expiry poll failed', { error: err.message });
×
369
      })
370
      .finally(() => {
371
        expiryPollInFlight = false;
1✔
372
      });
373
  }, 60_000);
374

375
  info('Warning expiry scheduler started');
14✔
376
}
377

378
/**
379
 * Stop the warning expiry scheduler.
380
 */
381
export function stopWarningExpiryScheduler() {
382
  if (expiryInterval) {
39✔
383
    clearInterval(expiryInterval);
5✔
384
    expiryInterval = null;
5✔
385
    info('Warning expiry scheduler stopped');
5✔
386
  }
387
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc