'use strict'; const PendingStudent = require('./pendingStudentModel'); const Class = require('../classes/classModel'); const User = require('../users/userModel'); const Role = require('../roles/roleModel'); const AppError = require('../../utils/AppError'); const bcrypt = require('bcryptjs'); const { calculateMeta, escapeRegex, getSearchTerm } = require('../../utils/pagination'); const { calculateRegistrationPricing } = require('../../utils/registrationPricing'); const { generateUsername, generateSimplePassword } = require('../../utils/credentials'); 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 POPULATE_LIST = [ { path: 'user', select: 'name phoneNumber nationalIdCode email username' }, { path: 'class', select: 'name tuitionFee startDate days startTime endTime', populate: { path: 'course', select: 'title sectionCount' } }, { path: 'course', select: 'title sectionCount' }, { path: 'reviewedBy', select: 'name username' } ]; const normalizePhone = (value) => String(value || '').replace(/[\s\-()]/g, '').trim(); const isValidNationalId = (code) => { if (!/^\d{10}$/.test(code)) return false; if (/^(\d)\1{9}$/.test(code)) return false; const check = Number(code[9]); const sum = code .split('') .slice(0, 9) .reduce((acc, digit, index) => acc + Number(digit) * (10 - index), 0); const remainder = sum % 11; return (remainder < 2 && check === remainder) || (remainder >= 2 && check === 11 - remainder); }; const getPublicClassForRegistration = async (classId) => { const cls = await Class.findOne({ _id: classId, isActive: { $ne: false }, showOnFrontend: { $ne: false } }) .select('name course professor capacity tuitionFee hasDiscount discount freeSpots startDate endDate days startTime endTime isActive') .populate({ path: 'course', select: 'title type description sectionCount hoursPerSection price' }) .populate({ path: 'professor', select: 'name surname' }) .lean(); if (!cls) throw new AppError('CLASS_NOT_FOUND', null, 'کلاس یافت نشد یا برای ثبت‌نام در دسترس نیست.'); return cls; }; const getPricingPreview = async ({ classId, type = 'enrollment', paymentPlan = 'full' }) => { const cls = await getPublicClassForRegistration(classId); const pricing = calculateRegistrationPricing(cls, cls.course || {}, { type, paymentPlan }); return { class: { _id: cls._id, name: cls.name, course: cls.course, professor: cls.professor, startDate: cls.startDate, days: cls.days, startTime: cls.startTime, endTime: cls.endTime, finalTuitionFee: pricing.tuitionFee }, pricing }; }; const allocateUniqueUsername = async () => { for (let attempt = 0; attempt < 12; attempt += 1) { const username = generateUsername(); const exists = await User.exists({ username }); if (!exists) return username; } throw new AppError('INTERNAL_SERVER_ERROR', null, 'Could not generate a unique username'); }; const findOrCreateUser = async (body) => { const name = mergeFullName(body.name, body.surname); const nationalIdCode = String(body.nationalIdCode || body.nationalId || '').trim(); const phoneNumber = normalizePhone(body.phoneNumber || body.phone); const email = body.email ? String(body.email).trim().toLowerCase() : undefined; const gender = normalizeGender(body.gender); if (!name) throw new AppError('VALIDATION_FAILED', { name: 'نام و نام خانوادگی الزامی است' }); if (!nationalIdCode) throw new AppError('VALIDATION_FAILED', { nationalIdCode: 'کد ملی الزامی است' }); if (!isValidNationalId(nationalIdCode)) { throw new AppError('VALIDATION_FAILED', { nationalIdCode: 'کد ملی معتبر نیست' }); } if (!phoneNumber) throw new AppError('VALIDATION_FAILED', { phoneNumber: 'شماره موبایل الزامی است' }); if (!/^(0?9\d{9}|\+989\d{9})$/.test(phoneNumber)) { throw new AppError('VALIDATION_FAILED', { phoneNumber: 'شماره موبایل معتبر نیست' }); } const normalizedPhone = phoneNumber.startsWith('+98') ? `0${phoneNumber.slice(3)}` : phoneNumber.startsWith('9') && phoneNumber.length === 10 ? `0${phoneNumber}` : phoneNumber; let user = await User.findOne({ $or: [{ phoneNumber: normalizedPhone }, { nationalIdCode }] }); if (user) { const updates = {}; if (user.name !== name) updates.name = name; if (email && user.email !== email) updates.email = email; if (gender && user.gender !== gender) updates.gender = gender; if (user.phoneNumber !== normalizedPhone) updates.phoneNumber = normalizedPhone; if (Object.keys(updates).length) { user = await User.findByIdAndUpdate(user._id, updates, { new: true, runValidators: true }); } return { user: { _id: user._id, name: user.name, phoneNumber: user.phoneNumber, nationalIdCode: user.nationalIdCode, email: user.email }, isNewUser: false }; } const userRole = await Role.findOne({ name: 'User' }); if (!userRole) throw new AppError('DEFAULT_ROLE_NOT_FOUND'); const username = await allocateUniqueUsername(); const plainPassword = generateSimplePassword(); const passwordHash = await bcrypt.hash(plainPassword, 10); user = await User.create({ name, nationalIdCode, phoneNumber: normalizedPhone, email, gender, username, passwordHash, role: userRole._id, preferredMessenger: ['SMS'] }); try { 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}`); } return { user: { _id: user._id, name: user.name, phoneNumber: user.phoneNumber, nationalIdCode: user.nationalIdCode, email: user.email }, isNewUser: true }; }; const createPendingStudentAfterPayment = async (body) => { const { userId, classId, type = 'enrollment', paymentPlan = 'full', paymentReference } = body; if (!userId || !classId) { throw new AppError('VALIDATION_FAILED', { form: 'اطلاعات ثبت‌نام ناقص است' }); } const [user, cls] = await Promise.all([ User.findById(userId).select('_id name phoneNumber').lean(), getPublicClassForRegistration(classId) ]); if (!user) throw new AppError('USER_NOT_FOUND'); const resolvedPlan = type === 'class_request' ? 'deposit' : paymentPlan; const pricing = calculateRegistrationPricing(cls, cls.course || {}, { type, paymentPlan: resolvedPlan }); const existing = await PendingStudent.findOne({ user: userId, class: classId, type, status: { $in: ['pending_payment', 'pending_review'] } }); if (existing) { throw new AppError('DUPLICATE_KEY', null, 'درخواست فعال برای این کلاس از قبل ثبت شده است.'); } const pending = await PendingStudent.create({ user: userId, class: classId, course: cls.course._id, type, paymentPlan: pricing.paymentPlan, tuitionFee: pricing.tuitionFee, classDiscount: pricing.classDiscount, paymentDiscount: pricing.paymentDiscount, totalAmount: pricing.totalAmount, amountDueNow: pricing.amountDueNow, secondInstallmentAmount: pricing.secondInstallmentAmount, secondInstallmentDueDate: pricing.secondInstallmentDueDate, status: 'pending_review', 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(); }; const getAllPendingStudents = async (query = {}) => { const page = parseInt(query.page, 10) || 1; const limit = Math.min(parseInt(query.limit, 10) || 20, 200); const skip = (page - 1) * limit; const filter = {}; if (query.trash === 'true' || query.isDeleted === 'true') { filter.isDeleted = true; } else { filter.isDeleted = { $ne: true }; } if (query.status) filter.status = query.status; if (query.type) filter.type = query.type; if (query.classId) filter.class = query.classId; const searchTerm = getSearchTerm(query); if (searchTerm) { const searchRegex = new RegExp(escapeRegex(searchTerm), 'i'); const matchedUsers = await User.find({ $or: [ { name: searchRegex }, { phoneNumber: searchRegex }, { nationalIdCode: searchRegex } ] }).select('_id').lean(); filter.$or = [ { adminNotes: searchRegex }, { user: { $in: matchedUsers.map((u) => u._id) } } ]; } const [items, total] = await Promise.all([ PendingStudent.find(filter) .populate(POPULATE_LIST) .skip(skip) .limit(limit) .sort({ createdAt: -1 }) .lean(), PendingStudent.countDocuments(filter) ]); return { data: items, meta: calculateMeta(total, page, limit) }; }; const getPendingStudentById = async (id) => { const pending = await PendingStudent.findById(id).populate(POPULATE_LIST).lean(); if (!pending) throw new AppError('NOT_FOUND', null, 'درخواست در انتظار یافت نشد.'); return pending; }; const sendClassRequestApprovedNotification = async (user, courseTitle, classCode = '') => { if (!user?.phoneNumber) return; await notifyAction({ actionKey: 'classRequestApproved', userId: user._id, 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 uniqueCode', 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, 'این درخواست قابل تأیید نیست.'); } if (pending.type === 'enrollment') { await classService.registerUsers(pending.class._id, [pending.user._id], { sendSms: true }); const transactions = [{ amount: pending.amountDueNow, status: 'paid', method: 'online', date: new Date(), notes: 'پرداخت اولیه — تأیید توسط آموزشگاه' }]; if (pending.paymentPlan === 'installments' && pending.secondInstallmentAmount > 0) { transactions.push({ amount: pending.secondInstallmentAmount, status: 'pending', dueDate: pending.secondInstallmentDueDate || new Date(), notes: 'قسط دوم' }); } await paymentService.createPayment({ user: pending.user._id, classes: [pending.class._id], course: pending.course, amount: pending.paymentPlan === 'installments' ? pending.totalAmount : pending.tuitionFee, discount: pending.paymentDiscount, notes: body.adminNotes || pending.adminNotes || '', transactions, sendSms: false }, actorId); } else { const courseTitle = pending.course?.title || pending.class?.course?.title || pending.class?.name || 'دوره'; await sendClassRequestApprovedNotification( pending.user, courseTitle, pending.class?.uniqueCode || '' ); } await PendingStudent.findByIdAndDelete(id); return { approved: true, type: pending.type }; }; const rejectPendingStudent = async (id, actorId, body = {}) => { 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, 'این درخواست قابل رد کردن نیست.'); } if (body.adminNotes != null) { 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 }; }; module.exports = { getPricingPreview, findOrCreateUser, createPendingStudentAfterPayment, getAllPendingStudents, getPendingStudentById, approvePendingStudent, rejectPendingStudent };