1036 lines
29 KiB
JavaScript
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
|
|
});
|
|
}
|
|
};
|