samoulla-backend / controllers /orderController.js
Samoulla Sync Bot
Auto-deploy Samoulla Backend: b68e45770de26ed39feb4b1c0925e5345eb3a61d
634b9bb
const Order = require('../models/orderModel');
const Product = require('../models/productModel');
const ShippingPrice = require('../models/shippingPriceModel');
const Capital = require('../models/capitalModel');
const Promo = require('../models/promoCodeModel');
const User = require('../models/userModel');
const {
sendOrderConfirmationEmail,
sendOrderStatusUpdateEmail,
sendAdminNewOrderEmail,
} = require('../utils/emailService');
const { PERMISSIONS } = require('../utils/permissions');
const {
notifyOrderCreated,
notifyOrderProcessing,
notifyOrderShipped,
notifyOrderCompleted,
notifyOrderCancelled,
notifyVendorNewOrder,
notifyProductOutOfStock,
notifyAdminsNewOrder,
notifyAdminsProductOutOfStock,
emitOrderUpdate,
} = require('../utils/notificationService');
const { restoreOrderStock, deductOrderStock } = require('../utils/orderUtils');
const { appendOrderToSheet } = require('../utils/googleSheetsService');
exports.getAllOrders = async (req, res) => {
try {
const orders = await Order.find()
.populate('user', 'name email address mobile')
.populate({
path: 'items.product',
select: 'nameAr nameEn imageCover price provider',
populate: {
path: 'provider',
select: 'storeName name',
},
})
.sort({ createdAt: -1 })
.lean();
res.status(200).json({
status: 'success',
results: orders.length,
data: { orders },
});
} catch (err) {
res.status(500).json({
status: 'error',
message: err.message,
});
}
};
// Get orders for the authenticated user only
exports.getMyOrders = async (req, res) => {
try {
if (!req.user) {
return res.status(401).json({
status: 'fail',
message: 'You must be logged in to view your orders',
});
}
const orders = await Order.find({ user: req.user._id })
.populate('items.product', 'nameAr nameEn imageCover price')
.sort({ createdAt: -1 })
.lean();
res.status(200).json({
status: 'success',
results: orders.length,
data: { orders },
});
} catch (err) {
res.status(500).json({
status: 'error',
message: err.message,
});
}
};
exports.getOrder = async (req, res) => {
try {
const order = await Order.findById(req.params.id)
.populate({
path: 'items.product',
populate: {
path: 'provider',
model: 'Provider',
},
})
.populate('user', 'name email mobile address')
.lean();
if (!order) {
return res.status(404).json({
status: 'fail',
message: 'No order found',
});
}
// Authorization check
let isAuthorized = false;
if (req.user) {
// 1) Admins and employees with manage_orders permission can see any order
const isAdmin = req.user.role === 'admin';
const isEmployee =
req.user.role === 'employee' &&
req.user.permissions.includes(PERMISSIONS.MANAGE_ORDERS);
// 2) Users can see their own orders
const orderUserId = (order.user && order.user._id) || order.user;
const isOwner =
orderUserId && String(orderUserId) === String(req.user._id);
// 3) Vendors can see the order if it contains one of their products
let isVendorOfThisOrder = false;
if (req.user.role === 'vendor' && req.user.provider) {
const vendorProviderId = String(
req.user.provider._id || req.user.provider,
);
isVendorOfThisOrder =
order.items &&
order.items.some((item) => {
if (!item.product) return false;
const itemProviderId = String(
(item.product &&
item.product.provider &&
item.product.provider._id) ||
(item.product && item.product.provider) ||
'',
);
return itemProviderId === vendorProviderId;
});
}
if (isAdmin || isEmployee || isOwner || isVendorOfThisOrder) {
isAuthorized = true;
}
} else {
// Guest Access:
// If the order has NO user attached, we assume it's a guest order and allow access (since they have the ID)
if (!order.user) {
isAuthorized = true;
}
}
if (!isAuthorized) {
return res.status(403).json({
status: 'fail',
message: 'You are not authorized to view this order',
});
}
res.status(200).json({
status: 'success',
data: { order },
});
} catch (err) {
res.status(500).json({
status: 'error',
message: err.message,
});
}
};
exports.updateOrder = async (req, res) => {
try {
// Get the old order to check if status changed
const oldOrder = await Order.findById(req.params.id);
if (!oldOrder) {
return res
.status(404)
.json({ status: 'fail', message: 'No order found' });
}
// Prevent moving order away from 'cancelled' if any items are cancelled
if (req.body.orderStatus && req.body.orderStatus !== 'cancelled') {
const hasCancelledItems = oldOrder.items.some(
(item) => item.fulfillmentStatus === 'cancelled',
);
if (hasCancelledItems) {
// If it's an admin, we allow it BUT we must reset the items and handle stock
if (req.user && req.user.role === 'admin') {
// Reset all cancelled items to pending
const updatedItems = oldOrder.items.map((item) => {
if (item.fulfillmentStatus === 'cancelled') {
return { ...item.toObject(), fulfillmentStatus: 'pending' };
}
return item.toObject();
});
req.body.items = updatedItems;
// If moving from 'cancelled' order status, deduct stock again
if (oldOrder.orderStatus === 'cancelled') {
await deductOrderStock(oldOrder.items);
}
} else {
return res.status(400).json({
status: 'fail',
message:
'نعتذر، لا يمكن تغيير حالة الطلب بينما توجد منتجات ملغاة. يجب معالجة المنتجات الملغاة أولاً.',
});
}
}
}
// Handle moving TO cancelled status - propagate to all items
if (
req.body.orderStatus === 'cancelled' &&
oldOrder.orderStatus !== 'cancelled'
) {
const updatedItems = oldOrder.items.map((item) => ({
...item.toObject(),
fulfillmentStatus: 'cancelled',
}));
req.body.items = updatedItems;
}
// Check if status is being changed to shipped or completed and all items are ready
if (
req.body.orderStatus &&
['shipped', 'completed'].includes(req.body.orderStatus)
) {
const allReady = oldOrder.items.every(
(item) => item.fulfillmentStatus === 'ready',
);
if (!allReady) {
return res.status(400).json({
status: 'fail',
message:
'Cannot change order status to shipped/completed until all items are marked as ready.',
});
}
}
const updatedOrder = await Order.findByIdAndUpdate(
req.params.id,
req.body,
{ new: true, runValidators: true },
)
.populate({
path: 'items.product',
populate: {
path: 'provider',
model: 'Provider',
},
})
.populate('user', 'name email');
// Check if order status changed and send email + notification
if (
req.body.orderStatus &&
req.body.orderStatus !== oldOrder.orderStatus &&
updatedOrder.user
) {
// If status changed to cancelled, restore stock
if (req.body.orderStatus === 'cancelled') {
await restoreOrderStock(updatedOrder.items);
// ── Capital: Reverse profit if order was previously completed ──
if (oldOrder.orderStatus === 'completed') {
try {
const populatedForReversal = await Order.findById(
updatedOrder._id,
).populate({
path: 'items.product',
select: 'costPrice',
});
let reversedProfit = 0;
for (const item of populatedForReversal.items) {
if (!item.product) continue;
const costPrice = item.product.costPrice || 0;
const sellingPrice = item.unitPrice;
reversedProfit += (sellingPrice - costPrice) * item.quantity;
}
if (reversedProfit > 0) {
const capital = await Capital.getCapital();
capital.currentCapital -= reversedProfit;
capital.logs.push({
type: 'adjustment',
amount: -reversedProfit,
balanceAfter: capital.currentCapital,
reference: updatedOrder._id,
referenceModel: 'Order',
description: `Reversed profit — completed order #${updatedOrder._id.toString().slice(-8).toUpperCase()} was cancelled`,
date: new Date(),
createdBy: req.user._id,
});
await capital.save();
}
} catch (capitalErr) {
console.error(
'Failed to reverse capital on order cancellation:',
capitalErr.message,
);
}
}
}
// Prepare order data for email
const emailOrderData = {
orderNumber: updatedOrder._id.toString().slice(-8).toUpperCase(),
total: updatedOrder.totalPrice,
};
// Send status update email (don't wait for it)
sendOrderStatusUpdateEmail(
emailOrderData,
updatedOrder.user,
req.body.orderStatus,
).catch(() => { });
// Send notification based on status
const userId = updatedOrder.user._id || updatedOrder.user;
try {
switch (req.body.orderStatus) {
case 'created':
await notifyOrderCreated(updatedOrder, userId);
break;
case 'processing':
await notifyOrderProcessing(updatedOrder, userId);
break;
case 'shipped':
await notifyOrderShipped(updatedOrder, userId);
break;
case 'completed':
await notifyOrderCompleted(updatedOrder, userId);
break;
case 'cancelled':
await notifyOrderCancelled(updatedOrder, userId);
break;
default:
break;
}
} catch (notifErr) {
// Silent fail
}
// ── Capital: Add profit when order is marked completed ──
if (req.body.orderStatus === 'completed') {
try {
const populatedForProfit = await Order.findById(
updatedOrder._id,
).populate({
path: 'items.product',
select: 'costPrice purchasePrice',
});
let orderProfit = 0;
for (const item of populatedForProfit.items) {
if (!item.product) continue;
const costPrice = item.product.costPrice || 0;
const sellingPrice = item.unitPrice;
orderProfit += (sellingPrice - costPrice) * item.quantity;
}
if (orderProfit > 0) {
const capital = await Capital.getCapital();
capital.currentCapital += orderProfit;
capital.logs.push({
type: 'profit',
amount: orderProfit,
balanceAfter: capital.currentCapital,
reference: updatedOrder._id,
referenceModel: 'Order',
description: `Profit from completed order #${updatedOrder._id.toString().slice(-8).toUpperCase()}`,
date: new Date(),
createdBy: req.user._id,
});
await capital.save();
}
} catch (capitalErr) {
// Silent fail — don't block order update
console.error(
'Failed to update capital on order completion:',
capitalErr.message,
);
}
}
// Explicitly broadcast order update for the dashboard (ONLY ONCE)
emitOrderUpdate(updatedOrder, req.body.orderStatus);
}
res.status(200).json({ status: 'success', data: { order: updatedOrder } });
} catch (err) {
res.status(500).json({ status: 'error', message: err.message });
}
};
exports.cancelOrder = async (req, res) => {
try {
const { id } = req.params;
const order = await Order.findById(id);
if (!order)
return res
.status(404)
.json({ status: 'fail', message: 'Order not found' });
if (!req.user)
return res.status(401).json({
status: 'fail',
message: 'You must be logged in to cancel an order',
});
const orderUserId = (order.user && order.user._id) || order.user;
const isOwner = orderUserId && String(orderUserId) === String(req.user._id);
const isAdmin = req.user.role === 'admin';
if (!isOwner && !isAdmin)
return res.status(403).json({
status: 'fail',
message: 'Not authorized to cancel this order',
});
if (order.orderStatus === 'cancelled') {
return res
.status(400)
.json({ status: 'fail', message: 'Order is already cancelled' });
}
await restoreOrderStock(order.items);
if (order.promo) {
try {
await Promo.findByIdAndUpdate(order.promo, { $inc: { usedCount: -1 } });
} catch (promoErr) {
console.error('Failed to restore promo usage:', promoErr);
}
}
order.orderStatus = 'cancelled';
order.canceledAt = new Date();
if (order.payment && order.payment.status !== 'failed') {
order.payment.status = 'failed';
}
await order.save();
// Send cancellation notification
try {
const userId = order.user;
await notifyOrderCancelled(order, userId, 'Order cancelled as requested');
} catch (notifErr) {
// Silent fail
}
// Explicitly broadcast order update for the dashboard (ONLY ONCE)
emitOrderUpdate(order, 'order_cancelled');
// Populate product details before returning
await order.populate('items.product', 'nameAr nameEn imageCover price');
res.status(200).json({ status: 'success', data: { order } });
} catch (err) {
res.status(500).json({ status: 'error', message: err.message });
}
};
exports.deleteOrder = async (req, res) => {
try {
const order = await Order.findByIdAndDelete(req.params.id);
if (!order) {
return res.status(404).json({
status: 'fail',
message: 'No order found',
});
}
// Explicitly broadcast order update for the dashboard
emitOrderUpdate({ _id: req.params.id }, 'order_deleted');
res.status(204).json({
status: 'success',
data: null,
});
} catch (err) {
res.status(500).json({
status: 'error',
message: err.message,
});
}
};
exports.getOrderSuccessPage = async (req, res) => {
try {
const { id } = req.params;
const order = await Order.findById(id).populate('items.product').lean();
if (!order)
return res
.status(404)
.json({ status: 'fail', message: 'Order not found' });
res.status(200).json({
status: 'success',
data: {
message: 'Order placed successfully',
order,
},
});
} catch (err) {
res.status(500).json({ status: 'error', message: err.message });
}
};
function computeDiscountAmount(type, value, base) {
const numericBase = Number(base) || 0;
const numericValue = Number(value) || 0;
if (type === 'shipping') return 0; // Does not affect subtotal base
if (type === 'percentage')
return Math.max(0, (numericBase * numericValue) / 100);
return Math.max(0, numericValue);
}
const SiteSettings = require('../models/siteSettingsModel');
exports.createOrder = async (req, res) => {
try {
const { name, mobile, address, payment, items, promoCode, source } = req.body;
// Check guest checkout settings
if (!req.user) {
const settings = await SiteSettings.getSettings();
if (!settings.checkout.allowGuestCheckout) {
return res.status(401).json({
status: 'fail',
message:
'Guest checkout is disabled. Please log in or create an account to place an order.',
});
}
}
if (!items || !Array.isArray(items) || items.length === 0) {
return res
.status(400)
.json({ status: 'fail', message: 'Cart items are required' });
}
if (payment && payment.method === 'visa' && !req.user) {
return res.status(400).json({
status: 'fail',
message: 'You must be logged in to pay with Visa',
});
}
const productIds = items.map((i) => i.productId);
const products = await Product.find({ _id: { $in: productIds } });
const productMap = new Map(products.map((p) => [String(p._id), p]));
const missingIds = productIds.filter((id) => !productMap.has(String(id)));
if (missingIds.length > 0) {
return res.status(400).json({
status: 'fail',
message: 'Some products were not found',
missingProductIds: missingIds,
});
}
// Decrease stock for each product and track out-of-stock items
// Decrease stock for each product in bulk
const bulkOps = items.map((i) => ({
updateOne: {
filter: { _id: i.productId },
update: { $inc: { stock: -(Number(i.quantity) || 1) } },
},
}));
await Product.bulkWrite(bulkOps);
// Identify products that went out of stock
const outOfStockItems = await Product.find({
_id: { $in: productIds },
stock: { $lte: 0 },
});
let subtotal = 0;
const orderItems = [];
for (const i of items) {
const prod = productMap.get(String(i.productId));
const quantity = Number(i.quantity) || 1;
const unitPrice =
prod.salePrice && prod.salePrice < prod.price
? prod.salePrice
: prod.price;
subtotal += unitPrice * quantity;
orderItems.push({
product: prod._id,
name: prod.nameAr || prod.nameEn || prod.barCode,
quantity,
unitPrice,
});
}
// Dynamic Shipping Calculation:
// Strictly find price for the governorate level
const govPrice = await ShippingPrice.findOne({
$or: [
{ areaNameAr: address.governorate },
{
areaNameEn: {
$regex: new RegExp(`^${address.governorate.trim()}$`, 'i'),
},
},
],
type: 'city',
});
const SHIPPING_COST = govPrice ? govPrice.fees : 300;
const deliveryDays = govPrice ? govPrice.deliveryDays : 3;
// Calculate estimated delivery date
const estimatedDeliveryDate = new Date();
estimatedDeliveryDate.setDate(
estimatedDeliveryDate.getDate() + deliveryDays,
);
let totalPrice = subtotal + SHIPPING_COST;
let appliedPromo = null;
let discountAmountApplied = 0;
if (promoCode) {
const upper = String(promoCode).trim().toUpperCase();
const now = new Date();
const promo = await Promo.findOne({
code: upper,
active: true,
$and: [
{ $or: [{ startsAt: null }, { startsAt: { $lte: now } }] },
{ $or: [{ expiresAt: null }, { expiresAt: { $gt: now } }] },
],
minOrderValue: { $lte: subtotal },
});
if (!promo)
return res
.status(400)
.json({ status: 'fail', message: 'Invalid or expired promo code' });
if (promo.perUserLimit && promo.perUserLimit > 0) {
if (!req.user)
return res.status(400).json({
status: 'fail',
message: 'You must be logged in to use this promo code',
});
const userUses = await Order.countDocuments({
user: req.user._id,
promo: promo._id,
});
if (userUses >= promo.perUserLimit)
return res.status(400).json({
status: 'fail',
message: 'Promo code usage limit reached for this user',
});
}
if (promo.usageLimit != null && promo.usedCount >= promo.usageLimit)
return res
.status(400)
.json({ status: 'fail', message: 'Promo code has been fully used' });
await Promo.findByIdAndUpdate(promo._id, { $inc: { usedCount: 1 } });
appliedPromo = promo;
const discount = computeDiscountAmount(promo.type, promo.value, subtotal);
discountAmountApplied = discount;
if (promo.type === 'shipping') {
totalPrice = subtotal; // Shipping is free
discountAmountApplied = SHIPPING_COST; // Record the shipping saving as the discount
} else {
totalPrice = Math.max(0, subtotal - discount) + SHIPPING_COST;
}
}
const normalizedPayment = { ...payment };
// Don't automatically mark visa payments as paid - wait for Paymob callback
if (!normalizedPayment || !normalizedPayment.status) {
normalizedPayment.status = 'pending';
}
let promoData = {};
if (promoCode && appliedPromo) {
const discountAmount = Number(discountAmountApplied.toFixed(2));
promoData = {
promo: appliedPromo._id,
promoCode: appliedPromo.code,
discountAmount,
discountType: appliedPromo.type,
};
}
const orderData = {
name,
mobile,
address,
items: orderItems,
payment: normalizedPayment,
totalPrice,
estimatedDeliveryDate,
shippingPrice: SHIPPING_COST,
source: source || 'direct',
...promoData,
};
if (req.user) orderData.user = req.user._id;
let newOrder;
try {
newOrder = await Order.create(orderData);
} catch (err) {
// Rollback: Restore stock if order creation fails
try {
await restoreOrderStock(orderItems);
} catch (restoreErr) {
console.error(
'Failed to restore stock after order creation error:',
restoreErr,
);
}
if (appliedPromo) {
try {
await Promo.findByIdAndUpdate(appliedPromo._id, {
$inc: { usedCount: -1 },
});
} catch (err2) {
// Silent fail or use a proper logger
}
}
throw err;
}
// Send notifications (ONLY for CASH orders)
// For Visa, we notify only after successful payment confirmation in paymentController
if (newOrder.payment.method === 'cash') {
try {
if (req.user) {
notifyOrderCreated(newOrder, req.user._id).catch(() => { });
}
const vendorIds = new Set();
products.forEach((p) => {
if (p.provider) vendorIds.add(p.provider.toString());
});
vendorIds.forEach((vId) => {
notifyVendorNewOrder(newOrder, vId).catch(() => { });
});
notifyAdminsNewOrder(newOrder).catch(() => { });
} catch (notifErr) {
// Silent fail
}
}
// Out-of-stock notifications should happen regardless of payment method
// because stock IS deducted from the DB now
try {
outOfStockItems.forEach((prod) => {
if (prod.provider) {
notifyProductOutOfStock(prod, prod.provider).catch(() => { });
}
notifyAdminsProductOutOfStock(prod).catch(() => { });
});
} catch (vendorNotifErr) {
// Silent fail
}
// Explicitly broadcast order update for the dashboard (ONLY ONCE)
emitOrderUpdate(newOrder, 'order_created');
res.status(201).json({
status: 'success',
data: { order: newOrder },
});
// Append to Google Sheet (fire-and-forget — never blocks the response)
appendOrderToSheet(newOrder).catch(() => { });
// Send order confirmation email (non-blocking - after response)
// ONLY for cash orders. Visa orders will send once payment is confirmed.
if (req.user && newOrder.payment.method === 'cash') {
// Populate order with product details and provider for email
const populatedOrder = await Order.findById(newOrder._id).populate({
path: 'items.product',
select: 'nameAr price imageCover',
populate: {
path: 'provider',
select: 'storeName',
},
});
// Prepare order data for email template
const emailOrderData = {
orderNumber: newOrder._id.toString().slice(-8).toUpperCase(), // Last 8 chars of ID
items: populatedOrder.items.map((item) => ({
product: {
nameAr:
item.product && item.product.nameAr
? item.product.nameAr
: item.name,
imageCover:
item.product && item.product.imageCover
? item.product.imageCover
: null,
provider:
item.product && item.product.provider
? item.product.provider
: null,
},
quantity: item.quantity,
price: item.unitPrice,
})),
subtotal: subtotal,
discount: discountAmountApplied,
shippingCost: SHIPPING_COST,
total: totalPrice,
paymentMethod: newOrder.payment.method,
shippingAddress: {
street: newOrder.address.street,
city: newOrder.address.city,
governorate: newOrder.address.governorate,
phone: newOrder.mobile,
},
};
sendOrderConfirmationEmail(emailOrderData, req.user).catch(() => { });
}
// Notify all admin users by email about the new order
User.find({ role: 'admin' })
.select('email name')
.lean()
.then((admins) => {
if (!admins || admins.length === 0) return;
// Reuse emailOrderData if already built (cash orders), otherwise build a minimal version
const adminOrderData = {
orderNumber: newOrder._id.toString().slice(-8).toUpperCase(),
items: newOrder.items.map((item) => ({
product: { nameAr: item.name, imageCover: null, provider: null },
quantity: item.quantity,
price: item.unitPrice,
})),
subtotal,
discount: discountAmountApplied,
shippingCost: SHIPPING_COST,
total: totalPrice,
paymentMethod: newOrder.payment.method,
shippingAddress: {
street: newOrder.address.street,
city: newOrder.address.city,
governorate: newOrder.address.governorate,
phone: newOrder.mobile,
},
};
const customer = req.user
? { name: req.user.name, email: req.user.email }
: { name: 'عميل غير مسجل', email: '-' };
admins.forEach((admin) => {
sendAdminNewOrderEmail(adminOrderData, customer, admin.email).catch(
() => { },
);
});
})
.catch(() => { });
} catch (err) {
res.status(500).json({
status: 'error',
message: err.message,
});
}
};
exports.updateOrderItemFulfillment = async (req, res) => {
try {
const { orderId, productId } = req.params;
const { fulfillmentStatus } = req.body;
// Validate fulfillment status
const validStatuses = ['pending', 'preparing', 'ready', 'cancelled'];
if (!validStatuses.includes(fulfillmentStatus)) {
return res
.status(400)
.json({ status: 'fail', message: 'Invalid fulfillment status' });
}
const order = await Order.findById(orderId).populate('items.product');
if (!order) {
return res
.status(404)
.json({ status: 'fail', message: 'Order not found' });
}
// Find the item
const item = order.items.find(
(it) => it.product && it.product._id.toString() === productId,
);
if (!item) {
return res
.status(404)
.json({ status: 'fail', message: 'Product not found in this order' });
}
// Authorization check
// Admins and employees with manage_orders can update anything
const isAdmin = req.user.role === 'admin';
const isEmployee =
req.user.role === 'employee' &&
req.user.permissions.includes(PERMISSIONS.MANAGE_ORDERS);
// For vendors, check if they own the product
if (!isAdmin && !isEmployee) {
if (req.user.role !== 'vendor' || !req.user.provider) {
return res
.status(403)
.json({ status: 'fail', message: 'Not authorized' });
}
const providerId = req.user.provider._id || req.user.provider;
if (item.product.provider.toString() !== providerId.toString()) {
return res.status(403).json({
status: 'fail',
message: 'You can only update your own items',
});
}
}
item.fulfillmentStatus = fulfillmentStatus;
// Handle cancellation
if (fulfillmentStatus === 'cancelled') {
order.orderStatus = 'cancelled';
// Restore stock for all items in the order
await restoreOrderStock(order.items);
} else if (
// Auto-revert order status to processing if an item is moved back from 'ready'
// when the order was already shipped or completed
fulfillmentStatus !== 'ready' &&
['shipped', 'completed'].includes(order.orderStatus)
) {
order.orderStatus = 'processing';
}
await order.save();
// Broadcast update
emitOrderUpdate(order, 'order_item_updated');
res.status(200).json({
status: 'success',
data: { order },
});
} catch (err) {
res.status(500).json({ status: 'error', message: err.message });
}
};
exports.updateOrderItemQuantity = async (req, res) => {
try {
const { orderId, productId } = req.params;
const { quantity } = req.body;
if (!quantity || quantity < 1) {
return res.status(400).json({
status: 'fail',
message: 'Quantity must be at least 1',
});
}
const order = await Order.findById(orderId).populate('items.product');
if (!order) {
return res.status(404).json({
status: 'fail',
message: 'Order not found',
});
}
// Authorization check: Only admins or authorized employees can edit quantities
const isAdmin = req.user.role === 'admin';
const isEmployee =
req.user.role === 'employee' &&
req.user.permissions.includes(PERMISSIONS.MANAGE_ORDERS);
if (!isAdmin && !isEmployee) {
return res.status(403).json({
status: 'fail',
message: 'Not authorized to change quantities',
});
}
// Find the item
const item = order.items.find(
(it) => it.product && it.product._id.toString() === productId,
);
if (!item) {
return res.status(404).json({
status: 'fail',
message: 'Product not found in this order',
});
}
const oldQuantity = item.quantity;
const quantityDiff = quantity - oldQuantity;
// Update product stock
if (quantityDiff !== 0) {
const product = await Product.findById(productId);
if (product) {
// If increasing quantity, check if there's enough stock
if (quantityDiff > 0 && product.stock < quantityDiff) {
return res.status(400).json({
status: 'fail',
message: `Insufficient stock for ${product.nameAr || product.nameEn}. Available: ${product.stock}`,
});
}
product.stock -= quantityDiff;
await product.save();
}
}
// Update item quantity
item.quantity = quantity;
// Recalculate totals
let newSubtotal = 0;
order.items.forEach((it) => {
newSubtotal += it.unitPrice * it.quantity;
});
// Handle discount recalculation if there was a promo code
if (order.promo) {
const promo = await Promo.findById(order.promo);
if (promo) {
const newDiscount = computeDiscountAmount(
promo.type,
promo.value,
newSubtotal,
);
order.discountAmount = newDiscount;
if (promo.type === 'shipping') {
order.totalPrice = newSubtotal;
} else {
order.totalPrice =
Math.max(0, newSubtotal - newDiscount) + order.shippingPrice;
}
} else {
// Promo not found, keep old shipping logic but update total
order.totalPrice =
newSubtotal + order.shippingPrice - order.discountAmount;
}
} else {
order.totalPrice =
newSubtotal + order.shippingPrice - order.discountAmount;
}
await order.save();
// Broadcast update
emitOrderUpdate(order, 'order_quantity_updated');
res.status(200).json({
status: 'success',
data: { order },
});
} catch (err) {
res.status(500).json({ status: 'error', message: err.message });
}
};
exports.addOrderItem = async (req, res) => {
try {
const { id: orderId } = req.params;
const { productId, quantity } = req.body;
if (!quantity || quantity < 1) {
return res.status(400).json({
status: 'fail',
message: 'Quantity must be at least 1',
});
}
const order = await Order.findById(orderId).populate('items.product');
if (!order) {
return res.status(404).json({
status: 'fail',
message: 'Order not found',
});
}
const product = await Product.findById(productId);
if (!product) {
return res.status(404).json({
status: 'fail',
message: 'Product not found',
});
}
if (product.stock < quantity) {
return res.status(400).json({
status: 'fail',
message: `Insufficient stock for ${product.nameAr || product.nameEn}. Available: ${product.stock}`,
});
}
// Authorization check
const isAdmin = req.user.role === 'admin';
const isEmployee =
req.user.role === 'employee' &&
req.user.permissions.includes(PERMISSIONS.MANAGE_ORDERS);
if (!isAdmin && !isEmployee) {
return res.status(403).json({
status: 'fail',
message: 'Not authorized to add items to orders',
});
}
// Check if product already exists in order
const existingItem = order.items.find(
(item) => item.product && item.product._id.toString() === productId,
);
if (existingItem) {
existingItem.quantity += quantity;
existingItem.fulfillmentStatus = 'pending';
} else {
order.items.push({
product: productId,
name: product.nameAr || product.nameEn,
quantity,
unitPrice:
product.salePrice && product.salePrice < product.price
? product.salePrice
: product.price,
});
}
// Deduct stock
product.stock -= quantity;
await product.save();
// Recalculate totals
let newSubtotal = 0;
order.items.forEach((it) => {
newSubtotal += it.unitPrice * it.quantity;
});
if (order.promo) {
const promo = await Promo.findById(order.promo);
if (promo) {
const newDiscount = computeDiscountAmount(
promo.type,
promo.value,
newSubtotal,
);
order.discountAmount = newDiscount;
if (promo.type === 'shipping') {
order.totalPrice = newSubtotal;
} else {
order.totalPrice =
Math.max(0, newSubtotal - newDiscount) + order.shippingPrice;
}
} else {
order.totalPrice =
newSubtotal + order.shippingPrice - order.discountAmount;
}
} else {
order.totalPrice =
newSubtotal + order.shippingPrice - order.discountAmount;
}
await order.save();
// Broadcast update
emitOrderUpdate(order, 'order_item_added');
res.status(200).json({
status: 'success',
data: { order },
});
} catch (err) {
res.status(500).json({ status: 'error', message: err.message });
}
};
exports.removeOrderItem = async (req, res) => {
try {
const { orderId, productId } = req.params;
const order = await Order.findById(orderId);
if (!order) {
return res.status(404).json({
status: 'fail',
message: 'Order not found',
});
}
// Authorization check
const isAdmin = req.user.role === 'admin';
const isEmployee =
req.user.role === 'employee' &&
req.user.permissions.includes(PERMISSIONS.MANAGE_ORDERS);
if (!isAdmin && !isEmployee) {
return res.status(403).json({
status: 'fail',
message: 'Not authorized to remove items from orders',
});
}
// Find the item index
const itemIndex = order.items.findIndex(
(it) => it.product && it.product.toString() === productId,
);
if (itemIndex === -1) {
return res.status(404).json({
status: 'fail',
message: 'Product not found in this order',
});
}
// Prevent removing the last item (order must have at least one item)
if (order.items.length <= 1) {
return res.status(400).json({
status: 'fail',
message: 'Cannot remove the last item. Use "Delete Order" instead.',
});
}
const itemToRemove = order.items[itemIndex];
// Restore stock
const product = await Product.findById(productId);
if (product) {
product.stock += itemToRemove.quantity;
await product.save();
}
// Remove the item
order.items.splice(itemIndex, 1);
// Recalculate totals
let newSubtotal = 0;
order.items.forEach((it) => {
newSubtotal += it.unitPrice * it.quantity;
});
if (order.promo) {
const promo = await Promo.findById(order.promo);
if (promo) {
const newDiscount = computeDiscountAmount(
promo.type,
promo.value,
newSubtotal,
);
order.discountAmount = newDiscount;
if (promo.type === 'shipping') {
order.totalPrice = newSubtotal;
} else {
order.totalPrice =
Math.max(0, newSubtotal - newDiscount) + order.shippingPrice;
}
} else {
order.totalPrice =
newSubtotal + order.shippingPrice - order.discountAmount;
}
} else {
order.totalPrice =
newSubtotal + order.shippingPrice - order.discountAmount;
}
await order.save();
// Broadcast update
emitOrderUpdate(order, 'order_item_removed');
res.status(200).json({
status: 'success',
data: { order },
});
} catch (err) {
res.status(500).json({ status: 'error', message: err.message });
}
};