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