const { DataTypes } = require('sequelize'); const { v4: uuidv4 } = require('uuid'); module.exports = (sequelize) => { const QuizSession = sequelize.define('QuizSession', { id: { type: DataTypes.CHAR(36), primaryKey: true, allowNull: false }, userId: { type: DataTypes.CHAR(36), allowNull: true, field: 'user_id' }, guestSessionId: { type: DataTypes.CHAR(36), allowNull: true, field: 'guest_session_id' }, categoryId: { type: DataTypes.CHAR(36), allowNull: false, field: 'category_id' }, quizType: { type: DataTypes.ENUM('practice', 'timed', 'exam'), allowNull: false, defaultValue: 'practice', field: 'quiz_type', validate: { isIn: { args: [['practice', 'timed', 'exam']], msg: 'Quiz type must be practice, timed, or exam' } } }, difficulty: { type: DataTypes.ENUM('easy', 'medium', 'hard', 'mixed'), allowNull: false, defaultValue: 'mixed', validate: { isIn: { args: [['easy', 'medium', 'hard', 'mixed']], msg: 'Difficulty must be easy, medium, hard, or mixed' } } }, totalQuestions: { type: DataTypes.INTEGER.UNSIGNED, allowNull: false, defaultValue: 10, field: 'total_questions', validate: { min: { args: [1], msg: 'Total questions must be at least 1' }, max: { args: [100], msg: 'Total questions cannot exceed 100' } } }, questionsAnswered: { type: DataTypes.INTEGER.UNSIGNED, allowNull: false, defaultValue: 0, field: 'questions_answered' }, correctAnswers: { type: DataTypes.INTEGER.UNSIGNED, allowNull: false, defaultValue: 0, field: 'correct_answers' }, score: { type: DataTypes.DECIMAL(5, 2), allowNull: false, defaultValue: 0.00, validate: { min: { args: [0], msg: 'Score cannot be negative' }, max: { args: [100], msg: 'Score cannot exceed 100' } } }, totalPoints: { type: DataTypes.INTEGER.UNSIGNED, allowNull: false, defaultValue: 0, field: 'total_points' }, maxPoints: { type: DataTypes.INTEGER.UNSIGNED, allowNull: false, defaultValue: 0, field: 'max_points' }, timeLimit: { type: DataTypes.INTEGER.UNSIGNED, allowNull: true, field: 'time_limit', validate: { min: { args: [60], msg: 'Time limit must be at least 60 seconds' } } }, timeSpent: { type: DataTypes.INTEGER.UNSIGNED, allowNull: false, defaultValue: 0, field: 'time_spent' }, startedAt: { type: DataTypes.DATE, allowNull: true, field: 'started_at' }, completedAt: { type: DataTypes.DATE, allowNull: true, field: 'completed_at' }, status: { type: DataTypes.ENUM('not_started', 'in_progress', 'completed', 'abandoned', 'timed_out'), allowNull: false, defaultValue: 'not_started', validate: { isIn: { args: [['not_started', 'in_progress', 'completed', 'abandoned', 'timed_out']], msg: 'Status must be not_started, in_progress, completed, abandoned, or timed_out' } } }, isPassed: { type: DataTypes.BOOLEAN, allowNull: true, field: 'is_passed' }, passPercentage: { type: DataTypes.DECIMAL(5, 2), allowNull: false, defaultValue: 70.00, field: 'pass_percentage', validate: { min: { args: [0], msg: 'Pass percentage cannot be negative' }, max: { args: [100], msg: 'Pass percentage cannot exceed 100' } } } }, { tableName: 'quiz_sessions', timestamps: true, underscored: true, hooks: { beforeValidate: (session) => { // Generate UUID if not provided if (!session.id) { session.id = uuidv4(); } // Validate that either userId or guestSessionId is provided, but not both if (!session.userId && !session.guestSessionId) { throw new Error('Either userId or guestSessionId must be provided'); } if (session.userId && session.guestSessionId) { throw new Error('Cannot have both userId and guestSessionId'); } // Set started_at when status changes to in_progress if (session.status === 'in_progress' && !session.startedAt) { session.startedAt = new Date(); } // Set completed_at when status changes to completed, abandoned, or timed_out if (['completed', 'abandoned', 'timed_out'].includes(session.status) && !session.completedAt) { session.completedAt = new Date(); } } } }); // Instance Methods /** * Start the quiz session */ QuizSession.prototype.start = async function() { if (this.status !== 'not_started') { throw new Error('Quiz has already been started'); } this.status = 'in_progress'; this.startedAt = new Date(); await this.save(); return this; }; /** * Complete the quiz session and calculate final score */ QuizSession.prototype.complete = async function() { if (this.status !== 'in_progress') { throw new Error('Quiz is not in progress'); } this.status = 'completed'; this.completedAt = new Date(); // Calculate final score this.calculateScore(); // Determine if passed this.isPassed = this.score >= this.passPercentage; await this.save(); return this; }; /** * Abandon the quiz session */ QuizSession.prototype.abandon = async function() { if (this.status !== 'in_progress') { throw new Error('Can only abandon a quiz that is in progress'); } this.status = 'abandoned'; this.completedAt = new Date(); await this.save(); return this; }; /** * Mark quiz as timed out */ QuizSession.prototype.timeout = async function() { if (this.status !== 'in_progress') { throw new Error('Can only timeout a quiz that is in progress'); } this.status = 'timed_out'; this.completedAt = new Date(); // Calculate score with answered questions this.calculateScore(); this.isPassed = this.score >= this.passPercentage; await this.save(); return this; }; /** * Calculate score based on correct answers */ QuizSession.prototype.calculateScore = function() { if (this.totalQuestions === 0) { this.score = 0; return 0; } // Score as percentage this.score = ((this.correctAnswers / this.totalQuestions) * 100).toFixed(2); return parseFloat(this.score); }; /** * Record an answer for a question * @param {boolean} isCorrect - Whether the answer was correct * @param {number} points - Points earned for this question */ QuizSession.prototype.recordAnswer = async function(isCorrect, points = 0) { if (this.status !== 'in_progress') { throw new Error('Cannot record answer for a quiz that is not in progress'); } this.questionsAnswered += 1; if (isCorrect) { this.correctAnswers += 1; this.totalPoints += points; } // Auto-complete if all questions answered if (this.questionsAnswered >= this.totalQuestions) { return await this.complete(); } await this.save(); return this; }; /** * Update time spent on quiz * @param {number} seconds - Seconds to add to time spent */ QuizSession.prototype.updateTimeSpent = async function(seconds) { this.timeSpent += seconds; // Check if timed out if (this.timeLimit && this.timeSpent >= this.timeLimit && this.status === 'in_progress') { return await this.timeout(); } await this.save(); return this; }; /** * Get quiz progress information */ QuizSession.prototype.getProgress = function() { return { id: this.id, status: this.status, totalQuestions: this.totalQuestions, questionsAnswered: this.questionsAnswered, questionsRemaining: this.totalQuestions - this.questionsAnswered, progressPercentage: ((this.questionsAnswered / this.totalQuestions) * 100).toFixed(2), correctAnswers: this.correctAnswers, currentAccuracy: this.questionsAnswered > 0 ? ((this.correctAnswers / this.questionsAnswered) * 100).toFixed(2) : 0, timeSpent: this.timeSpent, timeLimit: this.timeLimit, timeRemaining: this.timeLimit ? Math.max(0, this.timeLimit - this.timeSpent) : null, startedAt: this.startedAt, isTimedOut: this.timeLimit && this.timeSpent >= this.timeLimit }; }; /** * Get quiz results summary */ QuizSession.prototype.getResults = function() { if (this.status === 'not_started' || this.status === 'in_progress') { throw new Error('Quiz is not completed yet'); } return { id: this.id, status: this.status, quizType: this.quizType, difficulty: this.difficulty, totalQuestions: this.totalQuestions, questionsAnswered: this.questionsAnswered, correctAnswers: this.correctAnswers, score: parseFloat(this.score), totalPoints: this.totalPoints, maxPoints: this.maxPoints, isPassed: this.isPassed, passPercentage: parseFloat(this.passPercentage), timeSpent: this.timeSpent, timeLimit: this.timeLimit, startedAt: this.startedAt, completedAt: this.completedAt, duration: this.completedAt && this.startedAt ? Math.floor((this.completedAt - this.startedAt) / 1000) : 0 }; }; /** * Check if quiz is currently active */ QuizSession.prototype.isActive = function() { return this.status === 'in_progress'; }; /** * Check if quiz is completed (any terminal state) */ QuizSession.prototype.isCompleted = function() { return ['completed', 'abandoned', 'timed_out'].includes(this.status); }; // Class Methods /** * Create a new quiz session * @param {Object} options - Quiz session options */ QuizSession.createSession = async function(options) { const { userId, guestSessionId, categoryId, quizType = 'practice', difficulty = 'mixed', totalQuestions = 10, timeLimit = null, passPercentage = 70.00 } = options; return await QuizSession.create({ userId, guestSessionId, categoryId, quizType, difficulty, totalQuestions, timeLimit, passPercentage, status: 'not_started' }); }; /** * Find active session for a user * @param {string} userId - User ID */ QuizSession.findActiveForUser = async function(userId) { return await QuizSession.findOne({ where: { userId, status: 'in_progress' }, order: [['started_at', 'DESC']] }); }; /** * Find active session for a guest * @param {string} guestSessionId - Guest session ID */ QuizSession.findActiveForGuest = async function(guestSessionId) { return await QuizSession.findOne({ where: { guestSessionId, status: 'in_progress' }, order: [['started_at', 'DESC']] }); }; /** * Get user quiz history * @param {string} userId - User ID * @param {number} limit - Number of results to return */ QuizSession.getUserHistory = async function(userId, limit = 10) { return await QuizSession.findAll({ where: { userId, status: ['completed', 'abandoned', 'timed_out'] }, order: [['completed_at', 'DESC']], limit }); }; /** * Get guest quiz history * @param {string} guestSessionId - Guest session ID * @param {number} limit - Number of results to return */ QuizSession.getGuestHistory = async function(guestSessionId, limit = 10) { return await QuizSession.findAll({ where: { guestSessionId, status: ['completed', 'abandoned', 'timed_out'] }, order: [['completed_at', 'DESC']], limit }); }; /** * Get user statistics * @param {string} userId - User ID */ QuizSession.getUserStats = async function(userId) { const { Op } = require('sequelize'); const sessions = await QuizSession.findAll({ where: { userId, status: 'completed' } }); if (sessions.length === 0) { return { totalQuizzes: 0, averageScore: 0, passRate: 0, totalTimeSpent: 0 }; } const totalQuizzes = sessions.length; const totalScore = sessions.reduce((sum, s) => sum + parseFloat(s.score), 0); const passedQuizzes = sessions.filter(s => s.isPassed).length; const totalTimeSpent = sessions.reduce((sum, s) => sum + s.timeSpent, 0); return { totalQuizzes, averageScore: (totalScore / totalQuizzes).toFixed(2), passRate: ((passedQuizzes / totalQuizzes) * 100).toFixed(2), totalTimeSpent, passedQuizzes }; }; /** * Get category statistics * @param {string} categoryId - Category ID */ QuizSession.getCategoryStats = async function(categoryId) { const sessions = await QuizSession.findAll({ where: { categoryId, status: 'completed' } }); if (sessions.length === 0) { return { totalAttempts: 0, averageScore: 0, passRate: 0 }; } const totalAttempts = sessions.length; const totalScore = sessions.reduce((sum, s) => sum + parseFloat(s.score), 0); const passedAttempts = sessions.filter(s => s.isPassed).length; return { totalAttempts, averageScore: (totalScore / totalAttempts).toFixed(2), passRate: ((passedAttempts / totalAttempts) * 100).toFixed(2), passedAttempts }; }; /** * Clean up abandoned sessions older than specified days * @param {number} days - Number of days (default 7) */ QuizSession.cleanupAbandoned = async function(days = 7) { const { Op } = require('sequelize'); const cutoffDate = new Date(); cutoffDate.setDate(cutoffDate.getDate() - days); const deleted = await QuizSession.destroy({ where: { status: ['not_started', 'abandoned'], createdAt: { [Op.lt]: cutoffDate } } }); return deleted; }; // Associations QuizSession.associate = (models) => { // Quiz session belongs to a user (optional, null for guests) QuizSession.belongsTo(models.User, { foreignKey: 'userId', as: 'user' }); // Quiz session belongs to a guest session (optional, null for users) QuizSession.belongsTo(models.GuestSession, { foreignKey: 'guestSessionId', as: 'guestSession' }); // Quiz session belongs to a category QuizSession.belongsTo(models.Category, { foreignKey: 'categoryId', as: 'category' }); // Quiz session has many quiz session questions (junction table for questions) if (models.QuizSessionQuestion) { QuizSession.hasMany(models.QuizSessionQuestion, { foreignKey: 'quizSessionId', as: 'sessionQuestions' }); } // Quiz session has many quiz answers if (models.QuizAnswer) { QuizSession.hasMany(models.QuizAnswer, { foreignKey: 'quizSessionId', as: 'answers' }); } }; return QuizSession; };