Spaces:
Running
Running
| const Notification = require('../models/notificationModel'); | |
| const { getIO } = require('./socket'); | |
| /** | |
| * Emit unread count to a specific user | |
| */ | |
| const emitUnreadCount = async (userId) => { | |
| try { | |
| const unreadCount = await Notification.countDocuments({ | |
| user: userId, | |
| isRead: false, | |
| }); | |
| getIO().to(userId.toString()).emit('unreadCountUpdate', unreadCount); | |
| return unreadCount; | |
| } catch (err) { | |
| // eslint-disable-next-line no-console | |
| console.error('Error emitting unread count:', err); | |
| return 0; | |
| } | |
| }; | |
| /** | |
| * Check if a user has enabled a specific notification type | |
| */ | |
| const checkUserPreference = async ( | |
| userId, | |
| type, | |
| channel = 'app', | |
| timestamp = null, | |
| ) => { | |
| try { | |
| const User = require('../models/userModel'); | |
| // Use lean() for faster reading and plain object access | |
| const user = await User.findById(userId) | |
| .select('notificationPreferences role permissions') | |
| .lean(); | |
| if (!user || !user.notificationPreferences) return true; | |
| const { notificationPreferences, role, permissions = [] } = user; | |
| // Map notification types to preference categories and required permissions | |
| let category = 'general'; | |
| if (type && typeof type === 'string') { | |
| if (type.startsWith('order_')) { | |
| category = 'orderUpdates'; | |
| } else if (type === 'newsletter') { | |
| category = 'newsletters'; | |
| } else if (type === 'promo_code') { | |
| category = 'promotions'; | |
| } else if (type.startsWith('product_')) { | |
| category = 'productUpdates'; | |
| } | |
| } | |
| // Admins always receive all store-critical notifications via app | |
| if (role === 'admin' && channel === 'app') { | |
| if (category === 'orderUpdates' || category === 'productUpdates') | |
| return true; | |
| } | |
| // Employees only receive notifications relevant to their permissions | |
| if (role === 'employee' && channel === 'app') { | |
| if (category === 'orderUpdates') | |
| return permissions.includes('manage_orders'); | |
| if (category === 'productUpdates') | |
| return permissions.includes('manage_products'); | |
| } | |
| // 1. Check if the specific channel is enabled for this category | |
| const categoryPref = notificationPreferences[category]; | |
| const isChannelEnabled = | |
| categoryPref && categoryPref[channel] !== undefined | |
| ? Boolean(categoryPref[channel]) | |
| : true; | |
| if (!isChannelEnabled) return false; | |
| // 2. For vendors, check specific vendorOrderVisibility for order updates | |
| if (role === 'vendor' && category === 'orderUpdates') { | |
| const visibility = notificationPreferences.vendorOrderVisibility || {}; | |
| // If a timestamp is provided, check if it falls within any blackout period | |
| if (timestamp) { | |
| const orderTime = new Date(timestamp).getTime(); | |
| // Check historical blackout periods | |
| const blackoutPeriods = visibility.blackoutPeriods || []; | |
| const isInHistoricalBlackout = blackoutPeriods.some((p) => { | |
| const start = new Date(p.start).getTime(); | |
| const end = new Date(p.end).getTime(); | |
| return orderTime >= start && orderTime <= end; | |
| }); | |
| if (isInHistoricalBlackout) return false; | |
| // Check current active blackout if visibility is OFF | |
| if (visibility[channel] === false && visibility.disabledAt) { | |
| const disabledAtTime = new Date(visibility.disabledAt).getTime(); | |
| if (orderTime >= disabledAtTime) return false; | |
| } | |
| } else if (visibility[channel] === false) { | |
| // Fallback to active state if no timestamp provided | |
| return false; | |
| } | |
| } | |
| return true; | |
| } catch (err) { | |
| // eslint-disable-next-line no-console | |
| console.error('Error checking user preference:', err); | |
| return true; | |
| } | |
| }; | |
| /** | |
| * Create a notification for a specific user and emit via socket | |
| */ | |
| const createNotification = async ({ | |
| userId, | |
| title, | |
| message, | |
| type = 'general', | |
| relatedId = null, | |
| relatedModel = null, | |
| priority = 'medium', | |
| metadata = {}, | |
| timestamp = null, | |
| }) => { | |
| try { | |
| // Check user preference for the specific channel | |
| const isEnabled = await checkUserPreference(userId, type, 'app', timestamp); | |
| if (!isEnabled) { | |
| return null; | |
| } | |
| const notification = await Notification.create({ | |
| user: userId, | |
| title, | |
| message, | |
| type, | |
| relatedId, | |
| relatedModel, | |
| priority, | |
| metadata, | |
| }); | |
| // Real-time socket emission to the specific user | |
| const io = getIO(); | |
| io.to(userId.toString()).emit('newNotification', notification); | |
| // Update the user's unread count | |
| await emitUnreadCount(userId); | |
| return notification; | |
| } catch (error) { | |
| // eslint-disable-next-line no-console | |
| console.error('Error creating notification:', error); | |
| // Don't throw to avoid breaking the main flow (e.g. order creation) | |
| return null; | |
| } | |
| }; | |
| /** | |
| * Broadcast an order update to all admin/vendor dashboards | |
| */ | |
| const emitOrderUpdate = (order, type) => { | |
| try { | |
| const io = getIO(); | |
| io.emit('orderUpdated', { | |
| orderId: order._id, | |
| type: type, | |
| status: order.orderStatus || null, | |
| }); | |
| } catch (error) { | |
| // eslint-disable-next-line no-console | |
| console.error('Error emitting order update:', error); | |
| } | |
| }; | |
| /** | |
| * Create notifications for multiple users and emit via socket | |
| */ | |
| const createBulkNotifications = async ( | |
| userIds, | |
| notificationData, | |
| timestamp = null, | |
| ) => { | |
| try { | |
| const { type } = notificationData; | |
| // Filter users based on their preferences for 'app' channel | |
| const User = require('../models/userModel'); | |
| // Important: Use lean() to get plain objects for easier filtering | |
| const usersWithPrefs = await User.find({ _id: { $in: userIds } }) | |
| .select('notificationPreferences role permissions') | |
| .lean(); | |
| const allowedUserIds = usersWithPrefs | |
| .filter((user) => { | |
| if (!user || !user.notificationPreferences) return true; | |
| const { notificationPreferences, role, permissions = [] } = user; | |
| let category = 'general'; | |
| if (type && typeof type === 'string') { | |
| if (type.startsWith('order_')) { | |
| category = 'orderUpdates'; | |
| } else if (type === 'newsletter') { | |
| category = 'newsletters'; | |
| } else if (type === 'promo_code') { | |
| category = 'promotions'; | |
| } else if (type.startsWith('product_')) { | |
| category = 'productUpdates'; | |
| } | |
| } | |
| // Admins always receive all store-critical notifications | |
| if (role === 'admin') { | |
| if (category === 'orderUpdates' || category === 'productUpdates') | |
| return true; | |
| } | |
| // Employees only receive notifications matching their permissions | |
| if (role === 'employee') { | |
| if (category === 'orderUpdates') | |
| return permissions.includes('manage_orders'); | |
| if (category === 'productUpdates') | |
| return permissions.includes('manage_products'); | |
| } | |
| // 1. Check if the specific channel ('app') is enabled for this category | |
| const categoryPref = notificationPreferences[category]; | |
| const isChannelEnabled = | |
| categoryPref && categoryPref.app !== undefined | |
| ? Boolean(categoryPref.app) | |
| : true; | |
| if (!isChannelEnabled) return false; | |
| // Note: vendorOrderVisibility only controls order visibility in the table, | |
| // not notifications. Notifications are controlled by orderUpdates.app above. | |
| // So we don't check vendorOrderVisibility here for notifications. | |
| return true; | |
| }) | |
| .map((user) => user._id); | |
| if (allowedUserIds.length === 0) return []; | |
| const notifications = allowedUserIds.map((userId) => ({ | |
| user: userId, | |
| ...notificationData, | |
| })); | |
| const result = await Notification.insertMany(notifications); | |
| // Real-time socket emission for each user | |
| const io = getIO(); | |
| result.forEach((notif) => { | |
| io.to(notif.user.toString()).emit('newNotification', notif); | |
| emitUnreadCount(notif.user); // Don't await in loop | |
| }); | |
| // Emit once to the admin global dashboard using the first notification as a sample | |
| if (result.length > 0) { | |
| io.emit('newNotificationAdmin', result[0]); | |
| } | |
| return result; | |
| } catch (error) { | |
| // eslint-disable-next-line no-console | |
| console.error('Error creating bulk notifications:', error); | |
| return []; | |
| } | |
| }; | |
| /** | |
| * Create order-related notifications (Localized in Arabic) | |
| */ | |
| const notifyOrderCreated = async (order, userId) => { | |
| const orderIdShort = order._id.toString().slice(-6).toUpperCase(); | |
| return createNotification({ | |
| userId, | |
| title: 'تم استلام طلبك بنجاح', | |
| message: `تم استلام طلبك رقم #${orderIdShort} بنجاح. سنقوم بمراجعته وتجهيزه في أقرب وقت.`, | |
| type: 'order_created', | |
| relatedId: order._id, | |
| relatedModel: 'Order', | |
| priority: 'high', | |
| metadata: { | |
| orderId: order._id, | |
| totalAmount: order.totalAmount, | |
| }, | |
| timestamp: order.createdAt, | |
| }); | |
| }; | |
| const notifyOrderProcessing = async (order, userId) => { | |
| const orderIdShort = order._id.toString().slice(-6).toUpperCase(); | |
| return createNotification({ | |
| userId, | |
| title: 'تم تأكيد طلبك', | |
| message: `تم تأكيد طلبك رقم #${orderIdShort} وهو الآن قيد التجهيز.`, | |
| type: 'order_confirmed', | |
| relatedId: order._id, | |
| relatedModel: 'Order', | |
| priority: 'high', | |
| metadata: { | |
| orderId: order._id, | |
| }, | |
| timestamp: order.createdAt, | |
| }); | |
| }; | |
| const notifyOrderShipped = async (order, userId) => { | |
| const orderIdShort = order._id.toString().slice(-6).toUpperCase(); | |
| return createNotification({ | |
| userId, | |
| title: 'تم شحن الطلب', | |
| message: `طلبك رقم #${orderIdShort} في الطريق إليك الآن عبر شركة الشحن.`, | |
| type: 'order_shipped', | |
| relatedId: order._id, | |
| relatedModel: 'Order', | |
| priority: 'high', | |
| metadata: { | |
| orderId: order._id, | |
| trackingNumber: order.trackingNumber || null, | |
| }, | |
| timestamp: order.createdAt, | |
| }); | |
| }; | |
| const notifyOrderCompleted = async (order, userId) => { | |
| const orderIdShort = order._id.toString().slice(-6).toUpperCase(); | |
| return createNotification({ | |
| userId, | |
| title: 'تم توصيل الطلب', | |
| message: `تم توصيل طلبك رقم #${orderIdShort} بنجاح. شكراً لتسوقك معنا!`, | |
| type: 'order_delivered', | |
| relatedId: order._id, | |
| relatedModel: 'Order', | |
| priority: 'high', | |
| metadata: { | |
| orderId: order._id, | |
| }, | |
| timestamp: order.createdAt, | |
| }); | |
| }; | |
| const notifyOrderCancelled = async (order, userId, reason = '') => { | |
| const orderIdShort = order._id.toString().slice(-6).toUpperCase(); | |
| return createNotification({ | |
| userId, | |
| title: 'تم إلغاء الطلب', | |
| message: `تم إلغاء طلبك رقم #${orderIdShort}. ${reason ? `السبب: ${reason}` : ''}`, | |
| type: 'order_cancelled', | |
| relatedId: order._id, | |
| relatedModel: 'Order', | |
| priority: 'high', | |
| metadata: { | |
| orderId: order._id, | |
| reason, | |
| }, | |
| timestamp: order.createdAt, | |
| }); | |
| }; | |
| /** | |
| * Notify vendor about new order | |
| */ | |
| const notifyVendorNewOrder = async (order, providerId) => { | |
| try { | |
| const User = require('../models/userModel'); | |
| const vendorUsers = await User.find({ | |
| provider: providerId, | |
| role: 'vendor', | |
| isActive: true, | |
| }).select('_id'); | |
| if (vendorUsers && vendorUsers.length > 0) { | |
| const userIds = vendorUsers.map((u) => u._id); | |
| const orderIdShort = order._id.toString().slice(-6).toUpperCase(); | |
| return createBulkNotifications( | |
| userIds, | |
| { | |
| title: 'طلب جديد للمتجر', | |
| message: `لقد تلقيت طلباً جديداً برقم #${orderIdShort}. يرجى البدء في تجهيز المنتجات.`, | |
| type: 'order_created', | |
| relatedId: order._id, | |
| relatedModel: 'Order', | |
| priority: 'high', | |
| metadata: { | |
| orderId: order._id, | |
| totalPrice: order.totalPrice, | |
| }, | |
| }, | |
| order.createdAt, | |
| ); | |
| } | |
| return null; | |
| } catch (error) { | |
| console.error('Error in notifyVendorNewOrder:', error); | |
| return null; | |
| } | |
| }; | |
| /** | |
| * Product-related notifications | |
| */ | |
| const notifyProductOutOfStock = async (product, providerId) => { | |
| try { | |
| const User = require('../models/userModel'); | |
| const vendorUsers = await User.find({ | |
| provider: providerId, | |
| role: 'vendor', | |
| isActive: true, | |
| }).select('_id'); | |
| if (vendorUsers && vendorUsers.length > 0) { | |
| const userIds = vendorUsers.map((u) => u._id); | |
| return createBulkNotifications(userIds, { | |
| title: 'منتج نفد من المخزون', | |
| message: `نعتذر، ولكن المنتج "${product.nameAr || product.nameEn}" غير متوفر حالياً ونفد من المخزون.`, | |
| type: 'product_out_of_stock', | |
| relatedId: product._id, | |
| relatedModel: 'Product', | |
| priority: 'medium', | |
| metadata: { | |
| productId: product._id, | |
| productName: product.nameAr || product.nameEn, | |
| }, | |
| }); | |
| } | |
| return null; | |
| } catch (error) { | |
| console.error('Error in notifyProductOutOfStock:', error); | |
| return null; | |
| } | |
| }; | |
| const notifyProductBackInStock = async (product, userIds) => | |
| createBulkNotifications(userIds, { | |
| title: 'المنتج متوفر الآن!', | |
| message: `خبر سعيد! المنتج "${ | |
| product.nameAr || product.nameEn | |
| }" الذي اشتريته سابقاً متوفر الآن مرة أخرى.`, | |
| type: 'product_back_in_stock', | |
| relatedId: product._id, | |
| relatedModel: 'Product', | |
| priority: 'medium', | |
| metadata: { | |
| productId: product._id, | |
| productName: product.nameAr || product.nameEn, | |
| }, | |
| }); | |
| /** | |
| * Notify all users who have previously purchased this product that it's back in stock | |
| */ | |
| const notifyPastBuyersProductBackInStock = async (product) => { | |
| try { | |
| // eslint-disable-next-line global-require | |
| const Order = require('../models/orderModel'); | |
| // Find all users who have this product in their orders | |
| const orders = await Order.find({ | |
| 'items.product': product._id, | |
| user: { $exists: true, $ne: null }, | |
| }).distinct('user'); | |
| if (orders && orders.length > 0) { | |
| return notifyProductBackInStock(product, orders); | |
| } | |
| return null; | |
| } catch (error) { | |
| // eslint-disable-next-line no-console | |
| console.error('Error in notifyPastBuyersProductBackInStock:', error); | |
| return null; | |
| } | |
| }; | |
| /** | |
| * Promo code notifications | |
| */ | |
| const notifyPromoCode = async (userId, promoCode, discount) => | |
| createNotification({ | |
| userId, | |
| title: 'كود خصم جديد متاح!', | |
| message: `استخدم كود "${promoCode}" للحصول على خصم ${discount}% على مشترياتك القادمة!`, | |
| type: 'promo_code', | |
| priority: 'medium', | |
| metadata: { | |
| promoCode, | |
| discount, | |
| }, | |
| }); | |
| /** | |
| * Review-related notifications | |
| */ | |
| const notifyReviewResponse = async (review, userId) => | |
| createNotification({ | |
| userId, | |
| title: 'تم الرد على تقييمك', | |
| message: 'لقد قام المتجر بالرد على التقييم الذي تركته للمنتج.', | |
| type: 'review_response', | |
| relatedId: review._id, | |
| relatedModel: 'Review', | |
| priority: 'low', | |
| metadata: { | |
| reviewId: review._id, | |
| }, | |
| }); | |
| /** | |
| * Auto-delete old notifications to keep database clean | |
| * Rules: | |
| * - Unread: Delete after 15 days | |
| * - Read: Delete after 7 days | |
| */ | |
| const deleteOldNotifications = async () => { | |
| try { | |
| const now = new Date(); | |
| const fifteenDaysAgo = new Date(now.getTime() - 15 * 24 * 60 * 60 * 1000); | |
| const sevenDaysAgo = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000); | |
| const result = await Notification.deleteMany({ | |
| $or: [ | |
| // Unread and older than 15 days | |
| { isRead: false, createdAt: { $lt: fifteenDaysAgo } }, | |
| // Read and older than 7 days (using updatedAt as proxy for readAt date) | |
| { isRead: true, updatedAt: { $lt: sevenDaysAgo } }, | |
| ], | |
| }); | |
| return result; | |
| } catch (error) { | |
| // eslint-disable-next-line no-console | |
| console.error('Error in periodic notification cleanup:', error); | |
| return null; | |
| } | |
| }; | |
| /** | |
| * Notify all admins and employees about product out of stock | |
| */ | |
| const notifyAdminsProductOutOfStock = async (product) => { | |
| try { | |
| const User = require('../models/userModel'); | |
| const staff = await User.find({ | |
| role: { $in: ['admin', 'employee'] }, | |
| isActive: true, | |
| }).select('_id role permissions'); | |
| // Admins always get notified; employees only if they have manage_products permission | |
| const eligible = staff.filter( | |
| (u) => | |
| u.role === 'admin' || | |
| (u.permissions && u.permissions.includes('manage_products')), | |
| ); | |
| if (eligible.length > 0) { | |
| const eligibleIds = eligible.map((a) => a._id); | |
| return createBulkNotifications(eligibleIds, { | |
| title: 'منتج نفد من المخزون', | |
| message: `تنبيه: المنتج "${product.nameAr || product.nameEn}" نفد من المخزون.`, | |
| type: 'product_out_of_stock', | |
| relatedId: product._id, | |
| relatedModel: 'Product', | |
| priority: 'high', | |
| metadata: { | |
| productId: product._id, | |
| productName: product.nameAr || product.nameEn, | |
| }, | |
| }); | |
| } | |
| return null; | |
| } catch (error) { | |
| console.error('Error in notifyAdminsProductOutOfStock:', error); | |
| return null; | |
| } | |
| }; | |
| /** | |
| * Notify all admins and employees about a new order | |
| */ | |
| const notifyAdminsNewOrder = async (order) => { | |
| try { | |
| const User = require('../models/userModel'); | |
| const staff = await User.find({ | |
| role: { $in: ['admin', 'employee'] }, | |
| isActive: true, | |
| }).select('_id role permissions'); | |
| // Admins always get notified; employees only if they have manage_orders permission | |
| const eligible = staff.filter( | |
| (u) => | |
| u.role === 'admin' || | |
| (u.permissions && u.permissions.includes('manage_orders')), | |
| ); | |
| if (eligible.length > 0) { | |
| const eligibleIds = eligible.map((a) => a._id); | |
| const orderIdShort = order._id.toString().slice(-6).toUpperCase(); | |
| return createBulkNotifications( | |
| eligibleIds, | |
| { | |
| title: 'طلب جديد في المتجر', | |
| message: `لقد تم استلام طلب جديد رقم #${orderIdShort} بقيمة ${order.totalPrice} ج.م`, | |
| type: 'order_created', | |
| relatedId: order._id, | |
| relatedModel: 'Order', | |
| priority: 'high', | |
| metadata: { | |
| orderId: order._id, | |
| totalPrice: order.totalPrice, | |
| }, | |
| }, | |
| order.createdAt, | |
| ); | |
| } | |
| return null; | |
| } catch (error) { | |
| console.error('Error in notifyAdminsNewOrder:', error); | |
| return null; | |
| } | |
| }; | |
| /** | |
| * Notify users who explicitly subscribed to be notified when this product is back in stock. | |
| * Marks subscriptions as notified so they don't receive duplicate notifications. | |
| */ | |
| const notifyStockSubscribers = async (product) => { | |
| try { | |
| const StockSubscription = require('../models/stockSubscriptionModel'); | |
| // Find all pending (not yet notified) subscriptions for this product | |
| const subscriptions = await StockSubscription.find({ | |
| product: product._id, | |
| notifiedAt: null, | |
| }); | |
| if (!subscriptions || subscriptions.length === 0) return null; | |
| const userIds = subscriptions.map((s) => s.user); | |
| const result = await createBulkNotifications(userIds, { | |
| title: 'المنتج متوفر الآن! 🎉', | |
| message: `خبر سعيد! المنتج "${product.nameAr || product.nameEn}" الذي طلبت إشعاره متوفر الآن. لا تفوّت الفرصة!`, | |
| type: 'product_back_in_stock', | |
| relatedId: product._id, | |
| relatedModel: 'Product', | |
| priority: 'high', | |
| metadata: { | |
| productId: product._id, | |
| productName: product.nameAr || product.nameEn, | |
| productSlug: product.slug, | |
| }, | |
| }); | |
| // Mark all subscriptions as notified so they don't get notified again | |
| await StockSubscription.updateMany( | |
| { product: product._id, notifiedAt: null }, | |
| { notifiedAt: new Date() }, | |
| ); | |
| return result; | |
| } catch (error) { | |
| // eslint-disable-next-line no-console | |
| console.error('Error in notifyStockSubscribers:', error); | |
| return null; | |
| } | |
| }; | |
| module.exports = { | |
| createNotification, | |
| createBulkNotifications, | |
| notifyOrderCreated, | |
| notifyOrderProcessing, | |
| notifyOrderShipped, | |
| notifyOrderCompleted, | |
| notifyOrderCancelled, | |
| notifyVendorNewOrder, | |
| notifyProductOutOfStock, | |
| notifyProductBackInStock, | |
| notifyPastBuyersProductBackInStock, | |
| notifyStockSubscribers, | |
| notifyPromoCode, | |
| notifyReviewResponse, | |
| deleteOldNotifications, | |
| emitOrderUpdate, | |
| notifyAdminsProductOutOfStock, | |
| notifyAdminsNewOrder, | |
| }; | |