MAJOR FEATURE: Complete Course Content Management System NEW FEATURES: - Enhanced Trainer Dashboard with clickable cards - New Trainer Courses Page (/trainer/courses) with filtering and management - New Trainer Students Page (/trainer/students) with enrollment management - Backend API endpoints for trainer-specific data - Enhanced API services with React Query integration TECHNICAL IMPROVEMENTS: - Created TrainerCourses.js and TrainerStudents.js components - Updated Dashboard.js with enhanced navigation - Added new routes in App.js for trainer pages - Implemented secure trainer-specific backend endpoints - Added role-based access control and data isolation DESIGN FEATURES: - Responsive design with mobile-first approach - Beautiful UI with hover effects and transitions - Consistent styling throughout the application - Accessibility improvements with ARIA labels SECURITY: - Trainers can only access their own data - Secure API authentication required - Data isolation between different trainers PERFORMANCE: - Efficient React Query implementation - Optimized database queries - Responsive image handling BUG FIXES: - Fixed phone number login functionality - Removed temporary debug endpoints - Cleaned up authentication logging This release provides trainers with comprehensive tools to manage their courses and students, significantly improving the user experience and functionality of the CourseWorx platform.
564 lines
No EOL
16 KiB
JavaScript
564 lines
No EOL
16 KiB
JavaScript
const express = require('express');
|
|
const { body, validationResult } = require('express-validator');
|
|
const { Course, User, Enrollment } = require('../models');
|
|
const { auth, requireSuperAdmin, requireTrainer } = require('../middleware/auth');
|
|
const multer = require('multer');
|
|
const path = require('path');
|
|
const fs = require('fs');
|
|
|
|
const router = express.Router();
|
|
|
|
// Multer storage for course images
|
|
const courseImageStorage = multer.diskStorage({
|
|
destination: function (req, file, cb) {
|
|
const courseName = req.body.courseName || req.params.courseName;
|
|
const dir = path.join(__dirname, '../uploads/courses', courseName);
|
|
fs.mkdirSync(dir, { recursive: true });
|
|
cb(null, dir);
|
|
},
|
|
filename: function (req, file, cb) {
|
|
cb(null, file.fieldname + '-' + Date.now() + path.extname(file.originalname));
|
|
}
|
|
});
|
|
|
|
const uploadCourseImage = multer({
|
|
storage: courseImageStorage,
|
|
fileFilter: (req, file, cb) => {
|
|
if (file.mimetype.startsWith('image/')) {
|
|
cb(null, true);
|
|
} else {
|
|
cb(new Error('Only image files are allowed'), false);
|
|
}
|
|
},
|
|
limits: { fileSize: 5 * 1024 * 1024 }
|
|
});
|
|
|
|
// @route GET /api/courses
|
|
// @desc Get all courses (with filters)
|
|
// @access Public
|
|
router.get('/', async (req, res) => {
|
|
try {
|
|
const {
|
|
category,
|
|
level,
|
|
trainerId,
|
|
isPublished,
|
|
page = 1,
|
|
limit = 12,
|
|
search,
|
|
sortBy = 'createdAt',
|
|
sortOrder = 'DESC'
|
|
} = req.query;
|
|
|
|
const offset = (page - 1) * limit;
|
|
const whereClause = {};
|
|
|
|
if (category) whereClause.category = category;
|
|
if (level) whereClause.level = level;
|
|
if (trainerId) whereClause.trainerId = trainerId;
|
|
// Only apply isPublished filter if it's provided in the query
|
|
if (isPublished !== undefined) {
|
|
whereClause.isPublished = isPublished === 'true';
|
|
} else {
|
|
// For non-authenticated users, only show published courses
|
|
if (!req.user || req.user.role === 'trainee') {
|
|
whereClause.isPublished = true;
|
|
}
|
|
}
|
|
|
|
if (search) {
|
|
whereClause[require('sequelize').Op.or] = [
|
|
{ title: { [require('sequelize').Op.iLike]: `%${search}%` } },
|
|
{ description: { [require('sequelize').Op.iLike]: `%${search}%` } },
|
|
{ shortDescription: { [require('sequelize').Op.iLike]: `%${search}%` } }
|
|
];
|
|
}
|
|
|
|
const { count, rows: courses } = await Course.findAndCountAll({
|
|
where: whereClause,
|
|
include: [
|
|
{
|
|
model: User,
|
|
as: 'trainer',
|
|
attributes: ['id', 'firstName', 'lastName', 'avatar']
|
|
}
|
|
],
|
|
order: [[sortBy, sortOrder]],
|
|
limit: parseInt(limit),
|
|
offset: parseInt(offset)
|
|
});
|
|
|
|
res.json({
|
|
courses,
|
|
pagination: {
|
|
currentPage: parseInt(page),
|
|
totalPages: Math.ceil(count / limit),
|
|
totalItems: count,
|
|
itemsPerPage: parseInt(limit)
|
|
}
|
|
});
|
|
} catch (error) {
|
|
console.error('Get courses error:', error);
|
|
res.status(500).json({ error: 'Server error.' });
|
|
}
|
|
});
|
|
|
|
// @route GET /api/courses/:id
|
|
// @desc Get course by ID
|
|
// @access Public
|
|
router.get('/:id', async (req, res) => {
|
|
try {
|
|
const course = await Course.findByPk(req.params.id, {
|
|
include: [
|
|
{
|
|
model: User,
|
|
as: 'trainer',
|
|
attributes: ['id', 'firstName', 'lastName', 'avatar', 'email']
|
|
},
|
|
{
|
|
model: Enrollment,
|
|
as: 'enrollments',
|
|
attributes: ['id', 'status', 'enrolledAt', 'progress'],
|
|
include: [
|
|
{
|
|
model: User,
|
|
as: 'user',
|
|
attributes: ['id', 'firstName', 'lastName', 'avatar']
|
|
}
|
|
]
|
|
}
|
|
]
|
|
});
|
|
|
|
if (!course) {
|
|
return res.status(404).json({ error: 'Course not found.' });
|
|
}
|
|
|
|
res.json({ course });
|
|
} catch (error) {
|
|
console.error('Get course error:', error);
|
|
res.status(500).json({ error: 'Server error.' });
|
|
}
|
|
});
|
|
|
|
// @route POST /api/courses
|
|
// @desc Create new course (Super Admin or Trainer)
|
|
// @access Private
|
|
router.post('/', [
|
|
auth,
|
|
requireTrainer,
|
|
body('title').isLength({ min: 3, max: 200 }),
|
|
body('description').notEmpty(),
|
|
body('price').isFloat({ min: 0 }),
|
|
body('level').isIn(['beginner', 'intermediate', 'advanced']),
|
|
body('category').optional().isLength({ min: 2, max: 100 })
|
|
], async (req, res) => {
|
|
try {
|
|
const errors = validationResult(req);
|
|
if (!errors.isEmpty()) {
|
|
return res.status(400).json({ errors: errors.array() });
|
|
}
|
|
|
|
const {
|
|
title,
|
|
description,
|
|
shortDescription,
|
|
price,
|
|
duration,
|
|
level,
|
|
category,
|
|
tags,
|
|
requirements,
|
|
learningOutcomes,
|
|
curriculum,
|
|
maxStudents,
|
|
startDate,
|
|
endDate
|
|
} = req.body;
|
|
|
|
const course = await Course.create({
|
|
title,
|
|
description,
|
|
shortDescription,
|
|
price,
|
|
duration,
|
|
level,
|
|
category,
|
|
tags: tags || [],
|
|
requirements,
|
|
learningOutcomes,
|
|
curriculum: curriculum || [],
|
|
maxStudents,
|
|
startDate,
|
|
endDate,
|
|
trainerId: req.user.id,
|
|
isPublished: true // Auto-publish for all users
|
|
});
|
|
|
|
const courseWithTrainer = await Course.findByPk(course.id, {
|
|
include: [
|
|
{
|
|
model: User,
|
|
as: 'trainer',
|
|
attributes: ['id', 'firstName', 'lastName', 'avatar']
|
|
}
|
|
]
|
|
});
|
|
|
|
res.status(201).json({
|
|
message: 'Course created successfully.',
|
|
course: courseWithTrainer
|
|
});
|
|
} catch (error) {
|
|
console.error('Create course error:', error);
|
|
res.status(500).json({ error: 'Server error.' });
|
|
}
|
|
});
|
|
|
|
// @route PUT /api/courses/:id
|
|
// @desc Update course (Owner or Super Admin)
|
|
// @access Private
|
|
router.put('/:id', [
|
|
auth,
|
|
body('title').optional().isLength({ min: 3, max: 200 }),
|
|
body('description').optional().notEmpty(),
|
|
body('price').optional().isFloat({ min: 0 }),
|
|
body('level').optional().isIn(['beginner', 'intermediate', 'advanced']),
|
|
body('category').optional().isLength({ min: 2, max: 100 })
|
|
], async (req, res) => {
|
|
try {
|
|
const errors = validationResult(req);
|
|
if (!errors.isEmpty()) {
|
|
return res.status(400).json({ errors: errors.array() });
|
|
}
|
|
|
|
const course = await Course.findByPk(req.params.id);
|
|
if (!course) {
|
|
return res.status(404).json({ error: 'Course not found.' });
|
|
}
|
|
|
|
// Check permissions
|
|
if (req.user.role !== 'super_admin' && course.trainerId !== req.user.id) {
|
|
return res.status(403).json({ error: 'Not authorized to update this course.' });
|
|
}
|
|
|
|
const updateData = req.body;
|
|
await course.update(updateData);
|
|
|
|
const updatedCourse = await Course.findByPk(course.id, {
|
|
include: [
|
|
{
|
|
model: User,
|
|
as: 'trainer',
|
|
attributes: ['id', 'firstName', 'lastName', 'avatar']
|
|
}
|
|
]
|
|
});
|
|
|
|
res.json({
|
|
message: 'Course updated successfully.',
|
|
course: updatedCourse
|
|
});
|
|
} catch (error) {
|
|
console.error('Update course error:', error);
|
|
res.status(500).json({ error: 'Server error.' });
|
|
}
|
|
});
|
|
|
|
// @route DELETE /api/courses/:id
|
|
// @desc Delete course (Owner or Super Admin)
|
|
// @access Private
|
|
router.delete('/:id', auth, async (req, res) => {
|
|
try {
|
|
const course = await Course.findByPk(req.params.id);
|
|
if (!course) {
|
|
return res.status(404).json({ error: 'Course not found.' });
|
|
}
|
|
|
|
// Check permissions
|
|
if (req.user.role !== 'super_admin' && course.trainerId !== req.user.id) {
|
|
return res.status(403).json({ error: 'Not authorized to delete this course.' });
|
|
}
|
|
|
|
await course.destroy();
|
|
|
|
res.json({ message: 'Course deleted successfully.' });
|
|
} catch (error) {
|
|
console.error('Delete course error:', error);
|
|
res.status(500).json({ error: 'Server error.' });
|
|
}
|
|
});
|
|
|
|
// @route PUT /api/courses/:id/publish
|
|
// @desc Publish/unpublish course (Owner or Super Admin)
|
|
// @access Private
|
|
router.put('/:id/publish', [
|
|
auth,
|
|
body('isPublished').isBoolean()
|
|
], async (req, res) => {
|
|
try {
|
|
const errors = validationResult(req);
|
|
if (!errors.isEmpty()) {
|
|
return res.status(400).json({ errors: errors.array() });
|
|
}
|
|
|
|
const course = await Course.findByPk(req.params.id);
|
|
if (!course) {
|
|
return res.status(404).json({ error: 'Course not found.' });
|
|
}
|
|
|
|
// Check permissions
|
|
if (req.user.role !== 'super_admin' && course.trainerId !== req.user.id) {
|
|
return res.status(403).json({ error: 'Not authorized to update this course.' });
|
|
}
|
|
|
|
await course.update({ isPublished: req.body.isPublished });
|
|
|
|
res.json({
|
|
message: `Course ${req.body.isPublished ? 'published' : 'unpublished'} successfully.`,
|
|
course: {
|
|
id: course.id,
|
|
title: course.title,
|
|
isPublished: course.isPublished
|
|
}
|
|
});
|
|
} catch (error) {
|
|
console.error('Publish course error:', error);
|
|
res.status(500).json({ error: 'Server error.' });
|
|
}
|
|
});
|
|
|
|
// @route POST /api/courses/:courseName/image
|
|
// @desc Upload course image (Super Admin or Trainer)
|
|
// @access Private
|
|
router.post('/:courseName/image', [auth, requireTrainer, uploadCourseImage.single('image')], async (req, res) => {
|
|
try {
|
|
if (!req.file) {
|
|
return res.status(400).json({ error: 'No image file uploaded.' });
|
|
}
|
|
res.json({
|
|
message: 'Image uploaded successfully.',
|
|
imageUrl: `/uploads/courses/${req.params.courseName}/${req.file.filename}`
|
|
});
|
|
} catch (error) {
|
|
console.error('Upload course image error:', error);
|
|
res.status(500).json({ error: 'Server error.' });
|
|
}
|
|
});
|
|
|
|
// @route GET /api/courses/categories/all
|
|
// @desc Get all course categories
|
|
// @access Public
|
|
router.get('/categories/all', async (req, res) => {
|
|
try {
|
|
const categories = await Course.findAll({
|
|
attributes: [
|
|
[require('sequelize').fn('DISTINCT', require('sequelize').col('category')), 'category']
|
|
],
|
|
where: {
|
|
category: { [require('sequelize').Op.not]: null },
|
|
isPublished: true
|
|
},
|
|
raw: true
|
|
});
|
|
|
|
const categoryList = categories
|
|
.map(cat => cat.category)
|
|
.filter(cat => cat)
|
|
.sort();
|
|
|
|
res.json({ categories: categoryList });
|
|
} catch (error) {
|
|
console.error('Get categories error:', error);
|
|
res.status(500).json({ error: 'Server error.' });
|
|
}
|
|
});
|
|
|
|
// @route PUT /api/courses/:id/assign-trainer
|
|
// @desc Assign trainer to course (Super Admin only)
|
|
// @access Private (Super Admin)
|
|
router.put('/:id/assign-trainer', [
|
|
auth,
|
|
requireSuperAdmin,
|
|
body('trainerId').isUUID().withMessage('Valid trainer ID is required')
|
|
], async (req, res) => {
|
|
try {
|
|
const errors = validationResult(req);
|
|
if (!errors.isEmpty()) {
|
|
return res.status(400).json({ errors: errors.array() });
|
|
}
|
|
|
|
const course = await Course.findByPk(req.params.id);
|
|
if (!course) {
|
|
return res.status(404).json({ error: 'Course not found.' });
|
|
}
|
|
|
|
const { trainerId } = req.body;
|
|
|
|
// Verify the trainer exists and is actually a trainer
|
|
const trainer = await User.findByPk(trainerId);
|
|
if (!trainer) {
|
|
return res.status(404).json({ error: 'Trainer not found.' });
|
|
}
|
|
|
|
if (trainer.role !== 'trainer') {
|
|
return res.status(400).json({ error: 'Selected user is not a trainer.' });
|
|
}
|
|
|
|
if (!trainer.isActive) {
|
|
return res.status(400).json({ error: 'Selected trainer account is inactive.' });
|
|
}
|
|
|
|
// Update the course with the new trainer
|
|
await course.update({ trainerId });
|
|
|
|
const updatedCourse = await Course.findByPk(course.id, {
|
|
include: [
|
|
{
|
|
model: User,
|
|
as: 'trainer',
|
|
attributes: ['id', 'firstName', 'lastName', 'avatar', 'email']
|
|
}
|
|
]
|
|
});
|
|
|
|
res.json({
|
|
message: 'Trainer assigned successfully.',
|
|
course: updatedCourse
|
|
});
|
|
} catch (error) {
|
|
console.error('Assign trainer error:', error);
|
|
res.status(500).json({ error: 'Server error.' });
|
|
}
|
|
});
|
|
|
|
// @route GET /api/courses/trainers/available
|
|
// @desc Get available trainers for assignment (Super Admin only)
|
|
// @access Private (Super Admin)
|
|
router.get('/trainers/available', auth, requireSuperAdmin, async (req, res) => {
|
|
try {
|
|
console.log('Available trainers endpoint called by user:', req.user.id, req.user.role);
|
|
|
|
const trainers = await User.findAll({
|
|
where: {
|
|
role: 'trainer',
|
|
isActive: true
|
|
},
|
|
attributes: ['id', 'firstName', 'lastName', 'email', 'avatar'],
|
|
order: [['firstName', 'ASC'], ['lastName', 'ASC']]
|
|
});
|
|
|
|
console.log('Available trainers found:', trainers.length, trainers.map(t => ({ id: t.id, name: `${t.firstName} ${t.lastName}`, email: t.email })));
|
|
|
|
res.json({ trainers });
|
|
} catch (error) {
|
|
console.error('Get available trainers error:', error);
|
|
res.status(500).json({ error: 'Server error.' });
|
|
}
|
|
});
|
|
|
|
// @route GET /api/courses/trainer/:trainerId
|
|
// @desc Get courses for a specific trainer
|
|
// @access Private (Trainer can only see their own courses, Super Admin can see any trainer's courses)
|
|
router.get('/trainer/:trainerId', auth, async (req, res) => {
|
|
try {
|
|
const { trainerId } = req.params;
|
|
const { isPublished, page = 1, limit = 12, search, sortBy = 'createdAt', sortOrder = 'DESC' } = req.query;
|
|
|
|
// Check if user can access this trainer's courses
|
|
if (req.user.role === 'trainer' && req.user.id !== trainerId) {
|
|
return res.status(403).json({ error: 'Access denied. You can only view your own courses.' });
|
|
}
|
|
|
|
const offset = (page - 1) * limit;
|
|
const whereClause = { trainerId };
|
|
|
|
// Apply filters
|
|
if (isPublished !== undefined) {
|
|
whereClause.isPublished = isPublished === 'true';
|
|
}
|
|
|
|
if (search) {
|
|
whereClause[require('sequelize').Op.or] = [
|
|
{ title: { [require('sequelize').Op.iLike]: `%${search}%` } },
|
|
{ description: { [require('sequelize').Op.iLike]: `%${search}%` } },
|
|
{ shortDescription: { [require('sequelize').Op.iLike]: `%${search}%` } }
|
|
];
|
|
}
|
|
|
|
const { count, rows: courses } = await Course.findAndCountAll({
|
|
where: whereClause,
|
|
include: [
|
|
{
|
|
model: User,
|
|
as: 'trainer',
|
|
attributes: ['id', 'firstName', 'lastName', 'avatar']
|
|
}
|
|
],
|
|
order: [[sortBy, sortOrder]],
|
|
limit: parseInt(limit),
|
|
offset: parseInt(offset)
|
|
});
|
|
|
|
res.json({
|
|
courses,
|
|
pagination: {
|
|
currentPage: parseInt(page),
|
|
totalPages: Math.ceil(count / limit),
|
|
totalItems: count,
|
|
itemsPerPage: parseInt(limit)
|
|
}
|
|
});
|
|
} catch (error) {
|
|
console.error('Get trainer courses error:', error);
|
|
res.status(500).json({ error: 'Server error.' });
|
|
}
|
|
});
|
|
|
|
// @route GET /api/courses/stats/overview
|
|
// @desc Get course statistics (Super Admin or Trainer)
|
|
// @access Private
|
|
router.get('/stats/overview', auth, async (req, res) => {
|
|
try {
|
|
console.log('Course stats endpoint called by user:', req.user.id, req.user.role);
|
|
|
|
const whereClause = {};
|
|
if (req.user.role === 'trainer') {
|
|
whereClause.trainerId = req.user.id;
|
|
}
|
|
|
|
const totalCourses = await Course.count({ where: whereClause });
|
|
const publishedCourses = await Course.count({
|
|
where: { ...whereClause, isPublished: true }
|
|
});
|
|
const featuredCourses = await Course.count({
|
|
where: { ...whereClause, isFeatured: true }
|
|
});
|
|
|
|
console.log('Course stats calculated:', { totalCourses, publishedCourses, featuredCourses, whereClause });
|
|
|
|
// For trainers, provide specific stats
|
|
let myCourses = 0;
|
|
let myPublishedCourses = 0;
|
|
if (req.user.role === 'trainer') {
|
|
myCourses = totalCourses;
|
|
myPublishedCourses = publishedCourses;
|
|
}
|
|
|
|
res.json({
|
|
stats: {
|
|
totalCourses,
|
|
publishedCourses,
|
|
unpublishedCourses: totalCourses - publishedCourses,
|
|
featuredCourses,
|
|
myCourses,
|
|
myPublishedCourses
|
|
}
|
|
});
|
|
} catch (error) {
|
|
console.error('Get course stats error:', error);
|
|
res.status(500).json({ error: 'Server error.' });
|
|
}
|
|
});
|
|
|
|
module.exports = router;
|