363 lines
14 KiB
JavaScript
363 lines
14 KiB
JavaScript
// /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
|
|
};
|