Initial commit: teaching institution management API.
Express/MongoDB backend with JWT auth, RBAC, S3 uploads, notifications, and scheduled jobs.
This commit is contained in:
@@ -0,0 +1,9 @@
|
||||
// /events/eventEmitter.js
|
||||
|
||||
const EventEmitter = require('events');
|
||||
|
||||
class DomainEventEmitter extends EventEmitter {}
|
||||
|
||||
const eventEmitter = new DomainEventEmitter();
|
||||
|
||||
module.exports = eventEmitter;
|
||||
@@ -0,0 +1,154 @@
|
||||
// /events/eventListeners.js
|
||||
|
||||
const eventEmitter = require('./eventEmitter');
|
||||
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 logger = require('../utils/logger');
|
||||
const { sendEmail } = require('../utils/senders/emailSender');
|
||||
const { sendSMS } = require('../utils/senders/smsSender');
|
||||
const { sendBaleMessage } = require('../utils/senders/baleBotSender');
|
||||
|
||||
const safeEventListener = (handler) => {
|
||||
return async (payload) => {
|
||||
try {
|
||||
await handler(payload);
|
||||
} catch (error) {
|
||||
logger.error(`[EventListener Error] Handler failure: ${error.message}`, { stack: error.stack });
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
const recordEventLog = async (eventName, payload, actor = null) => {
|
||||
try {
|
||||
await EventLog.create({
|
||||
eventName,
|
||||
payload,
|
||||
actor: actor || payload?.actorId || payload?.userId || null
|
||||
});
|
||||
} catch (err) {
|
||||
logger.error(`[EventLog Error] Failed to write audit log: ${err.message}`);
|
||||
}
|
||||
};
|
||||
|
||||
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;
|
||||
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;
|
||||
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;
|
||||
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
|
||||
});
|
||||
}
|
||||
}));
|
||||
|
||||
// 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(eventName, safeEventListener(async (payload) => {
|
||||
await recordEventLog(eventName, payload);
|
||||
}));
|
||||
}
|
||||
});
|
||||
|
||||
logger.info('Domain event listeners successfully registered.');
|
||||
};
|
||||
|
||||
module.exports = registerEventListeners;
|
||||
@@ -0,0 +1,25 @@
|
||||
// /events/eventLogModel.js
|
||||
|
||||
const mongoose = require('mongoose');
|
||||
|
||||
const eventLogSchema = new mongoose.Schema({
|
||||
eventName: {
|
||||
type: String,
|
||||
required: true,
|
||||
index: true
|
||||
},
|
||||
payload: {
|
||||
type: mongoose.Schema.Types.Mixed
|
||||
},
|
||||
actor: {
|
||||
type: mongoose.Schema.Types.ObjectId,
|
||||
ref: 'User',
|
||||
default: null
|
||||
},
|
||||
createdAt: {
|
||||
type: Date,
|
||||
default: Date.now
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = mongoose.model('EventLog', eventLogSchema);
|
||||
Reference in New Issue
Block a user