Files
gameno-api/components/classes/classService.js
T

286 lines
11 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 } = 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 payoutType payoutPercentage payoutHourlyRate extraExpensePerSession 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);
return { payoutType, payoutPercentage, payoutHourlyRate, extraExpensePerSession };
};
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);
};
module.exports = { getAll, getOne, create, update, remove, restore, registerUsers, removeUser, getMyClasses, getPublicClasses, getPublicOne };