Files
gameno-api/components/sessions/sessionService.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

400 lines
13 KiB
JavaScript

// /components/sessions/sessionService.js
const Session = require('./sessionModel');
const Course = require('../courses/courseModel');
const Class = require('../classes/classModel');
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 } }).select('students').lean();
return new Map(classes.map((cls) => [String(cls._id), cls.students || []]));
};
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) => ({
...session,
isDue: isSessionDue(session),
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 (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.findByIdAndDelete(id);
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.deleteMany({ _id: { $in: sessionIds } });
return { deletedCount: result.deletedCount || 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();
if (classItem && hasCompleteAttendance({ ...session.toObject(), class: classItem }) && 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 }).select('_id');
const classIds = userClasses.map((c) => c._id);
const filter = { class: { $in: classIds } };
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
};