Files
gameno-api/components/classes/classService.js
T
kavehhn 0d57854dc3 Add class numberOfSessions and fix attendance completion detection.
Sessions with full student attendance are now excluded from the pending list reliably by loading class rosters from the database.
2026-08-16 08:55:03 +03:30

238 lines
8.7 KiB
JavaScript

// /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 { pickNotifyFlags } = require('../../utils/notifyFlags');
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 = pickNotifyFlags(notifyInput);
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) {
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').lean();
await Promise.all(
users.map(async (user) => {
if (!user.phoneNumber) return;
try {
await sendClassRegisteredSms(user.phoneNumber, classLabel, user._id, {
courseName: cls.course?.title || classLabel,
...schedule
});
} catch (err) {
logger.error(`[registerUsers] SMS 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 };