// /components/professors/professorService.js 'use strict'; const mongoose = require('mongoose'); const Professor = require('./professorModel'); const User = require('../users/userModel'); const Role = require('../roles/roleModel'); const Class = require('../classes/classModel'); const Session = require('../sessions/sessionModel'); const Payment = require('../payments/paymentModel'); const AppError = require('../../utils/AppError'); const eventEmitter = require('../../events/eventEmitter'); const EVENT_NAMES = require('../../constants/eventNames'); const { parsePaginationAndSort, calculateMeta, escapeRegex, getSearchTerm } = require('../../utils/pagination'); const { allocatePlaceholderNationalId } = require('../../utils/nationalId'); const { generateUsername, generateSimplePassword } = require('../../utils/credentials'); const bcrypt = require('bcryptjs'); const { resolveSessionDurationHours, calculateProfessorPayout, calculateHourlyShare, calculatePercentageShare } = require('../../utils/professorShare'); const { formatJalali } = require('../../utils/jalaliDate'); const ensureProfessorRole = async () => { let role = await Role.findOne({ name: 'Professor' }); if (!role) { role = await Role.create({ name: 'Professor', description: 'استاد و مدرس دوره‌ها', permissions: ['professors:read', 'classes:read', 'sessions:read', 'attendances:read'], isSystem: true }); } return role; }; const formatProfessorDoc = (doc) => { if (!doc) return null; const raw = doc.toObject ? doc.toObject({ virtuals: true }) : doc; const user = raw.user && typeof raw.user === 'object' ? raw.user : null; const rawName = String(user?.name || raw.name || '').trim(); const parts = rawName.split(/\s+/); let firstName = rawName; let surname = ''; if (parts.length > 1) { firstName = parts[0]; surname = parts.slice(1).join(' '); } return { _id: raw._id, id: raw._id, user: user ? (user._id || user) : raw.user, userId: user ? (user._id || user) : raw.user, name: firstName, surname: surname || raw.surname || '', fullName: rawName, nationalIdCode: user?.nationalIdCode || raw.nationalIdCode || '', nationalId: user?.nationalIdCode || raw.nationalIdCode || '', phoneNumber: user?.phoneNumber || raw.phoneNumber || '', phone: user?.phoneNumber || raw.phoneNumber || '', email: user?.email || raw.email || '', cardNumber: user?.cardNumber || raw.cardNumber || '', shabaNumber: user?.shabaNumber || raw.shabaNumber || '', bio: raw.bio || '', expertise: Array.isArray(raw.expertise) ? raw.expertise : [], courses: raw.courses || [], isActive: raw.isActive !== false && (user ? user.isActive !== false : true), createdAt: raw.createdAt, updatedAt: raw.updatedAt }; }; const createProfessor = async (data) => { const professorRole = await ensureProfessorRole(); let user = null; const rawUserId = data.userId || data.user || data.id; if (rawUserId && mongoose.Types.ObjectId.isValid(rawUserId)) { user = await User.findById(rawUserId); } const phoneNumber = String(data.phoneNumber || data.phone || '').trim(); let nationalIdCode = String(data.nationalIdCode || data.nationalId || '').trim(); if (!user && phoneNumber) { user = await User.findOne({ phoneNumber }); } if (!user && nationalIdCode) { user = await User.findOne({ nationalIdCode }); } if (!user) { if (!nationalIdCode) { nationalIdCode = await allocatePlaceholderNationalId(phoneNumber || data.name); } const fullName = String(data.name || '').trim() + (data.surname ? ` ${String(data.surname).trim()}` : ''); const username = generateUsername(); const plainPassword = generateSimplePassword(); const passwordHash = await bcrypt.hash(plainPassword, 10); user = await User.create({ name: fullName.trim() || `استاد ${phoneNumber}`, nationalIdCode, phoneNumber: phoneNumber || `0999${Date.now().toString().slice(-7)}`, email: data.email ? String(data.email).trim().toLowerCase() : undefined, cardNumber: data.cardNumber ? String(data.cardNumber).trim() : undefined, shabaNumber: data.shabaNumber || data.iban ? String(data.shabaNumber || data.iban).trim() : undefined, role: professorRole._id, username, passwordHash, isActive: data.isActive !== false }); } else { // Promote user role to Professor if (String(user.role) !== String(professorRole._id)) { user.role = professorRole._id; } if (data.name) { const fullName = String(data.name).trim() + (data.surname ? ` ${String(data.surname).trim()}` : ''); user.name = fullName.trim(); } if (data.email) user.email = String(data.email).trim().toLowerCase(); if (data.cardNumber) user.cardNumber = String(data.cardNumber).trim(); if (data.shabaNumber || data.iban) user.shabaNumber = String(data.shabaNumber || data.iban).trim(); await user.save(); } let professor = await Professor.findOne({ user: user._id }); let expertise = []; if (Array.isArray(data.expertise)) { expertise = data.expertise.map(String).map((s) => s.trim()).filter(Boolean); } else if (typeof data.expertise === 'string' && data.expertise.trim()) { expertise = data.expertise.split(',').map((s) => s.trim()).filter(Boolean); } if (professor) { professor.bio = data.bio != null ? String(data.bio).trim() : professor.bio; if (expertise.length > 0) professor.expertise = expertise; if (data.courses) professor.courses = data.courses; professor.isActive = data.isActive !== false; await professor.save(); } else { professor = await Professor.create({ user: user._id, bio: data.bio ? String(data.bio).trim() : undefined, expertise, courses: data.courses || [], isActive: data.isActive !== false }); } eventEmitter.emit(EVENT_NAMES.PROFESSOR_CREATED, { professorId: professor._id, name: user.name }); const populated = await Professor.findById(professor._id).populate('user').populate('courses', 'title type price'); return formatProfessorDoc(populated); }; const createProfessorFromUser = async (userId, additionalData = {}) => { if (!userId) { throw new AppError('VALIDATION_FAILED', null, 'شناسه کاربر الزامی است'); } const user = await User.findById(userId); if (!user) { throw new AppError('USER_NOT_FOUND'); } const professorRole = await ensureProfessorRole(); if (String(user.role) !== String(professorRole._id)) { user.role = professorRole._id; } let firstName = String(additionalData.name || '').trim(); let lastName = String(additionalData.surname || '').trim(); if (firstName || lastName) { user.name = `${firstName} ${lastName}`.trim(); } if (additionalData.email) user.email = String(additionalData.email).trim().toLowerCase(); if (additionalData.cardNumber) user.cardNumber = String(additionalData.cardNumber).trim(); if (additionalData.shabaNumber || additionalData.iban) { user.shabaNumber = String(additionalData.shabaNumber || additionalData.iban).trim(); } await user.save(); let expertise = []; if (Array.isArray(additionalData.expertise)) { expertise = additionalData.expertise.map(String).map((s) => s.trim()).filter(Boolean); } else if (typeof additionalData.expertise === 'string' && additionalData.expertise.trim()) { expertise = additionalData.expertise.split(',').map((s) => s.trim()).filter(Boolean); } let professor = await Professor.findOne({ user: user._id }); if (professor) { if (additionalData.bio !== undefined) professor.bio = String(additionalData.bio).trim(); if (expertise.length > 0) professor.expertise = expertise; professor.isActive = true; await professor.save(); } else { professor = await Professor.create({ user: user._id, bio: additionalData.bio ? String(additionalData.bio).trim() : undefined, expertise, isActive: true }); } eventEmitter.emit(EVENT_NAMES.PROFESSOR_CREATED, { professorId: professor._id, name: user.name }); const populated = await Professor.findById(professor._id).populate('user').populate('courses', 'title type price'); const updatedUser = await User.findById(user._id) .select('-passwordHash -refreshTokens') .populate({ path: 'role', select: 'name permissions' }) .lean(); return { professor: formatProfessorDoc(populated), user: updatedUser }; }; const getProfessorById = async (id) => { let professor = null; if (mongoose.Types.ObjectId.isValid(id)) { professor = await Professor.findById(id) .populate('user', 'name nationalIdCode phoneNumber email cardNumber shabaNumber role isActive') .populate('courses', 'title type price'); if (!professor) { professor = await Professor.findOne({ user: id }) .populate('user', 'name nationalIdCode phoneNumber email cardNumber shabaNumber role isActive') .populate('courses', 'title type price'); } } if (!professor) { throw new AppError('PROFESSOR_NOT_FOUND'); } return formatProfessorDoc(professor); }; const getAllProfessors = async (queryParams = {}) => { const page = parseInt(queryParams.page, 10) || 1; const limit = Math.min(parseInt(queryParams.limit, 10) || 20, 200); const skip = (page - 1) * limit; const filter = {}; if (queryParams.isActive !== undefined) { filter.isActive = queryParams.isActive === 'true' || queryParams.isActive === true; } const searchTerm = getSearchTerm(queryParams); if (searchTerm) { const searchRegex = new RegExp(escapeRegex(searchTerm), 'i'); const matchedUsers = await User.find({ $or: [ { name: searchRegex }, { phoneNumber: searchRegex }, { nationalIdCode: searchRegex }, { email: searchRegex } ] }).select('_id').lean(); const userIds = matchedUsers.map((u) => u._id); filter.$or = [ { user: { $in: userIds } }, { bio: searchRegex }, { expertise: searchRegex } ]; } const [professors, totalCount] = await Promise.all([ Professor.find(filter) .populate('user', 'name nationalIdCode phoneNumber email cardNumber shabaNumber role isActive') .populate('courses', 'title type price') .sort({ createdAt: -1 }) .skip(skip) .limit(limit), Professor.countDocuments(filter) ]); const activeCoursesCounts = await Class.aggregate([ { $match: { isDeleted: { $ne: true }, isActive: { $ne: false }, professor: { $in: professors.map((p) => p._id) } } }, { $group: { _id: '$professor', count: { $sum: 1 } } } ]); const activeCountMap = Object.fromEntries(activeCoursesCounts.map((c) => [String(c._id), c.count])); const formatted = professors.map((p) => { const item = formatProfessorDoc(p); return { ...item, activeCoursesCount: activeCountMap[String(p._id)] || 0 }; }); const meta = calculateMeta(totalCount, page, limit); return { data: formatted, meta }; }; const updateProfessor = async (id, updateData = {}) => { let professor = await Professor.findById(id).populate('user'); if (!professor && mongoose.Types.ObjectId.isValid(id)) { professor = await Professor.findOne({ user: id }).populate('user'); } if (!professor) { throw new AppError('PROFESSOR_NOT_FOUND'); } if (updateData.bio !== undefined) { professor.bio = updateData.bio ? String(updateData.bio).trim() : undefined; } if (updateData.expertise !== undefined) { if (Array.isArray(updateData.expertise)) { professor.expertise = updateData.expertise.map(String).map((s) => s.trim()).filter(Boolean); } else if (typeof updateData.expertise === 'string') { professor.expertise = updateData.expertise.split(',').map((s) => s.trim()).filter(Boolean); } } if (updateData.courses !== undefined) { professor.courses = updateData.courses; } if (updateData.isActive !== undefined) { professor.isActive = Boolean(updateData.isActive); } // Update User personal details if (professor.user) { const user = await User.findById(professor.user._id || professor.user); if (user) { if (updateData.name || updateData.surname) { const first = updateData.name !== undefined ? String(updateData.name).trim() : ''; const last = updateData.surname !== undefined ? String(updateData.surname).trim() : ''; user.name = `${first} ${last}`.trim() || user.name; } if (updateData.phoneNumber || updateData.phone) { user.phoneNumber = String(updateData.phoneNumber || updateData.phone).trim(); } if (updateData.nationalIdCode || updateData.nationalId) { user.nationalIdCode = String(updateData.nationalIdCode || updateData.nationalId).trim(); } if (updateData.email !== undefined) { user.email = updateData.email ? String(updateData.email).trim().toLowerCase() : undefined; } if (updateData.cardNumber !== undefined) { user.cardNumber = updateData.cardNumber ? String(updateData.cardNumber).trim() : undefined; } if (updateData.shabaNumber !== undefined || updateData.iban !== undefined) { const val = updateData.shabaNumber || updateData.iban; user.shabaNumber = val ? String(val).trim() : undefined; } await user.save(); } } await professor.save(); const populated = await Professor.findById(professor._id).populate('user').populate('courses', 'title type price'); return formatProfessorDoc(populated); }; const deleteProfessor = async (id) => { const professor = await Professor.findById(id); if (!professor) { throw new AppError('PROFESSOR_NOT_FOUND'); } await Professor.findByIdAndDelete(id); return null; }; const searchProfessors = async (queryParams) => { return getAllProfessors(queryParams); }; /** * Get comprehensive portal data for an authenticated professor (used in gameno-front teaching portal) */ const getProfessorPortalData = async (userId) => { if (!userId) { throw new AppError('AUTHENTICATION_FAILED'); } const user = await User.findById(userId).populate({ path: 'role', select: 'name' }).lean(); if (!user) { throw new AppError('USER_NOT_FOUND'); } let professor = await Professor.findOne({ user: userId }) .populate('user', 'name nationalIdCode phoneNumber email cardNumber shabaNumber') .populate('courses', 'title type price hoursPerSection sectionCount'); // Auto-link professor record if user holds Professor role if (!professor) { if (user.role?.name === 'Professor') { professor = await Professor.create({ user: userId, isActive: true }); professor = await Professor.findById(professor._id) .populate('user', 'name nationalIdCode phoneNumber email cardNumber shabaNumber') .populate('courses', 'title type price hoursPerSection sectionCount'); } else { throw new AppError('FORBIDDEN', null, 'شما دسترسی به پنل اساتید را ندارید'); } } const professorId = professor._id; // 1. Fetch all classes taught by this professor const classes = await Class.find({ professor: professorId, isDeleted: { $ne: true } }) .populate({ path: 'course', select: 'title type price hoursPerSection sectionCount description' }) .populate({ path: 'students', select: 'name phoneNumber nationalIdCode' }) .sort({ createdAt: -1 }) .lean(); const classIds = classes.map((c) => c._id); // 2. Fetch all sessions for these classes const sessions = classIds.length ? await Session.find({ class: { $in: classIds }, isDeleted: { $ne: true } }) .populate('class', 'name startDate') .populate('course', 'title') .sort({ day: 1, startTime: 1 }) .lean() : []; // 3. Fetch all payments for these classes to calculate actual class revenue const payments = classIds.length ? await Payment.find({ classes: { $in: classIds }, isDeleted: { $ne: true } }) .select('classes amount discount paidAmount') .lean() : []; const revenueByClass = new Map(classIds.map((id) => [String(id), { expected: 0, received: 0 }])); for (const p of payments) { const payable = Math.max(0, (p.amount || 0) - (p.discount || 0)); const paid = Math.max(0, Number(p.paidAmount) || 0); for (const cRef of p.classes || []) { const key = String(cRef); if (revenueByClass.has(key)) { const cur = revenueByClass.get(key); cur.expected += payable; cur.received += paid; } } } // Group sessions by class const sessionsByClass = new Map(classIds.map((id) => [String(id), { held: 0, scheduled: 0, cancelled: 0, all: [] }])); const now = new Date(); const upcomingSessionsList = []; const heldSessionsList = []; for (const s of sessions) { const key = String(s.class?._id || s.class); const entry = sessionsByClass.get(key); if (entry) { entry.all.push(s); if (s.status === 'held') entry.held += 1; else if (s.status === 'cancelled') entry.cancelled += 1; else entry.scheduled += 1; } const sessionDate = s.day ? new Date(s.day) : null; if (s.status === 'held') { heldSessionsList.push(s); } else if (sessionDate && sessionDate >= now) { upcomingSessionsList.push(s); } } let totalEarnedPayout = 0; let totalPendingPayout = 0; let totalSessionsPlanned = 0; let totalSessionsHeld = 0; const distinctStudentIds = new Set(); const classCards = classes.map((cls) => { const key = String(cls._id); const rev = revenueByClass.get(key) || { expected: 0, received: 0 }; const sess = sessionsByClass.get(key) || { held: 0, scheduled: 0, cancelled: 0, all: [] }; const students = cls.students || []; students.forEach((st) => distinctStudentIds.add(String(st._id || st))); const plannedSessions = cls.numberOfSessions ?? (cls.course?.sectionCount || sess.all.length || 1); totalSessionsPlanned += plannedSessions; totalSessionsHeld += sess.held; const durationHours = resolveSessionDurationHours(cls); // Calculate actual payout earned so far (from held sessions or received revenue) const earnedPayout = calculateProfessorPayout({ payoutType: cls.payoutType, payoutPercentage: cls.payoutPercentage, payoutHourlyRate: cls.payoutHourlyRate, revenue: rev.received, sessionDurationHours: durationHours, sessionsCount: sess.held, extraExpensePerSession: cls.extraExpensePerSession, serviceFeePerPerson: cls.serviceFeePerPerson, studentsCount: students.length }); // Calculate potential total payout if all sessions held & expected revenue collected const maxPotentialPayout = calculateProfessorPayout({ payoutType: cls.payoutType, payoutPercentage: cls.payoutPercentage, payoutHourlyRate: cls.payoutHourlyRate, revenue: rev.expected || (cls.tuitionFee * students.length), sessionDurationHours: durationHours, sessionsCount: plannedSessions, extraExpensePerSession: cls.extraExpensePerSession, serviceFeePerPerson: cls.serviceFeePerPerson, studentsCount: students.length }); totalEarnedPayout += earnedPayout.totalPayout; totalPendingPayout += Math.max(0, maxPotentialPayout.totalPayout - earnedPayout.totalPayout); return { _id: cls._id, name: cls.name, courseTitle: cls.course?.title || '—', courseType: cls.course?.type || 'General', startDate: cls.startDate, endDate: cls.endDate, days: cls.days || [], startTime: cls.startTime || '', endTime: cls.endTime || '', isActive: cls.isActive !== false, capacity: cls.capacity || 20, studentsCount: students.length, students: students.map((s) => ({ _id: s._id, name: s.name, phoneNumber: s.phoneNumber })), sessionsPlanned: plannedSessions, sessionsHeld: sess.held, sessionsScheduled: sess.scheduled, sessionsCancelled: sess.cancelled, payoutType: cls.payoutType || 'percentage', payoutPercentage: cls.payoutPercentage || 0, payoutHourlyRate: cls.payoutHourlyRate || 0, extraExpensePerSession: cls.extraExpensePerSession || 0, serviceFeePerPerson: cls.serviceFeePerPerson || 0, sessionDurationHours: durationHours, earnedPayout: earnedPayout.totalPayout, baseShare: earnedPayout.baseShare, extraExpenses: earnedPayout.extraExpenses, maxPotentialPayout: maxPotentialPayout.totalPayout, expectedRevenue: rev.expected, receivedRevenue: rev.received }; }); // Monthly earnings breakdown (by held session dates) const monthlyTimelineMap = new Map(); for (const s of heldSessionsList) { if (!s.day) continue; const d = new Date(s.day); const monthKey = d.toISOString().slice(0, 7); // YYYY-MM const cls = classes.find((c) => String(c._id) === String(s.class?._id || s.class)); if (!cls) continue; const duration = resolveSessionDurationHours(cls); let sessionPayout = 0; if (cls.payoutType === 'hourly') { sessionPayout = calculateHourlyShare({ payoutHourlyRate: cls.payoutHourlyRate, sessionDurationHours: duration, sessionsCount: 1 }) + (cls.extraExpensePerSession || 0); } else { const classRev = revenueByClass.get(String(cls._id))?.received || 0; const planned = cls.numberOfSessions || 10; sessionPayout = (calculatePercentageShare({ payoutPercentage: cls.payoutPercentage, revenue: classRev / planned, serviceFeePerPerson: (cls.serviceFeePerPerson || 0) / planned, studentsCount: (cls.students || []).length })) + (cls.extraExpensePerSession || 0); } if (!monthlyTimelineMap.has(monthKey)) { monthlyTimelineMap.set(monthKey, { monthKey, monthLabel: monthKey, amount: 0, sessionsCount: 0 }); } const entry = monthlyTimelineMap.get(monthKey); entry.amount += Math.round(sessionPayout); entry.sessionsCount += 1; } const monthlyEarnings = Array.from(monthlyTimelineMap.values()).sort((a, b) => a.monthKey.localeCompare(b.monthKey)); return { professor: formatProfessorDoc(professor), stats: { totalClasses: classes.length, activeClasses: classes.filter((c) => c.isActive !== false).length, totalStudents: distinctStudentIds.size, totalSessionsPlanned, totalSessionsHeld, totalEarnedPayout: Math.round(totalEarnedPayout), totalPendingPayout: Math.round(totalPendingPayout) }, classes: classCards, upcomingSessions: upcomingSessionsList.slice(0, 15).map((s) => ({ _id: s._id, day: s.day, startTime: s.startTime, endTime: s.endTime, topic: s.topic, place: s.place, className: s.class?.name || '—', courseTitle: s.course?.title || '—' })), recentSessions: heldSessionsList.slice(-10).reverse().map((s) => ({ _id: s._id, day: s.day, startTime: s.startTime, endTime: s.endTime, topic: s.topic, place: s.place, className: s.class?.name || '—', courseTitle: s.course?.title || '—', attendanceCount: (s.attendanceList || []).filter((a) => a.status === 'present').length })), monthlyEarnings }; }; module.exports = { createProfessor, createProfessorFromUser, getProfessorById, getAllProfessors, updateProfessor, deleteProfessor, searchProfessors, getProfessorPortalData, formatProfessorDoc };