add changes
This commit is contained in:
1075
controllers/admin.controller.js
Normal file
1075
controllers/admin.controller.js
Normal file
File diff suppressed because it is too large
Load Diff
288
controllers/auth.controller.js
Normal file
288
controllers/auth.controller.js
Normal file
@@ -0,0 +1,288 @@
|
||||
const jwt = require('jsonwebtoken');
|
||||
const { v4: uuidv4 } = require('uuid');
|
||||
const { User, GuestSession, QuizSession, sequelize } = require('../models');
|
||||
const config = require('../config/config');
|
||||
|
||||
/**
|
||||
* @desc Register a new user
|
||||
* @route POST /api/auth/register
|
||||
* @access Public
|
||||
*/
|
||||
exports.register = async (req, res) => {
|
||||
const transaction = await sequelize.transaction();
|
||||
|
||||
try {
|
||||
const { username, email, password, guestSessionId } = req.body;
|
||||
|
||||
// Check if user already exists
|
||||
const existingUser = await User.findOne({
|
||||
where: {
|
||||
[sequelize.Sequelize.Op.or]: [
|
||||
{ email: email.toLowerCase() },
|
||||
{ username: username.toLowerCase() }
|
||||
]
|
||||
}
|
||||
});
|
||||
|
||||
if (existingUser) {
|
||||
await transaction.rollback();
|
||||
if (existingUser.email === email.toLowerCase()) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: 'Email already registered'
|
||||
});
|
||||
} else {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: 'Username already taken'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Create new user (password will be hashed by beforeCreate hook)
|
||||
const user = await User.create({
|
||||
id: uuidv4(),
|
||||
username: username.toLowerCase(),
|
||||
email: email.toLowerCase(),
|
||||
password: password,
|
||||
role: 'user',
|
||||
is_active: true
|
||||
}, { transaction });
|
||||
|
||||
// Handle guest session migration if provided
|
||||
let migratedData = null;
|
||||
if (guestSessionId) {
|
||||
try {
|
||||
const guestSession = await GuestSession.findOne({
|
||||
where: { guest_id: guestSessionId }
|
||||
});
|
||||
|
||||
if (guestSession && !guestSession.is_converted) {
|
||||
// Migrate quiz sessions from guest to user
|
||||
const migratedSessions = await QuizSession.update(
|
||||
{
|
||||
user_id: user.id,
|
||||
guest_session_id: null
|
||||
},
|
||||
{
|
||||
where: { guest_session_id: guestSession.id },
|
||||
transaction
|
||||
}
|
||||
);
|
||||
|
||||
// Mark guest session as converted
|
||||
await guestSession.update({
|
||||
is_converted: true,
|
||||
converted_user_id: user.id,
|
||||
converted_at: new Date()
|
||||
}, { transaction });
|
||||
|
||||
// Recalculate user stats from migrated sessions
|
||||
const quizSessions = await QuizSession.findAll({
|
||||
where: {
|
||||
user_id: user.id,
|
||||
status: 'completed'
|
||||
},
|
||||
transaction
|
||||
});
|
||||
|
||||
let totalQuizzes = quizSessions.length;
|
||||
let quizzesPassed = 0;
|
||||
let totalQuestionsAnswered = 0;
|
||||
let correctAnswers = 0;
|
||||
|
||||
quizSessions.forEach(session => {
|
||||
if (session.is_passed) quizzesPassed++;
|
||||
totalQuestionsAnswered += session.questions_answered || 0;
|
||||
correctAnswers += session.correct_answers || 0;
|
||||
});
|
||||
|
||||
// Update user stats
|
||||
await user.update({
|
||||
total_quizzes: totalQuizzes,
|
||||
quizzes_passed: quizzesPassed,
|
||||
total_questions_answered: totalQuestionsAnswered,
|
||||
correct_answers: correctAnswers
|
||||
}, { transaction });
|
||||
|
||||
migratedData = {
|
||||
quizzes: migratedSessions[0],
|
||||
stats: {
|
||||
totalQuizzes,
|
||||
quizzesPassed,
|
||||
accuracy: totalQuestionsAnswered > 0
|
||||
? ((correctAnswers / totalQuestionsAnswered) * 100).toFixed(2)
|
||||
: 0
|
||||
}
|
||||
};
|
||||
}
|
||||
} catch (guestError) {
|
||||
// Log error but don't fail registration
|
||||
console.error('Guest migration error:', guestError.message);
|
||||
// Continue with registration even if migration fails
|
||||
}
|
||||
}
|
||||
|
||||
// Commit transaction before generating JWT
|
||||
await transaction.commit();
|
||||
|
||||
// Generate JWT token (after commit to avoid rollback issues)
|
||||
const token = jwt.sign(
|
||||
{
|
||||
userId: user.id,
|
||||
email: user.email,
|
||||
username: user.username,
|
||||
role: user.role
|
||||
},
|
||||
config.jwt.secret,
|
||||
{ expiresIn: config.jwt.expire }
|
||||
);
|
||||
|
||||
// Return user data (exclude password)
|
||||
const userData = user.toSafeJSON();
|
||||
|
||||
res.status(201).json({
|
||||
success: true,
|
||||
message: 'User registered successfully',
|
||||
data: {
|
||||
user: userData,
|
||||
token,
|
||||
migratedData
|
||||
}
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
// Only rollback if transaction is still active
|
||||
if (!transaction.finished) {
|
||||
await transaction.rollback();
|
||||
}
|
||||
console.error('Registration error:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: 'Error registering user',
|
||||
error: error.message
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* @desc Login user
|
||||
* @route POST /api/auth/login
|
||||
* @access Public
|
||||
*/
|
||||
exports.login = async (req, res) => {
|
||||
try {
|
||||
const { email, password } = req.body;
|
||||
|
||||
// Find user by email
|
||||
const user = await User.findOne({
|
||||
where: {
|
||||
email: email.toLowerCase(),
|
||||
is_active: true
|
||||
}
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
return res.status(401).json({
|
||||
success: false,
|
||||
message: 'Invalid email or password'
|
||||
});
|
||||
}
|
||||
|
||||
// Verify password
|
||||
const isPasswordValid = await user.comparePassword(password);
|
||||
if (!isPasswordValid) {
|
||||
return res.status(401).json({
|
||||
success: false,
|
||||
message: 'Invalid email or password'
|
||||
});
|
||||
}
|
||||
|
||||
// Update last_login
|
||||
await user.update({ last_login: new Date() });
|
||||
|
||||
// Generate JWT token
|
||||
const token = jwt.sign(
|
||||
{
|
||||
userId: user.id,
|
||||
email: user.email,
|
||||
username: user.username,
|
||||
role: user.role
|
||||
},
|
||||
config.jwt.secret,
|
||||
{ expiresIn: config.jwt.expire }
|
||||
);
|
||||
|
||||
// Return user data (exclude password)
|
||||
const userData = user.toSafeJSON();
|
||||
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
message: 'Login successful',
|
||||
data: {
|
||||
user: userData,
|
||||
token
|
||||
}
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('Login error:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: 'Error logging in',
|
||||
error: error.message
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* @desc Logout user (client-side token removal)
|
||||
* @route POST /api/auth/logout
|
||||
* @access Public
|
||||
*/
|
||||
exports.logout = async (req, res) => {
|
||||
// Since we're using JWT (stateless), logout is handled client-side
|
||||
// by removing the token from storage
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
message: 'Logout successful. Please remove token from client storage.'
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* @desc Verify JWT token and return user info
|
||||
* @route GET /api/auth/verify
|
||||
* @access Private
|
||||
*/
|
||||
exports.verifyToken = async (req, res) => {
|
||||
try {
|
||||
// User is already attached to req by verifyToken middleware
|
||||
const user = await User.findByPk(req.user.userId);
|
||||
|
||||
if (!user || !user.isActive) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: 'User not found or inactive'
|
||||
});
|
||||
}
|
||||
|
||||
// Return user data (exclude password)
|
||||
const userData = user.toSafeJSON();
|
||||
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
message: 'Token valid',
|
||||
data: {
|
||||
user: userData
|
||||
}
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('Token verification error:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: 'Error verifying token',
|
||||
error: error.message
|
||||
});
|
||||
}
|
||||
};
|
||||
481
controllers/category.controller.js
Normal file
481
controllers/category.controller.js
Normal file
@@ -0,0 +1,481 @@
|
||||
const { Category, Question } = require('../models');
|
||||
|
||||
/**
|
||||
* @desc Get all active categories
|
||||
* @route GET /api/categories
|
||||
* @access Public
|
||||
*/
|
||||
exports.getAllCategories = async (req, res) => {
|
||||
try {
|
||||
// Check if request is from guest or authenticated user
|
||||
const isGuest = !req.user; // If no user attached, it's a guest/public request
|
||||
|
||||
// Build query conditions
|
||||
const whereConditions = {
|
||||
isActive: true
|
||||
};
|
||||
|
||||
// If guest, only show guest-accessible categories
|
||||
if (isGuest) {
|
||||
whereConditions.guestAccessible = true;
|
||||
}
|
||||
|
||||
// Fetch categories
|
||||
const categories = await Category.findAll({
|
||||
where: whereConditions,
|
||||
attributes: [
|
||||
'id',
|
||||
'name',
|
||||
'slug',
|
||||
'description',
|
||||
'icon',
|
||||
'color',
|
||||
'questionCount',
|
||||
'displayOrder',
|
||||
'guestAccessible'
|
||||
],
|
||||
order: [
|
||||
['displayOrder', 'ASC'],
|
||||
['name', 'ASC']
|
||||
]
|
||||
});
|
||||
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
count: categories.length,
|
||||
data: categories,
|
||||
message: isGuest
|
||||
? `${categories.length} guest-accessible categories available`
|
||||
: `${categories.length} categories available`
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error fetching categories:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: 'Error fetching categories',
|
||||
error: error.message
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* @desc Get category details by ID
|
||||
* @route GET /api/categories/:id
|
||||
* @access Public (with optional auth for access control)
|
||||
*/
|
||||
exports.getCategoryById = async (req, res) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const isGuest = !req.user;
|
||||
|
||||
// Validate ID (accepts UUID or numeric)
|
||||
if (!id) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: 'Invalid category ID'
|
||||
});
|
||||
}
|
||||
|
||||
// UUID format validation (basic check)
|
||||
const isUUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(id);
|
||||
const isNumeric = !isNaN(id) && Number.isInteger(Number(id));
|
||||
|
||||
if (!isUUID && !isNumeric) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: 'Invalid category ID format'
|
||||
});
|
||||
}
|
||||
|
||||
// Find category
|
||||
const category = await Category.findByPk(id, {
|
||||
attributes: [
|
||||
'id',
|
||||
'name',
|
||||
'slug',
|
||||
'description',
|
||||
'icon',
|
||||
'color',
|
||||
'questionCount',
|
||||
'displayOrder',
|
||||
'guestAccessible',
|
||||
'isActive'
|
||||
]
|
||||
});
|
||||
|
||||
// Check if category exists
|
||||
if (!category) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: 'Category not found'
|
||||
});
|
||||
}
|
||||
|
||||
// Check if category is active
|
||||
if (!category.isActive) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: 'Category not found'
|
||||
});
|
||||
}
|
||||
|
||||
// Check guest access
|
||||
if (isGuest && !category.guestAccessible) {
|
||||
return res.status(403).json({
|
||||
success: false,
|
||||
message: 'This category requires authentication. Please register or login to access.',
|
||||
requiresAuth: true
|
||||
});
|
||||
}
|
||||
|
||||
// Get question preview (first 5 questions)
|
||||
const questionPreview = await Question.findAll({
|
||||
where: {
|
||||
categoryId: id,
|
||||
isActive: true
|
||||
},
|
||||
attributes: [
|
||||
'id',
|
||||
'questionText',
|
||||
'questionType',
|
||||
'difficulty',
|
||||
'points',
|
||||
'timesAttempted',
|
||||
'timesCorrect'
|
||||
],
|
||||
order: [['createdAt', 'ASC']],
|
||||
limit: 5
|
||||
});
|
||||
|
||||
// Calculate category stats
|
||||
const allQuestions = await Question.findAll({
|
||||
where: {
|
||||
categoryId: id,
|
||||
isActive: true
|
||||
},
|
||||
attributes: ['difficulty', 'timesAttempted', 'timesCorrect']
|
||||
});
|
||||
|
||||
const stats = {
|
||||
totalQuestions: allQuestions.length,
|
||||
questionsByDifficulty: {
|
||||
easy: allQuestions.filter(q => q.difficulty === 'easy').length,
|
||||
medium: allQuestions.filter(q => q.difficulty === 'medium').length,
|
||||
hard: allQuestions.filter(q => q.difficulty === 'hard').length
|
||||
},
|
||||
totalAttempts: allQuestions.reduce((sum, q) => sum + (q.timesAttempted || 0), 0),
|
||||
totalCorrect: allQuestions.reduce((sum, q) => sum + (q.timesCorrect || 0), 0)
|
||||
};
|
||||
|
||||
// Calculate average accuracy
|
||||
stats.averageAccuracy = stats.totalAttempts > 0
|
||||
? Math.round((stats.totalCorrect / stats.totalAttempts) * 100)
|
||||
: 0;
|
||||
|
||||
// Prepare response
|
||||
const categoryData = {
|
||||
id: category.id,
|
||||
name: category.name,
|
||||
slug: category.slug,
|
||||
description: category.description,
|
||||
icon: category.icon,
|
||||
color: category.color,
|
||||
questionCount: category.questionCount,
|
||||
displayOrder: category.displayOrder,
|
||||
guestAccessible: category.guestAccessible
|
||||
};
|
||||
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
data: {
|
||||
category: categoryData,
|
||||
questionPreview: questionPreview.map(q => ({
|
||||
id: q.id,
|
||||
questionText: q.questionText,
|
||||
questionType: q.questionType,
|
||||
difficulty: q.difficulty,
|
||||
points: q.points,
|
||||
accuracy: q.timesAttempted > 0
|
||||
? Math.round((q.timesCorrect / q.timesAttempted) * 100)
|
||||
: 0
|
||||
})),
|
||||
stats
|
||||
},
|
||||
message: `Category details retrieved successfully`
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error fetching category details:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: 'Error fetching category details',
|
||||
error: error.message
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* @desc Create new category (Admin only)
|
||||
* @route POST /api/categories
|
||||
* @access Private/Admin
|
||||
*/
|
||||
exports.createCategory = async (req, res) => {
|
||||
try {
|
||||
const {
|
||||
name,
|
||||
slug,
|
||||
description,
|
||||
icon,
|
||||
color,
|
||||
guestAccessible,
|
||||
displayOrder
|
||||
} = req.body;
|
||||
|
||||
// Validate required fields
|
||||
if (!name) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: 'Category name is required'
|
||||
});
|
||||
}
|
||||
|
||||
// Check if category with same name exists
|
||||
const existingByName = await Category.findOne({
|
||||
where: { name }
|
||||
});
|
||||
|
||||
if (existingByName) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: 'A category with this name already exists'
|
||||
});
|
||||
}
|
||||
|
||||
// Check if custom slug provided and if it exists
|
||||
if (slug) {
|
||||
const existingBySlug = await Category.findOne({
|
||||
where: { slug }
|
||||
});
|
||||
|
||||
if (existingBySlug) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: 'A category with this slug already exists'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Create category (slug will be auto-generated by model hook if not provided)
|
||||
const category = await Category.create({
|
||||
name,
|
||||
slug,
|
||||
description: description || null,
|
||||
icon: icon || null,
|
||||
color: color || '#3B82F6',
|
||||
guestAccessible: guestAccessible !== undefined ? guestAccessible : false,
|
||||
displayOrder: displayOrder || 0,
|
||||
isActive: true,
|
||||
questionCount: 0,
|
||||
quizCount: 0
|
||||
});
|
||||
|
||||
res.status(201).json({
|
||||
success: true,
|
||||
data: {
|
||||
id: category.id,
|
||||
name: category.name,
|
||||
slug: category.slug,
|
||||
description: category.description,
|
||||
icon: category.icon,
|
||||
color: category.color,
|
||||
guestAccessible: category.guestAccessible,
|
||||
displayOrder: category.displayOrder,
|
||||
questionCount: category.questionCount,
|
||||
isActive: category.isActive
|
||||
},
|
||||
message: 'Category created successfully'
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error creating category:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: 'Error creating category',
|
||||
error: error.message
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* @desc Update category (Admin only)
|
||||
* @route PUT /api/categories/:id
|
||||
* @access Private/Admin
|
||||
*/
|
||||
exports.updateCategory = async (req, res) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const {
|
||||
name,
|
||||
slug,
|
||||
description,
|
||||
icon,
|
||||
color,
|
||||
guestAccessible,
|
||||
displayOrder,
|
||||
isActive
|
||||
} = req.body;
|
||||
|
||||
// Validate ID
|
||||
if (!id) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: 'Category ID is required'
|
||||
});
|
||||
}
|
||||
|
||||
// Find category
|
||||
const category = await Category.findByPk(id);
|
||||
|
||||
if (!category) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: 'Category not found'
|
||||
});
|
||||
}
|
||||
|
||||
// Check if new name conflicts with existing category
|
||||
if (name && name !== category.name) {
|
||||
const existingByName = await Category.findOne({
|
||||
where: { name }
|
||||
});
|
||||
|
||||
if (existingByName) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: 'A category with this name already exists'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Check if new slug conflicts with existing category
|
||||
if (slug && slug !== category.slug) {
|
||||
const existingBySlug = await Category.findOne({
|
||||
where: { slug }
|
||||
});
|
||||
|
||||
if (existingBySlug) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: 'A category with this slug already exists'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Update category
|
||||
const updateData = {};
|
||||
if (name !== undefined) updateData.name = name;
|
||||
if (slug !== undefined) updateData.slug = slug;
|
||||
if (description !== undefined) updateData.description = description;
|
||||
if (icon !== undefined) updateData.icon = icon;
|
||||
if (color !== undefined) updateData.color = color;
|
||||
if (guestAccessible !== undefined) updateData.guestAccessible = guestAccessible;
|
||||
if (displayOrder !== undefined) updateData.displayOrder = displayOrder;
|
||||
if (isActive !== undefined) updateData.isActive = isActive;
|
||||
|
||||
await category.update(updateData);
|
||||
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
data: {
|
||||
id: category.id,
|
||||
name: category.name,
|
||||
slug: category.slug,
|
||||
description: category.description,
|
||||
icon: category.icon,
|
||||
color: category.color,
|
||||
guestAccessible: category.guestAccessible,
|
||||
displayOrder: category.displayOrder,
|
||||
questionCount: category.questionCount,
|
||||
isActive: category.isActive
|
||||
},
|
||||
message: 'Category updated successfully'
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error updating category:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: 'Error updating category',
|
||||
error: error.message
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* @desc Delete category (soft delete - Admin only)
|
||||
* @route DELETE /api/categories/:id
|
||||
* @access Private/Admin
|
||||
*/
|
||||
exports.deleteCategory = async (req, res) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
|
||||
// Validate ID
|
||||
if (!id) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: 'Category ID is required'
|
||||
});
|
||||
}
|
||||
|
||||
// Find category
|
||||
const category = await Category.findByPk(id);
|
||||
|
||||
if (!category) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: 'Category not found'
|
||||
});
|
||||
}
|
||||
|
||||
// Check if already deleted
|
||||
if (!category.isActive) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: 'Category is already deleted'
|
||||
});
|
||||
}
|
||||
|
||||
// Check if category has questions
|
||||
const questionCount = await Question.count({
|
||||
where: {
|
||||
categoryId: id,
|
||||
isActive: true
|
||||
}
|
||||
});
|
||||
|
||||
// Soft delete - set isActive to false
|
||||
await category.update({ isActive: false });
|
||||
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
data: {
|
||||
id: category.id,
|
||||
name: category.name,
|
||||
questionCount: questionCount
|
||||
},
|
||||
message: questionCount > 0
|
||||
? `Category deleted successfully. ${questionCount} questions are still associated with this category.`
|
||||
: 'Category deleted successfully'
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error deleting category:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: 'Error deleting category',
|
||||
error: error.message
|
||||
});
|
||||
}
|
||||
};
|
||||
447
controllers/guest.controller.js
Normal file
447
controllers/guest.controller.js
Normal file
@@ -0,0 +1,447 @@
|
||||
const { GuestSession, Category, User, QuizSession, sequelize } = require('../models');
|
||||
const { v4: uuidv4 } = require('uuid');
|
||||
const jwt = require('jsonwebtoken');
|
||||
const config = require('../config/config');
|
||||
|
||||
/**
|
||||
* @desc Start a new guest session
|
||||
* @route POST /api/guest/start-session
|
||||
* @access Public
|
||||
*/
|
||||
exports.startGuestSession = async (req, res) => {
|
||||
try {
|
||||
const { deviceId } = req.body;
|
||||
|
||||
// Get IP address
|
||||
const ipAddress = req.ip || req.connection.remoteAddress || 'unknown';
|
||||
|
||||
// Get user agent
|
||||
const userAgent = req.headers['user-agent'] || 'unknown';
|
||||
|
||||
// Generate unique guest_id
|
||||
const timestamp = Date.now();
|
||||
const randomString = Math.random().toString(36).substring(2, 10);
|
||||
const guestId = `guest_${timestamp}_${randomString}`;
|
||||
|
||||
// Calculate expiry (24 hours from now by default)
|
||||
const expiryHours = parseInt(config.guest.sessionExpireHours) || 24;
|
||||
const expiresAt = new Date(Date.now() + expiryHours * 60 * 60 * 1000);
|
||||
const maxQuizzes = parseInt(config.guest.maxQuizzes) || 3;
|
||||
|
||||
// Generate session token (JWT) before creating session
|
||||
const sessionToken = jwt.sign(
|
||||
{ guestId },
|
||||
config.jwt.secret,
|
||||
{ expiresIn: `${expiryHours}h` }
|
||||
);
|
||||
|
||||
// Create guest session
|
||||
const guestSession = await GuestSession.create({
|
||||
guestId: guestId,
|
||||
sessionToken: sessionToken,
|
||||
deviceId: deviceId || null,
|
||||
ipAddress: ipAddress,
|
||||
userAgent: userAgent,
|
||||
expiresAt: expiresAt,
|
||||
maxQuizzes: maxQuizzes,
|
||||
quizzesAttempted: 0,
|
||||
isConverted: false
|
||||
});
|
||||
|
||||
// Get guest-accessible categories
|
||||
const categories = await Category.findAll({
|
||||
where: {
|
||||
isActive: true,
|
||||
guestAccessible: true
|
||||
},
|
||||
attributes: ['id', 'name', 'slug', 'description', 'icon', 'color', 'questionCount'],
|
||||
order: [['displayOrder', 'ASC'], ['name', 'ASC']]
|
||||
});
|
||||
|
||||
// Return response
|
||||
res.status(201).json({
|
||||
success: true,
|
||||
message: 'Guest session created successfully',
|
||||
data: {
|
||||
guestId: guestSession.guestId,
|
||||
sessionToken,
|
||||
expiresAt: guestSession.expiresAt,
|
||||
expiresIn: `${expiryHours} hours`,
|
||||
restrictions: {
|
||||
maxQuizzes: guestSession.maxQuizzes,
|
||||
quizzesRemaining: guestSession.maxQuizzes - guestSession.quizzesAttempted,
|
||||
features: {
|
||||
canTakeQuizzes: true,
|
||||
canViewResults: true,
|
||||
canBookmarkQuestions: false,
|
||||
canTrackProgress: false,
|
||||
canEarnAchievements: false
|
||||
}
|
||||
},
|
||||
availableCategories: categories,
|
||||
upgradeMessage: 'Sign up to unlock unlimited quizzes, track progress, and earn achievements!'
|
||||
}
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error creating guest session:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: 'Error creating guest session',
|
||||
error: error.message
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* @desc Get guest session details
|
||||
* @route GET /api/guest/session/:guestId
|
||||
* @access Public
|
||||
*/
|
||||
exports.getGuestSession = async (req, res) => {
|
||||
try {
|
||||
const { guestId } = req.params;
|
||||
|
||||
// Find guest session
|
||||
const guestSession = await GuestSession.findOne({
|
||||
where: { guestId: guestId }
|
||||
});
|
||||
|
||||
if (!guestSession) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: 'Guest session not found'
|
||||
});
|
||||
}
|
||||
|
||||
// Check if session is expired
|
||||
if (guestSession.isExpired()) {
|
||||
return res.status(410).json({
|
||||
success: false,
|
||||
message: 'Guest session has expired. Please start a new session.',
|
||||
expired: true
|
||||
});
|
||||
}
|
||||
|
||||
// Check if session is converted
|
||||
if (guestSession.isConverted) {
|
||||
return res.status(410).json({
|
||||
success: false,
|
||||
message: 'This guest session has been converted to a user account',
|
||||
converted: true,
|
||||
userId: guestSession.convertedUserId
|
||||
});
|
||||
}
|
||||
|
||||
// Get guest-accessible categories
|
||||
const categories = await Category.findAll({
|
||||
where: {
|
||||
isActive: true,
|
||||
guestAccessible: true
|
||||
},
|
||||
attributes: ['id', 'name', 'slug', 'description', 'icon', 'color', 'questionCount'],
|
||||
order: [['displayOrder', 'ASC'], ['name', 'ASC']]
|
||||
});
|
||||
|
||||
// Calculate time until expiry
|
||||
const now = new Date();
|
||||
const expiresAt = new Date(guestSession.expiresAt);
|
||||
const hoursRemaining = Math.max(0, Math.floor((expiresAt - now) / (1000 * 60 * 60)));
|
||||
const minutesRemaining = Math.max(0, Math.floor(((expiresAt - now) % (1000 * 60 * 60)) / (1000 * 60)));
|
||||
|
||||
// Return session details
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
data: {
|
||||
guestId: guestSession.guestId,
|
||||
expiresAt: guestSession.expiresAt,
|
||||
expiresIn: `${hoursRemaining}h ${minutesRemaining}m`,
|
||||
isExpired: false,
|
||||
restrictions: {
|
||||
maxQuizzes: guestSession.maxQuizzes,
|
||||
quizzesAttempted: guestSession.quizzesAttempted,
|
||||
quizzesRemaining: Math.max(0, guestSession.maxQuizzes - guestSession.quizzesAttempted),
|
||||
features: {
|
||||
canTakeQuizzes: guestSession.quizzesAttempted < guestSession.maxQuizzes,
|
||||
canViewResults: true,
|
||||
canBookmarkQuestions: false,
|
||||
canTrackProgress: false,
|
||||
canEarnAchievements: false
|
||||
}
|
||||
},
|
||||
availableCategories: categories,
|
||||
upgradeMessage: 'Sign up to unlock unlimited quizzes, track progress, and earn achievements!'
|
||||
}
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error getting guest session:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: 'Error retrieving guest session',
|
||||
error: error.message
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* @desc Check guest quiz limit
|
||||
* @route GET /api/guest/quiz-limit
|
||||
* @access Protected (Guest Token Required)
|
||||
*/
|
||||
exports.checkQuizLimit = async (req, res) => {
|
||||
try {
|
||||
// Guest session is already verified and attached by middleware
|
||||
const guestSession = req.guestSession;
|
||||
|
||||
// Calculate remaining quizzes
|
||||
const quizzesRemaining = guestSession.maxQuizzes - guestSession.quizzesAttempted;
|
||||
const hasReachedLimit = quizzesRemaining <= 0;
|
||||
|
||||
// Calculate time until reset (session expiry)
|
||||
const now = new Date();
|
||||
const expiresAt = new Date(guestSession.expiresAt);
|
||||
const timeRemainingMs = expiresAt - now;
|
||||
const hoursRemaining = Math.floor(timeRemainingMs / (1000 * 60 * 60));
|
||||
const minutesRemaining = Math.floor((timeRemainingMs % (1000 * 60 * 60)) / (1000 * 60));
|
||||
|
||||
// Format reset time
|
||||
let resetTime;
|
||||
if (hoursRemaining > 0) {
|
||||
resetTime = `${hoursRemaining}h ${minutesRemaining}m`;
|
||||
} else {
|
||||
resetTime = `${minutesRemaining}m`;
|
||||
}
|
||||
|
||||
// Prepare response
|
||||
const response = {
|
||||
success: true,
|
||||
data: {
|
||||
guestId: guestSession.guestId,
|
||||
quizLimit: {
|
||||
maxQuizzes: guestSession.maxQuizzes,
|
||||
quizzesAttempted: guestSession.quizzesAttempted,
|
||||
quizzesRemaining: Math.max(0, quizzesRemaining),
|
||||
hasReachedLimit: hasReachedLimit
|
||||
},
|
||||
session: {
|
||||
expiresAt: guestSession.expiresAt,
|
||||
timeRemaining: resetTime,
|
||||
resetTime: resetTime
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Add upgrade prompt if limit reached
|
||||
if (hasReachedLimit) {
|
||||
response.data.upgradePrompt = {
|
||||
message: 'You have reached your quiz limit!',
|
||||
benefits: [
|
||||
'Unlimited quizzes',
|
||||
'Track your progress over time',
|
||||
'Earn achievements and badges',
|
||||
'Bookmark questions for review',
|
||||
'Compete on leaderboards'
|
||||
],
|
||||
callToAction: 'Sign up now to continue learning!'
|
||||
};
|
||||
response.message = 'Quiz limit reached. Sign up to continue!';
|
||||
} else {
|
||||
response.message = `You have ${quizzesRemaining} quiz${quizzesRemaining === 1 ? '' : 'zes'} remaining`;
|
||||
}
|
||||
|
||||
res.status(200).json(response);
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error checking quiz limit:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: 'Error checking quiz limit',
|
||||
error: error.message
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* @desc Convert guest session to registered user account
|
||||
* @route POST /api/guest/convert
|
||||
* @access Protected (Guest Token Required)
|
||||
*/
|
||||
exports.convertGuestToUser = async (req, res) => {
|
||||
const transaction = await sequelize.transaction();
|
||||
|
||||
try {
|
||||
const { username, email, password } = req.body;
|
||||
const guestSession = req.guestSession; // Attached by middleware
|
||||
|
||||
// Validate required fields
|
||||
if (!username || !email || !password) {
|
||||
await transaction.rollback();
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: 'Username, email, and password are required'
|
||||
});
|
||||
}
|
||||
|
||||
// Validate username length
|
||||
if (username.length < 3 || username.length > 50) {
|
||||
await transaction.rollback();
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: 'Username must be between 3 and 50 characters'
|
||||
});
|
||||
}
|
||||
|
||||
// Validate email format
|
||||
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
if (!emailRegex.test(email)) {
|
||||
await transaction.rollback();
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: 'Invalid email format'
|
||||
});
|
||||
}
|
||||
|
||||
// Validate password strength
|
||||
if (password.length < 8) {
|
||||
await transaction.rollback();
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: 'Password must be at least 8 characters long'
|
||||
});
|
||||
}
|
||||
|
||||
// Check if email already exists
|
||||
const existingEmail = await User.findOne({
|
||||
where: { email: email.toLowerCase() },
|
||||
transaction
|
||||
});
|
||||
|
||||
if (existingEmail) {
|
||||
await transaction.rollback();
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: 'Email already registered'
|
||||
});
|
||||
}
|
||||
|
||||
// Check if username already exists
|
||||
const existingUsername = await User.findOne({
|
||||
where: { username },
|
||||
transaction
|
||||
});
|
||||
|
||||
if (existingUsername) {
|
||||
await transaction.rollback();
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: 'Username already taken'
|
||||
});
|
||||
}
|
||||
|
||||
// Create new user account (password will be hashed by User model hook)
|
||||
const user = await User.create({
|
||||
username,
|
||||
email: email.toLowerCase(),
|
||||
password,
|
||||
role: 'user'
|
||||
}, { transaction });
|
||||
|
||||
// Migrate quiz sessions from guest to user
|
||||
const migratedSessions = await QuizSession.update(
|
||||
{
|
||||
userId: user.id,
|
||||
guestSessionId: null
|
||||
},
|
||||
{
|
||||
where: { guestSessionId: guestSession.id },
|
||||
transaction
|
||||
}
|
||||
);
|
||||
|
||||
// Mark guest session as converted
|
||||
await guestSession.update({
|
||||
isConverted: true,
|
||||
convertedUserId: user.id
|
||||
}, { transaction });
|
||||
|
||||
// Recalculate user stats from migrated sessions
|
||||
const quizSessions = await QuizSession.findAll({
|
||||
where: {
|
||||
userId: user.id,
|
||||
status: 'completed'
|
||||
},
|
||||
transaction
|
||||
});
|
||||
|
||||
let totalQuizzes = quizSessions.length;
|
||||
let quizzesPassed = 0;
|
||||
let totalQuestionsAnswered = 0;
|
||||
let correctAnswers = 0;
|
||||
|
||||
quizSessions.forEach(session => {
|
||||
if (session.isPassed) quizzesPassed++;
|
||||
totalQuestionsAnswered += session.questionsAnswered || 0;
|
||||
correctAnswers += session.correctAnswers || 0;
|
||||
});
|
||||
|
||||
// Update user stats
|
||||
await user.update({
|
||||
totalQuizzes,
|
||||
quizzesPassed,
|
||||
totalQuestionsAnswered,
|
||||
correctAnswers
|
||||
}, { transaction });
|
||||
|
||||
// Commit transaction
|
||||
await transaction.commit();
|
||||
|
||||
// Generate JWT token for the new user
|
||||
const token = jwt.sign(
|
||||
{
|
||||
userId: user.id,
|
||||
email: user.email,
|
||||
username: user.username,
|
||||
role: user.role
|
||||
},
|
||||
config.jwt.secret,
|
||||
{ expiresIn: config.jwt.expire }
|
||||
);
|
||||
|
||||
// Return response
|
||||
res.status(201).json({
|
||||
success: true,
|
||||
message: 'Guest account successfully converted to registered user',
|
||||
data: {
|
||||
user: user.toSafeJSON(),
|
||||
token,
|
||||
migration: {
|
||||
quizzesTransferred: migratedSessions[0],
|
||||
stats: {
|
||||
totalQuizzes,
|
||||
quizzesPassed,
|
||||
totalQuestionsAnswered,
|
||||
correctAnswers,
|
||||
accuracy: totalQuestionsAnswered > 0
|
||||
? ((correctAnswers / totalQuestionsAnswered) * 100).toFixed(2)
|
||||
: 0
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
if (!transaction.finished) {
|
||||
await transaction.rollback();
|
||||
}
|
||||
|
||||
console.error('Error converting guest to user:', error);
|
||||
console.error('Error stack:', error.stack);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: 'Error converting guest account',
|
||||
error: error.message,
|
||||
stack: process.env.NODE_ENV === 'development' ? error.stack : undefined
|
||||
});
|
||||
}
|
||||
};
|
||||
1238
controllers/question.controller.js
Normal file
1238
controllers/question.controller.js
Normal file
File diff suppressed because it is too large
Load Diff
1180
controllers/quiz.controller.js
Normal file
1180
controllers/quiz.controller.js
Normal file
File diff suppressed because it is too large
Load Diff
1107
controllers/user.controller.js
Normal file
1107
controllers/user.controller.js
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user