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.
This commit is contained in:
2026-08-16 08:55:03 +03:30
parent cd98adff84
commit 0d57854dc3
3 changed files with 67 additions and 17 deletions
+5
View File
@@ -73,6 +73,11 @@ const classSchema = new mongoose.Schema({
trim: true,
default: ''
},
numberOfSessions: {
type: Number,
min: 1,
default: null
},
isActive: {
type: Boolean,
default: true
+20 -3
View File
@@ -18,7 +18,7 @@ const applyScheduleFields = (payload, body) => {
return payload;
};
const CLASS_LIST_FIELDS = 'name course professor students capacity tuitionFee hasDiscount discount freeSpots showOnFrontend startDate endDate days startTime endTime isActive adminNotes createdAt updatedAt';
const CLASS_LIST_FIELDS = 'name course professor students capacity tuitionFee hasDiscount discount freeSpots showOnFrontend startDate endDate days startTime endTime numberOfSessions isActive adminNotes createdAt updatedAt';
const normalizePricingFields = (body = {}) => {
const tuitionFee = Math.max(0, Number(body.tuitionFee) || 0);
@@ -29,6 +29,13 @@ const normalizePricingFields = (body = {}) => {
return { tuitionFee, hasDiscount, discount };
};
const normalizeNumberOfSessions = (value) => {
if (value === '' || value === null || value === undefined) return null;
const parsed = Number(value);
if (!Number.isFinite(parsed) || parsed < 1) return null;
return Math.floor(parsed);
};
const enrichClassForDisplay = (cls) => {
const tuitionFee = cls.tuitionFee || 0;
const discount = cls.hasDiscount ? (cls.discount || 0) : 0;
@@ -77,7 +84,11 @@ const getOne = async (id) => {
};
const create = async (body) => {
const payload = applyScheduleFields({ ...body, ...normalizePricingFields(body) }, body);
const payload = applyScheduleFields({
...body,
...normalizePricingFields(body),
numberOfSessions: normalizeNumberOfSessions(body.numberOfSessions)
}, body);
if (body.freeSpots === '' || body.freeSpots === null || body.freeSpots === undefined) {
payload.freeSpots = null;
} else {
@@ -88,7 +99,13 @@ const create = async (body) => {
};
const update = async (id, body) => {
const payload = applyScheduleFields({ ...body, ...normalizePricingFields(body) }, body);
const payload = applyScheduleFields({
...body,
...normalizePricingFields(body),
...(body.numberOfSessions !== undefined
? { numberOfSessions: normalizeNumberOfSessions(body.numberOfSessions) }
: {})
}, body);
if (body.freeSpots === '' || body.freeSpots === null) {
payload.freeSpots = null;
} else if (body.freeSpots !== undefined) {
+42 -14
View File
@@ -111,6 +111,22 @@ const getSessionEndDateTime = (session) => {
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);
@@ -118,38 +134,42 @@ const isSessionDue = (session) => {
return end.getTime() <= Date.now();
};
const getClassStudentIds = (session) => {
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((student) => String(student._id || student)).filter(Boolean);
return students.map(normalizeId).filter(Boolean);
};
const getRecordedUserIds = (session) =>
(session.attendanceList || []).map((record) => String(record.user?._id || record.user)).filter(Boolean);
(session.attendanceList || []).map((record) => normalizeId(record.user)).filter(Boolean);
const hasCompleteAttendance = (session) => {
const studentIds = getClassStudentIds(session);
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) =>
session.status === 'held' && isSessionDue(session) && !hasCompleteAttendance(session);
const isAttendancePending = (session, studentsByClassId = null) =>
isSessionDue(session) && !hasCompleteAttendance(session, studentsByClassId);
const enrichSessionAttendance = (session) => ({
const enrichSessionAttendance = (session, studentsByClassId = null) => ({
...session,
isDue: isSessionDue(session),
attendanceComplete: hasCompleteAttendance(session),
attendancePending: isAttendancePending(session)
attendanceComplete: hasCompleteAttendance(session, studentsByClassId),
attendancePending: isAttendancePending(session, studentsByClassId)
});
const filterByAttendanceScope = (sessions, scope) => {
const filterByAttendanceScope = (sessions, scope, studentsByClassId = null) => {
const dueSessions = sessions.filter(isSessionDue);
if (scope === 'pending') {
return dueSessions.filter((session) => !hasCompleteAttendance(session));
return dueSessions.filter((session) => !hasCompleteAttendance(session, studentsByClassId));
}
if (scope === 'recorded') {
return dueSessions.filter((session) => hasCompleteAttendance(session));
return dueSessions.filter((session) => hasCompleteAttendance(session, studentsByClassId));
}
if (scope === 'due') {
return dueSessions;
@@ -213,7 +233,9 @@ const getAllSessions = async (queryParams) => {
let sessions = sortByClosestAttendance(matched);
if (attendanceScope) {
sessions = filterByAttendanceScope(sessions, attendanceScope).map(enrichSessionAttendance);
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);
@@ -328,6 +350,12 @@ const updateSessionAttendance = async (sessionId, attendanceList, recordedBy = n
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,