1181 lines
37 KiB
JavaScript
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
|
|
});
|
|
}
|
|
};
|