add changes

This commit is contained in:
AD2025
2025-11-11 00:25:50 +02:00
commit e3ca132c5e
86 changed files with 22238 additions and 0 deletions

451
backend/models/Question.js Normal file
View 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;
};