samoulla-backend / utils /notificationService.js
Samoulla Sync Bot
Auto-deploy Samoulla Backend: b68e45770de26ed39feb4b1c0925e5345eb3a61d
634b9bb
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,
};