const { Category, Question } = require('../models'); /** * @desc Get all active categories * @route GET /api/categories * @access Public */ exports.getAllCategories = async (req, res) => { try { // Check if request is from guest or authenticated user const isGuest = !req.user; // If no user attached, it's a guest/public request // Build query conditions const whereConditions = { isActive: true }; // If guest, only show guest-accessible categories if (isGuest) { whereConditions.guestAccessible = true; } // Fetch categories const categories = await Category.findAll({ where: whereConditions, attributes: [ 'id', 'name', 'slug', 'description', 'icon', 'color', 'questionCount', 'displayOrder', 'guestAccessible' ], order: [ ['displayOrder', 'ASC'], ['name', 'ASC'] ] }); res.status(200).json({ success: true, count: categories.length, data: categories, message: isGuest ? `${categories.length} guest-accessible categories available` : `${categories.length} categories available` }); } catch (error) { console.error('Error fetching categories:', error); res.status(500).json({ success: false, message: 'Error fetching categories', error: error.message }); } }; /** * @desc Get category details by ID * @route GET /api/categories/:id * @access Public (with optional auth for access control) */ exports.getCategoryById = async (req, res) => { try { const { id } = req.params; const isGuest = !req.user; // Validate ID (accepts UUID or numeric) if (!id) { return res.status(400).json({ success: false, message: 'Invalid category ID' }); } // UUID format validation (basic check) const isUUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(id); const isNumeric = !isNaN(id) && Number.isInteger(Number(id)); if (!isUUID && !isNumeric) { return res.status(400).json({ success: false, message: 'Invalid category ID format' }); } // Find category const category = await Category.findByPk(id, { attributes: [ 'id', 'name', 'slug', 'description', 'icon', 'color', 'questionCount', 'displayOrder', 'guestAccessible', 'isActive' ] }); // Check if category exists if (!category) { return res.status(404).json({ success: false, message: 'Category not found' }); } // Check if category is active if (!category.isActive) { return res.status(404).json({ success: false, message: 'Category not found' }); } // Check guest access if (isGuest && !category.guestAccessible) { return res.status(403).json({ success: false, message: 'This category requires authentication. Please register or login to access.', requiresAuth: true }); } // Get question preview (first 5 questions) const questionPreview = await Question.findAll({ where: { categoryId: id, isActive: true }, attributes: [ 'id', 'questionText', 'questionType', 'difficulty', 'points', 'timesAttempted', 'timesCorrect' ], order: [['createdAt', 'ASC']], limit: 5 }); // Calculate category stats const allQuestions = await Question.findAll({ where: { categoryId: id, isActive: true }, attributes: ['difficulty', 'timesAttempted', 'timesCorrect'] }); const stats = { totalQuestions: allQuestions.length, questionsByDifficulty: { easy: allQuestions.filter(q => q.difficulty === 'easy').length, medium: allQuestions.filter(q => q.difficulty === 'medium').length, hard: allQuestions.filter(q => q.difficulty === 'hard').length }, totalAttempts: allQuestions.reduce((sum, q) => sum + (q.timesAttempted || 0), 0), totalCorrect: allQuestions.reduce((sum, q) => sum + (q.timesCorrect || 0), 0) }; // Calculate average accuracy stats.averageAccuracy = stats.totalAttempts > 0 ? Math.round((stats.totalCorrect / stats.totalAttempts) * 100) : 0; // Prepare response const categoryData = { id: category.id, name: category.name, slug: category.slug, description: category.description, icon: category.icon, color: category.color, questionCount: category.questionCount, displayOrder: category.displayOrder, guestAccessible: category.guestAccessible }; res.status(200).json({ success: true, data: { category: categoryData, questionPreview: questionPreview.map(q => ({ id: q.id, questionText: q.questionText, questionType: q.questionType, difficulty: q.difficulty, points: q.points, accuracy: q.timesAttempted > 0 ? Math.round((q.timesCorrect / q.timesAttempted) * 100) : 0 })), stats }, message: `Category details retrieved successfully` }); } catch (error) { console.error('Error fetching category details:', error); res.status(500).json({ success: false, message: 'Error fetching category details', error: error.message }); } }; /** * @desc Create new category (Admin only) * @route POST /api/categories * @access Private/Admin */ exports.createCategory = async (req, res) => { try { const { name, slug, description, icon, color, guestAccessible, displayOrder } = req.body; // Validate required fields if (!name) { return res.status(400).json({ success: false, message: 'Category name is required' }); } // Check if category with same name exists const existingByName = await Category.findOne({ where: { name } }); if (existingByName) { return res.status(400).json({ success: false, message: 'A category with this name already exists' }); } // Check if custom slug provided and if it exists if (slug) { const existingBySlug = await Category.findOne({ where: { slug } }); if (existingBySlug) { return res.status(400).json({ success: false, message: 'A category with this slug already exists' }); } } // Create category (slug will be auto-generated by model hook if not provided) const category = await Category.create({ name, slug, description: description || null, icon: icon || null, color: color || '#3B82F6', guestAccessible: guestAccessible !== undefined ? guestAccessible : false, displayOrder: displayOrder || 0, isActive: true, questionCount: 0, quizCount: 0 }); res.status(201).json({ success: true, data: { id: category.id, name: category.name, slug: category.slug, description: category.description, icon: category.icon, color: category.color, guestAccessible: category.guestAccessible, displayOrder: category.displayOrder, questionCount: category.questionCount, isActive: category.isActive }, message: 'Category created successfully' }); } catch (error) { console.error('Error creating category:', error); res.status(500).json({ success: false, message: 'Error creating category', error: error.message }); } }; /** * @desc Update category (Admin only) * @route PUT /api/categories/:id * @access Private/Admin */ exports.updateCategory = async (req, res) => { try { const { id } = req.params; const { name, slug, description, icon, color, guestAccessible, displayOrder, isActive } = req.body; // Validate ID if (!id) { return res.status(400).json({ success: false, message: 'Category ID is required' }); } // Find category const category = await Category.findByPk(id); if (!category) { return res.status(404).json({ success: false, message: 'Category not found' }); } // Check if new name conflicts with existing category if (name && name !== category.name) { const existingByName = await Category.findOne({ where: { name } }); if (existingByName) { return res.status(400).json({ success: false, message: 'A category with this name already exists' }); } } // Check if new slug conflicts with existing category if (slug && slug !== category.slug) { const existingBySlug = await Category.findOne({ where: { slug } }); if (existingBySlug) { return res.status(400).json({ success: false, message: 'A category with this slug already exists' }); } } // Update category const updateData = {}; if (name !== undefined) updateData.name = name; if (slug !== undefined) updateData.slug = slug; if (description !== undefined) updateData.description = description; if (icon !== undefined) updateData.icon = icon; if (color !== undefined) updateData.color = color; if (guestAccessible !== undefined) updateData.guestAccessible = guestAccessible; if (displayOrder !== undefined) updateData.displayOrder = displayOrder; if (isActive !== undefined) updateData.isActive = isActive; await category.update(updateData); res.status(200).json({ success: true, data: { id: category.id, name: category.name, slug: category.slug, description: category.description, icon: category.icon, color: category.color, guestAccessible: category.guestAccessible, displayOrder: category.displayOrder, questionCount: category.questionCount, isActive: category.isActive }, message: 'Category updated successfully' }); } catch (error) { console.error('Error updating category:', error); res.status(500).json({ success: false, message: 'Error updating category', error: error.message }); } }; /** * @desc Delete category (soft delete - Admin only) * @route DELETE /api/categories/:id * @access Private/Admin */ exports.deleteCategory = async (req, res) => { try { const { id } = req.params; // Validate ID if (!id) { return res.status(400).json({ success: false, message: 'Category ID is required' }); } // Find category const category = await Category.findByPk(id); if (!category) { return res.status(404).json({ success: false, message: 'Category not found' }); } // Check if already deleted if (!category.isActive) { return res.status(400).json({ success: false, message: 'Category is already deleted' }); } // Check if category has questions const questionCount = await Question.count({ where: { categoryId: id, isActive: true } }); // Soft delete - set isActive to false await category.update({ isActive: false }); res.status(200).json({ success: true, data: { id: category.id, name: category.name, questionCount: questionCount }, message: questionCount > 0 ? `Category deleted successfully. ${questionCount} questions are still associated with this category.` : 'Category deleted successfully' }); } catch (error) { console.error('Error deleting category:', error); res.status(500).json({ success: false, message: 'Error deleting category', error: error.message }); } };