Files
Tasks/backend/controllers/quiz.controller.js
2025-11-12 00:49:22 +02:00

1181 lines
37 KiB
JavaScript

const { QuizSession, Question, Category, GuestSession, QuizSessionQuestion, QuizAnswer, sequelize } = require('../models');
const { v4: uuidv4 } = require('uuid');
const { Op } = require('sequelize');
/**
* Start a new quiz session
* POST /api/quiz/start
*/
exports.startQuizSession = async (req, res) => {
const transaction = await sequelize.transaction();
try {
const { categoryId, questionCount = 10, difficulty = 'mixed', quizType = 'practice' } = req.body;
const userId = req.user?.userId;
const guestSessionId = req.guestSessionId; // UUID from guest middleware for foreign key
const guestId = req.guestId; // String ID for guest session lookup
// Validate: Must be either authenticated user or guest
if (!userId && !guestSessionId) {
await transaction.rollback();
return res.status(401).json({
success: false,
message: 'Authentication required. Please login or start a guest session.'
});
}
// Validate categoryId is provided
if (!categoryId) {
await transaction.rollback();
return res.status(400).json({
success: false,
message: 'Category ID is required'
});
}
// 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)) {
await transaction.rollback();
return res.status(400).json({
success: false,
message: 'Invalid category ID format'
});
}
// Validate questionCount
const count = parseInt(questionCount);
if (isNaN(count) || count < 1 || count > 50) {
await transaction.rollback();
return res.status(400).json({
success: false,
message: 'Question count must be between 1 and 50'
});
}
// Validate difficulty
const validDifficulties = ['easy', 'medium', 'hard', 'mixed'];
if (!validDifficulties.includes(difficulty)) {
await transaction.rollback();
return res.status(400).json({
success: false,
message: 'Invalid difficulty. Must be: easy, medium, hard, or mixed'
});
}
// Validate quizType
const validQuizTypes = ['practice', 'timed', 'exam'];
if (!validQuizTypes.includes(quizType)) {
await transaction.rollback();
return res.status(400).json({
success: false,
message: 'Invalid quiz type. Must be: practice, timed, or exam'
});
}
// Check if category exists and is active
const category = await Category.findByPk(categoryId, { transaction });
if (!category) {
await transaction.rollback();
return res.status(404).json({
success: false,
message: 'Category not found'
});
}
if (!category.isActive) {
await transaction.rollback();
return res.status(400).json({
success: false,
message: 'Category is not active'
});
}
// Check guest access permissions
if (guestSessionId && !category.guestAccessible) {
await transaction.rollback();
return res.status(403).json({
success: false,
message: 'This category is not accessible to guest users. Please register to access all categories.'
});
}
// For guest users: check quiz limit
if (guestSessionId) {
const guestSession = await GuestSession.findOne({
where: { guestId: guestId }, // Use the string guestId to lookup
transaction
});
if (!guestSession) {
await transaction.rollback();
return res.status(404).json({
success: false,
message: 'Guest session not found'
});
}
// Check if guest session is expired
if (new Date() > new Date(guestSession.expiresAt)) {
await transaction.rollback();
return res.status(410).json({
success: false,
message: 'Guest session has expired. Please start a new session.'
});
}
// Check if guest session is converted
if (guestSession.isConverted) {
await transaction.rollback();
return res.status(410).json({
success: false,
message: 'This guest session has been converted to a user account. Please login.'
});
}
// Check quiz limit
if (guestSession.quizzesAttempted >= guestSession.maxQuizzes) {
await transaction.rollback();
return res.status(403).json({
success: false,
message: 'Quiz limit reached. Please register to continue taking quizzes.',
quizLimit: {
maxQuizzes: guestSession.maxQuizzes,
quizzesAttempted: guestSession.quizzesAttempted
}
});
}
// Increment guest quizzes_attempted
await guestSession.increment('quizzesAttempted', { transaction });
}
// Build question query based on difficulty
const questionWhere = {
categoryId: categoryId,
isActive: true
};
if (difficulty !== 'mixed') {
questionWhere.difficulty = difficulty;
}
// Get random questions from the category
const availableQuestions = await Question.findAll({
where: questionWhere,
order: sequelize.random(),
limit: count,
attributes: ['id', 'questionText', 'questionType', 'options', 'difficulty', 'points', 'tags'],
transaction
});
// Check if we have enough questions
if (availableQuestions.length === 0) {
await transaction.rollback();
return res.status(404).json({
success: false,
message: `No ${difficulty !== 'mixed' ? difficulty : ''} questions available in this category`.trim()
});
}
if (availableQuestions.length < count) {
// Continue with available questions but inform user
console.log(`Only ${availableQuestions.length} questions available, requested ${count}`);
}
const actualQuestionCount = availableQuestions.length;
// Calculate total points
const totalPoints = availableQuestions.reduce((sum, q) => sum + q.points, 0);
// Set time limit for timed/exam quizzes (2 minutes per question, stored in seconds)
let timeLimit = null;
if (quizType === 'timed' || quizType === 'exam') {
timeLimit = actualQuestionCount * 2 * 60; // 2 minutes per question = 120 seconds per question
}
// Create quiz session
const quizSession = await QuizSession.create({
id: uuidv4(),
userId: userId || null,
guestSessionId: guestSessionId || null,
categoryId: categoryId,
quizType: quizType,
difficulty: difficulty,
totalQuestions: actualQuestionCount,
questionsAnswered: 0,
correctAnswers: 0,
score: 0,
totalPoints: totalPoints,
timeLimit: timeLimit,
status: 'in_progress',
startedAt: new Date()
}, { transaction });
// Create quiz_session_questions records (junction table)
const quizSessionQuestions = availableQuestions.map((question, index) => ({
id: uuidv4(),
quizSessionId: quizSession.id,
questionId: question.id,
questionOrder: index + 1
}));
await QuizSessionQuestion.bulkCreate(quizSessionQuestions, { transaction });
// Commit transaction
await transaction.commit();
// Prepare questions response (exclude correctAnswer)
const questionsResponse = availableQuestions.map((q, index) => ({
id: q.id,
questionText: q.questionText,
questionType: q.questionType,
options: q.options,
difficulty: q.difficulty,
points: q.points,
tags: q.tags,
order: index + 1
}));
// Response
res.status(201).json({
success: true,
data: {
sessionId: quizSession.id,
category: {
id: category.id,
name: category.name,
slug: category.slug,
icon: category.icon,
color: category.color
},
quizType: quizSession.quizType,
difficulty: quizSession.difficulty,
totalQuestions: quizSession.totalQuestions,
totalPoints: quizSession.totalPoints,
timeLimit: quizSession.timeLimit ? Math.floor(quizSession.timeLimit / 60) : null, // Convert seconds to minutes for response
status: quizSession.status,
startedAt: quizSession.startedAt,
questions: questionsResponse
},
message: 'Quiz session started successfully'
});
} catch (error) {
await transaction.rollback();
console.error('Error starting quiz session:', error);
res.status(500).json({
success: false,
message: 'An error occurred while starting the quiz session',
error: process.env.NODE_ENV === 'development' ? error.message : undefined
});
}
};
/**
* Submit an answer for a quiz question
* POST /api/quiz/submit
*/
exports.submitAnswer = async (req, res) => {
const transaction = await sequelize.transaction();
try {
const { quizSessionId, questionId, userAnswer, timeSpent = 0 } = req.body;
const userId = req.user?.userId;
const guestSessionId = req.guestSessionId;
// Validate required fields
if (!quizSessionId) {
await transaction.rollback();
return res.status(400).json({
success: false,
message: 'Quiz session ID is required'
});
}
if (!questionId) {
await transaction.rollback();
return res.status(400).json({
success: false,
message: 'Question ID is required'
});
}
if (userAnswer === undefined || userAnswer === null || userAnswer === '') {
await transaction.rollback();
return res.status(400).json({
success: false,
message: 'User answer is required'
});
}
// Validate UUID format for quizSessionId
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(quizSessionId)) {
await transaction.rollback();
return res.status(400).json({
success: false,
message: 'Invalid quiz session ID format'
});
}
// Validate UUID format for questionId
if (!uuidRegex.test(questionId)) {
await transaction.rollback();
return res.status(400).json({
success: false,
message: 'Invalid question ID format'
});
}
// Find quiz session
const quizSession = await QuizSession.findByPk(quizSessionId, { transaction });
if (!quizSession) {
await transaction.rollback();
return res.status(404).json({
success: false,
message: 'Quiz session not found'
});
}
// Check authorization: session must belong to current user or guest
if (userId && quizSession.userId !== userId) {
await transaction.rollback();
return res.status(403).json({
success: false,
message: 'You are not authorized to submit answers for this quiz session'
});
}
if (guestSessionId && quizSession.guestSessionId !== guestSessionId) {
await transaction.rollback();
return res.status(403).json({
success: false,
message: 'You are not authorized to submit answers for this quiz session'
});
}
// Check if session is in progress
if (quizSession.status !== 'in_progress') {
await transaction.rollback();
return res.status(400).json({
success: false,
message: `Cannot submit answer for a quiz session with status: ${quizSession.status}`
});
}
// Check if question belongs to this quiz session
const questionInSession = await QuizSessionQuestion.findOne({
where: {
quizSessionId: quizSessionId,
questionId: questionId
},
transaction
});
if (!questionInSession) {
await transaction.rollback();
return res.status(400).json({
success: false,
message: 'Question does not belong to this quiz session'
});
}
// Check if already answered
const existingAnswer = await QuizAnswer.findOne({
where: {
quizSessionId: quizSessionId,
questionId: questionId
},
transaction
});
if (existingAnswer) {
await transaction.rollback();
return res.status(400).json({
success: false,
message: 'Question has already been answered in this quiz session'
});
}
// Get the question with correct answer
const question = await Question.findByPk(questionId, {
include: [{
model: Category,
as: 'category',
attributes: ['id', 'name', 'slug', 'icon', 'color']
}],
transaction
});
if (!question) {
await transaction.rollback();
return res.status(404).json({
success: false,
message: 'Question not found'
});
}
// Parse correct answer (may be JSON array for multiple choice)
let correctAnswer = question.correctAnswer;
try {
// Try to parse as JSON (for multiple choice questions with array format)
const parsed = JSON.parse(correctAnswer);
if (Array.isArray(parsed)) {
correctAnswer = parsed[0]; // Take first element if array
}
} catch (e) {
// Not JSON, use as is
}
// Compare answer with correct answer (case-insensitive)
const isCorrect = userAnswer.toString().toLowerCase().trim() === correctAnswer.toString().toLowerCase().trim();
// Calculate points earned
const pointsEarned = isCorrect ? question.points : 0;
// Create quiz answer record
const quizAnswer = await QuizAnswer.create({
id: uuidv4(),
quizSessionId: quizSessionId,
questionId: questionId,
selectedOption: userAnswer,
isCorrect: isCorrect,
pointsEarned: pointsEarned,
timeTaken: timeSpent,
answeredAt: new Date()
}, { transaction });
// Update quiz session stats
if (isCorrect) {
await quizSession.increment('correctAnswers', { by: 1, transaction });
await quizSession.increment('score', { by: pointsEarned, transaction });
}
await quizSession.increment('questionsAnswered', { by: 1, transaction });
await quizSession.increment('timeSpent', { by: timeSpent, transaction });
// Update question stats
await question.increment('timesAttempted', { by: 1, transaction });
if (isCorrect) {
await question.increment('timesCorrect', { by: 1, transaction });
}
// Commit transaction
await transaction.commit();
// Return response with feedback (exclude correct answer if incorrect)
const response = {
success: true,
data: {
answerId: quizAnswer.id,
questionId: question.id,
isCorrect: isCorrect,
pointsEarned: pointsEarned,
timeTaken: timeSpent,
feedback: {
explanation: question.explanation,
questionText: question.questionText,
userAnswer: userAnswer,
difficulty: question.difficulty,
category: question.category
},
sessionProgress: {
questionsAnswered: quizSession.questionsAnswered + 1,
totalQuestions: quizSession.totalQuestions,
currentScore: quizSession.score + pointsEarned,
correctAnswers: quizSession.correctAnswers + (isCorrect ? 1 : 0)
}
},
message: isCorrect ? 'Correct answer!' : 'Incorrect answer'
};
// Only include correct answer in response if user got it wrong (for learning)
if (!isCorrect) {
response.data.feedback.correctAnswer = question.correctAnswer;
}
res.status(201).json(response);
} catch (error) {
await transaction.rollback();
console.error('Error submitting answer:', error);
res.status(500).json({
success: false,
message: 'An error occurred while submitting the answer',
error: process.env.NODE_ENV === 'development' ? error.message : undefined
});
}
};
/**
* Complete Quiz Session
* Finalize a quiz session, calculate final score and results
* POST /api/quiz/complete
*/
exports.completeQuizSession = async (req, res) => {
const transaction = await sequelize.transaction();
try {
const { sessionId } = req.body;
// Validate required fields
if (!sessionId) {
return res.status(400).json({
success: false,
message: 'Session ID is required'
});
}
// 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(sessionId)) {
return res.status(400).json({
success: false,
message: 'Invalid session ID format'
});
}
// Find the quiz session
const quizSession = await QuizSession.findByPk(sessionId, {
include: [
{
model: Category,
as: 'category',
attributes: ['id', 'name', 'slug', 'icon', 'color']
}
],
transaction
});
if (!quizSession) {
await transaction.rollback();
return res.status(404).json({
success: false,
message: 'Quiz session not found'
});
}
// Check authorization - user must own the session
const isUser = req.user && quizSession.userId === req.user.userId;
const isGuest = req.guestSessionId && quizSession.guestSessionId === req.guestSessionId;
if (!isUser && !isGuest) {
await transaction.rollback();
return res.status(403).json({
success: false,
message: 'You do not have permission to complete this quiz session'
});
}
// Check if session is already completed
if (quizSession.status === 'completed') {
await transaction.rollback();
return res.status(400).json({
success: false,
message: 'Quiz session is already completed'
});
}
// Check if session is in progress
if (quizSession.status !== 'in_progress') {
await transaction.rollback();
return res.status(400).json({
success: false,
message: `Cannot complete quiz session with status: ${quizSession.status}`
});
}
// Get all answers for this session
const answers = await QuizAnswer.findAll({
where: { quizSessionId: sessionId },
transaction
});
// Calculate final score (already tracked in session)
const finalScore = parseFloat(quizSession.score) || 0;
const totalQuestions = quizSession.totalQuestions;
const questionsAnswered = quizSession.questionsAnswered;
const correctAnswers = quizSession.correctAnswers;
// Calculate percentage
const percentage = totalQuestions > 0
? Math.round((finalScore / quizSession.totalPoints) * 100)
: 0;
// Determine pass/fail (70% is passing)
const isPassed = percentage >= 70;
// Calculate time taken (in seconds)
const endTime = new Date();
const startTime = new Date(quizSession.startedAt);
const timeTaken = Math.floor((endTime - startTime) / 1000); // seconds
// Check if timed quiz exceeded time limit
let isTimeout = false;
if (quizSession.timeLimit && timeTaken > quizSession.timeLimit) {
isTimeout = true;
}
// Update quiz session
await quizSession.update({
status: isTimeout ? 'timeout' : 'completed',
score: finalScore,
percentage,
isPassed,
timeSpent: timeTaken,
endTime,
completedAt: endTime
}, { transaction });
// Update user stats if registered user
if (quizSession.userId) {
const { User } = require('../models');
const user = await User.findByPk(quizSession.userId, { transaction });
if (user) {
await user.increment('totalQuizzes', { by: 1, transaction });
if (isPassed) {
await user.increment('quizzesPassed', { by: 1, transaction });
}
// Update accuracy - use correct field names
await user.increment('totalQuestionsAnswered', { by: questionsAnswered, transaction });
await user.increment('correctAnswers', { by: correctAnswers, transaction });
// Calculate streak (simplified - can be enhanced later)
const today = new Date().toDateString();
const lastActive = user.lastActiveDate ? new Date(user.lastActiveDate).toDateString() : null;
if (lastActive === today) {
// Already counted for today
} else {
const yesterday = new Date();
yesterday.setDate(yesterday.getDate() - 1);
const yesterdayStr = yesterday.toDateString();
if (lastActive === yesterdayStr) {
// Continue streak
await user.increment('currentStreak', { by: 1, transaction });
if (user.currentStreak + 1 > user.longestStreak) {
await user.update({ longestStreak: user.currentStreak + 1 }, { transaction });
}
} else {
// Reset streak
await user.update({ currentStreak: 1 }, { transaction });
// Update longest streak if necessary
if (user.longestStreak < 1) {
await user.update({ longestStreak: 1 }, { transaction });
}
}
}
await user.update({ lastActiveDate: new Date() }, { transaction });
}
}
// Commit transaction
await transaction.commit();
// Prepare detailed results
const results = {
sessionId: quizSession.id,
status: quizSession.status,
category: {
id: quizSession.category.id,
name: quizSession.category.name,
slug: quizSession.category.slug,
icon: quizSession.category.icon,
color: quizSession.category.color
},
quizType: quizSession.quizType,
difficulty: quizSession.difficulty,
score: {
earned: finalScore,
total: parseFloat(quizSession.totalPoints) || 0,
percentage
},
questions: {
total: totalQuestions,
answered: questionsAnswered,
correct: correctAnswers,
incorrect: questionsAnswered - correctAnswers,
unanswered: totalQuestions - questionsAnswered
},
accuracy: questionsAnswered > 0
? Math.round((correctAnswers / questionsAnswered) * 100)
: 0,
isPassed,
time: {
started: quizSession.startedAt,
completed: quizSession.completedAt,
taken: timeTaken,
limit: quizSession.timeLimit,
isTimeout
}
};
res.status(200).json({
success: true,
data: results,
message: isPassed
? 'Congratulations! Quiz completed successfully'
: 'Quiz completed. Keep practicing to improve!'
});
} catch (error) {
await transaction.rollback();
console.error('Error completing quiz session:', error);
res.status(500).json({
success: false,
message: 'An error occurred while completing the quiz session',
error: process.env.NODE_ENV === 'development' ? error.message : undefined
});
}
};
/**
* Get session details with questions and answers
* GET /api/quiz/session/:sessionId
*/
exports.getSessionDetails = async (req, res) => {
try {
const { sessionId } = req.params;
const userId = req.user?.userId;
const guestSessionId = req.guestSessionId;
// Validate: Must be either authenticated user or guest
if (!userId && !guestSessionId) {
return res.status(401).json({
success: false,
message: 'Authentication required. Please login or start a guest session.'
});
}
// Validate sessionId is provided
if (!sessionId) {
return res.status(400).json({
success: false,
message: 'Session ID is required'
});
}
// 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(sessionId)) {
return res.status(400).json({
success: false,
message: 'Invalid session ID format'
});
}
// Find quiz session with all associations
const quizSession = await QuizSession.findByPk(sessionId, {
include: [
{
model: Category,
as: 'category',
attributes: ['id', 'name', 'slug', 'icon', 'color']
}
]
});
// Check if session exists
if (!quizSession) {
return res.status(404).json({
success: false,
message: 'Quiz session not found'
});
}
// Authorization: Check if session belongs to current user/guest
const isOwner = userId
? quizSession.userId === userId
: quizSession.guestSessionId === guestSessionId;
if (!isOwner) {
return res.status(403).json({
success: false,
message: 'You do not have permission to access this quiz session'
});
}
// Get questions for this session with their answers
const sessionQuestions = await QuizSessionQuestion.findAll({
where: { quizSessionId: sessionId },
include: [
{
model: Question,
as: 'question',
attributes: [
'id', 'questionText', 'questionType', 'options',
'difficulty', 'points', 'explanation', 'tags',
'correctAnswer' // Include correct answer for review
],
include: [
{
model: Category,
as: 'category',
attributes: ['id', 'name', 'slug', 'icon', 'color']
}
]
}
],
order: [['questionOrder', 'ASC']]
});
// Get all answers for this session
const answers = await QuizAnswer.findAll({
where: { quizSessionId: sessionId },
attributes: ['questionId', 'selectedOption', 'isCorrect', 'pointsEarned', 'timeTaken', 'answeredAt']
});
// Create a map of answers by questionId for quick lookup
const answerMap = {};
answers.forEach(answer => {
answerMap[answer.questionId] = {
userAnswer: answer.selectedOption,
isCorrect: answer.isCorrect,
pointsEarned: parseFloat(answer.pointsEarned) || 0,
timeTaken: answer.timeTaken,
answeredAt: answer.answeredAt
};
});
// Build questions array with answers
const questionsWithAnswers = sessionQuestions.map(sq => {
const question = sq.question;
const answer = answerMap[question.id];
return {
id: question.id,
questionText: question.questionText,
questionType: question.questionType,
options: question.options,
difficulty: question.difficulty,
points: question.points,
explanation: question.explanation,
tags: question.tags,
order: sq.questionOrder,
// Only show correct answer if session is completed or if already answered
correctAnswer: (quizSession.status === 'completed' || quizSession.status === 'timeout' || answer)
? question.correctAnswer
: undefined,
userAnswer: answer?.userAnswer || null,
isCorrect: answer ? answer.isCorrect : null,
pointsEarned: answer?.pointsEarned || 0,
timeTaken: answer?.timeTaken || null,
answeredAt: answer?.answeredAt || null,
isAnswered: !!answer
};
});
// Calculate progress
const totalQuestions = sessionQuestions.length;
const answeredQuestions = answers.length;
const correctAnswers = answers.filter(a => a.isCorrect).length;
const incorrectAnswers = answers.filter(a => !a.isCorrect).length;
const unansweredQuestions = totalQuestions - answeredQuestions;
const progressPercentage = totalQuestions > 0 ? Math.round((answeredQuestions / totalQuestions) * 100) : 0;
// Calculate time tracking
const startedAt = quizSession.startedAt;
const endTime = quizSession.endTime || new Date();
const timeSpent = Math.floor((endTime - startedAt) / 1000); // seconds
const timeLimit = quizSession.timeLimit; // already in seconds
const timeRemaining = timeLimit ? Math.max(0, timeLimit - timeSpent) : null;
// Build response
const response = {
success: true,
data: {
session: {
id: quizSession.id,
status: quizSession.status,
quizType: quizSession.quizType,
difficulty: quizSession.difficulty,
category: {
id: quizSession.category.id,
name: quizSession.category.name,
slug: quizSession.category.slug,
icon: quizSession.category.icon,
color: quizSession.category.color
},
score: {
earned: parseFloat(quizSession.score) || 0,
total: parseFloat(quizSession.totalPoints) || 0,
percentage: quizSession.percentage || 0
},
isPassed: quizSession.isPassed || false,
startedAt,
completedAt: quizSession.completedAt,
timeSpent, // seconds
timeLimit, // seconds
timeRemaining // seconds (null if no limit)
},
progress: {
totalQuestions,
answeredQuestions,
correctAnswers,
incorrectAnswers,
unansweredQuestions,
progressPercentage
},
questions: questionsWithAnswers
},
message: 'Quiz session details retrieved successfully'
};
return res.status(200).json(response);
} catch (error) {
console.error('Error getting session details:', error);
return res.status(500).json({
success: false,
message: 'An error occurred while retrieving session details',
error: process.env.NODE_ENV === 'development' ? error.message : undefined
});
}
};
/**
* Review completed quiz session with all answers and explanations
* GET /api/quiz/review/:sessionId
*/
exports.reviewQuizSession = async (req, res) => {
try {
const { sessionId } = req.params;
const userId = req.user?.userId;
const guestSessionId = req.guestSessionId;
// Validate: Must be either authenticated user or guest
if (!userId && !guestSessionId) {
return res.status(401).json({
success: false,
message: 'Authentication required. Please login or start a guest session.'
});
}
// Validate sessionId is provided
if (!sessionId) {
return res.status(400).json({
success: false,
message: 'Session ID is required'
});
}
// 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(sessionId)) {
return res.status(400).json({
success: false,
message: 'Invalid session ID format'
});
}
// Find quiz session with category
const quizSession = await QuizSession.findByPk(sessionId, {
include: [
{
model: Category,
as: 'category',
attributes: ['id', 'name', 'slug', 'icon', 'color']
}
]
});
// Check if session exists
if (!quizSession) {
return res.status(404).json({
success: false,
message: 'Quiz session not found'
});
}
// Authorization: Check if session belongs to current user/guest
const isOwner = userId
? quizSession.userId === userId
: quizSession.guestSessionId === guestSessionId;
if (!isOwner) {
return res.status(403).json({
success: false,
message: 'You do not have permission to access this quiz session'
});
}
// Check if session is completed or timed out
if (quizSession.status !== 'completed' && quizSession.status !== 'timeout') {
return res.status(400).json({
success: false,
message: 'Can only review completed or timed out quiz sessions',
currentStatus: quizSession.status
});
}
// Get questions for this session with their details
const sessionQuestions = await QuizSessionQuestion.findAll({
where: { quizSessionId: sessionId },
include: [
{
model: Question,
as: 'question',
attributes: [
'id', 'questionText', 'questionType', 'options',
'difficulty', 'points', 'explanation', 'tags',
'correctAnswer' // Include for review
],
include: [
{
model: Category,
as: 'category',
attributes: ['id', 'name', 'slug']
}
]
}
],
order: [['questionOrder', 'ASC']]
});
// Get all answers for this session
const answers = await QuizAnswer.findAll({
where: { quizSessionId: sessionId },
attributes: ['questionId', 'selectedOption', 'isCorrect', 'pointsEarned', 'timeTaken', 'answeredAt']
});
// Create a map of answers by questionId
const answerMap = {};
answers.forEach(answer => {
answerMap[answer.questionId] = {
userAnswer: answer.selectedOption,
isCorrect: answer.isCorrect,
pointsEarned: parseFloat(answer.pointsEarned) || 0,
timeTaken: answer.timeTaken,
answeredAt: answer.answeredAt
};
});
// Build questions array with complete review information
const reviewQuestions = sessionQuestions.map(sq => {
const question = sq.question;
const answer = answerMap[question.id];
// Determine result status for visual feedback
let resultStatus = 'unanswered';
if (answer) {
resultStatus = answer.isCorrect ? 'correct' : 'incorrect';
}
// For multiple choice, mark which option is correct and which was selected
let optionsWithFeedback = null;
if (question.questionType === 'multiple' && question.options) {
optionsWithFeedback = question.options.map(option => ({
id: option.id,
text: option.text,
isCorrect: option.id === question.correctAnswer,
isSelected: answer ? option.id === answer.userAnswer : false,
feedback: option.id === question.correctAnswer
? 'correct-answer'
: (answer && option.id === answer.userAnswer ? 'user-selected-wrong' : null)
}));
}
return {
id: question.id,
questionText: question.questionText,
questionType: question.questionType,
options: optionsWithFeedback || question.options,
difficulty: question.difficulty,
points: question.points,
explanation: question.explanation,
tags: question.tags,
order: sq.questionOrder,
correctAnswer: question.correctAnswer,
userAnswer: answer?.userAnswer || null,
isCorrect: answer ? answer.isCorrect : null,
resultStatus, // 'correct', 'incorrect', 'unanswered'
pointsEarned: answer?.pointsEarned || 0,
pointsPossible: question.points,
timeTaken: answer?.timeTaken || null,
answeredAt: answer?.answeredAt || null,
// Visual feedback helpers
showExplanation: true,
wasAnswered: !!answer
};
});
// Calculate summary statistics
const totalQuestions = sessionQuestions.length;
const answeredQuestions = answers.length;
const correctAnswers = answers.filter(a => a.isCorrect).length;
const incorrectAnswers = answers.filter(a => !a.isCorrect).length;
const unansweredQuestions = totalQuestions - answeredQuestions;
const finalScore = parseFloat(quizSession.score) || 0;
const totalPoints = parseFloat(quizSession.totalPoints) || 0;
const accuracy = answeredQuestions > 0 ? Math.round((correctAnswers / answeredQuestions) * 100) : 0;
// Calculate time statistics
const totalTimeTaken = answers.reduce((sum, a) => sum + (a.timeTaken || 0), 0);
const averageTimePerQuestion = answeredQuestions > 0
? Math.round(totalTimeTaken / answeredQuestions)
: 0;
// Build response
const response = {
success: true,
data: {
session: {
id: quizSession.id,
status: quizSession.status,
quizType: quizSession.quizType,
difficulty: quizSession.difficulty,
category: {
id: quizSession.category.id,
name: quizSession.category.name,
slug: quizSession.category.slug,
icon: quizSession.category.icon,
color: quizSession.category.color
},
startedAt: quizSession.startedAt,
completedAt: quizSession.completedAt,
timeSpent: quizSession.timeSpent
},
summary: {
score: {
earned: finalScore,
total: totalPoints,
percentage: quizSession.percentage || 0
},
questions: {
total: totalQuestions,
answered: answeredQuestions,
correct: correctAnswers,
incorrect: incorrectAnswers,
unanswered: unansweredQuestions
},
accuracy,
isPassed: quizSession.isPassed || false,
timeStatistics: {
totalTime: totalTimeTaken,
averageTimePerQuestion,
timeLimit: quizSession.timeLimit,
wasTimedOut: quizSession.status === 'timeout'
}
},
questions: reviewQuestions
},
message: 'Quiz review retrieved successfully'
};
return res.status(200).json(response);
} catch (error) {
console.error('Error reviewing quiz session:', error);
return res.status(500).json({
success: false,
message: 'An error occurred while reviewing quiz session',
error: process.env.NODE_ENV === 'development' ? error.message : undefined
});
}
};