Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
422f176
feat(redis): add centralized Redis client and cache utilities
BillChirico Mar 2, 2026
6859442
feat(redis): integrate Redis into startup/shutdown and health endpoint
BillChirico Mar 2, 2026
67075f7
feat(cache): add Discord API and reputation cache layers
BillChirico Mar 2, 2026
a42bfab
feat(cache): integrate caching into modules and API routes
BillChirico Mar 2, 2026
713cd4d
feat(cache): config invalidation, tests, and test mocks for all modules
BillChirico Mar 2, 2026
a82a08e
feat(redis): add distributed Redis-backed rate limiter
BillChirico Mar 2, 2026
1e92e97
chore: add Redis service to Railway and Docker Compose
BillChirico Mar 2, 2026
c6f09b6
fix: check pipeline command errors and remove unused windowSec
BillChirico Mar 2, 2026
193aaa8
fix: remove unused cacheGet and cacheSet imports from community routes
BillChirico Mar 2, 2026
a8d573e
fix: standardize leaderboard cache invalidation to use pattern delete
BillChirico Mar 2, 2026
4c68ec5
fix: repair corrupted JSDoc header in reviewHandler.js
BillChirico Mar 2, 2026
7f7e798
fix: move cache invalidation after all reputation DB writes
BillChirico Mar 2, 2026
d0ace9a
fix: escape regex metacharacters in glob-to-regex conversion
BillChirico Mar 2, 2026
6efff59
fix: replace flushdb() with prefix-scoped SCAN+DEL in cacheClear
BillChirico Mar 2, 2026
6aac00f
fix: close existing connection in _resetRedis to prevent leaks
BillChirico Mar 2, 2026
196c84e
fix: return Redis-cached channel metadata on DJS cache miss
BillChirico Mar 2, 2026
fc6c67d
fix: replace dynamic import with static import for cacheDelPattern
BillChirico Mar 2, 2026
648527e
fix: wrap fake timers in try/finally to prevent timer leaks
BillChirico Mar 2, 2026
3181c26
chore: remove flushdb mock from cache test (no longer called)
BillChirico Mar 2, 2026
1f0eae5
fix: update reputationCache tests for paginated leaderboard keys
BillChirico Mar 2, 2026
60862ce
fix: correct leaderboard cache key pattern and fetchChannelCached ret…
BillChirico Mar 2, 2026
5c0cbd5
fix: make _resetRedisClient async so callers can await full reset
BillChirico Mar 2, 2026
e98ae3a
test: await _resetRedis() in hooks; assert new cache-invalidation lis…
BillChirico Mar 2, 2026
600b870
feat(redis): wire distributed rate limiter and extend cache to commands
BillChirico Mar 2, 2026
e0c1bf4
fix: resolve merge conflicts with main
BillChirico Mar 2, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 6 additions & 2 deletions src/api/routes/community.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,12 +12,16 @@ import { getConfig } from '../../modules/config.js';
import { computeLevel } from '../../modules/reputation.js';
import { REPUTATION_DEFAULTS } from '../../modules/reputationDefaults.js';
import { cacheGetOrSet, TTL } from '../../utils/cache.js';
import { rateLimit } from '../middleware/rateLimit.js';
import { redisRateLimit } from '../middleware/redisRateLimit.js';

const router = Router();

/** Aggressive rate limiter for public endpoints: 30 req/min per IP */
const communityRateLimit = rateLimit({ windowMs: 60 * 1000, max: 30 });
const communityRateLimit = redisRateLimit({
windowMs: 60 * 1000,
max: 30,
keyPrefix: 'rl:community',
});
router.use(communityRateLimit);

/**
Expand Down
6 changes: 3 additions & 3 deletions src/api/server.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import express from 'express';
import { error, info, warn } from '../logger.js';
import { PerformanceMonitor } from '../modules/performanceMonitor.js';
import apiRouter from './index.js';
import { rateLimit } from './middleware/rateLimit.js';
import { redisRateLimit } from './middleware/redisRateLimit.js';
import { stopAuthCleanup } from './routes/auth.js';
import { swaggerSpec } from './swagger.js';
import { stopGuildCacheCleanup } from './utils/discordApi.js';
Expand All @@ -17,7 +17,7 @@ import { setupLogStream, stopLogStream } from './ws/logStream.js';
/** @type {import('node:http').Server | null} */
let server = null;

/** @type {ReturnType<typeof rateLimit> | null} */
/** @type {ReturnType<typeof redisRateLimit> | null} */
let rateLimiter = null;

/**
Expand Down Expand Up @@ -64,7 +64,7 @@ export function createApp(client, dbPool) {
rateLimiter.destroy();
rateLimiter = null;
}
rateLimiter = rateLimit();
rateLimiter = redisRateLimit();
app.use(rateLimiter);

// Raw OpenAPI spec (JSON) — public for Mintlify
Expand Down
20 changes: 12 additions & 8 deletions src/commands/leaderboard.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { EmbedBuilder, SlashCommandBuilder } from 'discord.js';
import { getPool } from '../db.js';
import { error as logError } from '../logger.js';
import { getConfig } from '../modules/config.js';
import { getLeaderboardCached } from '../utils/reputationCache.js';
import { safeEditReply } from '../utils/safeSend.js';

export const data = new SlashCommandBuilder()
Expand All @@ -30,14 +31,17 @@ export async function execute(interaction) {

try {
const pool = getPool();
const { rows } = await pool.query(
`SELECT user_id, xp, level
FROM reputation
WHERE guild_id = $1
ORDER BY xp DESC
LIMIT 10`,
[interaction.guildId],
);
const rows = await getLeaderboardCached(interaction.guildId, async () => {
const result = await pool.query(
`SELECT user_id, xp, level
FROM reputation
WHERE guild_id = $1
ORDER BY xp DESC
LIMIT 10`,
[interaction.guildId],
);
return result.rows;
});
Comment on lines +34 to +44
Copy link

Copilot AI Mar 2, 2026

Choose a reason for hiding this comment

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

Add/update tests in tests/commands/leaderboard.test.js to assert the caching wrapper is used (e.g., getLeaderboardCached called with the guild id) and to validate behavior on cache hit vs miss (DB query executed only when the cache factory runs). This ensures the new integration actually prevents redundant DB calls.

Copilot uses AI. Check for mistakes.

if (rows.length === 0) {
await safeEditReply(interaction, {
Expand Down
45 changes: 30 additions & 15 deletions src/commands/rank.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,11 @@ import { error as logError } from '../logger.js';
import { getConfig } from '../modules/config.js';
import { buildProgressBar, computeLevel } from '../modules/reputation.js';
import { REPUTATION_DEFAULTS } from '../modules/reputationDefaults.js';
import {
getRankCached,
getReputationCached,
setReputationCache,
} from '../utils/reputationCache.js';
import { safeEditReply } from '../utils/safeSend.js';

export const data = new SlashCommandBuilder()
Expand Down Expand Up @@ -43,15 +48,23 @@ export async function execute(interaction) {
const repCfg = { ...REPUTATION_DEFAULTS, ...cfg.reputation };
const thresholds = repCfg.levelThresholds;

// Fetch reputation row
const { rows } = await pool.query(
'SELECT xp, level, messages_count FROM reputation WHERE guild_id = $1 AND user_id = $2',
[interaction.guildId, target.id],
);
// Fetch reputation row (cached)
const cachedRep = await getReputationCached(interaction.guildId, target.id);
let repRow = cachedRep;
if (!repRow) {
const { rows } = await pool.query(
'SELECT xp, level, messages_count FROM reputation WHERE guild_id = $1 AND user_id = $2',
[interaction.guildId, target.id],
);
repRow = rows[0] ?? null;
if (repRow) {
await setReputationCache(interaction.guildId, target.id, repRow);
}
}
Comment on lines +51 to +63
Copy link

Copilot AI Mar 2, 2026

Choose a reason for hiding this comment

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

The new cache branches (rep cache hit vs miss; rank cache factory invocation) aren’t directly asserted in tests. Add coverage in tests/commands/rank.test.js to validate: (1) on cache hit, pool.query is not called and setReputationCache is not called; (2) on cache miss, pool.query is called and setReputationCache is called with the fetched row; (3) getRankCached bypasses the DB query when returning a cached value.

Copilot uses AI. Check for mistakes.

const xp = rows[0]?.xp ?? 0;
const xp = repRow?.xp ?? 0;
const level = computeLevel(xp, thresholds);
const messagesCount = rows[0]?.messages_count ?? 0;
const messagesCount = repRow?.messages_count ?? 0;

// XP within current level and needed for next
const currentThreshold = level > 0 ? thresholds[level - 1] : 0;
Expand All @@ -62,14 +75,16 @@ export async function execute(interaction) {
const progressBar =
nextThreshold !== null ? buildProgressBar(xpInLevel, xpNeeded) : `${'▓'.repeat(10)} MAX`;

// Rank position in guild
const rankRow = await pool.query(
`SELECT COUNT(*) + 1 AS rank
FROM reputation
WHERE guild_id = $1 AND xp > $2`,
[interaction.guildId, xp],
);
const rank = Number(rankRow.rows[0]?.rank ?? 1);
// Rank position in guild (cached)
const rank = await getRankCached(interaction.guildId, target.id, async () => {
const rankRow = await pool.query(
`SELECT COUNT(*) + 1 AS rank
FROM reputation
WHERE guild_id = $1 AND xp > $2`,
[interaction.guildId, xp],
);
return { rank: Number(rankRow.rows[0]?.rank ?? 1) };
}).then((r) => r?.rank ?? 1);
Comment on lines +78 to +87
Copy link

Copilot AI Mar 2, 2026

Choose a reason for hiding this comment

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

The new cache branches (rep cache hit vs miss; rank cache factory invocation) aren’t directly asserted in tests. Add coverage in tests/commands/rank.test.js to validate: (1) on cache hit, pool.query is not called and setReputationCache is not called; (2) on cache miss, pool.query is called and setReputationCache is called with the fetched row; (3) getRankCached bypasses the DB query when returning a cached value.

Copilot uses AI. Check for mistakes.
Comment on lines +79 to +87
Copy link

Copilot AI Mar 2, 2026

Choose a reason for hiding this comment

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

Mixing await with a chained .then(...) makes the flow harder to read and slightly harder to debug. Prefer awaiting the cached result into a variable and then deriving rank from that value in a separate statement.

Copilot uses AI. Check for mistakes.

const levelLabel = `Level ${level}`;
const xpLabel = nextThreshold !== null ? `${xp} / ${nextThreshold} XP` : `${xp} XP (Max Level)`;
Expand Down
9 changes: 5 additions & 4 deletions src/commands/review.js
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import {
STATUS_LABELS,
updateReviewMessage,
} from '../modules/reviewHandler.js';
import { fetchChannelCached } from '../utils/discordCache.js';
import { safeEditReply } from '../utils/safeSend.js';

export const data = new SlashCommandBuilder()
Expand Down Expand Up @@ -159,10 +160,10 @@ async function handleRequest(interaction, pool, guildConfig) {
let targetChannel = interaction.channel;

if (reviewChannelId && reviewChannelId !== interaction.channelId) {
try {
const fetched = await interaction.client.channels.fetch(reviewChannelId);
if (fetched) targetChannel = fetched;
} catch {
const fetched = await fetchChannelCached(interaction.client, reviewChannelId);
if (fetched) {
targetChannel = fetched;
} else {
warn('Review channel not found, using current channel', {
reviewChannelId,
guildId: interaction.guildId,
Expand Down
12 changes: 6 additions & 6 deletions src/commands/welcome.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
buildRulesAgreementMessage,
normalizeWelcomeOnboardingConfig,
} from '../modules/welcomeOnboarding.js';
import { fetchChannelCached } from '../utils/discordCache.js';
import { isModerator } from '../utils/permissions.js';
import { safeEditReply, safeSend } from '../utils/safeSend.js';

Expand Down Expand Up @@ -36,9 +37,7 @@ export async function execute(interaction) {
const resultLines = [];

if (onboarding.rulesChannel) {
const rulesChannel =
interaction.guild.channels.cache.get(onboarding.rulesChannel) ||
(await interaction.guild.channels.fetch(onboarding.rulesChannel).catch(() => null));
const rulesChannel = await fetchChannelCached(interaction.client, onboarding.rulesChannel);

if (rulesChannel?.isTextBased?.()) {
const rulesMsg = buildRulesAgreementMessage();
Expand All @@ -53,9 +52,10 @@ export async function execute(interaction) {

const roleMenuMsg = buildRoleMenuMessage(guildConfig?.welcome);
if (roleMenuMsg && guildConfig?.welcome?.channelId) {
const welcomeChannel =
interaction.guild.channels.cache.get(guildConfig.welcome.channelId) ||
(await interaction.guild.channels.fetch(guildConfig.welcome.channelId).catch(() => null));
const welcomeChannel = await fetchChannelCached(
interaction.client,
guildConfig.welcome.channelId,
);

if (welcomeChannel?.isTextBased?.()) {
await safeSend(welcomeChannel, roleMenuMsg);
Expand Down
Loading
Loading