diff --git a/Middleware/cache.js b/Middleware/cache.js new file mode 100644 index 0000000..b861855 --- /dev/null +++ b/Middleware/cache.js @@ -0,0 +1,73 @@ +const redisClient = require('../config/redis'); + +/** + * Cache middleware for GET requests + * @param {number} duration - Cache duration in seconds (default: 3600 = 1 hour) + */ +const cache = (duration = 3600) => { + return async (req, res, next) => { + if (req.method !== 'GET') { + return next(); + } + + try { + const client = redisClient.getClient(); + // Include userId in cache key for user-specific routes + const key = req.user + ? `cache:${req.originalUrl || req.url}:${req.user.id}` + : `cache:${req.originalUrl || req.url}`; + + const cachedData = await client.get(key); + if (cachedData) { + console.log(`Cache HIT: ${key}`); + return res.json(JSON.parse(cachedData)); + } + + console.log(`Cache MISS: ${key}`); + const originalJson = res.json.bind(res); + res.json = (data) => { + client.setEx(key, duration, JSON.stringify(data)) + .catch(err => console.error('Cache set error:', err)); + return originalJson(data); + }; + + next(); + } catch (error) { + console.error('Cache middleware error:', error); + next(); + } + }; +}; + +/** + * Clear cache by pattern + * @param {string} pattern - Redis key pattern (e.g., 'cache:users*') + */ +const clearCache = async (pattern) => { + try { + const client = redisClient.getClient(); + const keys = await client.keys(pattern); + if (keys.length > 0) { + await client.del(keys); + console.log(`Cleared ${keys.length} cache keys matching: ${pattern}`); + } + } catch (error) { + console.error('Clear cache error:', error); + } +}; + +/** + * Clear specific cache key + * @param {string} key + */ +const clearCacheKey = async (key) => { + try { + const client = redisClient.getClient(); + await client.del(key); + console.log(`Cleared cache key: ${key}`); + } catch (error) { + console.error('Clear cache key error:', error); + } +}; + +module.exports = { cache, clearCache, clearCacheKey }; \ No newline at end of file diff --git a/app.js b/app.js index 0443bbe..c63f45f 100644 --- a/app.js +++ b/app.js @@ -9,6 +9,7 @@ const ListingRouter = require('./router/listingRouter'); const MarketPlaceRouter = require('./router/marketplaceRouter'); const notificationRouter = require('./router/notificationRouter'); const { protect, restrictTo } = require('./controllers/authController'); +const redisClient = require('./config/redis'); //development-logging if (process.env.NODE_ENV === 'development') { @@ -19,6 +20,17 @@ if (process.env.NODE_ENV === 'development') { app.use(express.json()); app.use(cookiesParser()); +// Initialize Redis connection +redisClient.connect() + .then(() => console.log('Redis connected successfully')) + .catch(err => console.error('Redis connection failed:', err)); + +// Handle graceful shutdown +process.on('SIGINT', async () => { + await redisClient.disconnect(); + process.exit(0); +}); + //routes// app.use('/api/v1/listings', ListingRouter); app.use('/api/v1/marketplace/listings', MarketPlaceRouter); diff --git a/config/redis.js b/config/redis.js new file mode 100644 index 0000000..29d40c6 --- /dev/null +++ b/config/redis.js @@ -0,0 +1,70 @@ + +const redis = require('redis'); + +class RedisClient { + constructor() { + this.client = null; + this.isConnected = false; + } + + async connect() { + try { + this.client = redis.createClient({ + url: process.env.REDIS_URL || 'redis://localhost:6379', + password: process.env.REDIS_PASSWORD, + socket: { + reconnectStrategy: (retries) => { + if (retries > 10) { + console.error('Redis: Max reconnection attempts reached'); + return new Error('Max reconnection attempts reached'); + } + return Math.min(retries * 100, 3000); + } + } + }); + + this.client.on('error', (err) => { + console.error('Redis Client Error:', err); + this.isConnected = false; + }); + + this.client.on('connect', () => { + console.log('Redis: Connecting...'); + }); + + this.client.on('ready', () => { + console.log('Redis: Connected and ready'); + this.isConnected = true; + }); + + this.client.on('reconnecting', () => { + console.log('Redis: Reconnecting...'); + }); + + await this.client.connect(); + return this.client; + } catch (error) { + console.error('Failed to connect to Redis:', error); + throw error; + } + } + + getClient() { + if (!this.isConnected) { + throw new Error('Redis client is not connected'); + } + return this.client; + } + + async disconnect() { + if (this.client) { + await this.client.quit(); + this.isConnected = false; + console.log('Redis: Disconnected'); + } + } +} + +const redisClient = new RedisClient(); + +module.exports = redisClient; \ No newline at end of file diff --git a/controllers/authController.js b/controllers/authController.js index fd83e72..82a4ff6 100644 --- a/controllers/authController.js +++ b/controllers/authController.js @@ -26,7 +26,7 @@ exports.protect = catchAsync(async (req, res, next) => { const user = await User.findByPk(decoded.user_id); if (!user) return next( - new AppError('The user belonginf to this token does not exist', 401) + new AppError('The user belonging to this token does not exist', 401) ); //check if user is verified if (!user.is_verified) diff --git a/controllers/listingController.js b/controllers/listingController.js index 428f6a2..57f770b 100644 --- a/controllers/listingController.js +++ b/controllers/listingController.js @@ -1,7 +1,7 @@ const AppError = require("../utils/appError"); const catchAsync = require("../utils/catchAsync"); const { Listing, MarketplaceListing, User } = require("../db/models"); -const { where } = require("sequelize"); +const { clearCache, clearCacheKey } = require('../Middleware/cache'); exports.createListing = catchAsync(async (req, res, next) => { const payload = { @@ -19,7 +19,6 @@ exports.createListing = catchAsync(async (req, res, next) => { const listing = await Listing.create(payload); if (!listing) return next(new AppError("Error creating listing", 500)); - // console.log("Listing created successfully:", listing.toJSON()); // create a market place listing, if listing is successful const marketplace_listing = await MarketplaceListing.create({ @@ -29,6 +28,16 @@ exports.createListing = catchAsync(async (req, res, next) => { if (!marketplace_listing) return next(new AppError("Error creating marketplaceListing", 500)); +// Clear relevant caches + try { + await clearCacheKey(`cache:/api/v1/listings:${req.user.id}`); // getAllListings + await clearCacheKey(`cache:/api/v1/listings/${listing.id}:${req.user.id}`); // getListingById + await clearCacheKey(`cache:/api/v1/listings/listingstats:${req.user.id}`); // getUserListingData + await clearCache('cache:/api/v1/marketplace*'); // Marketplace listings + } catch (err) { + console.error('Redis cache clear error:', err); + } + res.status(201).json({ status: "success", data: listing, @@ -100,11 +109,22 @@ exports.deleteListing = catchAsync(async (req, res, next) => { } if (!marketplace) { - return next(new AppError("No marketplace found with that ID")); + return next(new AppError("No marketplace found with that ID", 404)); } + await marketplace.destroy(); await listing.destroy(); + // Clear relevant caches + try { + await clearCacheKey(`cache:/api/v1/listings:${req.user.id}`); + await clearCacheKey(`cache:/api/v1/listings/${id}:${req.user.id}`); + await clearCacheKey(`cache:/api/v1/listings/listingstats:${req.user.id}`); + await clearCache('cache:/api/v1/marketplace*'); + } catch (err) { + console.error('Redis cache clear error:', err); + } + res.status(204).json({ status: "success", data: null, @@ -115,55 +135,63 @@ exports.updateListingStatus = catchAsync(async (req, res, next) => { const { id } = req.params; const { status } = req.query; - try { - const allowedStatuses = [ - "pending", - "accepted", - "in-progress", - "completed", - "cancelled", - ]; - - if (!allowedStatuses.includes(status)) { - return next( - new AppError( - `Invalid status. Allowed values: ${allowedStatuses.join(", ")}`, - 400 - ) - ); - } - - const listing = await Listing.findByPk(id); - if (!listing) { - return next(new AppError("Listing not found", 404)); - } - - listing.status = status; - await listing.save(); + const allowedStatuses = [ + "pending", + "accepted", + "in-progress", + "completed", + "cancelled", + ]; + + if (!allowedStatuses.includes(status)) { + return next( + new AppError( + `Invalid status. Allowed values: ${allowedStatuses.join(", ")}`, + 400 + ) + ); + } - res.status(200).json({ - status: "success", - message: `Listing status updated to: '${status}' `, - data: { - id: listing.id, - status: listing.status, - }, - }); + const listing = await Listing.findByPk(id); + if (!listing) { + return next(new AppError("Listing not found", 404)); + } + + listing.status = status; + await listing.save(); + + // Clear relevant caches + try { + await clearCacheKey(`cache:/api/v1/listings:${req.user.id}`); + await clearCacheKey(`cache:/api/v1/listings/${id}:${req.user.id}`); + await clearCacheKey(`cache:/api/v1/listings/listingstats:${req.user.id}`); + await clearCache('cache:/api/v1/marketplace/listings*'); } catch (err) { - return next(new AppError(err.message, 500)); + console.error('Redis cache clear error:', err); } + + res.status(200).json({ + status: "success", + message: `Listing status updated to: '${status}'`, + data: { + id: listing.id, + status: listing.status, + }, + }); }); exports.getUserListingData = catchAsync(async (req, res, next) => { const listingdata = await Listing.findAll({ where: { user_id_id: req.user.id }, }); + const totalcompletedlisting = await Listing.findAll({ where: { user_id_id: req.user.id, status: "completed", }, }); + const recentListing = await Listing.findAll({ where: { user_id_id: req.user.id, @@ -171,9 +199,10 @@ exports.getUserListingData = catchAsync(async (req, res, next) => { order: [['created_at', 'DESC']], limit: 5, }); + res.status(200).json({ total_waste_posted: listingdata.length, total_waste_completed: totalcompletedlisting.length, recent_listing: recentListing, }); -}); +}); \ No newline at end of file diff --git a/controllers/marketPlaceController.js b/controllers/marketPlaceController.js index 5690c5f..a916d15 100644 --- a/controllers/marketPlaceController.js +++ b/controllers/marketPlaceController.js @@ -1,6 +1,6 @@ const catchAsync = require('../utils/catchAsync'); -const { MarketplaceListing } = require('../db/models'); -const { Listing } = require('../db/models'); +const { MarketplaceListing, Listing } = require('../db/models'); +const { clearCache, clearCacheKey } = require('../Middleware/cache'); const { Op, where } = require('sequelize'); const AppError = require('../utils/appError'); @@ -16,6 +16,7 @@ exports.getAllMarketPlaceListing = catchAsync(async (req, res, next) => { as: 'listing', }, }); + res.status(200).json({ status: 'success', length: marketPlaceListings.length, @@ -44,13 +45,27 @@ exports.acceptListing = catchAsync(async (req, res, next) => { }, }, }); + await marketPlaceListing.update({ recycler_id_id: req.user.id, }); + // Clear relevant caches + try { + // Marketplace caches + await clearCacheKey(`cache:/api/v1/marketplace:${req.user.id}`); + await clearCacheKey(`cache:/api/v1/marketplace/myAccepted:${req.user.id}`); + } catch (err) { + console.error('Redis cache clear error:', err); + } + res.status(200).json({ status: 'success', message: 'Listing Accepted', + data: { + listing, + marketPlaceListing, + }, }); }); @@ -71,4 +86,4 @@ exports.getMyAcceptedListing = catchAsync(async (req, res, next) => { length: myAccepted.length, data: myAccepted, }); -}); +}); \ No newline at end of file diff --git a/controllers/notificationController.js b/controllers/notificationController.js index 6c1e42c..4738e7c 100644 --- a/controllers/notificationController.js +++ b/controllers/notificationController.js @@ -1,17 +1,18 @@ -const AppError = require('../utils/appError'); -const catchAsync = require('../utils/catchAsync'); -const { Notification } = require('../db/models'); -const models = require('../db/models'); +const AppError = require("../utils/appError"); +const catchAsync = require("../utils/catchAsync"); +const { Notification, User } = require("../db/models"); +const { clearCache } = require("../Middleware/cache"); +const { Op } = require("sequelize"); // Get user notifications with pagination and optional filtering -exports.getUserNotifications = catchAsync(async (req, res) => { +exports.getUserNotifications = catchAsync(async (req, res, next) => { const userId = req.user.id; const { page = 1, limit = 20, isRead, type } = req.query; // Validate userId const parsedUserId = userId; // already a string from JWT/session - if (!parsedUserId || typeof parsedUserId !== 'string') { - return new AppError('Invalid user ID provided', 400); + if (!parsedUserId || typeof parsedUserId !== "string") { + return new AppError("Invalid user ID provided", 400); } // Validate pagination params @@ -20,46 +21,54 @@ exports.getUserNotifications = catchAsync(async (req, res) => { const offset = (parsedPage - 1) * parsedLimit; if (isNaN(parsedPage)) { - return new AppError('Invalid page number provided', 400); + return new AppError("Invalid page number provided", 400); } if (isNaN(parsedLimit)) { return new AppError( - 'Invalid limit provided. Must be between 1 and 100', + "Invalid limit provided. Must be between 1 and 100", 400 ); } // Build where clause with optional filters - const where = { userId: parsedUserId }; + const where = { userId }; if (isRead !== undefined) { - where.isRead = isRead === 'true'; + where.is_read = isRead === "true"; } - if (type && ['pickup', 'reward', 'marketplace', 'general'].includes(type)) { + if (type && ["pickup", "reward", "marketplace", "general"].includes(type)) { where.type = type; } else if (type) { - return new AppError('Invalid notification type provided', 400); + return new AppError("Invalid notification type provided", 400); } // Query with pagination and filters const { count, rows: notifications } = await Notification.findAndCountAll({ where, - order: [['created_at', 'DESC']], + order: [["created_at", "DESC"]], limit: parsedLimit, offset, + attributes: [ + "id", + "type", + "message", + ["is_read", "isRead"], // Map to camelCase in response + ["created_at", "createdAt"], // Map to camelCase in response + ], include: [ { - model: models.User, - as: 'user', - attributes: ['id', 'name'], + model: User, + as: "user", + attributes: ["id", "name"], required: false, }, ], }); + // Calculate pagination metadata const totalPages = Math.ceil(count / parsedLimit); res.status(200).json({ - status: 'success', + status: "success", length: notifications.length, notifications, pagination: { @@ -78,27 +87,34 @@ exports.sendNotification = catchAsync(async (req, res) => { // Validate request body if (!userId || !type || !message) { - return new AppError('Missing required fields: userId, type, message', 400); + return new AppError("Missing required fields: userId, type, message", 400); } // Validate notification type - const validTypes = ['pickup', 'reward', 'marketplace', 'general']; + const validTypes = ["pickup", "reward", "marketplace", "general"]; if (!validTypes.includes(type)) { return new AppError( - `Invalid notification type. Must be one of: ${validTypes.join(', ')}`, + `Invalid notification type. Must be one of: ${validTypes.join(", ")}`, 400 ); } - const notification = await models.Notification.create({ + const notification = await Notification.create({ userId, type, message, isRead: false, }); + // Clear notification caches + try { + await clearCache(`cache:/api/v1/notifications*`); + } catch (err) { + console.error("Redis cache clear error:", err); + } + res.status(201).json({ - status: 'success', + status: "success", data: notification, }); }); @@ -107,10 +123,10 @@ exports.markNotificationAsRead = catchAsync(async (req, res) => { const { notificationId } = req.params; if (!notificationId) { - return new AppError('Missing required field: notificationId', 400); + return new AppError("Missing required field: notificationId", 400); } - const notification = await models.Notification.findOne({ + const notification = await Notification.findOne({ where: { id: notificationId, userId: req.user.id, @@ -118,26 +134,32 @@ exports.markNotificationAsRead = catchAsync(async (req, res) => { }); if (!notification) { - return new AppError('Notification not found', 404); + return new AppError("Notification not found", 404); } await notification.update({ is_read: true }); + // Clear notification caches + try { + await clearCache(`cache:/api/v1/notifications*`); + } catch (err) { + console.error("Redis cache clear error:", err); + } + res.status(200).json({ - staus: 'success', - notification, + status: "success", + data: notification, }); }); -// Delete a notification exports.deleteNotification = catchAsync(async (req, res) => { const { notificationId } = req.params; if (!notificationId) { - return new AppError('Missing required field: notificationId', 400); + return new AppError("Missing required field: notificationId", 400); } - const notification = await models.Notification.findOne({ + const notification = await Notification.findOne({ where: { id: notificationId, userId: req.user.id, @@ -145,12 +167,20 @@ exports.deleteNotification = catchAsync(async (req, res) => { }); if (!notification) { - return new AppError('Notification not found', 404); + return new AppError("Notification not found", 404); } await notification.destroy(); + // Clear notification caches + try { + await clearCache(`cache:/api/v1/notifications*`); + } catch (err) { + console.error("Redis cache clear error:", err); + } + res.status(204).json({ - status: 'success', + status: "success", + data: null, }); }); diff --git a/package-lock.json b/package-lock.json index e23d5ba..507576b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -20,6 +20,7 @@ "path": "^0.12.7", "pg": "^8.16.3", "pg-hstore": "^2.3.4", + "redis": "^5.8.3", "sequelize": "^6.37.7" }, "devDependencies": { @@ -62,6 +63,66 @@ "node": ">=14" } }, + "node_modules/@redis/bloom": { + "version": "5.8.3", + "resolved": "https://registry.npmjs.org/@redis/bloom/-/bloom-5.8.3.tgz", + "integrity": "sha512-1eldTzHvdW3Oi0TReb8m1yiFt8ZwyF6rv1NpZyG5R4TpCwuAdKQetBKoCw7D96tNFgsVVd6eL+NaGZZCqhRg4g==", + "license": "MIT", + "engines": { + "node": ">= 18" + }, + "peerDependencies": { + "@redis/client": "^5.8.3" + } + }, + "node_modules/@redis/client": { + "version": "5.8.3", + "resolved": "https://registry.npmjs.org/@redis/client/-/client-5.8.3.tgz", + "integrity": "sha512-MZVUE+l7LmMIYlIjubPosruJ9ltSLGFmJqsXApTqPLyHLjsJUSAbAJb/A3N34fEqean4ddiDkdWzNu4ZKPvRUg==", + "license": "MIT", + "dependencies": { + "cluster-key-slot": "1.1.2" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/@redis/json": { + "version": "5.8.3", + "resolved": "https://registry.npmjs.org/@redis/json/-/json-5.8.3.tgz", + "integrity": "sha512-DRR09fy/u8gynHGJ4gzXYeM7D8nlS6EMv5o+h20ndTJiAc7RGR01fdk2FNjnn1Nz5PjgGGownF+s72bYG4nZKQ==", + "license": "MIT", + "engines": { + "node": ">= 18" + }, + "peerDependencies": { + "@redis/client": "^5.8.3" + } + }, + "node_modules/@redis/search": { + "version": "5.8.3", + "resolved": "https://registry.npmjs.org/@redis/search/-/search-5.8.3.tgz", + "integrity": "sha512-EMIvEeGRR2I0BJEz4PV88DyCuPmMT1rDtznlsHY3cKSDcc9vj0Q411jUnX0iU2vVowUgWn/cpySKjpXdZ8m+5g==", + "license": "MIT", + "engines": { + "node": ">= 18" + }, + "peerDependencies": { + "@redis/client": "^5.8.3" + } + }, + "node_modules/@redis/time-series": { + "version": "5.8.3", + "resolved": "https://registry.npmjs.org/@redis/time-series/-/time-series-5.8.3.tgz", + "integrity": "sha512-5Jwy3ilsUYQjzpE7WZ1lEeG1RkqQ5kHtwV1p8yxXHSEmyUbC/T/AVgyjMcm52Olj/Ov/mhDKjx6ndYUi14bXsw==", + "license": "MIT", + "engines": { + "node": ">= 18" + }, + "peerDependencies": { + "@redis/client": "^5.8.3" + } + }, "node_modules/@types/debug": { "version": "4.1.12", "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.12.tgz", @@ -395,6 +456,15 @@ "lodash": ">=4.0" } }, + "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-convert": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", @@ -1857,6 +1927,22 @@ "node": ">= 6" } }, + "node_modules/redis": { + "version": "5.8.3", + "resolved": "https://registry.npmjs.org/redis/-/redis-5.8.3.tgz", + "integrity": "sha512-MfSrfV6+tEfTw8c4W0yFp6XWX8Il4laGU7Bx4kvW4uiYM1AuZ3KGqEGt1LdQHeD1nEyLpIWetZ/SpY3kkbgrYw==", + "license": "MIT", + "dependencies": { + "@redis/bloom": "5.8.3", + "@redis/client": "5.8.3", + "@redis/json": "5.8.3", + "@redis/search": "5.8.3", + "@redis/time-series": "5.8.3" + }, + "engines": { + "node": ">= 18" + } + }, "node_modules/require-directory": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", diff --git a/package.json b/package.json index ae9e141..2c1817a 100644 --- a/package.json +++ b/package.json @@ -22,6 +22,7 @@ "path": "^0.12.7", "pg": "^8.16.3", "pg-hstore": "^2.3.4", + "redis": "^5.8.3", "sequelize": "^6.37.7" }, "devDependencies": { diff --git a/router/listingRouter.js b/router/listingRouter.js index e21c1d4..94ed51e 100644 --- a/router/listingRouter.js +++ b/router/listingRouter.js @@ -12,6 +12,7 @@ const { VerifyExternalAccess, } = require("../controllers/authController"); const upload = require("../config/multer"); +const { cache } = require('../Middleware/cache'); const router = express.Router(); @@ -20,11 +21,13 @@ router.use(protect); router .route("/") .post(upload.single("image_url"), createListing) - .get(getAllListings); -router.get("/listingstats", VerifyExternalAccess, getUserListingData); + .get(cache(300), getAllListings); + +router.get("/listingstats", cache(300), VerifyExternalAccess, getUserListingData); + router .route("/:id") - .get(getListingById) + .get(cache(600) ,getListingById) .delete(deleteListing) .patch(updateListingStatus); diff --git a/router/marketplaceRouter.js b/router/marketplaceRouter.js index 30b98b9..2252f69 100644 --- a/router/marketplaceRouter.js +++ b/router/marketplaceRouter.js @@ -5,12 +5,19 @@ const { getMyAcceptedListing, } = require('../controllers/marketPlaceController'); const { protect, restrictTo } = require('../controllers/authController'); +const { cache } = require('../Middleware/cache'); const router = express.Router(); router.use(protect); -router.route('/').get(restrictTo('collector'), getAllMarketPlaceListing); -router.route('/myAccepted').get(getMyAcceptedListing); -router.patch('/:listingID', restrictTo('collector'), acceptListing); -module.exports = router; +router.route('/myAccepted') + .get(cache(180), restrictTo('collector'), getMyAcceptedListing); + +router.route('/') + .get(cache(300), restrictTo('collector'), getAllMarketPlaceListing); + +router.route('/:listingID') + .patch(restrictTo('collector'), acceptListing); + +module.exports = router; \ No newline at end of file diff --git a/router/notificationRouter.js b/router/notificationRouter.js index f3a160d..abf8b8a 100644 --- a/router/notificationRouter.js +++ b/router/notificationRouter.js @@ -7,13 +7,16 @@ const { deleteNotification, } = require('../controllers/notificationController'); const { protect } = require('../controllers/authController'); +const { cache } = require('../Middleware/cache'); router.use(protect); -router.get('/', getUserNotifications); -router.post('/', sendNotification); +router + .route('/') + .get(cache(300), getUserNotifications) + .post(sendNotification); router .route('/:notificationId') .patch(markNotificationAsRead) .delete(deleteNotification); -module.exports = router; +module.exports = router; \ No newline at end of file