const { GuestSession, Category, User, QuizSession, sequelize } = require('../models'); const { v4: uuidv4 } = require('uuid'); const jwt = require('jsonwebtoken'); const config = require('../config/config'); /** * @desc Start a new guest session * @route POST /api/guest/start-session * @access Public */ exports.startGuestSession = async (req, res) => { try { const { deviceId } = req.body; // Get IP address const ipAddress = req.ip || req.connection.remoteAddress || 'unknown'; // Get user agent const userAgent = req.headers['user-agent'] || 'unknown'; // Generate unique guest_id const timestamp = Date.now(); const randomString = Math.random().toString(36).substring(2, 10); const guestId = `guest_${timestamp}_${randomString}`; // Calculate expiry (24 hours from now by default) const expiryHours = parseInt(config.guest.sessionExpireHours) || 24; const expiresAt = new Date(Date.now() + expiryHours * 60 * 60 * 1000); const maxQuizzes = parseInt(config.guest.maxQuizzes) || 3; // Generate session token (JWT) before creating session const sessionToken = jwt.sign( { guestId }, config.jwt.secret, { expiresIn: `${expiryHours}h` } ); // Create guest session const guestSession = await GuestSession.create({ guestId: guestId, sessionToken: sessionToken, deviceId: deviceId || null, ipAddress: ipAddress, userAgent: userAgent, expiresAt: expiresAt, maxQuizzes: maxQuizzes, quizzesAttempted: 0, isConverted: false }); // Get guest-accessible categories const categories = await Category.findAll({ where: { isActive: true, guestAccessible: true }, attributes: ['id', 'name', 'slug', 'description', 'icon', 'color', 'questionCount'], order: [['displayOrder', 'ASC'], ['name', 'ASC']] }); // Return response res.status(201).json({ success: true, message: 'Guest session created successfully', data: { guestId: guestSession.guestId, sessionToken, expiresAt: guestSession.expiresAt, expiresIn: `${expiryHours} hours`, restrictions: { maxQuizzes: guestSession.maxQuizzes, quizzesRemaining: guestSession.maxQuizzes - guestSession.quizzesAttempted, features: { canTakeQuizzes: true, canViewResults: true, canBookmarkQuestions: false, canTrackProgress: false, canEarnAchievements: false } }, availableCategories: categories, upgradeMessage: 'Sign up to unlock unlimited quizzes, track progress, and earn achievements!' } }); } catch (error) { console.error('Error creating guest session:', error); res.status(500).json({ success: false, message: 'Error creating guest session', error: error.message }); } }; /** * @desc Get guest session details * @route GET /api/guest/session/:guestId * @access Public */ exports.getGuestSession = async (req, res) => { try { const { guestId } = req.params; // Find guest session const guestSession = await GuestSession.findOne({ where: { guestId: guestId } }); if (!guestSession) { return res.status(404).json({ success: false, message: 'Guest session not found' }); } // Check if session is expired if (guestSession.isExpired()) { return res.status(410).json({ success: false, message: 'Guest session has expired. Please start a new session.', expired: true }); } // Check if session is converted if (guestSession.isConverted) { return res.status(410).json({ success: false, message: 'This guest session has been converted to a user account', converted: true, userId: guestSession.convertedUserId }); } // Get guest-accessible categories const categories = await Category.findAll({ where: { isActive: true, guestAccessible: true }, attributes: ['id', 'name', 'slug', 'description', 'icon', 'color', 'questionCount'], order: [['displayOrder', 'ASC'], ['name', 'ASC']] }); // Calculate time until expiry const now = new Date(); const expiresAt = new Date(guestSession.expiresAt); const hoursRemaining = Math.max(0, Math.floor((expiresAt - now) / (1000 * 60 * 60))); const minutesRemaining = Math.max(0, Math.floor(((expiresAt - now) % (1000 * 60 * 60)) / (1000 * 60))); // Return session details res.status(200).json({ success: true, data: { guestId: guestSession.guestId, expiresAt: guestSession.expiresAt, expiresIn: `${hoursRemaining}h ${minutesRemaining}m`, isExpired: false, restrictions: { maxQuizzes: guestSession.maxQuizzes, quizzesAttempted: guestSession.quizzesAttempted, quizzesRemaining: Math.max(0, guestSession.maxQuizzes - guestSession.quizzesAttempted), features: { canTakeQuizzes: guestSession.quizzesAttempted < guestSession.maxQuizzes, canViewResults: true, canBookmarkQuestions: false, canTrackProgress: false, canEarnAchievements: false } }, availableCategories: categories, upgradeMessage: 'Sign up to unlock unlimited quizzes, track progress, and earn achievements!' } }); } catch (error) { console.error('Error getting guest session:', error); res.status(500).json({ success: false, message: 'Error retrieving guest session', error: error.message }); } }; /** * @desc Check guest quiz limit * @route GET /api/guest/quiz-limit * @access Protected (Guest Token Required) */ exports.checkQuizLimit = async (req, res) => { try { // Guest session is already verified and attached by middleware const guestSession = req.guestSession; // Calculate remaining quizzes const quizzesRemaining = guestSession.maxQuizzes - guestSession.quizzesAttempted; const hasReachedLimit = quizzesRemaining <= 0; // Calculate time until reset (session expiry) const now = new Date(); const expiresAt = new Date(guestSession.expiresAt); const timeRemainingMs = expiresAt - now; const hoursRemaining = Math.floor(timeRemainingMs / (1000 * 60 * 60)); const minutesRemaining = Math.floor((timeRemainingMs % (1000 * 60 * 60)) / (1000 * 60)); // Format reset time let resetTime; if (hoursRemaining > 0) { resetTime = `${hoursRemaining}h ${minutesRemaining}m`; } else { resetTime = `${minutesRemaining}m`; } // Prepare response const response = { success: true, data: { guestId: guestSession.guestId, quizLimit: { maxQuizzes: guestSession.maxQuizzes, quizzesAttempted: guestSession.quizzesAttempted, quizzesRemaining: Math.max(0, quizzesRemaining), hasReachedLimit: hasReachedLimit }, session: { expiresAt: guestSession.expiresAt, timeRemaining: resetTime, resetTime: resetTime } } }; // Add upgrade prompt if limit reached if (hasReachedLimit) { response.data.upgradePrompt = { message: 'You have reached your quiz limit!', benefits: [ 'Unlimited quizzes', 'Track your progress over time', 'Earn achievements and badges', 'Bookmark questions for review', 'Compete on leaderboards' ], callToAction: 'Sign up now to continue learning!' }; response.message = 'Quiz limit reached. Sign up to continue!'; } else { response.message = `You have ${quizzesRemaining} quiz${quizzesRemaining === 1 ? '' : 'zes'} remaining`; } res.status(200).json(response); } catch (error) { console.error('Error checking quiz limit:', error); res.status(500).json({ success: false, message: 'Error checking quiz limit', error: error.message }); } }; /** * @desc Convert guest session to registered user account * @route POST /api/guest/convert * @access Protected (Guest Token Required) */ exports.convertGuestToUser = async (req, res) => { const transaction = await sequelize.transaction(); try { const { username, email, password } = req.body; const guestSession = req.guestSession; // Attached by middleware // Validate required fields if (!username || !email || !password) { await transaction.rollback(); return res.status(400).json({ success: false, message: 'Username, email, and password are required' }); } // Validate username length if (username.length < 3 || username.length > 50) { await transaction.rollback(); return res.status(400).json({ success: false, message: 'Username must be between 3 and 50 characters' }); } // Validate email format const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; if (!emailRegex.test(email)) { await transaction.rollback(); return res.status(400).json({ success: false, message: 'Invalid email format' }); } // Validate password strength if (password.length < 8) { await transaction.rollback(); return res.status(400).json({ success: false, message: 'Password must be at least 8 characters long' }); } // Check if email already exists const existingEmail = await User.findOne({ where: { email: email.toLowerCase() }, transaction }); if (existingEmail) { await transaction.rollback(); return res.status(400).json({ success: false, message: 'Email already registered' }); } // Check if username already exists const existingUsername = await User.findOne({ where: { username }, transaction }); if (existingUsername) { await transaction.rollback(); return res.status(400).json({ success: false, message: 'Username already taken' }); } // Create new user account (password will be hashed by User model hook) const user = await User.create({ username, email: email.toLowerCase(), password, role: 'user' }, { transaction }); // Migrate quiz sessions from guest to user const migratedSessions = await QuizSession.update( { userId: user.id, guestSessionId: null }, { where: { guestSessionId: guestSession.id }, transaction } ); // Mark guest session as converted await guestSession.update({ isConverted: true, convertedUserId: user.id }, { transaction }); // Recalculate user stats from migrated sessions const quizSessions = await QuizSession.findAll({ where: { userId: user.id, status: 'completed' }, transaction }); let totalQuizzes = quizSessions.length; let quizzesPassed = 0; let totalQuestionsAnswered = 0; let correctAnswers = 0; quizSessions.forEach(session => { if (session.isPassed) quizzesPassed++; totalQuestionsAnswered += session.questionsAnswered || 0; correctAnswers += session.correctAnswers || 0; }); // Update user stats await user.update({ totalQuizzes, quizzesPassed, totalQuestionsAnswered, correctAnswers }, { transaction }); // Commit transaction await transaction.commit(); // Generate JWT token for the new user const token = jwt.sign( { userId: user.id, email: user.email, username: user.username, role: user.role }, config.jwt.secret, { expiresIn: config.jwt.expire } ); // Return response res.status(201).json({ success: true, message: 'Guest account successfully converted to registered user', data: { user: user.toSafeJSON(), token, migration: { quizzesTransferred: migratedSessions[0], stats: { totalQuizzes, quizzesPassed, totalQuestionsAnswered, correctAnswers, accuracy: totalQuestionsAnswered > 0 ? ((correctAnswers / totalQuestionsAnswered) * 100).toFixed(2) : 0 } } } }); } catch (error) { if (!transaction.finished) { await transaction.rollback(); } console.error('Error converting guest to user:', error); console.error('Error stack:', error.stack); res.status(500).json({ success: false, message: 'Error converting guest account', error: error.message, stack: process.env.NODE_ENV === 'development' ? error.stack : undefined }); } };