448 lines
13 KiB
JavaScript
448 lines
13 KiB
JavaScript
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
|
|
});
|
|
}
|
|
};
|