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, };