From 091e5192804c6c52336706f16d0753b391b1c299 Mon Sep 17 00:00:00 2001 From: Kavehhn174 Date: Fri, 21 Aug 2026 10:44:51 +0330 Subject: [PATCH] Add sessionHolding template, unique 8-digit codes, reason slot for sessionCancelled, and disable unused notifications --- components/certificates/certificateModel.js | 3 + components/certificates/certificateService.js | 27 + components/classes/classModel.js | 3 + components/classes/classService.js | 28 +- components/payments/paymentModel.js | 3 + components/payments/paymentService.js | 172 +++-- components/payments/transactionModel.js | 3 + .../pendingStudents/pendingStudentModel.js | 3 + .../pendingStudents/pendingStudentService.js | 95 ++- components/settings/notificationActions.js | 251 +++++++ components/settings/settingModel.js | 4 + components/settings/settingService.js | 53 +- components/settings/smsTemplates.js | 142 +++- components/settings/smsTemplates.test.js | 16 + components/users/userModel.js | 3 + components/users/userService.js | 38 +- constants/eventNames.js | 2 + events/eventListeners.js | 202 +++--- jobs/classReminderJob.js | 62 +- jobs/paymentReminderJob.js | 61 +- scripts/convert-attendance-payments.js | 670 ++++++++++++++++++ utils/actionNotify.js | 60 ++ utils/mongooseUniqueCodePlugin.js | 32 + utils/notificationSettings.js | 102 +++ utils/notifyResolver.js | 22 + utils/senders/smsMessages.js | 355 ++++++++-- utils/senders/smsSender.js | 18 + utils/uniqueCode.js | 30 + 28 files changed, 2180 insertions(+), 280 deletions(-) create mode 100644 components/settings/notificationActions.js create mode 100644 scripts/convert-attendance-payments.js create mode 100644 utils/actionNotify.js create mode 100644 utils/mongooseUniqueCodePlugin.js create mode 100644 utils/notificationSettings.js create mode 100644 utils/notifyResolver.js create mode 100644 utils/uniqueCode.js diff --git a/components/certificates/certificateModel.js b/components/certificates/certificateModel.js index f3bf48b..605dba2 100644 --- a/components/certificates/certificateModel.js +++ b/components/certificates/certificateModel.js @@ -1,6 +1,7 @@ // /components/certificates/certificateModel.js const mongoose = require('mongoose'); +const uniqueCodePlugin = require('../../utils/mongooseUniqueCodePlugin'); const certificateSchema = new mongoose.Schema({ user: { @@ -66,4 +67,6 @@ const certificateSchema = new mongoose.Schema({ timestamps: true }); +certificateSchema.plugin(uniqueCodePlugin); + module.exports = mongoose.model('Certificate', certificateSchema); diff --git a/components/certificates/certificateService.js b/components/certificates/certificateService.js index 66b673e..13e3a00 100644 --- a/components/certificates/certificateService.js +++ b/components/certificates/certificateService.js @@ -8,6 +8,9 @@ const config = require('../../config/config'); const { commitTempFile, generatePresignedUrl, deleteFromBucket } = require('../../utils/s3Client'); const eventEmitter = require('../../events/eventEmitter'); const EVENT_NAMES = require('../../constants/eventNames'); +const { notifyAction } = require('../../utils/actionNotify'); +const { sendCertificateIssuedSms } = require('../../utils/senders/smsMessages'); +const Course = require('../courses/courseModel'); const { parsePaginationAndSort, buildFilterQuery, calculateMeta } = require('../../utils/pagination'); const BUCKET = 'certificates'; @@ -54,6 +57,30 @@ const createCertificate = async (data) => { eventEmitter.emit(EVENT_NAMES.CERTIFICATE_ISSUED, { certificateId: certificate._id, userId: user._id }); + try { + let courseName = ''; + if (certificate.course) { + const course = await Course.findById(certificate.course).select('title').lean(); + courseName = course?.title || ''; + } + await notifyAction({ + actionKey: 'certificateIssued', + userId: user._id, + phoneNumber: user.phoneNumber, + email: user.email, + subject: 'صدور گواهینامه', + body: `گواهینامه «${certificate.title}» با کد ${certificate.uniqueCode || ''} صادر شد.`, + smsHandler: () => sendCertificateIssuedSms(user.phoneNumber, { + fullName: user.name || '', + certificateTitle: certificate.title, + certificateCode: certificate.uniqueCode || '', + courseName + }, user._id) + }); + } catch { + // Notification failure should not block certificate creation + } + return withAccessUrl(certificate); }; diff --git a/components/classes/classModel.js b/components/classes/classModel.js index a70a8d1..aa304d7 100644 --- a/components/classes/classModel.js +++ b/components/classes/classModel.js @@ -2,6 +2,7 @@ 'use strict'; const mongoose = require('mongoose'); +const uniqueCodePlugin = require('../../utils/mongooseUniqueCodePlugin'); const classSchema = new mongoose.Schema({ name: { @@ -92,6 +93,8 @@ const classSchema = new mongoose.Schema({ toObject: { virtuals: true } }); +classSchema.plugin(uniqueCodePlugin); + classSchema.virtual('finalTuitionFee').get(function () { const tuition = this.tuitionFee || 0; if (!this.hasDiscount) return tuition; diff --git a/components/classes/classService.js b/components/classes/classService.js index 559c634..1908eaf 100644 --- a/components/classes/classService.js +++ b/components/classes/classService.js @@ -8,7 +8,8 @@ const AppError = require('../../utils/AppError'); const { calculateMeta, escapeRegex, getSearchTerm } = require('../../utils/pagination'); const { sendClassRegisteredSms } = require('../../utils/senders/smsMessages'); const { buildClassScheduleContext, normalizeWeekdays, normalizeClockTime } = require('../../utils/classSchedule'); -const { pickNotifyFlags } = require('../../utils/notifyFlags'); +const { resolveNotifyFlags } = require('../../utils/notifyResolver'); +const { notifyAction } = require('../../utils/actionNotify'); const logger = require('../../utils/logger'); const applyScheduleFields = (payload, body) => { @@ -127,7 +128,7 @@ const registerUsers = async (classId, userIds, notifyInput = {}) => { const cls = await Class.findById(classId).populate({ path: 'course', select: 'title' }); if (!cls) throw new AppError('CLASS_NOT_FOUND'); - const notify = pickNotifyFlags(notifyInput); + const notify = await resolveNotifyFlags(notifyInput, 'classRegistered'); const toAdd = (userIds || []).filter( (id) => !cls.students.map((s) => s.toString()).includes(id.toString()) ); @@ -138,24 +139,35 @@ const registerUsers = async (classId, userIds, notifyInput = {}) => { cls.students.push(...toAdd); await cls.save(); - if (notify.sms) { + if (notify.sms || notify.email || notify.bot) { const classLabel = cls.name || cls.course?.title || 'کلاس'; const sessions = await Session.find({ class: classId }) .select('day startTime endTime') .sort({ day: 1 }) .lean(); const schedule = buildClassScheduleContext(cls, sessions); - const users = await User.find({ _id: { $in: toAdd } }).select('phoneNumber').lean(); + const users = await User.find({ _id: { $in: toAdd } }).select('phoneNumber email name').lean(); await Promise.all( users.map(async (user) => { if (!user.phoneNumber) return; try { - await sendClassRegisteredSms(user.phoneNumber, classLabel, user._id, { - courseName: cls.course?.title || classLabel, - ...schedule + await notifyAction({ + actionKey: 'classRegistered', + userId: user._id, + phoneNumber: user.phoneNumber, + email: user.email, + subject: 'ثبت‌نام در کلاس', + body: `ثبت‌نام شما در کلاس «${classLabel}» انجام شد. کد کلاس: ${cls.uniqueCode || ''}`, + smsHandler: () => sendClassRegisteredSms(user.phoneNumber, classLabel, user._id, { + fullName: user.name, + courseName: cls.course?.title || classLabel, + classCode: cls.uniqueCode || '', + ...schedule + }), + requestSource: notifyInput }); } catch (err) { - logger.error(`[registerUsers] SMS failed for ${user.phoneNumber}: ${err.message}`); + logger.error(`[registerUsers] Notification failed for ${user.phoneNumber}: ${err.message}`); } }) ); diff --git a/components/payments/paymentModel.js b/components/payments/paymentModel.js index aaf8397..939aadb 100644 --- a/components/payments/paymentModel.js +++ b/components/payments/paymentModel.js @@ -4,6 +4,7 @@ const mongoose = require('mongoose'); const { getPayableAmount, normalizeDiscount } = require('../../utils/paymentAmount'); +const uniqueCodePlugin = require('../../utils/mongooseUniqueCodePlugin'); require('./transactionModel'); const paymentSchema = new mongoose.Schema({ @@ -51,6 +52,8 @@ const paymentSchema = new mongoose.Schema({ toObject: { virtuals: true } }); +paymentSchema.plugin(uniqueCodePlugin); + paymentSchema.virtual('transactions', { ref: 'Transaction', localField: '_id', diff --git a/components/payments/paymentService.js b/components/payments/paymentService.js index 77f7498..9149641 100644 --- a/components/payments/paymentService.js +++ b/components/payments/paymentService.js @@ -11,10 +11,14 @@ const User = require('../users/userModel'); const Course = require('../courses/courseModel'); const Class = require('../classes/classModel'); const Session = require('../sessions/sessionModel'); -const { sendInvoiceCreatedSms } = require('../../utils/senders/smsMessages'); -const { buildClassScheduleContext } = require('../../utils/classSchedule'); -const { parseImportDate } = require('../../utils/jalaliDate'); -const { pickNotifyFlags, omitNotifyFields } = require('../../utils/notifyFlags'); +const { omitNotifyFields } = require('../../utils/notifyFlags'); +const { resolveNotifyFlags } = require('../../utils/notifyResolver'); +const { notifyAction } = require('../../utils/actionNotify'); +const { + sendInvoiceCreatedSms, + sendPaymentStatusChangedSms, + sendTransactionRecordedSms +} = require('../../utils/senders/smsMessages'); const { getPayableAmount, normalizeDiscount, @@ -28,6 +32,98 @@ const logger = require('../../utils/logger'); const PAYMENT_METHODS = new Set(['online', 'card', 'cash']); +const PAYMENT_STATUS_LABELS = { + pending: 'در انتظار پرداخت', + partial: 'پرداخت جزئی', + paid: 'پرداخت‌شده', + overdue: 'معوق' +}; + +const formatPaymentContext = async (payment) => { + const user = await User.findById(payment.user).select('name phoneNumber email').lean(); + let courseName = ''; + let schedule = { classStartDate: '', classDays: '', courseTime: '' }; + + if (payment.course) { + const course = await Course.findById(payment.course).select('title').lean(); + if (course?.title) courseName = course.title; + } + if (payment.classes?.length) { + const cls = await Class.findById(payment.classes[0]).populate('course', 'title').lean(); + if (!courseName) { + courseName = cls?.course?.title || cls?.name || ''; + } + if (cls) { + const sessions = await Session.find({ class: cls._id }).select('day startTime endTime').sort({ day: 1 }).lean(); + schedule = buildClassScheduleContext(cls, sessions); + } + } + + return { + user, + courseName: courseName || '-', + schedule, + invoiceCode: payment.uniqueCode || '' + }; +}; + +const notifyPaymentStatusChanged = async (payment, newStatus, oldStatus) => { + try { + const notify = await resolveNotifyFlags({}, 'paymentStatusChanged'); + if (!notify.sms && !notify.email && !notify.bot) return; + + const { user, courseName, invoiceCode } = await formatPaymentContext(payment); + if (!user?.phoneNumber) return; + + await notifyAction({ + actionKey: 'paymentStatusChanged', + userId: user._id, + phoneNumber: user.phoneNumber, + email: user.email, + subject: 'تغییر وضعیت پرداخت', + body: `وضعیت صورتحساب ${invoiceCode} از «${PAYMENT_STATUS_LABELS[oldStatus] || oldStatus}» به «${PAYMENT_STATUS_LABELS[newStatus] || newStatus}» تغییر کرد.`, + smsHandler: () => sendPaymentStatusChangedSms(user.phoneNumber, { + fullName: user.name || '', + status: newStatus, + statusLabel: PAYMENT_STATUS_LABELS[newStatus] || newStatus, + amount: getPayableAmount(payment), + invoiceCode, + course: courseName + }, user._id) + }); + } catch (err) { + logger.error(`[notifyPaymentStatusChanged] Failed for payment ${payment._id}: ${err.message}`); + } +}; + +const notifyTransactionRecorded = async (payment, transaction) => { + try { + const notify = await resolveNotifyFlags({}, 'transactionRecorded'); + if (!notify.sms && !notify.email && !notify.bot) return; + + const { user, invoiceCode } = await formatPaymentContext(payment); + if (!user?.phoneNumber) return; + + await notifyAction({ + actionKey: 'transactionRecorded', + userId: user._id, + phoneNumber: user.phoneNumber, + email: user.email, + subject: 'ثبت تراکنش', + body: `تراکنش ${transaction.uniqueCode || ''} به مبلغ ${transaction.amount} تومان ثبت شد.`, + smsHandler: () => sendTransactionRecordedSms(user.phoneNumber, { + fullName: user.name || '', + amount: transaction.amount, + invoiceCode, + transactionCode: transaction.uniqueCode || '', + receiptNumber: transaction.receiptNumber || '' + }, user._id) + }); + } catch (err) { + logger.error(`[notifyTransactionRecorded] Failed for transaction ${transaction._id}: ${err.message}`); + } +}; + const populatePayment = (query) => query .populate({ path: 'user', select: 'name phoneNumber' }) .populate({ path: 'classes', select: 'name tuitionFee' }) @@ -65,7 +161,7 @@ const refreshPaymentTotals = async (payment) => { return payment; }; -const emitPaymentStatusChangedIfNeeded = (payment, previousStatus, actorId = null) => { +const emitPaymentStatusChangedIfNeeded = async (payment, previousStatus, actorId = null) => { if (payment.status === previousStatus) return; eventEmitter.emit(EVENT_NAMES.PAYMENT_STATUS_CHANGED, { paymentId: payment._id, @@ -74,6 +170,7 @@ const emitPaymentStatusChangedIfNeeded = (payment, previousStatus, actorId = nul newStatus: payment.status, actorId }); + await notifyPaymentStatusChanged(payment, payment.status, previousStatus); }; const reconcilePendingTransactions = async (payment, { adjustAmount = true } = {}) => { @@ -197,7 +294,7 @@ const getPaymentById = async (id) => { }; const createPayment = async (body, actorId = null) => { - const notify = pickNotifyFlags(body); + const notify = await resolveNotifyFlags(body, 'invoiceCreated'); const payload = omitNotifyFields(body); const incomingTransactions = Array.isArray(payload.transactions) ? payload.transactions : null; delete payload.transactions; @@ -229,43 +326,29 @@ const createPayment = async (body, actorId = null) => { }); } - if (notify.sms) { + if (notify.sms || notify.email || notify.bot) { try { - const user = await User.findById(payment.user).select('name phoneNumber').lean(); + const { user, courseName, schedule, invoiceCode } = await formatPaymentContext(payment); if (user?.phoneNumber) { - let courseName = ''; - let schedule = { classStartDate: '', classDays: '', courseTime: '' }; - if (payment.course) { - const course = await Course.findById(payment.course).select('title').lean(); - if (course?.title) courseName = course.title; - } - if (payment.classes && payment.classes.length > 0) { - const cls = await Class.findById(payment.classes[0]).populate('course', 'title').lean(); - if (!courseName) { - if (cls?.course?.title) { - courseName = cls.course.title; - } else if (cls?.name) { - courseName = cls.name; - } - } - if (cls) { - const sessions = await Session.find({ class: cls._id }) - .select('day startTime endTime') - .sort({ day: 1 }) - .lean(); - schedule = buildClassScheduleContext(cls, sessions); - } - } - - await sendInvoiceCreatedSms(user.phoneNumber, { - fullName: user.name || '', - amount: getPayableAmount(payment), - course: courseName || '-', - ...schedule - }, user._id); + await notifyAction({ + actionKey: 'invoiceCreated', + userId: user._id, + phoneNumber: user.phoneNumber, + email: user.email, + subject: 'ایجاد صورتحساب', + body: `صورتحساب ${invoiceCode} به مبلغ ${getPayableAmount(payment)} تومان بابت «${courseName}» ایجاد شد.`, + smsHandler: () => sendInvoiceCreatedSms(user.phoneNumber, { + fullName: user.name || '', + amount: getPayableAmount(payment), + course: courseName, + invoiceCode, + ...schedule + }, user._id), + requestSource: body + }); } } catch (err) { - logger.error(`[createPayment] Invoice SMS failed for payment ${payment._id}: ${err.message}`); + logger.error(`[createPayment] Invoice notification failed for payment ${payment._id}: ${err.message}`); } } @@ -296,7 +379,7 @@ const updatePayment = async (id, body, actorId = null) => { await refreshPaymentTotals(payment); } - emitPaymentStatusChangedIfNeeded(payment, previousStatus, actorId); + await emitPaymentStatusChangedIfNeeded(payment, previousStatus, actorId); return getPaymentById(payment._id); }; @@ -317,7 +400,7 @@ const addTransaction = async (paymentId, trxData, actorId = null) => { const previousStatus = payment.status; const payload = buildTransactionPayload(payment, { ...trxData, status: trxData.status || 'paid' }, actorId); if (!payload.amount) throw new AppError('VALIDATION_FAILED', { amount: 'Amount is required' }, 'مبلغ تراکنش الزامی است.'); - await Transaction.create(payload); + const transaction = await Transaction.create(payload); await reconcilePendingTransactions(payment); await refreshPaymentTotals(payment); @@ -327,7 +410,8 @@ const addTransaction = async (paymentId, trxData, actorId = null) => { userId: payment.user, actorId }); - emitPaymentStatusChangedIfNeeded(payment, previousStatus, actorId); + await notifyTransactionRecorded(payment, transaction); + await emitPaymentStatusChangedIfNeeded(payment, previousStatus, actorId); return getPaymentById(paymentId); }; @@ -373,7 +457,7 @@ const updateTransaction = async (transactionId, body, actorId = null) => { // Preserve admin-edited amounts; only reconcile balance when recording new payments. await reconcilePendingTransactions(payment, { adjustAmount: false }); await refreshPaymentTotals(payment); - emitPaymentStatusChangedIfNeeded(payment, previousStatus, actorId); + await emitPaymentStatusChangedIfNeeded(payment, previousStatus, actorId); return getPaymentById(payment._id); }; @@ -394,7 +478,7 @@ const cancelTransaction = async (transactionId, actorId = null) => { await trx.save(); await refreshPaymentTotals(payment); - emitPaymentStatusChangedIfNeeded(payment, previousStatus, actorId); + await emitPaymentStatusChangedIfNeeded(payment, previousStatus, actorId); return getPaymentById(payment._id); }; diff --git a/components/payments/transactionModel.js b/components/payments/transactionModel.js index 09565b9..eeca474 100644 --- a/components/payments/transactionModel.js +++ b/components/payments/transactionModel.js @@ -2,6 +2,7 @@ 'use strict'; const mongoose = require('mongoose'); +const uniqueCodePlugin = require('../../utils/mongooseUniqueCodePlugin'); const transactionSchema = new mongoose.Schema({ payment: { @@ -43,6 +44,8 @@ const transactionSchema = new mongoose.Schema({ timestamps: true }); +transactionSchema.plugin(uniqueCodePlugin); + transactionSchema.index({ payment: 1, dueDate: 1 }); module.exports = mongoose.model('Transaction', transactionSchema); diff --git a/components/pendingStudents/pendingStudentModel.js b/components/pendingStudents/pendingStudentModel.js index e7d560d..ea952c2 100644 --- a/components/pendingStudents/pendingStudentModel.js +++ b/components/pendingStudents/pendingStudentModel.js @@ -1,6 +1,7 @@ 'use strict'; const mongoose = require('mongoose'); +const uniqueCodePlugin = require('../../utils/mongooseUniqueCodePlugin'); const pendingStudentSchema = new mongoose.Schema({ user: { @@ -55,6 +56,8 @@ const pendingStudentSchema = new mongoose.Schema({ timestamps: true }); +pendingStudentSchema.plugin(uniqueCodePlugin); + pendingStudentSchema.index({ user: 1, class: 1, type: 1, status: 1 }); module.exports = mongoose.model('PendingStudent', pendingStudentSchema); diff --git a/components/pendingStudents/pendingStudentService.js b/components/pendingStudents/pendingStudentService.js index 9028ed8..c6c7404 100644 --- a/components/pendingStudents/pendingStudentService.js +++ b/components/pendingStudents/pendingStudentService.js @@ -9,12 +9,13 @@ const bcrypt = require('bcryptjs'); const { calculateMeta, escapeRegex, getSearchTerm } = require('../../utils/pagination'); const { calculateRegistrationPricing } = require('../../utils/registrationPricing'); const { generateUsername, generateSimplePassword } = require('../../utils/credentials'); -const { sendAccountCreatedSms } = require('../../utils/senders/smsMessages'); +const { sendAccountCreatedSms, sendClassRequestApprovedSms, sendClassRequestRejectedSms, sendPendingRegistrationSms } = require('../../utils/senders/smsMessages'); +const { notifyAction } = require('../../utils/actionNotify'); +const { resolveNotifyFlags } = require('../../utils/notifyResolver'); const { mergeFullName, normalizeGender } = require('../../utils/userProfile'); const classService = require('../classes/classService'); const paymentService = require('../payments/paymentService'); const logger = require('../../utils/logger'); -const { recordAndSend } = require('../../utils/senders/notificationRecorder'); const POPULATE_LIST = [ { path: 'user', select: 'name phoneNumber nationalIdCode email username' }, @@ -148,7 +149,18 @@ const findOrCreateUser = async (body) => { }); try { - await sendAccountCreatedSms(normalizedPhone, username, plainPassword, user._id); + const notify = await resolveNotifyFlags({}, 'accountCreated'); + if (notify.sms || notify.email || notify.bot) { + await notifyAction({ + actionKey: 'accountCreated', + userId: user._id, + phoneNumber: normalizedPhone, + email, + subject: 'ایجاد حساب کاربری', + body: `حساب کاربری شما ایجاد شد. نام کاربری: ${username} — کد کاربری: ${user.uniqueCode || ''}`, + smsHandler: () => sendAccountCreatedSms(normalizedPhone, username, plainPassword, user._id, user.uniqueCode) + }); + } } catch (err) { logger.error(`[findOrCreateUser] Account SMS failed for ${normalizedPhone}: ${err.message}`); } @@ -214,6 +226,27 @@ const createPendingStudentAfterPayment = async (body) => { paymentReference: paymentReference || undefined }); + try { + const notify = await resolveNotifyFlags({}, 'pendingRegistration'); + if (notify.sms || notify.email || notify.bot) { + await notifyAction({ + actionKey: 'pendingRegistration', + userId: user._id, + phoneNumber: user.phoneNumber, + email: user.email, + subject: 'دریافت درخواست ثبت‌نام', + body: `درخواست ثبت‌نام شما در «${cls.name}» دریافت شد. کد درخواست: ${pending.uniqueCode || ''}`, + smsHandler: () => sendPendingRegistrationSms(user.phoneNumber, { + fullName: user.name || '', + className: cls.name || '', + registrationCode: pending.uniqueCode || '' + }, user._id) + }); + } + } catch (err) { + logger.error(`[createPendingStudentAfterPayment] Notification failed: ${err.message}`); + } + return PendingStudent.findById(pending._id).populate(POPULATE_LIST).lean(); }; @@ -262,26 +295,27 @@ const getPendingStudentById = async (id) => { return pending; }; -const sendClassRequestApprovedSms = async (user, courseTitle) => { +const sendClassRequestApprovedNotification = async (user, courseTitle, classCode = '') => { if (!user?.phoneNumber) return; - const body = `درخواست تشکیل کلاس جدید برای «${courseTitle}» تأیید شد. به‌زودی با شما هماهنگ می‌کنیم.`; - await recordAndSend({ + await notifyAction({ + actionKey: 'classRequestApproved', userId: user._id, - channel: 'sms', - subject: 'تأیید درخواست کلاس', - body, - relatedEvent: 'class_request.approved', - sendFn: async () => { - logger.info(`[class_request.approved] SMS queued for ${user.phoneNumber}: ${body}`); - return { skipped: true, reason: 'template_pending' }; - } + phoneNumber: user.phoneNumber, + email: user.email, + subject: 'تأیید درخواست تشکیل کلاس', + body: `درخواست تشکیل کلاس «${courseTitle}» تأیید شد.`, + smsHandler: () => sendClassRequestApprovedSms(user.phoneNumber, { + fullName: user.name || '', + courseName: courseTitle, + classCode + }, user._id) }); }; const approvePendingStudent = async (id, actorId, body = {}) => { const pending = await PendingStudent.findById(id) .populate({ path: 'user', select: 'name phoneNumber' }) - .populate({ path: 'class', select: 'name course', populate: { path: 'course', select: 'title' } }) + .populate({ path: 'class', select: 'name course uniqueCode', populate: { path: 'course', select: 'title' } }) .populate({ path: 'course', select: 'title' }); if (!pending) throw new AppError('NOT_FOUND', null, 'درخواست در انتظار یافت نشد.'); @@ -321,7 +355,11 @@ const approvePendingStudent = async (id, actorId, body = {}) => { }, actorId); } else { const courseTitle = pending.course?.title || pending.class?.course?.title || pending.class?.name || 'دوره'; - await sendClassRequestApprovedSms(pending.user, courseTitle); + await sendClassRequestApprovedNotification( + pending.user, + courseTitle, + pending.class?.uniqueCode || '' + ); } await PendingStudent.findByIdAndDelete(id); @@ -330,7 +368,10 @@ const approvePendingStudent = async (id, actorId, body = {}) => { }; const rejectPendingStudent = async (id, actorId, body = {}) => { - const pending = await PendingStudent.findById(id); + const pending = await PendingStudent.findById(id) + .populate({ path: 'user', select: 'name phoneNumber email' }) + .populate({ path: 'class', select: 'name', populate: { path: 'course', select: 'title' } }) + .populate({ path: 'course', select: 'title' }); if (!pending) throw new AppError('NOT_FOUND', null, 'درخواست در انتظار یافت نشد.'); if (!['pending_review', 'pending_payment'].includes(pending.status)) { throw new AppError('VALIDATION_FAILED', null, 'این درخواست قابل رد کردن نیست.'); @@ -340,6 +381,26 @@ const rejectPendingStudent = async (id, actorId, body = {}) => { logger.info(`[rejectPendingStudent] ${id} by ${actorId}: ${String(body.adminNotes).trim()}`); } + try { + const courseTitle = pending.course?.title || pending.class?.course?.title || pending.class?.name || 'دوره'; + await notifyAction({ + actionKey: 'classRequestRejected', + userId: pending.user?._id, + phoneNumber: pending.user?.phoneNumber, + email: pending.user?.email, + subject: 'رد درخواست ثبت‌نام', + body: `درخواست شما برای «${courseTitle}» رد شد.`, + smsHandler: () => sendClassRequestRejectedSms(pending.user.phoneNumber, { + fullName: pending.user?.name || '', + courseName: courseTitle, + reason: body.adminNotes ? String(body.adminNotes).trim() : '—', + registrationCode: pending.uniqueCode || '' + }, pending.user?._id) + }); + } catch (err) { + logger.error(`[rejectPendingStudent] Notification failed: ${err.message}`); + } + await PendingStudent.findByIdAndDelete(id); return { rejected: true }; }; diff --git a/components/settings/notificationActions.js b/components/settings/notificationActions.js new file mode 100644 index 0000000..f4832c9 --- /dev/null +++ b/components/settings/notificationActions.js @@ -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 +}; diff --git a/components/settings/settingModel.js b/components/settings/settingModel.js index 49c5380..0c8cd56 100644 --- a/components/settings/settingModel.js +++ b/components/settings/settingModel.js @@ -27,6 +27,10 @@ const settingSchema = new mongoose.Schema({ botEnabled: { type: Boolean, default: true + }, + notificationSettings: { + type: mongoose.Schema.Types.Mixed, + default: {} } }, { timestamps: true, diff --git a/components/settings/settingService.js b/components/settings/settingService.js index eec995f..382489e 100644 --- a/components/settings/settingService.js +++ b/components/settings/settingService.js @@ -15,6 +15,17 @@ const { getPublicMessaging, invalidateMessagingCache } = require('./messagingFlags'); +const { + emptyNotificationSettingsMap, + mergeNotificationSettings, + parseIncomingNotificationSettings, + NOTIFICATION_ACTION_KEYS +} = require('./notificationActions'); +const { + getPublicNotificationSettings, + invalidateNotificationSettingsCache, + readStoredMap: readNotificationStoredMap +} = require('../../utils/notificationSettings'); const emptyTemplateMap = () => { const map = {}; @@ -64,11 +75,33 @@ const toPublicTemplates = (storedMap) => { }; const getSettings = async () => { - const doc = await Setting.findOne({ key: SETTINGS_KEY }).lean(); + let doc = await Setting.findOne({ key: SETTINGS_KEY }).lean(); + if (!doc) { + const created = await Setting.create({ + key: SETTINGS_KEY, + smsTemplates: emptyTemplateMap(), + notificationSettings: emptyNotificationSettingsMap() + }); + doc = created.toObject(); + } else { + const mergedNotifications = mergeNotificationSettings(readNotificationStoredMap(doc)); + const storedKeys = Object.keys(readNotificationStoredMap(doc)); + const missingAction = NOTIFICATION_ACTION_KEYS.some((key) => !storedKeys.includes(key)); + if (missingAction || storedKeys.length === 0) { + await Setting.updateOne( + { key: SETTINGS_KEY }, + { $set: { notificationSettings: mergedNotifications } } + ); + doc = { ...doc, notificationSettings: mergedNotifications }; + invalidateNotificationSettingsCache(); + } + } + const storedMap = readStoredMap(doc); return { smsTemplates: toPublicTemplates(storedMap), - messaging: getPublicMessaging(doc) + messaging: getPublicMessaging(doc), + notificationSettings: await getPublicNotificationSettings(doc) }; }; @@ -150,13 +183,27 @@ const saveSettings = async (body = {}) => { if (incomingMessaging.botEnabled !== undefined) doc.botEnabled = incomingMessaging.botEnabled; } + const incomingNotificationSettings = parseIncomingNotificationSettings(body.notificationSettings); + if (incomingNotificationSettings !== undefined) { + const storedNotificationMap = existing + ? readNotificationStoredMap(existing.toObject ? existing.toObject() : existing) + : emptyNotificationSettingsMap(); + const nextNotificationMap = mergeNotificationSettings(storedNotificationMap, incomingNotificationSettings); + doc.set('notificationSettings', JSON.parse(JSON.stringify(nextNotificationMap))); + doc.markModified('notificationSettings'); + } else if (!existing) { + doc.set('notificationSettings', emptyNotificationSettingsMap()); + } + await doc.save(); invalidateMessagingCache(); + invalidateNotificationSettingsCache(); const saved = await Setting.findOne({ key: SETTINGS_KEY }).lean(); return { smsTemplates: toPublicTemplates(readStoredMap(saved)), - messaging: getPublicMessaging(saved) + messaging: getPublicMessaging(saved), + notificationSettings: await getPublicNotificationSettings(saved) }; }; diff --git a/components/settings/smsTemplates.js b/components/settings/smsTemplates.js index b1f4267..5236dfc 100644 --- a/components/settings/smsTemplates.js +++ b/components/settings/smsTemplates.js @@ -9,7 +9,8 @@ const SMS_TEMPLATE_DEFS = [ { key: 'username', label: 'نام کاربری', defaultName: 'user' }, { key: 'password', label: 'رمز عبور', defaultName: 'password' }, { key: 'fullName', label: 'نام کاربر', defaultName: 'name' }, - { key: 'phoneNumber', label: 'شماره همراه', defaultName: 'mobile' } + { key: 'phoneNumber', label: 'شماره همراه', defaultName: 'mobile' }, + { key: 'userCode', label: 'کد کاربری', defaultName: 'userCode' } ] }, { @@ -23,7 +24,8 @@ const SMS_TEMPLATE_DEFS = [ { key: 'phoneNumber', label: 'شماره همراه', defaultName: 'mobile' }, { key: 'classStartDate', label: 'تاریخ شروع کلاس', defaultName: 'classStartDate' }, { key: 'classDays', label: 'روزهای برگزاری', defaultName: 'classDays' }, - { key: 'courseTime', label: 'ساعت دوره', defaultName: 'courseTime' } + { key: 'courseTime', label: 'ساعت دوره', defaultName: 'courseTime' }, + { key: 'classCode', label: 'کد کلاس', defaultName: 'classCode' } ] }, { @@ -33,13 +35,17 @@ const SMS_TEMPLATE_DEFS = [ slots: [ { key: 'className', label: 'نام کلاس', defaultName: 'className' }, { key: 'courseName', label: 'نام دوره', defaultName: 'courseName' }, + { key: 'topic', label: 'موضوع جلسه', defaultName: 'topic' }, { key: 'time', label: 'ساعت', defaultName: 'time' }, + { key: 'classTime', label: 'ساعت کلاس', defaultName: 'classTime' }, + { key: 'sessionDate', label: 'تاریخ جلسه', defaultName: 'sessionDate' }, { key: 'place', label: 'مکان', defaultName: 'place' }, { key: 'fullName', label: 'نام کاربر', defaultName: 'fullName' }, { key: 'phoneNumber', label: 'شماره همراه', defaultName: 'mobile' }, { key: 'classStartDate', label: 'تاریخ شروع کلاس', defaultName: 'classStartDate' }, { key: 'classDays', label: 'روزهای برگزاری', defaultName: 'classDays' }, - { key: 'courseTime', label: 'ساعت دوره', defaultName: 'courseTime' } + { key: 'courseTime', label: 'ساعت دوره', defaultName: 'courseTime' }, + { key: 'classCode', label: 'کد کلاس', defaultName: 'classCode' } ] }, { @@ -53,7 +59,135 @@ const SMS_TEMPLATE_DEFS = [ { key: 'phoneNumber', label: 'شماره همراه', defaultName: 'MOBILE' }, { key: 'classStartDate', label: 'تاریخ شروع کلاس', defaultName: 'classStartDate' }, { key: 'classDays', label: 'روزهای برگزاری', defaultName: 'classDays' }, - { key: 'courseTime', label: 'ساعت دوره', defaultName: 'courseTime' } + { key: 'courseTime', label: 'ساعت دوره', defaultName: 'courseTime' }, + { key: 'invoiceCode', label: 'کد صورتحساب', defaultName: 'invoiceCode' } + ] + }, + { + key: 'passwordReset', + label: 'بازنشانی رمز عبور', + envKey: 'SMS_TEMPLATE_PASSWORD_RESET', + slots: [ + { key: 'username', label: 'نام کاربری', defaultName: 'user' }, + { key: 'password', label: 'رمز عبور', defaultName: 'password' }, + { key: 'fullName', label: 'نام کاربر', defaultName: 'name' }, + { key: 'phoneNumber', label: 'شماره همراه', defaultName: 'mobile' }, + { key: 'userCode', label: 'کد کاربری', defaultName: 'userCode' } + ] + }, + { + key: 'paymentStatusChanged', + label: 'تغییر وضعیت پرداخت', + envKey: 'SMS_TEMPLATE_PAYMENT_STATUS_CHANGED', + slots: [ + { key: 'fullName', label: 'نام کارآموز', defaultName: 'fullName' }, + { key: 'status', label: 'وضعیت', defaultName: 'status' }, + { key: 'amount', label: 'مبلغ', defaultName: 'amount' }, + { key: 'invoiceCode', label: 'کد صورتحساب', defaultName: 'invoiceCode' }, + { key: 'course', label: 'نام دوره', defaultName: 'course' }, + { key: 'phoneNumber', label: 'شماره همراه', defaultName: 'mobile' } + ] + }, + { + key: 'paymentReminder', + label: 'یادآوری سررسید پرداخت', + envKey: 'SMS_TEMPLATE_PAYMENT_REMINDER', + slots: [ + { key: 'fullName', label: 'نام کارآموز', defaultName: 'fullName' }, + { key: 'amount', label: 'مبلغ باقی‌مانده', defaultName: 'amount' }, + { key: 'dueDate', label: 'تاریخ سررسید', defaultName: 'dueDate' }, + { key: 'invoiceCode', label: 'کد صورتحساب', defaultName: 'invoiceCode' }, + { key: 'course', label: 'نام دوره', defaultName: 'course' }, + { key: 'phoneNumber', label: 'شماره همراه', defaultName: 'mobile' } + ] + }, + { + key: 'transactionRecorded', + label: 'ثبت تراکنش', + envKey: 'SMS_TEMPLATE_TRANSACTION_RECORDED', + slots: [ + { key: 'fullName', label: 'نام کارآموز', defaultName: 'fullName' }, + { key: 'amount', label: 'مبلغ', defaultName: 'amount' }, + { key: 'invoiceCode', label: 'کد صورتحساب', defaultName: 'invoiceCode' }, + { key: 'transactionCode', label: 'کد تراکنش', defaultName: 'transactionCode' }, + { key: 'receiptNumber', label: 'شماره رسید', defaultName: 'receiptNumber' }, + { key: 'phoneNumber', label: 'شماره همراه', defaultName: 'mobile' } + ] + }, + { + key: 'sessionCancelled', + label: 'لغو جلسه', + envKey: 'SMS_TEMPLATE_SESSION_CANCELLED', + slots: [ + { key: 'fullName', label: 'نام کارآموز', defaultName: 'fullName' }, + { key: 'className', label: 'نام کلاس', defaultName: 'className' }, + { key: 'topic', label: 'موضوع جلسه', defaultName: 'topic' }, + { key: 'sessionDate', label: 'تاریخ جلسه', defaultName: 'sessionDate' }, + { key: 'reason', label: 'دلیل لغو', defaultName: 'reason' }, + { key: 'classCode', label: 'کد کلاس', defaultName: 'classCode' }, + { key: 'phoneNumber', label: 'شماره همراه', defaultName: 'mobile' } + ] + }, + { + key: 'sessionHolding', + label: 'برگزاری جلسه طبق برنامه', + envKey: 'SMS_TEMPLATE_SESSION_HOLDING', + slots: [ + { key: 'fullName', label: 'نام کارآموز', defaultName: 'FULLNAME' }, + { key: 'topic', label: 'موضوع جلسه', defaultName: 'TOPIC' }, + { key: 'className', label: 'نام کلاس', defaultName: 'CLASSNAME' }, + { key: 'sessionDate', label: 'تاریخ جلسه', defaultName: 'SESSIONDATE' }, + { key: 'classTime', label: 'ساعت کلاس', defaultName: 'CLASSTIME' }, + { key: 'courseName', label: 'نام دوره', defaultName: 'courseName' }, + { key: 'classCode', label: 'کد کلاس', defaultName: 'classCode' }, + { key: 'place', label: 'مکان', defaultName: 'place' }, + { key: 'phoneNumber', label: 'شماره همراه', defaultName: 'mobile' } + ] + }, + { + key: 'classRequestApproved', + label: 'تأیید درخواست تشکیل کلاس', + envKey: 'SMS_TEMPLATE_CLASS_REQUEST_APPROVED', + slots: [ + { key: 'fullName', label: 'نام کارآموز', defaultName: 'fullName' }, + { key: 'courseName', label: 'نام دوره', defaultName: 'courseName' }, + { key: 'classCode', label: 'کد کلاس', defaultName: 'classCode' }, + { key: 'phoneNumber', label: 'شماره همراه', defaultName: 'mobile' } + ] + }, + { + key: 'classRequestRejected', + label: 'رد درخواست ثبت‌نام', + envKey: 'SMS_TEMPLATE_CLASS_REQUEST_REJECTED', + slots: [ + { key: 'fullName', label: 'نام کارآموز', defaultName: 'fullName' }, + { key: 'courseName', label: 'نام دوره', defaultName: 'courseName' }, + { key: 'reason', label: 'دلیل', defaultName: 'reason' }, + { key: 'registrationCode', label: 'کد درخواست', defaultName: 'registrationCode' }, + { key: 'phoneNumber', label: 'شماره همراه', defaultName: 'mobile' } + ] + }, + { + key: 'pendingRegistration', + label: 'دریافت درخواست ثبت‌نام', + envKey: 'SMS_TEMPLATE_PENDING_REGISTRATION', + slots: [ + { key: 'fullName', label: 'نام کارآموز', defaultName: 'fullName' }, + { key: 'className', label: 'نام کلاس', defaultName: 'className' }, + { key: 'registrationCode', label: 'کد درخواست', defaultName: 'registrationCode' }, + { key: 'phoneNumber', label: 'شماره همراه', defaultName: 'mobile' } + ] + }, + { + key: 'certificateIssued', + label: 'صدور گواهینامه', + envKey: 'SMS_TEMPLATE_CERTIFICATE_ISSUED', + slots: [ + { key: 'fullName', label: 'نام کارآموز', defaultName: 'fullName' }, + { key: 'certificateTitle', label: 'عنوان گواهینامه', defaultName: 'certificateTitle' }, + { key: 'certificateCode', label: 'کد گواهینامه', defaultName: 'certificateCode' }, + { key: 'courseName', label: 'نام دوره', defaultName: 'courseName' }, + { key: 'phoneNumber', label: 'شماره همراه', defaultName: 'mobile' } ] } ]; diff --git a/components/settings/smsTemplates.test.js b/components/settings/smsTemplates.test.js index e0aa9e0..1db975d 100644 --- a/components/settings/smsTemplates.test.js +++ b/components/settings/smsTemplates.test.js @@ -76,4 +76,20 @@ describe('SMS template variables', () => { assert.ok(slots.includes('username')); assert.ok(slots.includes('password')); }); + + it('exposes reason slot on the sessionCancelled template', () => { + const def = SMS_TEMPLATE_DEFS.find((item) => item.key === 'sessionCancelled'); + const slots = (def?.slots || []).map((slot) => slot.key); + assert.ok(slots.includes('reason')); + }); + + it('defines the sessionHolding template with all expected slots', () => { + const def = SMS_TEMPLATE_DEFS.find((item) => item.key === 'sessionHolding'); + assert.ok(def, 'sessionHolding template definition missing'); + const slots = (def.slots || []).map((slot) => slot.key); + const expected = ['fullName', 'topic', 'className', 'sessionDate', 'classTime']; + for (const slot of expected) { + assert.ok(slots.includes(slot), `sessionHolding is missing slot ${slot}`); + } + }); }); diff --git a/components/users/userModel.js b/components/users/userModel.js index 5684ae1..658f8a1 100644 --- a/components/users/userModel.js +++ b/components/users/userModel.js @@ -1,6 +1,7 @@ // /components/users/userModel.js const mongoose = require('mongoose'); +const uniqueCodePlugin = require('../../utils/mongooseUniqueCodePlugin'); const refreshTokenSchema = new mongoose.Schema({ token: { type: String, required: true }, @@ -131,4 +132,6 @@ const userSchema = new mongoose.Schema({ timestamps: true }); +userSchema.plugin(uniqueCodePlugin); + module.exports = mongoose.model('User', userSchema); diff --git a/components/users/userService.js b/components/users/userService.js index c21fd86..ee58417 100644 --- a/components/users/userService.js +++ b/components/users/userService.js @@ -11,7 +11,8 @@ const { generateUsername, generateSimplePassword } = require('../../utils/creden const { sendAccountCreatedSms, sendPasswordResetSms } = require('../../utils/senders/smsMessages'); const logger = require('../../utils/logger'); const { mergeFullName, normalizeGender } = require('../../utils/userProfile'); -const { pickNotifyFlags } = require('../../utils/notifyFlags'); +const { resolveNotifyFlags } = require('../../utils/notifyResolver'); +const { notifyAction } = require('../../utils/actionNotify'); const { assertCanResetPasswordAndSms, isCredentialsSmsDelivered @@ -180,8 +181,24 @@ const createUserAdmin = async (body) => { }); try { - if (pickNotifyFlags(body).sms) { - await sendAccountCreatedSms(profile.phoneNumber, username, plainPassword, user._id); + const notify = await resolveNotifyFlags(body, 'accountCreated'); + if (notify.sms || notify.email || notify.bot) { + await notifyAction({ + actionKey: 'accountCreated', + userId: user._id, + phoneNumber: profile.phoneNumber, + email: profile.email, + subject: 'ایجاد حساب کاربری', + body: `حساب کاربری شما ایجاد شد. نام کاربری: ${username} — کد کاربری: ${user.uniqueCode || ''}`, + smsHandler: () => sendAccountCreatedSms( + profile.phoneNumber, + username, + plainPassword, + user._id, + user.uniqueCode + ), + requestSource: body + }); } } catch (err) { logger.error(`[createUserAdmin] Account SMS failed for ${profile.phoneNumber}: ${err.message}`); @@ -287,8 +304,19 @@ const resetPasswordAndSendSms = async (id) => { let smsSent = false; try { - const sendResult = await sendPasswordResetSms(phoneNumber, username, plainPassword, user._id); - smsSent = isCredentialsSmsDelivered(sendResult); + const notify = await resolveNotifyFlags({}, 'passwordReset'); + if (notify.sms || notify.email || notify.bot) { + const sendResult = await notifyAction({ + actionKey: 'passwordReset', + userId: user._id, + phoneNumber, + email: user.email, + subject: 'بازنشانی رمز عبور', + body: `رمز عبور شما بازنشانی شد. نام کاربری: ${username} — کد کاربری: ${user.uniqueCode || ''}`, + smsHandler: () => sendPasswordResetSms(phoneNumber, username, plainPassword, user._id, user.uniqueCode) + }); + smsSent = isCredentialsSmsDelivered(sendResult?.sms); + } } catch (err) { logger.error(`[resetPasswordAndSendSms] SMS failed for user=${user._id}: ${err.message}`); } diff --git a/constants/eventNames.js b/constants/eventNames.js index 7dd89c2..b83c0a1 100644 --- a/constants/eventNames.js +++ b/constants/eventNames.js @@ -13,6 +13,8 @@ const EVENT_NAMES = { SESSION_CREATED: 'session.created', SESSION_CANCELLED: 'session.cancelled', + SESSION_HOLDING: 'session.holding', + SESSION_REMINDER: 'session.reminder', ATTENDANCE_RECORDED: 'attendance.recorded', ATTENDANCE_BULK_RECORDED: 'attendance.bulk_recorded', diff --git a/events/eventListeners.js b/events/eventListeners.js index 61281d8..b344c73 100644 --- a/events/eventListeners.js +++ b/events/eventListeners.js @@ -5,11 +5,11 @@ const EVENT_NAMES = require('../constants/eventNames'); const EventLog = require('./eventLogModel'); const Notification = require('../components/notifications/notificationModel'); const User = require('../components/users/userModel'); -const Course = require('../components/courses/courseModel'); +const Session = require('../components/sessions/sessionModel'); const logger = require('../utils/logger'); -const { sendEmail } = require('../utils/senders/emailSender'); -const { sendSMS } = require('../utils/senders/smsSender'); -const { sendBaleMessage } = require('../utils/senders/baleBotSender'); +const { notifyAction } = require('../utils/actionNotify'); +const { sendSessionCancelledSms, sendSessionHoldingSms } = require('../utils/senders/smsMessages'); +const { resolveNotifyFlags } = require('../utils/notifyResolver'); const safeEventListener = (handler) => { return async (payload) => { @@ -33,115 +33,133 @@ const recordEventLog = async (eventName, payload, actor = null) => { } }; -const dispatchNotification = async ({ userId, preferredChannel, subject, body, relatedEvent }) => { - try { - const user = await User.findById(userId); - if (!user) return; - - const channelMap = { - Email: 'email', - SMS: 'sms', - Bale: 'baleBot', - WhatsApp: 'sms', - Telegram: 'baleBot' - }; - - const preferred = Array.isArray(user.preferredMessenger) - ? user.preferredMessenger[0] - : user.preferredMessenger; - const channel = channelMap[preferred || preferredChannel] || 'sms'; - - const notification = await Notification.create({ - user: userId, - channel, - subject: subject || 'Institution Notification', - body, - status: 'pending', - relatedEvent - }); - - let sendResult; - try { - if (channel === 'email' && user.email) { - sendResult = await sendEmail({ to: user.email, subject, body }); - } else if (channel === 'baleBot') { - sendResult = await sendBaleMessage({ chatId: user.phoneNumber, body }); - } else { - sendResult = await sendSMS({ phoneNumber: user.phoneNumber, body }); - } - - notification.status = 'sent'; - notification.sentAt = new Date(); - await notification.save(); - eventEmitter.emit(EVENT_NAMES.NOTIFICATION_SENT, { notificationId: notification._id }); - } catch (sendErr) { - notification.status = 'failed'; - notification.lastError = sendErr.message; - notification.retryCount = 1; - await notification.save(); - eventEmitter.emit(EVENT_NAMES.NOTIFICATION_FAILED, { notificationId: notification._id, error: sendErr.message }); - } - } catch (error) { - logger.error(`[DispatchNotification Error]: ${error.message}`); - } -}; - const registerEventListeners = () => { - // Listener for USER_ENROLLED eventEmitter.on(EVENT_NAMES.USER_ENROLLED, safeEventListener(async (payload) => { - const { userId, courseId, actorId } = payload; + const { actorId } = payload; await recordEventLog(EVENT_NAMES.USER_ENROLLED, payload, actorId); - - const course = await Course.findById(courseId); - const courseTitle = course ? course.title : 'Course'; - - await dispatchNotification({ - userId, - subject: 'Course Enrollment Confirmation', - body: `You have been successfully enrolled in ${courseTitle}.`, - relatedEvent: EVENT_NAMES.USER_ENROLLED - }); })); - // Listener for PAYMENT_STATUS_CHANGED eventEmitter.on(EVENT_NAMES.PAYMENT_STATUS_CHANGED, safeEventListener(async (payload) => { - const { paymentId, userId, newStatus, actorId } = payload; + const { actorId } = payload; await recordEventLog(EVENT_NAMES.PAYMENT_STATUS_CHANGED, payload, actorId); - - await dispatchNotification({ - userId, - subject: 'Payment Status Update', - body: `Your payment status has been updated to: ${newStatus}.`, - relatedEvent: EVENT_NAMES.PAYMENT_STATUS_CHANGED - }); })); - // Listener for COURSE_PRICE_CHANGED eventEmitter.on(EVENT_NAMES.COURSE_PRICE_CHANGED, safeEventListener(async (payload) => { const { courseId, oldPrice, newPrice, actorId } = payload; await recordEventLog(EVENT_NAMES.COURSE_PRICE_CHANGED, payload, actorId); logger.info(`[Event] Course ${courseId} price changed from ${oldPrice} to ${newPrice}`); })); - // Listener for SESSION_CANCELLED eventEmitter.on(EVENT_NAMES.SESSION_CANCELLED, safeEventListener(async (payload) => { - const { sessionId, courseId, topic, actorId } = payload; + const { sessionId, courseId, topic, reason, actorId } = payload; await recordEventLog(EVENT_NAMES.SESSION_CANCELLED, payload, actorId); - const enrolledUsers = await User.find({ courses: courseId }); - for (const student of enrolledUsers) { - await dispatchNotification({ - userId: student._id, - subject: 'Session Cancellation Notice', - body: `The session for topic "${topic || 'Upcoming Class'}" has been cancelled.`, - relatedEvent: EVENT_NAMES.SESSION_CANCELLED - }); + const notify = await resolveNotifyFlags({}, 'sessionCancelled'); + if (!notify.sms && !notify.email && !notify.bot) return; + + const session = sessionId + ? await Session.findById(sessionId).populate({ path: 'class', select: 'name uniqueCode students' }).lean() + : null; + const classDoc = session?.class; + const studentIds = classDoc?.students?.length + ? classDoc.students + : (await User.find({ courses: courseId }).select('_id')).map((u) => u._id); + + if (!studentIds.length) return; + + const sessionDate = session?.day + ? new Date(session.day).toLocaleDateString('fa-IR') + : ''; + const className = classDoc?.name || 'کلاس'; + const classCode = classDoc?.uniqueCode || ''; + + const users = await User.find({ _id: { $in: studentIds } }).select('name phoneNumber email').lean(); + for (const student of users) { + if (!student.phoneNumber) continue; + try { + await notifyAction({ + actionKey: 'sessionCancelled', + userId: student._id, + phoneNumber: student.phoneNumber, + email: student.email, + subject: 'لغو جلسه', + body: `جلسه «${topic || className}» لغو شد.`, + smsHandler: () => sendSessionCancelledSms(student.phoneNumber, { + fullName: student.name || '', + className, + topic: topic || className, + sessionDate, + reason: reason || 'اعلام آموزشگاه', + classCode + }, student._id) + }); + } catch (err) { + logger.error(`[SESSION_CANCELLED] Notification failed for user ${student._id}: ${err.message}`); + } } })); - // Generic audit logger for all other events - Object.values(EVENT_NAMES).forEach(eventName => { - if (![EVENT_NAMES.USER_ENROLLED, EVENT_NAMES.PAYMENT_STATUS_CHANGED, EVENT_NAMES.COURSE_PRICE_CHANGED, EVENT_NAMES.SESSION_CANCELLED].includes(eventName)) { + eventEmitter.on(EVENT_NAMES.SESSION_HOLDING, safeEventListener(async (payload) => { + const { sessionId, courseId, topic, classTime, actorId } = payload; + await recordEventLog(EVENT_NAMES.SESSION_HOLDING, payload, actorId); + + const notify = await resolveNotifyFlags({}, 'sessionHolding'); + if (!notify.sms && !notify.email && !notify.bot) return; + + const session = sessionId + ? await Session.findById(sessionId).populate({ path: 'class', select: 'name uniqueCode students startTime place' }).populate('course', 'title').lean() + : null; + const classDoc = session?.class; + const studentIds = classDoc?.students?.length + ? classDoc.students + : (await User.find({ courses: courseId }).select('_id')).map((u) => u._id); + + if (!studentIds.length) return; + + const sessionDate = session?.day + ? new Date(session.day).toLocaleDateString('fa-IR') + : ''; + const className = classDoc?.name || session?.course?.title || 'کلاس'; + const classCode = classDoc?.uniqueCode || ''; + const timeLabel = classTime || session?.startTime || ''; + const topicLabel = topic || session?.topic || className; + + const users = await User.find({ _id: { $in: studentIds } }).select('name phoneNumber email').lean(); + for (const student of users) { + if (!student.phoneNumber) continue; + try { + await notifyAction({ + actionKey: 'sessionHolding', + userId: student._id, + phoneNumber: student.phoneNumber, + email: student.email, + subject: 'برگزاری جلسه طبق برنامه', + body: `جلسه «${topicLabel}» کلاس ${className} در تاریخ ${sessionDate} و ساعت ${timeLabel} طبق برنامه برگزار خواهد شد.`, + smsHandler: () => sendSessionHoldingSms(student.phoneNumber, { + fullName: student.name || '', + className, + topic: topicLabel, + sessionDate, + classTime: timeLabel, + courseName: session?.course?.title || className, + classCode, + place: session?.place || '-' + }, student._id) + }); + } catch (err) { + logger.error(`[SESSION_HOLDING] Notification failed for user ${student._id}: ${err.message}`); + } + } + })); + + Object.values(EVENT_NAMES).forEach((eventName) => { + if (![ + EVENT_NAMES.USER_ENROLLED, + EVENT_NAMES.PAYMENT_STATUS_CHANGED, + EVENT_NAMES.COURSE_PRICE_CHANGED, + EVENT_NAMES.SESSION_CANCELLED, + EVENT_NAMES.SESSION_HOLDING + ].includes(eventName)) { eventEmitter.on(eventName, safeEventListener(async (payload) => { await recordEventLog(eventName, payload); })); diff --git a/jobs/classReminderJob.js b/jobs/classReminderJob.js index 80d413f..de46434 100644 --- a/jobs/classReminderJob.js +++ b/jobs/classReminderJob.js @@ -6,6 +6,8 @@ const Session = require('../components/sessions/sessionModel'); const User = require('../components/users/userModel'); const { sendClassReminderSms } = require('../utils/senders/smsMessages'); const { buildClassScheduleContext } = require('../utils/classSchedule'); +const { notifyAction } = require('../utils/actionNotify'); +const { resolveNotifyFlags } = require('../utils/notifyResolver'); const logger = require('../utils/logger'); const parseStartDateTime = (session) => { @@ -37,7 +39,7 @@ const runClassReminderJob = async () => { reminderSentAt: null, day: { $gte: dayFrom, $lte: dayTo }, }) - .populate({ path: 'class', select: 'name students startDate days startTime endTime' }) + .populate({ path: 'class', select: 'name students startDate days startTime endTime uniqueCode' }) .populate({ path: 'course', select: 'title' }) .lean(); @@ -60,27 +62,43 @@ const runClassReminderJob = async () => { : []; const schedule = buildClassScheduleContext(classDoc, classSessions, session); - const users = await User.find({ _id: { $in: studentIds } }).select('phoneNumber').lean(); - await Promise.all( - users.map(async (user) => { - if (!user.phoneNumber) return; - try { - await sendClassReminderSms( - user.phoneNumber, - classLabel, - timeLabel, - placeLabel, - user._id, - { - courseName: session.course?.title || classLabel, - ...schedule - } - ); - } catch (err) { - logger.error(`[ClassReminderJob] SMS failed for ${user.phoneNumber}: ${err.message}`); - } - }) - ); + const users = await User.find({ _id: { $in: studentIds } }).select('phoneNumber email name').lean(); + const notify = await resolveNotifyFlags({}, 'classReminder'); + if (notify.sms || notify.email || notify.bot) { + await Promise.all( + users.map(async (user) => { + if (!user.phoneNumber) return; + try { + await notifyAction({ + actionKey: 'classReminder', + userId: user._id, + phoneNumber: user.phoneNumber, + email: user.email, + subject: 'یادآوری کلاس', + body: `یادآوری کلاس «${classLabel}» ساعت ${timeLabel} — مکان: ${placeLabel || '-'}`, + smsHandler: () => sendClassReminderSms( + user.phoneNumber, + classLabel, + timeLabel, + placeLabel, + user._id, + { + fullName: user.name, + topic: session.topic || classLabel, + sessionDate: session.day ? new Date(session.day).toLocaleDateString('fa-IR') : '', + classTime: timeLabel || session.startTime || '', + courseName: session.course?.title || classLabel, + classCode: classDoc?.uniqueCode || '', + ...schedule + } + ) + }); + } catch (err) { + logger.error(`[ClassReminderJob] Notification failed for ${user.phoneNumber}: ${err.message}`); + } + }) + ); + } await Session.updateOne({ _id: session._id }, { reminderSentAt: new Date() }); logger.info(`[ClassReminderJob] Reminders sent for session ${session._id}`); diff --git a/jobs/paymentReminderJob.js b/jobs/paymentReminderJob.js index 3332774..b510c3d 100644 --- a/jobs/paymentReminderJob.js +++ b/jobs/paymentReminderJob.js @@ -2,9 +2,51 @@ const cron = require('node-cron'); const Payment = require('../components/payments/paymentModel'); +const User = require('../components/users/userModel'); +const Course = require('../components/courses/courseModel'); const eventEmitter = require('../events/eventEmitter'); const EVENT_NAMES = require('../constants/eventNames'); const logger = require('../utils/logger'); +const { getPayableAmount } = require('../utils/paymentAmount'); +const { notifyAction } = require('../utils/actionNotify'); +const { sendPaymentReminderSms } = require('../utils/senders/smsMessages'); +const { resolveNotifyFlags } = require('../utils/notifyResolver'); + +const formatDueDate = (date) => { + if (!date) return ''; + const parsed = new Date(date); + if (Number.isNaN(parsed.getTime())) return ''; + return parsed.toLocaleDateString('fa-IR'); +}; + +const notifyPaymentReminder = async (payment, user) => { + const notify = await resolveNotifyFlags({}, 'paymentReminder'); + if (!notify.sms && !notify.email && !notify.bot) return; + + let courseName = '-'; + if (payment.course) { + const course = await Course.findById(payment.course).select('title').lean(); + if (course?.title) courseName = course.title; + } + + const amountDue = getPayableAmount(payment) - (payment.paidAmount || 0); + + await notifyAction({ + actionKey: 'paymentReminder', + userId: user._id, + phoneNumber: user.phoneNumber, + email: user.email, + subject: 'یادآوری سررسید پرداخت', + body: `یادآوری: مبلغ ${amountDue} تومان تا ${formatDueDate(payment.dueDate)} سررسید دارد. کد صورتحساب: ${payment.uniqueCode || ''}`, + smsHandler: () => sendPaymentReminderSms(user.phoneNumber, { + fullName: user.name || '', + amount: amountDue, + dueDate: formatDueDate(payment.dueDate), + invoiceCode: payment.uniqueCode || '', + course: courseName + }, user._id) + }); +}; const runPaymentReminderJob = async () => { try { @@ -12,37 +54,41 @@ const runPaymentReminderJob = async () => { const threeDaysFromNow = new Date(); threeDaysFromNow.setDate(now.getDate() + 3); - // Find pending/partiallyPaid payments due in the next 3 days const upcomingPayments = await Payment.find({ - status: { $in: ['pending', 'partiallyPaid'] }, + status: { $in: ['pending', 'partial'] }, dueDate: { $gte: now, $lte: threeDaysFromNow } - }).populate('user'); + }).populate({ path: 'user', select: 'name phoneNumber email' }); for (const payment of upcomingPayments) { if (payment.user) { eventEmitter.emit(EVENT_NAMES.PAYMENT_REMINDER_DUE, { paymentId: payment._id, userId: payment.user._id, - amountDue: payment.amount - payment.amountPaid, + amountDue: getPayableAmount(payment) - (payment.paidAmount || 0), dueDate: payment.dueDate }); + try { + await notifyPaymentReminder(payment, payment.user); + } catch (err) { + logger.error(`[PaymentReminderJob] Notification failed for payment ${payment._id}: ${err.message}`); + } } } - // Find overdue payments and update status to overdue const overduePayments = await Payment.find({ - status: { $in: ['pending', 'partiallyPaid'] }, + status: { $in: ['pending', 'partial'] }, dueDate: { $lt: now } }); for (const payment of overduePayments) { + const previousStatus = payment.status; payment.status = 'overdue'; await payment.save(); if (payment.user) { eventEmitter.emit(EVENT_NAMES.PAYMENT_STATUS_CHANGED, { paymentId: payment._id, userId: payment.user, - oldStatus: 'pending', + oldStatus: previousStatus, newStatus: 'overdue' }); } @@ -53,7 +99,6 @@ const runPaymentReminderJob = async () => { }; const startPaymentReminderJob = () => { - // Run daily at midnight cron.schedule('0 0 * * *', async () => { await runPaymentReminderJob(); }); diff --git a/scripts/convert-attendance-payments.js b/scripts/convert-attendance-payments.js new file mode 100644 index 0000000..daa913c --- /dev/null +++ b/scripts/convert-attendance-payments.js @@ -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(); diff --git a/utils/actionNotify.js b/utils/actionNotify.js new file mode 100644 index 0000000..6d045f0 --- /dev/null +++ b/utils/actionNotify.js @@ -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 +}; diff --git a/utils/mongooseUniqueCodePlugin.js b/utils/mongooseUniqueCodePlugin.js new file mode 100644 index 0000000..1b6fac9 --- /dev/null +++ b/utils/mongooseUniqueCodePlugin.js @@ -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; diff --git a/utils/notificationSettings.js b/utils/notificationSettings.js new file mode 100644 index 0000000..84c5877 --- /dev/null +++ b/utils/notificationSettings.js @@ -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 +}; diff --git a/utils/notifyResolver.js b/utils/notifyResolver.js new file mode 100644 index 0000000..d45f1de --- /dev/null +++ b/utils/notifyResolver.js @@ -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 +}; diff --git a/utils/senders/smsMessages.js b/utils/senders/smsMessages.js index a442af9..8f63788 100644 --- a/utils/senders/smsMessages.js +++ b/utils/senders/smsMessages.js @@ -13,22 +13,26 @@ const resolveUserIdByPhone = async (phoneNumber) => { return user?._id || null; }; -const sendAccountCredentialsSms = async ({ +const resolveUserName = async (userId, fallback = '') => { + if (!userId) return fallback; + const user = await User.findById(userId).select('name uniqueCode').lean(); + return { + fullName: user?.name || fallback, + userCode: user?.uniqueCode || '' + }; +}; + +const sendTemplateSms = async ({ + templateKey, receiver, - username, - password, userId = null, subject, body, - relatedEvent + relatedEvent, + slotValues = {} }) => { const resolvedUserId = userId || await resolveUserIdByPhone(receiver); - let fullName = ''; - if (resolvedUserId) { - const user = await User.findById(resolvedUserId).select('name').lean(); - if (user?.name) fullName = user.name; - } - const { templateId, variables } = await getSmsTemplate('accountCreated'); + const { templateId, variables } = await getSmsTemplate(templateKey); return recordAndSend({ userId: resolvedUserId, channel: 'sms', @@ -36,37 +40,74 @@ const sendAccountCredentialsSms = async ({ body, relatedEvent, sendFn: () => sendSingleSms(receiver, templateId, buildSmsParameters(variables, { - username, - password, - fullName: fullName || username, - name: fullName || username, phoneNumber: receiver, - mobile: receiver + mobile: receiver, + ...slotValues })) }); }; -const sendAccountCreatedSms = async (receiver, username, password, userId = null) => { - return sendAccountCredentialsSms({ +const sendAccountCredentialsSms = async ({ + receiver, + username, + password, + userId = null, + subject, + body, + relatedEvent, + templateKey = 'accountCreated', + userCode = '' +}) => { + const resolvedUserId = userId || await resolveUserIdByPhone(receiver); + let fullName = ''; + let code = userCode; + if (resolvedUserId) { + const profile = await resolveUserName(resolvedUserId, username); + fullName = profile.fullName; + code = code || profile.userCode; + } + return sendTemplateSms({ + templateKey, receiver, - username, - password, - userId, - subject: 'ایجاد حساب کاربری', - body: `حساب کاربری شما ایجاد شد. نام کاربری: ${username}`, - relatedEvent: 'user.created' + userId: resolvedUserId, + subject, + body, + relatedEvent, + slotValues: { + username, + password, + fullName: fullName || username, + name: fullName || username, + userCode: code + } }); }; -const sendPasswordResetSms = async (receiver, username, password, userId = null) => { +const sendAccountCreatedSms = async (receiver, username, password, userId = null, userCode = '') => { return sendAccountCredentialsSms({ receiver, username, password, userId, + userCode, + subject: 'ایجاد حساب کاربری', + body: `حساب کاربری شما ایجاد شد. نام کاربری: ${username}`, + relatedEvent: 'user.created', + templateKey: 'accountCreated' + }); +}; + +const sendPasswordResetSms = async (receiver, username, password, userId = null, userCode = '') => { + return sendAccountCredentialsSms({ + receiver, + username, + password, + userId, + userCode, subject: 'بازنشانی رمز عبور', body: `رمز عبور شما بازنشانی شد. نام کاربری: ${username}`, - relatedEvent: 'user.password_reset' + relatedEvent: 'user.password_reset', + templateKey: 'passwordReset' }); }; @@ -74,27 +115,26 @@ const sendClassRegisteredSms = async (receiver, className, userId = null, extraC const resolvedUserId = userId || await resolveUserIdByPhone(receiver); let fullName = extraContext.fullName || ''; if (!fullName && resolvedUserId) { - const user = await User.findById(resolvedUserId).select('name').lean(); - if (user?.name) fullName = user.name; + const profile = await resolveUserName(resolvedUserId); + fullName = profile.fullName; } - const { templateId, variables } = await getSmsTemplate('classRegistered'); - return recordAndSend({ + return sendTemplateSms({ + templateKey: 'classRegistered', + receiver, userId: resolvedUserId, - channel: 'sms', subject: 'ثبت‌نام در کلاس', body: `ثبت‌نام شما در کلاس «${className}» انجام شد.`, relatedEvent: 'user.enrolled', - sendFn: () => sendSingleSms(receiver, templateId, buildSmsParameters(variables, { + slotValues: { className, courseName: extraContext.courseName || className, fullName: fullName || 'کارآموز', - phoneNumber: receiver, - mobile: receiver, classStartDate: extraContext.classStartDate || '', classDays: extraContext.classDays || '', courseTime: extraContext.courseTime || '', + classCode: extraContext.classCode || '', ...extraContext - })) + } }); }; @@ -102,74 +142,226 @@ const sendClassReminderSms = async (receiver, className, time, place = '', userI const resolvedUserId = userId || await resolveUserIdByPhone(receiver); let fullName = extraContext.fullName || ''; if (!fullName && resolvedUserId) { - const user = await User.findById(resolvedUserId).select('name').lean(); - if (user?.name) fullName = user.name; + const profile = await resolveUserName(resolvedUserId); + fullName = profile.fullName; } - const { templateId, variables } = await getSmsTemplate('classReminder'); - return recordAndSend({ + return sendTemplateSms({ + templateKey: 'classReminder', + receiver, userId: resolvedUserId, - channel: 'sms', subject: 'یادآوری کلاس', body: `یادآوری کلاس «${className}» ساعت ${time} — مکان: ${place || '-'}`, relatedEvent: 'session.reminder', - sendFn: () => sendSingleSms(receiver, templateId, buildSmsParameters(variables, { + slotValues: { className, + topic: extraContext.topic || className, courseName: extraContext.courseName || className, time, + classTime: extraContext.classTime || extraContext.courseTime || extraContext.time || time || '', + sessionDate: extraContext.sessionDate || '', place: place || '-', fullName: fullName || 'کارآموز', - phoneNumber: receiver, - mobile: receiver, classStartDate: extraContext.classStartDate || '', classDays: extraContext.classDays || '', - courseTime: extraContext.courseTime || extraContext.time || time || '', + courseTime: extraContext.courseTime || extraContext.classTime || extraContext.time || time || '', + classCode: extraContext.classCode || '', ...extraContext - })) + } }); }; const sendInvoiceCreatedSms = async (receiver, payload, userId = null) => { const resolvedUserId = userId || await resolveUserIdByPhone(receiver); - const { templateId, variables } = await getSmsTemplate('invoiceCreated'); + const data = typeof payload === 'object' && payload !== null ? payload : { amount: payload }; + const fullNameLabel = data.fullName || 'کارآموز'; + const amountLabel = data.amount != null ? String(data.amount) : ''; + const courseLabel = data.course || '-'; - let fullName = ''; - let amount = ''; - let course = ''; - let classStartDate = ''; - let classDays = ''; - let courseTime = ''; - - if (typeof payload === 'object' && payload !== null) { - fullName = payload.fullName || ''; - amount = payload.amount != null ? String(payload.amount) : ''; - course = payload.course || ''; - classStartDate = payload.classStartDate || ''; - classDays = payload.classDays || ''; - courseTime = payload.courseTime || ''; - } else { - amount = String(payload || ''); - } - - const amountLabel = String(amount); - const courseLabel = course || '-'; - const fullNameLabel = fullName || 'کارآموز'; - - return recordAndSend({ + return sendTemplateSms({ + templateKey: 'invoiceCreated', + receiver, userId: resolvedUserId, - channel: 'sms', subject: 'ایجاد صورتحساب', body: `کارآموز عزیز، ${fullNameLabel}، یک صورتحساب به مبلغ ${amountLabel} تومان بابت دوره «${courseLabel}» برای شما ایجاد شد.`, relatedEvent: 'payment.created', - sendFn: () => sendSingleSms(receiver, templateId, buildSmsParameters(variables, { + slotValues: { fullName: fullNameLabel, amount: amountLabel, course: courseLabel, - phoneNumber: receiver, - mobile: receiver, - classStartDate, - classDays, - courseTime - })) + classStartDate: data.classStartDate || '', + classDays: data.classDays || '', + courseTime: data.courseTime || '', + invoiceCode: data.invoiceCode || '' + } + }); +}; + +const sendPaymentStatusChangedSms = async (receiver, payload, userId = null) => { + const data = payload || {}; + return sendTemplateSms({ + templateKey: 'paymentStatusChanged', + receiver, + userId, + subject: 'تغییر وضعیت پرداخت', + body: `وضعیت صورتحساب شما به «${data.statusLabel || data.status || '-'}» تغییر کرد.`, + relatedEvent: 'payment.status_changed', + slotValues: { + fullName: data.fullName || 'کارآموز', + status: data.statusLabel || data.status || '-', + amount: data.amount != null ? String(data.amount) : '', + invoiceCode: data.invoiceCode || '', + course: data.course || '-' + } + }); +}; + +const sendPaymentReminderSms = async (receiver, payload, userId = null) => { + const data = payload || {}; + return sendTemplateSms({ + templateKey: 'paymentReminder', + receiver, + userId, + subject: 'یادآوری سررسید پرداخت', + body: `یادآوری: مبلغ ${data.amount || '-'} تومان تا ${data.dueDate || '-'} سررسید دارد.`, + relatedEvent: 'payment.reminder_due', + slotValues: { + fullName: data.fullName || 'کارآموز', + amount: data.amount != null ? String(data.amount) : '', + dueDate: data.dueDate || '', + invoiceCode: data.invoiceCode || '', + course: data.course || '-' + } + }); +}; + +const sendTransactionRecordedSms = async (receiver, payload, userId = null) => { + const data = payload || {}; + return sendTemplateSms({ + templateKey: 'transactionRecorded', + receiver, + userId, + subject: 'ثبت تراکنش', + body: `تراکنش به مبلغ ${data.amount || '-'} تومان ثبت شد.`, + relatedEvent: 'payment.transaction_added', + slotValues: { + fullName: data.fullName || 'کارآموز', + amount: data.amount != null ? String(data.amount) : '', + invoiceCode: data.invoiceCode || '', + transactionCode: data.transactionCode || '', + receiptNumber: data.receiptNumber || '' + } + }); +}; + +const sendSessionCancelledSms = async (receiver, payload, userId = null) => { + const data = payload || {}; + return sendTemplateSms({ + templateKey: 'sessionCancelled', + receiver, + userId, + subject: 'لغو جلسه', + body: `جلسه «${data.topic || data.className || 'کلاس'}» لغو شد.`, + relatedEvent: 'session.cancelled', + slotValues: { + fullName: data.fullName || 'کارآموز', + className: data.className || '-', + topic: data.topic || '-', + sessionDate: data.sessionDate || '', + reason: data.reason || '-', + classCode: data.classCode || '' + } + }); +}; + +const sendSessionHoldingSms = async (receiver, payload, userId = null) => { + const data = payload || {}; + return sendTemplateSms({ + templateKey: 'sessionHolding', + receiver, + userId, + subject: 'برگزاری جلسه طبق برنامه', + body: `جلسه «${data.topic || data.className || 'کلاس'}» کلاس ${data.className || ''} در تاریخ ${data.sessionDate || '-'} و ساعت ${data.classTime || data.time || '-'} طبق برنامه برگزار خواهد شد.`, + relatedEvent: 'session.holding', + slotValues: { + fullName: data.fullName || 'کارآموز', + topic: data.topic || '-', + className: data.className || '-', + sessionDate: data.sessionDate || '', + classTime: data.classTime || data.time || '', + courseName: data.courseName || data.className || '-', + classCode: data.classCode || '', + place: data.place || '-' + } + }); +}; + +const sendClassRequestApprovedSms = async (receiver, payload, userId = null) => { + const data = payload || {}; + return sendTemplateSms({ + templateKey: 'classRequestApproved', + receiver, + userId, + subject: 'تأیید درخواست تشکیل کلاس', + body: `درخواست تشکیل کلاس «${data.courseName || '-'}» تأیید شد.`, + relatedEvent: 'class_request.approved', + slotValues: { + fullName: data.fullName || 'کارآموز', + courseName: data.courseName || '-', + classCode: data.classCode || '' + } + }); +}; + +const sendClassRequestRejectedSms = async (receiver, payload, userId = null) => { + const data = payload || {}; + return sendTemplateSms({ + templateKey: 'classRequestRejected', + receiver, + userId, + subject: 'رد درخواست ثبت‌نام', + body: `درخواست شما برای «${data.courseName || '-'}» رد شد.`, + relatedEvent: 'class_request.rejected', + slotValues: { + fullName: data.fullName || 'کارآموز', + courseName: data.courseName || '-', + reason: data.reason || '-', + registrationCode: data.registrationCode || '' + } + }); +}; + +const sendPendingRegistrationSms = async (receiver, payload, userId = null) => { + const data = payload || {}; + return sendTemplateSms({ + templateKey: 'pendingRegistration', + receiver, + userId, + subject: 'دریافت درخواست ثبت‌نام', + body: `درخواست ثبت‌نام شما در «${data.className || '-'}» دریافت شد.`, + relatedEvent: 'pending.registration_received', + slotValues: { + fullName: data.fullName || 'کارآموز', + className: data.className || '-', + registrationCode: data.registrationCode || '' + } + }); +}; + +const sendCertificateIssuedSms = async (receiver, payload, userId = null) => { + const data = payload || {}; + return sendTemplateSms({ + templateKey: 'certificateIssued', + receiver, + userId, + subject: 'صدور گواهینامه', + body: `گواهینامه «${data.certificateTitle || '-'}» برای شما صادر شد.`, + relatedEvent: 'certificate.issued', + slotValues: { + fullName: data.fullName || 'کارآموز', + certificateTitle: data.certificateTitle || '-', + certificateCode: data.certificateCode || '', + courseName: data.courseName || '-' + } }); }; @@ -178,5 +370,14 @@ module.exports = { sendPasswordResetSms, sendClassRegisteredSms, sendClassReminderSms, - sendInvoiceCreatedSms + sendInvoiceCreatedSms, + sendPaymentStatusChangedSms, + sendPaymentReminderSms, + sendTransactionRecordedSms, + sendSessionCancelledSms, + sendSessionHoldingSms, + sendClassRequestApprovedSms, + sendClassRequestRejectedSms, + sendPendingRegistrationSms, + sendCertificateIssuedSms }; diff --git a/utils/senders/smsSender.js b/utils/senders/smsSender.js index e1f43dc..795c24b 100644 --- a/utils/senders/smsSender.js +++ b/utils/senders/smsSender.js @@ -8,6 +8,15 @@ const { sendClassRegisteredSms, sendClassReminderSms, sendInvoiceCreatedSms, + sendPaymentStatusChangedSms, + sendPaymentReminderSms, + sendTransactionRecordedSms, + sendSessionCancelledSms, + sendSessionHoldingSms, + sendClassRequestApprovedSms, + sendClassRequestRejectedSms, + sendPendingRegistrationSms, + sendCertificateIssuedSms, } = require('./smsMessages'); /** @@ -36,4 +45,13 @@ module.exports = { sendClassRegisteredSms, sendClassReminderSms, sendInvoiceCreatedSms, + sendPaymentStatusChangedSms, + sendPaymentReminderSms, + sendTransactionRecordedSms, + sendSessionCancelledSms, + sendSessionHoldingSms, + sendClassRequestApprovedSms, + sendClassRequestRejectedSms, + sendPendingRegistrationSms, + sendCertificateIssuedSms, }; diff --git a/utils/uniqueCode.js b/utils/uniqueCode.js new file mode 100644 index 0000000..735f9e0 --- /dev/null +++ b/utils/uniqueCode.js @@ -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 +};