// /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; }; /** 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) => query .populate('course', 'title type') .populate('class', 'name') .populate('professor', 'name surname'); const getAllSessions = async (queryParams) => { const { page, limit, skip } = parsePaginationAndSort(queryParams, 'day', 'asc'); const filter = buildFilterQuery(queryParams, ['topic', 'place', 'note'], [ 'page', 'limit', 'sortBy', 'sortOrder', 'q', 'lang', 'courseId', 'classId', 'professorId', 'class' // handled below so we always cast consistently ]); 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 [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 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 };