From 3c1f9b23dae1385c3400c5114f036025f1f7a379 Mon Sep 17 00:00:00 2001 From: dharm2112 Date: Sun, 22 Feb 2026 15:35:32 +0530 Subject: [PATCH 1/3] Update DATABASE_URL format in .env.example --- .env.example | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.env.example b/.env.example index 967a1a4..2a0dd36 100644 --- a/.env.example +++ b/.env.example @@ -1,7 +1,7 @@ # Environment Configuration NODE_ENV=development PORT=3000 - +1 # Database Configuration # PostgreSQL connection string # Format: postgresql://USER:PASSWORD@HOST:PORT/DATABASE From 33d02c2db82b5c3bd1df7b3153bcaa6a14836100 Mon Sep 17 00:00:00 2001 From: dharm2112 Date: Sun, 22 Feb 2026 15:41:36 +0530 Subject: [PATCH 2/3] Fix formatting in .env.example --- .env.example | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.env.example b/.env.example index 2a0dd36..967a1a4 100644 --- a/.env.example +++ b/.env.example @@ -1,7 +1,7 @@ # Environment Configuration NODE_ENV=development PORT=3000 -1 + # Database Configuration # PostgreSQL connection string # Format: postgresql://USER:PASSWORD@HOST:PORT/DATABASE From 482c4dc9f558940b5eea2168c4994075aa1fe23b Mon Sep 17 00:00:00 2001 From: dharm2112 Date: Sun, 22 Feb 2026 16:09:16 +0530 Subject: [PATCH 3/3] Implement Redis caching for leaderboard data and add cache invalidation on relevant updates --- .env.example | 4 + package-lock.json | 112 ++++++++++++++++++ package.json | 13 ++- src/config/env.js | 3 + src/config/redis.js | 86 ++++++++++++++ src/controllers/dashboard.controller.js | 33 ++++-- src/server.js | 4 + src/services/cache.service.js | 144 ++++++++++++++++++++++++ src/services/challenge.service.js | 8 ++ src/services/evaluation.service.js | 25 +++- src/services/penalty.service.js | 15 +++ 11 files changed, 426 insertions(+), 21 deletions(-) create mode 100644 src/config/redis.js create mode 100644 src/services/cache.service.js diff --git a/.env.example b/.env.example index 967a1a4..f877193 100644 --- a/.env.example +++ b/.env.example @@ -32,3 +32,7 @@ DAILY_EVALUATION_TIME=0 1 * * * # CORS Configuration # Use '*' for development, specify frontend URL for production CORS_ORIGIN=* + +# Redis Configuration (optional - used for leaderboard caching) +# If Redis is unavailable, the app falls back to direct database queries +REDIS_URL=redis://localhost:6379 diff --git a/package-lock.json b/package-lock.json index 3a8e95c..a064539 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16,6 +16,7 @@ "dotenv": "^16.3.1", "express": "^4.18.2", "express-validator": "^7.0.1", + "ioredis": "^5.9.3", "jsonwebtoken": "^9.0.2", "node-cron": "^3.0.3", "prisma": "^5.8.0", @@ -45,6 +46,12 @@ "kuler": "^2.0.0" } }, + "node_modules/@ioredis/commands": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/@ioredis/commands/-/commands-1.5.0.tgz", + "integrity": "sha512-eUgLqrMf8nJkZxT24JvVRrQya1vZkQh8BBeYNwGDqa5I0VUi8ACx7uFvAaLxintokpTenkK6DASvo/bvNbBGow==", + "license": "MIT" + }, "node_modules/@prisma/client": { "version": "5.22.0", "resolved": "https://registry.npmjs.org/@prisma/client/-/client-5.22.0.tgz", @@ -323,6 +330,15 @@ "fsevents": "~2.3.2" } }, + "node_modules/cluster-key-slot": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/cluster-key-slot/-/cluster-key-slot-1.1.2.tgz", + "integrity": "sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/color": { "version": "5.0.3", "resolved": "https://registry.npmjs.org/color/-/color-5.0.3.tgz", @@ -455,6 +471,15 @@ "node": ">=0.4.0" } }, + "node_modules/denque": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/denque/-/denque-2.1.0.tgz", + "integrity": "sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.10" + } + }, "node_modules/depd": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", @@ -925,6 +950,53 @@ "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", "license": "ISC" }, + "node_modules/ioredis": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/ioredis/-/ioredis-5.9.3.tgz", + "integrity": "sha512-VI5tMCdeoxZWU5vjHWsiE/Su76JGhBvWF1MJnV9ZtGltHk9BmD48oDq8Tj8haZ85aceXZMxLNDQZRVo5QKNgXA==", + "license": "MIT", + "dependencies": { + "@ioredis/commands": "1.5.0", + "cluster-key-slot": "^1.1.0", + "debug": "^4.3.4", + "denque": "^2.1.0", + "lodash.defaults": "^4.2.0", + "lodash.isarguments": "^3.1.0", + "redis-errors": "^1.2.0", + "redis-parser": "^3.0.0", + "standard-as-callback": "^2.1.0" + }, + "engines": { + "node": ">=12.22.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/ioredis" + } + }, + "node_modules/ioredis/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/ioredis/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, "node_modules/ipaddr.js": { "version": "1.9.1", "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", @@ -1053,12 +1125,24 @@ "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", "license": "MIT" }, + "node_modules/lodash.defaults": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/lodash.defaults/-/lodash.defaults-4.2.0.tgz", + "integrity": "sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==", + "license": "MIT" + }, "node_modules/lodash.includes": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", "integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==", "license": "MIT" }, + "node_modules/lodash.isarguments": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/lodash.isarguments/-/lodash.isarguments-3.1.0.tgz", + "integrity": "sha512-chi4NHZlZqZD18a0imDHnZPrDeBbTtVN7GXMwuGdRH9qotxAjYs3aVLKc7zNOG9eddR5Ksd8rvFEBc9SsggPpg==", + "license": "MIT" + }, "node_modules/lodash.isboolean": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz", @@ -1367,6 +1451,7 @@ "integrity": "sha512-vtpjW3XuYCSnMsNVBjLMNkTj6OZbudcPPTPYHqX0CJfpcdWciI1dM8uHETwmDxxiqEwCIE6WvXucWUetJgfu/A==", "hasInstallScript": true, "license": "Apache-2.0", + "peer": true, "dependencies": { "@prisma/engines": "5.22.0" }, @@ -1472,6 +1557,27 @@ "node": ">=8.10.0" } }, + "node_modules/redis-errors": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/redis-errors/-/redis-errors-1.2.0.tgz", + "integrity": "sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/redis-parser": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/redis-parser/-/redis-parser-3.0.0.tgz", + "integrity": "sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A==", + "license": "MIT", + "dependencies": { + "redis-errors": "^1.0.0" + }, + "engines": { + "node": ">=4" + } + }, "node_modules/safe-buffer": { "version": "5.2.1", "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", @@ -1664,6 +1770,12 @@ "node": "*" } }, + "node_modules/standard-as-callback": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/standard-as-callback/-/standard-as-callback-2.1.0.tgz", + "integrity": "sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==", + "license": "MIT" + }, "node_modules/statuses": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", diff --git a/package.json b/package.json index 7ffad32..7695f80 100644 --- a/package.json +++ b/package.json @@ -19,16 +19,17 @@ "author": "", "license": "ISC", "dependencies": { - "express": "^4.18.2", - "prisma": "^5.8.0", "@prisma/client": "^5.8.0", - "bcryptjs": "^2.4.3", - "jsonwebtoken": "^9.0.2", - "dotenv": "^16.3.1", - "node-cron": "^3.0.3", "axios": "^1.6.5", + "bcryptjs": "^2.4.3", "cors": "^2.8.5", + "dotenv": "^16.3.1", + "express": "^4.18.2", "express-validator": "^7.0.1", + "ioredis": "^5.9.3", + "jsonwebtoken": "^9.0.2", + "node-cron": "^3.0.3", + "prisma": "^5.8.0", "winston": "^3.11.0" }, "devDependencies": { diff --git a/src/config/env.js b/src/config/env.js index 66625e0..a16b5fd 100644 --- a/src/config/env.js +++ b/src/config/env.js @@ -28,6 +28,9 @@ const config = { // CORS Configuration corsOrigin: process.env.CORS_ORIGIN || "*", + + // Redis Configuration + redisUrl: process.env.REDIS_URL || "redis://localhost:6379", }; // Validate critical environment variables diff --git a/src/config/redis.js b/src/config/redis.js new file mode 100644 index 0000000..cada850 --- /dev/null +++ b/src/config/redis.js @@ -0,0 +1,86 @@ +const Redis = require("ioredis"); +const { config } = require("./env"); +const logger = require("../utils/logger"); + +let redisClient = null; +let isConnected = false; + +/** + * Create and configure Redis client with error handling. + * If Redis is unavailable, the app continues to work without caching. + */ +const createRedisClient = () => { + try { + redisClient = new Redis(config.redisUrl, { + maxRetriesPerRequest: 1, + retryStrategy(times) { + if (times > 3) { + logger.warn("Redis: max reconnection attempts reached, giving up"); + return null; // Stop retrying + } + return Math.min(times * 500, 2000); + }, + lazyConnect: true, + }); + + redisClient.on("connect", () => { + isConnected = true; + logger.info("Redis: connected successfully"); + }); + + redisClient.on("error", (err) => { + isConnected = false; + logger.warn(`Redis: connection error - ${err.message}`); + }); + + redisClient.on("close", () => { + isConnected = false; + logger.info("Redis: connection closed"); + }); + + // Attempt to connect (non-blocking) + redisClient.connect().catch((err) => { + isConnected = false; + logger.warn(`Redis: initial connection failed - ${err.message}. Falling back to database queries.`); + }); + } catch (err) { + logger.warn(`Redis: failed to create client - ${err.message}. Caching disabled.`); + redisClient = null; + isConnected = false; + } +}; + +/** + * Check if Redis is ready to accept commands + * @returns {boolean} + */ +const isRedisReady = () => { + return redisClient !== null && isConnected; +}; + +/** + * Get the Redis client instance + * @returns {Redis|null} + */ +const getRedisClient = () => { + return redisClient; +}; + +/** + * Gracefully disconnect Redis + */ +const disconnectRedis = async () => { + if (redisClient) { + try { + await redisClient.quit(); + logger.info("Redis: disconnected gracefully"); + } catch (err) { + logger.warn(`Redis: error during disconnect - ${err.message}`); + } + } +}; + +// Initialize on module load +createRedisClient(); + +module.exports = { getRedisClient, isRedisReady, disconnectRedis }; diff --git a/src/controllers/dashboard.controller.js b/src/controllers/dashboard.controller.js index 1e32004..8582549 100644 --- a/src/controllers/dashboard.controller.js +++ b/src/controllers/dashboard.controller.js @@ -3,6 +3,7 @@ const evaluationService = require("../services/evaluation.service"); const penaltyService = require("../services/penalty.service"); const statsService = require("../services/stats.service"); const { asyncHandler } = require("../middlewares/error.middleware"); +const { getLeaderboardCache, setLeaderboardCache } = require("../services/cache.service"); /** * Get dashboard overview for current user @@ -64,10 +65,10 @@ const getDashboard = asyncHandler(async (req, res) => { totalPenalties: membership.totalPenalties, todayStatus: todayResult ? { - completed: todayResult.completed, - submissionsCount: todayResult.submissionsCount, - evaluatedAt: todayResult.evaluatedAt, - } + completed: todayResult.completed, + submissionsCount: todayResult.submissionsCount, + evaluatedAt: todayResult.evaluatedAt, + } : null, recentResults: recentResults.map((r) => ({ date: r.date, @@ -166,7 +167,16 @@ const getChallengeProgress = asyncHandler(async (req, res) => { const getChallengeLeaderboard = asyncHandler(async (req, res) => { const { challengeId } = req.params; - // Get all members with their stats + // Check cache first + const cachedData = await getLeaderboardCache(challengeId); + if (cachedData) { + return res.status(200).json({ + success: true, + data: cachedData, + }); + } + + // Cache miss — query database (original logic unchanged) const members = await prisma.challengeMember.findMany({ where: { challengeId, @@ -212,6 +222,9 @@ const getChallengeLeaderboard = asyncHandler(async (req, res) => { }) ); + // Store in cache (fire-and-forget, errors handled internally) + await setLeaderboardCache(challengeId, leaderboard); + res.status(200).json({ success: true, data: leaderboard, @@ -266,11 +279,11 @@ const getTodayStatus = asyncHandler(async (req, res) => { requiredSubmissions: membership.challenge.minSubmissionsPerDay, status: result ? { - completed: result.completed, - submissionsCount: result.submissionsCount, - problemsSolved: result.problemsSolved, - evaluatedAt: result.evaluatedAt, - } + completed: result.completed, + submissionsCount: result.submissionsCount, + problemsSolved: result.problemsSolved, + evaluatedAt: result.evaluatedAt, + } : null, }; }) diff --git a/src/server.js b/src/server.js index ff036d7..000cfbf 100644 --- a/src/server.js +++ b/src/server.js @@ -1,6 +1,7 @@ const createApp = require("./app"); const { config, validateConfig } = require("./config/env"); const { disconnectPrisma } = require("./config/prisma"); +const { disconnectRedis } = require("./config/redis"); const cronManager = require("./config/cron"); const logger = require("./utils/logger"); @@ -38,6 +39,9 @@ const startServer = async () => { // Disconnect from database await disconnectPrisma(); + // Disconnect Redis + await disconnectRedis(); + process.exit(0); }); diff --git a/src/services/cache.service.js b/src/services/cache.service.js new file mode 100644 index 0000000..56fe421 --- /dev/null +++ b/src/services/cache.service.js @@ -0,0 +1,144 @@ +const { getRedisClient, isRedisReady } = require("../config/redis"); +const logger = require("../utils/logger"); + +const CACHE_PREFIX = "leaderboard:"; +const DEFAULT_TTL = 60; // seconds + +// In-memory fallback cache (used when Redis is unavailable) +const memoryCache = new Map(); + +/** + * Build the cache key for a challenge leaderboard + * @param {string} challengeId + * @returns {string} + */ +const buildKey = (challengeId) => `${CACHE_PREFIX}${challengeId}`; + +/** + * Get data from in-memory fallback cache + * @param {string} key + * @returns {*|null} + */ +const memoryGet = (key) => { + const entry = memoryCache.get(key); + if (!entry) return null; + if (Date.now() > entry.expiresAt) { + memoryCache.delete(key); + return null; + } + return entry.data; +}; + +/** + * Set data in in-memory fallback cache + * @param {string} key + * @param {*} data + * @param {number} ttl - Time to live in seconds + */ +const memorySet = (key, data, ttl) => { + memoryCache.set(key, { + data, + expiresAt: Date.now() + ttl * 1000, + }); +}; + +/** + * Delete data from in-memory fallback cache + * @param {string} key + */ +const memoryDel = (key) => { + memoryCache.delete(key); +}; + +/** + * Get cached leaderboard data for a challenge. + * Tries Redis first, falls back to in-memory cache. + * Returns parsed data or null if cache miss. + * @param {string} challengeId + * @returns {Promise} + */ +const getLeaderboardCache = async (challengeId) => { + const key = buildKey(challengeId); + + // Try Redis first + if (isRedisReady()) { + try { + const data = await getRedisClient().get(key); + if (data) { + logger.info(`Cache HIT (Redis) for leaderboard:${challengeId}`); + return JSON.parse(data); + } + logger.info(`Cache MISS for leaderboard:${challengeId}`); + return null; + } catch (err) { + logger.warn(`Redis read error for leaderboard:${challengeId} - ${err.message}`); + } + } + + // Fallback to in-memory cache + const memData = memoryGet(key); + if (memData) { + logger.info(`Cache HIT (in-memory) for leaderboard:${challengeId}`); + return memData; + } + + logger.info(`Cache MISS for leaderboard:${challengeId}`); + return null; +}; + +/** + * Store leaderboard data in cache with TTL. + * Stores in both Redis and in-memory fallback. + * Silently fails if errors occur. + * @param {string} challengeId + * @param {Array} data + * @param {number} ttl - Time to live in seconds (default 60) + */ +const setLeaderboardCache = async (challengeId, data, ttl = DEFAULT_TTL) => { + const key = buildKey(challengeId); + + // Always store in in-memory fallback + memorySet(key, data, ttl); + + // Try Redis + if (isRedisReady()) { + try { + await getRedisClient().set(key, JSON.stringify(data), "EX", ttl); + logger.info(`Cache SET (Redis) for leaderboard:${challengeId} (TTL: ${ttl}s)`); + } catch (err) { + logger.warn(`Redis write error for leaderboard:${challengeId} - ${err.message}`); + } + } else { + logger.info(`Cache SET (in-memory) for leaderboard:${challengeId} (TTL: ${ttl}s)`); + } +}; + +/** + * Invalidate (delete) cached leaderboard data for a challenge. + * Called when leaderboard-affecting data changes. + * Clears both Redis and in-memory cache. + * Silently fails if errors occur. + * @param {string} challengeId + */ +const invalidateLeaderboardCache = async (challengeId) => { + const key = buildKey(challengeId); + + // Always clear in-memory fallback + memoryDel(key); + + // Try Redis + if (isRedisReady()) { + try { + await getRedisClient().del(key); + logger.info(`Cache INVALIDATED for leaderboard:${challengeId}`); + } catch (err) { + logger.warn(`Cache invalidation error for leaderboard:${challengeId} - ${err.message}`); + } + } +}; + +module.exports = { + getLeaderboardCache, + setLeaderboardCache, + invalidateLeaderboardCache, +}; diff --git a/src/services/challenge.service.js b/src/services/challenge.service.js index e0c74e3..c084a50 100644 --- a/src/services/challenge.service.js +++ b/src/services/challenge.service.js @@ -1,6 +1,7 @@ const { prisma } = require("../config/prisma"); const { AppError } = require("../middlewares/error.middleware"); const logger = require("../utils/logger"); +const { invalidateLeaderboardCache } = require("./cache.service"); /** * Create a new challenge @@ -217,6 +218,13 @@ const joinChallenge = async (userId, challengeId) => { `User ${membership.user.username} joined challenge: ${membership.challenge.name}` ); + // Invalidate leaderboard cache when a new member joins + try { + await invalidateLeaderboardCache(challengeId); + } catch (err) { + logger.warn(`Cache invalidation failed after joinChallenge: ${err.message}`); + } + return membership; }; diff --git a/src/services/evaluation.service.js b/src/services/evaluation.service.js index d62cc6f..0aae471 100644 --- a/src/services/evaluation.service.js +++ b/src/services/evaluation.service.js @@ -2,6 +2,7 @@ const { prisma } = require("../config/prisma"); const leetcodeService = require("./leetcode.service"); const penaltyService = require("./penalty.service"); const logger = require("../utils/logger"); +const { invalidateLeaderboardCache } = require("./cache.service"); /** * Run daily evaluation for all active challenges @@ -167,8 +168,7 @@ const evaluateMember = async (challenge, member, evaluationDate) => { ); logger.debug( - `Filtered ${enrichedSubmissions.length} submissions to ${ - filteredSubmissions.length + `Filtered ${enrichedSubmissions.length} submissions to ${filteredSubmissions.length } matching difficulties: ${challenge.difficultyFilter.join(", ")}` ); } @@ -216,8 +216,7 @@ const evaluateMember = async (challenge, member, evaluationDate) => { } logger.info( - `Member ${user.username} evaluation: ${ - completed ? "PASSED" : "FAILED" + `Member ${user.username} evaluation: ${completed ? "PASSED" : "FAILED" } (${submissionsCount}/${challenge.minSubmissionsPerDay})` ); }; @@ -234,7 +233,7 @@ const createDailyResult = async ( problemsSolved, metadata = {} ) => { - return await prisma.dailyResult.create({ + const result = await prisma.dailyResult.create({ data: { challengeId, memberId, @@ -246,6 +245,15 @@ const createDailyResult = async ( metadata, }, }); + + // Invalidate leaderboard cache when new results are created + try { + await invalidateLeaderboardCache(challengeId); + } catch (err) { + logger.warn(`Cache invalidation failed after createDailyResult: ${err.message}`); + } + + return result; }; /** @@ -277,6 +285,13 @@ const updateStreak = async (memberId, completed) => { }, }); } + + // Invalidate leaderboard cache when streaks change + try { + await invalidateLeaderboardCache(member.challengeId); + } catch (err) { + logger.warn(`Cache invalidation failed after updateStreak: ${err.message}`); + } }; /** diff --git a/src/services/penalty.service.js b/src/services/penalty.service.js index 9900c55..d68fca5 100644 --- a/src/services/penalty.service.js +++ b/src/services/penalty.service.js @@ -1,5 +1,6 @@ const { prisma } = require("../config/prisma"); const logger = require("../utils/logger"); +const { invalidateLeaderboardCache } = require("./cache.service"); /** * Apply penalty to a challenge member @@ -46,6 +47,13 @@ const applyPenalty = async (memberId, amount, reason, date) => { `Penalty applied: ${amount} to ${member.user.username} for ${member.challenge.name}. Reason: ${reason}` ); + // Invalidate leaderboard cache when penalties change + try { + await invalidateLeaderboardCache(member.challengeId); + } catch (err) { + logger.warn(`Cache invalidation failed after applyPenalty: ${err.message}`); + } + return penalty; }; @@ -158,6 +166,13 @@ const adjustPenalty = async (penaltyId, adjustmentAmount) => { `Penalty adjusted: ${penaltyId}, adjustment: ${adjustmentAmount}` ); + // Invalidate leaderboard cache when penalties are adjusted + try { + await invalidateLeaderboardCache(penalty.member.challengeId); + } catch (err) { + logger.warn(`Cache invalidation failed after adjustPenalty: ${err.message}`); + } + return updatedMember; };