From 303d191cdedb0e86201cc29e480c2a6d3ef03dfb Mon Sep 17 00:00:00 2001 From: Trishanth Sai Date: Wed, 8 Jul 2026 18:20:44 +0530 Subject: [PATCH 1/3] Implement product recommendation endpoint --- BACKEND/controllers/product.controller.js | 191 +++++++++++++++++++--- package-lock.json | 28 ++++ package.json | 1 + 3 files changed, 198 insertions(+), 22 deletions(-) diff --git a/BACKEND/controllers/product.controller.js b/BACKEND/controllers/product.controller.js index 04ccd4c8..443137c2 100644 --- a/BACKEND/controllers/product.controller.js +++ b/BACKEND/controllers/product.controller.js @@ -1,12 +1,24 @@ -import Product from '../models/product.model.js'; +import mongoose from "mongoose"; +import Product from "../models/product.model.js"; +import Order from "../models/order.model.js"; // Escapes user-supplied text before it is used inside a RegExp, so values like // "a+b" are matched literally instead of being interpreted as regex syntax. -const escapeRegExp = (value) => value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +const escapeRegExp = (value) => + value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); export const createProduct = async (req, res) => { try { - const { name, description, brand, basePrice, baseStock, hasVariants, variants } = req.body; + const { + name, + description, + brand, + basePrice, + baseStock, + hasVariants, + variants, + } = req.body; + const newProduct = new Product({ name, description, @@ -14,9 +26,11 @@ export const createProduct = async (req, res) => { basePrice: hasVariants ? undefined : basePrice, baseStock: hasVariants ? undefined : baseStock, hasVariants, - variants: hasVariants ? variants : [] + variants: hasVariants ? variants : [], }); + await newProduct.save(); + res.status(201).json(newProduct); } catch (error) { res.status(500).json({ message: error.message }); @@ -25,7 +39,10 @@ export const createProduct = async (req, res) => { export const getProducts = async (req, res) => { try { - const products = await Product.find(); + const products = await Product.find({ + isDeleted: { $ne: true }, + }); + res.status(200).json(products); } catch (error) { res.status(500).json({ message: error.message }); @@ -34,40 +51,163 @@ export const getProducts = async (req, res) => { export const getProductById = async (req, res) => { try { - const product = await Product.findById(req.params.id); - if (!product) return res.status(404).json({ message: "Product not found" }); + const product = await Product.findOne({ + _id: req.params.id, + isDeleted: { $ne: true }, + }); + + if (!product) { + return res.status(404).json({ + message: "Product not found", + }); + } + res.status(200).json(product); } catch (error) { res.status(500).json({ message: error.message }); } }; -export const deleteProduct = async (req, res) => res.status(501).json({ message: "Not implemented" }); -export const getProductCategories = async (req, res) => res.status(501).json({ message: "Not implemented" }); -export const updateProduct = async (req, res) => res.status(501).json({ message: "Not implemented" }); -export const getRelatedProducts = async (req, res) => res.status(501).json({ message: "Not implemented" }); +export const deleteProduct = async (req, res) => + res.status(501).json({ message: "Not implemented" }); + +export const getProductCategories = async (req, res) => + res.status(501).json({ message: "Not implemented" }); + +export const updateProduct = async (req, res) => + res.status(501).json({ message: "Not implemented" }); + +export const getRelatedProducts = async (req, res) => { + try { + const { id } = req.params; + + if (!mongoose.Types.ObjectId.isValid(id)) { + return res.status(400).json({ + success: false, + message: "Invalid product ID", + }); + } + + const productId = new mongoose.Types.ObjectId(id); + + // Ensure product exists + const product = await Product.findOne({ + _id: productId, + isDeleted: { $ne: true }, + }); + + if (!product) { + return res.status(404).json({ + success: false, + message: "Product not found", + }); + } + + // Products frequently bought together + const recommendations = await Order.aggregate([ + { + $match: { + paymentStatus: "completed", + "items.product": productId, + }, + }, + { + $unwind: "$items", + }, + { + $match: { + "items.product": { + $ne: productId, + }, + }, + }, + { + $group: { + _id: "$items.product", + purchaseCount: { + $sum: 1, + }, + }, + }, + { + $sort: { + purchaseCount: -1, + }, + }, + { + $limit: 5, + }, + ]); + + const productIds = recommendations.map((item) => item._id); + + if (productIds.length === 0) { + return res.status(200).json({ + success: true, + count: 0, + data: [], + }); + } + + const relatedProducts = await Product.find({ + _id: { $in: productIds }, + isDeleted: { $ne: true }, + }); + + // Preserve recommendation ranking + const orderedProducts = productIds + .map((id) => + relatedProducts.find( + (product) => product._id.toString() === id.toString() + ) + ) + .filter(Boolean); + + return res.status(200).json({ + success: true, + count: orderedProducts.length, + data: orderedProducts, + }); + } catch (error) { + console.error("Recommendation engine error:", error); + + return res.status(500).json({ + success: false, + message: "Failed to fetch related products", + error: error.message, + }); + } +}; + export const searchProducts = async (req, res) => { try { - const filter = { isDeleted: { $ne: true } }; + const filter = { + isDeleted: { $ne: true }, + }; - // ?brands=Apple,Samsung — match products whose brand is ANY of the provided - // values (case-insensitive, exact match per brand). if (req.query.brands !== undefined) { const brandList = String(req.query.brands) - .split(',') + .split(",") .map((b) => b.trim()) .filter(Boolean); if (brandList.length > 0) { filter.brand = { - $in: brandList.map((b) => new RegExp(`^${escapeRegExp(b)}$`, 'i')), + $in: brandList.map( + (b) => new RegExp(`^${escapeRegExp(b)}$`, "i") + ), }; } } - // Optional free-text query matched against the product name. - if (req.query.q !== undefined && String(req.query.q).trim() !== '') { - filter.name = { $regex: escapeRegExp(String(req.query.q).trim()), $options: 'i' }; + if ( + req.query.q !== undefined && + String(req.query.q).trim() !== "" + ) { + filter.name = { + $regex: escapeRegExp(String(req.query.q).trim()), + $options: "i", + }; } const products = await Product.find(filter); @@ -78,8 +218,15 @@ export const searchProducts = async (req, res) => { data: products, }); } catch (error) { - res.status(500).json({ success: false, message: error.message }); + res.status(500).json({ + success: false, + message: error.message, + }); } }; -export const getProductBundle = async (req, res) => res.status(501).json({ message: "Not implemented" }); -export const restockProduct = async (req, res) => res.status(501).json({ message: "Not implemented" }); + +export const getProductBundle = async (req, res) => + res.status(501).json({ message: "Not implemented" }); + +export const restockProduct = async (req, res) => + res.status(501).json({ message: "Not implemented" }); \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index f4ac1038..717741bc 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,6 +12,7 @@ "cors": "^2.8.5", "dotenv": "^16.5.0", "express": "^4.21.2", + "express-rate-limit": "^8.5.2", "mongodb": "^7.0.0", "mongoose": "^8.13.2", "serverless-http": "^4.0.0" @@ -1182,6 +1183,24 @@ "url": "https://opencollective.com/express" } }, + "node_modules/express-rate-limit": { + "version": "8.5.2", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.5.2.tgz", + "integrity": "sha512-5Kb34ipNX694DH48vN9irak1Qx30nb0PLYHXfJgw4YEjiC3ZEmZJhwOp+VfiCYwFzvFTdB9QkArYS5kXa2cx2A==", + "license": "MIT", + "dependencies": { + "ip-address": "^10.2.0" + }, + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/express-rate-limit" + }, + "peerDependencies": { + "express": ">= 4.11" + } + }, "node_modules/express/node_modules/debug": { "version": "2.6.9", "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", @@ -1600,6 +1619,15 @@ "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", "license": "ISC" }, + "node_modules/ip-address": { + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz", + "integrity": "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, "node_modules/ipaddr.js": { "version": "1.9.1", "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", diff --git a/package.json b/package.json index 8fe7c469..f4a526f1 100644 --- a/package.json +++ b/package.json @@ -17,6 +17,7 @@ "cors": "^2.8.5", "dotenv": "^16.5.0", "express": "^4.21.2", + "express-rate-limit": "^8.5.2", "mongodb": "^7.0.0", "mongoose": "^8.13.2", "serverless-http": "^4.0.0" From 4be01b72b30657b8df79216a5549b4f6b4d18db4 Mon Sep 17 00:00:00 2001 From: Trishanth Sai Date: Wed, 8 Jul 2026 18:47:34 +0530 Subject: [PATCH 2/3] feat: add recommendation endpoint --- BACKEND/controllers/product.controller.js | 683 +--------------------- 1 file changed, 31 insertions(+), 652 deletions(-) diff --git a/BACKEND/controllers/product.controller.js b/BACKEND/controllers/product.controller.js index 2abc8bc2..63d2c4aa 100644 --- a/BACKEND/controllers/product.controller.js +++ b/BACKEND/controllers/product.controller.js @@ -1,24 +1,15 @@ +import Product from '../models/product.model.js'; import mongoose from "mongoose"; -import Product from "../models/product.model.js"; import Order from "../models/order.model.js"; + // Escapes user-supplied text before it is used inside a RegExp, so values like // "a+b" are matched literally instead of being interpreted as regex syntax. -const escapeRegExp = (value) => - value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +const escapeRegExp = (value) => value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); export const createProduct = async (req, res) => { try { - const { - name, - description, - brand, - basePrice, - baseStock, - hasVariants, - variants, - } = req.body; - + const { name, description, brand, basePrice, baseStock, hasVariants, variants } = req.body; const newProduct = new Product({ name, description, @@ -26,11 +17,9 @@ export const createProduct = async (req, res) => { basePrice: hasVariants ? undefined : basePrice, baseStock: hasVariants ? undefined : baseStock, hasVariants, - variants: hasVariants ? variants : [], + variants: hasVariants ? variants : [] }); - await newProduct.save(); - res.status(201).json(newProduct); } catch (error) { res.status(500).json({ message: error.message }); @@ -39,10 +28,7 @@ export const createProduct = async (req, res) => { export const getProducts = async (req, res) => { try { - const products = await Product.find({ - isDeleted: { $ne: true }, - }); - + const products = await Product.find(); res.status(200).json(products); } catch (error) { res.status(500).json({ message: error.message }); @@ -51,659 +37,52 @@ export const getProducts = async (req, res) => { export const getProductById = async (req, res) => { try { - const product = await Product.findOne({ - _id: req.params.id, - isDeleted: { $ne: true }, - }); - - if (!product) { - return res.status(404).json({ - message: "Product not found", - }); - } - + const product = await Product.findById(req.params.id); + if (!product) return res.status(404).json({ message: "Product not found" }); res.status(200).json(product); } catch (error) { res.status(500).json({ message: error.message }); } }; -export const deleteProduct = async (req, res) => - res.status(501).json({ message: "Not implemented" }); - -export const getProductCategories = async (req, res) => - res.status(501).json({ message: "Not implemented" }); - -export const updateProduct = async (req, res) => - res.status(501).json({ message: "Not implemented" }); - -export const getRelatedProducts = async (req, res) => { - try { - const { id } = req.params; - - if (!mongoose.Types.ObjectId.isValid(id)) { - return res.status(400).json({ - success: false, - message: "Invalid product ID", - }); - } - - const productId = new mongoose.Types.ObjectId(id); - - // Ensure product exists - const product = await Product.findOne({ - _id: productId, - isDeleted: { $ne: true }, - }); - - if (!product) { - return res.status(404).json({ - success: false, - message: "Product not found", - }); - } - - // Products frequently bought together - const recommendations = await Order.aggregate([ - { - $match: { - paymentStatus: "completed", - "items.product": productId, - }, - }, - { - $unwind: "$items", - }, - { - $match: { - "items.product": { - $ne: productId, - }, - }, - }, - { - $group: { - _id: "$items.product", - purchaseCount: { - $sum: 1, - }, - }, - }, - { - $sort: { - purchaseCount: -1, - }, - }, - { - $limit: 5, - }, - ]); - - const productIds = recommendations.map((item) => item._id); - - if (productIds.length === 0) { - return res.status(200).json({ - success: true, - count: 0, - data: [], - }); - } - - const relatedProducts = await Product.find({ - _id: { $in: productIds }, - isDeleted: { $ne: true }, - }); - - // Preserve recommendation ranking - const orderedProducts = productIds - .map((id) => - relatedProducts.find( - (product) => product._id.toString() === id.toString() - ) - ) - .filter(Boolean); - - return res.status(200).json({ - success: true, - count: orderedProducts.length, - data: orderedProducts, - }); - } catch (error) { - console.error("Recommendation engine error:", error); - - return res.status(500).json({ - success: false, - message: "Failed to fetch related products", - error: error.message, - }); - } -}; - +export const deleteProduct = async (req, res) => res.status(501).json({ message: "Not implemented" }); +export const getProductCategories = async (req, res) => res.status(501).json({ message: "Not implemented" }); +export const updateProduct = async (req, res) => res.status(501).json({ message: "Not implemented" }); +export const getRelatedProducts = async (req, res) => res.status(501).json({ message: "Not implemented" }); export const searchProducts = async (req, res) => { try { - const filter = { - isDeleted: { $ne: true }, - }; + const filter = { isDeleted: { $ne: true } }; + // ?brands=Apple,Samsung — match products whose brand is ANY of the provided + // values (case-insensitive, exact match per brand). if (req.query.brands !== undefined) { const brandList = String(req.query.brands) - .split(",") + .split(',') .map((b) => b.trim()) .filter(Boolean); if (brandList.length > 0) { filter.brand = { - $in: brandList.map( - (b) => new RegExp(`^${escapeRegExp(b)}$`, "i") - ), -import Product from "../models/product.model.js"; -import mongoose from "mongoose"; -import { escapeRegex } from '../utils/escapeRegex.js'; -import cloudinary from '../config/cloudinary.js'; -import { AppError } from "../middleware/errorMiddleware.js"; -import { getIO } from "../socket.js"; -import { indexProduct, deleteProductFromIndex, searchProductsES } from '../services/elasticsearch.service.js'; -import redis from '../config/redis.js'; - -const CACHE_TTL = 300; // seconds - -function buildCacheKey(query) { - const sorted = Object.keys(query).sort().reduce((acc, k) => { acc[k] = query[k]; return acc; }, {}); - return `products:list:${JSON.stringify(sorted)}`; -} - -async function invalidateProductCache() { - if (!redis) return; - try { - const keys = await redis.keys('products:*'); - if (keys.length) await redis.del(...keys); - } catch (err) { - console.warn('[Redis] Cache invalidation error:', err.message); - } -} - -const cloudinaryConfigured = () => - process.env.CLOUDINARY_CLOUD_NAME && - process.env.CLOUDINARY_API_KEY && - process.env.CLOUDINARY_API_SECRET; - -const uploadToCloudinary = (buffer) => { - return new Promise((resolve, reject) => { - const stream = cloudinary.uploader.upload_stream( - { folder: 'product-store' }, - (error, result) => { - if (error) reject(error); - else resolve(result); - } - ); - stream.on('error', reject); - stream.end(buffer); - }); -}; - -const extractCloudinaryPublicId = (url) => { - if (!url || !url.includes('res.cloudinary.com')) return null; - const parts = url.split('/'); - const uploadIdx = parts.indexOf('upload'); - if (uploadIdx === -1) return null; - const afterUpload = parts.slice(uploadIdx + 1); - if (afterUpload[0] && /^v\d+$/.test(afterUpload[0])) afterUpload.shift(); - return afterUpload.join('/').replace(/\.[^.]+$/, ''); -}; - -// @desc Get all products -export const getProducts = async (req, res, next) => { - try { - const page = parseInt(req.query.page, 10) || 1; - const limit = parseInt(req.query.limit, 10) || 10; - const { sort, category, minPrice, maxPrice, brand, minRating, inStock } = req.query; - - if (page < 1 || limit < 1) { - return res.status(400).json({ - success: false, - message: "Invalid pagination parameters. page and limit must be positive integers.", - }); - } - - // Check Redis cache first - const cacheKey = buildCacheKey(req.query); - if (redis) { - try { - const cached = await redis.get(cacheKey); - if (cached) { - return res.status(200).json(JSON.parse(cached)); - } - } catch (err) { - console.warn('[Redis] Cache read error:', err.message); - } - } - - let sortOption = {}; - if (sort === "price_asc") { - sortOption = { price: 1 }; - } else if (sort === "price_desc") { - sortOption = { price: -1 }; - } else if (sort === "newest") { - sortOption = { createdAt: -1 }; - } - - const filter = { isDeleted: { $ne: true } }; - if (category) filter.category = category; - - if (minPrice || maxPrice) { - filter.price = {}; - if (minPrice) filter.price.$gte = Number(minPrice); - if (maxPrice) filter.price.$lte = Number(maxPrice); - } - if (brand) { - // Case-insensitive brand search - filter.brand = { $regex: new RegExp(brand, 'i') }; - } - if (minRating) { - filter.averageRating = { $gte: Number(minRating) }; - } - if (inStock === 'true') { - filter.stock = { $gt: 0 }; - } - - const skip = (page - 1) * limit; - const totalProducts = await Product.countDocuments(filter); - const products = await Product.find(filter).sort(sortOption).skip(skip).limit(limit); - const totalPages = totalProducts > 0 ? Math.ceil(totalProducts / limit) : 0; - - const result = { - success: true, - currentPage: page, - totalPages, - totalProducts, - limit, - data: products, + $in: brandList.map((b) => new RegExp(`^${escapeRegExp(b)}$`, 'i')), }; - - // Store in Redis cache - if (redis) { - try { - await redis.set(cacheKey, JSON.stringify(result), 'EX', CACHE_TTL); - } catch (err) { - console.warn('[Redis] Cache write error:', err.message); - } - } - - res.status(200).json(result); - } catch (error) { - next(error); - } -}; - -// @desc Get distinct product categories -export const getProductCategories = async (req, res, next) => { - try { - const categories = await Product.distinct('category', { isDeleted: { $ne: true }, category: { $ne: '' } }); - res.status(200).json({ success: true, data: categories.sort() }); - } catch (error) { - next(error); + } } -}; - -// @desc Create a new product -export const createProduct = async (req, res, next) => { - const { name, price, image: imageUrl, description, category, brand, stock, originalPrice, discount } = req.body; - if (!name || price === undefined || price === null || price === '' || isNaN(Number(price))) { - return next(new AppError("Please provide all fields", 400)); + // Optional free-text query matched against the product name. + if (req.query.q !== undefined && String(req.query.q).trim() !== '') { + filter.name = { $regex: escapeRegExp(String(req.query.q).trim()), $options: 'i' }; } - if (Number(price) < 0) { - return next(new AppError("Price cannot be negative", 400)); - } + const products = await Product.find(filter); - let finalImageUrl = imageUrl || ''; - - if (req.file) { - if (!cloudinaryConfigured()) { - return next(new AppError("File uploads are not configured. Please use an image URL instead.", 503)); - } - try { - const result = await uploadToCloudinary(req.file.buffer); - finalImageUrl = result.secure_url; - } catch (error) { - return next(new AppError("Image upload failed", 500)); - } - } - - if (!finalImageUrl) { - return next(new AppError("Please provide a product image", 400)); - } - - const newProduct = new Product({ - name, - price: Number(price), - image: finalImageUrl, - images: Array.isArray(req.body.images) ? req.body.images : [], - description, - category, - brand, - ...(stock !== undefined && { stock: Number(stock) }), - ...(originalPrice !== undefined && { originalPrice: Number(originalPrice) }), - ...(discount !== undefined && { discount: Number(discount) }), + res.status(200).json({ + success: true, + count: products.length, + data: products, }); - - try { - await newProduct.save(); - await indexProduct(newProduct); - await invalidateProductCache(); - res.status(201).json({ success: true, data: newProduct }); - } catch (error) { - next(error); - } -}; - -// @desc Update a product -export const updateProduct = async (req, res, next) => { - const { id } = req.params; - - if (!mongoose.Types.ObjectId.isValid(id)) { - return next(new AppError("Invalid Product Id format", 404)); - } - - if ((!req.body || Object.keys(req.body).length === 0) && !req.file) { - return next(new AppError("No update fields provided", 400)); - } - - let existing; - try { - existing = await Product.findById(id); - } catch (error) { - return next(error); - } - if (!existing) { - return next(new AppError("Product not found", 404)); - } - - const { name, price, image: imageUrl, description, category, brand, stock, originalPrice, discount } = req.body; - const updateData = {}; - if (name !== undefined) updateData.name = name; - if (price !== undefined) { - if (price === '' || isNaN(Number(price))) { - return next(new AppError("Invalid price value", 400)); - } - updateData.price = Number(price); - } - if (imageUrl !== undefined) updateData.image = imageUrl; - if (req.body.images !== undefined) updateData.images = Array.isArray(req.body.images) ? req.body.images : []; - if (description !== undefined) updateData.description = description; - if (category !== undefined) updateData.category = category; - if (brand !== undefined) updateData.brand = brand; - if (stock !== undefined) updateData.stock = Number(stock); - if (originalPrice !== undefined) updateData.originalPrice = Number(originalPrice); - if (discount !== undefined) updateData.discount = Number(discount); - - if (req.file) { - if (!cloudinaryConfigured()) { - return next(new AppError("File uploads are not configured. Please use an image URL instead.", 503)); - } - try { - const result = await uploadToCloudinary(req.file.buffer); - updateData.image = result.secure_url; - - } catch (error) { - return next(new AppError("Image upload failed", 500)); - } - } - - try { - const updatedProduct = await Product.findByIdAndUpdate(id, updateData, { new: true, runValidators: true }); - if (!updatedProduct) { - return next(new AppError("Product not found", 404)); - } - if (req.file){ - const oldPublicId = extractCloudinaryPublicId(existing.image); - if (oldPublicId) { - cloudinary.uploader.destroy(oldPublicId).catch((err) => { - console.warn("Old image cleanup failed:", err.message); - }); - } - } - - await indexProduct(updatedProduct); - await invalidateProductCache(); - - res.status(200).json({ success: true, data: updatedProduct }); - - if (stock !== undefined) { - getIO()?.emit("stockUpdate", { - productId: updatedProduct._id, - newStock: updatedProduct.stock - }); - } - } catch (error) { - next(error); - } -}; - -// @desc Restock a product by incrementing its stock -export const restockProduct = async (req, res, next) => { - const { id } = req.params; - const { amount } = req.body; - - if (!mongoose.Types.ObjectId.isValid(id)) { - return next(new AppError("Invalid Product Id format", 404)); - } - - if (typeof amount !== 'number' || !Number.isInteger(amount) || amount <= 0) { - return next(new AppError("Restock amount must be a positive integer", 400)); - } - - try { - const product = await Product.findOneAndUpdate( - { _id: id, isDeleted: { $ne: true } }, - { $inc: { stock: amount } }, - { new: true, runValidators: true } - ); - if (!product) return next(new AppError("Product not found", 404)); - res.status(200).json({ success: true, data: product }); - } catch (error) { - next(error); - } -}; - -// @desc Delete a product (soft delete) -export const deleteProduct = async (req, res, next) => { - const { id } = req.params; - - if (!mongoose.Types.ObjectId.isValid(id)) { - return res.status(404).json({ success: false, message: "Invalid Product Id" }); - } - - try { - const product = await Product.findByIdAndUpdate(id, { isDeleted: true }, { new: true }); - if (!product) { - return res.status(404).json({ success: false, message: "Product not found" }); - } - await deleteProductFromIndex(id); - await invalidateProductCache(); - res.status(200).json({ success: true, message: "Product deleted successfully" }); - } catch (error) { - console.log("error in deleting product:", error.message); - res.status(500).json({ success: false, message: "Server Error" }); - } -}; - -// @desc Get product by ID -export const getProductById = async (req, res, next) => { - const { id } = req.params; - - if (!mongoose.Types.ObjectId.isValid(id)) { - return next(new AppError("Invalid Product Id format", 404)); - } - - try { - const product = await Product.findOne({ _id: id, isDeleted: { $ne: true } }); - if (!product) { - return next(new AppError("Product not found", 404)); - } - res.status(200).json({ success: true, data: product }); - } catch (error) { - next(error); - } -}; - -const stopWords = new Set(["the", "a", "an", "and", "or", "but", "in", "on", "at", "to", "for", "with", "of"]); - -function tokenize(text) { - return text - .toLowerCase() - .split(/\s+/) - .map(w => w.replace(/[^a-z0-9]/g, "")) - .filter(w => w.length > 1 && !stopWords.has(w)); -} - -export const getRelatedProducts = async (req, res) => { - const { id } = req.params; - - if (!mongoose.Types.ObjectId.isValid(id)) { - return res.status(400).json({ success: false, message: "Invalid Product Id format" }); - } - - try { - const product = await Product.findById(id); - - if (!product || product.isDeleted === true) { - return res.status(404).json({ success: false, message: "Product not found" }); - } - - const targetTagsSet = new Set((product.tags || []).map(t => t.toLowerCase())); - const targetWords = new Set(tokenize(product.name)); - - const orConditions = []; - if (product.category) orConditions.push({ category: product.category }); - if (product.brand) orConditions.push({ brand: product.brand }); - if (targetTagsSet.size > 0) orConditions.push({ tags: { $in: [ ...targetTagsSet ] } }); - - const query = { - _id: { $ne: product._id }, - isDeleted: { $ne: true }, - }; - if (orConditions.length > 0) query.$or = orConditions; - - const candidates = await Product.find(query).sort({ updatedAt: -1 }).limit(50); - - const scored = candidates.map(c => { - let score = 0; - - if (c.category && product.category && - c.category.toLowerCase() === product.category.toLowerCase()) { - score += 3; - } - - if (c.brand && product.brand && - c.brand.toLowerCase() === product.brand.toLowerCase()) { - score += 1; - } - - if (c.tags && c.tags.length > 0) { - for (const tag of c.tags) { - if (targetTags.has(tag.toLowerCase())) { - score += 2; - } - } - } - - const candidateWords = tokenize(c.name); - for (const word of candidateWords) { - if (targetWords.has(word)) { - score += 0.5; - } - } - - return { product: c, score }; - }); - - scored.sort((a, b) => b.score - a.score); - - const related = scored.slice(0, 5).map(s => s.product); - - res.status(200).json({ success: true, data: related }); - } catch (error) { - console.error("Error in getRelatedProducts:", error.message); - res.status(500).json({ success: false, message: "Server Error" }); - } -}; - -export const getProductBundle = async (req, res) => { - const { id } = req.params; - - if (!mongoose.Types.ObjectId.isValid(id)) { - return res.status(400).json({ success: false, message: "Invalid Product Id" }); - } - - try { - const product = await Product.findById(id).populate('complementaryItems.product'); - if (!product || product.isDeleted === true) { - return res.status(404).json({ success: false, message: "Product not found" }); - } - - const items = product.complementaryItems - .filter(ci => ci.product && !ci.product.isDeleted) - .slice(0, 3); - - const bundleTotal = [product, ...items.map(i => i.product)] - .reduce((sum, p) => sum + p.price, 0); - - const bundleDiscount = 0.1; - const bundlePrice = +(bundleTotal * (1 - bundleDiscount)).toFixed(2); - const savings = +(bundleTotal * bundleDiscount).toFixed(2); - - res.status(200).json({ - success: true, - data: { - mainProduct: product, - items: items.map(ci => ({ - product: ci.product, - reason: ci.reason - })), - bundleTotal, - bundleDiscount, - bundlePrice, - savings - } - }); - } catch (error) { - console.error("Error in fetching bundle:", error.message); - res.status(500).json({ success: false, message: "Server Error" }); - } -}; - -// @desc Search products -export const searchProducts = async (req, res, next) => { - const { q, brands } = req.query; - const brandList = brands ? brands.split(',').map((b) => b.trim()).filter(Boolean) : []; - const hasQuery = !!(q && q.trim()); - - try { - if (!hasQuery && brandList.length === 0) { - const products = await Product.find({ isDeleted: { $ne: true } }); - return res.status(200).json({ success: true, count: products.length, data: products }); - } - - if (hasQuery && brandList.length === 0) { - const esProducts = await searchProductsES(q); - if (esProducts) { - return res.status(200).json({ success: true, count: esProducts.length, data: esProducts }); - } - } - - const filter = { isDeleted: { $ne: true } }; - if (hasQuery) { - filter.name = new RegExp(escapeRegex(q.trim()), 'i'); - } - if (brandList.length > 0) { - filter.brand = { $in: brandList.map((b) => new RegExp(`^${escapeRegex(b)}$`, 'i')) }; - } - - const products = await Product.find(filter); - res.status(200).json({ success: true, count: products.length, data: products }); - } catch (error) { - next(error); - } + } catch (error) { + res.status(500).json({ success: false, message: error.message }); + } }; +export const getProductBundle = async (req, res) => res.status(501).json({ message: "Not implemented" }); +export const restockProduct = async (req, res) => res.status(501).json({ message: "Not implemented" }); From 9da88a3131f344b4d5f74ae0648c56aaec64c792 Mon Sep 17 00:00:00 2001 From: Trishanth Sai Date: Wed, 8 Jul 2026 19:07:26 +0530 Subject: [PATCH 3/3] feat: add recommendation endpoint for related products --- BACKEND/models/returnRequest.model.js | 2 +- controller.txt | Bin 0 -> 11046 bytes 2 files changed, 1 insertion(+), 1 deletion(-) create mode 100644 controller.txt diff --git a/BACKEND/models/returnRequest.model.js b/BACKEND/models/returnRequest.model.js index 4e97c16b..b9ff7bd9 100644 --- a/BACKEND/models/returnRequest.model.js +++ b/BACKEND/models/returnRequest.model.js @@ -50,7 +50,7 @@ const returnRequestSchema = new mongoose.Schema({ type: [returnItemSchema], required: [true, "At least one return item is required"], validate: { - validator: function (v: any[]) { + validator: function (v) { return v && v.length > 0; }, message: "Items array cannot be empty", diff --git a/controller.txt b/controller.txt new file mode 100644 index 0000000000000000000000000000000000000000..aace5b4160e44b89cef8d5c91dc49996111ca90c GIT binary patch literal 11046 zcmds-U2hXd6o%)zQvbtZS;%R@feLOwPzh)yq^d!*>J6pEiA^xXapE{7h~}@ieV-W* z^SO=_V%nl8i9I`ezR!Ekncctt8k>nZFjF%#bJOVa)a;vG{hgX4Gt$aSU72dVF`eRS z$J{kvnlDUBq_x1BnAZ0e^m--+jr8Az^?J~h$~f#e=*WvFX&{Xel4aTn6fEUi8BPHT=os{^}dDv2A>8QCkliyiJRyfU&Bpf<3Yu(GS) z6RjMH;=ZmA^sR4Y+mY<(hvrD`Z5Zp=n0va~i!|KO_j~5PrO@be(7kUZz}f@R8tJZ0 z^Gv+HlJ4J{-%QuMH>Xpi}K%v4ycv`T!{{X~4^e#&)8eeik4#sgvYW7+vr zMTqBfc6@AEkJqB)cg2Z|3}1Z?l(kgOiQ=bkucnc)uGkveKCEZ1sH(@kFdao1-AD^Q z&7_&DoJgDE)-6TltpY#ddg^g6#SAi|ljS|Naw?9@0@FpE>SQ_Na4(K@w*tqQ>qWCD zru;eJE0x}(Q;KXpvRCOey=CiGrH16St6oDo=EsGH-6V%(#05Pa=*gTBY6)D=S2-Tq zYRK$B4P*Z8lSy#6(Cc&b;8S^t$;(ARs^R&(7h~8e^APo$N_P-t39f)8c$tm zZNhfWKYr#qpWVHkOHcY!7|s%?a`b2^Z@S(S>o;c3)#UZKdO00KDi5|O?YoXHG(>qS z!N*1GYMsL6MC%iIG@SNbW^du*CZ!KjasCK`(Ct&v+*ueW4acfTue8E6FtsOJb9;U^ zYmF%=krR_eeRLJTox=oF3^BGP-qiY;aAqJ5yH=K6eFj&1nr`FhCFANy(G@>GQMy?s zzt86|DEV$7-Q1qwa35!wuD5g2bE@V~_iEVSsdbcPNa{M`H0C;%V!Bq(1gPaxR@+*e z{+y4zl{B1^I8C~dY&9Kf9NVa`YTdK#QRm?88RpMb?bc?ew)OL9O*E1P2aEZ*EvCYx z)X@aJEFwx2^qL=t&rG@F&cH_Iq2)|IE{|vK@7hW*u{&>8kk<34>3$h@;#8jJBg-XS zuHkj{;&toPq^_;iBdcrc{=|9(`EFOZ2-x}s3|>7=Q)ob$TL0n5~?HCK6R@y6Da-BxRo`L2R938%L5IZ@L-_6~ka zGlL0(_zlm!yl?rx+=KDiXU@e_>17`A+;UI(OzbO4r?y*QJ}1LCB@Y?KOjO@flVBsU zCNh;PPK!P9#a})1K>VjRzwwHX_?Y_TW%GG5InCnb(U9U(i3)M9FQ=5=n@(!yJOA>? z;B4Es)^^V#dYTHK>U(>JIIJZe$foe5;f|dr&yjI2Rf2G7{+2(7N$<>kiLR?XlL6T{pGM+34z1()G}XXo zshgq8>iDske%Inf>~-G-kn^1Vxao~&={zI$Pq)F}D~leWe0~akm9qMLVCoB$evp=& zpgRqD`WbNWEE!5{BRV*dcW;W!g>J+TZz04wvdirZJ=69Ab?@uSwnbdzm{>G!yF2#K zDmV&HZ{N}aDTXzwQ0@H{lw$6&Z`xwT=}b@Wdxx3~-Xb-7c?vyQDAYB73I(G1H)?w3 z#o|2?`hwc~D2~RdqL>VaRB(WGpTS->w-!rZo~?Nw#Jjk!7H@}IZ;$jw z@3>qoV(aWEK9oLG^$=r$OGkEyC97vs>V5xC#WLS_t$yw;B09%*&s;?QHve>m_~DHi ze-?tTQ*^HRvh-YxA!+aDJHq&FgcPge?k%=nTLmqj?X5Pklr^(`T0x&MspJ}^h)~)q z?rQRT)7^RPTi3JjIBxN1X^Zpq524bptG3tPw|HQEGPGTb%c#}ryVo8ZDWm7#c`E2+ N@o$r*8S&rO?H_~MD@_0Z literal 0 HcmV?d00001