first commit
This commit is contained in:
345
src/controllers/products/recommendation.js
Normal file
345
src/controllers/products/recommendation.js
Normal file
@@ -0,0 +1,345 @@
|
||||
// controllers/products/recommendationController.js
|
||||
|
||||
const Product = require('../../models/mongodb/Product');
|
||||
const { prisma } = require('../../config/database');
|
||||
|
||||
/**
|
||||
* @desc Get recommendations for a product
|
||||
* @route GET /api/products/:slug/recommendations
|
||||
* @access Public
|
||||
*/
|
||||
const getProductRecommendations = async (req, res, next) => {
|
||||
try {
|
||||
const { slug } = req.params;
|
||||
const { limit = 12 } = req.query;
|
||||
|
||||
// Get the current product
|
||||
const currentProduct = await Product.findOne({ slug, status: 'active' });
|
||||
|
||||
if (!currentProduct) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: 'Product not found',
|
||||
});
|
||||
}
|
||||
|
||||
// Build recommendation query
|
||||
const recommendations = await getRecommendedProducts(
|
||||
currentProduct,
|
||||
parseInt(limit)
|
||||
);
|
||||
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
count: recommendations.length,
|
||||
data: recommendations,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Get recommendations error:', error);
|
||||
next(error);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* @desc Get "Customers also bought" products
|
||||
* @route GET /api/products/:slug/also-bought
|
||||
* @access Public
|
||||
*/
|
||||
const getAlsoBoughtProducts = async (req, res, next) => {
|
||||
try {
|
||||
const { slug } = req.params;
|
||||
const { limit = 8 } = req.query;
|
||||
|
||||
const currentProduct = await Product.findOne({ slug, status: 'active' });
|
||||
|
||||
if (!currentProduct) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: 'Product not found',
|
||||
});
|
||||
}
|
||||
|
||||
// Get products frequently bought together
|
||||
const alsoBought = await getFrequentlyBoughtTogether(
|
||||
currentProduct._id.toString(),
|
||||
parseInt(limit)
|
||||
);
|
||||
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
count: alsoBought.length,
|
||||
data: alsoBought,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Get also bought error:', error);
|
||||
next(error);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* @desc Get similar products
|
||||
* @route GET /api/products/:slug/similar
|
||||
* @access Public
|
||||
*/
|
||||
const getSimilarProducts = async (req, res, next) => {
|
||||
try {
|
||||
const { slug } = req.params;
|
||||
const { limit = 10 } = req.query;
|
||||
|
||||
const currentProduct = await Product.findOne({ slug, status: 'active' });
|
||||
|
||||
if (!currentProduct) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: 'Product not found',
|
||||
});
|
||||
}
|
||||
|
||||
// Get similar products based on attributes
|
||||
const similar = await getSimilarProductsByAttributes(
|
||||
currentProduct,
|
||||
parseInt(limit)
|
||||
);
|
||||
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
count: similar.length,
|
||||
data: similar,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Get similar products error:', error);
|
||||
next(error);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* @desc Get personalized recommendations for user
|
||||
* @route GET /api/products/recommendations/personalized
|
||||
* @access Private
|
||||
*/
|
||||
const getPersonalizedRecommendations = async (req, res, next) => {
|
||||
try {
|
||||
const userId = req.user?.id;
|
||||
const { limit = 20 } = req.query;
|
||||
|
||||
if (!userId) {
|
||||
// Return popular products for non-authenticated users
|
||||
const popularProducts = await Product.find({ status: 'active' })
|
||||
.sort({ purchaseCount: -1, viewCount: -1 })
|
||||
.limit(parseInt(limit));
|
||||
|
||||
return res.status(200).json({
|
||||
success: true,
|
||||
count: popularProducts.length,
|
||||
data: popularProducts,
|
||||
});
|
||||
}
|
||||
|
||||
// Get user's purchase history
|
||||
const userOrders = await prisma.order.findMany({
|
||||
where: {
|
||||
userId,
|
||||
status: 'DELIVERED',
|
||||
},
|
||||
include: {
|
||||
items: true,
|
||||
},
|
||||
take: 10,
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
|
||||
// Get user's wishlist
|
||||
const wishlist = await prisma.wishlistItem.findMany({
|
||||
where: { userId },
|
||||
take: 20,
|
||||
});
|
||||
|
||||
// Extract product IDs
|
||||
const purchasedProductIds = userOrders.flatMap((order) =>
|
||||
order.items.map((item) => item.productId)
|
||||
);
|
||||
const wishlistProductIds = wishlist.map((item) => item.productId);
|
||||
|
||||
// Get categories and tags from purchased products
|
||||
const purchasedProducts = await Product.find({
|
||||
_id: { $in: purchasedProductIds },
|
||||
});
|
||||
|
||||
const categories = [...new Set(purchasedProducts.map((p) => p.category))];
|
||||
const tags = [
|
||||
...new Set(purchasedProducts.flatMap((p) => p.tags || [])),
|
||||
];
|
||||
|
||||
// Build personalized recommendations
|
||||
const recommendations = await Product.find({
|
||||
status: 'active',
|
||||
_id: {
|
||||
$nin: [...purchasedProductIds, ...wishlistProductIds],
|
||||
},
|
||||
$or: [
|
||||
{ category: { $in: categories } },
|
||||
{ tags: { $in: tags } },
|
||||
{ isFeatured: true },
|
||||
],
|
||||
})
|
||||
.sort({ purchaseCount: -1, viewCount: -1 })
|
||||
.limit(parseInt(limit));
|
||||
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
count: recommendations.length,
|
||||
data: recommendations,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Get personalized recommendations error:', error);
|
||||
next(error);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Helper Functions
|
||||
*/
|
||||
|
||||
// Get recommended products based on multiple factors
|
||||
async function getRecommendedProducts(currentProduct, limit) {
|
||||
const priceRange = {
|
||||
min: currentProduct.basePrice * 0.7,
|
||||
max: currentProduct.basePrice * 1.3,
|
||||
};
|
||||
|
||||
// Score-based recommendation
|
||||
const recommendations = await Product.aggregate([
|
||||
{
|
||||
$match: {
|
||||
_id: { $ne: currentProduct._id },
|
||||
status: 'active',
|
||||
},
|
||||
},
|
||||
{
|
||||
$addFields: {
|
||||
score: {
|
||||
$add: [
|
||||
{ $cond: [{ $eq: ['$category', currentProduct.category] }, 50, 0] },
|
||||
{
|
||||
$cond: [
|
||||
{
|
||||
$and: [
|
||||
{ $gte: ['$basePrice', priceRange.min] },
|
||||
{ $lte: ['$basePrice', priceRange.max] },
|
||||
],
|
||||
},
|
||||
30,
|
||||
0,
|
||||
],
|
||||
},
|
||||
{
|
||||
$multiply: [
|
||||
{
|
||||
$size: {
|
||||
$ifNull: [
|
||||
{
|
||||
$setIntersection: [
|
||||
{ $ifNull: ['$tags', []] },
|
||||
currentProduct.tags || [],
|
||||
],
|
||||
},
|
||||
[],
|
||||
],
|
||||
},
|
||||
},
|
||||
5,
|
||||
],
|
||||
},
|
||||
{ $cond: ['$isFeatured', 20, 0] },
|
||||
{ $divide: [{ $ifNull: ['$viewCount', 0] }, 100] },
|
||||
{ $divide: [{ $multiply: [{ $ifNull: ['$purchaseCount', 0] }, 10] }, 10] },
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
{ $sort: { score: -1 } },
|
||||
{ $limit: limit },
|
||||
]);
|
||||
|
||||
return recommendations;
|
||||
}
|
||||
|
||||
// Get frequently bought together products
|
||||
async function getFrequentlyBoughtTogether(productId, limit) {
|
||||
try {
|
||||
const ordersWithProduct = await prisma.orderItem.findMany({
|
||||
where: { productId },
|
||||
select: { orderId: true },
|
||||
distinct: ['orderId'],
|
||||
});
|
||||
|
||||
if (ordersWithProduct.length === 0) {
|
||||
const product = await Product.findById(productId);
|
||||
return await Product.find({
|
||||
_id: { $ne: productId },
|
||||
category: product.category,
|
||||
status: 'active',
|
||||
})
|
||||
.sort({ purchaseCount: -1 })
|
||||
.limit(limit);
|
||||
}
|
||||
|
||||
const orderIds = ordersWithProduct.map((item) => item.orderId);
|
||||
|
||||
const otherProducts = await prisma.orderItem.findMany({
|
||||
where: {
|
||||
orderId: { in: orderIds },
|
||||
productId: { not: productId },
|
||||
},
|
||||
select: { productId: true },
|
||||
});
|
||||
|
||||
const productFrequency = {};
|
||||
otherProducts.forEach((item) => {
|
||||
productFrequency[item.productId] =
|
||||
(productFrequency[item.productId] || 0) + 1;
|
||||
});
|
||||
|
||||
const sortedProductIds = Object.entries(productFrequency)
|
||||
.sort(([, a], [, b]) => b - a)
|
||||
.slice(0, limit)
|
||||
.map(([id]) => id);
|
||||
|
||||
const products = await Product.find({
|
||||
_id: { $in: sortedProductIds },
|
||||
status: 'active',
|
||||
});
|
||||
|
||||
return products;
|
||||
} catch (error) {
|
||||
console.error('Get frequently bought together error:', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
// Get similar products by attributes
|
||||
async function getSimilarProductsByAttributes(currentProduct, limit) {
|
||||
const similar = await Product.find({
|
||||
_id: { $ne: currentProduct._id },
|
||||
status: 'active',
|
||||
$or: [
|
||||
{ category: currentProduct.category },
|
||||
{ tags: { $in: currentProduct.tags || [] } },
|
||||
{ brand: currentProduct.brand },
|
||||
],
|
||||
})
|
||||
.sort({
|
||||
purchaseCount: -1,
|
||||
viewCount: -1,
|
||||
})
|
||||
.limit(limit);
|
||||
|
||||
return similar;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
getProductRecommendations,
|
||||
getAlsoBoughtProducts,
|
||||
getSimilarProducts,
|
||||
getPersonalizedRecommendations,
|
||||
};
|
||||
Reference in New Issue
Block a user