Spaces:
Running
Running
| 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 }); | |
| } | |
| }; | |