// /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 { notifyAction } = require('../../utils/actionNotify'); const { sendSessionHoldingSms } = require('../../utils/senders/smsMessages'); const logger = require('../../utils/logger'); 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, sort } = 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); if (attendanceScope) { const matched = await populateSessionList(Session.find(filter), { includeClassStudents }).lean(); let sessions = sortByClosestAttendance(matched); 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 effectiveSort = { ...sort }; if (effectiveSort.day) { effectiveSort.startTime = effectiveSort.day; } const [sessions, totalCount] = await Promise.all([ populateSessionList(Session.find(filter).sort(effectiveSort).skip(skip).limit(limit), { includeClassStudents: false }).lean(), Session.countDocuments(filter) ]); 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 }; }; const notifySessionHolding = async (sessionId, actorId = null) => { const session = await Session.findById(sessionId) .populate({ path: 'class', select: 'name uniqueCode students startTime place' }) .populate('course', 'title'); if (!session) { throw new AppError('SESSION_NOT_FOUND'); } const classDoc = session.class; let studentIds = classDoc?.students?.length ? classDoc.students : (await User.find({ courses: session.course?._id || session.course }).select('_id')).map((u) => u._id); if (!studentIds.length) { throw new AppError('NOT_FOUND', null, 'هیچ دانشجویی در این کلاس ثبت‌نام نشده است.'); } const sessionDate = session.day ? new Date(session.day).toLocaleDateString('fa-IR') : ''; const className = classDoc?.name || session.course?.title || 'کلاس'; const classCode = classDoc?.uniqueCode || ''; const timeLabel = session.startTime || ''; const topicLabel = session.topic || className; const users = await User.find({ _id: { $in: studentIds } }).select('name phoneNumber email').lean(); const validUsers = users.filter((u) => u.phoneNumber); if (!validUsers.length) { throw new AppError('NOT_FOUND', null, 'هیچ دانشجویی با شماره همراه معتبر در این کلاس یافت نشد.'); } let sentCount = 0; for (const student of validUsers) { try { await notifyAction({ actionKey: 'sessionHolding', userId: student._id, phoneNumber: student.phoneNumber, email: student.email, subject: 'برگزاری جلسه طبق برنامه', body: `جلسه «${topicLabel}» کلاس ${className} در تاریخ ${sessionDate} و ساعت ${timeLabel} طبق برنامه برگزار خواهد شد.`, smsHandler: () => sendSessionHoldingSms(student.phoneNumber, { fullName: student.name || '', className, topic: topicLabel, sessionDate, classTime: timeLabel, courseName: session.course?.title || className, classCode, place: session.place || '-' }, student._id), requestSource: { notifySms: true, notifyEmail: true, notifyBot: true } }); sentCount += 1; } catch (err) { logger.error(`[notifySessionHolding] Failed for user ${student._id}: ${err.message}`); } } return { success: true, sentCount, totalStudents: validUsers.length }; }; module.exports = { createSession, getSessionById, getAllSessions, updateSession, deleteSession, bulkDeleteSessions, bulkUpdateSessionStatus, searchSessions, updateSessionAttendance, getMySessions, notifySessionHolding, isSessionDue, hasCompleteAttendance, isAttendancePending };