Initial commit: teaching institution management API.

Express/MongoDB backend with JWT auth, RBAC, S3 uploads, notifications, and scheduled jobs.
This commit is contained in:
2026-08-09 04:18:08 +02:00
commit f04c797be6
107 changed files with 9190 additions and 0 deletions
+63
View File
@@ -0,0 +1,63 @@
// /components/sessions/sessionController.js
const catchAsync = require('../../utils/catchAsync');
const sessionService = require('./sessionService');
const { successResponse, listResponse } = require('../../utils/apiResponse');
exports.create = catchAsync(async (req, res, next) => {
const actorId = req.user?._id;
const session = await sessionService.createSession(req.body, actorId);
return successResponse(res, 201, 'Session created successfully', session);
});
exports.getOne = catchAsync(async (req, res, next) => {
const session = await sessionService.getSessionById(req.params.id);
return successResponse(res, 200, 'Session retrieved successfully', session);
});
exports.getAll = catchAsync(async (req, res, next) => {
const { data, meta } = await sessionService.getAllSessions(req.query);
return listResponse(res, 200, data, meta);
});
exports.update = catchAsync(async (req, res, next) => {
const actorId = req.user?._id;
const session = await sessionService.updateSession(req.params.id, req.body, actorId);
return successResponse(res, 200, 'Session updated successfully', session);
});
exports.delete = catchAsync(async (req, res, next) => {
await sessionService.deleteSession(req.params.id);
return successResponse(res, 200, 'Session deleted successfully');
});
exports.bulkDelete = catchAsync(async (req, res, next) => {
const result = await sessionService.bulkDeleteSessions(req.body.ids || req.body.sessionIds);
return successResponse(res, 200, 'Sessions deleted successfully', result);
});
exports.bulkUpdateStatus = catchAsync(async (req, res, next) => {
const actorId = req.user?._id;
const result = await sessionService.bulkUpdateSessionStatus(
req.body.ids || req.body.sessionIds,
req.body.status,
actorId
);
return successResponse(res, 200, 'Session statuses updated successfully', result);
});
exports.search = catchAsync(async (req, res, next) => {
const { data, meta } = await sessionService.searchSessions(req.query);
return listResponse(res, 200, data, meta);
});
exports.updateAttendance = catchAsync(async (req, res, next) => {
const recordedBy = req.user?._id;
const session = await sessionService.updateSessionAttendance(req.params.id, req.body.attendanceList, recordedBy);
return successResponse(res, 200, 'Session attendance updated successfully', session);
});
exports.getMySessions = catchAsync(async (req, res, next) => {
const { data, meta } = await sessionService.getMySessions(req.user._id, req.query);
return listResponse(res, 200, data, meta);
});
+89
View File
@@ -0,0 +1,89 @@
// /components/sessions/sessionModel.js
const mongoose = require('mongoose');
const attendanceRecordSchema = new mongoose.Schema({
user: {
type: mongoose.Schema.Types.ObjectId,
ref: 'User',
required: true
},
status: {
type: String,
enum: ['present', 'absent', 'late', 'excused'],
default: 'present'
},
note: {
type: String,
trim: true
},
recordedBy: {
type: mongoose.Schema.Types.ObjectId,
ref: 'User'
}
}, {
_id: false
});
const sessionSchema = new mongoose.Schema({
course: {
type: mongoose.Schema.Types.ObjectId,
ref: 'Course',
required: true,
index: true
},
class: {
type: mongoose.Schema.Types.ObjectId,
ref: 'Class',
required: true,
index: true
},
professor: {
type: mongoose.Schema.Types.ObjectId,
ref: 'Professor',
required: true,
index: true
},
day: {
type: Date,
required: true,
index: true
},
startTime: {
type: String,
required: true,
trim: true
},
endTime: {
type: String,
required: true,
trim: true
},
place: {
type: String,
trim: true
},
topic: {
type: String,
trim: true
},
note: {
type: String,
trim: true,
maxlength: 5000
},
status: {
type: String,
enum: ['scheduled', 'held', 'cancelled'],
default: 'scheduled'
},
attendanceList: [attendanceRecordSchema],
reminderSentAt: {
type: Date,
default: null
}
}, {
timestamps: true
});
module.exports = mongoose.model('Session', sessionSchema);
+32
View File
@@ -0,0 +1,32 @@
// /components/sessions/sessionRoutes.js
const express = require('express');
const sessionController = require('./sessionController');
const {
validateCreateSession,
validateUpdateSession,
validateUpdateAttendanceList
} = require('./sessionValidator');
const authMiddleware = require('../../middlewares/authMiddleware');
const perm = require('../../middlewares/permissionMiddleware');
const { PERMISSIONS } = require('../../constants/permissions');
const router = express.Router();
router.use(authMiddleware);
// User Scope
router.get('/user/my-sessions', sessionController.getMySessions);
// Admin Scope
router.post('/admin/create', perm.requires(PERMISSIONS.SESSIONS_CREATE), validateCreateSession, sessionController.create);
router.get('/admin/get-all', perm.requires(PERMISSIONS.SESSIONS_READ), sessionController.getAll);
router.get('/admin/search', perm.requires(PERMISSIONS.SESSIONS_SEARCH), sessionController.search);
router.get('/admin/get-one/:id', perm.requires(PERMISSIONS.SESSIONS_READ), sessionController.getOne);
router.put('/admin/update/:id', perm.requires(PERMISSIONS.SESSIONS_UPDATE), validateUpdateSession, sessionController.update);
router.delete('/admin/delete/:id', perm.requires(PERMISSIONS.SESSIONS_DELETE), sessionController.delete);
router.post('/admin/bulk-delete', perm.requires(PERMISSIONS.SESSIONS_DELETE), sessionController.bulkDelete);
router.post('/admin/bulk-status', perm.requires(PERMISSIONS.SESSIONS_UPDATE), sessionController.bulkUpdateStatus);
router.put('/admin/:id/attendance', perm.requires(PERMISSIONS.SESSIONS_ATTENDANCE), validateUpdateAttendanceList, sessionController.updateAttendance);
module.exports = router;
+299
View File
@@ -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
};
+6
View File
@@ -0,0 +1,6 @@
// Stub validator — pass-through middleware (no validation yet)
const passThrough = (req, res, next) => next();
module.exports = new Proxy({}, {
get: () => passThrough
});