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
@@ -1,6 +1,7 @@
// /components/certificates/certificateModel.js
const mongoose = require('mongoose');
const uniqueCodePlugin = require('../../utils/mongooseUniqueCodePlugin');
const certificateSchema = new mongoose.Schema({
user: {
@@ -66,4 +67,6 @@ const certificateSchema = new mongoose.Schema({
timestamps: true
});
certificateSchema.plugin(uniqueCodePlugin);
module.exports = mongoose.model('Certificate', certificateSchema);
@@ -8,6 +8,9 @@ const config = require('../../config/config');
const { commitTempFile, generatePresignedUrl, deleteFromBucket } = require('../../utils/s3Client');
const eventEmitter = require('../../events/eventEmitter');
const EVENT_NAMES = require('../../constants/eventNames');
const { notifyAction } = require('../../utils/actionNotify');
const { sendCertificateIssuedSms } = require('../../utils/senders/smsMessages');
const Course = require('../courses/courseModel');
const { parsePaginationAndSort, buildFilterQuery, calculateMeta } = require('../../utils/pagination');
const BUCKET = 'certificates';
@@ -54,6 +57,30 @@ const createCertificate = async (data) => {
eventEmitter.emit(EVENT_NAMES.CERTIFICATE_ISSUED, { certificateId: certificate._id, userId: user._id });
try {
let courseName = '';
if (certificate.course) {
const course = await Course.findById(certificate.course).select('title').lean();
courseName = course?.title || '';
}
await notifyAction({
actionKey: 'certificateIssued',
userId: user._id,
phoneNumber: user.phoneNumber,
email: user.email,
subject: 'صدور گواهینامه',
body: `گواهینامه «${certificate.title}» با کد ${certificate.uniqueCode || ''} صادر شد.`,
smsHandler: () => sendCertificateIssuedSms(user.phoneNumber, {
fullName: user.name || '',
certificateTitle: certificate.title,
certificateCode: certificate.uniqueCode || '',
courseName
}, user._id)
});
} catch {
// Notification failure should not block certificate creation
}
return withAccessUrl(certificate);
};
+3
View File
@@ -2,6 +2,7 @@
'use strict';
const mongoose = require('mongoose');
const uniqueCodePlugin = require('../../utils/mongooseUniqueCodePlugin');
const classSchema = new mongoose.Schema({
name: {
@@ -92,6 +93,8 @@ const classSchema = new mongoose.Schema({
toObject: { virtuals: true }
});
classSchema.plugin(uniqueCodePlugin);
classSchema.virtual('finalTuitionFee').get(function () {
const tuition = this.tuitionFee || 0;
if (!this.hasDiscount) return tuition;
+20 -8
View File
@@ -8,7 +8,8 @@ const AppError = require('../../utils/AppError');
const { calculateMeta, escapeRegex, getSearchTerm } = require('../../utils/pagination');
const { sendClassRegisteredSms } = require('../../utils/senders/smsMessages');
const { buildClassScheduleContext, normalizeWeekdays, normalizeClockTime } = require('../../utils/classSchedule');
const { pickNotifyFlags } = require('../../utils/notifyFlags');
const { resolveNotifyFlags } = require('../../utils/notifyResolver');
const { notifyAction } = require('../../utils/actionNotify');
const logger = require('../../utils/logger');
const applyScheduleFields = (payload, body) => {
@@ -127,7 +128,7 @@ const registerUsers = async (classId, userIds, notifyInput = {}) => {
const cls = await Class.findById(classId).populate({ path: 'course', select: 'title' });
if (!cls) throw new AppError('CLASS_NOT_FOUND');
const notify = pickNotifyFlags(notifyInput);
const notify = await resolveNotifyFlags(notifyInput, 'classRegistered');
const toAdd = (userIds || []).filter(
(id) => !cls.students.map((s) => s.toString()).includes(id.toString())
);
@@ -138,24 +139,35 @@ const registerUsers = async (classId, userIds, notifyInput = {}) => {
cls.students.push(...toAdd);
await cls.save();
if (notify.sms) {
if (notify.sms || notify.email || notify.bot) {
const classLabel = cls.name || cls.course?.title || 'کلاس';
const sessions = await Session.find({ class: classId })
.select('day startTime endTime')
.sort({ day: 1 })
.lean();
const schedule = buildClassScheduleContext(cls, sessions);
const users = await User.find({ _id: { $in: toAdd } }).select('phoneNumber').lean();
const users = await User.find({ _id: { $in: toAdd } }).select('phoneNumber email name').lean();
await Promise.all(
users.map(async (user) => {
if (!user.phoneNumber) return;
try {
await sendClassRegisteredSms(user.phoneNumber, classLabel, user._id, {
courseName: cls.course?.title || classLabel,
...schedule
await notifyAction({
actionKey: 'classRegistered',
userId: user._id,
phoneNumber: user.phoneNumber,
email: user.email,
subject: 'ثبت‌نام در کلاس',
body: `ثبت‌نام شما در کلاس «${classLabel}» انجام شد. کد کلاس: ${cls.uniqueCode || ''}`,
smsHandler: () => sendClassRegisteredSms(user.phoneNumber, classLabel, user._id, {
fullName: user.name,
courseName: cls.course?.title || classLabel,
classCode: cls.uniqueCode || '',
...schedule
}),
requestSource: notifyInput
});
} catch (err) {
logger.error(`[registerUsers] SMS failed for ${user.phoneNumber}: ${err.message}`);
logger.error(`[registerUsers] Notification failed for ${user.phoneNumber}: ${err.message}`);
}
})
);
+3
View File
@@ -4,6 +4,7 @@
const mongoose = require('mongoose');
const { getPayableAmount, normalizeDiscount } = require('../../utils/paymentAmount');
const uniqueCodePlugin = require('../../utils/mongooseUniqueCodePlugin');
require('./transactionModel');
const paymentSchema = new mongoose.Schema({
@@ -51,6 +52,8 @@ const paymentSchema = new mongoose.Schema({
toObject: { virtuals: true }
});
paymentSchema.plugin(uniqueCodePlugin);
paymentSchema.virtual('transactions', {
ref: 'Transaction',
localField: '_id',
+128 -44
View File
@@ -11,10 +11,14 @@ const User = require('../users/userModel');
const Course = require('../courses/courseModel');
const Class = require('../classes/classModel');
const Session = require('../sessions/sessionModel');
const { sendInvoiceCreatedSms } = require('../../utils/senders/smsMessages');
const { buildClassScheduleContext } = require('../../utils/classSchedule');
const { parseImportDate } = require('../../utils/jalaliDate');
const { pickNotifyFlags, omitNotifyFields } = require('../../utils/notifyFlags');
const { omitNotifyFields } = require('../../utils/notifyFlags');
const { resolveNotifyFlags } = require('../../utils/notifyResolver');
const { notifyAction } = require('../../utils/actionNotify');
const {
sendInvoiceCreatedSms,
sendPaymentStatusChangedSms,
sendTransactionRecordedSms
} = require('../../utils/senders/smsMessages');
const {
getPayableAmount,
normalizeDiscount,
@@ -28,6 +32,98 @@ const logger = require('../../utils/logger');
const PAYMENT_METHODS = new Set(['online', 'card', 'cash']);
const PAYMENT_STATUS_LABELS = {
pending: 'در انتظار پرداخت',
partial: 'پرداخت جزئی',
paid: 'پرداخت‌شده',
overdue: 'معوق'
};
const formatPaymentContext = async (payment) => {
const user = await User.findById(payment.user).select('name phoneNumber email').lean();
let courseName = '';
let schedule = { classStartDate: '', classDays: '', courseTime: '' };
if (payment.course) {
const course = await Course.findById(payment.course).select('title').lean();
if (course?.title) courseName = course.title;
}
if (payment.classes?.length) {
const cls = await Class.findById(payment.classes[0]).populate('course', 'title').lean();
if (!courseName) {
courseName = cls?.course?.title || cls?.name || '';
}
if (cls) {
const sessions = await Session.find({ class: cls._id }).select('day startTime endTime').sort({ day: 1 }).lean();
schedule = buildClassScheduleContext(cls, sessions);
}
}
return {
user,
courseName: courseName || '-',
schedule,
invoiceCode: payment.uniqueCode || ''
};
};
const notifyPaymentStatusChanged = async (payment, newStatus, oldStatus) => {
try {
const notify = await resolveNotifyFlags({}, 'paymentStatusChanged');
if (!notify.sms && !notify.email && !notify.bot) return;
const { user, courseName, invoiceCode } = await formatPaymentContext(payment);
if (!user?.phoneNumber) return;
await notifyAction({
actionKey: 'paymentStatusChanged',
userId: user._id,
phoneNumber: user.phoneNumber,
email: user.email,
subject: 'تغییر وضعیت پرداخت',
body: `وضعیت صورتحساب ${invoiceCode} از «${PAYMENT_STATUS_LABELS[oldStatus] || oldStatus}» به «${PAYMENT_STATUS_LABELS[newStatus] || newStatus}» تغییر کرد.`,
smsHandler: () => sendPaymentStatusChangedSms(user.phoneNumber, {
fullName: user.name || '',
status: newStatus,
statusLabel: PAYMENT_STATUS_LABELS[newStatus] || newStatus,
amount: getPayableAmount(payment),
invoiceCode,
course: courseName
}, user._id)
});
} catch (err) {
logger.error(`[notifyPaymentStatusChanged] Failed for payment ${payment._id}: ${err.message}`);
}
};
const notifyTransactionRecorded = async (payment, transaction) => {
try {
const notify = await resolveNotifyFlags({}, 'transactionRecorded');
if (!notify.sms && !notify.email && !notify.bot) return;
const { user, invoiceCode } = await formatPaymentContext(payment);
if (!user?.phoneNumber) return;
await notifyAction({
actionKey: 'transactionRecorded',
userId: user._id,
phoneNumber: user.phoneNumber,
email: user.email,
subject: 'ثبت تراکنش',
body: `تراکنش ${transaction.uniqueCode || ''} به مبلغ ${transaction.amount} تومان ثبت شد.`,
smsHandler: () => sendTransactionRecordedSms(user.phoneNumber, {
fullName: user.name || '',
amount: transaction.amount,
invoiceCode,
transactionCode: transaction.uniqueCode || '',
receiptNumber: transaction.receiptNumber || ''
}, user._id)
});
} catch (err) {
logger.error(`[notifyTransactionRecorded] Failed for transaction ${transaction._id}: ${err.message}`);
}
};
const populatePayment = (query) => query
.populate({ path: 'user', select: 'name phoneNumber' })
.populate({ path: 'classes', select: 'name tuitionFee' })
@@ -65,7 +161,7 @@ const refreshPaymentTotals = async (payment) => {
return payment;
};
const emitPaymentStatusChangedIfNeeded = (payment, previousStatus, actorId = null) => {
const emitPaymentStatusChangedIfNeeded = async (payment, previousStatus, actorId = null) => {
if (payment.status === previousStatus) return;
eventEmitter.emit(EVENT_NAMES.PAYMENT_STATUS_CHANGED, {
paymentId: payment._id,
@@ -74,6 +170,7 @@ const emitPaymentStatusChangedIfNeeded = (payment, previousStatus, actorId = nul
newStatus: payment.status,
actorId
});
await notifyPaymentStatusChanged(payment, payment.status, previousStatus);
};
const reconcilePendingTransactions = async (payment, { adjustAmount = true } = {}) => {
@@ -197,7 +294,7 @@ const getPaymentById = async (id) => {
};
const createPayment = async (body, actorId = null) => {
const notify = pickNotifyFlags(body);
const notify = await resolveNotifyFlags(body, 'invoiceCreated');
const payload = omitNotifyFields(body);
const incomingTransactions = Array.isArray(payload.transactions) ? payload.transactions : null;
delete payload.transactions;
@@ -229,43 +326,29 @@ const createPayment = async (body, actorId = null) => {
});
}
if (notify.sms) {
if (notify.sms || notify.email || notify.bot) {
try {
const user = await User.findById(payment.user).select('name phoneNumber').lean();
const { user, courseName, schedule, invoiceCode } = await formatPaymentContext(payment);
if (user?.phoneNumber) {
let courseName = '';
let schedule = { classStartDate: '', classDays: '', courseTime: '' };
if (payment.course) {
const course = await Course.findById(payment.course).select('title').lean();
if (course?.title) courseName = course.title;
}
if (payment.classes && payment.classes.length > 0) {
const cls = await Class.findById(payment.classes[0]).populate('course', 'title').lean();
if (!courseName) {
if (cls?.course?.title) {
courseName = cls.course.title;
} else if (cls?.name) {
courseName = cls.name;
}
}
if (cls) {
const sessions = await Session.find({ class: cls._id })
.select('day startTime endTime')
.sort({ day: 1 })
.lean();
schedule = buildClassScheduleContext(cls, sessions);
}
}
await sendInvoiceCreatedSms(user.phoneNumber, {
fullName: user.name || '',
amount: getPayableAmount(payment),
course: courseName || '-',
...schedule
}, user._id);
await notifyAction({
actionKey: 'invoiceCreated',
userId: user._id,
phoneNumber: user.phoneNumber,
email: user.email,
subject: 'ایجاد صورتحساب',
body: `صورتحساب ${invoiceCode} به مبلغ ${getPayableAmount(payment)} تومان بابت «${courseName}» ایجاد شد.`,
smsHandler: () => sendInvoiceCreatedSms(user.phoneNumber, {
fullName: user.name || '',
amount: getPayableAmount(payment),
course: courseName,
invoiceCode,
...schedule
}, user._id),
requestSource: body
});
}
} catch (err) {
logger.error(`[createPayment] Invoice SMS failed for payment ${payment._id}: ${err.message}`);
logger.error(`[createPayment] Invoice notification failed for payment ${payment._id}: ${err.message}`);
}
}
@@ -296,7 +379,7 @@ const updatePayment = async (id, body, actorId = null) => {
await refreshPaymentTotals(payment);
}
emitPaymentStatusChangedIfNeeded(payment, previousStatus, actorId);
await emitPaymentStatusChangedIfNeeded(payment, previousStatus, actorId);
return getPaymentById(payment._id);
};
@@ -317,7 +400,7 @@ const addTransaction = async (paymentId, trxData, actorId = null) => {
const previousStatus = payment.status;
const payload = buildTransactionPayload(payment, { ...trxData, status: trxData.status || 'paid' }, actorId);
if (!payload.amount) throw new AppError('VALIDATION_FAILED', { amount: 'Amount is required' }, 'مبلغ تراکنش الزامی است.');
await Transaction.create(payload);
const transaction = await Transaction.create(payload);
await reconcilePendingTransactions(payment);
await refreshPaymentTotals(payment);
@@ -327,7 +410,8 @@ const addTransaction = async (paymentId, trxData, actorId = null) => {
userId: payment.user,
actorId
});
emitPaymentStatusChangedIfNeeded(payment, previousStatus, actorId);
await notifyTransactionRecorded(payment, transaction);
await emitPaymentStatusChangedIfNeeded(payment, previousStatus, actorId);
return getPaymentById(paymentId);
};
@@ -373,7 +457,7 @@ const updateTransaction = async (transactionId, body, actorId = null) => {
// Preserve admin-edited amounts; only reconcile balance when recording new payments.
await reconcilePendingTransactions(payment, { adjustAmount: false });
await refreshPaymentTotals(payment);
emitPaymentStatusChangedIfNeeded(payment, previousStatus, actorId);
await emitPaymentStatusChangedIfNeeded(payment, previousStatus, actorId);
return getPaymentById(payment._id);
};
@@ -394,7 +478,7 @@ const cancelTransaction = async (transactionId, actorId = null) => {
await trx.save();
await refreshPaymentTotals(payment);
emitPaymentStatusChangedIfNeeded(payment, previousStatus, actorId);
await emitPaymentStatusChangedIfNeeded(payment, previousStatus, actorId);
return getPaymentById(payment._id);
};
+3
View File
@@ -2,6 +2,7 @@
'use strict';
const mongoose = require('mongoose');
const uniqueCodePlugin = require('../../utils/mongooseUniqueCodePlugin');
const transactionSchema = new mongoose.Schema({
payment: {
@@ -43,6 +44,8 @@ const transactionSchema = new mongoose.Schema({
timestamps: true
});
transactionSchema.plugin(uniqueCodePlugin);
transactionSchema.index({ payment: 1, dueDate: 1 });
module.exports = mongoose.model('Transaction', transactionSchema);
@@ -1,6 +1,7 @@
'use strict';
const mongoose = require('mongoose');
const uniqueCodePlugin = require('../../utils/mongooseUniqueCodePlugin');
const pendingStudentSchema = new mongoose.Schema({
user: {
@@ -55,6 +56,8 @@ const pendingStudentSchema = new mongoose.Schema({
timestamps: true
});
pendingStudentSchema.plugin(uniqueCodePlugin);
pendingStudentSchema.index({ user: 1, class: 1, type: 1, status: 1 });
module.exports = mongoose.model('PendingStudent', pendingStudentSchema);
@@ -9,12 +9,13 @@ const bcrypt = require('bcryptjs');
const { calculateMeta, escapeRegex, getSearchTerm } = require('../../utils/pagination');
const { calculateRegistrationPricing } = require('../../utils/registrationPricing');
const { generateUsername, generateSimplePassword } = require('../../utils/credentials');
const { sendAccountCreatedSms } = require('../../utils/senders/smsMessages');
const { sendAccountCreatedSms, sendClassRequestApprovedSms, sendClassRequestRejectedSms, sendPendingRegistrationSms } = require('../../utils/senders/smsMessages');
const { notifyAction } = require('../../utils/actionNotify');
const { resolveNotifyFlags } = require('../../utils/notifyResolver');
const { mergeFullName, normalizeGender } = require('../../utils/userProfile');
const classService = require('../classes/classService');
const paymentService = require('../payments/paymentService');
const logger = require('../../utils/logger');
const { recordAndSend } = require('../../utils/senders/notificationRecorder');
const POPULATE_LIST = [
{ path: 'user', select: 'name phoneNumber nationalIdCode email username' },
@@ -148,7 +149,18 @@ const findOrCreateUser = async (body) => {
});
try {
await sendAccountCreatedSms(normalizedPhone, username, plainPassword, user._id);
const notify = await resolveNotifyFlags({}, 'accountCreated');
if (notify.sms || notify.email || notify.bot) {
await notifyAction({
actionKey: 'accountCreated',
userId: user._id,
phoneNumber: normalizedPhone,
email,
subject: 'ایجاد حساب کاربری',
body: `حساب کاربری شما ایجاد شد. نام کاربری: ${username} — کد کاربری: ${user.uniqueCode || ''}`,
smsHandler: () => sendAccountCreatedSms(normalizedPhone, username, plainPassword, user._id, user.uniqueCode)
});
}
} catch (err) {
logger.error(`[findOrCreateUser] Account SMS failed for ${normalizedPhone}: ${err.message}`);
}
@@ -214,6 +226,27 @@ const createPendingStudentAfterPayment = async (body) => {
paymentReference: paymentReference || undefined
});
try {
const notify = await resolveNotifyFlags({}, 'pendingRegistration');
if (notify.sms || notify.email || notify.bot) {
await notifyAction({
actionKey: 'pendingRegistration',
userId: user._id,
phoneNumber: user.phoneNumber,
email: user.email,
subject: 'دریافت درخواست ثبت‌نام',
body: `درخواست ثبت‌نام شما در «${cls.name}» دریافت شد. کد درخواست: ${pending.uniqueCode || ''}`,
smsHandler: () => sendPendingRegistrationSms(user.phoneNumber, {
fullName: user.name || '',
className: cls.name || '',
registrationCode: pending.uniqueCode || ''
}, user._id)
});
}
} catch (err) {
logger.error(`[createPendingStudentAfterPayment] Notification failed: ${err.message}`);
}
return PendingStudent.findById(pending._id).populate(POPULATE_LIST).lean();
};
@@ -262,26 +295,27 @@ const getPendingStudentById = async (id) => {
return pending;
};
const sendClassRequestApprovedSms = async (user, courseTitle) => {
const sendClassRequestApprovedNotification = async (user, courseTitle, classCode = '') => {
if (!user?.phoneNumber) return;
const body = `درخواست تشکیل کلاس جدید برای «${courseTitle}» تأیید شد. به‌زودی با شما هماهنگ می‌کنیم.`;
await recordAndSend({
await notifyAction({
actionKey: 'classRequestApproved',
userId: user._id,
channel: 'sms',
subject: 'تأیید درخواست کلاس',
body,
relatedEvent: 'class_request.approved',
sendFn: async () => {
logger.info(`[class_request.approved] SMS queued for ${user.phoneNumber}: ${body}`);
return { skipped: true, reason: 'template_pending' };
}
phoneNumber: user.phoneNumber,
email: user.email,
subject: 'تأیید درخواست تشکیل کلاس',
body: `درخواست تشکیل کلاس «${courseTitle}» تأیید شد.`,
smsHandler: () => sendClassRequestApprovedSms(user.phoneNumber, {
fullName: user.name || '',
courseName: courseTitle,
classCode
}, user._id)
});
};
const approvePendingStudent = async (id, actorId, body = {}) => {
const pending = await PendingStudent.findById(id)
.populate({ path: 'user', select: 'name phoneNumber' })
.populate({ path: 'class', select: 'name course', populate: { path: 'course', select: 'title' } })
.populate({ path: 'class', select: 'name course uniqueCode', populate: { path: 'course', select: 'title' } })
.populate({ path: 'course', select: 'title' });
if (!pending) throw new AppError('NOT_FOUND', null, 'درخواست در انتظار یافت نشد.');
@@ -321,7 +355,11 @@ const approvePendingStudent = async (id, actorId, body = {}) => {
}, actorId);
} else {
const courseTitle = pending.course?.title || pending.class?.course?.title || pending.class?.name || 'دوره';
await sendClassRequestApprovedSms(pending.user, courseTitle);
await sendClassRequestApprovedNotification(
pending.user,
courseTitle,
pending.class?.uniqueCode || ''
);
}
await PendingStudent.findByIdAndDelete(id);
@@ -330,7 +368,10 @@ const approvePendingStudent = async (id, actorId, body = {}) => {
};
const rejectPendingStudent = async (id, actorId, body = {}) => {
const pending = await PendingStudent.findById(id);
const pending = await PendingStudent.findById(id)
.populate({ path: 'user', select: 'name phoneNumber email' })
.populate({ path: 'class', select: 'name', populate: { path: 'course', select: 'title' } })
.populate({ path: 'course', select: 'title' });
if (!pending) throw new AppError('NOT_FOUND', null, 'درخواست در انتظار یافت نشد.');
if (!['pending_review', 'pending_payment'].includes(pending.status)) {
throw new AppError('VALIDATION_FAILED', null, 'این درخواست قابل رد کردن نیست.');
@@ -340,6 +381,26 @@ const rejectPendingStudent = async (id, actorId, body = {}) => {
logger.info(`[rejectPendingStudent] ${id} by ${actorId}: ${String(body.adminNotes).trim()}`);
}
try {
const courseTitle = pending.course?.title || pending.class?.course?.title || pending.class?.name || 'دوره';
await notifyAction({
actionKey: 'classRequestRejected',
userId: pending.user?._id,
phoneNumber: pending.user?.phoneNumber,
email: pending.user?.email,
subject: 'رد درخواست ثبت‌نام',
body: `درخواست شما برای «${courseTitle}» رد شد.`,
smsHandler: () => sendClassRequestRejectedSms(pending.user.phoneNumber, {
fullName: pending.user?.name || '',
courseName: courseTitle,
reason: body.adminNotes ? String(body.adminNotes).trim() : '—',
registrationCode: pending.uniqueCode || ''
}, pending.user?._id)
});
} catch (err) {
logger.error(`[rejectPendingStudent] Notification failed: ${err.message}`);
}
await PendingStudent.findByIdAndDelete(id);
return { rejected: true };
};
+251
View File
@@ -0,0 +1,251 @@
'use strict';
const { CHANNELS } = require('../../utils/messagingChannels');
const NOTIFICATION_CATEGORIES = [
{ key: 'users', label: 'کاربران' },
{ key: 'classes', label: 'کلاس‌ها و جلسات' },
{ key: 'payments', label: 'پرداخت‌ها' },
{ key: 'registrations', label: 'ثبت‌نام و درخواست‌ها' },
{ key: 'certificates', label: 'گواهینامه‌ها' }
];
const NOTIFICATION_ACTION_DEFS = [
{
key: 'accountCreated',
label: 'ایجاد حساب کاربری',
description: 'ارسال نام کاربری و رمز عبور پس از ایجاد حساب',
category: 'users',
relatedEvent: 'user.created',
smsTemplateKey: 'accountCreated',
defaultChannels: { sms: true, email: false, bot: false }
},
{
key: 'passwordReset',
label: 'بازنشانی رمز عبور',
description: 'ارسال رمز عبور جدید پس از بازنشانی توسط مدیر',
category: 'users',
relatedEvent: 'user.password_reset',
smsTemplateKey: 'passwordReset',
defaultChannels: { sms: true, email: false, bot: false }
},
{
key: 'classRegistered',
label: 'ثبت‌نام در کلاس',
description: 'اطلاع‌رسانی پس از ثبت‌نام کارآموز در کلاس',
category: 'classes',
relatedEvent: 'user.enrolled',
smsTemplateKey: 'classRegistered',
defaultChannels: { sms: true, email: false, bot: false }
},
{
key: 'classReminder',
label: 'یادآوری کلاس',
description: 'یادآوری خودکار حدود ۳۰ دقیقه قبل از شروع جلسه',
category: 'classes',
relatedEvent: 'session.reminder',
smsTemplateKey: 'classReminder',
defaultChannels: { sms: true, email: false, bot: false }
},
{
key: 'sessionCancelled',
label: 'لغو جلسه',
description: 'اطلاع‌رسانی لغو جلسه به کارآموزان ثبت‌نام‌شده',
category: 'classes',
relatedEvent: 'session.cancelled',
smsTemplateKey: 'sessionCancelled',
defaultChannels: { sms: true, email: true, bot: false }
},
{
key: 'sessionHolding',
label: 'برگزاری جلسه طبق برنامه',
description: 'اطلاع‌رسانی برگزاری جلسه طبق برنامه به کارآموزان',
category: 'classes',
relatedEvent: 'session.holding',
smsTemplateKey: 'sessionHolding',
defaultChannels: { sms: true, email: false, bot: false }
},
{
key: 'invoiceCreated',
label: 'ایجاد صورتحساب',
description: 'اطلاع‌رسانی صدور صورتحساب جدید',
category: 'payments',
relatedEvent: 'payment.created',
smsTemplateKey: 'invoiceCreated',
defaultChannels: { sms: true, email: false, bot: false }
},
{
key: 'paymentStatusChanged',
label: 'تغییر وضعیت پرداخت',
description: 'اطلاع‌رسانی تغییر وضعیت صورتحساب (پرداخت‌شده، معوق و …)',
category: 'payments',
relatedEvent: 'payment.status_changed',
smsTemplateKey: 'paymentStatusChanged',
defaultChannels: { sms: true, email: true, bot: false }
},
{
key: 'paymentReminder',
label: 'یادآوری سررسید پرداخت',
description: 'یادآوری خودکار پرداخت‌های نزدیک به سررسید',
category: 'payments',
relatedEvent: 'payment.reminder_due',
smsTemplateKey: 'paymentReminder',
defaultChannels: { sms: true, email: true, bot: false }
},
{
key: 'transactionRecorded',
label: 'ثبت تراکنش',
description: 'اطلاع‌رسانی ثبت یا تأیید تراکنش پرداخت',
category: 'payments',
relatedEvent: 'payment.transaction_added',
smsTemplateKey: 'transactionRecorded',
defaultChannels: { sms: true, email: false, bot: false }
},
{
key: 'pendingRegistration',
label: 'دریافت درخواست ثبت‌نام',
description: 'تأیید دریافت درخواست ثبت‌نام آنلاین توسط کارآموز',
category: 'registrations',
relatedEvent: 'pending.registration_received',
smsTemplateKey: 'pendingRegistration',
defaultChannels: { sms: false, email: false, bot: false }
},
{
key: 'classRequestApproved',
label: 'تأیید درخواست تشکیل کلاس',
description: 'اطلاع‌رسانی تأیید درخواست تشکیل کلاس جدید',
category: 'registrations',
relatedEvent: 'class_request.approved',
smsTemplateKey: 'classRequestApproved',
defaultChannels: { sms: false, email: false, bot: false }
},
{
key: 'classRequestRejected',
label: 'رد درخواست ثبت‌نام',
description: 'اطلاع‌رسانی رد درخواست ثبت‌نام یا تشکیل کلاس',
category: 'registrations',
relatedEvent: 'class_request.rejected',
smsTemplateKey: 'classRequestRejected',
defaultChannels: { sms: false, email: false, bot: false }
},
{
key: 'certificateIssued',
label: 'صدور گواهینامه',
description: 'اطلاع‌رسانی بارگذاری یا صدور گواهینامه',
category: 'certificates',
relatedEvent: 'certificate.issued',
smsTemplateKey: 'certificateIssued',
defaultChannels: { sms: true, email: true, bot: false }
}
];
const NOTIFICATION_ACTION_KEYS = NOTIFICATION_ACTION_DEFS.map((def) => def.key);
const defaultActionChannels = (def) => ({
sms: def?.defaultChannels?.sms !== false,
email: def?.defaultChannels?.email === true,
bot: def?.defaultChannels?.bot === true
});
const emptyNotificationSettingsMap = () => {
const map = {};
NOTIFICATION_ACTION_DEFS.forEach((def) => {
map[def.key] = defaultActionChannels(def);
});
return map;
};
const normalizeChannelValue = (value, fallback) => {
if (value === undefined || value === null || value === '') return fallback;
if (typeof value === 'boolean') return value;
if (typeof value === 'number') return value !== 0;
const normalized = String(value).trim().toLowerCase();
if (['false', '0', 'no', 'off'].includes(normalized)) return false;
if (['true', '1', 'yes', 'on'].includes(normalized)) return true;
return fallback;
};
const normalizeStoredActionEntry = (raw, def) => {
const defaults = defaultActionChannels(def);
if (raw == null || typeof raw !== 'object') {
return { ...defaults };
}
return {
sms: normalizeChannelValue(raw.sms, defaults.sms),
email: normalizeChannelValue(raw.email, defaults.email),
bot: normalizeChannelValue(raw.bot, defaults.bot)
};
};
const mergeNotificationSettings = (storedMap = {}, incomingMap = null) => {
const next = {};
for (const def of NOTIFICATION_ACTION_DEFS) {
const stored = normalizeStoredActionEntry(storedMap[def.key], def);
const incoming = incomingMap && Object.prototype.hasOwnProperty.call(incomingMap, def.key)
? incomingMap[def.key]
: null;
if (incoming && typeof incoming === 'object') {
next[def.key] = {
sms: incoming.sms !== undefined ? normalizeChannelValue(incoming.sms, stored.sms) : stored.sms,
email: incoming.email !== undefined ? normalizeChannelValue(incoming.email, stored.email) : stored.email,
bot: incoming.bot !== undefined ? normalizeChannelValue(incoming.bot, stored.bot) : stored.bot
};
} else {
next[def.key] = stored;
}
}
return next;
};
const toPublicNotificationSettings = (storedMap = {}) => {
return NOTIFICATION_CATEGORIES.map((category) => ({
key: category.key,
label: category.label,
actions: NOTIFICATION_ACTION_DEFS
.filter((def) => def.category === category.key)
.map((def) => {
const channels = normalizeStoredActionEntry(storedMap[def.key], def);
return {
key: def.key,
label: def.label,
description: def.description,
relatedEvent: def.relatedEvent,
smsTemplateKey: def.smsTemplateKey,
channels: {
sms: channels.sms,
email: channels.email,
bot: channels.bot
}
};
})
}));
};
const parseIncomingNotificationSettings = (raw) => {
if (raw === undefined) return undefined;
if (raw == null) return emptyNotificationSettingsMap();
const incomingMap = Array.isArray(raw)
? Object.fromEntries(
raw.flatMap((group) => (group?.actions || []).map((action) => [action.key, action.channels || action]))
)
: raw;
return mergeNotificationSettings({}, incomingMap);
};
const findActionDef = (actionKey) => NOTIFICATION_ACTION_DEFS.find((def) => def.key === actionKey);
module.exports = {
CHANNELS,
NOTIFICATION_CATEGORIES,
NOTIFICATION_ACTION_DEFS,
NOTIFICATION_ACTION_KEYS,
emptyNotificationSettingsMap,
mergeNotificationSettings,
toPublicNotificationSettings,
parseIncomingNotificationSettings,
normalizeStoredActionEntry,
findActionDef
};
+4
View File
@@ -27,6 +27,10 @@ const settingSchema = new mongoose.Schema({
botEnabled: {
type: Boolean,
default: true
},
notificationSettings: {
type: mongoose.Schema.Types.Mixed,
default: {}
}
}, {
timestamps: true,
+50 -3
View File
@@ -15,6 +15,17 @@ const {
getPublicMessaging,
invalidateMessagingCache
} = require('./messagingFlags');
const {
emptyNotificationSettingsMap,
mergeNotificationSettings,
parseIncomingNotificationSettings,
NOTIFICATION_ACTION_KEYS
} = require('./notificationActions');
const {
getPublicNotificationSettings,
invalidateNotificationSettingsCache,
readStoredMap: readNotificationStoredMap
} = require('../../utils/notificationSettings');
const emptyTemplateMap = () => {
const map = {};
@@ -64,11 +75,33 @@ const toPublicTemplates = (storedMap) => {
};
const getSettings = async () => {
const doc = await Setting.findOne({ key: SETTINGS_KEY }).lean();
let doc = await Setting.findOne({ key: SETTINGS_KEY }).lean();
if (!doc) {
const created = await Setting.create({
key: SETTINGS_KEY,
smsTemplates: emptyTemplateMap(),
notificationSettings: emptyNotificationSettingsMap()
});
doc = created.toObject();
} else {
const mergedNotifications = mergeNotificationSettings(readNotificationStoredMap(doc));
const storedKeys = Object.keys(readNotificationStoredMap(doc));
const missingAction = NOTIFICATION_ACTION_KEYS.some((key) => !storedKeys.includes(key));
if (missingAction || storedKeys.length === 0) {
await Setting.updateOne(
{ key: SETTINGS_KEY },
{ $set: { notificationSettings: mergedNotifications } }
);
doc = { ...doc, notificationSettings: mergedNotifications };
invalidateNotificationSettingsCache();
}
}
const storedMap = readStoredMap(doc);
return {
smsTemplates: toPublicTemplates(storedMap),
messaging: getPublicMessaging(doc)
messaging: getPublicMessaging(doc),
notificationSettings: await getPublicNotificationSettings(doc)
};
};
@@ -150,13 +183,27 @@ const saveSettings = async (body = {}) => {
if (incomingMessaging.botEnabled !== undefined) doc.botEnabled = incomingMessaging.botEnabled;
}
const incomingNotificationSettings = parseIncomingNotificationSettings(body.notificationSettings);
if (incomingNotificationSettings !== undefined) {
const storedNotificationMap = existing
? readNotificationStoredMap(existing.toObject ? existing.toObject() : existing)
: emptyNotificationSettingsMap();
const nextNotificationMap = mergeNotificationSettings(storedNotificationMap, incomingNotificationSettings);
doc.set('notificationSettings', JSON.parse(JSON.stringify(nextNotificationMap)));
doc.markModified('notificationSettings');
} else if (!existing) {
doc.set('notificationSettings', emptyNotificationSettingsMap());
}
await doc.save();
invalidateMessagingCache();
invalidateNotificationSettingsCache();
const saved = await Setting.findOne({ key: SETTINGS_KEY }).lean();
return {
smsTemplates: toPublicTemplates(readStoredMap(saved)),
messaging: getPublicMessaging(saved)
messaging: getPublicMessaging(saved),
notificationSettings: await getPublicNotificationSettings(saved)
};
};
+138 -4
View File
@@ -9,7 +9,8 @@ const SMS_TEMPLATE_DEFS = [
{ key: 'username', label: 'نام کاربری', defaultName: 'user' },
{ key: 'password', label: 'رمز عبور', defaultName: 'password' },
{ key: 'fullName', label: 'نام کاربر', defaultName: 'name' },
{ key: 'phoneNumber', label: 'شماره همراه', defaultName: 'mobile' }
{ key: 'phoneNumber', label: 'شماره همراه', defaultName: 'mobile' },
{ key: 'userCode', label: 'کد کاربری', defaultName: 'userCode' }
]
},
{
@@ -23,7 +24,8 @@ const SMS_TEMPLATE_DEFS = [
{ key: 'phoneNumber', label: 'شماره همراه', defaultName: 'mobile' },
{ key: 'classStartDate', label: 'تاریخ شروع کلاس', defaultName: 'classStartDate' },
{ key: 'classDays', label: 'روزهای برگزاری', defaultName: 'classDays' },
{ key: 'courseTime', label: 'ساعت دوره', defaultName: 'courseTime' }
{ key: 'courseTime', label: 'ساعت دوره', defaultName: 'courseTime' },
{ key: 'classCode', label: 'کد کلاس', defaultName: 'classCode' }
]
},
{
@@ -33,13 +35,17 @@ const SMS_TEMPLATE_DEFS = [
slots: [
{ key: 'className', label: 'نام کلاس', defaultName: 'className' },
{ key: 'courseName', label: 'نام دوره', defaultName: 'courseName' },
{ key: 'topic', label: 'موضوع جلسه', defaultName: 'topic' },
{ key: 'time', label: 'ساعت', defaultName: 'time' },
{ key: 'classTime', label: 'ساعت کلاس', defaultName: 'classTime' },
{ key: 'sessionDate', label: 'تاریخ جلسه', defaultName: 'sessionDate' },
{ key: 'place', label: 'مکان', defaultName: 'place' },
{ key: 'fullName', label: 'نام کاربر', defaultName: 'fullName' },
{ key: 'phoneNumber', label: 'شماره همراه', defaultName: 'mobile' },
{ key: 'classStartDate', label: 'تاریخ شروع کلاس', defaultName: 'classStartDate' },
{ key: 'classDays', label: 'روزهای برگزاری', defaultName: 'classDays' },
{ key: 'courseTime', label: 'ساعت دوره', defaultName: 'courseTime' }
{ key: 'courseTime', label: 'ساعت دوره', defaultName: 'courseTime' },
{ key: 'classCode', label: 'کد کلاس', defaultName: 'classCode' }
]
},
{
@@ -53,7 +59,135 @@ const SMS_TEMPLATE_DEFS = [
{ key: 'phoneNumber', label: 'شماره همراه', defaultName: 'MOBILE' },
{ key: 'classStartDate', label: 'تاریخ شروع کلاس', defaultName: 'classStartDate' },
{ key: 'classDays', label: 'روزهای برگزاری', defaultName: 'classDays' },
{ key: 'courseTime', label: 'ساعت دوره', defaultName: 'courseTime' }
{ key: 'courseTime', label: 'ساعت دوره', defaultName: 'courseTime' },
{ key: 'invoiceCode', label: 'کد صورتحساب', defaultName: 'invoiceCode' }
]
},
{
key: 'passwordReset',
label: 'بازنشانی رمز عبور',
envKey: 'SMS_TEMPLATE_PASSWORD_RESET',
slots: [
{ key: 'username', label: 'نام کاربری', defaultName: 'user' },
{ key: 'password', label: 'رمز عبور', defaultName: 'password' },
{ key: 'fullName', label: 'نام کاربر', defaultName: 'name' },
{ key: 'phoneNumber', label: 'شماره همراه', defaultName: 'mobile' },
{ key: 'userCode', label: 'کد کاربری', defaultName: 'userCode' }
]
},
{
key: 'paymentStatusChanged',
label: 'تغییر وضعیت پرداخت',
envKey: 'SMS_TEMPLATE_PAYMENT_STATUS_CHANGED',
slots: [
{ key: 'fullName', label: 'نام کارآموز', defaultName: 'fullName' },
{ key: 'status', label: 'وضعیت', defaultName: 'status' },
{ key: 'amount', label: 'مبلغ', defaultName: 'amount' },
{ key: 'invoiceCode', label: 'کد صورتحساب', defaultName: 'invoiceCode' },
{ key: 'course', label: 'نام دوره', defaultName: 'course' },
{ key: 'phoneNumber', label: 'شماره همراه', defaultName: 'mobile' }
]
},
{
key: 'paymentReminder',
label: 'یادآوری سررسید پرداخت',
envKey: 'SMS_TEMPLATE_PAYMENT_REMINDER',
slots: [
{ key: 'fullName', label: 'نام کارآموز', defaultName: 'fullName' },
{ key: 'amount', label: 'مبلغ باقی‌مانده', defaultName: 'amount' },
{ key: 'dueDate', label: 'تاریخ سررسید', defaultName: 'dueDate' },
{ key: 'invoiceCode', label: 'کد صورتحساب', defaultName: 'invoiceCode' },
{ key: 'course', label: 'نام دوره', defaultName: 'course' },
{ key: 'phoneNumber', label: 'شماره همراه', defaultName: 'mobile' }
]
},
{
key: 'transactionRecorded',
label: 'ثبت تراکنش',
envKey: 'SMS_TEMPLATE_TRANSACTION_RECORDED',
slots: [
{ key: 'fullName', label: 'نام کارآموز', defaultName: 'fullName' },
{ key: 'amount', label: 'مبلغ', defaultName: 'amount' },
{ key: 'invoiceCode', label: 'کد صورتحساب', defaultName: 'invoiceCode' },
{ key: 'transactionCode', label: 'کد تراکنش', defaultName: 'transactionCode' },
{ key: 'receiptNumber', label: 'شماره رسید', defaultName: 'receiptNumber' },
{ key: 'phoneNumber', label: 'شماره همراه', defaultName: 'mobile' }
]
},
{
key: 'sessionCancelled',
label: 'لغو جلسه',
envKey: 'SMS_TEMPLATE_SESSION_CANCELLED',
slots: [
{ key: 'fullName', label: 'نام کارآموز', defaultName: 'fullName' },
{ key: 'className', label: 'نام کلاس', defaultName: 'className' },
{ key: 'topic', label: 'موضوع جلسه', defaultName: 'topic' },
{ key: 'sessionDate', label: 'تاریخ جلسه', defaultName: 'sessionDate' },
{ key: 'reason', label: 'دلیل لغو', defaultName: 'reason' },
{ key: 'classCode', label: 'کد کلاس', defaultName: 'classCode' },
{ key: 'phoneNumber', label: 'شماره همراه', defaultName: 'mobile' }
]
},
{
key: 'sessionHolding',
label: 'برگزاری جلسه طبق برنامه',
envKey: 'SMS_TEMPLATE_SESSION_HOLDING',
slots: [
{ key: 'fullName', label: 'نام کارآموز', defaultName: 'FULLNAME' },
{ key: 'topic', label: 'موضوع جلسه', defaultName: 'TOPIC' },
{ key: 'className', label: 'نام کلاس', defaultName: 'CLASSNAME' },
{ key: 'sessionDate', label: 'تاریخ جلسه', defaultName: 'SESSIONDATE' },
{ key: 'classTime', label: 'ساعت کلاس', defaultName: 'CLASSTIME' },
{ key: 'courseName', label: 'نام دوره', defaultName: 'courseName' },
{ key: 'classCode', label: 'کد کلاس', defaultName: 'classCode' },
{ key: 'place', label: 'مکان', defaultName: 'place' },
{ key: 'phoneNumber', label: 'شماره همراه', defaultName: 'mobile' }
]
},
{
key: 'classRequestApproved',
label: 'تأیید درخواست تشکیل کلاس',
envKey: 'SMS_TEMPLATE_CLASS_REQUEST_APPROVED',
slots: [
{ key: 'fullName', label: 'نام کارآموز', defaultName: 'fullName' },
{ key: 'courseName', label: 'نام دوره', defaultName: 'courseName' },
{ key: 'classCode', label: 'کد کلاس', defaultName: 'classCode' },
{ key: 'phoneNumber', label: 'شماره همراه', defaultName: 'mobile' }
]
},
{
key: 'classRequestRejected',
label: 'رد درخواست ثبت‌نام',
envKey: 'SMS_TEMPLATE_CLASS_REQUEST_REJECTED',
slots: [
{ key: 'fullName', label: 'نام کارآموز', defaultName: 'fullName' },
{ key: 'courseName', label: 'نام دوره', defaultName: 'courseName' },
{ key: 'reason', label: 'دلیل', defaultName: 'reason' },
{ key: 'registrationCode', label: 'کد درخواست', defaultName: 'registrationCode' },
{ key: 'phoneNumber', label: 'شماره همراه', defaultName: 'mobile' }
]
},
{
key: 'pendingRegistration',
label: 'دریافت درخواست ثبت‌نام',
envKey: 'SMS_TEMPLATE_PENDING_REGISTRATION',
slots: [
{ key: 'fullName', label: 'نام کارآموز', defaultName: 'fullName' },
{ key: 'className', label: 'نام کلاس', defaultName: 'className' },
{ key: 'registrationCode', label: 'کد درخواست', defaultName: 'registrationCode' },
{ key: 'phoneNumber', label: 'شماره همراه', defaultName: 'mobile' }
]
},
{
key: 'certificateIssued',
label: 'صدور گواهینامه',
envKey: 'SMS_TEMPLATE_CERTIFICATE_ISSUED',
slots: [
{ key: 'fullName', label: 'نام کارآموز', defaultName: 'fullName' },
{ key: 'certificateTitle', label: 'عنوان گواهینامه', defaultName: 'certificateTitle' },
{ key: 'certificateCode', label: 'کد گواهینامه', defaultName: 'certificateCode' },
{ key: 'courseName', label: 'نام دوره', defaultName: 'courseName' },
{ key: 'phoneNumber', label: 'شماره همراه', defaultName: 'mobile' }
]
}
];
+16
View File
@@ -76,4 +76,20 @@ describe('SMS template variables', () => {
assert.ok(slots.includes('username'));
assert.ok(slots.includes('password'));
});
it('exposes reason slot on the sessionCancelled template', () => {
const def = SMS_TEMPLATE_DEFS.find((item) => item.key === 'sessionCancelled');
const slots = (def?.slots || []).map((slot) => slot.key);
assert.ok(slots.includes('reason'));
});
it('defines the sessionHolding template with all expected slots', () => {
const def = SMS_TEMPLATE_DEFS.find((item) => item.key === 'sessionHolding');
assert.ok(def, 'sessionHolding template definition missing');
const slots = (def.slots || []).map((slot) => slot.key);
const expected = ['fullName', 'topic', 'className', 'sessionDate', 'classTime'];
for (const slot of expected) {
assert.ok(slots.includes(slot), `sessionHolding is missing slot ${slot}`);
}
});
});
+3
View File
@@ -1,6 +1,7 @@
// /components/users/userModel.js
const mongoose = require('mongoose');
const uniqueCodePlugin = require('../../utils/mongooseUniqueCodePlugin');
const refreshTokenSchema = new mongoose.Schema({
token: { type: String, required: true },
@@ -131,4 +132,6 @@ const userSchema = new mongoose.Schema({
timestamps: true
});
userSchema.plugin(uniqueCodePlugin);
module.exports = mongoose.model('User', userSchema);
+33 -5
View File
@@ -11,7 +11,8 @@ const { generateUsername, generateSimplePassword } = require('../../utils/creden
const { sendAccountCreatedSms, sendPasswordResetSms } = require('../../utils/senders/smsMessages');
const logger = require('../../utils/logger');
const { mergeFullName, normalizeGender } = require('../../utils/userProfile');
const { pickNotifyFlags } = require('../../utils/notifyFlags');
const { resolveNotifyFlags } = require('../../utils/notifyResolver');
const { notifyAction } = require('../../utils/actionNotify');
const {
assertCanResetPasswordAndSms,
isCredentialsSmsDelivered
@@ -180,8 +181,24 @@ const createUserAdmin = async (body) => {
});
try {
if (pickNotifyFlags(body).sms) {
await sendAccountCreatedSms(profile.phoneNumber, username, plainPassword, user._id);
const notify = await resolveNotifyFlags(body, 'accountCreated');
if (notify.sms || notify.email || notify.bot) {
await notifyAction({
actionKey: 'accountCreated',
userId: user._id,
phoneNumber: profile.phoneNumber,
email: profile.email,
subject: 'ایجاد حساب کاربری',
body: `حساب کاربری شما ایجاد شد. نام کاربری: ${username} — کد کاربری: ${user.uniqueCode || ''}`,
smsHandler: () => sendAccountCreatedSms(
profile.phoneNumber,
username,
plainPassword,
user._id,
user.uniqueCode
),
requestSource: body
});
}
} catch (err) {
logger.error(`[createUserAdmin] Account SMS failed for ${profile.phoneNumber}: ${err.message}`);
@@ -287,8 +304,19 @@ const resetPasswordAndSendSms = async (id) => {
let smsSent = false;
try {
const sendResult = await sendPasswordResetSms(phoneNumber, username, plainPassword, user._id);
smsSent = isCredentialsSmsDelivered(sendResult);
const notify = await resolveNotifyFlags({}, 'passwordReset');
if (notify.sms || notify.email || notify.bot) {
const sendResult = await notifyAction({
actionKey: 'passwordReset',
userId: user._id,
phoneNumber,
email: user.email,
subject: 'بازنشانی رمز عبور',
body: `رمز عبور شما بازنشانی شد. نام کاربری: ${username} — کد کاربری: ${user.uniqueCode || ''}`,
smsHandler: () => sendPasswordResetSms(phoneNumber, username, plainPassword, user._id, user.uniqueCode)
});
smsSent = isCredentialsSmsDelivered(sendResult?.sms);
}
} catch (err) {
logger.error(`[resetPasswordAndSendSms] SMS failed for user=${user._id}: ${err.message}`);
}
+2
View File
@@ -13,6 +13,8 @@ const EVENT_NAMES = {
SESSION_CREATED: 'session.created',
SESSION_CANCELLED: 'session.cancelled',
SESSION_HOLDING: 'session.holding',
SESSION_REMINDER: 'session.reminder',
ATTENDANCE_RECORDED: 'attendance.recorded',
ATTENDANCE_BULK_RECORDED: 'attendance.bulk_recorded',
+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);
}));
+40 -22
View File
@@ -6,6 +6,8 @@ const Session = require('../components/sessions/sessionModel');
const User = require('../components/users/userModel');
const { sendClassReminderSms } = require('../utils/senders/smsMessages');
const { buildClassScheduleContext } = require('../utils/classSchedule');
const { notifyAction } = require('../utils/actionNotify');
const { resolveNotifyFlags } = require('../utils/notifyResolver');
const logger = require('../utils/logger');
const parseStartDateTime = (session) => {
@@ -37,7 +39,7 @@ const runClassReminderJob = async () => {
reminderSentAt: null,
day: { $gte: dayFrom, $lte: dayTo },
})
.populate({ path: 'class', select: 'name students startDate days startTime endTime' })
.populate({ path: 'class', select: 'name students startDate days startTime endTime uniqueCode' })
.populate({ path: 'course', select: 'title' })
.lean();
@@ -60,27 +62,43 @@ const runClassReminderJob = async () => {
: [];
const schedule = buildClassScheduleContext(classDoc, classSessions, session);
const users = await User.find({ _id: { $in: studentIds } }).select('phoneNumber').lean();
await Promise.all(
users.map(async (user) => {
if (!user.phoneNumber) return;
try {
await sendClassReminderSms(
user.phoneNumber,
classLabel,
timeLabel,
placeLabel,
user._id,
{
courseName: session.course?.title || classLabel,
...schedule
}
);
} catch (err) {
logger.error(`[ClassReminderJob] SMS failed for ${user.phoneNumber}: ${err.message}`);
}
})
);
const users = await User.find({ _id: { $in: studentIds } }).select('phoneNumber email name').lean();
const notify = await resolveNotifyFlags({}, 'classReminder');
if (notify.sms || notify.email || notify.bot) {
await Promise.all(
users.map(async (user) => {
if (!user.phoneNumber) return;
try {
await notifyAction({
actionKey: 'classReminder',
userId: user._id,
phoneNumber: user.phoneNumber,
email: user.email,
subject: 'یادآوری کلاس',
body: `یادآوری کلاس «${classLabel}» ساعت ${timeLabel} — مکان: ${placeLabel || '-'}`,
smsHandler: () => sendClassReminderSms(
user.phoneNumber,
classLabel,
timeLabel,
placeLabel,
user._id,
{
fullName: user.name,
topic: session.topic || classLabel,
sessionDate: session.day ? new Date(session.day).toLocaleDateString('fa-IR') : '',
classTime: timeLabel || session.startTime || '',
courseName: session.course?.title || classLabel,
classCode: classDoc?.uniqueCode || '',
...schedule
}
)
});
} catch (err) {
logger.error(`[ClassReminderJob] Notification failed for ${user.phoneNumber}: ${err.message}`);
}
})
);
}
await Session.updateOne({ _id: session._id }, { reminderSentAt: new Date() });
logger.info(`[ClassReminderJob] Reminders sent for session ${session._id}`);
+53 -8
View File
@@ -2,9 +2,51 @@
const cron = require('node-cron');
const Payment = require('../components/payments/paymentModel');
const User = require('../components/users/userModel');
const Course = require('../components/courses/courseModel');
const eventEmitter = require('../events/eventEmitter');
const EVENT_NAMES = require('../constants/eventNames');
const logger = require('../utils/logger');
const { getPayableAmount } = require('../utils/paymentAmount');
const { notifyAction } = require('../utils/actionNotify');
const { sendPaymentReminderSms } = require('../utils/senders/smsMessages');
const { resolveNotifyFlags } = require('../utils/notifyResolver');
const formatDueDate = (date) => {
if (!date) return '';
const parsed = new Date(date);
if (Number.isNaN(parsed.getTime())) return '';
return parsed.toLocaleDateString('fa-IR');
};
const notifyPaymentReminder = async (payment, user) => {
const notify = await resolveNotifyFlags({}, 'paymentReminder');
if (!notify.sms && !notify.email && !notify.bot) return;
let courseName = '-';
if (payment.course) {
const course = await Course.findById(payment.course).select('title').lean();
if (course?.title) courseName = course.title;
}
const amountDue = getPayableAmount(payment) - (payment.paidAmount || 0);
await notifyAction({
actionKey: 'paymentReminder',
userId: user._id,
phoneNumber: user.phoneNumber,
email: user.email,
subject: 'یادآوری سررسید پرداخت',
body: `یادآوری: مبلغ ${amountDue} تومان تا ${formatDueDate(payment.dueDate)} سررسید دارد. کد صورتحساب: ${payment.uniqueCode || ''}`,
smsHandler: () => sendPaymentReminderSms(user.phoneNumber, {
fullName: user.name || '',
amount: amountDue,
dueDate: formatDueDate(payment.dueDate),
invoiceCode: payment.uniqueCode || '',
course: courseName
}, user._id)
});
};
const runPaymentReminderJob = async () => {
try {
@@ -12,37 +54,41 @@ const runPaymentReminderJob = async () => {
const threeDaysFromNow = new Date();
threeDaysFromNow.setDate(now.getDate() + 3);
// Find pending/partiallyPaid payments due in the next 3 days
const upcomingPayments = await Payment.find({
status: { $in: ['pending', 'partiallyPaid'] },
status: { $in: ['pending', 'partial'] },
dueDate: { $gte: now, $lte: threeDaysFromNow }
}).populate('user');
}).populate({ path: 'user', select: 'name phoneNumber email' });
for (const payment of upcomingPayments) {
if (payment.user) {
eventEmitter.emit(EVENT_NAMES.PAYMENT_REMINDER_DUE, {
paymentId: payment._id,
userId: payment.user._id,
amountDue: payment.amount - payment.amountPaid,
amountDue: getPayableAmount(payment) - (payment.paidAmount || 0),
dueDate: payment.dueDate
});
try {
await notifyPaymentReminder(payment, payment.user);
} catch (err) {
logger.error(`[PaymentReminderJob] Notification failed for payment ${payment._id}: ${err.message}`);
}
}
}
// Find overdue payments and update status to overdue
const overduePayments = await Payment.find({
status: { $in: ['pending', 'partiallyPaid'] },
status: { $in: ['pending', 'partial'] },
dueDate: { $lt: now }
});
for (const payment of overduePayments) {
const previousStatus = payment.status;
payment.status = 'overdue';
await payment.save();
if (payment.user) {
eventEmitter.emit(EVENT_NAMES.PAYMENT_STATUS_CHANGED, {
paymentId: payment._id,
userId: payment.user,
oldStatus: 'pending',
oldStatus: previousStatus,
newStatus: 'overdue'
});
}
@@ -53,7 +99,6 @@ const runPaymentReminderJob = async () => {
};
const startPaymentReminderJob = () => {
// Run daily at midnight
cron.schedule('0 0 * * *', async () => {
await runPaymentReminderJob();
});
+670
View File
@@ -0,0 +1,670 @@
#!/usr/bin/env node
'use strict';
/**
* Extract attendance + payment sheets from class xlsx files into dashboard import JSON.
* Amounts in spreadsheets are Rials; output uses Toman (one zero dropped).
*
* Usage:
* node scripts/convert-attendance-payments.js [raw-root] [output.json] [--exclude=pattern]
*/
const fs = require('fs');
const path = require('path');
const XLSX = require('xlsx');
const { projectSessionDates } = require('../components/dataImport/importHelpers');
const { rialsToToman } = require('../utils/paymentAmount');
const MONTHS = {
فروردین: 1,
اردیبهشت: 2,
خرداد: 3,
تیر: 4,
مرداد: 5,
شهریور: 6,
مهر: 7,
آبان: 8,
آذر: 9,
دی: 10,
بهمن: 11,
اسفند: 12
};
const ATTENDANCE_HINTS = ['حضور و غیاب', 'حضور غیاب'];
const PAYMENT_HINTS = ['شهریه'];
const INFO_HINTS = ['اطلاعات کلی', 'مشخصات کلی', 'اطلاعات'];
const DEFAULT_RAW = path.join(
__dirname,
'..',
'..',
'raw-data',
'برنامه آموزشی 1405-20260814T213206Z-1-001',
'برنامه آموزشی 1405'
);
const DEFAULT_OUT = path.join(__dirname, '..', '..', 'raw-data', '1405-attendance-payments-import.json');
const REF_DATA = path.join(__dirname, '..', '..', 'raw-data', 'data');
const toEnglishDigits = (value) =>
String(value ?? '')
.replace(/[۰-۹]/g, (d) => '۰۱۲۳۴۵۶۷۸۹'.indexOf(d))
.replace(/[٠-٩]/g, (d) => '٠١٢٣٤٥٦٧٨٩'.indexOf(d));
const cleanText = (value) => {
if (value == null) return '';
return String(value).replace(/\s+/g, ' ').trim();
};
const normalizeHeader = (value) => cleanText(value).replace(/\n/g, ' ');
const jalaliToGregorian = (jy, jm, jd) => {
const gy = jy <= 979 ? 621 : 1600;
jy -= jy <= 979 ? 0 : 979;
let days =
365 * jy +
Math.floor(jy / 33) * 8 +
Math.floor(((jy % 33) + 3) / 4) +
78 +
jd +
(jm < 7 ? (jm - 1) * 31 : (jm - 7) * 30 + 186);
let gyOut = gy + 400 * Math.floor(days / 146097);
days %= 146097;
if (days > 36524) {
gyOut += 100 * Math.floor(--days / 36524);
days %= 36524;
if (days >= 365) days += 1;
}
gyOut += 4 * Math.floor(days / 1461);
days %= 1461;
if (days > 365) {
gyOut += Math.floor((days - 1) / 365);
days = (days - 1) % 365;
}
let gd = days + 1;
const sal_a = [
0, 31,
(gyOut % 4 === 0 && gyOut % 100 !== 0) || gyOut % 400 === 0 ? 29 : 28,
31, 30, 31, 30, 31, 31, 30, 31, 30, 31
];
let gm = 0;
for (gm = 1; gm <= 12 && gd > sal_a[gm]; gm += 1) gd -= sal_a[gm];
return `${gyOut}-${String(gm).padStart(2, '0')}-${String(gd).padStart(2, '0')}`;
};
const parseJalaliDate = (raw) => {
if (!raw) return null;
const text = toEnglishDigits(raw).replace(/[./\-]/g, '/').trim();
const match = text.match(/^(\d{3,4})\/(\d{1,2})\/(\d{1,2})$/);
if (!match) return null;
const jy = Number(match[1]);
const jm = Number(match[2]);
const jd = Number(match[3]);
if (!jy || !jm || !jd || jm > 12 || jd > 31) return null;
try {
return jalaliToGregorian(jy, jm, jd);
} catch {
return null;
}
};
const formatJalaliSlash = (raw) => {
if (!raw) return null;
const text = toEnglishDigits(raw).replace(/[./\-]/g, '/').trim();
const match = text.match(/^(\d{3,4})\/(\d{1,2})\/(\d{1,2})$/);
if (!match) return null;
return `${match[1]}/${Number(match[2])}/${Number(match[3])}`;
};
const normalizePhone = (raw) => {
if (raw == null || raw === '') return '';
let digits = toEnglishDigits(raw).replace(/\D/g, '');
if (digits.startsWith('98') && digits.length === 12) digits = `0${digits.slice(2)}`;
if (digits.length === 10 && digits.startsWith('9')) digits = `0${digits}`;
return digits.length === 11 && digits.startsWith('09') ? digits : digits || '';
};
const normalizeNationalId = (raw) => {
if (raw == null || raw === '') return '';
return toEnglishDigits(raw).replace(/\D/g, '');
};
const normalizePersonName = (first, last) => {
const name = cleanText(`${first || ''} ${last || ''}`)
.replace(/ي/g, 'ی')
.replace(/ك/g, 'ک')
.replace(/[\u200c\u200d]/g, ' ')
.replace(/\s+/g, ' ')
.trim();
return name;
};
const parseMoney = (raw) => {
if (raw == null || raw === '') return 0;
const text = cleanText(raw);
if (!text) return 0;
if (/^[\d,.\s]+$/.test(toEnglishDigits(text).replace(/,/g, ''))) {
const digits = toEnglishDigits(text).replace(/[^\d]/g, '');
return digits ? Number(digits) : 0;
}
if (/e\+/i.test(text)) {
const n = Number(toEnglishDigits(text));
return Number.isFinite(n) ? Math.round(n) : 0;
}
return 0;
};
const mapAttendanceStatus = (raw) => {
const value = cleanText(raw);
if (!value) return null;
if (value === '*' || value === 'ح' || value === 'حاضر') return 'present';
if (value === 'غ' || value === 'غایب') return 'absent';
if (value === 'ت' || value === 'تأخیر' || value === 'تاخیر') return 'late';
if (value === 'م' || value === 'موجه') return 'excused';
return null;
};
const pickSheet = (wb, hints) => {
for (const hint of hints) {
const found = wb.SheetNames.find((n) => {
const norm = normalizeHeader(n);
return norm === hint || norm.includes(hint);
});
if (found) return found;
}
return null;
};
const sheetRows = (sheet) =>
XLSX.utils.sheet_to_json(sheet, { header: 1, defval: null, raw: false });
const parseFolderMeta = (folderName) => {
const cleaned = cleanText(folderName).replace(/^\d+\s*[-–—.]?\s*/, '');
const monthNames = Object.keys(MONTHS).join('|');
const monthMatch = cleaned.match(new RegExp(`(${monthNames})\\s+(\\d{4})$`));
let className = cleaned;
let courseTitle = cleaned;
let startDate = null;
if (monthMatch) {
const monthName = monthMatch[1];
const jalaliYear = Number(monthMatch[2]);
const jalaliMonth = MONTHS[monthName];
className = cleaned;
courseTitle = cleanText(cleaned.slice(0, monthMatch.index));
startDate = jalaliToGregorian(jalaliYear, jalaliMonth, 1);
}
const isPrivate = /خصوصی/.test(courseTitle) || /خصوصی/.test(className);
if (isPrivate) courseTitle = cleanText(courseTitle.replace(/خصوصی/g, ''));
return {
folderName,
courseTitle: courseTitle || className,
className,
type: isPrivate ? 'Private' : 'General',
startDate
};
};
const findClassXlsx = (dirPath) => {
const files = fs.readdirSync(dirPath).filter((f) => f.endsWith('.xlsx') && !f.startsWith('~$'));
if (!files.length) return null;
return path.join(dirPath, files.find((f) => !f.includes('لیست شرکت کنندگان')) || files[0]);
};
const findHeaderRow = (rows, predicate, max = 8) => {
for (let i = 0; i < Math.min(rows.length, max); i += 1) {
if (predicate(rows[i] || [])) return i;
}
return -1;
};
const loadReferenceData = () => {
const classesPath = path.join(REF_DATA, 'test.classes.json');
const coursesPath = path.join(REF_DATA, 'test.courses.json');
const usersPath = path.join(REF_DATA, 'test.users.json');
const classes = fs.existsSync(classesPath) ? JSON.parse(fs.readFileSync(classesPath, 'utf8')) : [];
const courses = fs.existsSync(coursesPath) ? JSON.parse(fs.readFileSync(coursesPath, 'utf8')) : [];
const users = fs.existsSync(usersPath) ? JSON.parse(fs.readFileSync(usersPath, 'utf8')) : [];
const courseById = new Map(courses.map((c) => [String(c._id.$oid || c._id), c.title]));
const classByName = new Map(classes.map((c) => [c.name, { ...c, courseTitle: courseById.get(String(c.course.$oid || c.course)) }]));
const usersByPhone = new Map();
const usersByNationalId = new Map();
for (const user of users) {
if (user.phoneNumber) usersByPhone.set(normalizePhone(user.phoneNumber), user);
if (user.nationalIdCode) usersByNationalId.set(normalizeNationalId(user.nationalIdCode), user);
}
return { classByName, usersByPhone, usersByNationalId };
};
const resolveClassMeta = (meta, refs) => {
const candidates = [
meta.className,
meta.folderName.replace(/^\d+\s*[-–—.]?\s*/, ''),
meta.folderName
];
for (const name of candidates) {
const hit = refs.classByName.get(name);
if (hit) {
return {
...meta,
className: hit.name,
courseTitle: hit.courseTitle || meta.courseTitle,
dbStartDate: hit.startDate?.$date?.slice(0, 10) || null,
tuitionFee: hit.tuitionFee || 0
};
}
}
return meta;
};
const extractInfoLookup = (sheet) => {
const rows = sheetRows(sheet);
const headerIdx = findHeaderRow(rows, (row) => {
const text = row.map(normalizeHeader).join('|');
return text.includes('نام') && (text.includes('تلفن') || text.includes('کد ملی'));
});
if (headerIdx < 0) return new Map();
const header = rows[headerIdx].map(normalizeHeader);
const idx = {
firstName: header.findIndex((h) => h === 'نام'),
lastName: header.findIndex((h) => h.includes('نام خانوادگی')),
fullName: header.findIndex((h) => h.includes('نام و نام خانوادگی')),
phone: header.findIndex((h) => h.includes('تلفن') || h.includes('شماره تماس')),
nationalId: header.findIndex((h) => h.includes('کد ملی'))
};
const lookup = new Map();
for (let i = headerIdx + 1; i < rows.length; i += 1) {
const row = rows[i];
if (!row) continue;
const name = idx.fullName >= 0
? cleanText(row[idx.fullName])
: normalizePersonName(row[idx.firstName], row[idx.lastName]);
if (!name) continue;
const phone = idx.phone >= 0 ? normalizePhone(row[idx.phone]) : '';
const nationalIdCode = idx.nationalId >= 0 ? normalizeNationalId(row[idx.nationalId]) : '';
lookup.set(name, { name, phoneNumber: phone || undefined, nationalIdCode: nationalIdCode || undefined });
}
return lookup;
};
const extractAttendance = (sheet) => {
const rows = sheetRows(sheet);
const headerRowIdx = findHeaderRow(rows, (row) => {
const text = row.map(normalizeHeader).join('|');
return text.includes('نام') && text.includes('نام خانوادگی');
});
if (headerRowIdx < 0) return { sessions: [], sessionCount: 0, weekdays: [], startDate: null };
const headerRow = rows[headerRowIdx];
const numberRow = headerRowIdx > 0 ? rows[headerRowIdx - 1] : null;
const firstNameIdx = headerRow.findIndex((h) => normalizeHeader(h) === 'نام');
const lastNameIdx = headerRow.findIndex((h) => normalizeHeader(h).includes('نام خانوادگی'));
const identityEndCol = Math.max(firstNameIdx, lastNameIdx) + 1;
const sessionCols = [];
for (let col = identityEndCol; col < headerRow.length; col += 1) {
const jalaliRaw = headerRow[col] || (numberRow ? numberRow[col] : null);
const isoDate = parseJalaliDate(jalaliRaw);
if (!isoDate) continue;
sessionCols.push({
col,
sessionNo: sessionCols.length + 1,
jalaliRaw,
isoDate
});
}
const totalCount = sessionCols.length;
if (!totalCount) {
return { sessions: [], sessionCount: 0, weekdays: [], startDate: null };
}
const knownDates = sessionCols.map((s) => s.isoDate);
const projectedDates = projectSessionDates(knownDates, totalCount);
const sessions = sessionCols.map((colMeta, index) => ({
topic: `جلسه ${colMeta.sessionNo}`,
day: projectedDates[index] || colMeta.isoDate,
dayJalali: colMeta.jalaliRaw ? formatJalaliSlash(colMeta.jalaliRaw) : undefined,
startTime: '19:00',
endTime: '20:30',
status: 'scheduled',
attendance: []
}));
for (let i = headerRowIdx + 1; i < rows.length; i += 1) {
const row = rows[i];
if (!row) continue;
const name = normalizePersonName(row[firstNameIdx], row[lastNameIdx]);
if (!name) continue;
for (const colMeta of sessionCols) {
const status = mapAttendanceStatus(row[colMeta.col]);
if (!status) continue;
const session = sessions.find((s) => s.topic === `جلسه ${colMeta.sessionNo}`);
if (!session) continue;
session.attendance.push({ name, status });
}
}
const today = new Date().toISOString().slice(0, 10);
for (const session of sessions) {
const hasAttendance = session.attendance.length > 0;
if (hasAttendance || (session.day && session.day <= today)) {
session.status = 'held';
}
}
const weekdays = [...new Set(
sessions.filter((s) => s.day).map((s) => new Date(`${s.day}T12:00:00.000Z`).getUTCDay())
)].sort();
return {
sessions,
sessionCount: totalCount,
weekdays,
startDate: sessions.find((s) => s.day)?.day || null
};
};
const findInstallmentGroups = (headerRow) => {
const groups = [];
for (let i = 0; i < headerRow.length; i += 1) {
const h = normalizeHeader(headerRow[i]);
const match = h.match(/^قسط (اول|دوم|سوم|چهارم|پنجم)$/);
if (!match) continue;
groups.push({ label: match[1], amountCol: i, dateCol: i + 1, receiptCol: i + 2 });
}
return groups;
};
const extractPayments = (sheet, infoLookup, refs) => {
const rows = sheetRows(sheet);
const headerIdx = findHeaderRow(rows, (row) => {
const text = row.map(normalizeHeader).join('|');
return text.includes('نام') && text.includes('هزینه کل دوره');
});
if (headerIdx < 0) return [];
const headerRow = rows[headerIdx].map(normalizeHeader);
const firstNameIdx = headerRow.findIndex((h) => h === 'نام');
const lastNameIdx = headerRow.findIndex((h) => h.includes('نام خانوادگی'));
const phoneIdx = headerRow.findIndex((h) => h.includes('شماره تماس') || h.includes('تلفن'));
const discountIdx = headerRow.findIndex((h) => h.includes('تخفیف'));
const totalIdx = headerRow.findIndex((h) => h === 'هزینه کل دوره');
const installmentTotalIdx = headerRow.findIndex((h) => h.includes('هزینه کل دوره') && h !== 'هزینه کل دوره');
const installmentGroups = findInstallmentGroups(headerRow);
let remainderCol = -1;
for (let i = headerRow.length - 1; i >= 0; i -= 1) {
const h = headerRow[i];
if (!h) continue;
if (h.includes('پرتال') || h.includes('هزینه های')) continue;
remainderCol = i;
break;
}
const payments = [];
for (let i = headerIdx + 1; i < rows.length; i += 1) {
const row = rows[i];
if (!row) continue;
const name = normalizePersonName(row[firstNameIdx], row[lastNameIdx]);
if (!name) continue;
const phoneNumber = phoneIdx >= 0 ? normalizePhone(row[phoneIdx]) : '';
const info = infoLookup.get(name) || {};
const userByPhone = phoneNumber ? refs.usersByPhone.get(phoneNumber) : null;
const nationalIdCode =
info.nationalIdCode ||
(userByPhone ? normalizeNationalId(userByPhone.nationalIdCode) : '');
const discountRials = discountIdx >= 0 ? parseMoney(row[discountIdx]) : 0;
let amountRials = totalIdx >= 0 ? parseMoney(row[totalIdx]) : 0;
const installmentTotalRials = installmentTotalIdx >= 0 ? parseMoney(row[installmentTotalIdx]) : 0;
if (!amountRials && installmentTotalRials) {
amountRials = installmentTotalRials + discountRials;
}
if (!amountRials && installmentTotalRials) amountRials = installmentTotalRials;
if (!amountRials) continue;
const amount = rialsToToman(amountRials);
const discount = rialsToToman(discountRials);
const transactions = [];
for (const group of installmentGroups) {
const amountPart = parseMoney(row[group.amountCol]);
if (!amountPart) continue;
const trxAmount = rialsToToman(amountPart);
const jalaliRaw = row[group.dateCol];
const isoDate = parseJalaliDate(jalaliRaw);
const receiptRaw = row[group.receiptCol];
let receiptNumber;
if (receiptRaw != null && receiptRaw !== '') {
const receiptText = cleanText(receiptRaw);
if (/^\d+(?:\.\d+)?(?:e\+?\d+)?$/i.test(toEnglishDigits(receiptText))) {
receiptNumber = toEnglishDigits(receiptText).replace(/\.\d+$/, '').replace(/e\+?\d+/i, '');
if (/e/i.test(receiptText)) {
receiptNumber = String(Math.round(Number(toEnglishDigits(receiptText))));
}
} else if (/^\d+$/.test(toEnglishDigits(receiptText))) {
receiptNumber = toEnglishDigits(receiptText);
}
}
const trx = {
amount: trxAmount,
method: 'card',
status: 'paid'
};
if (isoDate) {
trx.date = isoDate;
trx.dueDate = isoDate;
}
if (jalaliRaw) trx.dateJalali = formatJalaliSlash(jalaliRaw);
if (receiptNumber) trx.receiptNumber = receiptNumber;
transactions.push(trx);
}
const remainderRials = remainderCol >= 0 ? parseMoney(row[remainderCol]) : 0;
const remainder = rialsToToman(remainderRials);
const paidTotal = transactions.reduce((sum, trx) => sum + trx.amount, 0);
const payable = Math.max(0, amount - discount);
const inferredRemainder = Math.max(0, payable - paidTotal);
if (remainder > 0) {
transactions.push({
amount: remainder,
status: 'pending',
notes: 'مانده شهریه ثبت‌شده در اکسل'
});
} else if (inferredRemainder > 0 && paidTotal > 0) {
transactions.push({
amount: inferredRemainder,
status: 'pending',
notes: 'مانده محاسبه‌شده از اکسل'
});
}
const payment = {
name,
phoneNumber: phoneNumber || info.phoneNumber || undefined,
nationalIdCode: nationalIdCode || undefined,
amount,
discount,
transactions
};
Object.keys(payment).forEach((k) => {
if (payment[k] === undefined) delete payment[k];
});
payments.push(payment);
}
return payments;
};
const enrichIdentity = (sessions, payments, infoLookup, refs) => {
const enrichPerson = (person) => {
const info = infoLookup.get(person.name) || {};
if (!person.phoneNumber && info.phoneNumber) person.phoneNumber = info.phoneNumber;
if (!person.nationalIdCode && info.nationalIdCode) person.nationalIdCode = info.nationalIdCode;
if (person.phoneNumber) {
const user = refs.usersByPhone.get(normalizePhone(person.phoneNumber));
if (user && !person.nationalIdCode) person.nationalIdCode = normalizeNationalId(user.nationalIdCode);
}
if (person.nationalIdCode) {
const user = refs.usersByNationalId.get(normalizeNationalId(person.nationalIdCode));
if (user && !person.phoneNumber) person.phoneNumber = normalizePhone(user.phoneNumber);
}
};
for (const session of sessions) {
for (const record of session.attendance) enrichPerson(record);
}
for (const payment of payments) enrichPerson(payment);
};
const convertClassFolder = (dirPath, folderName, refs) => {
const meta = resolveClassMeta(parseFolderMeta(folderName), refs);
const xlsxPath = findClassXlsx(dirPath);
if (!xlsxPath) {
return { ...meta, sourceFile: null, sessions: [], payments: [], sessionCount: 0, warnings: ['missing_xlsx'] };
}
const wb = XLSX.readFile(xlsxPath, { cellDates: false, raw: false });
const infoSheet = pickSheet(wb, INFO_HINTS);
const attendanceSheetName = pickSheet(wb, ATTENDANCE_HINTS);
const paymentSheetName = pickSheet(wb, PAYMENT_HINTS);
const infoLookup = infoSheet ? extractInfoLookup(wb.Sheets[infoSheet]) : new Map();
const attendance = attendanceSheetName
? extractAttendance(wb.Sheets[attendanceSheetName])
: { sessions: [], sessionCount: 0, weekdays: [], startDate: null };
const payments = paymentSheetName
? extractPayments(wb.Sheets[paymentSheetName], infoLookup, refs)
: [];
enrichIdentity(attendance.sessions, payments, infoLookup, refs);
const warnings = [];
if (!attendanceSheetName) warnings.push('missing_attendance_sheet');
if (!paymentSheetName) warnings.push('missing_payment_sheet');
return {
...meta,
sourceFile: path.basename(xlsxPath),
sheets: {
info: infoSheet,
attendance: attendanceSheetName,
payments: paymentSheetName
},
sessionCount: attendance.sessionCount,
sessions: attendance.sessions,
weekdays: attendance.weekdays,
startDate: attendance.startDate || meta.dbStartDate || meta.startDate,
payments,
warnings
};
};
const buildImportDocument = (rawRoot, excludePatterns = [/تکسا\s+تیر/i]) => {
const refs = loadReferenceData();
const entries = fs
.readdirSync(rawRoot, { withFileTypes: true })
.filter((d) => d.isDirectory())
.map((d) => d.name)
.filter((name) => !excludePatterns.some((re) => re.test(name)))
.sort((a, b) => a.localeCompare(b, 'fa'));
const classes = entries.map((name) => convertClassFolder(path.join(rawRoot, name), name, refs));
const coursesMap = new Map();
for (const cls of classes) {
if (!coursesMap.has(cls.courseTitle)) {
coursesMap.set(cls.courseTitle, {
title: cls.courseTitle,
type: cls.type,
classes: []
});
}
const course = coursesMap.get(cls.courseTitle);
if (cls.type === 'Private') course.type = 'Private';
course.classes.push({
name: cls.className,
folderName: cls.folderName,
sourceFile: cls.sourceFile,
startDate: cls.startDate,
days: cls.weekdays,
startTime: '19:00',
endTime: '20:30',
numberOfSessions: cls.sessionCount || undefined,
sessions: cls.sessions,
payments: cls.payments
});
}
const courses = [...coursesMap.values()];
const summary = {
courses: courses.length,
classes: classes.length,
sessions: classes.reduce((n, c) => n + (c.sessionCount || 0), 0),
attendanceRecords: classes.reduce(
(n, c) => n + c.sessions.reduce((m, s) => m + s.attendance.length, 0),
0
),
payments: classes.reduce((n, c) => n + c.payments.length, 0),
transactions: classes.reduce(
(n, c) => n + c.payments.reduce((m, p) => m + (p.transactions?.length || 0), 0),
0
),
excluded: excludePatterns.map(String),
classesDetail: classes.map((c) => ({
className: c.className,
courseTitle: c.courseTitle,
sourceFile: c.sourceFile,
sessionCount: c.sessionCount,
attendanceRecords: c.sessions.reduce((m, s) => m + s.attendance.length, 0),
payments: c.payments.length,
transactions: c.payments.reduce((m, p) => m + (p.transactions?.length || 0), 0),
warnings: c.warnings
}))
};
return {
version: 1,
generatedAt: new Date().toISOString(),
source: path.basename(rawRoot),
summary,
courses
};
};
const main = () => {
const args = process.argv.slice(2);
const positional = args.filter((a) => !a.startsWith('--'));
const excludeArg = args.find((a) => a.startsWith('--exclude='));
const excludePatterns = excludeArg
? [new RegExp(excludeArg.slice('--exclude='.length), 'i')]
: [/تکسا\s+تیر/i];
const rawRoot = path.resolve(positional[0] || DEFAULT_RAW);
const outPath = path.resolve(positional[1] || DEFAULT_OUT);
if (!fs.existsSync(rawRoot)) {
console.error(`Raw data folder not found: ${rawRoot}`);
process.exit(1);
}
const doc = buildImportDocument(rawRoot, excludePatterns);
fs.writeFileSync(outPath, `${JSON.stringify(doc, null, 2)}\n`, 'utf8');
console.log(`Wrote ${outPath}`);
console.log(JSON.stringify(doc.summary, null, 2));
};
main();
+60
View File
@@ -0,0 +1,60 @@
'use strict';
const { recordAndSend } = require('./senders/notificationRecorder');
const { sendEmail } = require('./senders/emailSender');
const { sendBaleMessage } = require('./senders/baleBotSender');
const { resolveNotifyFlags } = require('./notifyResolver');
const { findActionDef } = require('../components/settings/notificationActions');
/**
* Send SMS (via handler), email, and bot notifications for an action when enabled.
* SMS handler should use template-based recordAndSend internally.
*/
const notifyAction = async ({
actionKey,
userId = null,
phoneNumber = null,
email = null,
subject = '',
body = '',
smsHandler = null,
requestSource = {},
relatedEvent = null
}) => {
const flags = await resolveNotifyFlags(requestSource, actionKey);
const def = findActionDef(actionKey);
const eventTag = relatedEvent || def?.relatedEvent || actionKey;
const results = {};
if (flags.sms && phoneNumber && typeof smsHandler === 'function') {
results.sms = await smsHandler();
}
if (flags.email && email) {
results.email = await recordAndSend({
userId,
channel: 'email',
subject,
body,
relatedEvent: eventTag,
sendFn: () => sendEmail({ to: email, subject, body })
});
}
if (flags.bot && phoneNumber) {
results.bot = await recordAndSend({
userId,
channel: 'baleBot',
subject,
body,
relatedEvent: eventTag,
sendFn: () => sendBaleMessage({ chatId: phoneNumber, body })
});
}
return results;
};
module.exports = {
notifyAction
};
+32
View File
@@ -0,0 +1,32 @@
'use strict';
const { generateUniqueCode } = require('./uniqueCode');
/**
* Mongoose plugin: auto-assigns an 8-digit uniqueCode before validation if missing.
*/
const uniqueCodePlugin = (schema, options = {}) => {
const field = options.field || 'uniqueCode';
schema.add({
[field]: {
type: String,
unique: true,
sparse: true,
trim: true,
index: true
}
});
schema.pre('validate', async function assignUniqueCode(next) {
if (this[field]) return next();
try {
this[field] = await generateUniqueCode(this.constructor, field);
return next();
} catch (err) {
return next(err);
}
});
};
module.exports = uniqueCodePlugin;
+102
View File
@@ -0,0 +1,102 @@
'use strict';
const config = require('../config/config');
const { Setting, SETTINGS_KEY } = require('../components/settings/settingModel');
const {
NOTIFICATION_ACTION_DEFS,
mergeNotificationSettings,
toPublicNotificationSettings,
normalizeStoredActionEntry,
findActionDef
} = require('../components/settings/notificationActions');
const {
isEnvChannelEnabled,
isDbChannelEnabled,
CHANNELS
} = require('./messagingChannels');
const { getDbMessagingFlags, invalidateMessagingCache } = require('../components/settings/messagingFlags');
let settingsCache = null;
let settingsCacheAt = 0;
const CACHE_TTL_MS = 10000;
const readStoredMap = (doc) => {
const stored = doc?.notificationSettings || {};
if (stored instanceof Map) {
return Object.fromEntries(stored.entries());
}
return { ...stored };
};
const getMergedSettingsMap = (doc) => mergeNotificationSettings(readStoredMap(doc));
const loadNotificationSettingsDoc = async () => {
const now = Date.now();
if (settingsCache && (now - settingsCacheAt) < CACHE_TTL_MS) {
return settingsCache;
}
const doc = await Setting.findOne({ key: SETTINGS_KEY })
.select('notificationSettings smsEnabled emailEnabled botEnabled')
.lean();
settingsCache = {
map: getMergedSettingsMap(doc),
dbMessaging: {
smsEnabled: doc?.smsEnabled,
emailEnabled: doc?.emailEnabled,
botEnabled: doc?.botEnabled
}
};
settingsCacheAt = now;
return settingsCache;
};
const invalidateNotificationSettingsCache = () => {
settingsCache = null;
settingsCacheAt = 0;
invalidateMessagingCache();
};
const isGlobalChannelEnabled = async (channel) => {
if (!CHANNELS.includes(channel)) return false;
if (!isEnvChannelEnabled(channel, config)) return false;
const db = await getDbMessagingFlags();
return isDbChannelEnabled(channel, db);
};
const isActionChannelEnabled = async (actionKey, channel) => {
if (!CHANNELS.includes(channel)) return false;
if (!(await isGlobalChannelEnabled(channel))) return false;
const cached = await loadNotificationSettingsDoc();
const def = findActionDef(actionKey);
if (!def) return true;
const entry = normalizeStoredActionEntry(cached.map[actionKey], def);
return entry[channel] === true;
};
const getActionChannelFlags = async (actionKey) => {
const result = { sms: false, email: false, bot: false };
await Promise.all(
CHANNELS.map(async (channel) => {
result[channel] = await isActionChannelEnabled(actionKey, channel);
})
);
return result;
};
const getPublicNotificationSettings = async (doc = null) => {
const resolved = doc || await Setting.findOne({ key: SETTINGS_KEY }).lean();
return toPublicNotificationSettings(getMergedSettingsMap(resolved));
};
module.exports = {
getMergedSettingsMap,
isActionChannelEnabled,
getActionChannelFlags,
getPublicNotificationSettings,
invalidateNotificationSettingsCache,
readStoredMap
};
+22
View File
@@ -0,0 +1,22 @@
'use strict';
const { pickNotifyFlags } = require('./notifyFlags');
const { getActionChannelFlags } = require('./notificationSettings');
/**
* Combines per-request notify flags (from admin forms) with dashboard per-action settings.
*/
const resolveNotifyFlags = async (source = {}, actionKey) => {
const request = pickNotifyFlags(source);
const dashboard = await getActionChannelFlags(actionKey);
return {
sms: request.sms && dashboard.sms,
email: request.email && dashboard.email,
bot: request.bot && dashboard.bot
};
};
module.exports = {
resolveNotifyFlags
};
+278 -77
View File
@@ -13,22 +13,26 @@ const resolveUserIdByPhone = async (phoneNumber) => {
return user?._id || null;
};
const sendAccountCredentialsSms = async ({
const resolveUserName = async (userId, fallback = '') => {
if (!userId) return fallback;
const user = await User.findById(userId).select('name uniqueCode').lean();
return {
fullName: user?.name || fallback,
userCode: user?.uniqueCode || ''
};
};
const sendTemplateSms = async ({
templateKey,
receiver,
username,
password,
userId = null,
subject,
body,
relatedEvent
relatedEvent,
slotValues = {}
}) => {
const resolvedUserId = userId || await resolveUserIdByPhone(receiver);
let fullName = '';
if (resolvedUserId) {
const user = await User.findById(resolvedUserId).select('name').lean();
if (user?.name) fullName = user.name;
}
const { templateId, variables } = await getSmsTemplate('accountCreated');
const { templateId, variables } = await getSmsTemplate(templateKey);
return recordAndSend({
userId: resolvedUserId,
channel: 'sms',
@@ -36,37 +40,74 @@ const sendAccountCredentialsSms = async ({
body,
relatedEvent,
sendFn: () => sendSingleSms(receiver, templateId, buildSmsParameters(variables, {
username,
password,
fullName: fullName || username,
name: fullName || username,
phoneNumber: receiver,
mobile: receiver
mobile: receiver,
...slotValues
}))
});
};
const sendAccountCreatedSms = async (receiver, username, password, userId = null) => {
return sendAccountCredentialsSms({
const sendAccountCredentialsSms = async ({
receiver,
username,
password,
userId = null,
subject,
body,
relatedEvent,
templateKey = 'accountCreated',
userCode = ''
}) => {
const resolvedUserId = userId || await resolveUserIdByPhone(receiver);
let fullName = '';
let code = userCode;
if (resolvedUserId) {
const profile = await resolveUserName(resolvedUserId, username);
fullName = profile.fullName;
code = code || profile.userCode;
}
return sendTemplateSms({
templateKey,
receiver,
username,
password,
userId,
subject: 'ایجاد حساب کاربری',
body: `حساب کاربری شما ایجاد شد. نام کاربری: ${username}`,
relatedEvent: 'user.created'
userId: resolvedUserId,
subject,
body,
relatedEvent,
slotValues: {
username,
password,
fullName: fullName || username,
name: fullName || username,
userCode: code
}
});
};
const sendPasswordResetSms = async (receiver, username, password, userId = null) => {
const sendAccountCreatedSms = async (receiver, username, password, userId = null, userCode = '') => {
return sendAccountCredentialsSms({
receiver,
username,
password,
userId,
userCode,
subject: 'ایجاد حساب کاربری',
body: `حساب کاربری شما ایجاد شد. نام کاربری: ${username}`,
relatedEvent: 'user.created',
templateKey: 'accountCreated'
});
};
const sendPasswordResetSms = async (receiver, username, password, userId = null, userCode = '') => {
return sendAccountCredentialsSms({
receiver,
username,
password,
userId,
userCode,
subject: 'بازنشانی رمز عبور',
body: `رمز عبور شما بازنشانی شد. نام کاربری: ${username}`,
relatedEvent: 'user.password_reset'
relatedEvent: 'user.password_reset',
templateKey: 'passwordReset'
});
};
@@ -74,27 +115,26 @@ const sendClassRegisteredSms = async (receiver, className, userId = null, extraC
const resolvedUserId = userId || await resolveUserIdByPhone(receiver);
let fullName = extraContext.fullName || '';
if (!fullName && resolvedUserId) {
const user = await User.findById(resolvedUserId).select('name').lean();
if (user?.name) fullName = user.name;
const profile = await resolveUserName(resolvedUserId);
fullName = profile.fullName;
}
const { templateId, variables } = await getSmsTemplate('classRegistered');
return recordAndSend({
return sendTemplateSms({
templateKey: 'classRegistered',
receiver,
userId: resolvedUserId,
channel: 'sms',
subject: 'ثبت‌نام در کلاس',
body: `ثبت‌نام شما در کلاس «${className}» انجام شد.`,
relatedEvent: 'user.enrolled',
sendFn: () => sendSingleSms(receiver, templateId, buildSmsParameters(variables, {
slotValues: {
className,
courseName: extraContext.courseName || className,
fullName: fullName || 'کارآموز',
phoneNumber: receiver,
mobile: receiver,
classStartDate: extraContext.classStartDate || '',
classDays: extraContext.classDays || '',
courseTime: extraContext.courseTime || '',
classCode: extraContext.classCode || '',
...extraContext
}))
}
});
};
@@ -102,74 +142,226 @@ const sendClassReminderSms = async (receiver, className, time, place = '', userI
const resolvedUserId = userId || await resolveUserIdByPhone(receiver);
let fullName = extraContext.fullName || '';
if (!fullName && resolvedUserId) {
const user = await User.findById(resolvedUserId).select('name').lean();
if (user?.name) fullName = user.name;
const profile = await resolveUserName(resolvedUserId);
fullName = profile.fullName;
}
const { templateId, variables } = await getSmsTemplate('classReminder');
return recordAndSend({
return sendTemplateSms({
templateKey: 'classReminder',
receiver,
userId: resolvedUserId,
channel: 'sms',
subject: 'یادآوری کلاس',
body: `یادآوری کلاس «${className}» ساعت ${time} — مکان: ${place || '-'}`,
relatedEvent: 'session.reminder',
sendFn: () => sendSingleSms(receiver, templateId, buildSmsParameters(variables, {
slotValues: {
className,
topic: extraContext.topic || className,
courseName: extraContext.courseName || className,
time,
classTime: extraContext.classTime || extraContext.courseTime || extraContext.time || time || '',
sessionDate: extraContext.sessionDate || '',
place: place || '-',
fullName: fullName || 'کارآموز',
phoneNumber: receiver,
mobile: receiver,
classStartDate: extraContext.classStartDate || '',
classDays: extraContext.classDays || '',
courseTime: extraContext.courseTime || extraContext.time || time || '',
courseTime: extraContext.courseTime || extraContext.classTime || extraContext.time || time || '',
classCode: extraContext.classCode || '',
...extraContext
}))
}
});
};
const sendInvoiceCreatedSms = async (receiver, payload, userId = null) => {
const resolvedUserId = userId || await resolveUserIdByPhone(receiver);
const { templateId, variables } = await getSmsTemplate('invoiceCreated');
const data = typeof payload === 'object' && payload !== null ? payload : { amount: payload };
const fullNameLabel = data.fullName || 'کارآموز';
const amountLabel = data.amount != null ? String(data.amount) : '';
const courseLabel = data.course || '-';
let fullName = '';
let amount = '';
let course = '';
let classStartDate = '';
let classDays = '';
let courseTime = '';
if (typeof payload === 'object' && payload !== null) {
fullName = payload.fullName || '';
amount = payload.amount != null ? String(payload.amount) : '';
course = payload.course || '';
classStartDate = payload.classStartDate || '';
classDays = payload.classDays || '';
courseTime = payload.courseTime || '';
} else {
amount = String(payload || '');
}
const amountLabel = String(amount);
const courseLabel = course || '-';
const fullNameLabel = fullName || 'کارآموز';
return recordAndSend({
return sendTemplateSms({
templateKey: 'invoiceCreated',
receiver,
userId: resolvedUserId,
channel: 'sms',
subject: 'ایجاد صورتحساب',
body: `کارآموز عزیز، ${fullNameLabel}، یک صورتحساب به مبلغ ${amountLabel} تومان بابت دوره «${courseLabel}» برای شما ایجاد شد.`,
relatedEvent: 'payment.created',
sendFn: () => sendSingleSms(receiver, templateId, buildSmsParameters(variables, {
slotValues: {
fullName: fullNameLabel,
amount: amountLabel,
course: courseLabel,
phoneNumber: receiver,
mobile: receiver,
classStartDate,
classDays,
courseTime
}))
classStartDate: data.classStartDate || '',
classDays: data.classDays || '',
courseTime: data.courseTime || '',
invoiceCode: data.invoiceCode || ''
}
});
};
const sendPaymentStatusChangedSms = async (receiver, payload, userId = null) => {
const data = payload || {};
return sendTemplateSms({
templateKey: 'paymentStatusChanged',
receiver,
userId,
subject: 'تغییر وضعیت پرداخت',
body: `وضعیت صورتحساب شما به «${data.statusLabel || data.status || '-'}» تغییر کرد.`,
relatedEvent: 'payment.status_changed',
slotValues: {
fullName: data.fullName || 'کارآموز',
status: data.statusLabel || data.status || '-',
amount: data.amount != null ? String(data.amount) : '',
invoiceCode: data.invoiceCode || '',
course: data.course || '-'
}
});
};
const sendPaymentReminderSms = async (receiver, payload, userId = null) => {
const data = payload || {};
return sendTemplateSms({
templateKey: 'paymentReminder',
receiver,
userId,
subject: 'یادآوری سررسید پرداخت',
body: `یادآوری: مبلغ ${data.amount || '-'} تومان تا ${data.dueDate || '-'} سررسید دارد.`,
relatedEvent: 'payment.reminder_due',
slotValues: {
fullName: data.fullName || 'کارآموز',
amount: data.amount != null ? String(data.amount) : '',
dueDate: data.dueDate || '',
invoiceCode: data.invoiceCode || '',
course: data.course || '-'
}
});
};
const sendTransactionRecordedSms = async (receiver, payload, userId = null) => {
const data = payload || {};
return sendTemplateSms({
templateKey: 'transactionRecorded',
receiver,
userId,
subject: 'ثبت تراکنش',
body: `تراکنش به مبلغ ${data.amount || '-'} تومان ثبت شد.`,
relatedEvent: 'payment.transaction_added',
slotValues: {
fullName: data.fullName || 'کارآموز',
amount: data.amount != null ? String(data.amount) : '',
invoiceCode: data.invoiceCode || '',
transactionCode: data.transactionCode || '',
receiptNumber: data.receiptNumber || ''
}
});
};
const sendSessionCancelledSms = async (receiver, payload, userId = null) => {
const data = payload || {};
return sendTemplateSms({
templateKey: 'sessionCancelled',
receiver,
userId,
subject: 'لغو جلسه',
body: `جلسه «${data.topic || data.className || 'کلاس'}» لغو شد.`,
relatedEvent: 'session.cancelled',
slotValues: {
fullName: data.fullName || 'کارآموز',
className: data.className || '-',
topic: data.topic || '-',
sessionDate: data.sessionDate || '',
reason: data.reason || '-',
classCode: data.classCode || ''
}
});
};
const sendSessionHoldingSms = async (receiver, payload, userId = null) => {
const data = payload || {};
return sendTemplateSms({
templateKey: 'sessionHolding',
receiver,
userId,
subject: 'برگزاری جلسه طبق برنامه',
body: `جلسه «${data.topic || data.className || 'کلاس'}» کلاس ${data.className || ''} در تاریخ ${data.sessionDate || '-'} و ساعت ${data.classTime || data.time || '-'} طبق برنامه برگزار خواهد شد.`,
relatedEvent: 'session.holding',
slotValues: {
fullName: data.fullName || 'کارآموز',
topic: data.topic || '-',
className: data.className || '-',
sessionDate: data.sessionDate || '',
classTime: data.classTime || data.time || '',
courseName: data.courseName || data.className || '-',
classCode: data.classCode || '',
place: data.place || '-'
}
});
};
const sendClassRequestApprovedSms = async (receiver, payload, userId = null) => {
const data = payload || {};
return sendTemplateSms({
templateKey: 'classRequestApproved',
receiver,
userId,
subject: 'تأیید درخواست تشکیل کلاس',
body: `درخواست تشکیل کلاس «${data.courseName || '-'}» تأیید شد.`,
relatedEvent: 'class_request.approved',
slotValues: {
fullName: data.fullName || 'کارآموز',
courseName: data.courseName || '-',
classCode: data.classCode || ''
}
});
};
const sendClassRequestRejectedSms = async (receiver, payload, userId = null) => {
const data = payload || {};
return sendTemplateSms({
templateKey: 'classRequestRejected',
receiver,
userId,
subject: 'رد درخواست ثبت‌نام',
body: `درخواست شما برای «${data.courseName || '-'}» رد شد.`,
relatedEvent: 'class_request.rejected',
slotValues: {
fullName: data.fullName || 'کارآموز',
courseName: data.courseName || '-',
reason: data.reason || '-',
registrationCode: data.registrationCode || ''
}
});
};
const sendPendingRegistrationSms = async (receiver, payload, userId = null) => {
const data = payload || {};
return sendTemplateSms({
templateKey: 'pendingRegistration',
receiver,
userId,
subject: 'دریافت درخواست ثبت‌نام',
body: `درخواست ثبت‌نام شما در «${data.className || '-'}» دریافت شد.`,
relatedEvent: 'pending.registration_received',
slotValues: {
fullName: data.fullName || 'کارآموز',
className: data.className || '-',
registrationCode: data.registrationCode || ''
}
});
};
const sendCertificateIssuedSms = async (receiver, payload, userId = null) => {
const data = payload || {};
return sendTemplateSms({
templateKey: 'certificateIssued',
receiver,
userId,
subject: 'صدور گواهینامه',
body: `گواهینامه «${data.certificateTitle || '-'}» برای شما صادر شد.`,
relatedEvent: 'certificate.issued',
slotValues: {
fullName: data.fullName || 'کارآموز',
certificateTitle: data.certificateTitle || '-',
certificateCode: data.certificateCode || '',
courseName: data.courseName || '-'
}
});
};
@@ -178,5 +370,14 @@ module.exports = {
sendPasswordResetSms,
sendClassRegisteredSms,
sendClassReminderSms,
sendInvoiceCreatedSms
sendInvoiceCreatedSms,
sendPaymentStatusChangedSms,
sendPaymentReminderSms,
sendTransactionRecordedSms,
sendSessionCancelledSms,
sendSessionHoldingSms,
sendClassRequestApprovedSms,
sendClassRequestRejectedSms,
sendPendingRegistrationSms,
sendCertificateIssuedSms
};
+18
View File
@@ -8,6 +8,15 @@ const {
sendClassRegisteredSms,
sendClassReminderSms,
sendInvoiceCreatedSms,
sendPaymentStatusChangedSms,
sendPaymentReminderSms,
sendTransactionRecordedSms,
sendSessionCancelledSms,
sendSessionHoldingSms,
sendClassRequestApprovedSms,
sendClassRequestRejectedSms,
sendPendingRegistrationSms,
sendCertificateIssuedSms,
} = require('./smsMessages');
/**
@@ -36,4 +45,13 @@ module.exports = {
sendClassRegisteredSms,
sendClassReminderSms,
sendInvoiceCreatedSms,
sendPaymentStatusChangedSms,
sendPaymentReminderSms,
sendTransactionRecordedSms,
sendSessionCancelledSms,
sendSessionHoldingSms,
sendClassRequestApprovedSms,
sendClassRequestRejectedSms,
sendPendingRegistrationSms,
sendCertificateIssuedSms,
};
+30
View File
@@ -0,0 +1,30 @@
'use strict';
const crypto = require('crypto');
const MIN_CODE = 10000000;
const MAX_CODE = 100000000;
/** Generate a random 8-digit numeric string (1000000099999999). */
const generateCode = () => String(crypto.randomInt(MIN_CODE, MAX_CODE));
/**
* Allocate a unique code for a Mongoose model field.
* Retries on collision up to maxAttempts times.
*/
const generateUniqueCode = async (Model, field = 'uniqueCode', maxAttempts = 24) => {
if (!Model) throw new Error('Model is required to generate a unique code');
for (let attempt = 0; attempt < maxAttempts; attempt += 1) {
const code = generateCode();
const exists = await Model.exists({ [field]: code });
if (!exists) return code;
}
throw new Error(`Could not generate a unique ${field} after ${maxAttempts} attempts`);
};
module.exports = {
generateCode,
generateUniqueCode
};