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}`);
}