Add sessionHolding template, unique 8-digit codes, reason slot for sessionCancelled, and disable unused notifications
This commit is contained in:
@@ -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);
|
||||
};
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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}`);
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
@@ -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 };
|
||||
};
|
||||
|
||||
@@ -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
|
||||
};
|
||||
@@ -27,6 +27,10 @@ const settingSchema = new mongoose.Schema({
|
||||
botEnabled: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
},
|
||||
notificationSettings: {
|
||||
type: mongoose.Schema.Types.Mixed,
|
||||
default: {}
|
||||
}
|
||||
}, {
|
||||
timestamps: true,
|
||||
|
||||
@@ -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)
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -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' }
|
||||
]
|
||||
}
|
||||
];
|
||||
|
||||
@@ -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}`);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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}`);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user