Initial commit: teaching institution management API.
Express/MongoDB backend with JWT auth, RBAC, S3 uploads, notifications, and scheduled jobs.
This commit is contained in:
@@ -0,0 +1,299 @@
|
||||
// /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 surname nationalIdCode phoneNumber' }
|
||||
})
|
||||
.populate('professor', 'name surname email phoneNumber')
|
||||
.populate('attendanceList.user', 'name surname 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
|
||||
};
|
||||
Reference in New Issue
Block a user