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