// /components/classes/classService.js 'use strict'; const Class = require('./classModel'); const User = require('../users/userModel'); const Session = require('../sessions/sessionModel'); const AppError = require('../../utils/AppError'); const { calculateMeta, escapeRegex, getSearchTerm } = require('../../utils/pagination'); const { sendClassRegisteredSms } = require('../../utils/senders/smsMessages'); const { buildClassScheduleContext, normalizeWeekdays, normalizeClockTime } = require('../../utils/classSchedule'); 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); return payload; }; const CLASS_LIST_FIELDS = 'name course professor students capacity tuitionFee hasDiscount discount freeSpots showOnFrontend startDate endDate days startTime endTime numberOfSessions isActive 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 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) || 1; const limit = Math.min(parseInt(query.limit) || 20, 200); const skip = (page - 1) * limit; const filter = {}; 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' }) .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' }) .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), 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), ...(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.findByIdAndDelete(id); if (!cls) throw new AppError('CLASS_NOT_FOUND'); }; 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 }) .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) || 1; const limit = Math.min(parseInt(query.limit) || 20, 200); const skip = (page - 1) * limit; const filter = { students: userId }; 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) || 1; const limit = Math.min(parseInt(query.limit) || 50, 200); const skip = (page - 1) * limit; const filter = { isActive: { $ne: false }, showOnFrontend: { $ne: false } }; 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 } }) .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); }; module.exports = { getAll, getOne, create, update, remove, registerUsers, removeUser, getMyClasses, getPublicClasses, getPublicOne };