// /components/users/userService.js 'use strict'; const User = require('./userModel'); const Role = require('../roles/roleModel'); const Class = require('../classes/classModel'); const Session = require('../sessions/sessionModel'); const Payment = require('../payments/paymentModel'); const Transaction = require('../payments/transactionModel'); const Certificate = require('../certificates/certificateModel'); const Document = require('../documents/documentModel'); const Waitlist = require('../waitlist/waitlistModel'); const Notification = require('../notifications/notificationModel'); const ActivityLog = require('../activityLogs/activityLogModel'); const professorService = require('../professors/professorService'); const bcrypt = require('bcryptjs'); const AppError = require('../../utils/AppError'); const { calculateMeta, escapeRegex, getSearchTerm } = require('../../utils/pagination'); const { generateUsername, generateSimplePassword } = require('../../utils/credentials'); const { sendAccountCreatedSms, sendPasswordResetSms } = require('../../utils/senders/smsMessages'); const logger = require('../../utils/logger'); const { mergeFullName, normalizeGender } = require('../../utils/userProfile'); const { resolveNotifyFlags } = require('../../utils/notifyResolver'); const { notifyAction } = require('../../utils/actionNotify'); const { assertCanResetPasswordAndSms, isCredentialsSmsDelivered } = require('./passwordReset'); const { allocatePlaceholderNationalId } = require('../../utils/nationalId'); const normalizeAdminNotes = (value) => { if (value == null) return undefined; if (!Array.isArray(value)) return undefined; return value.map((n) => String(n).trim()).filter(Boolean); }; const withUserListExtras = async (users) => { if (!users.length) return users; const userIds = users.map((u) => u._id); const counts = await Class.aggregate([ { $match: { students: { $in: userIds } } }, { $unwind: '$students' }, { $match: { students: { $in: userIds } } }, { $group: { _id: '$students', count: { $sum: 1 } } } ]); const countMap = Object.fromEntries(counts.map((c) => [String(c._id), c.count])); return users.map((u) => ({ ...u, nationalId: u.nationalIdCode, registeredClassesCount: countMap[String(u._id)] || 0 })); }; const POPULATE_ROLE = { path: 'role', select: 'name permissions' }; const SAFE_FIELDS = '-passwordHash -refreshTokens'; const ALLOWED_MESSENGERS = ['Bale', 'WhatsApp', 'Telegram', 'SMS', 'Email']; const normalizePreferredMessengers = (value) => { if (value == null || value === '') return undefined; const list = Array.isArray(value) ? value : [value]; const cleaned = [...new Set(list.map(String).filter((v) => ALLOWED_MESSENGERS.includes(v)))]; return cleaned; }; const allocateUniqueUsername = async (preferred) => { let username = preferred && String(preferred).trim(); if (username) { const exists = await User.exists({ username }); if (exists) throw new AppError('USER_ALREADY_EXISTS', null, 'Username already exists'); return username; } for (let attempt = 0; attempt < 12; attempt += 1) { 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 pickProfileFields = (body = {}) => { const fullName = mergeFullName(body.name, body.surname); const fields = { name: fullName || undefined, gender: normalizeGender(body.gender), nationalIdCode: body.nationalIdCode || body.nationalId || undefined, phoneNumber: body.phoneNumber || body.phone || undefined, email: body.email, address: body.address, birthCertificateNumber: body.birthCertificateNumber, postalCode: body.postalCode, placeOfIssue: body.placeOfIssue, fatherName: body.fatherName, birthDate: body.birthDate || undefined, education: body.education, parentPhoneNumber: body.parentPhoneNumber, cardNumber: body.cardNumber, shabaNumber: body.shabaNumber || body.iban, preferredMessenger: normalizePreferredMessengers(body.preferredMessenger), adminNotes: normalizeAdminNotes(body.adminNotes) }; Object.keys(fields).forEach((key) => { if (fields[key] === '' || fields[key] === null || fields[key] === undefined) { delete fields[key]; } }); return fields; }; const signUp = async (body) => { const profile = pickProfileFields(body); const { username, password } = body; const userRole = await Role.findOne({ name: 'User' }); if (!userRole) throw new AppError('DEFAULT_ROLE_NOT_FOUND'); if (!profile.nationalIdCode) { profile.nationalIdCode = await allocatePlaceholderNationalId(profile.phoneNumber); } const passwordHash = await bcrypt.hash(password, 10); const user = await User.create({ ...profile, username, passwordHash, role: userRole._id }); return user.populate(POPULATE_ROLE); }; const getUserById = async (id) => { const user = await User.findById(id).select(SAFE_FIELDS).populate(POPULATE_ROLE).lean(); if (!user) throw new AppError('USER_NOT_FOUND'); const [enriched] = await withUserListExtras([user]); return enriched; }; const getAllUsers = async (query) => { const page = parseInt(query.page) || 1; const limit = Math.min(parseInt(query.limit) || 20, 200); const skip = (page - 1) * limit; const filter = {}; const searchTerm = getSearchTerm(query); if (searchTerm) { const searchRegex = new RegExp(escapeRegex(searchTerm), 'i'); filter.$or = [ { name: searchRegex }, { username: searchRegex }, { nationalIdCode: searchRegex }, { phoneNumber: searchRegex }, { email: searchRegex } ]; } if (query.isActive !== undefined) filter.isActive = query.isActive === 'true'; const [items, total] = await Promise.all([ User.find(filter).select(SAFE_FIELDS).populate(POPULATE_ROLE).skip(skip).limit(limit).sort({ createdAt: -1 }).lean(), User.countDocuments(filter) ]); return { data: await withUserListExtras(items), meta: calculateMeta(total, page, limit) }; }; const searchUsers = async (query) => getAllUsers(query); const createUserAdmin = async (body) => { const profile = pickProfileFields(body); const { username: requestedUsername, password: requestedPassword, roleId, role } = body; let roleObj = null; if (roleId || role) { roleObj = await Role.findById(roleId || role); } if (!roleObj) { roleObj = await Role.findOne({ name: 'User' }); } if (!roleObj) throw new AppError('DEFAULT_ROLE_NOT_FOUND'); if (!profile.nationalIdCode) { profile.nationalIdCode = await allocatePlaceholderNationalId(profile.phoneNumber); } const plainPassword = (requestedPassword && String(requestedPassword).trim()) || generateSimplePassword(); const username = await allocateUniqueUsername(requestedUsername); const passwordHash = await bcrypt.hash(plainPassword, 10); const user = await User.create({ ...profile, username, passwordHash, role: roleObj._id }); try { 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}`); } const created = await User.findById(user._id).select(SAFE_FIELDS).populate(POPULATE_ROLE).lean(); return { ...created, generatedCredentials: { username, password: plainPassword } }; }; const updateUser = async (id, body) => { const { password, nationalId, nationalIdCode, phone, phoneNumber, roleId, role, username, passwordHash, refreshTokens, preferredMessenger, surname, name, gender, _id, id: bodyId, createdAt, updatedAt, __v, ...rest } = body; const update = { ...rest }; const mergedName = mergeFullName(name, surname); if (mergedName) update.name = mergedName; const normalizedGender = normalizeGender(gender); if (normalizedGender) update.gender = normalizedGender; if (nationalIdCode || nationalId) { update.nationalIdCode = nationalIdCode || nationalId; } if (phoneNumber || phone) { update.phoneNumber = phoneNumber || phone; } if (roleId || role) { update.role = roleId || role; } if (preferredMessenger !== undefined) { update.preferredMessenger = normalizePreferredMessengers(preferredMessenger) || []; } if (body.shabaNumber !== undefined || body.iban !== undefined) { update.shabaNumber = (body.shabaNumber || body.iban || '').trim() || undefined; delete update.iban; } if (body.cardNumber !== undefined) { update.cardNumber = (body.cardNumber || '').trim() || undefined; } if (body.adminNotes !== undefined) { update.adminNotes = normalizeAdminNotes(body.adminNotes) || []; } if (typeof username === 'string' && username.trim()) { update.username = username.trim(); } // Legacy field — drop if clients still send it delete update.surname; Object.keys(update).forEach((key) => { if (key === 'preferredMessenger' || key === 'adminNotes') return; if (update[key] === '' || update[key] === null || update[key] === undefined) { delete update[key]; } }); if (password) { update.passwordHash = await bcrypt.hash(password, 10); } const user = await User.findByIdAndUpdate(id, update, { new: true, runValidators: true }) .select(SAFE_FIELDS) .populate(POPULATE_ROLE) .lean(); if (!user) throw new AppError('USER_NOT_FOUND'); return user; }; const deleteUser = async (id) => { const user = await User.findByIdAndDelete(id); if (!user) throw new AppError('USER_NOT_FOUND'); }; const resetPasswordAndSendSms = async (id) => { const user = await User.findById(id); if (!user) throw new AppError('USER_NOT_FOUND'); const { username, phoneNumber } = assertCanResetPasswordAndSms(user); const plainPassword = generateSimplePassword(); user.passwordHash = await bcrypt.hash(plainPassword, 10); user.refreshTokens = []; await user.save(); let smsSent = false; try { 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}`); } logger.info(`[resetPasswordAndSendSms] Password reset for user=${user._id} smsSent=${smsSent}`); return { username, phoneNumber, smsSent, generatedCredentials: { username, password: plainPassword } }; }; const enrollUserInCourse = async (userId, courseId) => { const user = await User.findById(userId); if (!user) throw new AppError('USER_NOT_FOUND'); if (!user.courses.includes(courseId)) { user.courses.push(courseId); await user.save(); } return User.findById(userId).select(SAFE_FIELDS).populate('courses').lean(); }; const getUserFullProfile = async (id) => { const user = await User.findById(id).select(SAFE_FIELDS).populate(POPULATE_ROLE).lean(); if (!user) throw new AppError('USER_NOT_FOUND'); // 1. Enrolled Classes const classes = await Class.find({ students: id, isDeleted: { $ne: true } }) .populate('course', 'title type price hoursPerSection') .populate('professor', 'name surname phoneNumber email') .sort({ createdAt: -1 }) .lean(); const classIds = classes.map((c) => c._id); // 2. All Sessions for user's classes or where user is in attendanceList const sessions = await Session.find({ $or: [ { class: { $in: classIds } }, { 'attendanceList.user': id } ], isDeleted: { $ne: true } }) .populate('course', 'title') .populate('class', 'name') .populate('professor', 'name surname') .populate('attendanceList.recordedBy', 'name username') .sort({ day: -1, startTime: -1 }) .lean(); // Map attendance records specifically for this student let presentCount = 0; let absentCount = 0; let lateCount = 0; let excusedCount = 0; const userAttendances = sessions.map((sess) => { const record = (sess.attendanceList || []).find((r) => String(r.user) === String(id)); const status = record ? record.status : 'scheduled'; if (status === 'present') presentCount++; else if (status === 'absent') absentCount++; else if (status === 'late') lateCount++; else if (status === 'excused') excusedCount++; return { _id: sess._id, sessionId: sess._id, day: sess.day, startTime: sess.startTime, endTime: sess.endTime, place: sess.place, topic: sess.topic, sessionStatus: sess.status, course: sess.course, class: sess.class, professor: sess.professor, attendanceStatus: status, attendanceNote: record?.note || '', recordedBy: record?.recordedBy || null }; }); const totalRecordedAttendance = presentCount + absentCount + lateCount + excusedCount; const attendanceRate = totalRecordedAttendance > 0 ? Math.round(((presentCount + lateCount) / totalRecordedAttendance) * 100) : 0; // 3. Payments & Invoices const payments = await Payment.find({ user: id, isDeleted: { $ne: true } }) .populate('classes', 'name tuitionFee') .populate('course', 'title') .sort({ createdAt: -1 }) .lean(); const paymentIds = payments.map((p) => p._id); // 4. Bank Transactions const transactions = await Transaction.find({ $or: [ { user: id }, { payment: { $in: paymentIds } } ] }) .populate('payment', 'uniqueCode amount status') .populate('recordedBy', 'name username') .sort({ date: -1, createdAt: -1 }) .lean(); // 5. Financial Summary Calculations let totalTuition = 0; let totalDiscount = 0; let totalPayable = 0; let totalPaid = 0; let overdueAmount = 0; let pendingAmount = 0; const paymentStatusMap = { paid: { count: 0, amount: 0 }, partial: { count: 0, amount: 0 }, pending: { count: 0, amount: 0 }, overdue: { count: 0, amount: 0 }, cancelled: { count: 0, amount: 0 }, reverted: { count: 0, amount: 0 } }; payments.forEach((p) => { const payable = Math.max(0, (p.amount || 0) - (p.discount || 0)); totalTuition += (p.amount || 0); totalDiscount += (p.discount || 0); totalPayable += payable; totalPaid += (p.paidAmount || 0); const st = p.status || 'pending'; if (paymentStatusMap[st]) { paymentStatusMap[st].count++; paymentStatusMap[st].amount += payable; } if (st === 'overdue') { overdueAmount += Math.max(0, payable - (p.paidAmount || 0)); } else if (st === 'pending' || st === 'partial') { pendingAmount += Math.max(0, payable - (p.paidAmount || 0)); } }); const remainingDebt = Math.max(0, totalPayable - totalPaid); const financialSummary = { totalTuition, totalDiscount, totalPayable, totalPaid, remainingDebt, overdueAmount, pendingAmount, totalInvoicesCount: payments.length, totalTransactionsCount: transactions.length }; // Monthly transaction timeline for charts const monthlyTimelineMap = {}; transactions.forEach((t) => { if (t.status !== 'cancelled' && t.status !== 'reverted') { const d = t.date || t.createdAt; if (d) { const monthKey = new Date(d).toISOString().slice(0, 7); // YYYY-MM if (!monthlyTimelineMap[monthKey]) { monthlyTimelineMap[monthKey] = { month: monthKey, amount: 0, count: 0 }; } monthlyTimelineMap[monthKey].amount += (t.amount || 0); monthlyTimelineMap[monthKey].count++; } } }); const monthlyTransactions = Object.values(monthlyTimelineMap).sort((a, b) => a.month.localeCompare(b.month)); // 6. Certificates & Documents const certificates = await Certificate.find({ user: id }) .populate('course', 'title') .sort({ issuedAt: -1 }) .lean(); const documents = await Document.find({ user: id }) .populate('uploadedBy', 'name username') .sort({ createdAt: -1 }) .lean(); // 7. Waitlist Entries const waitlist = await Waitlist.find({ user: id, isDeleted: { $ne: true } }) .populate('course', 'title type price') .populate('class', 'name startDate') .sort({ createdAt: -1 }) .lean(); // 8. Notifications const notifications = await Notification.find({ user: id }) .sort({ createdAt: -1 }) .limit(50) .lean(); // 9. Activity Logs const activityLogs = await ActivityLog.find({ $or: [ { actor: id }, { resourceId: String(id) }, { 'metadata.userId': String(id) } ] }) .sort({ createdAt: -1 }) .limit(50) .lean(); const chartsData = { attendance: { present: presentCount, absent: absentCount, late: lateCount, excused: excusedCount, total: totalRecordedAttendance, attendanceRate }, paymentsBreakdown: [ { status: 'paid', count: paymentStatusMap.paid.count, amount: paymentStatusMap.paid.amount }, { status: 'partial', count: paymentStatusMap.partial.count, amount: paymentStatusMap.partial.amount }, { status: 'pending', count: paymentStatusMap.pending.count, amount: paymentStatusMap.pending.amount }, { status: 'overdue', count: paymentStatusMap.overdue.count, amount: paymentStatusMap.overdue.amount } ], financialSummary, monthlyTransactions }; return { user: { ...user, nationalId: user.nationalIdCode, registeredClassesCount: classes.length }, classes, sessions, attendances: userAttendances, payments, transactions, financialSummary, attendanceSummary: chartsData.attendance, chartsData, certificates, documents, waitlist, notifications, activityLogs }; }; const promoteToProfessor = async (id, data = {}) => { return professorService.createProfessorFromUser(id, data); }; module.exports = { signUp, getUserById, getUserFullProfile, getAllUsers, searchUsers, createUserAdmin, updateUser, deleteUser, resetPasswordAndSendSms, enrollUserInCourse, promoteToProfessor };