Keep the class schedule on the class itself so SMS and listings do not have to infer days and hours from sessions.
151 lines
5.3 KiB
JavaScript
151 lines
5.3 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 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('name course professor students capacity tuitionFee startDate endDate days startTime endTime isActive adminNotes createdAt updatedAt')
|
|
.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, 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 cls;
|
|
};
|
|
|
|
const create = async (body) => {
|
|
const payload = applyScheduleFields({ ...body }, body);
|
|
const cls = await Class.create(payload);
|
|
return getOne(cls._id);
|
|
};
|
|
|
|
const update = async (id, body) => {
|
|
const payload = applyScheduleFields({ ...body }, body);
|
|
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 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, meta: calculateMeta(total, page, limit) };
|
|
};
|
|
|
|
module.exports = { getAll, getOne, create, update, remove, registerUsers, removeUser, getMyClasses };
|