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 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
|
||||
};
|
||||
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 { 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;
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
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:
|
||||
===========================
|
||||
|
|
@ -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
|
||||
Loading…
Reference in a new issue