From 1736794a53e50a60145e52a947b13d6b985da152 Mon Sep 17 00:00:00 2001 From: deepesh224-ux Date: Sun, 12 Oct 2025 15:59:00 +0530 Subject: [PATCH] feat(wishlist): implement complete wishlist API with JWT authentication - Add Wishlist model with user reference and product IDs - Implement wishlist controller with CRUD operations - Add JWT authentication middleware - Create wishlist routes with proper validation - Add test token generation endpoint for development - Prevent duplicate products in wishlist - Handle edge cases (empty wishlist, invalid IDs) - Integrate wishlist routes into main app Endpoints: - GET /api/wishlist - Get user's wishlist - POST /api/wishlist - Add product to wishlist - DELETE /api/wishlist/:productId - Remove product - DELETE /api/wishlist - Clear entire wishlist - GET /api/test/token - Generate test JWT token --- .DS_Store | Bin 0 -> 6148 bytes .gitignore | 3 +- package-lock.json | 102 ++++++++++- package.json | 1 + src/app.js | 4 + src/controllers/wishlist.controller.js | 233 +++++++++++++++++++++++++ src/middleware/auth.middleware.js | 65 +++++++ src/models/wishlist.model.js | 54 ++++++ src/routes/test.routes.js | 33 ++++ src/routes/wishlist.routes.js | 27 +++ src/utils/auth.util.js | 25 +++ 11 files changed, 545 insertions(+), 2 deletions(-) create mode 100644 .DS_Store create mode 100644 src/controllers/wishlist.controller.js create mode 100644 src/middleware/auth.middleware.js create mode 100644 src/models/wishlist.model.js create mode 100644 src/routes/test.routes.js create mode 100644 src/routes/wishlist.routes.js create mode 100644 src/utils/auth.util.js diff --git a/.DS_Store b/.DS_Store new file mode 100644 index 0000000000000000000000000000000000000000..ecb2484dc4cd821739b1b7779fbc9a53aa807736 GIT binary patch literal 6148 zcmeHK%We}f6unLYnKXq41X4FhBe4x7v{0#H6Ve1!34+w5AOtAnMHo7riSlR`Riv!p zANU2fdrzK06 zH%wO2MuVQyV`|Vo(3(}iD)6@}zVXRfB#_%X4M&B-ca=z{E?rJLX(#Kvt$he5wNZ049+sMe*Zfou zL%%gIH(M{f?xc~su^$GbrWbSvLhw3WxV(Ak1)XqG3dccbDBPN^a57G&k=r~w+uqyF zyE}Uirg`^lZ@Z9p@9*qRry1ww?Yj>T8^``h5PpJ=Folyx;k6jb{CmJ0*I?1$Oe1Pw zN>hQFs>~HbX*%A!#;<5_rcu*LnahVVk(IfjD2a~tU2!K>)M#_7fK^~ofuw%S@%djl z|Ng&7vQJh4tH3{{fJ&8X=16.20.1" } }, + "node_modules/buffer-equal-constant-time": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", + "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", + "license": "BSD-3-Clause" + }, "node_modules/bytes": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", @@ -682,6 +689,15 @@ "node": ">= 0.4" } }, + "node_modules/ecdsa-sig-formatter": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", + "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + } + }, "node_modules/ee-first": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", @@ -1407,6 +1423,49 @@ "dev": true, "license": "MIT" }, + "node_modules/jsonwebtoken": { + "version": "9.0.2", + "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.2.tgz", + "integrity": "sha512-PRp66vJ865SSqOlgqS8hujT5U4AOgMfhrwYIuIhfKaoSCZcirrmASQr8CX7cUg+RMih+hgznrjp99o+W4pJLHQ==", + "license": "MIT", + "dependencies": { + "jws": "^3.2.2", + "lodash.includes": "^4.3.0", + "lodash.isboolean": "^3.0.3", + "lodash.isinteger": "^4.0.4", + "lodash.isnumber": "^3.0.3", + "lodash.isplainobject": "^4.0.6", + "lodash.isstring": "^4.0.1", + "lodash.once": "^4.0.0", + "ms": "^2.1.1", + "semver": "^7.5.4" + }, + "engines": { + "node": ">=12", + "npm": ">=6" + } + }, + "node_modules/jwa": { + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-1.4.2.tgz", + "integrity": "sha512-eeH5JO+21J78qMvTIDdBXidBd6nG2kZjg5Ohz/1fpa28Z4CcsWUzJ1ZZyFq/3z3N17aZy+ZuBoHljASbL1WfOw==", + "license": "MIT", + "dependencies": { + "buffer-equal-constant-time": "^1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/jws": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/jws/-/jws-3.2.2.tgz", + "integrity": "sha512-YHlZCB6lMTllWDtSPHz/ZXTsi8S00usEV6v1tjq8tOUZzw7DpSDWVXjXDre6ed1w/pd495ODpHZYSdkRTsa0HA==", + "license": "MIT", + "dependencies": { + "jwa": "^1.4.1", + "safe-buffer": "^5.0.1" + } + }, "node_modules/kareem": { "version": "2.6.3", "resolved": "https://registry.npmjs.org/kareem/-/kareem-2.6.3.tgz", @@ -1456,6 +1515,42 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "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.isboolean": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz", + "integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==", + "license": "MIT" + }, + "node_modules/lodash.isinteger": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz", + "integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==", + "license": "MIT" + }, + "node_modules/lodash.isnumber": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz", + "integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==", + "license": "MIT" + }, + "node_modules/lodash.isplainobject": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", + "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==", + "license": "MIT" + }, + "node_modules/lodash.isstring": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz", + "integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==", + "license": "MIT" + }, "node_modules/lodash.merge": { "version": "4.6.2", "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", @@ -1463,6 +1558,12 @@ "dev": true, "license": "MIT" }, + "node_modules/lodash.once": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz", + "integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==", + "license": "MIT" + }, "node_modules/math-intrinsics": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", @@ -2013,7 +2114,6 @@ "version": "7.7.2", "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz", "integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==", - "dev": true, "license": "ISC", "bin": { "semver": "bin/semver.js" diff --git a/package.json b/package.json index e62917e..65f3406 100644 --- a/package.json +++ b/package.json @@ -46,6 +46,7 @@ "cors": "^2.8.5", "dotenv": "^17.2.3", "express": "^5.1.0", + "jsonwebtoken": "^9.0.2", "mongoose": "^8.19.0" }, "devDependencies": { diff --git a/src/app.js b/src/app.js index 4d5d643..33b0b6a 100644 --- a/src/app.js +++ b/src/app.js @@ -3,6 +3,8 @@ import cors from 'cors'; import productRoutes from './routes/product.routes.js'; import cartRoutes from './routes/cart.routes.js'; import collectionRoutes from './routes/collection.routes.js'; +import wishlistRoutes from './routes/wishlist.routes.js'; +import testRoutes from './routes/test.routes.js'; import errorHandler from './middleware/error-handler.middleware.js'; import notFound from './middleware/notFound.middleware.js' const app = express(); @@ -17,6 +19,8 @@ app.get('/',(req,res)=>{ app.use('/api/products', productRoutes); app.use('/api/cart', cartRoutes); app.use('/api/collections', collectionRoutes); +app.use('/api/wishlist', wishlistRoutes); +app.use('/api/test', testRoutes); // Middleware for not found 404 app.use(notFound); diff --git a/src/controllers/wishlist.controller.js b/src/controllers/wishlist.controller.js new file mode 100644 index 0000000..68c9bdb --- /dev/null +++ b/src/controllers/wishlist.controller.js @@ -0,0 +1,233 @@ +import Wishlist from '../models/wishlist.model.js'; +import Product from '../models/product.model.js'; +import HttpException from '../utils/exceptions/http.exception.js'; +import mongoose from 'mongoose'; + +/** + * GET /api/wishlist + * Get user's wishlist with populated product details + */ +const getWishlist = async (req, res, next) => { + try { + const userId = req.user.id; // Assuming JWT middleware sets req.user + + const wishlist = await Wishlist.findOne({ userId }) + .populate('products.productId', 'name price image rating reviewsCount category collection') + .lean(); + + if (!wishlist) { + return res.status(200).json({ + success: true, + message: 'Wishlist is empty', + data: { + products: [], + totalItems: 0, + }, + }); + } + + // Transform the data to match expected format + const products = wishlist.products.map(item => ({ + ...item.productId, + addedAt: item.addedAt, + })); + + return res.status(200).json({ + success: true, + message: 'Wishlist retrieved successfully', + data: { + products, + totalItems: products.length, + }, + }); + } catch (error) { + console.error('Error fetching wishlist:', error); + next(new HttpException(500, 'Internal server error while fetching wishlist')); + } +}; + +/** + * POST /api/wishlist + * Add product to user's wishlist + */ +const addToWishlist = async (req, res, next) => { + try { + const userId = req.user.id; + const { productId } = req.body; + + // Validate productId + if (!productId) { + return next(new HttpException(400, 'Product ID is required')); + } + + if (!mongoose.Types.ObjectId.isValid(productId)) { + return next(new HttpException(400, 'Invalid product ID format')); + } + + // Check if product exists + const product = await Product.findById(productId); + if (!product) { + return next(new HttpException(404, 'Product not found')); + } + + // Find or create wishlist + let wishlist = await Wishlist.findOne({ userId }); + + if (!wishlist) { + // Create new wishlist + wishlist = new Wishlist({ + userId, + products: [{ productId, addedAt: new Date() }], + }); + } else { + // Check if product already exists in wishlist + const existingProduct = wishlist.products.find( + item => item.productId.toString() === productId + ); + + if (existingProduct) { + return next(new HttpException(409, 'Product already in wishlist')); + } + + // Add product to existing wishlist + wishlist.products.push({ productId, addedAt: new Date() }); + } + + await wishlist.save(); + + // Populate and return updated wishlist + const updatedWishlist = await Wishlist.findById(wishlist._id) + .populate('products.productId', 'name price image rating reviewsCount category collection') + .lean(); + + const products = updatedWishlist.products.map(item => ({ + ...item.productId, + addedAt: item.addedAt, + })); + + return res.status(201).json({ + success: true, + message: 'Product added to wishlist successfully', + data: { + products, + totalItems: products.length, + }, + }); + } catch (error) { + console.error('Error adding to wishlist:', error); + next(new HttpException(500, 'Internal server error while adding to wishlist')); + } +}; + +/** + * DELETE /api/wishlist/:productId + * Remove product from user's wishlist + */ +const removeFromWishlist = async (req, res, next) => { + try { + const userId = req.user.id; + const { productId } = req.params; + + // Validate productId + if (!mongoose.Types.ObjectId.isValid(productId)) { + return next(new HttpException(400, 'Invalid product ID format')); + } + + // Find wishlist + const wishlist = await Wishlist.findOne({ userId }); + + if (!wishlist) { + return next(new HttpException(404, 'Wishlist not found')); + } + + // Check if product exists in wishlist + const productIndex = wishlist.products.findIndex( + item => item.productId.toString() === productId + ); + + if (productIndex === -1) { + return next(new HttpException(404, 'Product not found in wishlist')); + } + + // Remove product from wishlist + wishlist.products.splice(productIndex, 1); + await wishlist.save(); + + // If wishlist is empty, delete it + if (wishlist.products.length === 0) { + await Wishlist.findByIdAndDelete(wishlist._id); + return res.status(200).json({ + success: true, + message: 'Product removed from wishlist successfully', + data: { + products: [], + totalItems: 0, + }, + }); + } + + // Populate and return updated wishlist + const updatedWishlist = await Wishlist.findById(wishlist._id) + .populate('products.productId', 'name price image rating reviewsCount category collection') + .lean(); + + const products = updatedWishlist.products.map(item => ({ + ...item.productId, + addedAt: item.addedAt, + })); + + return res.status(200).json({ + success: true, + message: 'Product removed from wishlist successfully', + data: { + products, + totalItems: products.length, + }, + }); + } catch (error) { + console.error('Error removing from wishlist:', error); + next(new HttpException(500, 'Internal server error while removing from wishlist')); + } +}; + +/** + * DELETE /api/wishlist + * Clear entire wishlist + */ +const clearWishlist = async (req, res, next) => { + try { + const userId = req.user.id; + + const wishlist = await Wishlist.findOneAndDelete({ userId }); + + if (!wishlist) { + return res.status(200).json({ + success: true, + message: 'Wishlist is already empty', + data: { + products: [], + totalItems: 0, + }, + }); + } + + return res.status(200).json({ + success: true, + message: 'Wishlist cleared successfully', + data: { + products: [], + totalItems: 0, + }, + }); + } catch (error) { + console.error('Error clearing wishlist:', error); + next(new HttpException(500, 'Internal server error while clearing wishlist')); + } +}; + +export { + getWishlist, + addToWishlist, + removeFromWishlist, + clearWishlist, +}; diff --git a/src/middleware/auth.middleware.js b/src/middleware/auth.middleware.js new file mode 100644 index 0000000..c099378 --- /dev/null +++ b/src/middleware/auth.middleware.js @@ -0,0 +1,65 @@ +import jwt from 'jsonwebtoken'; +import HttpException from '../utils/exceptions/http.exception.js'; + +/** + * JWT Authentication Middleware + * Verifies JWT token and sets req.user + */ +const authenticateToken = (req, res, next) => { + try { + const authHeader = req.headers['authorization']; + const token = authHeader && authHeader.split(' ')[1]; // Bearer TOKEN + + if (!token) { + return next(new HttpException(401, 'Access token is required')); + } + + // For now, we'll use a simple secret. In production, use process.env.JWT_SECRET + const secret = process.env.JWT_SECRET || 'your-secret-key'; + + jwt.verify(token, secret, (err, user) => { + if (err) { + return next(new HttpException(403, 'Invalid or expired token')); + } + + req.user = user; + next(); + }); + } catch (error) { + console.error('Auth middleware error:', error); + next(new HttpException(500, 'Authentication error')); + } +}; + +/** + * Optional Authentication Middleware + * Sets req.user if token is present, but doesn't require it + */ +const optionalAuth = (req, res, next) => { + try { + const authHeader = req.headers['authorization']; + const token = authHeader && authHeader.split(' ')[1]; + + if (!token) { + req.user = null; + return next(); + } + + const secret = process.env.JWT_SECRET || 'your-secret-key'; + + jwt.verify(token, secret, (err, user) => { + if (err) { + req.user = null; + } else { + req.user = user; + } + next(); + }); + } catch (error) { + console.error('Optional auth middleware error:', error); + req.user = null; + next(); + } +}; + +export { authenticateToken, optionalAuth }; diff --git a/src/models/wishlist.model.js b/src/models/wishlist.model.js new file mode 100644 index 0000000..c92aa8f --- /dev/null +++ b/src/models/wishlist.model.js @@ -0,0 +1,54 @@ +import mongoose from 'mongoose'; + +const wishlistSchema = new mongoose.Schema({ + userId: { + type: mongoose.Schema.Types.ObjectId, + ref: 'User', + required: true, + unique: true, + }, + products: [{ + productId: { + type: mongoose.Schema.Types.ObjectId, + ref: 'Product', + required: true, + }, + addedAt: { + type: Date, + default: Date.now, + }, + }], +}, { + timestamps: true, + versionKey: false, +}); + +// Index for efficient queries +wishlistSchema.index({ userId: 1 }); +wishlistSchema.index({ 'products.productId': 1 }); + +// Prevent duplicate products in wishlist +wishlistSchema.pre('save', function(next) { + if (this.isModified('products')) { + const productIds = this.products.map(item => item.productId.toString()); + const uniqueProductIds = [...new Set(productIds)]; + + if (productIds.length !== uniqueProductIds.length) { + // Remove duplicates, keeping the first occurrence + const seen = new Set(); + this.products = this.products.filter(item => { + const id = item.productId.toString(); + if (seen.has(id)) { + return false; + } + seen.add(id); + return true; + }); + } + } + next(); +}); + +const Wishlist = mongoose.model('Wishlist', wishlistSchema); + +export default Wishlist; diff --git a/src/routes/test.routes.js b/src/routes/test.routes.js new file mode 100644 index 0000000..437e0bf --- /dev/null +++ b/src/routes/test.routes.js @@ -0,0 +1,33 @@ +import express from 'express'; +import { generateTestToken } from '../utils/auth.util.js'; + +const router = express.Router(); + +/** + * GET /api/test/token + * Generate a test JWT token for development/testing + * This endpoint should be removed in production + */ +router.get('/token', (req, res) => { + try { + const token = generateTestToken(); + + res.status(200).json({ + success: true, + message: 'Test token generated successfully', + data: { + token, + expiresIn: '24h', + note: 'Use this token in Authorization header as: Bearer ', + }, + }); + } catch (error) { + console.error('Error generating test token:', error); + res.status(500).json({ + success: false, + message: 'Failed to generate test token', + }); + } +}); + +export default router; diff --git a/src/routes/wishlist.routes.js b/src/routes/wishlist.routes.js new file mode 100644 index 0000000..6db76c2 --- /dev/null +++ b/src/routes/wishlist.routes.js @@ -0,0 +1,27 @@ +import express from 'express'; +import { + getWishlist, + addToWishlist, + removeFromWishlist, + clearWishlist, +} from '../controllers/wishlist.controller.js'; +import { authenticateToken } from '../middleware/auth.middleware.js'; + +const router = express.Router(); + +// All wishlist routes require authentication +router.use(authenticateToken); + +// GET /api/wishlist - Get user's wishlist +router.get('/', getWishlist); + +// POST /api/wishlist - Add product to wishlist +router.post('/', addToWishlist); + +// DELETE /api/wishlist/:productId - Remove specific product from wishlist +router.delete('/:productId', removeFromWishlist); + +// DELETE /api/wishlist - Clear entire wishlist +router.delete('/', clearWishlist); + +export default router; diff --git a/src/utils/auth.util.js b/src/utils/auth.util.js new file mode 100644 index 0000000..d17e430 --- /dev/null +++ b/src/utils/auth.util.js @@ -0,0 +1,25 @@ +import jwt from 'jsonwebtoken'; + +/** + * Generate JWT token for testing purposes + * In production, this should be part of the login process + */ +export const generateTestToken = (userId = '507f1f77bcf86cd799439011') => { + const secret = process.env.JWT_SECRET || 'your-secret-key'; + + const payload = { + id: userId, + email: 'test@example.com', + role: 'user', + }; + + return jwt.sign(payload, secret, { expiresIn: '24h' }); +}; + +/** + * Verify JWT token + */ +export const verifyToken = (token) => { + const secret = process.env.JWT_SECRET || 'your-secret-key'; + return jwt.verify(token, secret); +};