add changes
This commit is contained in:
451
models/Question.js
Normal file
451
models/Question.js
Normal file
@@ -0,0 +1,451 @@
|
||||
const { v4: uuidv4 } = require('uuid');
|
||||
|
||||
module.exports = (sequelize, DataTypes) => {
|
||||
const Question = sequelize.define('Question', {
|
||||
id: {
|
||||
type: DataTypes.CHAR(36),
|
||||
primaryKey: true,
|
||||
defaultValue: () => uuidv4(),
|
||||
allowNull: false,
|
||||
comment: 'UUID primary key'
|
||||
},
|
||||
categoryId: {
|
||||
type: DataTypes.CHAR(36),
|
||||
allowNull: false,
|
||||
field: 'category_id',
|
||||
comment: 'Foreign key to categories table'
|
||||
},
|
||||
createdBy: {
|
||||
type: DataTypes.CHAR(36),
|
||||
allowNull: true,
|
||||
field: 'created_by',
|
||||
comment: 'User who created the question (admin)'
|
||||
},
|
||||
questionText: {
|
||||
type: DataTypes.TEXT,
|
||||
allowNull: false,
|
||||
field: 'question_text',
|
||||
validate: {
|
||||
notEmpty: {
|
||||
msg: 'Question text cannot be empty'
|
||||
},
|
||||
len: {
|
||||
args: [10, 5000],
|
||||
msg: 'Question text must be between 10 and 5000 characters'
|
||||
}
|
||||
},
|
||||
comment: 'The question text'
|
||||
},
|
||||
questionType: {
|
||||
type: DataTypes.ENUM('multiple', 'trueFalse', 'written'),
|
||||
allowNull: false,
|
||||
defaultValue: 'multiple',
|
||||
field: 'question_type',
|
||||
validate: {
|
||||
isIn: {
|
||||
args: [['multiple', 'trueFalse', 'written']],
|
||||
msg: 'Question type must be multiple, trueFalse, or written'
|
||||
}
|
||||
},
|
||||
comment: 'Type of question'
|
||||
},
|
||||
options: {
|
||||
type: DataTypes.JSON,
|
||||
allowNull: true,
|
||||
get() {
|
||||
const rawValue = this.getDataValue('options');
|
||||
return rawValue ? (typeof rawValue === 'string' ? JSON.parse(rawValue) : rawValue) : null;
|
||||
},
|
||||
set(value) {
|
||||
this.setDataValue('options', value);
|
||||
},
|
||||
comment: 'Answer options for multiple choice (JSON array)'
|
||||
},
|
||||
correctAnswer: {
|
||||
type: DataTypes.STRING(255),
|
||||
allowNull: false,
|
||||
field: 'correct_answer',
|
||||
validate: {
|
||||
notEmpty: {
|
||||
msg: 'Correct answer cannot be empty'
|
||||
}
|
||||
},
|
||||
comment: 'Correct answer (index for multiple choice, true/false for boolean)'
|
||||
},
|
||||
explanation: {
|
||||
type: DataTypes.TEXT,
|
||||
allowNull: true,
|
||||
comment: 'Explanation for the correct answer'
|
||||
},
|
||||
difficulty: {
|
||||
type: DataTypes.ENUM('easy', 'medium', 'hard'),
|
||||
allowNull: false,
|
||||
defaultValue: 'medium',
|
||||
validate: {
|
||||
isIn: {
|
||||
args: [['easy', 'medium', 'hard']],
|
||||
msg: 'Difficulty must be easy, medium, or hard'
|
||||
}
|
||||
},
|
||||
comment: 'Question difficulty level'
|
||||
},
|
||||
points: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: false,
|
||||
defaultValue: 10,
|
||||
validate: {
|
||||
min: {
|
||||
args: 1,
|
||||
msg: 'Points must be at least 1'
|
||||
},
|
||||
max: {
|
||||
args: 100,
|
||||
msg: 'Points cannot exceed 100'
|
||||
}
|
||||
},
|
||||
comment: 'Points awarded for correct answer'
|
||||
},
|
||||
timeLimit: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: true,
|
||||
field: 'time_limit',
|
||||
validate: {
|
||||
min: {
|
||||
args: 10,
|
||||
msg: 'Time limit must be at least 10 seconds'
|
||||
}
|
||||
},
|
||||
comment: 'Time limit in seconds (optional)'
|
||||
},
|
||||
keywords: {
|
||||
type: DataTypes.JSON,
|
||||
allowNull: true,
|
||||
get() {
|
||||
const rawValue = this.getDataValue('keywords');
|
||||
return rawValue ? (typeof rawValue === 'string' ? JSON.parse(rawValue) : rawValue) : null;
|
||||
},
|
||||
set(value) {
|
||||
this.setDataValue('keywords', value);
|
||||
},
|
||||
comment: 'Search keywords (JSON array)'
|
||||
},
|
||||
tags: {
|
||||
type: DataTypes.JSON,
|
||||
allowNull: true,
|
||||
get() {
|
||||
const rawValue = this.getDataValue('tags');
|
||||
return rawValue ? (typeof rawValue === 'string' ? JSON.parse(rawValue) : rawValue) : null;
|
||||
},
|
||||
set(value) {
|
||||
this.setDataValue('tags', value);
|
||||
},
|
||||
comment: 'Tags for categorization (JSON array)'
|
||||
},
|
||||
visibility: {
|
||||
type: DataTypes.ENUM('public', 'registered', 'premium'),
|
||||
allowNull: false,
|
||||
defaultValue: 'registered',
|
||||
validate: {
|
||||
isIn: {
|
||||
args: [['public', 'registered', 'premium']],
|
||||
msg: 'Visibility must be public, registered, or premium'
|
||||
}
|
||||
},
|
||||
comment: 'Who can see this question'
|
||||
},
|
||||
guestAccessible: {
|
||||
type: DataTypes.BOOLEAN,
|
||||
allowNull: false,
|
||||
defaultValue: false,
|
||||
field: 'guest_accessible',
|
||||
comment: 'Whether guests can access this question'
|
||||
},
|
||||
isActive: {
|
||||
type: DataTypes.BOOLEAN,
|
||||
allowNull: false,
|
||||
defaultValue: true,
|
||||
field: 'is_active',
|
||||
comment: 'Question active status'
|
||||
},
|
||||
timesAttempted: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: false,
|
||||
defaultValue: 0,
|
||||
field: 'times_attempted',
|
||||
validate: {
|
||||
min: 0
|
||||
},
|
||||
comment: 'Number of times question was attempted'
|
||||
},
|
||||
timesCorrect: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: false,
|
||||
defaultValue: 0,
|
||||
field: 'times_correct',
|
||||
validate: {
|
||||
min: 0
|
||||
},
|
||||
comment: 'Number of times answered correctly'
|
||||
}
|
||||
}, {
|
||||
sequelize,
|
||||
modelName: 'Question',
|
||||
tableName: 'questions',
|
||||
timestamps: true,
|
||||
underscored: true,
|
||||
indexes: [
|
||||
{
|
||||
fields: ['category_id']
|
||||
},
|
||||
{
|
||||
fields: ['created_by']
|
||||
},
|
||||
{
|
||||
fields: ['question_type']
|
||||
},
|
||||
{
|
||||
fields: ['difficulty']
|
||||
},
|
||||
{
|
||||
fields: ['visibility']
|
||||
},
|
||||
{
|
||||
fields: ['guest_accessible']
|
||||
},
|
||||
{
|
||||
fields: ['is_active']
|
||||
},
|
||||
{
|
||||
fields: ['created_at']
|
||||
},
|
||||
{
|
||||
fields: ['category_id', 'is_active', 'difficulty']
|
||||
},
|
||||
{
|
||||
fields: ['is_active', 'guest_accessible']
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
// Instance methods
|
||||
Question.prototype.incrementAttempted = async function() {
|
||||
this.timesAttempted += 1;
|
||||
await this.save();
|
||||
};
|
||||
|
||||
Question.prototype.incrementCorrect = async function() {
|
||||
this.timesCorrect += 1;
|
||||
await this.save();
|
||||
};
|
||||
|
||||
Question.prototype.getAccuracy = function() {
|
||||
if (this.timesAttempted === 0) return 0;
|
||||
return Math.round((this.timesCorrect / this.timesAttempted) * 100);
|
||||
};
|
||||
|
||||
Question.prototype.toSafeJSON = function() {
|
||||
const values = { ...this.get() };
|
||||
delete values.correctAnswer; // Hide correct answer
|
||||
return values;
|
||||
};
|
||||
|
||||
// Class methods
|
||||
Question.findActiveQuestions = async function(filters = {}) {
|
||||
const where = { isActive: true };
|
||||
|
||||
if (filters.categoryId) {
|
||||
where.categoryId = filters.categoryId;
|
||||
}
|
||||
|
||||
if (filters.difficulty) {
|
||||
where.difficulty = filters.difficulty;
|
||||
}
|
||||
|
||||
if (filters.visibility) {
|
||||
where.visibility = filters.visibility;
|
||||
}
|
||||
|
||||
if (filters.guestAccessible !== undefined) {
|
||||
where.guestAccessible = filters.guestAccessible;
|
||||
}
|
||||
|
||||
const options = {
|
||||
where,
|
||||
order: sequelize.random()
|
||||
};
|
||||
|
||||
if (filters.limit) {
|
||||
options.limit = filters.limit;
|
||||
}
|
||||
|
||||
return await this.findAll(options);
|
||||
};
|
||||
|
||||
Question.searchQuestions = async function(searchTerm, filters = {}) {
|
||||
const where = { isActive: true };
|
||||
|
||||
if (filters.categoryId) {
|
||||
where.categoryId = filters.categoryId;
|
||||
}
|
||||
|
||||
if (filters.difficulty) {
|
||||
where.difficulty = filters.difficulty;
|
||||
}
|
||||
|
||||
// Use raw query for full-text search
|
||||
const query = `
|
||||
SELECT *, MATCH(question_text, explanation) AGAINST(:searchTerm) as relevance
|
||||
FROM questions
|
||||
WHERE MATCH(question_text, explanation) AGAINST(:searchTerm)
|
||||
${filters.categoryId ? 'AND category_id = :categoryId' : ''}
|
||||
${filters.difficulty ? 'AND difficulty = :difficulty' : ''}
|
||||
AND is_active = 1
|
||||
ORDER BY relevance DESC
|
||||
LIMIT :limit
|
||||
`;
|
||||
|
||||
const replacements = {
|
||||
searchTerm,
|
||||
categoryId: filters.categoryId || null,
|
||||
difficulty: filters.difficulty || null,
|
||||
limit: filters.limit || 20
|
||||
};
|
||||
|
||||
const [results] = await sequelize.query(query, {
|
||||
replacements,
|
||||
type: sequelize.QueryTypes.SELECT
|
||||
});
|
||||
|
||||
return results;
|
||||
};
|
||||
|
||||
Question.getRandomQuestions = async function(categoryId, count = 10, difficulty = null, guestAccessible = false) {
|
||||
const where = {
|
||||
categoryId,
|
||||
isActive: true
|
||||
};
|
||||
|
||||
if (difficulty) {
|
||||
where.difficulty = difficulty;
|
||||
}
|
||||
|
||||
if (guestAccessible) {
|
||||
where.guestAccessible = true;
|
||||
}
|
||||
|
||||
return await this.findAll({
|
||||
where,
|
||||
order: sequelize.random(),
|
||||
limit: count
|
||||
});
|
||||
};
|
||||
|
||||
Question.getQuestionsByCategory = async function(categoryId, options = {}) {
|
||||
const where = {
|
||||
categoryId,
|
||||
isActive: true
|
||||
};
|
||||
|
||||
if (options.difficulty) {
|
||||
where.difficulty = options.difficulty;
|
||||
}
|
||||
|
||||
if (options.guestAccessible !== undefined) {
|
||||
where.guestAccessible = options.guestAccessible;
|
||||
}
|
||||
|
||||
const queryOptions = {
|
||||
where,
|
||||
order: options.random ? sequelize.random() : [['createdAt', 'DESC']]
|
||||
};
|
||||
|
||||
if (options.limit) {
|
||||
queryOptions.limit = options.limit;
|
||||
}
|
||||
|
||||
if (options.offset) {
|
||||
queryOptions.offset = options.offset;
|
||||
}
|
||||
|
||||
return await this.findAll(queryOptions);
|
||||
};
|
||||
|
||||
// Hooks
|
||||
Question.beforeValidate((question) => {
|
||||
// Ensure UUID is set
|
||||
if (!question.id) {
|
||||
question.id = uuidv4();
|
||||
}
|
||||
|
||||
// Validate options for multiple choice questions
|
||||
if (question.questionType === 'multiple') {
|
||||
if (!question.options || !Array.isArray(question.options) || question.options.length < 2) {
|
||||
throw new Error('Multiple choice questions must have at least 2 options');
|
||||
}
|
||||
}
|
||||
|
||||
// Validate trueFalse questions
|
||||
if (question.questionType === 'trueFalse') {
|
||||
if (!['true', 'false'].includes(question.correctAnswer.toLowerCase())) {
|
||||
throw new Error('True/False questions must have "true" or "false" as correct answer');
|
||||
}
|
||||
}
|
||||
|
||||
// Set points based on difficulty if not explicitly provided in creation
|
||||
if (question.isNewRecord && !question.changed('points')) {
|
||||
const pointsMap = {
|
||||
easy: 10,
|
||||
medium: 20,
|
||||
hard: 30
|
||||
};
|
||||
question.points = pointsMap[question.difficulty] || 10;
|
||||
}
|
||||
});
|
||||
|
||||
// Define associations
|
||||
Question.associate = function(models) {
|
||||
// Question belongs to a category
|
||||
Question.belongsTo(models.Category, {
|
||||
foreignKey: 'categoryId',
|
||||
as: 'category'
|
||||
});
|
||||
|
||||
// Question belongs to a user (creator)
|
||||
if (models.User) {
|
||||
Question.belongsTo(models.User, {
|
||||
foreignKey: 'createdBy',
|
||||
as: 'creator'
|
||||
});
|
||||
}
|
||||
|
||||
// Question has many quiz answers
|
||||
if (models.QuizAnswer) {
|
||||
Question.hasMany(models.QuizAnswer, {
|
||||
foreignKey: 'questionId',
|
||||
as: 'answers'
|
||||
});
|
||||
}
|
||||
|
||||
// Question belongs to many quiz sessions through quiz_session_questions
|
||||
if (models.QuizSession && models.QuizSessionQuestion) {
|
||||
Question.belongsToMany(models.QuizSession, {
|
||||
through: models.QuizSessionQuestion,
|
||||
foreignKey: 'questionId',
|
||||
otherKey: 'quizSessionId',
|
||||
as: 'quizSessions'
|
||||
});
|
||||
}
|
||||
|
||||
// Question belongs to many users through bookmarks
|
||||
if (models.User && models.UserBookmark) {
|
||||
Question.belongsToMany(models.User, {
|
||||
through: models.UserBookmark,
|
||||
foreignKey: 'questionId',
|
||||
otherKey: 'userId',
|
||||
as: 'bookmarkedBy'
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return Question;
|
||||
};
|
||||
Reference in New Issue
Block a user