1108 lines
31 KiB
JavaScript
1108 lines
31 KiB
JavaScript
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",
|
|
});
|
|
}
|
|
};
|