first commit

This commit is contained in:
2026-03-10 12:43:27 +05:30
commit edb525eb80
79 changed files with 25644 additions and 0 deletions

View File

@@ -0,0 +1,375 @@
const { prisma } = require('../../config/database');
const uploadToS3 = require('../../utils/uploadToS3');
exports.getAllCategories = async (req, res, next) => {
try {
const categories = await prisma.category.findMany({
orderBy: { name: 'asc' },
});
// res.json({ success: true, data: categories });
res.status(200).json({
// statusCode: 200,
status: true,
message: 'Categories fetched successfully',
data: categories,
});
} catch (error) {
next(error);
}
};
// exports.createCategory = async (req, res, next) => {
// try {
// const { name, description, image, parentId, metaTitle, metaDescription } =
// req.body;
// // Generate slug from name
// let slug = name
// .toLowerCase()
// .replace(/[^a-z0-9]+/g, '-')
// .replace(/(^-|-$)/g, '');
// // Check if slug already exists with the same parent
// const existing = await prisma.category.findFirst({
// where: {
// slug,
// parentId: parentId || null, // Handle both subcategories and root categories
// },
// });
// if (existing) {
// // If exists under same parent, append timestamp to make it unique
// slug = `${slug}-${Date.now()}`;
// }
// const category = await prisma.category.create({
// data: {
// name,
// slug,
// description,
// image,
// parentId,
// metaTitle,
// metaDescription,
// },
// });
// res.status(201).json({
// statusCode: 201,
// status: true,
// message: 'Category created successfully',
// data: category,
// });
// } catch (error) {
// // Handle Prisma duplicate error explicitly
// if (error.code === 'P2002') {
// return res.status(400).json({
// statusCode: 400,
// status: false,
// message: 'Duplicate field value entered',
// });
// }
// next(error);
// }
// };
// exports.updateCategory = async (req, res, next) => {
// try {
// const { id } = req.params;
// const { name, description, image, parentId, metaTitle, metaDescription } =
// req.body;
// let slug;
// if (name) {
// slug = name
// .toLowerCase()
// .replace(/[^a-z0-9]+/g, '-')
// .replace(/(^-|-$)/g, '');
// }
// const category = await prisma.category.update({
// where: { id },
// data: {
// name,
// slug,
// description,
// image,
// parentId,
// metaTitle,
// metaDescription,
// },
// });
// res.json({
// status: true,
// message: 'Category updated successfully',
// data: category,
// });
// } catch (error) {
// next(error);
// }
// };
exports.createCategory = async (req, res, next) => {
try {
const { name, description, parentId, metaTitle, metaDescription } =
req.body;
if (!name) {
return res.status(400).json({
status: false,
message: 'Category name is required',
});
}
// ✅ Generate slug
let slug = name
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/(^-|-$)/g, '');
// ✅ Check duplicate slug under same parent
const existing = await prisma.category.findFirst({
where: {
slug,
parentId: parentId || null,
},
});
if (existing) {
slug = `${slug}-${Date.now()}`;
}
// ✅ Upload image to S3 if provided
let imageUrl = null;
if (req.file) {
imageUrl = await uploadToS3(req.file, 'categories');
}
// ✅ Create category
const category = await prisma.category.create({
data: {
name,
slug,
description,
parentId: parentId || null,
metaTitle,
metaDescription,
image: imageUrl,
},
});
res.status(201).json({
statusCode: 201,
status: true,
message: 'Category created successfully',
data: category,
});
} catch (error) {
if (error.code === 'P2002') {
return res.status(400).json({
statusCode: 400,
status: false,
message: 'Duplicate field value entered',
});
}
next(error);
}
};
exports.updateCategory = async (req, res, next) => {
try {
const { id } = req.params;
const { name, description, parentId, metaTitle, metaDescription } =
req.body;
// Generate slug if name changed
let slug;
if (name) {
slug = name
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/(^-|-$)/g, '');
}
// ✅ Upload new image if provided
let imageUrl;
if (req.file) {
imageUrl = await uploadToS3(req.file, 'categories');
}
// Update category
const category = await prisma.category.update({
where: { id },
data: {
...(name && { name }),
...(slug && { slug }),
...(description && { description }),
...(parentId !== undefined && { parentId }),
...(metaTitle && { metaTitle }),
...(metaDescription && { metaDescription }),
...(imageUrl && { image: imageUrl }), // 👈 only update if new image uploaded
},
});
res.json({
status: true,
message: 'Category updated successfully',
data: category,
});
} catch (error) {
next(error);
}
};
exports.deleteCategory = async (req, res, next) => {
try {
const { id } = req.params;
await prisma.category.delete({
where: { id },
});
res.json({
status: true,
message: 'Category deleted successfully',
});
} catch (error) {
next(error);
}
};
exports.toggleCategoryStatus = async (req, res, next) => {
try {
const { id } = req.params;
const { isActive } = req.body;
// 1⃣ Update parent category
const parentCategory = await prisma.category.update({
where: { id },
data: { isActive },
});
// 2⃣ If parent is being deactivated, also deactivate all children recursively
if (!isActive) {
const deactivateChildren = async parentId => {
const children = await prisma.category.findMany({
where: { parentId },
});
for (const child of children) {
await prisma.category.update({
where: { id: child.id },
data: { isActive: false },
});
// Recursive call for nested subcategories
await deactivateChildren(child.id);
}
};
await deactivateChildren(id);
}
res.json({
status: true,
message: `Category ${isActive ? 'activated' : 'deactivated'} successfully`,
data: parentCategory,
});
} catch (error) {
next(error);
}
};
exports.reorderCategories = async (req, res) => {
const { orders } = req.body;
await Promise.all(
orders.map(item =>
prisma.category.update({
where: { id: item.id },
data: { sequence: item.sequence },
})
)
);
res.json({
status: true,
message: 'Category order updated',
});
};
exports.getCategoryHierarchy = async (req, res, next) => {
try {
// 1. Fetch all categories
const categories = await prisma.category.findMany({
orderBy: { name: 'asc' },
});
// 2. Convert array to a lookup map
const lookup = {};
categories.forEach(cat => {
lookup[cat.id] = { ...cat, children: [] };
});
const hierarchy = [];
// 3. Build hierarchical structure
categories.forEach(cat => {
if (cat.parentId) {
lookup[cat.parentId].children.push(lookup[cat.id]);
} else {
hierarchy.push(lookup[cat.id]);
}
});
res.status(200).json({
// statusCode: 200,
status: true,
message: 'Category hierarchy fetched successfully',
data: hierarchy,
});
} catch (error) {
next(error);
}
};
exports.getCategoryById = async (req, res) => {
try {
const { id } = req.params;
if (!id) {
return res.status(400).json({
success: false,
message: 'Category ID is required',
});
}
const category = await prisma.category.findUnique({
where: {
id: id, // ✅ PASS THE ACTUAL STRING VALUE
},
});
if (!category) {
return res.status(404).json({
success: false,
message: 'Category not found',
});
}
return res.status(200).json({
success: true,
message: 'Category details fetched successfully',
data: category,
});
} catch (error) {
console.error('Get category by id error:', error);
return res.status(500).json({
success: false,
message: 'Error fetching category',
});
}
};

View File

@@ -0,0 +1,463 @@
// const { prisma } = require('../../config/database');
// exports.getAllCoupons = async (req, res, next) => {
// try {
// const coupons = await prisma.coupon.findMany({
// orderBy: { createdAt: 'desc' },
// });
// // res.json({ success: true, data: coupons });
// res.status(200).json({
// // statusCode: 200,
// status: true,
// message: 'Coupons fetched successfully',
// data: coupons,
// });
// } catch (error) {
// next(error);
// }
// };
// exports.createCoupon = async (req, res, next) => {
// try {
// const {
// code,
// description,
// type,
// value,
// minOrderAmount,
// maxUses,
// validFrom,
// validUntil,
// } = req.body;
// const coupon = await prisma.coupon.create({
// data: {
// code,
// description,
// type,
// value,
// minOrderAmount,
// maxUses,
// validFrom: new Date(validFrom),
// validUntil: new Date(validUntil),
// },
// });
// res.status(201).json({
// // statusCode: 201,
// status: true,
// message: 'Coupon created successfully',
// data: coupon,
// });
// } catch (error) {
// next(error);
// }
// };
// controllers/admin/couponController.js - ENHANCED VERSION
const { prisma } = require('../../config/database');
// ==========================================
// ADMIN COUPON MANAGEMENT
// ==========================================
/**
* @desc Get all coupons (with filters)
* @route GET /api/admin/coupons
* @access Private/Admin
*/
exports.getAllCoupons = async (req, res, next) => {
try {
const { isActive, type, search, page = 1, limit = 20 } = req.query;
const skip = (parseInt(page) - 1) * parseInt(limit);
// Build filter
const where = {};
if (isActive !== undefined) {
where.isActive = isActive === 'true';
}
if (type) {
where.type = type;
}
if (search) {
where.OR = [
{ code: { contains: search, mode: 'insensitive' } },
{ description: { contains: search, mode: 'insensitive' } },
];
}
const [coupons, total] = await Promise.all([
prisma.coupon.findMany({
where,
orderBy: { createdAt: 'desc' },
skip,
take: parseInt(limit),
}),
prisma.coupon.count({ where }),
]);
// Add usage statistics
const couponsWithStats = coupons.map(coupon => ({
...coupon,
usagePercentage: coupon.maxUses
? Math.round((coupon.usedCount / coupon.maxUses) * 100)
: 0,
isExpired: new Date() > new Date(coupon.validUntil),
isNotStarted: new Date() < new Date(coupon.validFrom),
remainingUses: coupon.maxUses ? coupon.maxUses - coupon.usedCount : null,
}));
res.status(200).json({
status: true,
message: 'Coupons fetched successfully',
data: {
coupons: couponsWithStats,
pagination: {
total,
page: parseInt(page),
limit: parseInt(limit),
pages: Math.ceil(total / parseInt(limit)),
},
},
});
} catch (error) {
next(error);
}
};
/**
* @desc Get single coupon by ID
* @route GET /api/admin/coupons/:id
* @access Private/Admin
*/
exports.getCouponById = async (req, res, next) => {
try {
const { id } = req.params;
const coupon = await prisma.coupon.findUnique({
where: { id },
});
if (!coupon) {
return res.status(404).json({
status: false,
message: 'Coupon not found',
});
}
// Get usage statistics
const usageStats = {
...coupon,
usagePercentage: coupon.maxUses
? Math.round((coupon.usedCount / coupon.maxUses) * 100)
: 0,
isExpired: new Date() > new Date(coupon.validUntil),
isNotStarted: new Date() < new Date(coupon.validFrom),
remainingUses: coupon.maxUses ? coupon.maxUses - coupon.usedCount : null,
};
res.status(200).json({
status: true,
message: 'Coupon fetched successfully',
data: usageStats,
});
} catch (error) {
next(error);
}
};
/**
* @desc Create new coupon
* @route POST /api/admin/coupons
* @access Private/Admin
*/
exports.createCoupon = async (req, res, next) => {
try {
const {
code,
description,
type,
value,
minOrderAmount,
maxUses,
validFrom,
validUntil,
isActive = true,
} = req.body;
// Validate code uniqueness
const existingCoupon = await prisma.coupon.findUnique({
where: { code: code.toUpperCase() },
});
if (existingCoupon) {
return res.status(400).json({
status: false,
message: 'Coupon code already exists',
});
}
// Validate dates
const fromDate = new Date(validFrom);
const untilDate = new Date(validUntil);
if (fromDate >= untilDate) {
return res.status(400).json({
status: false,
message: 'Valid until date must be after valid from date',
});
}
// Validate value based on type
if (type === 'PERCENTAGE' && (value < 0 || value > 100)) {
return res.status(400).json({
status: false,
message: 'Percentage value must be between 0 and 100',
});
}
if (type === 'FIXED_AMOUNT' && value < 0) {
return res.status(400).json({
status: false,
message: 'Fixed amount must be greater than 0',
});
}
const coupon = await prisma.coupon.create({
data: {
code: code.toUpperCase(),
description,
type,
value: parseFloat(value),
minOrderAmount: minOrderAmount ? parseFloat(minOrderAmount) : null,
maxUses: maxUses ? parseInt(maxUses) : null,
validFrom: fromDate,
validUntil: untilDate,
isActive,
},
});
res.status(201).json({
status: true,
message: 'Coupon created successfully',
data: coupon,
});
} catch (error) {
console.error('Create coupon error:', error);
next(error);
}
};
/**
* @desc Update coupon
* @route PUT /api/admin/coupons/:id
* @access Private/Admin
*/
exports.updateCoupon = async (req, res, next) => {
try {
const { id } = req.params;
const {
code,
description,
type,
value,
minOrderAmount,
maxUses,
validFrom,
validUntil,
isActive,
} = req.body;
// Check if coupon exists
const existingCoupon = await prisma.coupon.findUnique({
where: { id },
});
if (!existingCoupon) {
return res.status(404).json({
status: false,
message: 'Coupon not found',
});
}
// If code is being changed, check uniqueness
if (code && code.toUpperCase() !== existingCoupon.code) {
const duplicateCoupon = await prisma.coupon.findUnique({
where: { code: code.toUpperCase() },
});
if (duplicateCoupon) {
return res.status(400).json({
status: false,
message: 'Coupon code already exists',
});
}
}
// Validate dates if provided
const fromDate = validFrom ? new Date(validFrom) : existingCoupon.validFrom;
const untilDate = validUntil
? new Date(validUntil)
: existingCoupon.validUntil;
if (fromDate >= untilDate) {
return res.status(400).json({
status: false,
message: 'Valid until date must be after valid from date',
});
}
const updateData = {};
if (code) updateData.code = code.toUpperCase();
if (description !== undefined) updateData.description = description;
if (type) updateData.type = type;
if (value !== undefined) updateData.value = parseFloat(value);
if (minOrderAmount !== undefined)
updateData.minOrderAmount = minOrderAmount
? parseFloat(minOrderAmount)
: null;
if (maxUses !== undefined)
updateData.maxUses = maxUses ? parseInt(maxUses) : null;
if (validFrom) updateData.validFrom = fromDate;
if (validUntil) updateData.validUntil = untilDate;
if (isActive !== undefined) updateData.isActive = isActive;
const coupon = await prisma.coupon.update({
where: { id },
data: updateData,
});
res.status(200).json({
status: true,
message: 'Coupon updated successfully',
data: coupon,
});
} catch (error) {
next(error);
}
};
/**
* @desc Delete coupon
* @route DELETE /api/admin/coupons/:id
* @access Private/Admin
*/
exports.deleteCoupon = async (req, res, next) => {
try {
const { id } = req.params;
const coupon = await prisma.coupon.findUnique({
where: { id },
});
if (!coupon) {
return res.status(404).json({
status: false,
message: 'Coupon not found',
});
}
// Check if coupon has been used
if (coupon.usedCount > 0) {
return res.status(400).json({
status: false,
message:
'Cannot delete a coupon that has been used. Consider deactivating it instead.',
});
}
await prisma.coupon.delete({
where: { id },
});
res.status(200).json({
status: true,
message: 'Coupon deleted successfully',
});
} catch (error) {
next(error);
}
};
/**
* @desc Toggle coupon active status
* @route PATCH /api/admin/coupons/:id/toggle
* @access Private/Admin
*/
exports.toggleCouponStatus = async (req, res, next) => {
try {
const { id } = req.params;
const coupon = await prisma.coupon.findUnique({
where: { id },
});
if (!coupon) {
return res.status(404).json({
status: false,
message: 'Coupon not found',
});
}
const updatedCoupon = await prisma.coupon.update({
where: { id },
data: { isActive: !coupon.isActive },
});
res.status(200).json({
status: true,
message: `Coupon ${updatedCoupon.isActive ? 'activated' : 'deactivated'} successfully`,
data: updatedCoupon,
});
} catch (error) {
next(error);
}
};
/**
* @desc Get coupon statistics
* @route GET /api/admin/coupons/stats/overview
* @access Private/Admin
*/
exports.getCouponStats = async (req, res, next) => {
try {
const [totalCoupons, activeCoupons, expiredCoupons, totalRedemptions] =
await Promise.all([
prisma.coupon.count(),
prisma.coupon.count({ where: { isActive: true } }),
prisma.coupon.count({
where: {
validUntil: { lt: new Date() },
},
}),
prisma.coupon.aggregate({
_sum: { usedCount: true },
}),
]);
// Get most used coupons
const mostUsed = await prisma.coupon.findMany({
where: { usedCount: { gt: 0 } },
orderBy: { usedCount: 'desc' },
take: 5,
});
res.status(200).json({
status: true,
message: 'Coupon statistics fetched successfully',
data: {
totalCoupons,
activeCoupons,
expiredCoupons,
totalRedemptions: totalRedemptions._sum.usedCount || 0,
mostUsedCoupons: mostUsed,
},
});
} catch (error) {
next(error);
}
};

View File

@@ -0,0 +1,589 @@
const { prisma } = require('../../config/database');
const Product = require('../../models/mongodb/Product');
const {
getLowStockProducts,
getInventoryStats,
} = require('../../services/inventoryService');
/**
* @desc Get dashboard stats with order overview graphs
* @route GET /api/admin/dashboard/stats
* @access Private/Admin
*/
exports.getDashboardStats = async (req, res, next) => {
try {
const [
totalUsers,
totalOrders,
totalProducts,
totalRevenue,
recentOrders,
topSellingProducts,
lowStockProducts,
inventoryStats,
orderOverview,
revenueOverview,
ordersByStatus,
monthlyComparison,
] = await Promise.all([
prisma.user.count(),
prisma.order.count(),
Product.countDocuments({ status: 'active' }),
prisma.order.aggregate({
_sum: { totalAmount: true },
where: { paymentStatus: 'PAID' },
}),
prisma.order.findMany({
take: 5,
orderBy: { createdAt: 'desc' },
include: {
user: {
select: { id: true, email: true, firstName: true, lastName: true },
},
items: true,
},
}),
getTopSellingProducts(), // ✅ Real top sellers
getLowStockProducts(10), // ✅ Low stock alerts
getInventoryStats(), // ✅ Inventory overview
getOrderOverview(),
getRevenueOverview(),
getOrdersByStatus(),
getMonthlyComparison(),
]);
// Enhance recent orders with product images
const orderProductIds = recentOrders
.flatMap(order => order.items.map(item => item.productId))
.filter(Boolean);
const orderProducts = await Product.find({
_id: { $in: orderProductIds },
}).lean();
const productMap = {};
orderProducts.forEach(product => {
productMap[product._id.toString()] = {
...product,
displayImage: getProductImage(product),
};
});
const enhancedRecentOrders = recentOrders.map(order => ({
...order,
items: order.items.map(item => ({
...item,
productImage:
productMap[item.productId]?.displayImage ||
'https://via.placeholder.com/300',
productDetails: productMap[item.productId] || null,
})),
}));
res.status(200).json({
statusCode: 200,
status: true,
message: 'Dashboard stats fetched successfully',
data: {
// Summary Stats
totalUsers,
totalOrders,
totalProducts,
totalRevenue: parseFloat(totalRevenue._sum.totalAmount || 0),
// ✅ Inventory Stats
inventory: inventoryStats,
// Lists
recentOrders: enhancedRecentOrders,
topProducts: topSellingProducts, // ✅ Real selling data
lowStockProducts, // ✅ Products needing restock
// Charts
charts: {
orderOverview,
revenueOverview,
ordersByStatus,
monthlyComparison,
},
},
});
} catch (error) {
console.error('Dashboard stats error:', error);
next(error);
}
};
// ✅ GET REAL TOP SELLING PRODUCTS
async function getTopSellingProducts() {
try {
// Get sales data from order items
const salesData = await prisma.orderItem.groupBy({
by: ['productId'],
_sum: { quantity: true },
_count: { productId: true },
orderBy: { _sum: { quantity: 'desc' } },
take: 10,
});
if (salesData.length === 0) {
// Fallback: Return recent products
const fallback = await Product.find({ status: 'active' })
.sort({ createdAt: -1 })
.limit(5)
.lean();
return fallback.map(p => ({
...p,
_id: p._id.toString(),
displayImage: getProductImage(p),
totalSold: 0,
totalOrders: 0,
stock: p.stock || 0,
stockStatus: getStockStatus(p.stock || 0),
}));
}
const productIds = salesData.map(item => item.productId);
const products = await Product.find({
_id: { $in: productIds },
status: 'active',
}).lean();
const statsMap = {};
salesData.forEach(item => {
statsMap[item.productId] = {
totalSold: item._sum.quantity || 0,
totalOrders: item._count.productId || 0,
};
});
function calculateStock(product) {
if (product.variants?.length > 0) {
return product.variants
.filter(v => v.isActive)
.reduce((sum, v) => sum + (v.inventory?.quantity || 0), 0);
}
return product.stock || 0;
}
const topProducts = products
.map(product => {
const stats = statsMap[product._id.toString()] || {
totalSold: 0,
totalOrders: 0,
};
const stock =
product.variants?.length > 0
? product.variants
.filter(v => v.isActive)
.reduce((sum, v) => sum + (v.inventory?.quantity || 0), 0)
: product.stock || 0;
return {
_id: product._id.toString(),
name: product.name,
slug: product.slug,
basePrice: product.basePrice,
displayImage: getProductImage(product),
totalSold: stats.totalSold,
totalOrders: stats.totalOrders,
revenue: stats.totalSold * (product.basePrice || 0),
stock,
stockStatus: getStockStatus(stock),
};
})
.sort((a, b) => b.totalSold - a.totalSold)
.slice(0, 5);
return topProducts;
} catch (error) {
console.error('Error fetching top selling products:', error);
return [];
}
}
function getStockStatus(stock) {
if (stock === 0) return 'OUT_OF_STOCK';
if (stock <= 5) return 'CRITICAL';
if (stock <= 10) return 'LOW';
return 'IN_STOCK';
}
function getProductImage(product) {
return (
product.images?.gallery?.[0] ||
product.images?.primary ||
product.variants?.[0]?.images?.[0] ||
'https://via.placeholder.com/300'
);
}
/**
* Get daily order count for last 30 days
*/
// async function getOrderOverview() {
// const thirtyDaysAgo = new Date();
// thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30);
// // Group orders by date
// const orders = await prisma.order.findMany({
// where: {
// createdAt: {
// gte: thirtyDaysAgo,
// },
// },
// select: {
// createdAt: true,
// status: true,
// },
// });
// // Create date map for last 30 days
// const dateMap = {};
// for (let i = 29; i >= 0; i--) {
// const date = new Date();
// date.setDate(date.getDate() - i);
// const dateKey = date.toISOString().split('T')[0];
// dateMap[dateKey] = { total: 0, completed: 0, pending: 0, cancelled: 0 };
// }
// // Count orders by date
// orders.forEach(order => {
// const dateKey = order.createdAt.toISOString().split('T')[0];
// if (dateMap[dateKey]) {
// dateMap[dateKey].total++;
// if (order.status === 'DELIVERED') {
// dateMap[dateKey].completed++;
// } else if (['PENDING', 'CONFIRMED', 'PROCESSING', 'SHIPPED'].includes(order.status)) {
// dateMap[dateKey].pending++;
// } else if (order.status === 'CANCELLED') {
// dateMap[dateKey].cancelled++;
// }
// }
// });
// // Convert to array format for charts
// return Object.entries(dateMap).map(([date, counts]) => ({
// date,
// label: new Date(date).toLocaleDateString('en-IN', { month: 'short', day: 'numeric' }),
// total: counts.total,
// completed: counts.completed,
// pending: counts.pending,
// cancelled: counts.cancelled,
// }));
// }
async function getOrderOverview() {
const thirtyDaysAgo = new Date();
thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30);
const orders = await prisma.order.findMany({
where: { createdAt: { gte: thirtyDaysAgo } },
select: { createdAt: true, status: true },
});
const dateMap = {};
for (let i = 29; i >= 0; i--) {
const date = new Date();
date.setDate(date.getDate() - i);
const dateKey = date.toISOString().split('T')[0];
dateMap[dateKey] = { total: 0, completed: 0, pending: 0, cancelled: 0 };
}
orders.forEach(order => {
const dateKey = order.createdAt.toISOString().split('T')[0];
if (dateMap[dateKey]) {
dateMap[dateKey].total++;
if (order.status === 'DELIVERED') dateMap[dateKey].completed++;
else if (
['PENDING', 'CONFIRMED', 'PROCESSING', 'SHIPPED'].includes(order.status)
)
dateMap[dateKey].pending++;
else if (order.status === 'CANCELLED') dateMap[dateKey].cancelled++;
}
});
return Object.entries(dateMap).map(([date, counts]) => ({
date,
label: new Date(date).toLocaleDateString('en-IN', {
month: 'short',
day: 'numeric',
}),
...counts,
}));
}
/**
* Get daily revenue for last 30 days
*/
// async function getRevenueOverview() {
// const thirtyDaysAgo = new Date();
// thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30);
// const orders = await prisma.order.findMany({
// where: {
// createdAt: {
// gte: thirtyDaysAgo,
// },
// paymentStatus: 'PAID',
// },
// select: {
// createdAt: true,
// totalAmount: true,
// },
// });
// // Create date map
// const dateMap = {};
// for (let i = 29; i >= 0; i--) {
// const date = new Date();
// date.setDate(date.getDate() - i);
// const dateKey = date.toISOString().split('T')[0];
// dateMap[dateKey] = 0;
// }
// // Sum revenue by date
// orders.forEach(order => {
// const dateKey = order.createdAt.toISOString().split('T')[0];
// if (dateMap[dateKey] !== undefined) {
// dateMap[dateKey] += parseFloat(order.totalAmount);
// }
// });
// return Object.entries(dateMap).map(([date, revenue]) => ({
// date,
// label: new Date(date).toLocaleDateString('en-IN', { month: 'short', day: 'numeric' }),
// revenue: Math.round(revenue * 100) / 100, // Round to 2 decimals
// }));
// }
async function getRevenueOverview() {
const thirtyDaysAgo = new Date();
thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30);
const orders = await prisma.order.findMany({
where: { createdAt: { gte: thirtyDaysAgo }, paymentStatus: 'PAID' },
select: { createdAt: true, totalAmount: true },
});
const dateMap = {};
for (let i = 29; i >= 0; i--) {
const date = new Date();
date.setDate(date.getDate() - i);
dateMap[date.toISOString().split('T')[0]] = 0;
}
orders.forEach(order => {
const dateKey = order.createdAt.toISOString().split('T')[0];
if (dateMap[dateKey] !== undefined) {
dateMap[dateKey] += parseFloat(order.totalAmount);
}
});
return Object.entries(dateMap).map(([date, revenue]) => ({
date,
label: new Date(date).toLocaleDateString('en-IN', {
month: 'short',
day: 'numeric',
}),
revenue: Math.round(revenue * 100) / 100,
}));
}
/**
* Get order counts by status
*/
// async function getOrdersByStatus() {
// const statusCounts = await prisma.order.groupBy({
// by: ['status'],
// _count: true,
// });
// const statusLabels = {
// PENDING: 'Pending',
// CONFIRMED: 'Confirmed',
// PROCESSING: 'Processing',
// SHIPPED: 'Shipped',
// DELIVERED: 'Delivered',
// CANCELLED: 'Cancelled',
// RETURN_REQUESTED: 'Return Requested',
// };
// const statusColors = {
// PENDING: '#FCD34D',
// CONFIRMED: '#60A5FA',
// PROCESSING: '#A78BFA',
// SHIPPED: '#C084FC',
// DELIVERED: '#34D399',
// CANCELLED: '#F87171',
// RETURN_REQUESTED: '#FB923C',
// };
// return statusCounts.map(item => ({
// status: item.status,
// label: statusLabels[item.status] || item.status,
// count: item._count,
// color: statusColors[item.status] || '#9CA3AF',
// }));
// }
async function getOrdersByStatus() {
const statusCounts = await prisma.order.groupBy({
by: ['status'],
_count: true,
});
const labels = {
PENDING: 'Pending',
CONFIRMED: 'Confirmed',
PROCESSING: 'Processing',
SHIPPED: 'Shipped',
DELIVERED: 'Delivered',
CANCELLED: 'Cancelled',
RETURN_REQUESTED: 'Return Requested',
};
const colors = {
PENDING: '#FCD34D',
CONFIRMED: '#60A5FA',
PROCESSING: '#A78BFA',
SHIPPED: '#C084FC',
DELIVERED: '#34D399',
CANCELLED: '#F87171',
RETURN_REQUESTED: '#FB923C',
};
return statusCounts.map(item => ({
status: item.status,
label: labels[item.status] || item.status,
count: item._count,
color: colors[item.status] || '#9CA3AF',
}));
}
/**
* Compare current month vs previous month
*/
// async function getMonthlyComparison() {
// const now = new Date();
// // Current month dates
// const currentMonthStart = new Date(now.getFullYear(), now.getMonth(), 1);
// const currentMonthEnd = new Date(now.getFullYear(), now.getMonth() + 1, 0);
// // Previous month dates
// const previousMonthStart = new Date(now.getFullYear(), now.getMonth() - 1, 1);
// const previousMonthEnd = new Date(now.getFullYear(), now.getMonth(), 0);
// const [currentMonth, previousMonth] = await Promise.all([
// prisma.order.aggregate({
// where: {
// createdAt: {
// gte: currentMonthStart,
// lte: currentMonthEnd,
// },
// },
// _count: true,
// _sum: {
// totalAmount: true,
// },
// }),
// prisma.order.aggregate({
// where: {
// createdAt: {
// gte: previousMonthStart,
// lte: previousMonthEnd,
// },
// },
// _count: true,
// _sum: {
// totalAmount: true,
// },
// }),
// ]);
// const currentRevenue = parseFloat(currentMonth._sum.totalAmount || 0);
// const previousRevenue = parseFloat(previousMonth._sum.totalAmount || 0);
// const orderGrowth = previousMonth._count > 0
// ? ((currentMonth._count - previousMonth._count) / previousMonth._count) * 100
// : 100;
// const revenueGrowth = previousRevenue > 0
// ? ((currentRevenue - previousRevenue) / previousRevenue) * 100
// : 100;
// return {
// currentMonth: {
// orders: currentMonth._count,
// revenue: Math.round(currentRevenue * 100) / 100,
// label: currentMonthStart.toLocaleDateString('en-IN', { month: 'long' }),
// },
// previousMonth: {
// orders: previousMonth._count,
// revenue: Math.round(previousRevenue * 100) / 100,
// label: previousMonthStart.toLocaleDateString('en-IN', { month: 'long' }),
// },
// growth: {
// orders: Math.round(orderGrowth * 10) / 10,
// revenue: Math.round(revenueGrowth * 10) / 10,
// },
// };
// }
async function getMonthlyComparison() {
const now = new Date();
const currentMonthStart = new Date(now.getFullYear(), now.getMonth(), 1);
const currentMonthEnd = new Date(now.getFullYear(), now.getMonth() + 1, 0);
const previousMonthStart = new Date(now.getFullYear(), now.getMonth() - 1, 1);
const previousMonthEnd = new Date(now.getFullYear(), now.getMonth(), 0);
const [currentMonth, previousMonth] = await Promise.all([
prisma.order.aggregate({
where: { createdAt: { gte: currentMonthStart, lte: currentMonthEnd } },
_count: true,
_sum: { totalAmount: true },
}),
prisma.order.aggregate({
where: { createdAt: { gte: previousMonthStart, lte: previousMonthEnd } },
_count: true,
_sum: { totalAmount: true },
}),
]);
const currentRevenue = parseFloat(currentMonth._sum.totalAmount || 0);
const previousRevenue = parseFloat(previousMonth._sum.totalAmount || 0);
return {
currentMonth: {
orders: currentMonth._count,
revenue: Math.round(currentRevenue * 100) / 100,
label: currentMonthStart.toLocaleDateString('en-IN', { month: 'long' }),
},
previousMonth: {
orders: previousMonth._count,
revenue: Math.round(previousRevenue * 100) / 100,
label: previousMonthStart.toLocaleDateString('en-IN', { month: 'long' }),
},
growth: {
orders:
previousMonth._count > 0
? Math.round(
((currentMonth._count - previousMonth._count) /
previousMonth._count) *
1000
) / 10
: 100,
revenue:
previousRevenue > 0
? Math.round(
((currentRevenue - previousRevenue) / previousRevenue) * 1000
) / 10
: 100,
},
};
}

View File

@@ -0,0 +1,395 @@
const { prisma } = require('../../config/database');
// exports.getAllOrders = async (req, res, next) => {
// try {
// const { page = 1, limit = 20, status, paymentStatus, userId } = req.query;
// const skip = (page - 1) * limit;
// const where = {};
// if (status) where.status = status;
// if (paymentStatus) where.paymentStatus = paymentStatus;
// if (userId) where.userId = userId;
// const [orders, total] = await Promise.all([
// prisma.order.findMany({
// where,
// include: {
// items: true,
// address: true,
// user: {
// select: { id: true, email: true, firstName: true, lastName: true },
// },
// },
// orderBy: { createdAt: 'desc' },
// skip: +skip,
// take: +limit,
// }),
// prisma.order.count({ where }),
// ]);
// // res.json({
// // success: true,
// // data: { orders, pagination: { page: +page, limit: +limit, total, pages: Math.ceil(total / limit) } },
// // });
// res.status(200).json({
// statusCode: 200,
// status: true,
// message: 'Orders fetched successfully',
// data: {
// orders,
// pagination: {
// page: +page,
// limit: +limit,
// total,
// pages: Math.ceil(total / limit),
// },
// },
// });
// } catch (error) {
// next(error);
// }
// };
// exports.getOrderDetails = async (req, res, next) => {
// try {
// const { id } = req.params;
// const order = await prisma.order.findUnique({
// where: { id },
// include: {
// items: {
// include: {
// product: true, // if you want product details
// }
// },
// address: true,
// user: {
// select: {
// id: true,
// firstName: true,
// lastName: true,
// email: true,
// phone: true,
// },
// },
// },
// });
// if (!order) {
// return res.status(404).json({
// success: false,
// message: "Order not found",
// });
// }
// res.json({
// success: true,
// data: order,
// });
// } catch (error) {
// next(error);
// }
// };
exports.getAllOrders = async (req, res, next) => {
try {
const {
page = 1,
limit = 20,
status,
search,
sortBy = 'createdAt',
sortOrder = 'desc',
} = req.query;
const skip = (parseInt(page) - 1) * parseInt(limit);
const where = {};
if (status) {
where.status = status;
}
if (search) {
where.OR = [
{ orderNumber: { contains: search, mode: 'insensitive' } },
{ trackingNumber: { contains: search, mode: 'insensitive' } },
];
}
const [orders, total] = await Promise.all([
prisma.order.findMany({
where,
include: {
user: {
select: {
id: true,
email: true,
firstName: true,
lastName: true,
phone: true,
},
},
address: true,
items: true,
// ✅ Include latest status change
statusHistory: {
take: 1,
orderBy: { createdAt: 'desc' },
include: {
admin: {
select: {
firstName: true,
lastName: true,
},
},
},
},
},
orderBy: {
[sortBy]: sortOrder,
},
skip,
take: parseInt(limit),
}),
prisma.order.count({ where }),
]);
res.status(200).json({
success: true,
data: {
orders,
pagination: {
total,
page: parseInt(page),
limit: parseInt(limit),
pages: Math.ceil(total / parseInt(limit)),
},
},
});
} catch (error) {
console.error('❌ Get all orders error:', error);
next(error);
}
};
// **
// * @desc Get single order with full history
// * @route GET /api/admin/orders/:orderId
// * @access Private/Admin
// */
exports.getOrderById = async (req, res, next) => {
try {
const { orderId } = req.params;
const order = await prisma.order.findUnique({
where: { id: orderId },
include: {
user: {
select: {
id: true,
email: true,
firstName: true,
lastName: true,
phone: true,
},
},
address: true,
items: true,
// ✅ Include full status history
statusHistory: {
orderBy: { createdAt: 'desc' },
include: {
admin: {
select: {
id: true,
email: true,
firstName: true,
lastName: true,
},
},
},
},
},
});
if (!order) {
return res.status(404).json({
success: false,
message: 'Order not found',
});
}
res.status(200).json({
success: true,
data: order,
});
} catch (error) {
console.error('❌ Get order error:', error);
next(error);
}
};
/**
* @desc Get order status history
* @route GET /api/admin/orders/:orderId/history
* @access Private/Admin
*/
exports.getOrderStatusHistory = async (req, res, next) => {
try {
const { orderId } = req.params;
const history = await prisma.orderStatusHistory.findMany({
where: { orderId },
include: {
admin: {
select: {
id: true,
email: true,
firstName: true,
lastName: true,
},
},
},
orderBy: { createdAt: 'desc' },
});
res.status(200).json({
success: true,
data: history,
});
} catch (error) {
console.error('❌ Get status history error:', error);
next(error);
}
};
/**
* @desc Get status change statistics
* @route GET /api/admin/orders/stats/status-changes
* @access Private/Admin
*/
exports.getStatusChangeStats = async (req, res, next) => {
try {
const { startDate, endDate } = req.query;
const where = {};
if (startDate || endDate) {
where.createdAt = {};
if (startDate) where.createdAt.gte = new Date(startDate);
if (endDate) where.createdAt.lte = new Date(endDate);
}
// Count status changes by status
const statusChanges = await prisma.orderStatusHistory.groupBy({
by: ['toStatus'],
where,
_count: true,
});
// Count status changes by admin
const changesByAdmin = await prisma.orderStatusHistory.groupBy({
by: ['changedBy'],
where,
_count: true,
orderBy: {
_count: {
changedBy: 'desc',
},
},
take: 10,
});
// Get admin details
const adminIds = changesByAdmin
.map(item => item.changedBy)
.filter(Boolean);
const admins = await prisma.user.findMany({
where: { id: { in: adminIds } },
select: {
id: true,
firstName: true,
lastName: true,
email: true,
},
});
const adminMap = Object.fromEntries(
admins.map(admin => [admin.id, admin])
);
const changesByAdminWithDetails = changesByAdmin.map(item => ({
admin: item.changedBy ? adminMap[item.changedBy] : null,
count: item._count,
}));
res.status(200).json({
success: true,
data: {
statusChanges,
changesByAdmin: changesByAdminWithDetails,
},
});
} catch (error) {
console.error('❌ Get status stats error:', error);
next(error);
}
};
exports.getOrderDetails = async (req, res, next) => {
try {
const { id } = req.params;
const order = await prisma.order.findUnique({
where: { id },
include: {
items: true, // OrderItem snapshot data
address: true, // shipping address
user: {
select: {
id: true,
firstName: true,
lastName: true,
email: true,
phone: true,
},
},
},
});
if (!order) {
return res.status(404).json({
// success: false,
// message: "Order not found",
statusCode: 404,
status: false,
message: 'Order not found',
});
}
// res.json({
// success: true,
// data: order,
// });
res.status(200).json({
statusCode: 200,
status: true,
message: 'Order details fetched successfully',
data: order,
});
} catch (error) {
next(error);
}
};

View File

@@ -0,0 +1,782 @@
const Product = require('../../models/mongodb/Product');
const uploadToS3 = require("../../utils/uploadToS3");
// exports.getAllProducts = async (req, res, next) => {
// try {
// const { page = 1, limit = 20, status, category, search } = req.query;
// const skip = (page - 1) * limit;
// const query = {};
// if (status) query.status = status;
// if (category) query.category = category;
// if (search) query.$text = { $search: search };
// const [products, total] = await Promise.all([
// Product.find(query)
// .sort({ createdAt: -1 })
// .skip(skip)
// .limit(+limit)
// .lean(),
// Product.countDocuments(query),
// ]);
// // res.json({
// // success: true,
// // data: { products, pagination: { page: +page, limit: +limit, total, pages: Math.ceil(total / limit) } },
// // });
// return res.status(200).json({
// statusCode: 200,
// status: true,
// message: 'Products fetched successfully',
// data: {
// products,
// pagination: {
// page: +page,
// limit: +limit,
// total,
// pages: Math.ceil(total / limit),
// },
// },
// });
// } catch (error) {
// next(error);
// }
// };
// exports.createProduct = async (req, res, next) => {
// try {
// const product = new Product(req.body);
// await product.save();
// res.status(201).json({ success: true, message: 'Product created', data: product });
// } catch (error) {
// next(error);
// }
// };
// exports.createProduct = async (req, res, next) => {
// try {
// let { name, slug, ...rest } = req.body;
// // Generate slug from name if not provided
// if (!slug) {
// slug = name
// .toLowerCase()
// .replace(/[^a-z0-9]+/g, '-') // replace spaces & special chars
// .replace(/(^-|-$)/g, ''); // remove leading/trailing hyphens
// }
// // Ensure slug is unique
// let slugExists = await Product.findOne({ slug });
// let counter = 1;
// const baseSlug = slug;
// while (slugExists) {
// slug = `${baseSlug}-${counter}`;
// slugExists = await Product.findOne({ slug });
// counter++;
// }
// // ✅ Upload images manually
// const primaryImage = req.files?.primaryImage
// ? await uploadToS3(req.files.primaryImage[0])
// : null;
// const galleryImages = req.files?.galleryImages
// ? await Promise.all(
// req.files.galleryImages.map((file) => uploadToS3(file))
// )
// : [];
// // const product = new Product({ name, slug, ...rest });
// const product = new Product({
// name,
// slug,
// ...rest,
// images: {
// primary: primaryImage,
// gallery: galleryImages,
// },
// });
// await product.save();
// // res.status(201).json({ success: true, message: 'Product created', data: product });
// return res.status(201).json({
// // statusCode: 201,
// status: true,
// message: 'Product created successfully',
// data: product,
// });
// } catch (error) {
// next(error);
// }
// };
// exports.createProduct = async (req, res, next) => {
// try {
// let { name, hasVariants, variants, ...rest } = req.body;
// let slug;
// // slug generation
// if (!slug) {
// slug = name
// .toLowerCase()
// .replace(/[^a-z0-9]+/g, "-")
// .replace(/(^-|-$)/g, "");
// }
// let exists = await Product.findOne({ slug });
// let i = 1;
// while (exists) {
// slug = `${slug}-${i++}`;
// exists = await Product.findOne({ slug });
// }
// // Upload main images
// const primaryImage = req.files?.primaryImage
// ? await uploadToS3(req.files.primaryImage[0])
// : null;
// const galleryImages = req.files?.galleryImages
// ? await Promise.all(
// req.files.galleryImages.map((f) => uploadToS3(f))
// )
// : [];
// const productData = {
// name,
// slug,
// ...rest,
// hasVariants: hasVariants === "true",
// };
// // 🔥 VARIANT LOGIC
// if (hasVariants === "true") {
// const parsedVariants = JSON.parse(variants); // IMPORTANT
// productData.variants = await Promise.all(
// parsedVariants.map(async (variant) => {
// const variantImages =
// req.files?.[`variantImages_${variant.color}`]
// ? await Promise.all(
// req.files[`variantImages_${variant.color}`].map(uploadToS3)
// )
// : [];
// return {
// size: variant.size,
// color: variant.color,
// sku: variant.sku,
// price: Number(variant.price),
// compareAtPrice: Number(variant.compareAtPrice),
// inventory: {
// quantity: Number(variant.quantity),
// trackInventory: true,
// },
// images: variantImages,
// };
// })
// );
// } else {
// // simple product images
// productData.images = {
// primary: primaryImage,
// gallery: galleryImages,
// };
// }
// const product = await Product.create(productData);
// res.status(201).json({
// status: true,
// message: "Product created successfully",
// data: product,
// });
// } catch (err) {
// next(err);
// }
// };
// exports.createProduct = async (req, res, next) => {
// try {
// const { name, hasVariants, variants, ...rest } = req.body;
// // ✅ Validate name
// if (!name || name.trim() === "") {
// return res.status(400).json({
// status: false,
// message: "Product name is required",
// });
// }
// // slug generation
// let slug = name
// .toLowerCase()
// .replace(/[^a-z0-9]+/g, "-")
// .replace(/(^-|-$)/g, "");
// // ensure unique slug
// let exists = await Product.findOne({ slug });
// let i = 1;
// while (exists) {
// slug = `${slug}-${i++}`;
// exists = await Product.findOne({ slug });
// }
// // Upload main images
// const primaryImage = req.files?.primaryImage
// ? await uploadToS3(req.files.primaryImage[0])
// : null;
// const galleryImages = req.files?.galleryImages
// ? await Promise.all(req.files.galleryImages.map(uploadToS3))
// : [];
// const productData = {
// name,
// slug,
// ...rest,
// hasVariants: hasVariants === "true",
// };
// // 🔥 VARIANT LOGIC
// if (hasVariants === "true") {
// const parsedVariants = JSON.parse(variants); // IMPORTANT
// productData.variants = await Promise.all(
// parsedVariants.map(async (variant) => {
// const variantImages =
// req.files?.[`variantImages_${variant.color}`]
// ? await Promise.all(
// req.files[`variantImages_${variant.color}`].map(uploadToS3)
// )
// : [];
// return {
// size: variant.size,
// color: variant.color,
// sku: variant.sku,
// price: Number(variant.price),
// compareAtPrice: Number(variant.compareAtPrice),
// inventory: {
// quantity: Number(variant.quantity),
// trackInventory: true,
// },
// images: variantImages,
// };
// })
// );
// } else {
// // simple product images
// productData.images = {
// primary: primaryImage,
// gallery: galleryImages,
// };
// }
// const product = await Product.create(productData);
// res.status(201).json({
// status: true,
// message: "Product created successfully",
// data: product,
// });
// } catch (err) {
// next(err);
// }
// };
// exports.createProduct = async (req, res, next) => {
// try {
// const { name, hasVariants, variants, ...rest } = req.body;
// console.log('📥 Request body:', { name, hasVariants, variantsCount: variants ? 'YES' : 'NO' });
// console.log('📥 Files received:', req.files ? req.files.length : 0);
// // ✅ Validate name
// if (!name || name.trim() === "") {
// return res.status(400).json({
// status: false,
// message: "Product name is required",
// });
// }
// // Generate unique slug
// let slug = name
// .toLowerCase()
// .replace(/[^a-z0-9]+/g, "-")
// .replace(/(^-|-$)/g, "");
// let exists = await Product.findOne({ slug });
// let i = 1;
// while (exists) {
// slug = `${slug}-${i++}`;
// exists = await Product.findOne({ slug });
// }
// const productData = {
// name,
// slug,
// ...rest,
// hasVariants: hasVariants === "true" || hasVariants === true,
// };
// // ======================
// // VARIANT MODE
// // ======================
// if (productData.hasVariants) {
// console.log('🎨 Processing variant product...');
// if (!variants) {
// return res.status(400).json({
// status: false,
// message: "Variants data is required when hasVariants is true",
// });
// }
// const parsedVariants = JSON.parse(variants);
// console.log('📦 Parsed variants:', parsedVariants.length);
// // ✅ Convert req.files array to object grouped by fieldname
// const filesGrouped = {};
// if (req.files && req.files.length > 0) {
// req.files.forEach(file => {
// if (!filesGrouped[file.fieldname]) {
// filesGrouped[file.fieldname] = [];
// }
// filesGrouped[file.fieldname].push(file);
// });
// }
// console.log('📸 Files grouped:', Object.keys(filesGrouped));
// // Process each variant
// productData.variants = await Promise.all(
// parsedVariants.map(async (variant) => {
// const color = variant.color;
// const fieldName = `variantImages_${color}`;
// console.log(`🔍 Looking for images with fieldname: ${fieldName}`);
// // Get images for this variant
// const variantFiles = filesGrouped[fieldName] || [];
// console.log(`📸 Found ${variantFiles.length} images for ${color}`);
// // Upload images to S3
// const variantImages = variantFiles.length > 0
// ? await Promise.all(variantFiles.map(uploadToS3))
// : [];
// console.log(`✅ Uploaded ${variantImages.length} images for ${color}`);
// return {
// size: variant.size || 'default',
// color: variant.color,
// sku: variant.sku,
// price: Number(variant.price),
// compareAtPrice: variant.compareAtPrice ? Number(variant.compareAtPrice) : null,
// inventory: {
// quantity: Number(variant.quantity || variant.stock || 0),
// trackInventory: true,
// },
// images: variantImages,
// isActive: true,
// };
// })
// );
// console.log('✅ All variants processed:', productData.variants.length);
// }
// // ======================
// // SIMPLE PRODUCT MODE
// // ======================
// else {
// console.log('📦 Processing simple product...');
// // ✅ Handle files from req.files array
// let primaryImage = null;
// let galleryImages = [];
// if (req.files && req.files.length > 0) {
// // Group files by fieldname
// const filesGrouped = {};
// req.files.forEach(file => {
// if (!filesGrouped[file.fieldname]) {
// filesGrouped[file.fieldname] = [];
// }
// filesGrouped[file.fieldname].push(file);
// });
// // Upload primary image
// if (filesGrouped['primaryImage'] && filesGrouped['primaryImage'][0]) {
// primaryImage = await uploadToS3(filesGrouped['primaryImage'][0]);
// }
// // Upload gallery images
// if (filesGrouped['galleryImages']) {
// galleryImages = await Promise.all(
// filesGrouped['galleryImages'].map(uploadToS3)
// );
// }
// }
// productData.images = {
// primary: primaryImage,
// gallery: galleryImages,
// videos: [],
// };
// console.log('✅ Images uploaded:', {
// primary: !!primaryImage,
// gallery: galleryImages.length,
// });
// }
// // Create product in MongoDB
// const product = await Product.create(productData);
// console.log('✅ Product created:', product._id);
// res.status(201).json({
// status: true,
// message: "Product created successfully",
// data: product,
// });
// } catch (err) {
// console.error('❌ Error creating product:', err);
// // Send detailed error for debugging
// if (process.env.NODE_ENV === 'development') {
// return res.status(400).json({
// status: false,
// message: "Failed to create product",
// error: err.message,
// stack: err.stack,
// });
// }
// next(err);
// }
// };
exports.getAllProducts = async (req, res, next) => {
try {
const { page = 1, limit = 20, status, category, search } = req.query;
const skip = (page - 1) * limit;
const query = {};
if (status) query.status = status;
if (category) query.category = category;
if (search) query.$text = { $search: search };
const [products, total] = await Promise.all([
Product.find(query)
.sort({ createdAt: -1 })
.skip(skip)
.limit(+limit)
.lean(),
Product.countDocuments(query),
]);
// ✅ Compute real-time stock for each product
const productsWithStock = products.map(product => {
const stockInfo = computeStockInfo(product);
return {
...product,
...stockInfo,
};
});
return res.status(200).json({
statusCode: 200,
status: true,
message: 'Products fetched successfully',
data: {
products: productsWithStock,
pagination: {
page: +page,
limit: +limit,
total,
pages: Math.ceil(total / limit),
},
},
});
} catch (error) {
next(error);
}
};
// ✅ Central stock calculator — use this everywhere
function computeStockInfo(product) {
let totalStock = 0;
let variantStockDetails = [];
if (product.hasVariants && product.variants?.length > 0) {
// Sum stock across all active variants
variantStockDetails = product.variants
.filter(v => v.isActive)
.map(v => ({
variantId: v._id?.toString(),
sku: v.sku,
size: v.size,
color: v.color,
price: v.price,
stock: v.inventory?.quantity || 0,
trackInventory: v.inventory?.trackInventory ?? true,
stockStatus: getStockStatus(v.inventory?.quantity || 0),
}));
totalStock = variantStockDetails.reduce((sum, v) => sum + v.stock, 0);
} else {
// Non-variant product — use root stock field
totalStock = product.stock || 0;
variantStockDetails = [];
}
return {
totalStock,
stockStatus: getStockStatus(totalStock),
variantStock: variantStockDetails, // per-variant breakdown
};
}
function getStockStatus(stock) {
if (stock === 0) return 'OUT_OF_STOCK';
if (stock <= 5) return 'CRITICAL';
if (stock <= 10) return 'LOW';
return 'IN_STOCK';
}
exports.createProduct = async (req, res, next) => {
try {
const { name, hasVariants, variants, ...rest } = req.body;
console.log('📥 Request body:', { name, hasVariants, variantsCount: variants ? 'YES' : 'NO' });
console.log('📥 Files received:', req.files ? req.files.length : 0);
// ✅ Validate name
if (!name || name.trim() === "") {
return res.status(400).json({
status: false,
message: "Product name is required",
});
}
// Generate unique slug
let slug = name
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/(^-|-$)/g, "");
let exists = await Product.findOne({ slug });
let i = 1;
while (exists) {
slug = `${slug}-${i++}`;
exists = await Product.findOne({ slug });
}
// ✅ Build productData carefully to avoid empty slug
const productData = {
name,
slug, // Generated slug
hasVariants: hasVariants === "true" || hasVariants === true,
};
// ✅ Add other fields from rest, but skip 'slug' if it exists
Object.keys(rest).forEach(key => {
if (key !== 'slug') {
productData[key] = rest[key];
}
});
// ======================
// VARIANT MODE
// ======================
if (productData.hasVariants) {
console.log('🎨 Processing variant product...');
if (!variants) {
return res.status(400).json({
status: false,
message: "Variants data is required when hasVariants is true",
});
}
const parsedVariants = JSON.parse(variants);
console.log('📦 Parsed variants:', parsedVariants.length);
// ✅ Convert req.files array to object grouped by fieldname
const filesGrouped = {};
if (req.files && req.files.length > 0) {
req.files.forEach(file => {
if (!filesGrouped[file.fieldname]) {
filesGrouped[file.fieldname] = [];
}
filesGrouped[file.fieldname].push(file);
});
}
console.log('📸 Files grouped:', Object.keys(filesGrouped));
// Process each variant
productData.variants = await Promise.all(
parsedVariants.map(async (variant) => {
const color = variant.color;
const fieldName = `variantImages_${color}`;
console.log(`🔍 Looking for images with fieldname: ${fieldName}`);
// Get images for this variant
const variantFiles = filesGrouped[fieldName] || [];
console.log(`📸 Found ${variantFiles.length} images for ${color}`);
// Upload images to S3
const variantImages = variantFiles.length > 0
? await Promise.all(variantFiles.map(uploadToS3))
: [];
console.log(`✅ Uploaded ${variantImages.length} images for ${color}`);
return {
size: variant.size || 'default',
color: variant.color,
sku: variant.sku,
price: Number(variant.price),
compareAtPrice: variant.compareAtPrice ? Number(variant.compareAtPrice) : null,
inventory: {
quantity: Number(variant.quantity || variant.stock || 0),
trackInventory: true,
},
images: variantImages,
isActive: true,
};
})
);
console.log('✅ All variants processed:', productData.variants.length);
}
// ======================
// SIMPLE PRODUCT MODE
// ======================
else {
console.log('📦 Processing simple product...');
// ✅ Handle files from req.files array
let primaryImage = null;
let galleryImages = [];
if (req.files && req.files.length > 0) {
// Group files by fieldname
const filesGrouped = {};
req.files.forEach(file => {
if (!filesGrouped[file.fieldname]) {
filesGrouped[file.fieldname] = [];
}
filesGrouped[file.fieldname].push(file);
});
// Upload primary image
if (filesGrouped['primaryImage'] && filesGrouped['primaryImage'][0]) {
primaryImage = await uploadToS3(filesGrouped['primaryImage'][0]);
}
// Upload gallery images
if (filesGrouped['galleryImages']) {
galleryImages = await Promise.all(
filesGrouped['galleryImages'].map(uploadToS3)
);
}
}
productData.images = {
primary: primaryImage,
gallery: galleryImages,
videos: [],
};
console.log('✅ Images uploaded:', {
primary: !!primaryImage,
gallery: galleryImages.length,
});
}
// Create product in MongoDB
const product = await Product.create(productData);
console.log('✅ Product created:', product._id);
res.status(201).json({
status: true,
message: "Product created successfully",
data: product,
});
} catch (err) {
console.error('❌ Error creating product:', err);
// Send detailed error for debugging
if (process.env.NODE_ENV === 'development') {
return res.status(400).json({
status: false,
message: "Failed to create product",
error: err.message,
stack: err.stack,
});
}
next(err);
}
};
exports.updateProduct = async (req, res, next) => {
try {
const product = await Product.findByIdAndUpdate(req.params.id, req.body, {
new: true,
});
// if (!product) return res.status(404).json({ success: false, message: 'Product not found' });
if (!product) {
return res.status(404).json({
statusCode: 404,
status: false,
message: 'Product not found',
});
}
// res.json({ success: true, message: 'Product updated', data: product });
return res.status(200).json({
statusCode: 200,
status: true,
message: 'Product updated successfully',
data: product,
});
} catch (error) {
next(error);
}
};
exports.deleteProduct = async (req, res, next) => {
try {
const product = await Product.findByIdAndDelete(req.params.id);
// if (!product) return res.status(404).json({ success: false, message: 'Product not found' });
if (!product) {
return res.status(404).json({
statusCode: 404,
status: false,
message: 'Product not found',
});
}
// res.json({ success: true, message: 'Product deleted' });
return res.status(200).json({
statusCode: 200,
status: true,
message: 'Product deleted successfully',
});
} catch (error) {
next(error);
}
};

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,160 @@
const { prisma } = require('../../config/database');
exports.getAllUsers = async (req, res, next) => {
try {
const { page = 1, limit = 20, role, search, isActive } = req.query;
const skip = (page - 1) * limit;
const where = {};
if (role) where.role = role;
if (isActive !== undefined) where.isActive = isActive === 'true';
if (search) {
where.OR = [
{ email: { contains: search, mode: 'insensitive' } },
{ firstName: { contains: search, mode: 'insensitive' } },
{ lastName: { contains: search, mode: 'insensitive' } },
{ username: { contains: search, mode: 'insensitive' } },
];
}
const [users, total] = await Promise.all([
prisma.user.findMany({
where,
select: {
id: true,
email: true,
username: true,
firstName: true,
lastName: true,
role: true,
isVerified: true,
isActive: true,
createdAt: true,
lastLoginAt: true,
},
orderBy: { createdAt: 'desc' },
skip: parseInt(skip),
take: parseInt(limit),
}),
prisma.user.count({ where }),
]);
// res.json({
// success: true,
// data: { users, pagination: { page: +page, limit: +limit, total, pages: Math.ceil(total / limit) } },
// });
return res.status(200).json({
statusCode: 200,
status: true,
message: 'Users fetched successfully',
data: {
users,
pagination: {
page: +page,
limit: +limit,
total,
pages: Math.ceil(total / limit),
},
},
});
} catch (error) {
next(error);
}
};
exports.updateUserStatus = async (req, res, next) => {
try {
const { isActive, role } = req.body;
const { id } = req.params;
const user = await prisma.user.findUnique({ where: { id } });
// if (!user) return res.status(404).json({ success: false, message: 'User not found' });
if (!user) {
return res.status(404).json({
statusCode: 404,
status: false,
message: 'User not found',
});
}
const updatedUser = await prisma.user.update({
where: { id },
data: {
...(isActive !== undefined && { isActive }),
...(role && { role }),
},
});
// res.json({ success: true, message: 'User updated successfully', data: updatedUser });
return res.status(200).json({
statusCode: 200,
status: true,
message: 'User updated successfully',
data: updatedUser,
});
} catch (error) {
next(error);
}
};
exports.getUserById = async (req, res, next) => {
try {
const { id } = req.params;
// if (!id) {
// return res.status(400).json({
// success: false,
// message: "User ID is required",
// });
// }
if (!id) {
return res.status(400).json({
statusCode: 400,
status: false,
message: 'User ID is required',
});
}
const user = await prisma.user.findUnique({
where: { id },
select: {
id: true,
email: true,
username: true,
firstName: true,
lastName: true,
role: true,
isVerified: true,
isActive: true,
createdAt: true,
lastLoginAt: true,
},
});
// if (!user) {
// return res.status(404).json({
// success: false,
// message: "User not found",
// });
// }
if (!user) {
return res.status(404).json({
statusCode: 404,
status: false,
message: 'User not found',
});
}
// res.json({ success: true, data: { user } });
return res.status(200).json({
statusCode: 200,
status: true,
message: 'User details fetched successfully',
data: { user },
});
} catch (error) {
next(error);
}
};