Files
Tasks/backend/controllers/question.controller.js
2025-11-11 00:25:50 +02:00

1036 lines
29 KiB
JavaScript

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
});
}
};