Files
Tasks/backend/controllers/user.controller.js
2025-11-12 00:49:22 +02:00

700 lines
20 KiB
JavaScript

const { User, QuizSession, Category, sequelize } = require('../models');
const { Op } = require('sequelize');
/**
* Get user dashboard with stats, recent sessions, and category performance
* GET /api/users/:userId/dashboard
*/
exports.getUserDashboard = async (req, res) => {
try {
const { userId } = req.params;
const requestUserId = req.user.userId;
// Validate UUID format first
const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
if (!uuidRegex.test(userId)) {
return res.status(400).json({
success: false,
message: 'Invalid user ID format'
});
}
// Check if user exists first (before authorization)
const user = await User.findByPk(userId, {
attributes: [
'id', 'username', 'email', 'role',
'totalQuizzes', 'quizzesPassed', 'totalQuestionsAnswered', 'correctAnswers',
'currentStreak', 'longestStreak', 'lastQuizDate',
'profileImage', 'createdAt'
]
});
if (!user) {
return res.status(404).json({
success: false,
message: 'User not found'
});
}
// Authorization: Users can only access their own dashboard
if (userId !== requestUserId) {
return res.status(403).json({
success: false,
message: 'You do not have permission to access this dashboard'
});
}
// Calculate overall accuracy
const overallAccuracy = user.totalQuestionsAnswered > 0
? Math.round((user.correctAnswers / user.totalQuestionsAnswered) * 100)
: 0;
// Calculate pass rate
const passRate = user.totalQuizzes > 0
? Math.round((user.quizzesPassed / user.totalQuizzes) * 100)
: 0;
// Get recent quiz sessions (last 10 completed)
const recentSessions = await QuizSession.findAll({
where: {
userId,
status: {
[Op.in]: ['completed', 'timeout']
}
},
include: [
{
model: Category,
as: 'category',
attributes: ['id', 'name', 'slug', 'icon', 'color']
}
],
attributes: [
'id', 'categoryId', 'quizType', 'difficulty', 'status',
'score', 'totalPoints', 'isPassed',
'questionsAnswered', 'correctAnswers', 'timeSpent',
'startedAt', 'completedAt'
],
order: [['completedAt', 'DESC']],
limit: 10
});
// Format recent sessions
const formattedRecentSessions = recentSessions.map(session => {
const earned = parseFloat(session.score) || 0;
const total = parseFloat(session.totalPoints) || 0;
const percentage = total > 0 ? Math.round((earned / total) * 100) : 0;
return {
id: session.id,
category: {
id: session.category.id,
name: session.category.name,
slug: session.category.slug,
icon: session.category.icon,
color: session.category.color
},
quizType: session.quizType,
difficulty: session.difficulty,
status: session.status,
score: {
earned,
total,
percentage
},
isPassed: session.isPassed,
questionsAnswered: session.questionsAnswered,
correctAnswers: session.correctAnswers,
accuracy: session.questionsAnswered > 0
? Math.round((session.correctAnswers / session.questionsAnswered) * 100)
: 0,
timeSpent: session.timeSpent,
completedAt: session.completedAt
};
});
// Get category-wise performance
const categoryPerformance = await sequelize.query(`
SELECT
c.id,
c.name,
c.slug,
c.icon,
c.color,
COUNT(qs.id) as quizzes_taken,
SUM(CASE WHEN qs.is_passed = 1 THEN 1 ELSE 0 END) as quizzes_passed,
ROUND(AVG((qs.score / NULLIF(qs.total_points, 0)) * 100), 0) as average_score,
SUM(qs.questions_answered) as total_questions,
SUM(qs.correct_answers) as correct_answers,
ROUND(
(SUM(qs.correct_answers) / NULLIF(SUM(qs.questions_answered), 0)) * 100,
0
) as accuracy,
MAX(qs.completed_at) as last_attempt
FROM categories c
INNER JOIN quiz_sessions qs ON c.id = qs.category_id
WHERE qs.user_id = :userId
AND qs.status IN ('completed', 'timeout')
GROUP BY c.id, c.name, c.slug, c.icon, c.color
ORDER BY quizzes_taken DESC, accuracy DESC
`, {
replacements: { userId },
type: sequelize.QueryTypes.SELECT
});
// Format category performance
const formattedCategoryPerformance = categoryPerformance.map(cat => ({
category: {
id: cat.id,
name: cat.name,
slug: cat.slug,
icon: cat.icon,
color: cat.color
},
stats: {
quizzesTaken: parseInt(cat.quizzes_taken) || 0,
quizzesPassed: parseInt(cat.quizzes_passed) || 0,
passRate: cat.quizzes_taken > 0
? Math.round((cat.quizzes_passed / cat.quizzes_taken) * 100)
: 0,
averageScore: parseInt(cat.average_score) || 0,
totalQuestions: parseInt(cat.total_questions) || 0,
correctAnswers: parseInt(cat.correct_answers) || 0,
accuracy: parseInt(cat.accuracy) || 0
},
lastAttempt: cat.last_attempt
}));
// Get activity summary (last 30 days)
const thirtyDaysAgo = new Date();
thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30);
const recentActivity = await QuizSession.findAll({
where: {
userId,
status: {
[Op.in]: ['completed', 'timeout']
},
completedAt: {
[Op.gte]: thirtyDaysAgo
}
},
attributes: [
[sequelize.fn('DATE', sequelize.col('completed_at')), 'date'],
[sequelize.fn('COUNT', sequelize.col('id')), 'quizzes_completed']
],
group: [sequelize.fn('DATE', sequelize.col('completed_at'))],
order: [[sequelize.fn('DATE', sequelize.col('completed_at')), 'DESC']],
raw: true
});
// Calculate streak status
const today = new Date().toDateString();
const lastActive = user.lastQuizDate?.toDateString();
const isActiveToday = lastActive === today;
const yesterday = new Date();
yesterday.setDate(yesterday.getDate() - 1);
const wasActiveYesterday = lastActive === yesterday.toDateString();
let streakStatus = 'inactive';
if (isActiveToday) {
streakStatus = 'active';
} else if (wasActiveYesterday) {
streakStatus = 'at-risk'; // User needs to complete a quiz today to maintain streak
}
// Build response
const response = {
success: true,
data: {
user: {
id: user.id,
username: user.username,
email: user.email,
role: user.role,
profileImage: user.profileImage,
memberSince: user.createdAt
},
stats: {
totalQuizzes: user.totalQuizzes,
quizzesPassed: user.quizzesPassed,
passRate,
totalQuestionsAnswered: user.totalQuestionsAnswered,
correctAnswers: user.correctAnswers,
overallAccuracy,
currentStreak: user.currentStreak,
longestStreak: user.longestStreak,
streakStatus,
lastActiveDate: user.lastQuizDate
},
recentSessions: formattedRecentSessions,
categoryPerformance: formattedCategoryPerformance,
recentActivity: recentActivity.map(activity => ({
date: activity.date,
quizzesCompleted: parseInt(activity.quizzes_completed) || 0
}))
},
message: 'User dashboard retrieved successfully'
};
return res.status(200).json(response);
} catch (error) {
console.error('Error getting user dashboard:', error);
return res.status(500).json({
success: false,
message: 'An error occurred while retrieving user dashboard',
error: process.env.NODE_ENV === 'development' ? error.message : undefined
});
}
};
/**
* Get user quiz history with pagination, filtering, and sorting
* GET /api/users/:userId/history
*/
exports.getQuizHistory = async (req, res) => {
try {
const { userId } = req.params;
const requestUserId = req.user.userId;
// Query parameters
const page = parseInt(req.query.page) || 1;
const limit = Math.min(parseInt(req.query.limit) || 10, 50); // Max 50 per page
const categoryId = req.query.category;
const startDate = req.query.startDate;
const endDate = req.query.endDate;
const sortBy = req.query.sortBy || 'date'; // 'date' or 'score'
const sortOrder = req.query.sortOrder || 'desc'; // 'asc' or 'desc'
const status = req.query.status; // 'completed', 'timeout', 'abandoned'
// Validate UUID format
const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
if (!uuidRegex.test(userId)) {
return res.status(400).json({
success: false,
message: 'Invalid user ID format'
});
}
// Check if user exists
const user = await User.findByPk(userId, {
attributes: ['id', 'username']
});
if (!user) {
return res.status(404).json({
success: false,
message: 'User not found'
});
}
// Authorization: Users can only access their own history
if (userId !== requestUserId) {
return res.status(403).json({
success: false,
message: 'You do not have permission to access this quiz history'
});
}
// Build where clause
const whereClause = {
userId,
status: {
[Op.in]: ['completed', 'timeout', 'abandoned']
}
};
// Filter by category
if (categoryId) {
if (!uuidRegex.test(categoryId)) {
return res.status(400).json({
success: false,
message: 'Invalid category ID format'
});
}
whereClause.categoryId = categoryId;
}
// Filter by status
if (status && ['completed', 'timeout', 'abandoned'].includes(status)) {
whereClause.status = status;
}
// Filter by date range
if (startDate || endDate) {
whereClause.completedAt = {};
if (startDate) {
const start = new Date(startDate);
if (isNaN(start.getTime())) {
return res.status(400).json({
success: false,
message: 'Invalid start date format'
});
}
whereClause.completedAt[Op.gte] = start;
}
if (endDate) {
const end = new Date(endDate);
if (isNaN(end.getTime())) {
return res.status(400).json({
success: false,
message: 'Invalid end date format'
});
}
// Set to end of day
end.setHours(23, 59, 59, 999);
whereClause.completedAt[Op.lte] = end;
}
}
// Determine sort field
let orderField;
if (sortBy === 'score') {
orderField = 'score';
} else {
orderField = 'completedAt';
}
const orderDirection = sortOrder.toUpperCase() === 'ASC' ? 'ASC' : 'DESC';
// Calculate offset
const offset = (page - 1) * limit;
// Get total count for pagination
const totalCount = await QuizSession.count({ where: whereClause });
const totalPages = Math.ceil(totalCount / limit);
// Get quiz sessions
const sessions = await QuizSession.findAll({
where: whereClause,
include: [
{
model: Category,
as: 'category',
attributes: ['id', 'name', 'slug', 'icon', 'color']
}
],
attributes: [
'id',
'categoryId',
'quizType',
'difficulty',
'status',
'score',
'totalPoints',
'isPassed',
'questionsAnswered',
'totalQuestions',
'correctAnswers',
'timeSpent',
'timeLimit',
'startedAt',
'completedAt',
'createdAt'
],
order: [[orderField, orderDirection]],
limit,
offset
});
// Format sessions
const formattedSessions = sessions.map(session => {
const earned = parseFloat(session.score) || 0;
const total = parseFloat(session.totalPoints) || 0;
const percentage = total > 0 ? Math.round((earned / total) * 100) : 0;
return {
id: session.id,
category: session.category ? {
id: session.category.id,
name: session.category.name,
slug: session.category.slug,
icon: session.category.icon,
color: session.category.color
} : null,
quizType: session.quizType,
difficulty: session.difficulty,
status: session.status,
score: {
earned,
total,
percentage
},
isPassed: session.isPassed,
questions: {
answered: session.questionsAnswered,
total: session.totalQuestions,
correct: session.correctAnswers,
accuracy: session.questionsAnswered > 0
? Math.round((session.correctAnswers / session.questionsAnswered) * 100)
: 0
},
time: {
spent: session.timeSpent,
limit: session.timeLimit,
percentage: session.timeLimit > 0
? Math.round((session.timeSpent / session.timeLimit) * 100)
: 0
},
startedAt: session.startedAt,
completedAt: session.completedAt,
createdAt: session.createdAt
};
});
// Build response
const response = {
success: true,
data: {
sessions: formattedSessions,
pagination: {
currentPage: page,
totalPages,
totalItems: totalCount,
itemsPerPage: limit,
hasNextPage: page < totalPages,
hasPreviousPage: page > 1
},
filters: {
category: categoryId || null,
status: status || null,
startDate: startDate || null,
endDate: endDate || null
},
sorting: {
sortBy,
sortOrder
}
},
message: 'Quiz history retrieved successfully'
};
return res.status(200).json(response);
} catch (error) {
console.error('Error getting quiz history:', error);
return res.status(500).json({
success: false,
message: 'An error occurred while retrieving quiz history',
error: process.env.NODE_ENV === 'development' ? error.message : undefined
});
}
};
/**
* Update user profile
* PUT /api/users/:userId
*/
exports.updateUserProfile = async (req, res) => {
const bcrypt = require('bcrypt');
try {
const { userId } = req.params;
const requestUserId = req.user.userId;
const { username, email, currentPassword, newPassword, profileImage } = req.body;
// Validate userId format
const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
if (!uuidRegex.test(userId)) {
return res.status(400).json({
success: false,
message: 'Invalid user ID format'
});
}
// Find user
const user = await User.findByPk(userId);
if (!user) {
return res.status(404).json({
success: false,
message: 'User not found'
});
}
// Authorization check - users can only update their own profile
if (userId !== requestUserId) {
return res.status(403).json({
success: false,
message: 'You are not authorized to update this profile'
});
}
// Track what fields are being updated
const updates = {};
const changedFields = [];
// Update username if provided
if (username !== undefined && username !== user.username) {
// Validate username
if (typeof username !== 'string' || username.trim().length === 0) {
return res.status(400).json({
success: false,
message: 'Username cannot be empty'
});
}
if (username.length < 3 || username.length > 50) {
return res.status(400).json({
success: false,
message: 'Username must be between 3 and 50 characters'
});
}
if (!/^[a-zA-Z0-9]+$/.test(username)) {
return res.status(400).json({
success: false,
message: 'Username must contain only letters and numbers'
});
}
// Check if username already exists
const existingUser = await User.findOne({ where: { username } });
if (existingUser && existingUser.id !== userId) {
return res.status(409).json({
success: false,
message: 'Username already exists'
});
}
updates.username = username;
changedFields.push('username');
}
// Update email if provided
if (email !== undefined && email !== user.email) {
// Validate email format
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!emailRegex.test(email)) {
return res.status(400).json({
success: false,
message: 'Invalid email format'
});
}
// Check if email already exists
const existingUser = await User.findOne({ where: { email } });
if (existingUser && existingUser.id !== userId) {
return res.status(409).json({
success: false,
message: 'Email already exists'
});
}
updates.email = email;
changedFields.push('email');
}
// Update password if provided
if (newPassword !== undefined) {
// Verify current password is provided
if (!currentPassword) {
return res.status(400).json({
success: false,
message: 'Current password is required to change password'
});
}
// Validate new password length first (before checking current password)
if (newPassword.length < 6) {
return res.status(400).json({
success: false,
message: 'New password must be at least 6 characters'
});
}
// Verify current password is correct
const isPasswordValid = await bcrypt.compare(currentPassword, user.password);
if (!isPasswordValid) {
return res.status(401).json({
success: false,
message: 'Current password is incorrect'
});
}
// Set new password (will be hashed by beforeUpdate hook in model)
updates.password = newPassword;
changedFields.push('password');
}
// Update profile image if provided
if (profileImage !== undefined && profileImage !== user.profileImage) {
// Allow null or empty string to remove profile image
if (profileImage === null || profileImage === '') {
updates.profileImage = null;
changedFields.push('profileImage');
} else if (typeof profileImage === 'string') {
// Basic URL validation (can be enhanced)
if (profileImage.length > 255) {
return res.status(400).json({
success: false,
message: 'Profile image URL is too long (max 255 characters)'
});
}
updates.profileImage = profileImage;
changedFields.push('profileImage');
} else {
return res.status(400).json({
success: false,
message: 'Profile image must be a string URL'
});
}
}
// Check if any fields were provided for update
if (Object.keys(updates).length === 0) {
return res.status(400).json({
success: false,
message: 'No fields provided for update'
});
}
// Update user
await user.update(updates);
// Fetch updated user (exclude password)
const updatedUser = await User.findByPk(userId, {
attributes: { exclude: ['password'] }
});
return res.status(200).json({
success: true,
data: {
user: updatedUser,
changedFields
},
message: 'Profile updated successfully'
});
} catch (error) {
console.error('Error updating user profile:', error);
// Handle Sequelize validation errors
if (error.name === 'SequelizeValidationError') {
return res.status(400).json({
success: false,
message: error.errors[0]?.message || 'Validation error'
});
}
// Handle unique constraint errors
if (error.name === 'SequelizeUniqueConstraintError') {
const field = error.errors[0]?.path;
return res.status(409).json({
success: false,
message: `${field} already exists`
});
}
return res.status(500).json({
success: false,
message: 'An error occurred while updating profile',
error: process.env.NODE_ENV === 'development' ? error.message : undefined
});
}
};