448 lines
15 KiB
JavaScript
448 lines
15 KiB
JavaScript
// /components/sessions/sessionService.js
|
|
|
|
const Session = require('./sessionModel');
|
|
const Course = require('../courses/courseModel');
|
|
const Class = require('../classes/classModel');
|
|
const User = require('../users/userModel');
|
|
const Professor = require('../professors/professorModel');
|
|
const AppError = require('../../utils/AppError');
|
|
const eventEmitter = require('../../events/eventEmitter');
|
|
const EVENT_NAMES = require('../../constants/eventNames');
|
|
const { parsePaginationAndSort, buildFilterQuery, calculateMeta } = require('../../utils/pagination');
|
|
|
|
const STATUS_MAP = {
|
|
scheduled: 'scheduled',
|
|
held: 'held',
|
|
cancelled: 'cancelled',
|
|
Scheduled: 'scheduled',
|
|
Held: 'held',
|
|
Cancelled: 'cancelled',
|
|
canceled: 'cancelled',
|
|
Canceled: 'cancelled'
|
|
};
|
|
|
|
const normalizeSessionPayload = (data) => {
|
|
const payload = { ...data };
|
|
|
|
if (payload.date && !payload.day) {
|
|
payload.day = payload.date;
|
|
}
|
|
delete payload.date;
|
|
|
|
if (payload.status) {
|
|
payload.status = STATUS_MAP[payload.status] || payload.status;
|
|
}
|
|
|
|
return payload;
|
|
};
|
|
|
|
const createSession = async (data, actorId = null) => {
|
|
// Support bulk create from dashboard: { sessions: [...], courseId, professorId, classId }
|
|
if (Array.isArray(data.sessions)) {
|
|
const created = [];
|
|
for (const item of data.sessions) {
|
|
const session = await createSession({
|
|
...item,
|
|
course: item.course || data.courseId || data.course,
|
|
class: item.class || data.classId || data.class,
|
|
professor: item.professor || data.professorId || data.professor
|
|
}, actorId);
|
|
created.push(session);
|
|
}
|
|
return created;
|
|
}
|
|
|
|
const payload = normalizeSessionPayload(data);
|
|
|
|
if (!payload.day) {
|
|
throw new AppError('VALIDATION_FAILED', { day: 'Date is required' }, 'تاریخ جلسه الزامی است.');
|
|
}
|
|
if (!payload.startTime || !payload.endTime) {
|
|
throw new AppError('VALIDATION_FAILED', { time: 'Start and end time are required' }, 'ساعت شروع و پایان الزامی است.');
|
|
}
|
|
|
|
const course = await Course.findById(payload.course);
|
|
if (!course) throw new AppError('COURSE_NOT_FOUND');
|
|
|
|
const classItem = await Class.findById(payload.class);
|
|
if (!classItem) throw new AppError('NOT_FOUND', null, 'کلاس یافت نشد.');
|
|
|
|
const professor = await Professor.findById(payload.professor);
|
|
if (!professor) throw new AppError('PROFESSOR_NOT_FOUND');
|
|
|
|
const session = await Session.create(payload);
|
|
eventEmitter.emit(EVENT_NAMES.SESSION_CREATED, {
|
|
sessionId: session._id,
|
|
courseId: session.course,
|
|
classId: session.class,
|
|
actorId
|
|
});
|
|
return session;
|
|
};
|
|
|
|
const getSessionById = async (id) => {
|
|
const session = await Session.findById(id)
|
|
.populate('course', 'title type')
|
|
.populate({
|
|
path: 'class',
|
|
select: 'name students capacity',
|
|
populate: { path: 'students', select: 'name nationalIdCode phoneNumber gender' }
|
|
})
|
|
.populate('professor', 'name surname email phoneNumber')
|
|
.populate('attendanceList.user', 'name username nationalIdCode');
|
|
if (!session) {
|
|
throw new AppError('SESSION_NOT_FOUND');
|
|
}
|
|
return session;
|
|
};
|
|
|
|
const startOfToday = () => {
|
|
const d = new Date();
|
|
d.setHours(0, 0, 0, 0);
|
|
return d;
|
|
};
|
|
|
|
const getSessionEndDateTime = (session) => {
|
|
const end = new Date(session.day || session.date || 0);
|
|
if (Number.isNaN(end.getTime())) return null;
|
|
const parts = String(session.endTime || '23:59').trim().split(':');
|
|
const hours = Number(parts[0]);
|
|
const minutes = Number(parts[1]);
|
|
end.setHours(Number.isFinite(hours) ? hours : 23, Number.isFinite(minutes) ? minutes : 59, 59, 999);
|
|
return end;
|
|
};
|
|
|
|
const normalizeId = (value) => {
|
|
if (value == null) return '';
|
|
if (typeof value === 'object' && value._id != null) return String(value._id);
|
|
return String(value);
|
|
};
|
|
|
|
const buildStudentsByClassId = async (sessions) => {
|
|
const classIds = [...new Set(
|
|
sessions.map((session) => normalizeId(session.class?._id || session.class)).filter(Boolean)
|
|
)];
|
|
if (!classIds.length) return new Map();
|
|
|
|
const classes = await Class.find({ _id: { $in: classIds }, isDeleted: { $ne: true } }).select('students').lean();
|
|
const allStudentIds = [...new Set(
|
|
classes.flatMap((cls) => (cls.students || []).map(normalizeId)).filter(Boolean)
|
|
)];
|
|
|
|
let existingSet = new Set();
|
|
if (allStudentIds.length) {
|
|
const existingUsers = await User.find({ _id: { $in: allStudentIds } }).select('_id').lean();
|
|
existingSet = new Set(existingUsers.map((user) => normalizeId(user._id)));
|
|
}
|
|
|
|
return new Map(classes.map((cls) => {
|
|
const validStudentIds = (cls.students || [])
|
|
.map(normalizeId)
|
|
.filter((id) => existingSet.has(id));
|
|
return [String(cls._id), validStudentIds];
|
|
}));
|
|
};
|
|
|
|
const getValidStudentIdsForClass = async (classItem) => {
|
|
if (!classItem) return [];
|
|
const rawIds = (classItem.students || []).map(normalizeId).filter(Boolean);
|
|
if (!rawIds.length) return [];
|
|
const existingUsers = await User.find({ _id: { $in: rawIds } }).select('_id').lean();
|
|
const existingSet = new Set(existingUsers.map((user) => normalizeId(user._id)));
|
|
return rawIds.filter((id) => existingSet.has(id));
|
|
};
|
|
|
|
const isSessionDue = (session) => {
|
|
if (session.status === 'cancelled') return false;
|
|
const end = getSessionEndDateTime(session);
|
|
if (!end) return false;
|
|
return end.getTime() <= Date.now();
|
|
};
|
|
|
|
const getClassStudentIds = (session, studentsByClassId = null) => {
|
|
const classId = normalizeId(session.class?._id || session.class);
|
|
if (studentsByClassId && classId && studentsByClassId.has(classId)) {
|
|
return (studentsByClassId.get(classId) || []).map(normalizeId).filter(Boolean);
|
|
}
|
|
const students = session.class?.students || [];
|
|
return students.map(normalizeId).filter(Boolean);
|
|
};
|
|
|
|
const getRecordedUserIds = (session) =>
|
|
(session.attendanceList || []).map((record) => normalizeId(record.user)).filter(Boolean);
|
|
|
|
const hasCompleteAttendance = (session, studentsByClassId = null) => {
|
|
const studentIds = getClassStudentIds(session, studentsByClassId);
|
|
if (!studentIds.length) return true;
|
|
const recorded = new Set(getRecordedUserIds(session));
|
|
return studentIds.every((id) => recorded.has(id));
|
|
};
|
|
|
|
const isAttendancePending = (session, studentsByClassId = null) =>
|
|
isSessionDue(session) && !hasCompleteAttendance(session, studentsByClassId);
|
|
|
|
const enrichSessionAttendance = (session, studentsByClassId = null) => {
|
|
const studentIds = getClassStudentIds(session, studentsByClassId);
|
|
const recordedIds = getRecordedUserIds(session);
|
|
const studentSet = new Set(studentIds);
|
|
const attendanceRecordedCount = recordedIds.filter((id) => studentSet.has(id)).length;
|
|
|
|
return {
|
|
...session,
|
|
isDue: isSessionDue(session),
|
|
attendanceTotalCount: studentIds.length,
|
|
attendanceRecordedCount,
|
|
attendanceComplete: hasCompleteAttendance(session, studentsByClassId),
|
|
attendancePending: isAttendancePending(session, studentsByClassId)
|
|
};
|
|
};
|
|
|
|
const filterByAttendanceScope = (sessions, scope, studentsByClassId = null) => {
|
|
const dueSessions = sessions.filter(isSessionDue);
|
|
if (scope === 'pending') {
|
|
return dueSessions.filter((session) => !hasCompleteAttendance(session, studentsByClassId));
|
|
}
|
|
if (scope === 'recorded') {
|
|
return dueSessions.filter((session) => hasCompleteAttendance(session, studentsByClassId));
|
|
}
|
|
if (scope === 'due') {
|
|
return dueSessions;
|
|
}
|
|
return sessions;
|
|
};
|
|
|
|
/** Upcoming soonest first, then most recent past — nearest attendance date. */
|
|
const sortByClosestAttendance = (sessions) => {
|
|
const today = startOfToday().getTime();
|
|
return [...sessions].sort((a, b) => {
|
|
const da = new Date(a.day || a.date || 0).getTime();
|
|
const db = new Date(b.day || b.date || 0).getTime();
|
|
const aUpcoming = da >= today;
|
|
const bUpcoming = db >= today;
|
|
if (aUpcoming && bUpcoming) return da - db;
|
|
if (!aUpcoming && !bUpcoming) return db - da;
|
|
return aUpcoming ? -1 : 1;
|
|
});
|
|
};
|
|
|
|
const populateSessionList = (query, { includeClassStudents = false } = {}) => {
|
|
let chain = query
|
|
.populate('course', 'title type')
|
|
.populate('class', includeClassStudents ? 'name students' : 'name')
|
|
.populate('professor', 'name surname');
|
|
return chain;
|
|
};
|
|
|
|
const getAllSessions = async (queryParams) => {
|
|
const { page, limit, skip } = parsePaginationAndSort(queryParams, 'day', 'asc');
|
|
const attendanceScope = queryParams.attendanceScope;
|
|
const filter = buildFilterQuery(queryParams, ['topic', 'place', 'note'], [
|
|
'page',
|
|
'limit',
|
|
'sortBy',
|
|
'sortOrder',
|
|
'q',
|
|
'lang',
|
|
'courseId',
|
|
'classId',
|
|
'professorId',
|
|
'class',
|
|
'attendanceScope'
|
|
]);
|
|
|
|
if (queryParams.courseId) filter.course = queryParams.courseId;
|
|
const classFilter = queryParams.classId || queryParams.class;
|
|
if (classFilter) {
|
|
filter.class = classFilter;
|
|
}
|
|
if (queryParams.professorId) filter.professor = queryParams.professorId;
|
|
|
|
if (queryParams.trash === 'true' || queryParams.isDeleted === 'true') {
|
|
filter.isDeleted = true;
|
|
} else {
|
|
filter.isDeleted = { $ne: true };
|
|
}
|
|
|
|
if (filter.status) {
|
|
filter.status = STATUS_MAP[filter.status] || filter.status;
|
|
}
|
|
|
|
const includeClassStudents = Boolean(attendanceScope);
|
|
|
|
const matched = await populateSessionList(Session.find(filter), { includeClassStudents }).lean();
|
|
|
|
let sessions = sortByClosestAttendance(matched);
|
|
if (attendanceScope) {
|
|
const studentsByClassId = await buildStudentsByClassId(matched);
|
|
sessions = filterByAttendanceScope(sessions, attendanceScope, studentsByClassId)
|
|
.map((session) => enrichSessionAttendance(session, studentsByClassId));
|
|
const totalCount = sessions.length;
|
|
sessions = sessions.slice(skip, skip + limit);
|
|
const meta = calculateMeta(totalCount, page, limit);
|
|
return { data: sessions, meta };
|
|
}
|
|
|
|
const totalCount = await Session.countDocuments(filter);
|
|
sessions = sessions.slice(skip, skip + limit);
|
|
const meta = calculateMeta(totalCount, page, limit);
|
|
return { data: sessions, meta };
|
|
};
|
|
|
|
const updateSession = async (id, updateData, actorId = null) => {
|
|
const session = await Session.findById(id);
|
|
if (!session) {
|
|
throw new AppError('SESSION_NOT_FOUND');
|
|
}
|
|
|
|
const payload = normalizeSessionPayload(updateData);
|
|
|
|
if (payload.day === null || payload.day === '') {
|
|
throw new AppError('VALIDATION_FAILED', { day: 'Date is required' }, 'تاریخ جلسه الزامی است.');
|
|
}
|
|
|
|
if (payload.status === 'cancelled' && session.status !== 'cancelled') {
|
|
eventEmitter.emit(EVENT_NAMES.SESSION_CANCELLED, {
|
|
sessionId: session._id,
|
|
courseId: session.course,
|
|
topic: payload.topic || session.topic,
|
|
actorId
|
|
});
|
|
}
|
|
|
|
Object.assign(session, payload);
|
|
await session.save();
|
|
return getSessionById(session._id);
|
|
};
|
|
|
|
const deleteSession = async (id) => {
|
|
const session = await Session.findById(id);
|
|
if (!session) {
|
|
throw new AppError('SESSION_NOT_FOUND');
|
|
}
|
|
await Session.findByIdAndUpdate(id, { $set: { isDeleted: true, deletedAt: new Date() } });
|
|
return null;
|
|
};
|
|
|
|
const normalizeIds = (ids) => {
|
|
if (!Array.isArray(ids)) return [];
|
|
return [...new Set(ids.map((id) => String(id || '').trim()).filter(Boolean))];
|
|
};
|
|
|
|
const bulkDeleteSessions = async (ids) => {
|
|
const sessionIds = normalizeIds(ids);
|
|
if (!sessionIds.length) {
|
|
throw new AppError('VALIDATION_FAILED', { ids: 'Required' }, 'هیچ جلسهای انتخاب نشده است.');
|
|
}
|
|
|
|
const result = await Session.updateMany(
|
|
{ _id: { $in: sessionIds } },
|
|
{ $set: { isDeleted: true, deletedAt: new Date() } }
|
|
);
|
|
return { deletedCount: result.modifiedCount || result.nModified || 0 };
|
|
};
|
|
|
|
const bulkUpdateSessionStatus = async (ids, status, actorId = null) => {
|
|
const sessionIds = normalizeIds(ids);
|
|
if (!sessionIds.length) {
|
|
throw new AppError('VALIDATION_FAILED', { ids: 'Required' }, 'هیچ جلسهای انتخاب نشده است.');
|
|
}
|
|
|
|
const normalizedStatus = STATUS_MAP[status] || status;
|
|
if (!['scheduled', 'held', 'cancelled'].includes(normalizedStatus)) {
|
|
throw new AppError('VALIDATION_FAILED', { status: 'Invalid' }, 'وضعیت جلسه نامعتبر است.');
|
|
}
|
|
|
|
const sessions = await Session.find({ _id: { $in: sessionIds } });
|
|
let updatedCount = 0;
|
|
|
|
for (const session of sessions) {
|
|
const previousStatus = session.status;
|
|
session.status = normalizedStatus;
|
|
await session.save();
|
|
updatedCount += 1;
|
|
|
|
if (normalizedStatus === 'cancelled' && previousStatus !== 'cancelled') {
|
|
eventEmitter.emit(EVENT_NAMES.SESSION_CANCELLED, {
|
|
sessionId: session._id,
|
|
courseId: session.course,
|
|
classId: session.class,
|
|
actorId
|
|
});
|
|
}
|
|
}
|
|
|
|
return { updatedCount, status: normalizedStatus };
|
|
};
|
|
|
|
const searchSessions = async (queryParams) => {
|
|
return getAllSessions(queryParams);
|
|
};
|
|
|
|
const updateSessionAttendance = async (sessionId, attendanceList, recordedBy = null) => {
|
|
const session = await Session.findById(sessionId);
|
|
if (!session) throw new AppError('SESSION_NOT_FOUND');
|
|
|
|
const list = Array.isArray(attendanceList) ? attendanceList : [];
|
|
|
|
session.attendanceList = list.map((record) => ({
|
|
user: record.user || record.userId,
|
|
status: record.status || 'present',
|
|
note: record.note || '',
|
|
recordedBy
|
|
}));
|
|
|
|
await session.save();
|
|
|
|
const classItem = await Class.findById(session.class).select('students').lean();
|
|
const validStudentIds = await getValidStudentIdsForClass(classItem);
|
|
if (
|
|
classItem
|
|
&& hasCompleteAttendance({ ...session.toObject(), class: { ...classItem, students: validStudentIds } })
|
|
&& session.status !== 'cancelled'
|
|
) {
|
|
session.status = 'held';
|
|
await session.save();
|
|
}
|
|
|
|
eventEmitter.emit(EVENT_NAMES.ATTENDANCE_RECORDED, {
|
|
sessionId: session._id,
|
|
recordCount: list.length,
|
|
recordedBy
|
|
});
|
|
|
|
return getSessionById(sessionId);
|
|
};
|
|
|
|
const getMySessions = async (userId, queryParams) => {
|
|
const userClasses = await Class.find({ students: userId, isDeleted: { $ne: true } }).select('_id');
|
|
const classIds = userClasses.map((c) => c._id);
|
|
|
|
const filter = { class: { $in: classIds }, isDeleted: { $ne: true } };
|
|
|
|
const { page, limit, skip } = parsePaginationAndSort(queryParams, 'day', 'asc');
|
|
const [matched, totalCount] = await Promise.all([
|
|
populateSessionList(Session.find(filter)).lean(),
|
|
Session.countDocuments(filter)
|
|
]);
|
|
|
|
const sessions = sortByClosestAttendance(matched).slice(skip, skip + limit);
|
|
const meta = calculateMeta(totalCount, page, limit);
|
|
return { data: sessions, meta };
|
|
};
|
|
|
|
module.exports = {
|
|
createSession,
|
|
getSessionById,
|
|
getAllSessions,
|
|
updateSession,
|
|
deleteSession,
|
|
bulkDeleteSessions,
|
|
bulkUpdateSessionStatus,
|
|
searchSessions,
|
|
updateSessionAttendance,
|
|
getMySessions,
|
|
isSessionDue,
|
|
hasCompleteAttendance,
|
|
isAttendancePending
|
|
};
|