345 lines
8.9 KiB
JavaScript
345 lines
8.9 KiB
JavaScript
// 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,
|
|
}; |