635 lines
16 KiB
JavaScript
635 lines
16 KiB
JavaScript
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,
|
|
indexes: [
|
|
{
|
|
fields: ['user_id']
|
|
},
|
|
{
|
|
fields: ['guest_session_id']
|
|
},
|
|
{
|
|
fields: ['category_id']
|
|
},
|
|
{
|
|
fields: ['status']
|
|
},
|
|
{
|
|
fields: ['created_at']
|
|
},
|
|
{
|
|
fields: ['user_id', 'created_at']
|
|
},
|
|
{
|
|
fields: ['guest_session_id', 'created_at']
|
|
},
|
|
{
|
|
fields: ['category_id', 'status']
|
|
}
|
|
],
|
|
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;
|
|
};
|