Add sessionHolding template, unique 8-digit codes, reason slot for sessionCancelled, and disable unused notifications

This commit is contained in:
2026-08-21 10:44:51 +03:30
parent 5328f36f06
commit 091e519280
28 changed files with 2180 additions and 280 deletions
+110 -92
View File
@@ -5,11 +5,11 @@ const EVENT_NAMES = require('../constants/eventNames');
const EventLog = require('./eventLogModel');
const Notification = require('../components/notifications/notificationModel');
const User = require('../components/users/userModel');
const Course = require('../components/courses/courseModel');
const Session = require('../components/sessions/sessionModel');
const logger = require('../utils/logger');
const { sendEmail } = require('../utils/senders/emailSender');
const { sendSMS } = require('../utils/senders/smsSender');
const { sendBaleMessage } = require('../utils/senders/baleBotSender');
const { notifyAction } = require('../utils/actionNotify');
const { sendSessionCancelledSms, sendSessionHoldingSms } = require('../utils/senders/smsMessages');
const { resolveNotifyFlags } = require('../utils/notifyResolver');
const safeEventListener = (handler) => {
return async (payload) => {
@@ -33,115 +33,133 @@ const recordEventLog = async (eventName, payload, actor = null) => {
}
};
const dispatchNotification = async ({ userId, preferredChannel, subject, body, relatedEvent }) => {
try {
const user = await User.findById(userId);
if (!user) return;
const channelMap = {
Email: 'email',
SMS: 'sms',
Bale: 'baleBot',
WhatsApp: 'sms',
Telegram: 'baleBot'
};
const preferred = Array.isArray(user.preferredMessenger)
? user.preferredMessenger[0]
: user.preferredMessenger;
const channel = channelMap[preferred || preferredChannel] || 'sms';
const notification = await Notification.create({
user: userId,
channel,
subject: subject || 'Institution Notification',
body,
status: 'pending',
relatedEvent
});
let sendResult;
try {
if (channel === 'email' && user.email) {
sendResult = await sendEmail({ to: user.email, subject, body });
} else if (channel === 'baleBot') {
sendResult = await sendBaleMessage({ chatId: user.phoneNumber, body });
} else {
sendResult = await sendSMS({ phoneNumber: user.phoneNumber, body });
}
notification.status = 'sent';
notification.sentAt = new Date();
await notification.save();
eventEmitter.emit(EVENT_NAMES.NOTIFICATION_SENT, { notificationId: notification._id });
} catch (sendErr) {
notification.status = 'failed';
notification.lastError = sendErr.message;
notification.retryCount = 1;
await notification.save();
eventEmitter.emit(EVENT_NAMES.NOTIFICATION_FAILED, { notificationId: notification._id, error: sendErr.message });
}
} catch (error) {
logger.error(`[DispatchNotification Error]: ${error.message}`);
}
};
const registerEventListeners = () => {
// Listener for USER_ENROLLED
eventEmitter.on(EVENT_NAMES.USER_ENROLLED, safeEventListener(async (payload) => {
const { userId, courseId, actorId } = payload;
const { actorId } = payload;
await recordEventLog(EVENT_NAMES.USER_ENROLLED, payload, actorId);
const course = await Course.findById(courseId);
const courseTitle = course ? course.title : 'Course';
await dispatchNotification({
userId,
subject: 'Course Enrollment Confirmation',
body: `You have been successfully enrolled in ${courseTitle}.`,
relatedEvent: EVENT_NAMES.USER_ENROLLED
});
}));
// Listener for PAYMENT_STATUS_CHANGED
eventEmitter.on(EVENT_NAMES.PAYMENT_STATUS_CHANGED, safeEventListener(async (payload) => {
const { paymentId, userId, newStatus, actorId } = payload;
const { actorId } = payload;
await recordEventLog(EVENT_NAMES.PAYMENT_STATUS_CHANGED, payload, actorId);
await dispatchNotification({
userId,
subject: 'Payment Status Update',
body: `Your payment status has been updated to: ${newStatus}.`,
relatedEvent: EVENT_NAMES.PAYMENT_STATUS_CHANGED
});
}));
// Listener for COURSE_PRICE_CHANGED
eventEmitter.on(EVENT_NAMES.COURSE_PRICE_CHANGED, safeEventListener(async (payload) => {
const { courseId, oldPrice, newPrice, actorId } = payload;
await recordEventLog(EVENT_NAMES.COURSE_PRICE_CHANGED, payload, actorId);
logger.info(`[Event] Course ${courseId} price changed from ${oldPrice} to ${newPrice}`);
}));
// Listener for SESSION_CANCELLED
eventEmitter.on(EVENT_NAMES.SESSION_CANCELLED, safeEventListener(async (payload) => {
const { sessionId, courseId, topic, actorId } = payload;
const { sessionId, courseId, topic, reason, actorId } = payload;
await recordEventLog(EVENT_NAMES.SESSION_CANCELLED, payload, actorId);
const enrolledUsers = await User.find({ courses: courseId });
for (const student of enrolledUsers) {
await dispatchNotification({
userId: student._id,
subject: 'Session Cancellation Notice',
body: `The session for topic "${topic || 'Upcoming Class'}" has been cancelled.`,
relatedEvent: EVENT_NAMES.SESSION_CANCELLED
});
const notify = await resolveNotifyFlags({}, 'sessionCancelled');
if (!notify.sms && !notify.email && !notify.bot) return;
const session = sessionId
? await Session.findById(sessionId).populate({ path: 'class', select: 'name uniqueCode students' }).lean()
: null;
const classDoc = session?.class;
const studentIds = classDoc?.students?.length
? classDoc.students
: (await User.find({ courses: courseId }).select('_id')).map((u) => u._id);
if (!studentIds.length) return;
const sessionDate = session?.day
? new Date(session.day).toLocaleDateString('fa-IR')
: '';
const className = classDoc?.name || 'کلاس';
const classCode = classDoc?.uniqueCode || '';
const users = await User.find({ _id: { $in: studentIds } }).select('name phoneNumber email').lean();
for (const student of users) {
if (!student.phoneNumber) continue;
try {
await notifyAction({
actionKey: 'sessionCancelled',
userId: student._id,
phoneNumber: student.phoneNumber,
email: student.email,
subject: 'لغو جلسه',
body: `جلسه «${topic || className}» لغو شد.`,
smsHandler: () => sendSessionCancelledSms(student.phoneNumber, {
fullName: student.name || '',
className,
topic: topic || className,
sessionDate,
reason: reason || 'اعلام آموزشگاه',
classCode
}, student._id)
});
} catch (err) {
logger.error(`[SESSION_CANCELLED] Notification failed for user ${student._id}: ${err.message}`);
}
}
}));
// Generic audit logger for all other events
Object.values(EVENT_NAMES).forEach(eventName => {
if (![EVENT_NAMES.USER_ENROLLED, EVENT_NAMES.PAYMENT_STATUS_CHANGED, EVENT_NAMES.COURSE_PRICE_CHANGED, EVENT_NAMES.SESSION_CANCELLED].includes(eventName)) {
eventEmitter.on(EVENT_NAMES.SESSION_HOLDING, safeEventListener(async (payload) => {
const { sessionId, courseId, topic, classTime, actorId } = payload;
await recordEventLog(EVENT_NAMES.SESSION_HOLDING, payload, actorId);
const notify = await resolveNotifyFlags({}, 'sessionHolding');
if (!notify.sms && !notify.email && !notify.bot) return;
const session = sessionId
? await Session.findById(sessionId).populate({ path: 'class', select: 'name uniqueCode students startTime place' }).populate('course', 'title').lean()
: null;
const classDoc = session?.class;
const studentIds = classDoc?.students?.length
? classDoc.students
: (await User.find({ courses: courseId }).select('_id')).map((u) => u._id);
if (!studentIds.length) return;
const sessionDate = session?.day
? new Date(session.day).toLocaleDateString('fa-IR')
: '';
const className = classDoc?.name || session?.course?.title || 'کلاس';
const classCode = classDoc?.uniqueCode || '';
const timeLabel = classTime || session?.startTime || '';
const topicLabel = topic || session?.topic || className;
const users = await User.find({ _id: { $in: studentIds } }).select('name phoneNumber email').lean();
for (const student of users) {
if (!student.phoneNumber) continue;
try {
await notifyAction({
actionKey: 'sessionHolding',
userId: student._id,
phoneNumber: student.phoneNumber,
email: student.email,
subject: 'برگزاری جلسه طبق برنامه',
body: `جلسه «${topicLabel}» کلاس ${className} در تاریخ ${sessionDate} و ساعت ${timeLabel} طبق برنامه برگزار خواهد شد.`,
smsHandler: () => sendSessionHoldingSms(student.phoneNumber, {
fullName: student.name || '',
className,
topic: topicLabel,
sessionDate,
classTime: timeLabel,
courseName: session?.course?.title || className,
classCode,
place: session?.place || '-'
}, student._id)
});
} catch (err) {
logger.error(`[SESSION_HOLDING] Notification failed for user ${student._id}: ${err.message}`);
}
}
}));
Object.values(EVENT_NAMES).forEach((eventName) => {
if (![
EVENT_NAMES.USER_ENROLLED,
EVENT_NAMES.PAYMENT_STATUS_CHANGED,
EVENT_NAMES.COURSE_PRICE_CHANGED,
EVENT_NAMES.SESSION_CANCELLED,
EVENT_NAMES.SESSION_HOLDING
].includes(eventName)) {
eventEmitter.on(eventName, safeEventListener(async (payload) => {
await recordEventLog(eventName, payload);
}));