const { User, QuizSession, Category, Question, UserBookmark, 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 }); } }; /** * Add bookmark for a question * POST /api/users/:userId/bookmarks */ exports.addBookmark = async (req, res) => { try { const { userId } = req.params; const requestUserId = req.user.userId; const { questionId } = req.body; // Validate userId 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); if (!user) { return res.status(404).json({ success: false, message: 'User not found' }); } // Authorization check - users can only manage their own bookmarks if (userId !== requestUserId) { return res.status(403).json({ success: false, message: 'You are not authorized to add bookmarks for this user' }); } // Validate questionId is provided if (!questionId) { return res.status(400).json({ success: false, message: 'Question ID is required' }); } // Validate questionId UUID format if (!uuidRegex.test(questionId)) { return res.status(400).json({ success: false, message: 'Invalid question ID format' }); } // Check if question exists and is active const question = await Question.findOne({ where: { id: questionId, isActive: true }, include: [{ model: Category, as: 'category', attributes: ['id', 'name', 'slug'] }] }); if (!question) { return res.status(404).json({ success: false, message: 'Question not found or not available' }); } // Check if already bookmarked const existingBookmark = await UserBookmark.findOne({ where: { userId, questionId } }); if (existingBookmark) { return res.status(409).json({ success: false, message: 'Question is already bookmarked' }); } // Create bookmark const bookmark = await UserBookmark.create({ userId, questionId }); // Return success with bookmark details return res.status(201).json({ success: true, data: { id: bookmark.id, questionId: bookmark.questionId, question: { id: question.id, questionText: question.questionText, difficulty: question.difficulty, category: question.category }, bookmarkedAt: bookmark.createdAt }, message: 'Question bookmarked successfully' }); } catch (error) { console.error('Error adding bookmark:', error); return res.status(500).json({ success: false, message: 'An error occurred while adding bookmark', error: process.env.NODE_ENV === 'development' ? error.message : undefined }); } }; /** * Remove bookmark for a question * DELETE /api/users/:userId/bookmarks/:questionId */ exports.removeBookmark = async (req, res) => { try { const { userId, questionId } = req.params; const requestUserId = req.user.userId; // Validate userId 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' }); } // Validate questionId UUID format if (!uuidRegex.test(questionId)) { return res.status(400).json({ success: false, message: 'Invalid question ID format' }); } // Check if user exists const user = await User.findByPk(userId); if (!user) { return res.status(404).json({ success: false, message: 'User not found' }); } // Authorization check - users can only manage their own bookmarks if (userId !== requestUserId) { return res.status(403).json({ success: false, message: 'You are not authorized to remove bookmarks for this user' }); } // Find the bookmark const bookmark = await UserBookmark.findOne({ where: { userId, questionId } }); if (!bookmark) { return res.status(404).json({ success: false, message: 'Bookmark not found' }); } // Delete the bookmark await bookmark.destroy(); return res.status(200).json({ success: true, data: { questionId }, message: 'Bookmark removed successfully' }); } catch (error) { console.error('Error removing bookmark:', error); return res.status(500).json({ success: false, message: 'An error occurred while removing bookmark', error: process.env.NODE_ENV === 'development' ? error.message : undefined }); } }; /** * Get user bookmarks with pagination and filtering * @route GET /api/users/:userId/bookmarks */ exports.getUserBookmarks = async (req, res) => { try { const { userId } = req.params; const requestUserId = req.user.userId; // 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", }); } // Check if user exists const user = await User.findByPk(userId); if (!user) { return res.status(404).json({ success: false, message: "User not found", }); } // Authorization: users can only view their own bookmarks if (userId !== requestUserId) { return res.status(403).json({ success: false, message: "You are not authorized to view these bookmarks", }); } // Pagination parameters const page = Math.max(parseInt(req.query.page) || 1, 1); const limit = Math.min(Math.max(parseInt(req.query.limit) || 10, 1), 50); const offset = (page - 1) * limit; // Category filter (optional) let categoryId = req.query.category; if (categoryId) { if (!uuidRegex.test(categoryId)) { return res.status(400).json({ success: false, message: "Invalid category ID format", }); } } // Difficulty filter (optional) const difficulty = req.query.difficulty; if (difficulty && !["easy", "medium", "hard"].includes(difficulty)) { return res.status(400).json({ success: false, message: "Invalid difficulty value. Must be: easy, medium, or hard", }); } // Sort options const sortBy = req.query.sortBy || "date"; // 'date' or 'difficulty' const sortOrder = (req.query.sortOrder || "desc").toLowerCase(); if (!["asc", "desc"].includes(sortOrder)) { return res.status(400).json({ success: false, message: "Invalid sort order. Must be: asc or desc", }); } // Build query conditions const whereConditions = { userId: userId, }; const questionWhereConditions = { isActive: true, }; if (categoryId) { questionWhereConditions.categoryId = categoryId; } if (difficulty) { questionWhereConditions.difficulty = difficulty; } // Determine sort order let orderClause; if (sortBy === "difficulty") { // Custom order for difficulty: easy, medium, hard const difficultyOrder = sortOrder === "asc" ? ["easy", "medium", "hard"] : ["hard", "medium", "easy"]; orderClause = [ [sequelize.literal(`FIELD(Question.difficulty, '${difficultyOrder.join("','")}')`)], ["createdAt", "DESC"] ]; } else { // Sort by bookmark date (createdAt) orderClause = [["createdAt", sortOrder.toUpperCase()]]; } // Get total count with filters const totalCount = await UserBookmark.count({ where: whereConditions, include: [ { model: Question, as: "Question", where: questionWhereConditions, required: true, }, ], }); // Get bookmarks with pagination const bookmarks = await UserBookmark.findAll({ where: whereConditions, include: [ { model: Question, as: "Question", where: questionWhereConditions, attributes: [ "id", "questionText", "questionType", "options", "difficulty", "points", "explanation", "tags", "keywords", "timesAttempted", "timesCorrect", ], include: [ { model: Category, as: "category", attributes: ["id", "name", "slug", "icon", "color"], }, ], }, ], order: orderClause, limit: limit, offset: offset, }); // Format response const formattedBookmarks = bookmarks.map((bookmark) => { const question = bookmark.Question; const accuracy = question.timesAttempted > 0 ? Math.round((question.timesCorrect / question.timesAttempted) * 100) : 0; return { bookmarkId: bookmark.id, bookmarkedAt: bookmark.createdAt, notes: bookmark.notes, question: { id: question.id, questionText: question.questionText, questionType: question.questionType, options: question.options, difficulty: question.difficulty, points: question.points, explanation: question.explanation, tags: question.tags, keywords: question.keywords, statistics: { timesAttempted: question.timesAttempted, timesCorrect: question.timesCorrect, accuracy: accuracy, }, category: question.category, }, }; }); // Calculate pagination metadata const totalPages = Math.ceil(totalCount / limit); return res.status(200).json({ success: true, data: { bookmarks: formattedBookmarks, pagination: { currentPage: page, totalPages: totalPages, totalItems: totalCount, itemsPerPage: limit, hasNextPage: page < totalPages, hasPreviousPage: page > 1, }, filters: { category: categoryId || null, difficulty: difficulty || null, }, sorting: { sortBy: sortBy, sortOrder: sortOrder, }, }, message: "User bookmarks retrieved successfully", }); } catch (error) { console.error("Error getting user bookmarks:", error); return res.status(500).json({ success: false, message: "Internal server error", }); } };