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:
parent
cca90322d8
commit
b4d90c650f
8 changed files with 996 additions and 144 deletions
110
backend/models/CourseContent.js
Normal file
110
backend/models/CourseContent.js
Normal 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;
|
||||||
70
backend/models/QuizQuestion.js
Normal file
70
backend/models/QuizQuestion.js
Normal 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;
|
||||||
|
|
@ -3,6 +3,8 @@ const Course = require('./Course');
|
||||||
const Enrollment = require('./Enrollment');
|
const Enrollment = require('./Enrollment');
|
||||||
const Attendance = require('./Attendance');
|
const Attendance = require('./Attendance');
|
||||||
const Assignment = require('./Assignment');
|
const Assignment = require('./Assignment');
|
||||||
|
const CourseContent = require('./CourseContent');
|
||||||
|
const QuizQuestion = require('./QuizQuestion');
|
||||||
|
|
||||||
// User associations
|
// User associations
|
||||||
User.hasMany(Course, { as: 'createdCourses', foreignKey: 'trainerId' });
|
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(User, { as: 'trainer', foreignKey: 'trainerId' });
|
||||||
Assignment.belongsTo(Course, { as: 'course', foreignKey: 'courseId' });
|
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 = {
|
module.exports = {
|
||||||
User,
|
User,
|
||||||
Course,
|
Course,
|
||||||
Enrollment,
|
Enrollment,
|
||||||
Attendance,
|
Attendance,
|
||||||
Assignment
|
Assignment,
|
||||||
|
CourseContent,
|
||||||
|
QuizQuestion
|
||||||
};
|
};
|
||||||
360
backend/routes/courseContent.js
Normal file
360
backend/routes/courseContent.js
Normal 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;
|
||||||
|
|
@ -1,100 +1,41 @@
|
||||||
const express = require('express');
|
const express = require('express');
|
||||||
const { body, validationResult } = require('express-validator');
|
const { body, validationResult } = require('express-validator');
|
||||||
const { Enrollment, Course, User } = require('../models');
|
const { Enrollment, Course, User } = require('../models');
|
||||||
const { auth, requireTrainee } = require('../middleware/auth');
|
const { auth, requireTrainer } = require('../middleware/auth');
|
||||||
|
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|
||||||
// @route POST /api/enrollments
|
// @route GET /api/enrollments
|
||||||
// @desc Enroll in a course
|
// @desc Get all enrollments (filtered by user role)
|
||||||
// @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
|
|
||||||
// @access Private
|
// @access Private
|
||||||
router.get('/my', auth, async (req, res) => {
|
router.get('/', auth, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { status, page = 1, limit = 10 } = req.query;
|
const {
|
||||||
const offset = (page - 1) * limit;
|
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 (status) whereClause.status = status;
|
||||||
|
if (paymentStatus) whereClause.paymentStatus = paymentStatus;
|
||||||
|
|
||||||
const { count, rows: enrollments } = await Enrollment.findAndCountAll({
|
const { count, rows: enrollments } = await Enrollment.findAndCountAll({
|
||||||
where: whereClause,
|
where: whereClause,
|
||||||
|
|
@ -102,10 +43,22 @@ router.get('/my', auth, async (req, res) => {
|
||||||
{
|
{
|
||||||
model: Course,
|
model: Course,
|
||||||
as: '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),
|
limit: parseInt(limit),
|
||||||
offset: parseInt(offset)
|
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
|
// @route GET /api/enrollments/:id
|
||||||
// @desc Get enrollment by ID
|
// @desc Get enrollment by ID
|
||||||
// @access Private
|
// @access Private
|
||||||
|
|
@ -139,14 +146,14 @@ router.get('/:id', auth, async (req, res) => {
|
||||||
{
|
{
|
||||||
model: User,
|
model: User,
|
||||||
as: 'trainer',
|
as: 'trainer',
|
||||||
attributes: ['id', 'firstName', 'lastName', 'avatar']
|
attributes: ['id', 'firstName', 'lastName', 'avatar', 'email']
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
model: User,
|
model: User,
|
||||||
as: '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.' });
|
return res.status(404).json({ error: 'Enrollment not found.' });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if user can access this enrollment
|
// Check if user has access to this enrollment
|
||||||
if (req.user.role !== 'super_admin' && enrollment.userId !== req.user.id) {
|
if (req.user.role === 'trainee' && enrollment.userId !== req.user.id) {
|
||||||
return res.status(403).json({ error: 'Not authorized to view this enrollment.' });
|
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
|
// @route PUT /api/enrollments/:id/status
|
||||||
// @desc Update enrollment status
|
// @desc Update enrollment status
|
||||||
// @access Private (Super Admin or Course Trainer)
|
// @access Private (Course owner or Super Admin)
|
||||||
router.put('/:id/status', [
|
router.put('/:id/status', [
|
||||||
auth,
|
auth,
|
||||||
body('status').isIn(['pending', 'active', 'completed', 'cancelled']),
|
body('status').isIn(['pending', 'active', 'completed', 'cancelled']),
|
||||||
body('progress').optional().isInt({ min: 0, max: 100 })
|
body('notes').optional().isLength({ max: 1000 })
|
||||||
], async (req, res) => {
|
], async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const errors = validationResult(req);
|
const errors = validationResult(req);
|
||||||
|
|
@ -203,39 +295,37 @@ router.put('/:id/status', [
|
||||||
return res.status(403).json({ error: 'Not authorized to update this enrollment.' });
|
return res.status(403).json({ error: 'Not authorized to update this enrollment.' });
|
||||||
}
|
}
|
||||||
|
|
||||||
const updateData = { status: req.body.status };
|
const { status, notes } = req.body;
|
||||||
if (req.body.progress !== undefined) {
|
const updateData = { status };
|
||||||
updateData.progress = req.body.progress;
|
|
||||||
|
if (status === 'completed' && enrollment.status !== 'completed') {
|
||||||
|
updateData.completedAt = new Date();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (req.body.status === 'completed') {
|
if (notes) {
|
||||||
updateData.completedAt = new Date();
|
updateData.notes = notes;
|
||||||
}
|
}
|
||||||
|
|
||||||
await enrollment.update(updateData);
|
await enrollment.update(updateData);
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
message: 'Enrollment status updated successfully.',
|
message: 'Enrollment status updated successfully.',
|
||||||
enrollment: {
|
enrollment
|
||||||
id: enrollment.id,
|
|
||||||
status: enrollment.status,
|
|
||||||
progress: enrollment.progress,
|
|
||||||
completedAt: enrollment.completedAt
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Update enrollment error:', error);
|
console.error('Update enrollment status error:', error);
|
||||||
res.status(500).json({ error: 'Server error.' });
|
res.status(500).json({ error: 'Server error.' });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// @route PUT /api/enrollments/:id/payment
|
// @route PUT /api/enrollments/:id/payment
|
||||||
// @desc Update payment status
|
// @desc Update enrollment payment status
|
||||||
// @access Private (Super Admin)
|
// @access Private (Course owner or Super Admin)
|
||||||
router.put('/:id/payment', [
|
router.put('/:id/payment', [
|
||||||
auth,
|
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) => {
|
], async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const errors = validationResult(req);
|
const errors = validationResult(req);
|
||||||
|
|
@ -243,51 +333,126 @@ router.put('/:id/payment', [
|
||||||
return res.status(400).json({ errors: errors.array() });
|
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) {
|
if (!enrollment) {
|
||||||
return res.status(404).json({ error: 'Enrollment not found.' });
|
return res.status(404).json({ error: 'Enrollment not found.' });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Only the enrolled user or super admin can update payment status
|
// Check permissions
|
||||||
if (req.user.role !== 'super_admin' && enrollment.userId !== req.user.id) {
|
const canUpdate = req.user.role === 'super_admin' ||
|
||||||
return res.status(403).json({ error: 'Not authorized to update this enrollment.' });
|
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 };
|
const { paymentStatus, paymentAmount, notes } = req.body;
|
||||||
if (req.body.paymentStatus === 'paid') {
|
const updateData = { paymentStatus };
|
||||||
|
|
||||||
|
if (paymentStatus === 'paid' && enrollment.paymentStatus !== 'paid') {
|
||||||
updateData.paymentDate = new Date();
|
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);
|
await enrollment.update(updateData);
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
message: 'Payment status updated successfully.',
|
message: 'Payment status updated successfully.',
|
||||||
enrollment: {
|
enrollment
|
||||||
id: enrollment.id,
|
|
||||||
paymentStatus: enrollment.paymentStatus,
|
|
||||||
paymentDate: enrollment.paymentDate,
|
|
||||||
status: enrollment.status
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} 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.' });
|
res.status(500).json({ error: 'Server error.' });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// @route DELETE /api/enrollments/:id
|
// @route DELETE /api/enrollments/:id
|
||||||
// @desc Cancel enrollment
|
// @desc Cancel enrollment
|
||||||
// @access Private
|
// @access Private (Enrolled user or Course owner)
|
||||||
router.delete('/:id', auth, async (req, res) => {
|
router.delete('/:id', auth, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const enrollment = await Enrollment.findByPk(req.params.id);
|
const enrollment = await Enrollment.findByPk(req.params.id, {
|
||||||
|
include: [
|
||||||
|
{
|
||||||
|
model: Course,
|
||||||
|
as: 'course'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
});
|
||||||
|
|
||||||
if (!enrollment) {
|
if (!enrollment) {
|
||||||
return res.status(404).json({ error: 'Enrollment not found.' });
|
return res.status(404).json({ error: 'Enrollment not found.' });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check permissions
|
// 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.' });
|
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;
|
module.exports = router;
|
||||||
|
|
@ -10,6 +10,7 @@ require('./models');
|
||||||
const authRoutes = require('./routes/auth');
|
const authRoutes = require('./routes/auth');
|
||||||
const userRoutes = require('./routes/users');
|
const userRoutes = require('./routes/users');
|
||||||
const courseRoutes = require('./routes/courses');
|
const courseRoutes = require('./routes/courses');
|
||||||
|
const courseContentRoutes = require('./routes/courseContent');
|
||||||
const enrollmentRoutes = require('./routes/enrollments');
|
const enrollmentRoutes = require('./routes/enrollments');
|
||||||
const attendanceRoutes = require('./routes/attendance');
|
const attendanceRoutes = require('./routes/attendance');
|
||||||
const assignmentRoutes = require('./routes/assignments');
|
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/auth', authRoutes);
|
||||||
app.use('/api/users', userRoutes);
|
app.use('/api/users', userRoutes);
|
||||||
app.use('/api/courses', courseRoutes);
|
app.use('/api/courses', courseRoutes);
|
||||||
|
app.use('/api/course-content', courseContentRoutes);
|
||||||
app.use('/api/enrollments', enrollmentRoutes);
|
app.use('/api/enrollments', enrollmentRoutes);
|
||||||
app.use('/api/attendance', attendanceRoutes);
|
app.use('/api/attendance', attendanceRoutes);
|
||||||
app.use('/api/assignments', assignmentRoutes);
|
app.use('/api/assignments', assignmentRoutes);
|
||||||
|
|
|
||||||
|
|
@ -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
|
// Enrollments API
|
||||||
export const enrollmentsAPI = {
|
export const enrollmentsAPI = {
|
||||||
|
getAll: (params) => api.get('/enrollments', { params }),
|
||||||
create: (data) => api.post('/enrollments', data),
|
create: (data) => api.post('/enrollments', data),
|
||||||
getMy: (params) => api.get('/enrollments/my', { params }),
|
getMy: (params) => api.get('/enrollments/my', { params }),
|
||||||
getById: (id) => api.get(`/enrollments/${id}`),
|
getById: (id) => api.get(`/enrollments/${id}`),
|
||||||
updateStatus: (id, data) => api.put(`/enrollments/${id}/status`, data),
|
updateStatus: (id, data) => api.put(`/enrollments/${id}/status`, data),
|
||||||
updatePayment: (id, data) => api.put(`/enrollments/${id}/payment`, 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}`),
|
cancel: (id) => api.delete(`/enrollments/${id}`),
|
||||||
|
getStats: () => api.get('/enrollments/stats/overview'),
|
||||||
};
|
};
|
||||||
|
|
||||||
// Attendance API
|
// Attendance API
|
||||||
|
|
|
||||||
99
version.txt
99
version.txt
|
|
@ -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:
|
MAJOR FEATURES IMPLEMENTED:
|
||||||
===========================
|
===========================
|
||||||
|
|
@ -31,12 +31,33 @@ MAJOR FEATURES IMPLEMENTED:
|
||||||
- Complete CRUD operations for courses
|
- Complete CRUD operations for courses
|
||||||
- Course publishing/unpublishing functionality
|
- Course publishing/unpublishing functionality
|
||||||
- Course categories and metadata
|
- Course categories and metadata
|
||||||
- Course enrollment system
|
|
||||||
- Course image upload functionality
|
- Course image upload functionality
|
||||||
- Course search and filtering
|
- Course search and filtering
|
||||||
- Course statistics and analytics
|
- 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
|
- Modern, responsive UI using Tailwind CSS
|
||||||
- Heroicons for consistent iconography
|
- Heroicons for consistent iconography
|
||||||
|
|
@ -47,7 +68,7 @@ MAJOR FEATURES IMPLEMENTED:
|
||||||
- Search and filter interfaces
|
- Search and filter interfaces
|
||||||
- Role-based UI elements
|
- Role-based UI elements
|
||||||
|
|
||||||
5. INTERNATIONALIZATION (i18n)
|
7. INTERNATIONALIZATION (i18n)
|
||||||
-------------------------------
|
-------------------------------
|
||||||
- English (LTR) and Arabic (RTL) language support
|
- English (LTR) and Arabic (RTL) language support
|
||||||
- Dynamic language switching
|
- Dynamic language switching
|
||||||
|
|
@ -55,16 +76,18 @@ MAJOR FEATURES IMPLEMENTED:
|
||||||
- Translation system using react-i18next
|
- Translation system using react-i18next
|
||||||
- Localized text for all user-facing content
|
- Localized text for all user-facing content
|
||||||
|
|
||||||
6. FILE UPLOAD SYSTEM
|
8. FILE UPLOAD SYSTEM
|
||||||
----------------------
|
----------------------
|
||||||
- Image upload for course thumbnails
|
- Image upload for course thumbnails
|
||||||
- Slider image upload for homepage
|
- Slider image upload for homepage
|
||||||
|
- Multi-type content file uploads (documents, images, videos)
|
||||||
- Multer middleware for file handling
|
- Multer middleware for file handling
|
||||||
- File validation and size limits
|
- File validation and size limits (100MB max)
|
||||||
- Organized file storage structure
|
- Organized file storage structure
|
||||||
- Automatic directory creation
|
- Automatic directory creation
|
||||||
|
- File type validation for different content types
|
||||||
|
|
||||||
7. BACKEND API SYSTEM
|
9. BACKEND API SYSTEM
|
||||||
----------------------
|
----------------------
|
||||||
- RESTful API design
|
- RESTful API design
|
||||||
- Express.js server with middleware
|
- Express.js server with middleware
|
||||||
|
|
@ -73,17 +96,53 @@ MAJOR FEATURES IMPLEMENTED:
|
||||||
- Error handling and logging
|
- Error handling and logging
|
||||||
- CORS configuration
|
- CORS configuration
|
||||||
- Environment-based configuration
|
- Environment-based configuration
|
||||||
|
- Course content management APIs
|
||||||
|
- Enrollment management APIs
|
||||||
|
|
||||||
8. DATABASE SYSTEM
|
10. DATABASE SYSTEM
|
||||||
-------------------
|
-------------------
|
||||||
- PostgreSQL database with Sequelize ORM
|
- PostgreSQL database with Sequelize ORM
|
||||||
- User model with proper relationships
|
- User model with proper relationships
|
||||||
- Course model with metadata
|
- 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
|
- Attendance tracking
|
||||||
- Assignment management
|
- Assignment management
|
||||||
- Database migrations and seeding
|
- 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:
|
TECHNICAL IMPROVEMENTS:
|
||||||
=======================
|
=======================
|
||||||
|
|
||||||
|
|
@ -102,6 +161,8 @@ TECHNICAL IMPROVEMENTS:
|
||||||
- Optimistic updates and caching
|
- Optimistic updates and caching
|
||||||
- Error handling and retry logic
|
- Error handling and retry logic
|
||||||
- Request/response interceptors
|
- Request/response interceptors
|
||||||
|
- Course content API integration
|
||||||
|
- Enrollment management API integration
|
||||||
|
|
||||||
3. SECURITY ENHANCEMENTS
|
3. SECURITY ENHANCEMENTS
|
||||||
-------------------------
|
-------------------------
|
||||||
|
|
@ -118,6 +179,8 @@ TECHNICAL IMPROVEMENTS:
|
||||||
- Image optimization and compression
|
- Image optimization and compression
|
||||||
- Lazy loading considerations
|
- Lazy loading considerations
|
||||||
- Caching strategies
|
- Caching strategies
|
||||||
|
- File upload optimization
|
||||||
|
- Content streaming for large files
|
||||||
|
|
||||||
BUG FIXES & RESOLUTIONS:
|
BUG FIXES & RESOLUTIONS:
|
||||||
========================
|
========================
|
||||||
|
|
@ -153,6 +216,8 @@ BUG FIXES & RESOLUTIONS:
|
||||||
- Improved error handling and logging
|
- Improved error handling and logging
|
||||||
- Fixed database connection issues
|
- Fixed database connection issues
|
||||||
- Enhanced API response consistency
|
- Enhanced API response consistency
|
||||||
|
- Added comprehensive content validation
|
||||||
|
- Enhanced file upload error handling
|
||||||
|
|
||||||
DEPENDENCIES & TECHNOLOGIES:
|
DEPENDENCIES & TECHNOLOGIES:
|
||||||
============================
|
============================
|
||||||
|
|
@ -174,7 +239,7 @@ Backend:
|
||||||
- PostgreSQL
|
- PostgreSQL
|
||||||
- JWT (jsonwebtoken)
|
- JWT (jsonwebtoken)
|
||||||
- bcryptjs
|
- bcryptjs
|
||||||
- multer
|
- multer (enhanced for multi-type uploads)
|
||||||
- express-validator
|
- express-validator
|
||||||
- csv-parser
|
- csv-parser
|
||||||
|
|
||||||
|
|
@ -200,6 +265,8 @@ Backend:
|
||||||
- middleware/ (Custom middleware)
|
- middleware/ (Custom middleware)
|
||||||
- config/ (Configuration files)
|
- config/ (Configuration files)
|
||||||
- uploads/ (File uploads)
|
- uploads/ (File uploads)
|
||||||
|
- course-content/ (Course content management)
|
||||||
|
- enrollments/ (Enrollment management)
|
||||||
|
|
||||||
CONFIGURATION:
|
CONFIGURATION:
|
||||||
==============
|
==============
|
||||||
|
|
@ -214,7 +281,9 @@ Environment Variables:
|
||||||
Database Schema:
|
Database Schema:
|
||||||
- Users table with role-based access
|
- Users table with role-based access
|
||||||
- Courses table with metadata
|
- 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
|
- Attendance and assignment tables
|
||||||
|
|
||||||
SECURITY CONSIDERATIONS:
|
SECURITY CONSIDERATIONS:
|
||||||
|
|
@ -238,8 +307,8 @@ DEPLOYMENT READINESS:
|
||||||
- Performance optimization
|
- Performance optimization
|
||||||
- Security hardening
|
- 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]
|
Release Date: [Current Date]
|
||||||
Version: 1.0.0
|
Version: 1.1.0
|
||||||
Status: Production Ready
|
Status: Production Ready
|
||||||
Loading…
Reference in a new issue