Only list sessions after their end time when at least one enrolled student lacks attendance, and expose attendanceScope on session listing.
372 lines
12 KiB
JavaScript
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
|
|
};
|