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,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);
|
||||
});
|
||||
@@ -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);
|
||||
@@ -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;
|
||||
@@ -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
|
||||
};
|
||||
@@ -0,0 +1,6 @@
|
||||
// Stub validator — pass-through middleware (no validation yet)
|
||||
const passThrough = (req, res, next) => next();
|
||||
|
||||
module.exports = new Proxy({}, {
|
||||
get: () => passThrough
|
||||
});
|
||||
Reference in New Issue
Block a user