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; };