331 lines
8.1 KiB
JavaScript
331 lines
8.1 KiB
JavaScript
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;
|
|
};
|