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
+43
View File
@@ -0,0 +1,43 @@
// /components/courses/courseController.js
const catchAsync = require('../../utils/catchAsync');
const courseService = require('./courseService');
const { successResponse, listResponse } = require('../../utils/apiResponse');
const isPublicRequest = (req) => String(req.originalUrl || req.path || '').includes('/user/');
exports.create = catchAsync(async (req, res) => {
const actorId = req.user?._id;
const course = await courseService.createCourse(req.body, actorId);
return successResponse(res, 201, 'Course created successfully', course);
});
exports.getOne = catchAsync(async (req, res) => {
const course = await courseService.getCourseById(req.params.id, {
publicOnly: isPublicRequest(req)
});
return successResponse(res, 200, 'Course retrieved successfully', course);
});
exports.getAll = catchAsync(async (req, res) => {
const { data, meta } = await courseService.getAllCourses(req.query, {
publicOnly: isPublicRequest(req)
});
return listResponse(res, 200, data, meta);
});
exports.update = catchAsync(async (req, res) => {
const actorId = req.user?._id;
const course = await courseService.updateCourse(req.params.id, req.body, actorId);
return successResponse(res, 200, 'Course updated successfully', course);
});
exports.delete = catchAsync(async (req, res) => {
await courseService.deleteCourse(req.params.id);
return successResponse(res, 200, 'Course deleted successfully');
});
exports.search = catchAsync(async (req, res) => {
const { data, meta } = await courseService.searchCourses(req.query);
return listResponse(res, 200, data, meta);
});
+95
View File
@@ -0,0 +1,95 @@
// /components/courses/courseModel.js
const mongoose = require('mongoose');
const discountSchema = new mongoose.Schema({
percent: { type: Number, required: true, min: 0, max: 100 },
validFrom: { type: Date, required: true },
validTo: { type: Date, required: true },
isActive: { type: Boolean, default: true }
});
const offerSchema = new mongoose.Schema({
title: { type: String, required: true, trim: true },
description: { type: String, trim: true },
type: {
type: String,
enum: ['fullAdvancePayment', 'earlyBird', 'custom'],
required: true
},
percent: { type: Number, min: 0, max: 100 },
fixedAmount: { type: Number, min: 0 },
validFrom: { type: Date },
validTo: { type: Date },
isActive: { type: Boolean, default: true }
});
const courseSchema = new mongoose.Schema({
title: {
type: String,
required: true,
trim: true,
index: true
},
description: {
type: String,
trim: true
},
type: {
type: String,
enum: ['General', 'Private'],
required: true
},
price: {
type: Number,
required: true,
min: 0
},
rating: {
type: Number,
default: 0,
min: 0,
max: 5
},
isOfficial: {
type: Boolean,
default: false
},
/** When true, course appears on the public website */
showOnFrontend: {
type: Boolean,
default: true,
index: true
},
/** Number of sessions in the course */
sectionCount: {
type: Number,
default: 1,
min: 1
},
/** Hours per session */
hoursPerSection: {
type: Number,
default: 1.5,
min: 0
},
/** Bullet-point highlights shown on the frontend */
highlights: [{
type: String,
trim: true
}],
professor: {
type: mongoose.Schema.Types.ObjectId,
ref: 'Professor'
},
discounts: [discountSchema],
offers: [offerSchema],
capacity: {
type: Number,
default: 30
}
}, {
timestamps: true
});
module.exports = mongoose.model('Course', courseSchema);
+24
View File
@@ -0,0 +1,24 @@
// /components/courses/courseRoutes.js
const express = require('express');
const courseController = require('./courseController');
const { validateCreateCourse, validateUpdateCourse } = require('./courseValidator');
const authMiddleware = require('../../middlewares/authMiddleware');
const perm = require('../../middlewares/permissionMiddleware');
const { PERMISSIONS } = require('../../constants/permissions');
const router = express.Router();
// User Scope (Public or Authenticated reading)
router.get('/user/get-all', courseController.getAll);
router.get('/user/get-one/:id', courseController.getOne);
// Admin Scope
router.post('/admin/create', authMiddleware, perm.requires(PERMISSIONS.COURSES_CREATE), validateCreateCourse, courseController.create);
router.get('/admin/get-all', authMiddleware, perm.requires(PERMISSIONS.COURSES_READ), courseController.getAll);
router.get('/admin/search', authMiddleware, perm.requires(PERMISSIONS.COURSES_SEARCH), courseController.search);
router.get('/admin/get-one/:id', authMiddleware, perm.requires(PERMISSIONS.COURSES_READ), courseController.getOne);
router.put('/admin/update/:id', authMiddleware, perm.requires(PERMISSIONS.COURSES_UPDATE), validateUpdateCourse, courseController.update);
router.delete('/admin/delete/:id', authMiddleware, perm.requires(PERMISSIONS.COURSES_DELETE), courseController.delete);
module.exports = router;
+139
View File
@@ -0,0 +1,139 @@
// /components/courses/courseService.js
const Course = require('./courseModel');
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 normalizeHighlights = (value) => {
if (!Array.isArray(value)) return undefined;
return value.map((item) => String(item).trim()).filter(Boolean);
};
const createCourse = async (data, actorId = null) => {
if (data.professor) {
const professor = await Professor.findById(data.professor);
if (!professor) {
throw new AppError('PROFESSOR_NOT_FOUND');
}
}
const payload = {
...data,
highlights: normalizeHighlights(data.highlights) ?? data.highlights
};
const course = await Course.create(payload);
if (data.professor) {
await Professor.findByIdAndUpdate(data.professor, { $addToSet: { courses: course._id } });
}
eventEmitter.emit(EVENT_NAMES.COURSE_CREATED, { courseId: course._id, title: course.title, actorId });
return course;
};
const getCourseById = async (id, { publicOnly = false } = {}) => {
const filter = { _id: id };
if (publicOnly) filter.showOnFrontend = { $ne: false };
const course = await Course.findOne(filter).populate('professor', 'name surname title expertise email phoneNumber');
if (!course) {
throw new AppError('COURSE_NOT_FOUND');
}
return course;
};
const getAllCourses = async (queryParams, { publicOnly = false } = {}) => {
const { page, limit, skip, sort } = parsePaginationAndSort(queryParams);
const filter = buildFilterQuery(queryParams, ['title', 'description'], [
'page', 'limit', 'sortBy', 'sortOrder', 'q', 'lang', 'sort'
]);
if (publicOnly) {
// Include legacy docs that predate the field (treat missing as visible)
filter.showOnFrontend = { $ne: false };
}
// Support legacy ?sort=-createdAt style from frontend
let finalSort = sort;
if (queryParams.sort && typeof queryParams.sort === 'string') {
const raw = queryParams.sort.trim();
if (raw.startsWith('-')) {
finalSort = { [raw.slice(1)]: -1 };
} else {
finalSort = { [raw]: 1 };
}
}
const [courses, totalCount] = await Promise.all([
Course.find(filter).populate('professor', 'name surname').sort(finalSort).skip(skip).limit(limit),
Course.countDocuments(filter)
]);
const meta = calculateMeta(totalCount, page, limit);
return { data: courses, meta };
};
const updateCourse = async (id, updateData, actorId = null) => {
const course = await Course.findById(id);
if (!course) {
throw new AppError('COURSE_NOT_FOUND');
}
if (updateData.professor && updateData.professor !== String(course.professor)) {
const professor = await Professor.findById(updateData.professor);
if (!professor) throw new AppError('PROFESSOR_NOT_FOUND');
if (course.professor) {
await Professor.findByIdAndUpdate(course.professor, { $pull: { courses: course._id } });
}
await Professor.findByIdAndUpdate(updateData.professor, { $addToSet: { courses: course._id } });
}
if (updateData.price !== undefined && updateData.price !== course.price) {
eventEmitter.emit(EVENT_NAMES.COURSE_PRICE_CHANGED, {
courseId: course._id,
oldPrice: course.price,
newPrice: updateData.price,
actorId
});
}
if (updateData.highlights !== undefined) {
updateData.highlights = normalizeHighlights(updateData.highlights) || [];
}
Object.assign(course, updateData);
await course.save();
return course;
};
const deleteCourse = async (id) => {
const course = await Course.findById(id);
if (!course) {
throw new AppError('COURSE_NOT_FOUND');
}
if (course.professor) {
await Professor.findByIdAndUpdate(course.professor, { $pull: { courses: course._id } });
}
await Course.findByIdAndDelete(id);
return null;
};
const searchCourses = async (queryParams) => {
return getAllCourses(queryParams);
};
module.exports = {
createCourse,
getCourseById,
getAllCourses,
updateCourse,
deleteCourse,
searchCourses
};
+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
});