const { Question, Category, sequelize } = require('../models'); const { Op } = require('sequelize'); /** * Get questions by category with filtering and pagination * GET /api/questions/category/:categoryId?difficulty=easy&limit=10&random=true */ exports.getQuestionsByCategory = async (req, res) => { try { const { categoryId } = req.params; const { difficulty, limit = 10, random = 'false' } = req.query; const isAuthenticated = !!req.user; // 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(categoryId)) { return res.status(400).json({ success: false, message: 'Invalid category ID format' }); } // Check if category exists and is active const category = await Category.findByPk(categoryId); if (!category) { return res.status(404).json({ success: false, message: 'Category not found' }); } if (!category.isActive) { return res.status(404).json({ success: false, message: 'Category is not available' }); } // Check guest access if (!isAuthenticated && !category.guestAccessible) { return res.status(403).json({ success: false, message: 'This category requires authentication. Please login or register to access these questions.' }); } // Build query conditions const whereConditions = { categoryId: categoryId, isActive: true }; // Filter by difficulty if provided if (difficulty && ['easy', 'medium', 'hard'].includes(difficulty.toLowerCase())) { whereConditions.difficulty = difficulty.toLowerCase(); } // Validate and parse limit const questionLimit = Math.min(Math.max(parseInt(limit, 10) || 10, 1), 50); // Build query options const queryOptions = { where: whereConditions, attributes: [ 'id', 'questionText', 'questionType', 'options', 'difficulty', 'points', 'timesAttempted', 'timesCorrect', 'explanation', 'tags', 'createdAt' ], include: [ { model: Category, as: 'category', attributes: ['id', 'name', 'slug', 'icon', 'color'] } ], limit: questionLimit }; // Random selection or default ordering if (random === 'true') { queryOptions.order = sequelize.random(); } else { queryOptions.order = [['createdAt', 'ASC']]; } // Execute query const questions = await Question.findAll(queryOptions); // Calculate accuracy for each question const questionsWithAccuracy = questions.map(question => { const questionData = question.toJSON(); questionData.accuracy = question.timesAttempted > 0 ? Math.round((question.timesCorrect / question.timesAttempted) * 100) : 0; // Remove sensitive data (correct_answer not included in attributes, but double-check) delete questionData.correctAnswer; return questionData; }); // Get total count for the category (with filters) const totalCount = await Question.count({ where: whereConditions }); res.status(200).json({ success: true, count: questionsWithAccuracy.length, total: totalCount, category: { id: category.id, name: category.name, slug: category.slug, icon: category.icon, color: category.color }, filters: { difficulty: difficulty || 'all', limit: questionLimit, random: random === 'true' }, data: questionsWithAccuracy, message: isAuthenticated ? `Retrieved ${questionsWithAccuracy.length} question(s) from ${category.name}` : `Retrieved ${questionsWithAccuracy.length} guest-accessible question(s) from ${category.name}` }); } catch (error) { console.error('Error in getQuestionsByCategory:', error); res.status(500).json({ success: false, message: 'An error occurred while retrieving questions', error: process.env.NODE_ENV === 'development' ? error.message : undefined }); } }; /** * Get single question by ID * GET /api/questions/:id */ exports.getQuestionById = async (req, res) => { try { const { id } = req.params; const isAuthenticated = !!req.user; // 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(id)) { return res.status(400).json({ success: false, message: 'Invalid question ID format' }); } // Query question with category info const question = await Question.findOne({ where: { id: id, isActive: true }, attributes: [ 'id', 'questionText', 'questionType', 'options', 'difficulty', 'points', 'timesAttempted', 'timesCorrect', 'explanation', 'tags', 'keywords', 'createdAt', 'updatedAt' ], include: [ { model: Category, as: 'category', attributes: ['id', 'name', 'slug', 'icon', 'color', 'guestAccessible', 'isActive'] } ] }); // Check if question exists if (!question) { return res.status(404).json({ success: false, message: 'Question not found' }); } // Check if category is active if (!question.category || !question.category.isActive) { return res.status(404).json({ success: false, message: 'Question category is not available' }); } // Check guest access to category if (!isAuthenticated && !question.category.guestAccessible) { return res.status(403).json({ success: false, message: 'This question requires authentication. Please login or register to access it.' }); } // Convert to JSON and add calculated fields const questionData = question.toJSON(); // Calculate accuracy questionData.accuracy = question.timesAttempted > 0 ? Math.round((question.timesCorrect / question.timesAttempted) * 100) : 0; // Add attempt statistics questionData.statistics = { timesAttempted: question.timesAttempted, timesCorrect: question.timesCorrect, accuracy: questionData.accuracy }; // Remove sensitive data - correctAnswer should not be in attributes, but double-check delete questionData.correctAnswer; delete questionData.correct_answer; // Clean up category object (remove isActive from response) if (questionData.category) { delete questionData.category.isActive; } res.status(200).json({ success: true, data: questionData, message: isAuthenticated ? 'Question retrieved successfully' : 'Guest-accessible question retrieved successfully' }); } catch (error) { console.error('Error in getQuestionById:', error); res.status(500).json({ success: false, message: 'An error occurred while retrieving the question', error: process.env.NODE_ENV === 'development' ? error.message : undefined }); } }; /** * Search questions using full-text search * GET /api/questions/search?q=javascript&category=uuid&difficulty=easy&limit=20 */ exports.searchQuestions = async (req, res) => { try { const { q, category, difficulty, limit = 20, page = 1 } = req.query; const isAuthenticated = !!req.user; // Validate search query if (!q || q.trim().length === 0) { return res.status(400).json({ success: false, message: 'Search query is required' }); } const searchTerm = q.trim(); // Validate and parse pagination const questionLimit = Math.min(Math.max(parseInt(limit, 10) || 20, 1), 100); const pageNumber = Math.max(parseInt(page, 10) || 1, 1); const offset = (pageNumber - 1) * questionLimit; // Build where conditions const whereConditions = { isActive: true }; // Add difficulty filter if provided if (difficulty && ['easy', 'medium', 'hard'].includes(difficulty.toLowerCase())) { whereConditions.difficulty = difficulty.toLowerCase(); } // Add category filter if provided if (category) { 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(category)) { return res.status(400).json({ success: false, message: 'Invalid category ID format' }); } whereConditions.categoryId = category; } // Build category include conditions const categoryWhere = { isActive: true }; // Filter by guest accessibility if not authenticated if (!isAuthenticated) { categoryWhere.guestAccessible = true; } // Use MySQL full-text search with MATCH AGAINST // Note: Full-text index exists on question_text and explanation columns const searchQuery = ` SELECT q.id, q.question_text, q.question_type, q.options, q.difficulty, q.points, q.times_attempted, q.times_correct, q.explanation, q.tags, q.created_at, c.id as category_id, c.name as category_name, c.slug as category_slug, c.icon as category_icon, c.color as category_color, c.guest_accessible as category_guest_accessible, MATCH(q.question_text, q.explanation) AGAINST(:searchTerm IN NATURAL LANGUAGE MODE) as relevance FROM questions q INNER JOIN categories c ON q.category_id = c.id WHERE q.is_active = true AND c.is_active = true ${!isAuthenticated ? 'AND c.guest_accessible = true' : ''} ${difficulty ? 'AND q.difficulty = :difficulty' : ''} ${category ? 'AND q.category_id = :categoryId' : ''} AND MATCH(q.question_text, q.explanation) AGAINST(:searchTerm IN NATURAL LANGUAGE MODE) ORDER BY relevance DESC, q.created_at DESC LIMIT :limit OFFSET :offset `; const countQuery = ` SELECT COUNT(*) as total FROM questions q INNER JOIN categories c ON q.category_id = c.id WHERE q.is_active = true AND c.is_active = true ${!isAuthenticated ? 'AND c.guest_accessible = true' : ''} ${difficulty ? 'AND q.difficulty = :difficulty' : ''} ${category ? 'AND q.category_id = :categoryId' : ''} AND MATCH(q.question_text, q.explanation) AGAINST(:searchTerm IN NATURAL LANGUAGE MODE) `; // Execute search query const replacements = { searchTerm, limit: questionLimit, offset: offset, ...(difficulty && { difficulty: difficulty.toLowerCase() }), ...(category && { categoryId: category }) }; const results = await sequelize.query(searchQuery, { replacements, type: sequelize.QueryTypes.SELECT }); const countResults = await sequelize.query(countQuery, { replacements: { searchTerm, ...(difficulty && { difficulty: difficulty.toLowerCase() }), ...(category && { categoryId: category }) }, type: sequelize.QueryTypes.SELECT }); // Format results const questions = Array.isArray(results) ? results : []; const formattedQuestions = questions.map(q => { // Calculate accuracy const accuracy = q.times_attempted > 0 ? Math.round((q.times_correct / q.times_attempted) * 100) : 0; // Parse JSON fields let options = null; let tags = null; try { options = q.options ? JSON.parse(q.options) : null; } catch (e) { options = q.options; } try { tags = q.tags ? JSON.parse(q.tags) : null; } catch (e) { tags = q.tags; } // Highlight search term in question text (basic implementation) const highlightedText = highlightSearchTerm(q.question_text, searchTerm); return { id: q.id, questionText: q.question_text, highlightedText, questionType: q.question_type, options, difficulty: q.difficulty, points: q.points, accuracy, explanation: q.explanation, tags, relevance: q.relevance, createdAt: q.created_at, category: { id: q.category_id, name: q.category_name, slug: q.category_slug, icon: q.category_icon, color: q.category_color } }; }); const totalResults = countResults && countResults.length > 0 ? countResults[0].total : 0; const totalPages = Math.ceil(totalResults / questionLimit); res.status(200).json({ success: true, count: formattedQuestions.length, total: totalResults, page: pageNumber, totalPages, limit: questionLimit, query: searchTerm, filters: { category: category || null, difficulty: difficulty || null }, data: formattedQuestions, message: isAuthenticated ? `Found ${totalResults} question(s) matching "${searchTerm}"` : `Found ${totalResults} guest-accessible question(s) matching "${searchTerm}"` }); } catch (error) { console.error('Error in searchQuestions:', error); // Check if it's a full-text search error if (error.message && error.message.includes('FULLTEXT')) { return res.status(500).json({ success: false, message: 'Full-text search is not available. Please ensure the database has full-text indexes configured.', error: process.env.NODE_ENV === 'development' ? error.message : undefined }); } res.status(500).json({ success: false, message: 'An error occurred while searching questions', error: process.env.NODE_ENV === 'development' ? error.message : undefined }); } }; /** * Helper function to highlight search terms in text */ function highlightSearchTerm(text, searchTerm) { if (!text || !searchTerm) return text; // Split search term into words const words = searchTerm.split(/\s+/).filter(w => w.length > 2); if (words.length === 0) return text; let highlightedText = text; words.forEach(word => { const regex = new RegExp(`(${word})`, 'gi'); highlightedText = highlightedText.replace(regex, '**$1**'); }); return highlightedText; } /** * Create a new question (Admin only) * POST /api/admin/questions */ exports.createQuestion = async (req, res) => { try { const { questionText, questionType, options, correctAnswer, difficulty, points, explanation, categoryId, tags, keywords } = req.body; // Validate required fields if (!questionText || questionText.trim().length === 0) { return res.status(400).json({ success: false, message: 'Question text is required' }); } if (!questionType) { return res.status(400).json({ success: false, message: 'Question type is required' }); } // Validate question type const validTypes = ['multiple', 'trueFalse', 'written']; if (!validTypes.includes(questionType)) { return res.status(400).json({ success: false, message: `Invalid question type. Must be one of: ${validTypes.join(', ')}` }); } if (!correctAnswer) { return res.status(400).json({ success: false, message: 'Correct answer is required' }); } if (!difficulty) { return res.status(400).json({ success: false, message: 'Difficulty level is required' }); } // Validate difficulty const validDifficulties = ['easy', 'medium', 'hard']; if (!validDifficulties.includes(difficulty.toLowerCase())) { return res.status(400).json({ success: false, message: `Invalid difficulty. Must be one of: ${validDifficulties.join(', ')}` }); } if (!categoryId) { return res.status(400).json({ success: false, message: 'Category ID is required' }); } // Validate category UUID 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(categoryId)) { return res.status(400).json({ success: false, message: 'Invalid category ID format' }); } // Check if category exists and is active const category = await Category.findByPk(categoryId); if (!category) { return res.status(404).json({ success: false, message: 'Category not found' }); } if (!category.isActive) { return res.status(400).json({ success: false, message: 'Cannot add question to inactive category' }); } // Validate options for multiple choice questions if (questionType === 'multiple') { if (!options || !Array.isArray(options)) { return res.status(400).json({ success: false, message: 'Options array is required for multiple choice questions' }); } if (options.length < 2) { return res.status(400).json({ success: false, message: 'Multiple choice questions must have at least 2 options' }); } if (options.length > 6) { return res.status(400).json({ success: false, message: 'Multiple choice questions can have at most 6 options' }); } // Validate each option has required fields for (let i = 0; i < options.length; i++) { const option = options[i]; if (!option.id || !option.text) { return res.status(400).json({ success: false, message: `Option ${i + 1} must have 'id' and 'text' fields` }); } } // Validate correctAnswer is one of the option IDs const optionIds = options.map(opt => opt.id); if (!optionIds.includes(correctAnswer)) { return res.status(400).json({ success: false, message: 'Correct answer must match one of the option IDs' }); } } // Validate trueFalse questions if (questionType === 'trueFalse') { if (correctAnswer !== 'true' && correctAnswer !== 'false') { return res.status(400).json({ success: false, message: 'True/False questions must have correctAnswer as "true" or "false"' }); } } // Calculate points based on difficulty if not provided let questionPoints = points; if (!questionPoints) { switch (difficulty.toLowerCase()) { case 'easy': questionPoints = 5; break; case 'medium': questionPoints = 10; break; case 'hard': questionPoints = 15; break; default: questionPoints = 10; } } // Validate tags if provided if (tags && !Array.isArray(tags)) { return res.status(400).json({ success: false, message: 'Tags must be an array' }); } // Validate keywords if provided if (keywords && !Array.isArray(keywords)) { return res.status(400).json({ success: false, message: 'Keywords must be an array' }); } // Create the question const question = await Question.create({ questionText: questionText.trim(), questionType, options: questionType === 'multiple' ? options : null, correctAnswer, difficulty: difficulty.toLowerCase(), points: questionPoints, explanation: explanation ? explanation.trim() : null, categoryId, tags: tags || null, keywords: keywords || null, createdBy: req.user.userId, isActive: true, timesAttempted: 0, timesCorrect: 0 }); // Increment category question count await category.increment('questionCount'); // Reload question with category info await question.reload({ include: [{ model: Category, as: 'category', attributes: ['id', 'name', 'slug', 'icon', 'color'] }] }); res.status(201).json({ success: true, data: { 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, category: question.category, createdAt: question.createdAt }, message: 'Question created successfully' }); } catch (error) { console.error('Error in createQuestion:', error); res.status(500).json({ success: false, message: 'An error occurred while creating the question', error: process.env.NODE_ENV === 'development' ? error.message : undefined }); } }; /** * Update a question (Admin only) * PUT /api/admin/questions/:id */ exports.updateQuestion = async (req, res) => { try { const { id } = req.params; const { questionText, questionType, options, correctAnswer, difficulty, points, explanation, categoryId, tags, keywords, isActive } = req.body; // 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(id)) { return res.status(400).json({ success: false, message: 'Invalid question ID format' }); } // Find existing question const question = await Question.findByPk(id, { include: [{ model: Category, as: 'category', attributes: ['id', 'name', 'slug', 'icon', 'color', 'isActive'] }] }); if (!question) { return res.status(404).json({ success: false, message: 'Question not found' }); } // Prepare update object (only include provided fields) const updates = {}; // Validate and update question text if (questionText !== undefined) { if (!questionText.trim()) { return res.status(400).json({ success: false, message: 'Question text cannot be empty' }); } updates.questionText = questionText.trim(); } // Validate and update question type if (questionType !== undefined) { const validTypes = ['multiple', 'trueFalse', 'written']; if (!validTypes.includes(questionType)) { return res.status(400).json({ success: false, message: 'Invalid question type. Must be: multiple, trueFalse, or written' }); } updates.questionType = questionType; } // Determine effective question type for validation const effectiveType = questionType || question.questionType; // Validate options for multiple choice if (effectiveType === 'multiple') { if (options !== undefined) { if (!Array.isArray(options)) { return res.status(400).json({ success: false, message: 'Options must be an array for multiple choice questions' }); } if (options.length < 2 || options.length > 6) { return res.status(400).json({ success: false, message: 'Multiple choice questions must have between 2 and 6 options' }); } // Validate option structure for (const option of options) { if (!option.id || !option.text) { return res.status(400).json({ success: false, message: 'Each option must have an id and text field' }); } } updates.options = options; } // Validate correct answer matches options if (correctAnswer !== undefined) { const effectiveOptions = options || question.options; const optionIds = effectiveOptions.map(opt => opt.id); if (!optionIds.includes(correctAnswer)) { return res.status(400).json({ success: false, message: 'Correct answer must match one of the option IDs' }); } updates.correctAnswer = correctAnswer; } } // Validate trueFalse correct answer if (effectiveType === 'trueFalse' && correctAnswer !== undefined) { if (correctAnswer !== 'true' && correctAnswer !== 'false') { return res.status(400).json({ success: false, message: 'True/False questions must have "true" or "false" as correct answer' }); } updates.correctAnswer = correctAnswer; } // Validate and update difficulty if (difficulty !== undefined) { const validDifficulties = ['easy', 'medium', 'hard']; if (!validDifficulties.includes(difficulty.toLowerCase())) { return res.status(400).json({ success: false, message: 'Invalid difficulty. Must be: easy, medium, or hard' }); } updates.difficulty = difficulty.toLowerCase(); // Auto-calculate points if difficulty changes and points not provided if (points === undefined) { updates.points = difficulty.toLowerCase() === 'easy' ? 5 : difficulty.toLowerCase() === 'medium' ? 10 : 15; } } // Update points if provided if (points !== undefined) { if (typeof points !== 'number' || points <= 0) { return res.status(400).json({ success: false, message: 'Points must be a positive number' }); } updates.points = points; } // Update category if provided if (categoryId !== undefined) { if (!uuidRegex.test(categoryId)) { return res.status(400).json({ success: false, message: 'Invalid category ID format' }); } const newCategory = await Category.findByPk(categoryId); if (!newCategory) { return res.status(404).json({ success: false, message: 'Category not found' }); } if (!newCategory.isActive) { return res.status(400).json({ success: false, message: 'Cannot assign question to inactive category' }); } // Update category counts if category changed if (categoryId !== question.categoryId) { await question.category.decrement('questionCount'); await newCategory.increment('questionCount'); } updates.categoryId = categoryId; } // Update other fields if (explanation !== undefined) { updates.explanation = explanation?.trim() || null; } if (tags !== undefined) { updates.tags = tags || null; } if (keywords !== undefined) { updates.keywords = keywords || null; } if (isActive !== undefined) { updates.isActive = isActive; } // Perform update await question.update(updates); // Reload with category await question.reload({ include: [{ model: Category, as: 'category', attributes: ['id', 'name', 'slug', 'icon', 'color'] }] }); // Return updated question (exclude correctAnswer) const responseData = question.toJSON(); delete responseData.correctAnswer; delete responseData.createdBy; res.status(200).json({ success: true, data: responseData, message: 'Question updated successfully' }); } catch (error) { console.error('Error updating question:', error); res.status(500).json({ success: false, message: 'An error occurred while updating the question', error: process.env.NODE_ENV === 'development' ? error.message : undefined }); } }; /** * Delete a question (Admin only - soft delete) * DELETE /api/admin/questions/:id */ exports.deleteQuestion = async (req, res) => { try { const { id } = req.params; // 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(id)) { return res.status(400).json({ success: false, message: 'Invalid question ID format' }); } // Find question const question = await Question.findByPk(id, { include: [{ model: Category, as: 'category', attributes: ['id', 'name', 'slug'] }] }); if (!question) { return res.status(404).json({ success: false, message: 'Question not found' }); } // Check if already deleted if (!question.isActive) { return res.status(400).json({ success: false, message: 'Question is already deleted' }); } // Soft delete - set isActive to false await question.update({ isActive: false }); // Decrement category question count if (question.category) { await question.category.decrement('questionCount'); } res.status(200).json({ success: true, data: { id: question.id, questionText: question.questionText, category: { id: question.category.id, name: question.category.name } }, message: 'Question deleted successfully' }); } catch (error) { console.error('Error deleting question:', error); res.status(500).json({ success: false, message: 'An error occurred while deleting the question', error: process.env.NODE_ENV === 'development' ? error.message : undefined }); } };