Release v1.1.0 - Course Content & Enrollment Management

New Features:
- Comprehensive course content management system
- Multi-type content support (Documents, Images, Videos, Articles, Quizzes, Certificates)
- File upload system with 100MB limit and type validation
- Quiz system with multiple question types
- Complete enrollment and subscriber management
- Status tracking (pending, active, completed, cancelled)
- Payment management (pending, paid, failed, refunded)
- Progress tracking (0-100%) with automatic updates
- Course capacity limits and validation
- Certificate issuance tracking
- Enrollment analytics and statistics

Technical Improvements:
- Enhanced file upload system with type validation
- New database models: CourseContent, QuizQuestion
- Comprehensive API endpoints for content and enrollment management
- Role-based access control for all new features
- Enhanced error handling and validation
- File management with automatic cleanup

This version provides a complete foundation for creating rich educational content and managing student enrollments.
This commit is contained in:
Mahmoud M. Abdalla 2025-07-27 23:59:01 +03:00
parent 52fe7e05c5
commit da2723f771
8 changed files with 996 additions and 144 deletions

View file

@ -0,0 +1,110 @@
const { DataTypes } = require('sequelize');
const { sequelize } = require('../config/database');
const CourseContent = sequelize.define('CourseContent', {
id: {
type: DataTypes.UUID,
defaultValue: DataTypes.UUIDV4,
primaryKey: true
},
courseId: {
type: DataTypes.UUID,
allowNull: false
},
title: {
type: DataTypes.STRING,
allowNull: false,
validate: {
notEmpty: true,
len: [1, 200]
}
},
description: {
type: DataTypes.TEXT,
allowNull: true
},
type: {
type: DataTypes.ENUM('document', 'image', 'video', 'article', 'quiz', 'certificate'),
allowNull: false
},
content: {
type: DataTypes.JSONB,
allowNull: true,
defaultValue: {}
},
fileUrl: {
type: DataTypes.STRING,
allowNull: true
},
fileSize: {
type: DataTypes.INTEGER, // in bytes
allowNull: true
},
fileType: {
type: DataTypes.STRING,
allowNull: true
},
duration: {
type: DataTypes.INTEGER, // in seconds, for videos
allowNull: true
},
order: {
type: DataTypes.INTEGER,
allowNull: false,
defaultValue: 0
},
isPublished: {
type: DataTypes.BOOLEAN,
defaultValue: true
},
isRequired: {
type: DataTypes.BOOLEAN,
defaultValue: true
},
points: {
type: DataTypes.INTEGER,
allowNull: true,
defaultValue: 0
},
// For quizzes
quizData: {
type: DataTypes.JSONB,
allowNull: true,
defaultValue: {}
},
// For articles
articleContent: {
type: DataTypes.TEXT,
allowNull: true
},
// For certificates
certificateTemplate: {
type: DataTypes.JSONB,
allowNull: true,
defaultValue: {}
},
// Metadata
metadata: {
type: DataTypes.JSONB,
allowNull: true,
defaultValue: {}
}
}, {
tableName: 'course_contents',
indexes: [
{
fields: ['courseId']
},
{
fields: ['type']
},
{
fields: ['isPublished']
},
{
fields: ['order']
}
]
});
module.exports = CourseContent;

View file

@ -0,0 +1,70 @@
const { DataTypes } = require('sequelize');
const { sequelize } = require('../config/database');
const QuizQuestion = sequelize.define('QuizQuestion', {
id: {
type: DataTypes.UUID,
defaultValue: DataTypes.UUIDV4,
primaryKey: true
},
contentId: {
type: DataTypes.UUID,
allowNull: false
},
question: {
type: DataTypes.TEXT,
allowNull: false
},
questionType: {
type: DataTypes.ENUM('multiple_choice', 'single_choice', 'true_false', 'text', 'file_upload'),
allowNull: false,
defaultValue: 'multiple_choice'
},
options: {
type: DataTypes.JSONB,
allowNull: true,
defaultValue: []
},
correctAnswer: {
type: DataTypes.JSONB,
allowNull: true
},
points: {
type: DataTypes.INTEGER,
allowNull: false,
defaultValue: 1
},
order: {
type: DataTypes.INTEGER,
allowNull: false,
defaultValue: 0
},
isRequired: {
type: DataTypes.BOOLEAN,
defaultValue: true
},
explanation: {
type: DataTypes.TEXT,
allowNull: true
},
metadata: {
type: DataTypes.JSONB,
allowNull: true,
defaultValue: {}
}
}, {
tableName: 'quiz_questions',
indexes: [
{
fields: ['contentId']
},
{
fields: ['questionType']
},
{
fields: ['order']
}
]
});
module.exports = QuizQuestion;

View file

@ -3,6 +3,8 @@ const Course = require('./Course');
const Enrollment = require('./Enrollment');
const Attendance = require('./Attendance');
const Assignment = require('./Assignment');
const CourseContent = require('./CourseContent');
const QuizQuestion = require('./QuizQuestion');
// User associations
User.hasMany(Course, { as: 'createdCourses', foreignKey: 'trainerId' });
@ -28,10 +30,20 @@ Attendance.belongsTo(Course, { as: 'course', foreignKey: 'courseId' });
Assignment.belongsTo(User, { as: 'trainer', foreignKey: 'trainerId' });
Assignment.belongsTo(Course, { as: 'course', foreignKey: 'courseId' });
// Course Content associations
Course.hasMany(CourseContent, { as: 'contents', foreignKey: 'courseId' });
CourseContent.belongsTo(Course, { as: 'course', foreignKey: 'courseId' });
// Quiz Question associations
CourseContent.hasMany(QuizQuestion, { as: 'questions', foreignKey: 'contentId' });
QuizQuestion.belongsTo(CourseContent, { as: 'content', foreignKey: 'contentId' });
module.exports = {
User,
Course,
Enrollment,
Attendance,
Assignment
Assignment,
CourseContent,
QuizQuestion
};

View file

@ -0,0 +1,360 @@
const express = require('express');
const { body, validationResult } = require('express-validator');
const { CourseContent, QuizQuestion, Course } = require('../models');
const { auth, requireTrainer } = require('../middleware/auth');
const multer = require('multer');
const path = require('path');
const fs = require('fs');
const router = express.Router();
// Multer storage for course content files
const contentFileStorage = multer.diskStorage({
destination: function (req, file, cb) {
const courseId = req.params.courseId;
const contentType = req.params.contentType || 'documents';
const dir = path.join(__dirname, '../uploads/courses', courseId, contentType);
fs.mkdirSync(dir, { recursive: true });
cb(null, dir);
},
filename: function (req, file, cb) {
const timestamp = Date.now();
const originalName = file.originalname.replace(/\s+/g, '_');
cb(null, `${timestamp}_${originalName}`);
}
});
const uploadContentFile = multer({
storage: contentFileStorage,
fileFilter: (req, file, cb) => {
const allowedTypes = {
document: ['application/pdf', 'application/msword', 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', 'text/plain'],
image: ['image/jpeg', 'image/png', 'image/gif', 'image/webp'],
video: ['video/mp4', 'video/webm', 'video/ogg', 'video/avi']
};
const contentType = req.params.contentType;
if (allowedTypes[contentType] && allowedTypes[contentType].includes(file.mimetype)) {
cb(null, true);
} else {
cb(new Error(`Invalid file type for ${contentType}`), false);
}
},
limits: { fileSize: 100 * 1024 * 1024 } // 100MB limit
});
// @route GET /api/courses/:courseId/content
// @desc Get all content for a course
// @access Private (Course owner or enrolled students)
router.get('/:courseId/content', auth, async (req, res) => {
try {
const { courseId } = req.params;
const { type, isPublished } = req.query;
const whereClause = { courseId };
if (type) whereClause.type = type;
if (isPublished !== undefined) {
whereClause.isPublished = isPublished === 'true';
}
const contents = await CourseContent.findAll({
where: whereClause,
include: [
{
model: QuizQuestion,
as: 'questions',
attributes: ['id', 'question', 'questionType', 'points', 'order']
}
],
order: [['order', 'ASC']]
});
res.json({ contents });
} catch (error) {
console.error('Get course content error:', error);
res.status(500).json({ error: 'Server error.' });
}
});
// @route GET /api/courses/:courseId/content/:contentId
// @desc Get specific content by ID
// @access Private (Course owner or enrolled students)
router.get('/:courseId/content/:contentId', auth, async (req, res) => {
try {
const { contentId } = req.params;
const content = await CourseContent.findByPk(contentId, {
include: [
{
model: QuizQuestion,
as: 'questions',
order: [['order', 'ASC']]
}
]
});
if (!content) {
return res.status(404).json({ error: 'Content not found.' });
}
res.json({ content });
} catch (error) {
console.error('Get content error:', error);
res.status(500).json({ error: 'Server error.' });
}
});
// @route POST /api/courses/:courseId/content
// @desc Create new course content
// @access Private (Course owner only)
router.post('/:courseId/content', [
auth,
requireTrainer,
body('title').isLength({ min: 1, max: 200 }),
body('type').isIn(['document', 'image', 'video', 'article', 'quiz', 'certificate']),
body('description').optional().isLength({ max: 1000 }),
body('order').optional().isInt({ min: 0 }),
body('points').optional().isInt({ min: 0 }),
body('isRequired').optional().isBoolean()
], async (req, res) => {
try {
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(400).json({ errors: errors.array() });
}
const { courseId } = req.params;
const {
title,
description,
type,
content,
order,
points,
isRequired,
articleContent,
quizData,
certificateTemplate
} = req.body;
// Verify course ownership
const course = await Course.findByPk(courseId);
if (!course) {
return res.status(404).json({ error: 'Course not found.' });
}
if (course.trainerId !== req.user.id && req.user.role !== 'super_admin') {
return res.status(403).json({ error: 'Not authorized to add content to this course.' });
}
const courseContent = await CourseContent.create({
courseId,
title,
description,
type,
content: content || {},
order: order || 0,
points: points || 0,
isRequired: isRequired !== undefined ? isRequired : true,
articleContent,
quizData,
certificateTemplate
});
res.status(201).json({
message: 'Content created successfully.',
content: courseContent
});
} catch (error) {
console.error('Create content error:', error);
res.status(500).json({ error: 'Server error.' });
}
});
// @route PUT /api/courses/:courseId/content/:contentId
// @desc Update course content
// @access Private (Course owner only)
router.put('/:courseId/content/:contentId', [
auth,
requireTrainer,
body('title').optional().isLength({ min: 1, max: 200 }),
body('description').optional().isLength({ max: 1000 }),
body('order').optional().isInt({ min: 0 }),
body('points').optional().isInt({ min: 0 }),
body('isRequired').optional().isBoolean(),
body('isPublished').optional().isBoolean()
], async (req, res) => {
try {
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(400).json({ errors: errors.array() });
}
const { contentId } = req.params;
const content = await CourseContent.findByPk(contentId, {
include: [{ model: Course, as: 'course' }]
});
if (!content) {
return res.status(404).json({ error: 'Content not found.' });
}
// Verify course ownership
if (content.course.trainerId !== req.user.id && req.user.role !== 'super_admin') {
return res.status(403).json({ error: 'Not authorized to update this content.' });
}
const updateData = req.body;
await content.update(updateData);
res.json({
message: 'Content updated successfully.',
content
});
} catch (error) {
console.error('Update content error:', error);
res.status(500).json({ error: 'Server error.' });
}
});
// @route DELETE /api/courses/:courseId/content/:contentId
// @desc Delete course content
// @access Private (Course owner only)
router.delete('/:courseId/content/:contentId', auth, requireTrainer, async (req, res) => {
try {
const { contentId } = req.params;
const content = await CourseContent.findByPk(contentId, {
include: [{ model: Course, as: 'course' }]
});
if (!content) {
return res.status(404).json({ error: 'Content not found.' });
}
// Verify course ownership
if (content.course.trainerId !== req.user.id && req.user.role !== 'super_admin') {
return res.status(403).json({ error: 'Not authorized to delete this content.' });
}
// Delete associated file if exists
if (content.fileUrl) {
const filePath = path.join(__dirname, '..', content.fileUrl);
if (fs.existsSync(filePath)) {
fs.unlinkSync(filePath);
}
}
await content.destroy();
res.json({ message: 'Content deleted successfully.' });
} catch (error) {
console.error('Delete content error:', error);
res.status(500).json({ error: 'Server error.' });
}
});
// @route POST /api/courses/:courseId/content/:contentType/upload
// @desc Upload file for course content
// @access Private (Course owner only)
router.post('/:courseId/content/:contentType/upload', [
auth,
requireTrainer,
uploadContentFile.single('file')
], async (req, res) => {
try {
if (!req.file) {
return res.status(400).json({ error: 'No file uploaded.' });
}
const { courseId, contentType } = req.params;
const { contentId } = req.body;
// Verify course ownership
const course = await Course.findByPk(courseId);
if (!course) {
return res.status(404).json({ error: 'Course not found.' });
}
if (course.trainerId !== req.user.id && req.user.role !== 'super_admin') {
return res.status(403).json({ error: 'Not authorized to upload content to this course.' });
}
const fileUrl = `/uploads/courses/${courseId}/${contentType}/${req.file.filename}`;
// Update content if contentId is provided
if (contentId) {
const content = await CourseContent.findByPk(contentId);
if (content) {
await content.update({
fileUrl,
fileSize: req.file.size,
fileType: req.file.mimetype
});
}
}
res.json({
message: 'File uploaded successfully.',
fileUrl,
fileSize: req.file.size,
fileType: req.file.mimetype
});
} catch (error) {
console.error('Upload content file error:', error);
res.status(500).json({ error: 'Server error.' });
}
});
// @route POST /api/courses/:courseId/content/:contentId/questions
// @desc Add quiz questions to content
// @access Private (Course owner only)
router.post('/:courseId/content/:contentId/questions', [
auth,
requireTrainer,
body('questions').isArray({ min: 1 })
], async (req, res) => {
try {
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(400).json({ errors: errors.array() });
}
const { contentId } = req.params;
const { questions } = req.body;
const content = await CourseContent.findByPk(contentId, {
include: [{ model: Course, as: 'course' }]
});
if (!content) {
return res.status(404).json({ error: 'Content not found.' });
}
if (content.type !== 'quiz') {
return res.status(400).json({ error: 'Can only add questions to quiz content.' });
}
// Verify course ownership
if (content.course.trainerId !== req.user.id && req.user.role !== 'super_admin') {
return res.status(403).json({ error: 'Not authorized to add questions to this content.' });
}
const createdQuestions = await QuizQuestion.bulkCreate(
questions.map((q, index) => ({
contentId,
...q,
order: q.order || index
}))
);
res.status(201).json({
message: 'Questions added successfully.',
questions: createdQuestions
});
} catch (error) {
console.error('Add quiz questions error:', error);
res.status(500).json({ error: 'Server error.' });
}
});
module.exports = router;

View file

@ -1,100 +1,41 @@
const express = require('express');
const { body, validationResult } = require('express-validator');
const { Enrollment, Course, User } = require('../models');
const { auth, requireTrainee } = require('../middleware/auth');
const { auth, requireTrainer } = require('../middleware/auth');
const router = express.Router();
// @route POST /api/enrollments
// @desc Enroll in a course
// @access Private (Trainee)
router.post('/', [
auth,
requireTrainee,
body('courseId').isUUID(),
body('paymentAmount').isFloat({ min: 0 })
], async (req, res) => {
try {
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(400).json({ errors: errors.array() });
}
const { courseId, paymentAmount } = req.body;
// Check if course exists and is published
const course = await Course.findByPk(courseId);
if (!course) {
return res.status(404).json({ error: 'Course not found.' });
}
if (!course.isPublished) {
return res.status(400).json({ error: 'Course is not available for enrollment.' });
}
// Check if already enrolled
const existingEnrollment = await Enrollment.findOne({
where: { userId: req.user.id, courseId }
});
if (existingEnrollment) {
return res.status(400).json({ error: 'Already enrolled in this course.' });
}
// Check if course is full
if (course.maxStudents) {
const enrolledCount = await Enrollment.count({
where: { courseId, status: ['active', 'pending'] }
});
if (enrolledCount >= course.maxStudents) {
return res.status(400).json({ error: 'Course is full.' });
}
}
const enrollment = await Enrollment.create({
userId: req.user.id,
courseId,
paymentAmount,
status: 'pending',
paymentStatus: 'pending'
});
const enrollmentWithDetails = await Enrollment.findByPk(enrollment.id, {
include: [
{
model: Course,
as: 'course',
attributes: ['id', 'title', 'thumbnail', 'price']
},
{
model: User,
as: 'user',
attributes: ['id', 'firstName', 'lastName']
}
]
});
res.status(201).json({
message: 'Enrollment created successfully.',
enrollment: enrollmentWithDetails
});
} catch (error) {
console.error('Create enrollment error:', error);
res.status(500).json({ error: 'Server error.' });
}
});
// @route GET /api/enrollments/my
// @desc Get user's enrollments
// @route GET /api/enrollments
// @desc Get all enrollments (filtered by user role)
// @access Private
router.get('/my', auth, async (req, res) => {
router.get('/', auth, async (req, res) => {
try {
const { status, page = 1, limit = 10 } = req.query;
const offset = (page - 1) * limit;
const {
courseId,
userId,
status,
paymentStatus,
page = 1,
limit = 20,
sortBy = 'enrolledAt',
sortOrder = 'DESC'
} = req.query;
const offset = (page - 1) * limit;
const whereClause = {};
// Filter by course if provided
if (courseId) whereClause.courseId = courseId;
// Filter by user if provided or if user is not admin/trainer
if (userId) {
whereClause.userId = userId;
} else if (req.user.role === 'trainee') {
whereClause.userId = req.user.id;
}
const whereClause = { userId: req.user.id };
if (status) whereClause.status = status;
if (paymentStatus) whereClause.paymentStatus = paymentStatus;
const { count, rows: enrollments } = await Enrollment.findAndCountAll({
where: whereClause,
@ -102,10 +43,22 @@ router.get('/my', auth, async (req, res) => {
{
model: Course,
as: 'course',
attributes: ['id', 'title', 'thumbnail', 'price', 'duration', 'level', 'category']
attributes: ['id', 'title', 'thumbnail', 'price', 'trainerId'],
include: [
{
model: User,
as: 'trainer',
attributes: ['id', 'firstName', 'lastName']
}
]
},
{
model: User,
as: 'user',
attributes: ['id', 'firstName', 'lastName', 'email', 'avatar']
}
],
order: [['enrolledAt', 'DESC']],
order: [[sortBy, sortOrder]],
limit: parseInt(limit),
offset: parseInt(offset)
});
@ -125,6 +78,60 @@ router.get('/my', auth, async (req, res) => {
}
});
// @route GET /api/enrollments/my
// @desc Get current user's enrollments
// @access Private
router.get('/my', auth, async (req, res) => {
try {
const {
status,
paymentStatus,
page = 1,
limit = 20
} = req.query;
const offset = (page - 1) * limit;
const whereClause = { userId: req.user.id };
if (status) whereClause.status = status;
if (paymentStatus) whereClause.paymentStatus = paymentStatus;
const { count, rows: enrollments } = await Enrollment.findAndCountAll({
where: whereClause,
include: [
{
model: Course,
as: 'course',
attributes: ['id', 'title', 'thumbnail', 'price', 'description', 'level', 'category'],
include: [
{
model: User,
as: 'trainer',
attributes: ['id', 'firstName', 'lastName', 'avatar']
}
]
}
],
order: [['enrolledAt', 'DESC']],
limit: parseInt(limit),
offset: parseInt(offset)
});
res.json({
enrollments,
pagination: {
currentPage: parseInt(page),
totalPages: Math.ceil(count / limit),
totalItems: count,
itemsPerPage: parseInt(limit)
}
});
} catch (error) {
console.error('Get my enrollments error:', error);
res.status(500).json({ error: 'Server error.' });
}
});
// @route GET /api/enrollments/:id
// @desc Get enrollment by ID
// @access Private
@ -139,14 +146,14 @@ router.get('/:id', auth, async (req, res) => {
{
model: User,
as: 'trainer',
attributes: ['id', 'firstName', 'lastName', 'avatar']
attributes: ['id', 'firstName', 'lastName', 'avatar', 'email']
}
]
},
{
model: User,
as: 'user',
attributes: ['id', 'firstName', 'lastName', 'avatar']
attributes: ['id', 'firstName', 'lastName', 'email', 'avatar']
}
]
});
@ -155,8 +162,8 @@ router.get('/:id', auth, async (req, res) => {
return res.status(404).json({ error: 'Enrollment not found.' });
}
// Check if user can access this enrollment
if (req.user.role !== 'super_admin' && enrollment.userId !== req.user.id) {
// Check if user has access to this enrollment
if (req.user.role === 'trainee' && enrollment.userId !== req.user.id) {
return res.status(403).json({ error: 'Not authorized to view this enrollment.' });
}
@ -167,13 +174,98 @@ router.get('/:id', auth, async (req, res) => {
}
});
// @route POST /api/enrollments
// @desc Create new enrollment (subscribe to course)
// @access Private
router.post('/', [
auth,
body('courseId').isUUID(),
body('paymentAmount').optional().isFloat({ min: 0 }),
body('notes').optional().isLength({ max: 1000 })
], async (req, res) => {
try {
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(400).json({ errors: errors.array() });
}
const { courseId, paymentAmount, notes } = req.body;
// Check if course exists and is published
const course = await Course.findByPk(courseId);
if (!course) {
return res.status(404).json({ error: 'Course not found.' });
}
if (!course.isPublished) {
return res.status(400).json({ error: 'Course is not published.' });
}
// Check if user is already enrolled
const existingEnrollment = await Enrollment.findOne({
where: { userId: req.user.id, courseId }
});
if (existingEnrollment) {
return res.status(400).json({ error: 'Already enrolled in this course.' });
}
// Check course capacity
if (course.maxStudents) {
const enrolledCount = await Enrollment.count({
where: { courseId, status: ['active', 'pending'] }
});
if (enrolledCount >= course.maxStudents) {
return res.status(400).json({ error: 'Course is at maximum capacity.' });
}
}
const enrollment = await Enrollment.create({
userId: req.user.id,
courseId,
status: 'pending',
paymentStatus: course.price > 0 ? 'pending' : 'paid',
paymentAmount: paymentAmount || course.price,
notes
});
const enrollmentWithDetails = await Enrollment.findByPk(enrollment.id, {
include: [
{
model: Course,
as: 'course',
include: [
{
model: User,
as: 'trainer',
attributes: ['id', 'firstName', 'lastName']
}
]
},
{
model: User,
as: 'user',
attributes: ['id', 'firstName', 'lastName', 'email']
}
]
});
res.status(201).json({
message: 'Enrollment created successfully.',
enrollment: enrollmentWithDetails
});
} catch (error) {
console.error('Create enrollment error:', error);
res.status(500).json({ error: 'Server error.' });
}
});
// @route PUT /api/enrollments/:id/status
// @desc Update enrollment status
// @access Private (Super Admin or Course Trainer)
// @access Private (Course owner or Super Admin)
router.put('/:id/status', [
auth,
body('status').isIn(['pending', 'active', 'completed', 'cancelled']),
body('progress').optional().isInt({ min: 0, max: 100 })
body('notes').optional().isLength({ max: 1000 })
], async (req, res) => {
try {
const errors = validationResult(req);
@ -203,39 +295,37 @@ router.put('/:id/status', [
return res.status(403).json({ error: 'Not authorized to update this enrollment.' });
}
const updateData = { status: req.body.status };
if (req.body.progress !== undefined) {
updateData.progress = req.body.progress;
const { status, notes } = req.body;
const updateData = { status };
if (status === 'completed' && enrollment.status !== 'completed') {
updateData.completedAt = new Date();
}
if (req.body.status === 'completed') {
updateData.completedAt = new Date();
if (notes) {
updateData.notes = notes;
}
await enrollment.update(updateData);
res.json({
message: 'Enrollment status updated successfully.',
enrollment: {
id: enrollment.id,
status: enrollment.status,
progress: enrollment.progress,
completedAt: enrollment.completedAt
}
enrollment
});
} catch (error) {
console.error('Update enrollment error:', error);
console.error('Update enrollment status error:', error);
res.status(500).json({ error: 'Server error.' });
}
});
// @route PUT /api/enrollments/:id/payment
// @desc Update payment status
// @access Private (Super Admin)
// @desc Update enrollment payment status
// @access Private (Course owner or Super Admin)
router.put('/:id/payment', [
auth,
requireTrainee,
body('paymentStatus').isIn(['pending', 'paid', 'failed', 'refunded'])
body('paymentStatus').isIn(['pending', 'paid', 'failed', 'refunded']),
body('paymentAmount').optional().isFloat({ min: 0 }),
body('notes').optional().isLength({ max: 1000 })
], async (req, res) => {
try {
const errors = validationResult(req);
@ -243,51 +333,126 @@ router.put('/:id/payment', [
return res.status(400).json({ errors: errors.array() });
}
const enrollment = await Enrollment.findByPk(req.params.id);
const enrollment = await Enrollment.findByPk(req.params.id, {
include: [
{
model: Course,
as: 'course'
}
]
});
if (!enrollment) {
return res.status(404).json({ error: 'Enrollment not found.' });
}
// Only the enrolled user or super admin can update payment status
if (req.user.role !== 'super_admin' && enrollment.userId !== req.user.id) {
return res.status(403).json({ error: 'Not authorized to update this enrollment.' });
// Check permissions
const canUpdate = req.user.role === 'super_admin' ||
enrollment.course.trainerId === req.user.id;
if (!canUpdate) {
return res.status(403).json({ error: 'Not authorized to update payment status.' });
}
const updateData = { paymentStatus: req.body.paymentStatus };
if (req.body.paymentStatus === 'paid') {
const { paymentStatus, paymentAmount, notes } = req.body;
const updateData = { paymentStatus };
if (paymentStatus === 'paid' && enrollment.paymentStatus !== 'paid') {
updateData.paymentDate = new Date();
updateData.status = 'active'; // Auto-activate enrollment when paid
}
if (paymentAmount) {
updateData.paymentAmount = paymentAmount;
}
if (notes) {
updateData.notes = notes;
}
await enrollment.update(updateData);
res.json({
message: 'Payment status updated successfully.',
enrollment: {
id: enrollment.id,
paymentStatus: enrollment.paymentStatus,
paymentDate: enrollment.paymentDate,
status: enrollment.status
}
enrollment
});
} catch (error) {
console.error('Update payment error:', error);
console.error('Update payment status error:', error);
res.status(500).json({ error: 'Server error.' });
}
});
// @route PUT /api/enrollments/:id/progress
// @desc Update enrollment progress
// @access Private (Enrolled user or Course owner)
router.put('/:id/progress', [
auth,
body('progress').isInt({ min: 0, max: 100 })
], async (req, res) => {
try {
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(400).json({ errors: errors.array() });
}
const enrollment = await Enrollment.findByPk(req.params.id, {
include: [
{
model: Course,
as: 'course'
}
]
});
if (!enrollment) {
return res.status(404).json({ error: 'Enrollment not found.' });
}
// Check permissions
const canUpdate = req.user.role === 'super_admin' ||
enrollment.course.trainerId === req.user.id ||
enrollment.userId === req.user.id;
if (!canUpdate) {
return res.status(403).json({ error: 'Not authorized to update progress.' });
}
const { progress } = req.body;
await enrollment.update({ progress });
res.json({
message: 'Progress updated successfully.',
enrollment
});
} catch (error) {
console.error('Update progress error:', error);
res.status(500).json({ error: 'Server error.' });
}
});
// @route DELETE /api/enrollments/:id
// @desc Cancel enrollment
// @access Private
// @access Private (Enrolled user or Course owner)
router.delete('/:id', auth, async (req, res) => {
try {
const enrollment = await Enrollment.findByPk(req.params.id);
const enrollment = await Enrollment.findByPk(req.params.id, {
include: [
{
model: Course,
as: 'course'
}
]
});
if (!enrollment) {
return res.status(404).json({ error: 'Enrollment not found.' });
}
// Check permissions
if (req.user.role !== 'super_admin' && enrollment.userId !== req.user.id) {
const canCancel = req.user.role === 'super_admin' ||
enrollment.course.trainerId === req.user.id ||
enrollment.userId === req.user.id;
if (!canCancel) {
return res.status(403).json({ error: 'Not authorized to cancel this enrollment.' });
}
@ -300,4 +465,46 @@ router.delete('/:id', auth, async (req, res) => {
}
});
// @route GET /api/enrollments/stats/overview
// @desc Get enrollment statistics
// @access Private (Super Admin or Trainer)
router.get('/stats/overview', auth, async (req, res) => {
try {
const whereClause = {};
// If trainer, only show their course enrollments
if (req.user.role === 'trainer') {
const trainerCourses = await Course.findAll({
where: { trainerId: req.user.id },
attributes: ['id']
});
whereClause.courseId = trainerCourses.map(c => c.id);
}
const totalEnrollments = await Enrollment.count({ where: whereClause });
const activeEnrollments = await Enrollment.count({
where: { ...whereClause, status: 'active' }
});
const completedEnrollments = await Enrollment.count({
where: { ...whereClause, status: 'completed' }
});
const pendingEnrollments = await Enrollment.count({
where: { ...whereClause, status: 'pending' }
});
res.json({
stats: {
totalEnrollments,
activeEnrollments,
completedEnrollments,
pendingEnrollments,
cancelledEnrollments: totalEnrollments - activeEnrollments - completedEnrollments - pendingEnrollments
}
});
} catch (error) {
console.error('Get enrollment stats error:', error);
res.status(500).json({ error: 'Server error.' });
}
});
module.exports = router;

View file

@ -10,6 +10,7 @@ require('./models');
const authRoutes = require('./routes/auth');
const userRoutes = require('./routes/users');
const courseRoutes = require('./routes/courses');
const courseContentRoutes = require('./routes/courseContent');
const enrollmentRoutes = require('./routes/enrollments');
const attendanceRoutes = require('./routes/attendance');
const assignmentRoutes = require('./routes/assignments');
@ -33,6 +34,7 @@ app.use('/uploads', express.static(path.join(__dirname, 'uploads')));
app.use('/api/auth', authRoutes);
app.use('/api/users', userRoutes);
app.use('/api/courses', courseRoutes);
app.use('/api/course-content', courseContentRoutes);
app.use('/api/enrollments', enrollmentRoutes);
app.use('/api/attendance', attendanceRoutes);
app.use('/api/assignments', assignmentRoutes);

View file

@ -86,14 +86,36 @@ export const coursesAPI = {
},
};
// Course Content API
export const courseContentAPI = {
getAll: (courseId, params) => api.get(`/course-content/${courseId}/content`, { params }),
getById: (courseId, contentId) => api.get(`/course-content/${courseId}/content/${contentId}`),
create: (courseId, data) => api.post(`/course-content/${courseId}/content`, data),
update: (courseId, contentId, data) => api.put(`/course-content/${courseId}/content/${contentId}`, data),
delete: (courseId, contentId) => api.delete(`/course-content/${courseId}/content/${contentId}`),
uploadFile: (courseId, contentType, file, contentId = null) => {
const formData = new FormData();
formData.append('file', file);
if (contentId) formData.append('contentId', contentId);
return api.post(`/course-content/${courseId}/content/${contentType}/upload`, formData, {
headers: { 'Content-Type': 'multipart/form-data' },
});
},
addQuizQuestions: (courseId, contentId, questions) =>
api.post(`/course-content/${courseId}/content/${contentId}/questions`, { questions }),
};
// Enrollments API
export const enrollmentsAPI = {
getAll: (params) => api.get('/enrollments', { params }),
create: (data) => api.post('/enrollments', data),
getMy: (params) => api.get('/enrollments/my', { params }),
getById: (id) => api.get(`/enrollments/${id}`),
updateStatus: (id, data) => api.put(`/enrollments/${id}/status`, data),
updatePayment: (id, data) => api.put(`/enrollments/${id}/payment`, data),
updateProgress: (id, progress) => api.put(`/enrollments/${id}/progress`, { progress }),
cancel: (id) => api.delete(`/enrollments/${id}`),
getStats: () => api.get('/enrollments/stats/overview'),
};
// Attendance API

View file

@ -1,7 +1,7 @@
CourseWorx v1.0.0 - Major Release
====================================
CourseWorx v1.1.0 - Course Content & Enrollment Management
==========================================================
This version represents a complete, functional Course Management System with comprehensive user management, course management, and administrative features.
This version adds comprehensive course content management and enrollment/subscriber functionality to the existing Course Management System.
MAJOR FEATURES IMPLEMENTED:
===========================
@ -31,12 +31,33 @@ MAJOR FEATURES IMPLEMENTED:
- Complete CRUD operations for courses
- Course publishing/unpublishing functionality
- Course categories and metadata
- Course enrollment system
- Course image upload functionality
- Course search and filtering
- Course statistics and analytics
4. FRONTEND USER INTERFACE
4. COURSE CONTENT MANAGEMENT SYSTEM
-----------------------------------
- Multi-type content support (Documents, Images, Videos, Articles, Quizzes, Certificates)
- File upload system with 100MB limit and type validation
- Content ordering and publishing control
- Quiz system with multiple question types (multiple choice, single choice, true/false, text, file upload)
- Points system for gamification
- Required/optional content marking
- Metadata storage for additional information
- File management with automatic cleanup
5. ENROLLMENT & SUBSCRIBER MANAGEMENT
--------------------------------------
- Complete enrollment lifecycle (enroll, unenroll, track progress)
- Status tracking (pending, active, completed, cancelled)
- Payment management (pending, paid, failed, refunded)
- Progress tracking (0-100%) with automatic updates
- Course capacity limits and validation
- Certificate issuance tracking
- Enrollment analytics and statistics
- Role-based enrollment access control
6. FRONTEND USER INTERFACE
---------------------------
- Modern, responsive UI using Tailwind CSS
- Heroicons for consistent iconography
@ -47,7 +68,7 @@ MAJOR FEATURES IMPLEMENTED:
- Search and filter interfaces
- Role-based UI elements
5. INTERNATIONALIZATION (i18n)
7. INTERNATIONALIZATION (i18n)
-------------------------------
- English (LTR) and Arabic (RTL) language support
- Dynamic language switching
@ -55,16 +76,18 @@ MAJOR FEATURES IMPLEMENTED:
- Translation system using react-i18next
- Localized text for all user-facing content
6. FILE UPLOAD SYSTEM
8. FILE UPLOAD SYSTEM
----------------------
- Image upload for course thumbnails
- Slider image upload for homepage
- Multi-type content file uploads (documents, images, videos)
- Multer middleware for file handling
- File validation and size limits
- File validation and size limits (100MB max)
- Organized file storage structure
- Automatic directory creation
- File type validation for different content types
7. BACKEND API SYSTEM
9. BACKEND API SYSTEM
----------------------
- RESTful API design
- Express.js server with middleware
@ -73,17 +96,53 @@ MAJOR FEATURES IMPLEMENTED:
- Error handling and logging
- CORS configuration
- Environment-based configuration
- Course content management APIs
- Enrollment management APIs
8. DATABASE SYSTEM
10. DATABASE SYSTEM
-------------------
- PostgreSQL database with Sequelize ORM
- User model with proper relationships
- Course model with metadata
- Enrollment system
- CourseContent model with multi-type support
- QuizQuestion model for quiz functionality
- Enhanced Enrollment model with comprehensive tracking
- Attendance tracking
- Assignment management
- Database migrations and seeding
NEW FEATURES IN v1.1.0:
========================
1. COURSE CONTENT MANAGEMENT
----------------------------
- Multi-type content system (Documents, Images, Videos, Articles, Quizzes, Certificates)
- File upload with type validation and size limits
- Content ordering and publishing control
- Quiz system with multiple question types
- Points system for gamification
- Required/optional content marking
- Metadata storage for additional information
2. ENROLLMENT & SUBSCRIBER SYSTEM
---------------------------------
- Complete enrollment lifecycle management
- Status and payment tracking
- Progress monitoring (0-100%)
- Course capacity validation
- Certificate issuance tracking
- Enrollment analytics and statistics
- Role-based access control
3. ENHANCED API SYSTEM
-----------------------
- Course content management endpoints
- Enrollment management endpoints
- File upload with validation
- Quiz question management
- Progress tracking APIs
- Statistics and analytics endpoints
TECHNICAL IMPROVEMENTS:
=======================
@ -102,6 +161,8 @@ TECHNICAL IMPROVEMENTS:
- Optimistic updates and caching
- Error handling and retry logic
- Request/response interceptors
- Course content API integration
- Enrollment management API integration
3. SECURITY ENHANCEMENTS
-------------------------
@ -118,6 +179,8 @@ TECHNICAL IMPROVEMENTS:
- Image optimization and compression
- Lazy loading considerations
- Caching strategies
- File upload optimization
- Content streaming for large files
BUG FIXES & RESOLUTIONS:
========================
@ -153,6 +216,8 @@ BUG FIXES & RESOLUTIONS:
- Improved error handling and logging
- Fixed database connection issues
- Enhanced API response consistency
- Added comprehensive content validation
- Enhanced file upload error handling
DEPENDENCIES & TECHNOLOGIES:
============================
@ -174,7 +239,7 @@ Backend:
- PostgreSQL
- JWT (jsonwebtoken)
- bcryptjs
- multer
- multer (enhanced for multi-type uploads)
- express-validator
- csv-parser
@ -200,6 +265,8 @@ Backend:
- middleware/ (Custom middleware)
- config/ (Configuration files)
- uploads/ (File uploads)
- course-content/ (Course content management)
- enrollments/ (Enrollment management)
CONFIGURATION:
==============
@ -214,7 +281,9 @@ Environment Variables:
Database Schema:
- Users table with role-based access
- Courses table with metadata
- Enrollments table for relationships
- CourseContent table with multi-type support
- QuizQuestion table for quiz functionality
- Enhanced Enrollments table with comprehensive tracking
- Attendance and assignment tables
SECURITY CONSIDERATIONS:
@ -238,8 +307,8 @@ DEPLOYMENT READINESS:
- Performance optimization
- Security hardening
This version (1.0.0) represents a complete, production-ready Course Management System with all core features implemented, tested, and optimized for real-world usage.
This version (1.1.0) adds comprehensive course content management and enrollment/subscriber functionality to the existing Course Management System, providing a complete foundation for creating rich educational content and managing student enrollments.
Release Date: [Current Date]
Version: 1.0.0
Version: 1.1.0
Status: Production Ready