// /components/classes/classService.js 'use strict'; const Class = require('./classModel'); const User = require('../users/userModel'); const Session = require('../sessions/sessionModel'); const PendingStudent = require('../pendingStudents/pendingStudentModel'); const Payment = require('../payments/paymentModel'); const AppError = require('../../utils/AppError'); const { calculateMeta, escapeRegex, getSearchTerm } = require('../../utils/pagination'); const { sendClassRegisteredSms, sendClassPlanProfessorSms } = require('../../utils/senders/smsMessages'); const { buildClassScheduleContext, normalizeWeekdays, normalizeClockTime, calculateClassEndDate, formatClassDaysFromIndexes } = require('../../utils/classSchedule'); const { formatJalaliDate } = require('../../utils/jalaliDate'); const { resolveNotifyFlags } = require('../../utils/notifyResolver'); const { notifyAction } = require('../../utils/actionNotify'); const logger = require('../../utils/logger'); const applyScheduleFields = (payload, body) => { if (body.days !== undefined) payload.days = normalizeWeekdays(body.days); if (body.startTime !== undefined) payload.startTime = normalizeClockTime(body.startTime); if (body.endTime !== undefined) payload.endTime = normalizeClockTime(body.endTime); const startDate = payload.startDate || body.startDate; const days = payload.days || body.days; const sessions = payload.numberOfSessions || body.numberOfSessions; if (startDate && days?.length && sessions) { const computedEnd = calculateClassEndDate(startDate, days, sessions); if (computedEnd && (!payload.endDate || !body.endDate)) { payload.endDate = computedEnd; } } return payload; }; const CLASS_LIST_FIELDS = 'name course professor students capacity tuitionFee hasDiscount discount freeSpots showOnFrontend startDate endDate days startTime endTime numberOfSessions payoutType payoutPercentage payoutHourlyRate extraExpensePerSession serviceFeePerPerson isActive isDeleted deletedAt adminNotes createdAt updatedAt'; const normalizePricingFields = (body = {}) => { const tuitionFee = Math.max(0, Number(body.tuitionFee) || 0); const hasDiscount = Boolean(body.hasDiscount); const discount = hasDiscount ? Math.min(Math.max(0, Number(body.discount) || 0), tuitionFee) : 0; return { tuitionFee, hasDiscount, discount }; }; const normalizePayoutFields = (body = {}) => { const payoutType = body.payoutType === 'hourly' ? 'hourly' : 'percentage'; const payoutPercentage = Math.min(100, Math.max(0, Number(body.payoutPercentage) || 0)); const payoutHourlyRate = Math.max(0, Number(body.payoutHourlyRate) || 0); const extraExpensePerSession = Math.max(0, Number(body.extraExpensePerSession) || 0); const serviceFeePerPerson = Math.max(0, Number(body.serviceFeePerPerson) || 0); return { payoutType, payoutPercentage, payoutHourlyRate, extraExpensePerSession, serviceFeePerPerson }; }; const normalizeNumberOfSessions = (value) => { if (value === '' || value === null || value === undefined) return null; const parsed = Number(value); if (!Number.isFinite(parsed) || parsed < 1) return null; return Math.floor(parsed); }; const enrichClassForDisplay = (cls) => { const tuitionFee = cls.tuitionFee || 0; const discount = cls.hasDiscount ? (cls.discount || 0) : 0; const finalTuitionFee = Math.max(0, tuitionFee - discount); let daysUntilStart = null; if (cls.startDate) { daysUntilStart = Math.ceil((new Date(cls.startDate).getTime() - Date.now()) / (1000 * 60 * 60 * 24)); } return { ...cls, finalTuitionFee, daysUntilStart }; }; const getAll = 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.courseId) filter.course = query.courseId; if (query.isActive !== undefined) filter.isActive = query.isActive === 'true'; const searchTerm = getSearchTerm(query); if (searchTerm) { filter.name = new RegExp(escapeRegex(searchTerm), 'i'); } const [items, total] = await Promise.all([ Class.find(filter) .select(CLASS_LIST_FIELDS) .populate({ path: 'course', select: 'title type price sectionCount hoursPerSection' }) .populate({ path: 'professor', select: 'name surname' }) .skip(skip).limit(limit).sort({ createdAt: -1 }).lean(), Class.countDocuments(filter) ]); return { data: items.map(enrichClassForDisplay), meta: calculateMeta(total, page, limit) }; }; const getOne = async (id) => { const cls = await Class.findById(id) .populate({ path: 'course', select: 'title type price sectionCount hoursPerSection' }) .populate({ path: 'professor', select: 'name surname phoneNumber' }) .populate({ path: 'students', select: 'name phoneNumber gender' }) .lean(); if (!cls) throw new AppError('CLASS_NOT_FOUND'); return enrichClassForDisplay(cls); }; const create = async (body) => { const payload = applyScheduleFields({ ...body, ...normalizePricingFields(body), ...normalizePayoutFields(body), numberOfSessions: normalizeNumberOfSessions(body.numberOfSessions) }, body); if (body.freeSpots === '' || body.freeSpots === null || body.freeSpots === undefined) { payload.freeSpots = null; } else { payload.freeSpots = Math.max(0, Number(body.freeSpots) || 0); } const cls = await Class.create(payload); return getOne(cls._id); }; const update = async (id, body) => { const payload = applyScheduleFields({ ...body, ...normalizePricingFields(body), ...normalizePayoutFields(body), ...(body.numberOfSessions !== undefined ? { numberOfSessions: normalizeNumberOfSessions(body.numberOfSessions) } : {}) }, body); if (body.freeSpots === '' || body.freeSpots === null) { payload.freeSpots = null; } else if (body.freeSpots !== undefined) { payload.freeSpots = Math.max(0, Number(body.freeSpots) || 0); } const cls = await Class.findByIdAndUpdate(id, payload, { new: true, runValidators: true }) .populate({ path: 'course', select: 'title' }) .lean(); if (!cls) throw new AppError('CLASS_NOT_FOUND'); return enrichClassForDisplay(cls); }; const remove = async (id) => { const cls = await Class.findById(id); if (!cls) throw new AppError('CLASS_NOT_FOUND'); const now = new Date(); await Class.findByIdAndUpdate(id, { $set: { isDeleted: true, deletedAt: now } }); await Session.updateMany({ class: id, isDeleted: { $ne: true } }, { $set: { isDeleted: true, deletedAt: now } }); await PendingStudent.updateMany({ class: id, isDeleted: { $ne: true } }, { $set: { isDeleted: true, deletedAt: now } }); await Payment.updateMany({ classes: id, isDeleted: { $ne: true } }, { $set: { isDeleted: true, deletedAt: now } }); return { success: true }; }; const restore = async (id) => { const cls = await Class.findById(id); if (!cls) throw new AppError('CLASS_NOT_FOUND'); await Class.findByIdAndUpdate(id, { $set: { isDeleted: false, deletedAt: null } }); await Session.updateMany({ class: id, isDeleted: true }, { $set: { isDeleted: false, deletedAt: null } }); await PendingStudent.updateMany({ class: id, isDeleted: true }, { $set: { isDeleted: false, deletedAt: null } }); await Payment.updateMany({ classes: id, isDeleted: true }, { $set: { isDeleted: false, deletedAt: null } }); return getOne(id); }; 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 = await resolveNotifyFlags(notifyInput, 'classRegistered'); const toAdd = (userIds || []).filter( (id) => !cls.students.map((s) => s.toString()).includes(id.toString()) ); if (toAdd.length === 0) { return getOne(classId); } cls.students.push(...toAdd); await cls.save(); if (notify.sms || notify.email || notify.bot) { const classLabel = cls.name || cls.course?.title || 'کلاس'; const sessions = await Session.find({ class: classId, isDeleted: { $ne: true } }) .select('day startTime endTime') .sort({ day: 1 }) .lean(); const schedule = buildClassScheduleContext(cls, sessions); 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 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] Notification failed for ${user.phoneNumber}: ${err.message}`); } }) ); } return getOne(classId); }; const removeUser = async (classId, userId) => { const cls = await Class.findById(classId); if (!cls) throw new AppError('CLASS_NOT_FOUND'); const before = cls.students.length; cls.students = cls.students.filter((id) => id.toString() !== String(userId)); if (cls.students.length !== before) { await cls.save(); } return getOne(classId); }; const getMyClasses = async (userId, 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 = { students: userId, isDeleted: { $ne: true } }; if (query.isActive !== undefined) filter.isActive = query.isActive === 'true'; const [items, total] = await Promise.all([ Class.find(filter) .populate({ path: 'course', select: 'title type price description' }) .populate({ path: 'professor', select: 'name surname' }) .skip(skip).limit(limit).sort({ startDate: -1, createdAt: -1 }).lean(), Class.countDocuments(filter) ]); return { data: items.map(enrichClassForDisplay), meta: calculateMeta(total, page, limit) }; }; const getPublicClasses = async (query = {}) => { const page = parseInt(query.page, 10) || 1; const limit = Math.min(parseInt(query.limit, 10) || 50, 200); const skip = (page - 1) * limit; const filter = { isActive: { $ne: false }, showOnFrontend: { $ne: false }, isDeleted: { $ne: true } }; if (query.courseId) filter.course = query.courseId; const [items, total] = await Promise.all([ Class.find(filter) .select('name course professor capacity tuitionFee hasDiscount discount freeSpots startDate endDate days startTime endTime') .populate({ path: 'course', select: 'title type description' }) .populate({ path: 'professor', select: 'name surname' }) .skip(skip).limit(limit).sort({ startDate: 1, createdAt: -1 }).lean(), Class.countDocuments(filter) ]); return { data: items.map(enrichClassForDisplay), meta: calculateMeta(total, page, limit) }; }; const getPublicOne = async (id) => { const cls = await Class.findOne({ _id: id, isActive: { $ne: false }, showOnFrontend: { $ne: false }, isDeleted: { $ne: true } }) .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 enrichClassForDisplay(cls); }; const sendClassPlanToProfessor = async (classId) => { const classDoc = await Class.findById(classId) .populate('course', 'title') .populate('professor', 'name surname phoneNumber'); if (!classDoc) { throw new AppError('CLASS_NOT_FOUND', null, 'کلاس یافت نشد.'); } if (!classDoc.professor) { throw new AppError('VALIDATION_FAILED', { field: 'professor' }, 'برای این کلاس هیچ استادی تعیین نشده است.'); } const professor = classDoc.professor; if (!professor.phoneNumber) { throw new AppError('VALIDATION_FAILED', { field: 'phoneNumber' }, 'شماره همراه استاد ثبت نشده است.'); } const profName = `${professor.name || ''} ${professor.surname || ''}`.trim() || 'استاد'; const className = classDoc.name || classDoc.course?.title || 'کلاس'; const classDays = formatClassDaysFromIndexes(classDoc.days) || 'طبق هماهنگی'; const classTimes = (classDoc.startTime && classDoc.endTime) ? `${classDoc.startTime} الی ${classDoc.endTime}` : (classDoc.startTime || classDoc.endTime || 'طبق هماهنگی'); const classStartDate = classDoc.startDate ? formatJalaliDate(classDoc.startDate) : ''; const classEndDate = classDoc.endDate ? formatJalaliDate(classDoc.endDate) : ''; const slotValues = { professorName: profName, className, classDays, classTimes, classStartDate, classEndDate, phoneNumber: professor.phoneNumber }; const result = await sendClassPlanProfessorSms(professor.phoneNumber, slotValues, professor._id); return { success: true, result, recipient: { name: profName, phoneNumber: professor.phoneNumber }, slotValues }; }; module.exports = { getAll, getOne, create, update, remove, restore, registerUsers, removeUser, getMyClasses, getPublicClasses, getPublicOne, sendClassPlanToProfessor };