Files
gameno-api/components/sessions/sessionService.js
T
kavehhn fafc362689 fix: filter attendance sessions to past-due with incomplete records
Only list sessions after their end time when at least one enrolled student lacks attendance, and expose attendanceScope on session listing.
2026-08-16 08:22:18 +03:30

372 lines
12 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 isSessionDue = (session) => {
if (session.status === 'cancelled') return false;
const end = getSessionEndDateTime(session);
if (!end) return false;
return end.getTime() <= Date.now();
};
const getClassStudentIds = (session) => {
const students = session.class?.students || [];
return students.map((student) => String(student._id || student)).filter(Boolean);
};
const getRecordedUserIds = (session) =>
(session.attendanceList || []).map((record) => String(record.user?._id || record.user)).filter(Boolean);
const hasCompleteAttendance = (session) => {
const studentIds = getClassStudentIds(session);
if (!studentIds.length) return true;
const recorded = new Set(getRecordedUserIds(session));
return studentIds.every((id) => recorded.has(id));
};
const isAttendancePending = (session) =>
session.status === 'held' && isSessionDue(session) && !hasCompleteAttendance(session);
const enrichSessionAttendance = (session) => ({
...session,
isDue: isSessionDue(session),
attendanceComplete: hasCompleteAttendance(session),
attendancePending: isAttendancePending(session)
});
const filterByAttendanceScope = (sessions, scope) => {
const dueSessions = sessions.filter(isSessionDue);
if (scope === 'pending') {
return dueSessions.filter((session) => !hasCompleteAttendance(session));
}
if (scope === 'recorded') {
return dueSessions.filter((session) => hasCompleteAttendance(session));
}
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) {
sessions = filterByAttendanceScope(sessions, attendanceScope).map(enrichSessionAttendance);
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();
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
};