Files

422 lines
15 KiB
JavaScript

'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
};