334 lines
7.9 KiB
JavaScript
334 lines
7.9 KiB
JavaScript
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;
|
|
};
|