add changes
This commit is contained in:
274
models/Category.js
Normal file
274
models/Category.js
Normal file
@@ -0,0 +1,274 @@
|
||||
const { v4: uuidv4 } = require('uuid');
|
||||
|
||||
module.exports = (sequelize, DataTypes) => {
|
||||
const Category = sequelize.define('Category', {
|
||||
id: {
|
||||
type: DataTypes.CHAR(36),
|
||||
primaryKey: true,
|
||||
defaultValue: () => uuidv4(),
|
||||
allowNull: false,
|
||||
comment: 'UUID primary key'
|
||||
},
|
||||
name: {
|
||||
type: DataTypes.STRING(100),
|
||||
allowNull: false,
|
||||
unique: {
|
||||
msg: 'Category name already exists'
|
||||
},
|
||||
validate: {
|
||||
notEmpty: {
|
||||
msg: 'Category name cannot be empty'
|
||||
},
|
||||
len: {
|
||||
args: [2, 100],
|
||||
msg: 'Category name must be between 2 and 100 characters'
|
||||
}
|
||||
},
|
||||
comment: 'Category name'
|
||||
},
|
||||
slug: {
|
||||
type: DataTypes.STRING(100),
|
||||
allowNull: false,
|
||||
unique: {
|
||||
msg: 'Category slug already exists'
|
||||
},
|
||||
validate: {
|
||||
notEmpty: {
|
||||
msg: 'Slug cannot be empty'
|
||||
},
|
||||
is: {
|
||||
args: /^[a-z0-9]+(?:-[a-z0-9]+)*$/,
|
||||
msg: 'Slug must be lowercase alphanumeric with hyphens only'
|
||||
}
|
||||
},
|
||||
comment: 'URL-friendly slug'
|
||||
},
|
||||
description: {
|
||||
type: DataTypes.TEXT,
|
||||
allowNull: true,
|
||||
comment: 'Category description'
|
||||
},
|
||||
icon: {
|
||||
type: DataTypes.STRING(255),
|
||||
allowNull: true,
|
||||
comment: 'Icon URL or class'
|
||||
},
|
||||
color: {
|
||||
type: DataTypes.STRING(20),
|
||||
allowNull: true,
|
||||
validate: {
|
||||
is: {
|
||||
args: /^#[0-9A-F]{6}$/i,
|
||||
msg: 'Color must be a valid hex color (e.g., #FF5733)'
|
||||
}
|
||||
},
|
||||
comment: 'Display color (hex format)'
|
||||
},
|
||||
isActive: {
|
||||
type: DataTypes.BOOLEAN,
|
||||
allowNull: false,
|
||||
defaultValue: true,
|
||||
field: 'is_active',
|
||||
comment: 'Category active status'
|
||||
},
|
||||
guestAccessible: {
|
||||
type: DataTypes.BOOLEAN,
|
||||
allowNull: false,
|
||||
defaultValue: false,
|
||||
field: 'guest_accessible',
|
||||
comment: 'Whether guests can access this category'
|
||||
},
|
||||
|
||||
// Statistics
|
||||
questionCount: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: false,
|
||||
defaultValue: 0,
|
||||
field: 'question_count',
|
||||
validate: {
|
||||
min: 0
|
||||
},
|
||||
comment: 'Total number of questions in this category'
|
||||
},
|
||||
quizCount: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: false,
|
||||
defaultValue: 0,
|
||||
field: 'quiz_count',
|
||||
validate: {
|
||||
min: 0
|
||||
},
|
||||
comment: 'Total number of quizzes taken in this category'
|
||||
},
|
||||
|
||||
// Display order
|
||||
displayOrder: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: false,
|
||||
defaultValue: 0,
|
||||
field: 'display_order',
|
||||
comment: 'Display order (lower numbers first)'
|
||||
}
|
||||
}, {
|
||||
sequelize,
|
||||
modelName: 'Category',
|
||||
tableName: 'categories',
|
||||
timestamps: true,
|
||||
underscored: true,
|
||||
indexes: [
|
||||
{
|
||||
unique: true,
|
||||
fields: ['name']
|
||||
},
|
||||
{
|
||||
unique: true,
|
||||
fields: ['slug']
|
||||
},
|
||||
{
|
||||
fields: ['is_active']
|
||||
},
|
||||
{
|
||||
fields: ['guest_accessible']
|
||||
},
|
||||
{
|
||||
fields: ['display_order']
|
||||
},
|
||||
{
|
||||
fields: ['is_active', 'guest_accessible']
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
// Helper function to generate slug from name
|
||||
Category.generateSlug = function(name) {
|
||||
return name
|
||||
.toLowerCase()
|
||||
.trim()
|
||||
.replace(/[^\w\s-]/g, '') // Remove special characters
|
||||
.replace(/[\s_-]+/g, '-') // Replace spaces and underscores with hyphens
|
||||
.replace(/^-+|-+$/g, ''); // Remove leading/trailing hyphens
|
||||
};
|
||||
|
||||
// Instance methods
|
||||
Category.prototype.incrementQuestionCount = async function() {
|
||||
this.questionCount += 1;
|
||||
await this.save();
|
||||
};
|
||||
|
||||
Category.prototype.decrementQuestionCount = async function() {
|
||||
if (this.questionCount > 0) {
|
||||
this.questionCount -= 1;
|
||||
await this.save();
|
||||
}
|
||||
};
|
||||
|
||||
Category.prototype.incrementQuizCount = async function() {
|
||||
this.quizCount += 1;
|
||||
await this.save();
|
||||
};
|
||||
|
||||
// Class methods
|
||||
Category.findActiveCategories = async function(includeGuestOnly = false) {
|
||||
const where = { isActive: true };
|
||||
if (includeGuestOnly) {
|
||||
where.guestAccessible = true;
|
||||
}
|
||||
return await this.findAll({
|
||||
where,
|
||||
order: [['displayOrder', 'ASC'], ['name', 'ASC']]
|
||||
});
|
||||
};
|
||||
|
||||
Category.findBySlug = async function(slug) {
|
||||
return await this.findOne({
|
||||
where: { slug, isActive: true }
|
||||
});
|
||||
};
|
||||
|
||||
Category.getGuestAccessibleCategories = async function() {
|
||||
return await this.findAll({
|
||||
where: {
|
||||
isActive: true,
|
||||
guestAccessible: true
|
||||
},
|
||||
order: [['displayOrder', 'ASC'], ['name', 'ASC']]
|
||||
});
|
||||
};
|
||||
|
||||
Category.getCategoriesWithStats = async function() {
|
||||
return await this.findAll({
|
||||
where: { isActive: true },
|
||||
attributes: [
|
||||
'id',
|
||||
'name',
|
||||
'slug',
|
||||
'description',
|
||||
'icon',
|
||||
'color',
|
||||
'questionCount',
|
||||
'quizCount',
|
||||
'guestAccessible',
|
||||
'displayOrder'
|
||||
],
|
||||
order: [['displayOrder', 'ASC'], ['name', 'ASC']]
|
||||
});
|
||||
};
|
||||
|
||||
// Hooks
|
||||
Category.beforeValidate((category) => {
|
||||
// Auto-generate slug from name if not provided
|
||||
if (!category.slug && category.name) {
|
||||
category.slug = Category.generateSlug(category.name);
|
||||
}
|
||||
|
||||
// Ensure UUID is set
|
||||
if (!category.id) {
|
||||
category.id = uuidv4();
|
||||
}
|
||||
});
|
||||
|
||||
Category.beforeCreate((category) => {
|
||||
// Ensure slug is generated even if validation was skipped
|
||||
if (!category.slug && category.name) {
|
||||
category.slug = Category.generateSlug(category.name);
|
||||
}
|
||||
});
|
||||
|
||||
Category.beforeUpdate((category) => {
|
||||
// Regenerate slug if name changed
|
||||
if (category.changed('name') && !category.changed('slug')) {
|
||||
category.slug = Category.generateSlug(category.name);
|
||||
}
|
||||
});
|
||||
|
||||
// Define associations
|
||||
Category.associate = function(models) {
|
||||
// Category has many questions
|
||||
if (models.Question) {
|
||||
Category.hasMany(models.Question, {
|
||||
foreignKey: 'categoryId',
|
||||
as: 'questions'
|
||||
});
|
||||
}
|
||||
|
||||
// Category has many quiz sessions
|
||||
if (models.QuizSession) {
|
||||
Category.hasMany(models.QuizSession, {
|
||||
foreignKey: 'categoryId',
|
||||
as: 'quizSessions'
|
||||
});
|
||||
}
|
||||
|
||||
// Category belongs to many guest settings (for guest-accessible categories)
|
||||
if (models.GuestSettings) {
|
||||
Category.belongsToMany(models.GuestSettings, {
|
||||
through: 'guest_settings_categories',
|
||||
foreignKey: 'categoryId',
|
||||
otherKey: 'guestSettingsId',
|
||||
as: 'guestSettings'
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return Category;
|
||||
};
|
||||
330
models/GuestSession.js
Normal file
330
models/GuestSession.js
Normal file
@@ -0,0 +1,330 @@
|
||||
const { v4: uuidv4 } = require('uuid');
|
||||
const jwt = require('jsonwebtoken');
|
||||
const config = require('../config/config');
|
||||
|
||||
module.exports = (sequelize, DataTypes) => {
|
||||
const GuestSession = sequelize.define('GuestSession', {
|
||||
id: {
|
||||
type: DataTypes.CHAR(36),
|
||||
primaryKey: true,
|
||||
defaultValue: () => uuidv4(),
|
||||
allowNull: false,
|
||||
comment: 'UUID primary key'
|
||||
},
|
||||
guestId: {
|
||||
type: DataTypes.STRING(100),
|
||||
allowNull: false,
|
||||
unique: {
|
||||
msg: 'Guest ID already exists'
|
||||
},
|
||||
field: 'guest_id',
|
||||
validate: {
|
||||
notEmpty: {
|
||||
msg: 'Guest ID cannot be empty'
|
||||
}
|
||||
},
|
||||
comment: 'Unique guest identifier'
|
||||
},
|
||||
sessionToken: {
|
||||
type: DataTypes.STRING(500),
|
||||
allowNull: false,
|
||||
unique: {
|
||||
msg: 'Session token already exists'
|
||||
},
|
||||
field: 'session_token',
|
||||
comment: 'JWT session token'
|
||||
},
|
||||
deviceId: {
|
||||
type: DataTypes.STRING(255),
|
||||
allowNull: true,
|
||||
field: 'device_id',
|
||||
comment: 'Device identifier (optional)'
|
||||
},
|
||||
ipAddress: {
|
||||
type: DataTypes.STRING(45),
|
||||
allowNull: true,
|
||||
field: 'ip_address',
|
||||
comment: 'IP address (supports IPv6)'
|
||||
},
|
||||
userAgent: {
|
||||
type: DataTypes.TEXT,
|
||||
allowNull: true,
|
||||
field: 'user_agent',
|
||||
comment: 'Browser user agent string'
|
||||
},
|
||||
quizzesAttempted: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: false,
|
||||
defaultValue: 0,
|
||||
field: 'quizzes_attempted',
|
||||
validate: {
|
||||
min: 0
|
||||
},
|
||||
comment: 'Number of quizzes attempted by guest'
|
||||
},
|
||||
maxQuizzes: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: false,
|
||||
defaultValue: 3,
|
||||
field: 'max_quizzes',
|
||||
validate: {
|
||||
min: 1,
|
||||
max: 100
|
||||
},
|
||||
comment: 'Maximum quizzes allowed for this guest'
|
||||
},
|
||||
expiresAt: {
|
||||
type: DataTypes.DATE,
|
||||
allowNull: false,
|
||||
field: 'expires_at',
|
||||
validate: {
|
||||
isDate: true,
|
||||
isAfter: new Date().toISOString()
|
||||
},
|
||||
comment: 'Session expiration timestamp'
|
||||
},
|
||||
isConverted: {
|
||||
type: DataTypes.BOOLEAN,
|
||||
allowNull: false,
|
||||
defaultValue: false,
|
||||
field: 'is_converted',
|
||||
comment: 'Whether guest converted to registered user'
|
||||
},
|
||||
convertedUserId: {
|
||||
type: DataTypes.CHAR(36),
|
||||
allowNull: true,
|
||||
field: 'converted_user_id',
|
||||
comment: 'User ID if guest converted to registered user'
|
||||
}
|
||||
}, {
|
||||
sequelize,
|
||||
modelName: 'GuestSession',
|
||||
tableName: 'guest_sessions',
|
||||
timestamps: true,
|
||||
underscored: true,
|
||||
indexes: [
|
||||
{
|
||||
unique: true,
|
||||
fields: ['guest_id']
|
||||
},
|
||||
{
|
||||
unique: true,
|
||||
fields: ['session_token']
|
||||
},
|
||||
{
|
||||
fields: ['expires_at']
|
||||
},
|
||||
{
|
||||
fields: ['is_converted']
|
||||
},
|
||||
{
|
||||
fields: ['converted_user_id']
|
||||
},
|
||||
{
|
||||
fields: ['device_id']
|
||||
},
|
||||
{
|
||||
fields: ['created_at']
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
// Static method to generate guest ID
|
||||
GuestSession.generateGuestId = function() {
|
||||
const timestamp = Date.now();
|
||||
const randomStr = Math.random().toString(36).substring(2, 15);
|
||||
return `guest_${timestamp}_${randomStr}`;
|
||||
};
|
||||
|
||||
// Static method to generate session token (JWT)
|
||||
GuestSession.generateToken = function(guestId, sessionId) {
|
||||
const payload = {
|
||||
guestId,
|
||||
sessionId,
|
||||
type: 'guest'
|
||||
};
|
||||
|
||||
return jwt.sign(payload, config.jwt.secret, {
|
||||
expiresIn: config.guest.sessionExpireHours + 'h'
|
||||
});
|
||||
};
|
||||
|
||||
// Static method to verify and decode token
|
||||
GuestSession.verifyToken = function(token) {
|
||||
try {
|
||||
return jwt.verify(token, config.jwt.secret);
|
||||
} catch (error) {
|
||||
throw new Error('Invalid or expired token');
|
||||
}
|
||||
};
|
||||
|
||||
// Static method to create new guest session
|
||||
GuestSession.createSession = async function(options = {}) {
|
||||
const guestId = GuestSession.generateGuestId();
|
||||
const sessionId = uuidv4();
|
||||
const sessionToken = GuestSession.generateToken(guestId, sessionId);
|
||||
|
||||
const expiryHours = options.expiryHours || config.guest.sessionExpireHours || 24;
|
||||
const expiresAt = new Date(Date.now() + expiryHours * 60 * 60 * 1000);
|
||||
|
||||
const session = await GuestSession.create({
|
||||
id: sessionId,
|
||||
guestId,
|
||||
sessionToken,
|
||||
deviceId: options.deviceId || null,
|
||||
ipAddress: options.ipAddress || null,
|
||||
userAgent: options.userAgent || null,
|
||||
maxQuizzes: options.maxQuizzes || config.guest.maxQuizzes || 3,
|
||||
expiresAt
|
||||
});
|
||||
|
||||
return session;
|
||||
};
|
||||
|
||||
// Instance methods
|
||||
GuestSession.prototype.isExpired = function() {
|
||||
return new Date() > new Date(this.expiresAt);
|
||||
};
|
||||
|
||||
GuestSession.prototype.hasReachedQuizLimit = function() {
|
||||
return this.quizzesAttempted >= this.maxQuizzes;
|
||||
};
|
||||
|
||||
GuestSession.prototype.getRemainingQuizzes = function() {
|
||||
return Math.max(0, this.maxQuizzes - this.quizzesAttempted);
|
||||
};
|
||||
|
||||
GuestSession.prototype.incrementQuizAttempt = async function() {
|
||||
this.quizzesAttempted += 1;
|
||||
await this.save();
|
||||
};
|
||||
|
||||
GuestSession.prototype.extend = async function(hours = 24) {
|
||||
const newExpiry = new Date(Date.now() + hours * 60 * 60 * 1000);
|
||||
this.expiresAt = newExpiry;
|
||||
|
||||
// Regenerate token with new expiry
|
||||
this.sessionToken = GuestSession.generateToken(this.guestId, this.id);
|
||||
await this.save();
|
||||
|
||||
return this;
|
||||
};
|
||||
|
||||
GuestSession.prototype.convertToUser = async function(userId) {
|
||||
this.isConverted = true;
|
||||
this.convertedUserId = userId;
|
||||
await this.save();
|
||||
};
|
||||
|
||||
GuestSession.prototype.getSessionInfo = function() {
|
||||
return {
|
||||
guestId: this.guestId,
|
||||
sessionId: this.id,
|
||||
quizzesAttempted: this.quizzesAttempted,
|
||||
maxQuizzes: this.maxQuizzes,
|
||||
remainingQuizzes: this.getRemainingQuizzes(),
|
||||
expiresAt: this.expiresAt,
|
||||
isExpired: this.isExpired(),
|
||||
hasReachedLimit: this.hasReachedQuizLimit(),
|
||||
isConverted: this.isConverted
|
||||
};
|
||||
};
|
||||
|
||||
// Class methods
|
||||
GuestSession.findByGuestId = async function(guestId) {
|
||||
return await this.findOne({
|
||||
where: { guestId }
|
||||
});
|
||||
};
|
||||
|
||||
GuestSession.findByToken = async function(token) {
|
||||
try {
|
||||
const decoded = GuestSession.verifyToken(token);
|
||||
return await this.findOne({
|
||||
where: {
|
||||
guestId: decoded.guestId,
|
||||
id: decoded.sessionId
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
GuestSession.findActiveSession = async function(guestId) {
|
||||
return await this.findOne({
|
||||
where: {
|
||||
guestId,
|
||||
isConverted: false
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
GuestSession.cleanupExpiredSessions = async function() {
|
||||
const expiredCount = await this.destroy({
|
||||
where: {
|
||||
expiresAt: {
|
||||
[sequelize.Sequelize.Op.lt]: new Date()
|
||||
},
|
||||
isConverted: false
|
||||
}
|
||||
});
|
||||
return expiredCount;
|
||||
};
|
||||
|
||||
GuestSession.getActiveGuestCount = async function() {
|
||||
return await this.count({
|
||||
where: {
|
||||
expiresAt: {
|
||||
[sequelize.Sequelize.Op.gt]: new Date()
|
||||
},
|
||||
isConverted: false
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
GuestSession.getConversionRate = async function() {
|
||||
const total = await this.count();
|
||||
if (total === 0) return 0;
|
||||
|
||||
const converted = await this.count({
|
||||
where: { isConverted: true }
|
||||
});
|
||||
|
||||
return Math.round((converted / total) * 100);
|
||||
};
|
||||
|
||||
// Hooks
|
||||
GuestSession.beforeValidate((session) => {
|
||||
// Ensure UUID is set
|
||||
if (!session.id) {
|
||||
session.id = uuidv4();
|
||||
}
|
||||
|
||||
// Ensure expiry is in the future (only for new records, not updates)
|
||||
if (session.isNewRecord && session.expiresAt && new Date(session.expiresAt) <= new Date()) {
|
||||
throw new Error('Expiry date must be in the future');
|
||||
}
|
||||
});
|
||||
|
||||
// Define associations
|
||||
GuestSession.associate = function(models) {
|
||||
// GuestSession belongs to a User (if converted)
|
||||
if (models.User) {
|
||||
GuestSession.belongsTo(models.User, {
|
||||
foreignKey: 'convertedUserId',
|
||||
as: 'convertedUser'
|
||||
});
|
||||
}
|
||||
|
||||
// GuestSession has many quiz sessions
|
||||
if (models.QuizSession) {
|
||||
GuestSession.hasMany(models.QuizSession, {
|
||||
foreignKey: 'guestSessionId',
|
||||
as: 'quizSessions'
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return GuestSession;
|
||||
};
|
||||
114
models/GuestSettings.js
Normal file
114
models/GuestSettings.js
Normal file
@@ -0,0 +1,114 @@
|
||||
const { v4: uuidv4 } = require('uuid');
|
||||
|
||||
module.exports = (sequelize, DataTypes) => {
|
||||
const GuestSettings = sequelize.define('GuestSettings', {
|
||||
id: {
|
||||
type: DataTypes.CHAR(36),
|
||||
primaryKey: true,
|
||||
defaultValue: () => uuidv4(),
|
||||
allowNull: false,
|
||||
comment: 'UUID primary key'
|
||||
},
|
||||
maxQuizzes: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: false,
|
||||
defaultValue: 3,
|
||||
validate: {
|
||||
min: {
|
||||
args: [1],
|
||||
msg: 'Maximum quizzes must be at least 1'
|
||||
},
|
||||
max: {
|
||||
args: [50],
|
||||
msg: 'Maximum quizzes cannot exceed 50'
|
||||
}
|
||||
},
|
||||
field: 'max_quizzes',
|
||||
comment: 'Maximum number of quizzes a guest can take'
|
||||
},
|
||||
expiryHours: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: false,
|
||||
defaultValue: 24,
|
||||
validate: {
|
||||
min: {
|
||||
args: [1],
|
||||
msg: 'Expiry hours must be at least 1'
|
||||
},
|
||||
max: {
|
||||
args: [168],
|
||||
msg: 'Expiry hours cannot exceed 168 (7 days)'
|
||||
}
|
||||
},
|
||||
field: 'expiry_hours',
|
||||
comment: 'Guest session expiry time in hours'
|
||||
},
|
||||
publicCategories: {
|
||||
type: DataTypes.JSON,
|
||||
allowNull: false,
|
||||
defaultValue: [],
|
||||
get() {
|
||||
const value = this.getDataValue('publicCategories');
|
||||
if (typeof value === 'string') {
|
||||
try {
|
||||
return JSON.parse(value);
|
||||
} catch (e) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
return value || [];
|
||||
},
|
||||
set(value) {
|
||||
this.setDataValue('publicCategories', JSON.stringify(value));
|
||||
},
|
||||
field: 'public_categories',
|
||||
comment: 'Array of category UUIDs accessible to guests'
|
||||
},
|
||||
featureRestrictions: {
|
||||
type: DataTypes.JSON,
|
||||
allowNull: false,
|
||||
defaultValue: {
|
||||
allowBookmarks: false,
|
||||
allowReview: true,
|
||||
allowPracticeMode: true,
|
||||
allowTimedMode: false,
|
||||
allowExamMode: false
|
||||
},
|
||||
get() {
|
||||
const value = this.getDataValue('featureRestrictions');
|
||||
if (typeof value === 'string') {
|
||||
try {
|
||||
return JSON.parse(value);
|
||||
} catch (e) {
|
||||
return {
|
||||
allowBookmarks: false,
|
||||
allowReview: true,
|
||||
allowPracticeMode: true,
|
||||
allowTimedMode: false,
|
||||
allowExamMode: false
|
||||
};
|
||||
}
|
||||
}
|
||||
return value || {
|
||||
allowBookmarks: false,
|
||||
allowReview: true,
|
||||
allowPracticeMode: true,
|
||||
allowTimedMode: false,
|
||||
allowExamMode: false
|
||||
};
|
||||
},
|
||||
set(value) {
|
||||
this.setDataValue('featureRestrictions', JSON.stringify(value));
|
||||
},
|
||||
field: 'feature_restrictions',
|
||||
comment: 'Feature restrictions for guest users'
|
||||
}
|
||||
}, {
|
||||
tableName: 'guest_settings',
|
||||
timestamps: true,
|
||||
underscored: true,
|
||||
comment: 'System-wide guest user settings'
|
||||
});
|
||||
|
||||
return GuestSettings;
|
||||
};
|
||||
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;
|
||||
};
|
||||
134
models/QuizAnswer.js
Normal file
134
models/QuizAnswer.js
Normal file
@@ -0,0 +1,134 @@
|
||||
const { v4: uuidv4 } = require('uuid');
|
||||
|
||||
module.exports = (sequelize, DataTypes) => {
|
||||
const QuizAnswer = sequelize.define('QuizAnswer', {
|
||||
id: {
|
||||
type: DataTypes.CHAR(36),
|
||||
primaryKey: true,
|
||||
defaultValue: () => uuidv4(),
|
||||
allowNull: false,
|
||||
comment: 'UUID primary key'
|
||||
},
|
||||
quizSessionId: {
|
||||
type: DataTypes.CHAR(36),
|
||||
allowNull: false,
|
||||
field: 'quiz_session_id',
|
||||
validate: {
|
||||
notEmpty: {
|
||||
msg: 'Quiz session ID is required'
|
||||
},
|
||||
isUUID: {
|
||||
args: 4,
|
||||
msg: 'Quiz session ID must be a valid UUID'
|
||||
}
|
||||
},
|
||||
comment: 'Foreign key to quiz_sessions table'
|
||||
},
|
||||
questionId: {
|
||||
type: DataTypes.CHAR(36),
|
||||
allowNull: false,
|
||||
field: 'question_id',
|
||||
validate: {
|
||||
notEmpty: {
|
||||
msg: 'Question ID is required'
|
||||
},
|
||||
isUUID: {
|
||||
args: 4,
|
||||
msg: 'Question ID must be a valid UUID'
|
||||
}
|
||||
},
|
||||
comment: 'Foreign key to questions table'
|
||||
},
|
||||
selectedOption: {
|
||||
type: DataTypes.STRING(255),
|
||||
allowNull: false,
|
||||
field: 'selected_option',
|
||||
validate: {
|
||||
notEmpty: {
|
||||
msg: 'Selected option is required'
|
||||
}
|
||||
},
|
||||
comment: 'The option selected by the user'
|
||||
},
|
||||
isCorrect: {
|
||||
type: DataTypes.BOOLEAN,
|
||||
allowNull: false,
|
||||
field: 'is_correct',
|
||||
comment: 'Whether the selected answer was correct'
|
||||
},
|
||||
pointsEarned: {
|
||||
type: DataTypes.INTEGER.UNSIGNED,
|
||||
allowNull: false,
|
||||
defaultValue: 0,
|
||||
field: 'points_earned',
|
||||
validate: {
|
||||
min: {
|
||||
args: [0],
|
||||
msg: 'Points earned must be non-negative'
|
||||
}
|
||||
},
|
||||
comment: 'Points earned for this answer'
|
||||
},
|
||||
timeTaken: {
|
||||
type: DataTypes.INTEGER.UNSIGNED,
|
||||
allowNull: false,
|
||||
defaultValue: 0,
|
||||
field: 'time_taken',
|
||||
validate: {
|
||||
min: {
|
||||
args: [0],
|
||||
msg: 'Time taken must be non-negative'
|
||||
}
|
||||
},
|
||||
comment: 'Time taken to answer in seconds'
|
||||
},
|
||||
answeredAt: {
|
||||
type: DataTypes.DATE,
|
||||
allowNull: false,
|
||||
defaultValue: DataTypes.NOW,
|
||||
field: 'answered_at',
|
||||
comment: 'When the question was answered'
|
||||
}
|
||||
}, {
|
||||
tableName: 'quiz_answers',
|
||||
timestamps: true,
|
||||
underscored: true,
|
||||
indexes: [
|
||||
{
|
||||
fields: ['quiz_session_id'],
|
||||
name: 'idx_quiz_answers_session_id'
|
||||
},
|
||||
{
|
||||
fields: ['question_id'],
|
||||
name: 'idx_quiz_answers_question_id'
|
||||
},
|
||||
{
|
||||
fields: ['quiz_session_id', 'question_id'],
|
||||
unique: true,
|
||||
name: 'idx_quiz_answers_session_question_unique'
|
||||
},
|
||||
{
|
||||
fields: ['is_correct'],
|
||||
name: 'idx_quiz_answers_is_correct'
|
||||
},
|
||||
{
|
||||
fields: ['answered_at'],
|
||||
name: 'idx_quiz_answers_answered_at'
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
// Associations
|
||||
QuizAnswer.associate = (models) => {
|
||||
QuizAnswer.belongsTo(models.QuizSession, {
|
||||
foreignKey: 'quizSessionId',
|
||||
as: 'quizSession'
|
||||
});
|
||||
QuizAnswer.belongsTo(models.Question, {
|
||||
foreignKey: 'questionId',
|
||||
as: 'question'
|
||||
});
|
||||
};
|
||||
|
||||
return QuizAnswer;
|
||||
};
|
||||
634
models/QuizSession.js
Normal file
634
models/QuizSession.js
Normal file
@@ -0,0 +1,634 @@
|
||||
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;
|
||||
};
|
||||
73
models/QuizSessionQuestion.js
Normal file
73
models/QuizSessionQuestion.js
Normal file
@@ -0,0 +1,73 @@
|
||||
const { DataTypes } = require('sequelize');
|
||||
const { v4: uuidv4 } = require('uuid');
|
||||
|
||||
module.exports = (sequelize) => {
|
||||
const QuizSessionQuestion = sequelize.define('QuizSessionQuestion', {
|
||||
id: {
|
||||
type: DataTypes.CHAR(36),
|
||||
primaryKey: true,
|
||||
allowNull: false
|
||||
},
|
||||
quizSessionId: {
|
||||
type: DataTypes.CHAR(36),
|
||||
allowNull: false,
|
||||
field: 'quiz_session_id'
|
||||
},
|
||||
questionId: {
|
||||
type: DataTypes.CHAR(36),
|
||||
allowNull: false,
|
||||
field: 'question_id'
|
||||
},
|
||||
questionOrder: {
|
||||
type: DataTypes.INTEGER.UNSIGNED,
|
||||
allowNull: false,
|
||||
field: 'question_order',
|
||||
validate: {
|
||||
min: 1
|
||||
}
|
||||
}
|
||||
}, {
|
||||
tableName: 'quiz_session_questions',
|
||||
underscored: true,
|
||||
timestamps: true,
|
||||
createdAt: 'created_at',
|
||||
updatedAt: 'updated_at',
|
||||
indexes: [
|
||||
{
|
||||
fields: ['quiz_session_id']
|
||||
},
|
||||
{
|
||||
fields: ['question_id']
|
||||
},
|
||||
{
|
||||
fields: ['quiz_session_id', 'question_order']
|
||||
},
|
||||
{
|
||||
unique: true,
|
||||
fields: ['quiz_session_id', 'question_id']
|
||||
}
|
||||
],
|
||||
hooks: {
|
||||
beforeValidate: (quizSessionQuestion) => {
|
||||
if (!quizSessionQuestion.id) {
|
||||
quizSessionQuestion.id = uuidv4();
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Define associations
|
||||
QuizSessionQuestion.associate = (models) => {
|
||||
QuizSessionQuestion.belongsTo(models.QuizSession, {
|
||||
foreignKey: 'quizSessionId',
|
||||
as: 'quizSession'
|
||||
});
|
||||
|
||||
QuizSessionQuestion.belongsTo(models.Question, {
|
||||
foreignKey: 'questionId',
|
||||
as: 'question'
|
||||
});
|
||||
};
|
||||
|
||||
return QuizSessionQuestion;
|
||||
};
|
||||
333
models/User.js
Normal file
333
models/User.js
Normal file
@@ -0,0 +1,333 @@
|
||||
const bcrypt = require('bcrypt');
|
||||
const { v4: uuidv4 } = require('uuid');
|
||||
|
||||
module.exports = (sequelize, DataTypes) => {
|
||||
const User = sequelize.define('User', {
|
||||
id: {
|
||||
type: DataTypes.CHAR(36),
|
||||
primaryKey: true,
|
||||
defaultValue: () => uuidv4(),
|
||||
allowNull: false,
|
||||
comment: 'UUID primary key'
|
||||
},
|
||||
username: {
|
||||
type: DataTypes.STRING(50),
|
||||
allowNull: false,
|
||||
unique: {
|
||||
msg: 'Username already exists'
|
||||
},
|
||||
validate: {
|
||||
notEmpty: {
|
||||
msg: 'Username cannot be empty'
|
||||
},
|
||||
len: {
|
||||
args: [3, 50],
|
||||
msg: 'Username must be between 3 and 50 characters'
|
||||
},
|
||||
isAlphanumeric: {
|
||||
msg: 'Username must contain only letters and numbers'
|
||||
}
|
||||
},
|
||||
comment: 'Unique username'
|
||||
},
|
||||
email: {
|
||||
type: DataTypes.STRING(100),
|
||||
allowNull: false,
|
||||
unique: {
|
||||
msg: 'Email already exists'
|
||||
},
|
||||
validate: {
|
||||
notEmpty: {
|
||||
msg: 'Email cannot be empty'
|
||||
},
|
||||
isEmail: {
|
||||
msg: 'Must be a valid email address'
|
||||
}
|
||||
},
|
||||
comment: 'User email address'
|
||||
},
|
||||
password: {
|
||||
type: DataTypes.STRING(255),
|
||||
allowNull: false,
|
||||
validate: {
|
||||
notEmpty: {
|
||||
msg: 'Password cannot be empty'
|
||||
},
|
||||
len: {
|
||||
args: [6, 255],
|
||||
msg: 'Password must be at least 6 characters'
|
||||
}
|
||||
},
|
||||
comment: 'Hashed password'
|
||||
},
|
||||
role: {
|
||||
type: DataTypes.ENUM('admin', 'user'),
|
||||
allowNull: false,
|
||||
defaultValue: 'user',
|
||||
validate: {
|
||||
isIn: {
|
||||
args: [['admin', 'user']],
|
||||
msg: 'Role must be either admin or user'
|
||||
}
|
||||
},
|
||||
comment: 'User role'
|
||||
},
|
||||
profileImage: {
|
||||
type: DataTypes.STRING(255),
|
||||
allowNull: true,
|
||||
field: 'profile_image',
|
||||
comment: 'Profile image URL'
|
||||
},
|
||||
isActive: {
|
||||
type: DataTypes.BOOLEAN,
|
||||
allowNull: false,
|
||||
defaultValue: true,
|
||||
field: 'is_active',
|
||||
comment: 'Account active status'
|
||||
},
|
||||
|
||||
// Statistics
|
||||
totalQuizzes: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: false,
|
||||
defaultValue: 0,
|
||||
field: 'total_quizzes',
|
||||
validate: {
|
||||
min: 0
|
||||
},
|
||||
comment: 'Total number of quizzes taken'
|
||||
},
|
||||
quizzesPassed: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: false,
|
||||
defaultValue: 0,
|
||||
field: 'quizzes_passed',
|
||||
validate: {
|
||||
min: 0
|
||||
},
|
||||
comment: 'Number of quizzes passed'
|
||||
},
|
||||
totalQuestionsAnswered: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: false,
|
||||
defaultValue: 0,
|
||||
field: 'total_questions_answered',
|
||||
validate: {
|
||||
min: 0
|
||||
},
|
||||
comment: 'Total questions answered'
|
||||
},
|
||||
correctAnswers: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: false,
|
||||
defaultValue: 0,
|
||||
field: 'correct_answers',
|
||||
validate: {
|
||||
min: 0
|
||||
},
|
||||
comment: 'Number of correct answers'
|
||||
},
|
||||
currentStreak: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: false,
|
||||
defaultValue: 0,
|
||||
field: 'current_streak',
|
||||
validate: {
|
||||
min: 0
|
||||
},
|
||||
comment: 'Current daily streak'
|
||||
},
|
||||
longestStreak: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: false,
|
||||
defaultValue: 0,
|
||||
field: 'longest_streak',
|
||||
validate: {
|
||||
min: 0
|
||||
},
|
||||
comment: 'Longest daily streak achieved'
|
||||
},
|
||||
|
||||
// Timestamps
|
||||
lastLogin: {
|
||||
type: DataTypes.DATE,
|
||||
allowNull: true,
|
||||
field: 'last_login',
|
||||
comment: 'Last login timestamp'
|
||||
},
|
||||
lastQuizDate: {
|
||||
type: DataTypes.DATE,
|
||||
allowNull: true,
|
||||
field: 'last_quiz_date',
|
||||
comment: 'Date of last quiz taken'
|
||||
}
|
||||
}, {
|
||||
sequelize,
|
||||
modelName: 'User',
|
||||
tableName: 'users',
|
||||
timestamps: true,
|
||||
underscored: true,
|
||||
indexes: [
|
||||
{
|
||||
unique: true,
|
||||
fields: ['email']
|
||||
},
|
||||
{
|
||||
unique: true,
|
||||
fields: ['username']
|
||||
},
|
||||
{
|
||||
fields: ['role']
|
||||
},
|
||||
{
|
||||
fields: ['is_active']
|
||||
},
|
||||
{
|
||||
fields: ['created_at']
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
// Instance methods
|
||||
User.prototype.comparePassword = async function(candidatePassword) {
|
||||
try {
|
||||
return await bcrypt.compare(candidatePassword, this.password);
|
||||
} catch (error) {
|
||||
throw new Error('Password comparison failed');
|
||||
}
|
||||
};
|
||||
|
||||
User.prototype.toJSON = function() {
|
||||
const values = { ...this.get() };
|
||||
delete values.password; // Never expose password in JSON
|
||||
return values;
|
||||
};
|
||||
|
||||
User.prototype.updateStreak = function() {
|
||||
const today = new Date();
|
||||
today.setHours(0, 0, 0, 0);
|
||||
|
||||
if (this.lastQuizDate) {
|
||||
const lastQuiz = new Date(this.lastQuizDate);
|
||||
lastQuiz.setHours(0, 0, 0, 0);
|
||||
|
||||
const daysDiff = Math.floor((today - lastQuiz) / (1000 * 60 * 60 * 24));
|
||||
|
||||
if (daysDiff === 1) {
|
||||
// Consecutive day - increment streak
|
||||
this.currentStreak += 1;
|
||||
if (this.currentStreak > this.longestStreak) {
|
||||
this.longestStreak = this.currentStreak;
|
||||
}
|
||||
} else if (daysDiff > 1) {
|
||||
// Streak broken - reset
|
||||
this.currentStreak = 1;
|
||||
}
|
||||
// If daysDiff === 0, same day - no change to streak
|
||||
} else {
|
||||
// First quiz
|
||||
this.currentStreak = 1;
|
||||
this.longestStreak = 1;
|
||||
}
|
||||
|
||||
this.lastQuizDate = new Date();
|
||||
};
|
||||
|
||||
User.prototype.calculateAccuracy = function() {
|
||||
if (this.totalQuestionsAnswered === 0) return 0;
|
||||
return ((this.correctAnswers / this.totalQuestionsAnswered) * 100).toFixed(2);
|
||||
};
|
||||
|
||||
User.prototype.getPassRate = function() {
|
||||
if (this.totalQuizzes === 0) return 0;
|
||||
return ((this.quizzesPassed / this.totalQuizzes) * 100).toFixed(2);
|
||||
};
|
||||
|
||||
User.prototype.toSafeJSON = function() {
|
||||
const values = { ...this.get() };
|
||||
delete values.password;
|
||||
return values;
|
||||
};
|
||||
|
||||
// Class methods
|
||||
User.findByEmail = async function(email) {
|
||||
return await this.findOne({ where: { email, isActive: true } });
|
||||
};
|
||||
|
||||
User.findByUsername = async function(username) {
|
||||
return await this.findOne({ where: { username, isActive: true } });
|
||||
};
|
||||
|
||||
// Hooks
|
||||
User.beforeCreate(async (user) => {
|
||||
// Hash password before creating user
|
||||
if (user.password) {
|
||||
const salt = await bcrypt.genSalt(10);
|
||||
user.password = await bcrypt.hash(user.password, salt);
|
||||
}
|
||||
|
||||
// Ensure UUID is set
|
||||
if (!user.id) {
|
||||
user.id = uuidv4();
|
||||
}
|
||||
});
|
||||
|
||||
User.beforeUpdate(async (user) => {
|
||||
// Hash password if it was changed
|
||||
if (user.changed('password')) {
|
||||
const salt = await bcrypt.genSalt(10);
|
||||
user.password = await bcrypt.hash(user.password, salt);
|
||||
}
|
||||
});
|
||||
|
||||
User.beforeBulkCreate(async (users) => {
|
||||
for (const user of users) {
|
||||
if (user.password) {
|
||||
const salt = await bcrypt.genSalt(10);
|
||||
user.password = await bcrypt.hash(user.password, salt);
|
||||
}
|
||||
if (!user.id) {
|
||||
user.id = uuidv4();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Define associations
|
||||
User.associate = function(models) {
|
||||
// User has many quiz sessions (when QuizSession model exists)
|
||||
if (models.QuizSession) {
|
||||
User.hasMany(models.QuizSession, {
|
||||
foreignKey: 'userId',
|
||||
as: 'quizSessions'
|
||||
});
|
||||
}
|
||||
|
||||
// User has many bookmarks (when Question model exists)
|
||||
if (models.Question) {
|
||||
User.belongsToMany(models.Question, {
|
||||
through: 'user_bookmarks',
|
||||
foreignKey: 'userId',
|
||||
otherKey: 'questionId',
|
||||
as: 'bookmarkedQuestions'
|
||||
});
|
||||
|
||||
// User has created questions (if admin)
|
||||
User.hasMany(models.Question, {
|
||||
foreignKey: 'createdBy',
|
||||
as: 'createdQuestions'
|
||||
});
|
||||
}
|
||||
|
||||
// User has many achievements (when Achievement model exists)
|
||||
if (models.Achievement) {
|
||||
User.belongsToMany(models.Achievement, {
|
||||
through: 'user_achievements',
|
||||
foreignKey: 'userId',
|
||||
otherKey: 'achievementId',
|
||||
as: 'achievements'
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return User;
|
||||
};
|
||||
96
models/UserBookmark.js
Normal file
96
models/UserBookmark.js
Normal file
@@ -0,0 +1,96 @@
|
||||
/**
|
||||
* UserBookmark Model
|
||||
* Junction table for user-saved questions
|
||||
*/
|
||||
|
||||
const { DataTypes } = require('sequelize');
|
||||
const { v4: uuidv4 } = require('uuid');
|
||||
|
||||
module.exports = (sequelize) => {
|
||||
const UserBookmark = sequelize.define('UserBookmark', {
|
||||
id: {
|
||||
type: DataTypes.UUID,
|
||||
defaultValue: () => uuidv4(),
|
||||
primaryKey: true,
|
||||
allowNull: false,
|
||||
comment: 'Primary key UUID'
|
||||
},
|
||||
userId: {
|
||||
type: DataTypes.UUID,
|
||||
allowNull: false,
|
||||
references: {
|
||||
model: 'users',
|
||||
key: 'id'
|
||||
},
|
||||
onDelete: 'CASCADE',
|
||||
comment: 'Reference to user who bookmarked'
|
||||
},
|
||||
questionId: {
|
||||
type: DataTypes.UUID,
|
||||
allowNull: false,
|
||||
references: {
|
||||
model: 'questions',
|
||||
key: 'id'
|
||||
},
|
||||
onDelete: 'CASCADE',
|
||||
comment: 'Reference to bookmarked question'
|
||||
},
|
||||
notes: {
|
||||
type: DataTypes.TEXT,
|
||||
allowNull: true,
|
||||
comment: 'Optional notes about the bookmark'
|
||||
},
|
||||
createdAt: {
|
||||
type: DataTypes.DATE,
|
||||
allowNull: false,
|
||||
defaultValue: DataTypes.NOW,
|
||||
field: 'created_at',
|
||||
comment: 'When the bookmark was created'
|
||||
},
|
||||
updatedAt: {
|
||||
type: DataTypes.DATE,
|
||||
allowNull: false,
|
||||
defaultValue: DataTypes.NOW,
|
||||
field: 'updated_at',
|
||||
comment: 'When the bookmark was last updated'
|
||||
}
|
||||
}, {
|
||||
tableName: 'user_bookmarks',
|
||||
timestamps: true,
|
||||
underscored: true,
|
||||
indexes: [
|
||||
{
|
||||
unique: true,
|
||||
fields: ['user_id', 'question_id'],
|
||||
name: 'idx_user_question_unique'
|
||||
},
|
||||
{
|
||||
fields: ['user_id'],
|
||||
name: 'idx_user_bookmarks_user'
|
||||
},
|
||||
{
|
||||
fields: ['question_id'],
|
||||
name: 'idx_user_bookmarks_question'
|
||||
},
|
||||
{
|
||||
fields: ['bookmarked_at'],
|
||||
name: 'idx_user_bookmarks_date'
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
// Define associations
|
||||
UserBookmark.associate = function(models) {
|
||||
UserBookmark.belongsTo(models.User, {
|
||||
foreignKey: 'userId',
|
||||
as: 'User'
|
||||
});
|
||||
|
||||
UserBookmark.belongsTo(models.Question, {
|
||||
foreignKey: 'questionId',
|
||||
as: 'Question'
|
||||
});
|
||||
};
|
||||
|
||||
return UserBookmark;
|
||||
};
|
||||
57
models/index.js
Normal file
57
models/index.js
Normal file
@@ -0,0 +1,57 @@
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const Sequelize = require('sequelize');
|
||||
const basename = path.basename(__filename);
|
||||
const env = process.env.NODE_ENV || 'development';
|
||||
const config = require('../config/database')[env];
|
||||
const db = {};
|
||||
|
||||
let sequelize;
|
||||
if (config.use_env_variable) {
|
||||
sequelize = new Sequelize(process.env[config.use_env_variable], config);
|
||||
} else {
|
||||
sequelize = new Sequelize(config.database, config.username, config.password, config);
|
||||
}
|
||||
|
||||
// Import all model files
|
||||
fs
|
||||
.readdirSync(__dirname)
|
||||
.filter(file => {
|
||||
return (
|
||||
file.indexOf('.') !== 0 &&
|
||||
file !== basename &&
|
||||
file.slice(-3) === '.js' &&
|
||||
file.indexOf('.test.js') === -1
|
||||
);
|
||||
})
|
||||
.forEach(file => {
|
||||
const model = require(path.join(__dirname, file))(sequelize, Sequelize.DataTypes);
|
||||
db[model.name] = model;
|
||||
});
|
||||
|
||||
// Setup model associations
|
||||
Object.keys(db).forEach(modelName => {
|
||||
if (db[modelName].associate) {
|
||||
db[modelName].associate(db);
|
||||
}
|
||||
});
|
||||
|
||||
db.sequelize = sequelize;
|
||||
db.Sequelize = Sequelize;
|
||||
|
||||
// Test database connection
|
||||
const testConnection = async () => {
|
||||
try {
|
||||
await sequelize.authenticate();
|
||||
console.log('✅ Database connection established successfully.');
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('❌ Unable to connect to the database:', error.message);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
// Export connection test function
|
||||
db.testConnection = testConnection;
|
||||
|
||||
module.exports = db;
|
||||
Reference in New Issue
Block a user