/** * Controller admin */ import bcrypt from 'bcrypt'; import { pool } from '../../db.js'; import { AppError, asyncHandler } from '../middleware/errorHandler.js'; // ============================================ // STATISTIQUES // ============================================ /** * Récupérer les statistiques globales * GET /api/admin/statistics */ export const getStatistics = asyncHandler(async (req, res) => { // Statistiques des utilisateurs const usersStats = await pool.query(` SELECT COUNT(*) as total_users, COUNT(CASE WHEN role = 'CLIENT' THEN 1 END) as clients, COUNT(CASE WHEN role = 'CLIENT' AND is_active = TRUE THEN 1 END) as active_clients, COUNT(CASE WHEN role = 'CLIENT' AND is_active = FALSE THEN 1 END) as inactive_clients, COUNT(CASE WHEN role = 'EMPLOYEE' THEN 1 END) as employees, COUNT(CASE WHEN role = 'ADMIN' THEN 1 END) as admins, COUNT(CASE WHEN is_verified = TRUE THEN 1 END) as verified_users FROM users `); // Statistiques des tickets (mises à jour avec distributed et used) const ticketsStats = await pool.query(` SELECT COUNT(*) as total_tickets, COUNT(CASE WHEN user_id IS NOT NULL THEN 1 END) as distributed, COUNT(CASE WHEN played_at IS NOT NULL THEN 1 END) as used, COUNT(CASE WHEN status = 'PENDING' THEN 1 END) as pending, COUNT(CASE WHEN status = 'CLAIMED' THEN 1 END) as claimed, COUNT(CASE WHEN status = 'REJECTED' THEN 1 END) as rejected, COUNT(CASE WHEN validated_at IS NOT NULL THEN 1 END) as validated FROM tickets `); // Statistiques des prix avec répartition par catégorie const prizesStats = await pool.query(` SELECT COUNT(DISTINCT p.id) as total_prizes, COUNT(DISTINCT CASE WHEN p.is_active = TRUE THEN p.id END) as active_prizes, SUM(p.stock) as total_stock, COUNT(t.id) as distributed FROM prizes p LEFT JOIN tickets t ON p.id = t.prize_id `); // Répartition des lots gagnés par catégorie const prizeDistribution = await pool.query(` SELECT p.id as prize_id, p.name as prize_name, p.type as prize_type, COUNT(t.id) as count, CASE WHEN (SELECT COUNT(*) FROM tickets) > 0 THEN ROUND((COUNT(t.id)::DECIMAL / (SELECT COUNT(*) FROM tickets)) * 100, 2) ELSE 0 END as percentage FROM prizes p LEFT JOIN tickets t ON p.id = t.prize_id WHERE p.type != 'GRAND_PRIZE' GROUP BY p.id, p.name, p.type ORDER BY count DESC `); // Statistiques démographiques - Genre const genderStats = await pool.query(` SELECT COUNT(CASE WHEN gender = 'MALE' THEN 1 END) as male, COUNT(CASE WHEN gender = 'FEMALE' THEN 1 END) as female, COUNT(CASE WHEN gender = 'OTHER' THEN 1 END) as other, COUNT(CASE WHEN gender = 'NOT_SPECIFIED' OR gender IS NULL THEN 1 END) as not_specified FROM users WHERE role = 'CLIENT' `); // Statistiques démographiques - Tranches d'âge const ageStats = await pool.query(` SELECT age_range as range, COUNT(*) as count FROM ( SELECT CASE WHEN EXTRACT(YEAR FROM AGE(CURRENT_DATE, date_of_birth)) BETWEEN 0 AND 17 THEN '0-17' WHEN EXTRACT(YEAR FROM AGE(CURRENT_DATE, date_of_birth)) BETWEEN 18 AND 25 THEN '18-25' WHEN EXTRACT(YEAR FROM AGE(CURRENT_DATE, date_of_birth)) BETWEEN 26 AND 35 THEN '26-35' WHEN EXTRACT(YEAR FROM AGE(CURRENT_DATE, date_of_birth)) BETWEEN 36 AND 45 THEN '36-45' WHEN EXTRACT(YEAR FROM AGE(CURRENT_DATE, date_of_birth)) BETWEEN 46 AND 55 THEN '46-55' WHEN EXTRACT(YEAR FROM AGE(CURRENT_DATE, date_of_birth)) BETWEEN 56 AND 65 THEN '56-65' WHEN EXTRACT(YEAR FROM AGE(CURRENT_DATE, date_of_birth)) > 65 THEN '65+' ELSE 'Non spécifié' END as age_range FROM users WHERE role = 'CLIENT' ) subquery GROUP BY age_range ORDER BY CASE age_range WHEN '0-17' THEN 1 WHEN '18-25' THEN 2 WHEN '26-35' THEN 3 WHEN '36-45' THEN 4 WHEN '46-55' THEN 5 WHEN '56-65' THEN 6 WHEN '65+' THEN 7 ELSE 8 END `); // Calculer les pourcentages pour les tranches d'âge const totalClients = parseInt(usersStats.rows[0].clients) || 1; const ageRanges = ageStats.rows.map(row => ({ range: row.range, count: parseInt(row.count), percentage: parseFloat(((parseInt(row.count) / totalClients) * 100).toFixed(2)) })); // Statistiques démographiques - Top villes const topCities = await pool.query(` SELECT city, COUNT(*) as count, ROUND((COUNT(*)::DECIMAL / (SELECT COUNT(*) FROM users WHERE role = 'CLIENT')) * 100, 2) as percentage FROM users WHERE role = 'CLIENT' AND city IS NOT NULL AND city != '' GROUP BY city ORDER BY count DESC LIMIT 10 `); // Statistiques du jeu const gameStats = await pool.query(` SELECT start_date, end_date, is_active, total_tickets, tickets_generated FROM game_settings ORDER BY created_at DESC LIMIT 1 `); // Formater la réponse selon le format attendu par le frontend res.json({ success: true, data: { users: { total: parseInt(usersStats.rows[0].total_users), clients: parseInt(usersStats.rows[0].clients), activeClients: parseInt(usersStats.rows[0].active_clients), inactiveClients: parseInt(usersStats.rows[0].inactive_clients), employees: parseInt(usersStats.rows[0].employees), admins: parseInt(usersStats.rows[0].admins), verifiedEmails: parseInt(usersStats.rows[0].verified_users) }, tickets: { total: parseInt(ticketsStats.rows[0].total_tickets), distributed: parseInt(ticketsStats.rows[0].distributed), used: parseInt(ticketsStats.rows[0].used), pending: parseInt(ticketsStats.rows[0].pending), validated: parseInt(ticketsStats.rows[0].validated), rejected: parseInt(ticketsStats.rows[0].rejected), claimed: parseInt(ticketsStats.rows[0].claimed) }, prizes: { total: parseInt(prizesStats.rows[0].total_prizes), active: parseInt(prizesStats.rows[0].active_prizes), totalStock: parseInt(prizesStats.rows[0].total_stock), distributed: parseInt(prizesStats.rows[0].distributed), byCategory: prizeDistribution.rows.map(prize => ({ prizeId: prize.prize_id, prizeName: prize.prize_name, prizeType: prize.prize_type, count: parseInt(prize.count), percentage: parseFloat(prize.percentage) })) }, demographics: { gender: { male: parseInt(genderStats.rows[0].male), female: parseInt(genderStats.rows[0].female), other: parseInt(genderStats.rows[0].other), notSpecified: parseInt(genderStats.rows[0].not_specified) }, ageRanges: ageRanges, topCities: topCities.rows.map(city => ({ city: city.city, count: parseInt(city.count), percentage: parseFloat(city.percentage) })) }, game: gameStats.rows[0] || null } }); }); // ============================================ // GESTION DES PRIX // ============================================ /** * Récupérer tous les prix * GET /api/admin/prizes */ export const getAllPrizes = asyncHandler(async (req, res) => { const result = await pool.query(` SELECT p.*, COALESCE(COUNT(t.id), 0) as total_tickets, COALESCE(COUNT(CASE WHEN t.played_at IS NOT NULL THEN 1 END), 0) as tickets_used, COALESCE(COUNT(t.id), 0) as initial_stock FROM prizes p LEFT JOIN tickets t ON p.id = t.prize_id GROUP BY p.id ORDER BY p.created_at DESC `); // Transform snake_case to camelCase for frontend const transformedData = result.rows.map(row => { // Pour GRAND_PRIZE, utiliser le stock de la table prizes car il n'y a pas de tickets créés const isGrandPrize = row.type === 'GRAND_PRIZE'; const initialStock = isGrandPrize ? parseInt(row.stock) : parseInt(row.initial_stock) || 0; return { ...row, initialStock: initialStock, ticketsUsed: parseInt(row.tickets_used) || 0, isActive: row.is_active, createdAt: row.created_at, updatedAt: row.updated_at, }; }); res.json({ success: true, data: transformedData, }); }); /** * Créer un nouveau prix * POST /api/admin/prizes */ export const createPrize = asyncHandler(async (req, res) => { const { type, name, description, value, stock, probability, isActive } = req.body; const result = await pool.query( `INSERT INTO prizes (type, name, description, value, stock, probability, is_active) VALUES ($1, $2, $3, $4, $5, $6, $7) RETURNING *`, [type, name, description, value, stock, probability, isActive ?? true] ); res.status(201).json({ success: true, message: 'Prix créé avec succès', data: result.rows[0], }); }); /** * Mettre à jour un prix * PUT /api/admin/prizes/:id */ export const updatePrize = asyncHandler(async (req, res, next) => { const { id } = req.params; const { name, description, value, stock, probability, isActive } = req.body; // Construire la requête dynamiquement const updates = []; const values = []; let paramCount = 1; if (name !== undefined) { updates.push(`name = $${paramCount++}`); values.push(name); } if (description !== undefined) { updates.push(`description = $${paramCount++}`); values.push(description); } if (value !== undefined) { updates.push(`value = $${paramCount++}`); values.push(value); } if (stock !== undefined) { updates.push(`stock = $${paramCount++}`); values.push(stock); } if (probability !== undefined) { updates.push(`probability = $${paramCount++}`); values.push(probability); } if (isActive !== undefined) { updates.push(`is_active = $${paramCount++}`); values.push(isActive); } if (updates.length === 0) { return next(new AppError('Aucune modification à apporter', 400)); } values.push(id); const query = ` UPDATE prizes SET ${updates.join(', ')} WHERE id = $${paramCount} RETURNING * `; const result = await pool.query(query, values); if (result.rows.length === 0) { return next(new AppError('Prix non trouvé', 404)); } res.json({ success: true, message: 'Prix mis à jour avec succès', data: result.rows[0], }); }); /** * Supprimer un prix (désactiver plutôt que supprimer) * DELETE /api/admin/prizes/:id */ export const deletePrize = asyncHandler(async (req, res, next) => { const { id } = req.params; // Désactiver le prix plutôt que le supprimer pour garder l'historique const result = await pool.query( 'UPDATE prizes SET is_active = FALSE WHERE id = $1 RETURNING *', [id] ); if (result.rows.length === 0) { return next(new AppError('Prix non trouvé', 404)); } res.json({ success: true, message: 'Prix désactivé avec succès', }); }); // ============================================ // GESTION DES UTILISATEURS // ============================================ /** * Récupérer tous les utilisateurs * GET /api/admin/users */ export const getAllUsers = asyncHandler(async (req, res) => { const { page = 1, limit = 10, role, isActive } = req.query; const offset = (page - 1) * limit; // Construction de la requête avec filtrage optionnel let queryText = ` SELECT u.id, u.email, u.first_name, u.last_name, u.phone, u.role, u.is_verified, u.is_active, u.created_at, COUNT(t.id) as tickets_count FROM users u LEFT JOIN tickets t ON u.id = t.user_id `; const queryParams = []; const whereClauses = []; let paramIndex = 1; // Ajouter le filtre par rôle si présent if (role) { whereClauses.push(`u.role = $${paramIndex}`); queryParams.push(role); paramIndex++; } // Ajouter le filtre par statut actif si présent if (isActive !== undefined) { whereClauses.push(`u.is_active = $${paramIndex}`); queryParams.push(isActive === 'true'); paramIndex++; } if (whereClauses.length > 0) { queryText += ` WHERE ${whereClauses.join(' AND ')}`; } queryText += ` GROUP BY u.id ORDER BY u.created_at DESC LIMIT $${paramIndex} OFFSET $${paramIndex + 1}`; queryParams.push(limit, offset); const result = await pool.query(queryText, queryParams); // Compter le total avec les mêmes filtres let countQuery = 'SELECT COUNT(*) FROM users'; const countWhereClauses = []; let countParams = []; let countParamIndex = 1; if (role) { countWhereClauses.push(`role = $${countParamIndex}`); countParams.push(role); countParamIndex++; } if (isActive !== undefined) { countWhereClauses.push(`is_active = $${countParamIndex}`); countParams.push(isActive === 'true'); countParamIndex++; } if (countWhereClauses.length > 0) { countQuery += ` WHERE ${countWhereClauses.join(' AND ')}`; } const countResult = await pool.query(countQuery, countParams); const total = parseInt(countResult.rows[0].count); res.json({ success: true, data: { users: result.rows, pagination: { total, page: parseInt(page), limit: parseInt(limit), totalPages: Math.ceil(total / limit), }, }, }); }); /** * Créer un employé * POST /api/admin/employees */ export const createEmployee = asyncHandler(async (req, res, next) => { const { email, password, firstName, lastName, phone } = req.body; // Vérifier si l'email existe déjà const existingUser = await pool.query('SELECT id FROM users WHERE email = $1', [email]); if (existingUser.rows.length > 0) { return next(new AppError('Cet email est déjà utilisé', 400)); } // Hasher le mot de passe const hashedPassword = await bcrypt.hash(password, 10); // Créer l'employé const result = await pool.query( `INSERT INTO users (email, password, first_name, last_name, phone, role, is_verified) VALUES ($1, $2, $3, $4, $5, 'EMPLOYEE', TRUE) RETURNING id, email, first_name, last_name, role, created_at`, [email, hashedPassword, firstName, lastName, phone] ); res.status(201).json({ success: true, message: 'Employé créé avec succès', data: result.rows[0], }); }); /** * Récupérer un utilisateur par ID * GET /api/admin/users/:id */ export const getUserById = asyncHandler(async (req, res, next) => { const { id } = req.params; const result = await pool.query( `SELECT u.id, u.email, u.first_name, u.last_name, u.phone, u.address, u.city, u.postal_code, u.role, u.is_verified, u.is_active, u.created_at, u.date_of_birth, u.gender, COUNT(t.id) as tickets_count, COUNT(CASE WHEN t.status = 'PENDING' THEN 1 END) as pending_tickets, COUNT(CASE WHEN t.status = 'CLAIMED' THEN 1 END) as claimed_tickets FROM users u LEFT JOIN tickets t ON u.id = t.user_id WHERE u.id = $1 GROUP BY u.id`, [id] ); if (result.rows.length === 0) { return next(new AppError('Utilisateur non trouvé', 404)); } res.json({ success: true, data: result.rows[0], }); }); /** * Mettre à jour un utilisateur * PUT /api/admin/users/:id */ export const updateUser = asyncHandler(async (req, res, next) => { const { id } = req.params; const { role, isVerified, isActive } = req.body; // Construire la requête dynamiquement const updates = []; const values = []; let paramCount = 1; if (role !== undefined) { updates.push(`role = $${paramCount++}`); values.push(role); } if (isVerified !== undefined) { updates.push(`is_verified = $${paramCount++}`); values.push(isVerified); } if (isActive !== undefined) { updates.push(`is_active = $${paramCount++}`); values.push(isActive); } if (updates.length === 0) { return next(new AppError('Aucune modification à apporter', 400)); } values.push(id); const query = ` UPDATE users SET ${updates.join(', ')} WHERE id = $${paramCount} RETURNING id, email, first_name, last_name, role, is_verified, is_active `; const result = await pool.query(query, values); if (result.rows.length === 0) { return next(new AppError('Utilisateur non trouvé', 404)); } res.json({ success: true, message: 'Utilisateur mis à jour avec succès', data: result.rows[0], }); }); /** * Supprimer un utilisateur * DELETE /api/admin/users/:id */ export const deleteUser = asyncHandler(async (req, res, next) => { const { id } = req.params; // Ne pas permettre de supprimer son propre compte if (id === req.user.id) { return next(new AppError('Vous ne pouvez pas supprimer votre propre compte', 400)); } const result = await pool.query('DELETE FROM users WHERE id = $1 RETURNING id', [id]); if (result.rows.length === 0) { return next(new AppError('Utilisateur non trouvé', 404)); } res.json({ success: true, message: 'Utilisateur supprimé avec succès', }); }); // ============================================ // GESTION DES TICKETS // ============================================ /** * Récupérer tous les tickets * GET /api/admin/tickets */ export const getAllTickets = asyncHandler(async (req, res) => { console.log('🚀 getAllTickets appelé avec query:', req.query); const { page = 1, limit = 20, status, prizeType } = req.query; const offset = (page - 1) * limit; console.log('📋 Paramètres extraits:', { page, limit, status, prizeType, offset }); let query = ` SELECT t.id, t.code, t.status, t.played_at, t.claimed_at, t.validated_at, t.created_at, u.email as user_email, u.first_name || ' ' || u.last_name as user_name, p.name as prize_name, p.type as prize_type, p.value as prize_value, v.first_name || ' ' || v.last_name as validated_by_name FROM tickets t LEFT JOIN users u ON t.user_id = u.id LEFT JOIN prizes p ON t.prize_id = p.id LEFT JOIN users v ON t.validated_by = v.id `; const params = []; let paramCount = 1; const whereClauses = []; // Filtre par statut if (status) { whereClauses.push(`t.status = $${paramCount++}`); params.push(status); } // Filtre par type de lot if (prizeType) { whereClauses.push(`p.type = $${paramCount++}`); params.push(prizeType); } // Ajouter les clauses WHERE si nécessaire if (whereClauses.length > 0) { query += ' WHERE ' + whereClauses.join(' AND '); } query += ` ORDER BY t.played_at DESC NULLS LAST, t.created_at DESC LIMIT $${paramCount++} OFFSET $${paramCount}`; params.push(limit, offset); // Log pour debug console.log('🔍 SQL Query:', query); console.log('🔍 Params:', params); const result = await pool.query(query, params); console.log('✅ Résultats:', result.rows.length, 'tickets'); if (result.rows.length > 0) { console.log('📦 Premier ticket:', { code: result.rows[0].code, prize: result.rows[0].prize_name, type: result.rows[0].prize_type }); } // Compter le total let countQuery = 'SELECT COUNT(*) FROM tickets t LEFT JOIN prizes p ON t.prize_id = p.id'; const countParams = []; const countWhereClauses = []; let countParamCount = 1; if (status) { countWhereClauses.push(`t.status = $${countParamCount++}`); countParams.push(status); } if (prizeType) { countWhereClauses.push(`p.type = $${countParamCount++}`); countParams.push(prizeType); } if (countWhereClauses.length > 0) { countQuery += ' WHERE ' + countWhereClauses.join(' AND '); } const countResult = await pool.query(countQuery, countParams); const total = parseInt(countResult.rows[0].count); // Récupérer les stats globales (tous les tickets, sans pagination) const statsQuery = ` SELECT COUNT(*) FILTER (WHERE status = 'PENDING') as pending, COUNT(*) FILTER (WHERE status = 'CLAIMED') as claimed, COUNT(*) FILTER (WHERE status = 'REJECTED') as rejected FROM tickets `; const statsResult = await pool.query(statsQuery); const stats = { pending: parseInt(statsResult.rows[0].pending) || 0, claimed: parseInt(statsResult.rows[0].claimed) || 0, rejected: parseInt(statsResult.rows[0].rejected) || 0, }; console.log('📊 Stats calculées:', stats); res.json({ success: true, data: result.rows, total, page: parseInt(page), limit: parseInt(limit), totalPages: Math.ceil(total / limit), stats, }); }); // ============================================ // GÉNÉRATION DE TICKETS EN MASSE // ============================================ /** * Générer des tickets en masse * POST /api/admin/generate-tickets */ export const generateTickets = asyncHandler(async (req, res) => { const { quantity } = req.body; if (!quantity || quantity < 1 || quantity > 1000000) { throw new AppError('La quantité doit être entre 1 et 1,000,000', 400); } // Récupérer tous les lots actifs avec leurs probabilités const prizesResult = await pool.query( `SELECT id, name, probability, stock FROM prizes WHERE is_active = TRUE AND stock > 0 ORDER BY probability DESC` ); const prizes = prizesResult.rows; if (prizes.length === 0) { throw new AppError('Aucun lot actif avec du stock disponible', 400); } // Calculer la somme totale des probabilités const totalProbability = prizes.reduce((sum, prize) => sum + parseFloat(prize.probability), 0); if (totalProbability === 0) { throw new AppError('La somme des probabilités est 0', 400); } // Générer les tickets const tickets = []; const codes = new Set(); console.log(`🎫 Génération de ${quantity} tickets...`); for (let i = 0; i < quantity; i++) { // Générer un code unique let code; do { code = generateTicketCode(); } while (codes.has(code)); codes.add(code); // Sélectionner un lot selon la probabilité const random = Math.random() * totalProbability; let cumulativeProbability = 0; let selectedPrize = prizes[0]; for (const prize of prizes) { cumulativeProbability += parseFloat(prize.probability); if (random <= cumulativeProbability) { selectedPrize = prize; break; } } tickets.push({ code, prizeId: selectedPrize.id, }); // Log de progression tous les 10000 tickets if ((i + 1) % 10000 === 0) { console.log(` ✓ ${i + 1}/${quantity} tickets générés`); } } console.log(`📊 Insertion de ${tickets.length} tickets dans la base de données...`); // Insérer les tickets en batch (par lots de 1000 pour éviter les problèmes de mémoire) const batchSize = 1000; let inserted = 0; for (let i = 0; i < tickets.length; i += batchSize) { const batch = tickets.slice(i, i + batchSize); // Construire la requête d'insertion const values = batch.map((t, idx) => { return `($${idx * 2 + 1}, $${idx * 2 + 2})`; }).join(', '); const params = batch.flatMap(t => [t.code, t.prizeId]); await pool.query( `INSERT INTO tickets (code, prize_id) VALUES ${values}`, params ); inserted += batch.length; console.log(` ✓ ${inserted}/${tickets.length} tickets insérés`); } console.log(`✅ ${inserted} tickets générés avec succès!`); res.json({ success: true, message: `${inserted} tickets générés avec succès`, data: { generated: inserted, }, }); }); /** * Générer un code ticket aléatoire unique */ function generateTicketCode() { const chars = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789'; // Sans I, O, 0, 1 pour éviter confusion let code = ''; for (let i = 0; i < 10; i++) { code += chars.charAt(Math.floor(Math.random() * chars.length)); } return code; } /** * Exporter les données marketing pour emailing * GET /api/admin/marketing/export */ export const exportMarketingData = asyncHandler(async (req, res) => { const { segment } = req.query; // Construction de la requête selon le segment let whereClause = "WHERE u.role = 'CLIENT'"; if (segment === 'active') { // Utilisateurs qui ont joué au moins 1 ticket whereClause += " AND (SELECT COUNT(*) FROM tickets t WHERE t.user_id = u.id AND t.played_at IS NOT NULL) > 0"; } else if (segment === 'winners') { // Utilisateurs qui ont gagné au moins 1 lot whereClause += " AND (SELECT COUNT(*) FROM tickets t WHERE t.user_id = u.id AND t.status = 'CLAIMED') > 0"; } else if (segment === 'verified') { // Utilisateurs vérifiés uniquement whereClause += " AND u.is_verified = TRUE"; } const query = ` SELECT u.id, u.email, u.first_name, u.last_name, u.phone, u.city, u.postal_code, u.gender, u.date_of_birth, u.is_verified, u.created_at, COALESCE(COUNT(DISTINCT t.id), 0) as tickets_played, COALESCE(COUNT(DISTINCT CASE WHEN t.status = 'CLAIMED' THEN t.id END), 0) as prizes_won, COALESCE(MAX(t.played_at), NULL) as last_activity FROM users u LEFT JOIN tickets t ON u.id = t.user_id AND t.played_at IS NOT NULL ${whereClause} GROUP BY u.id, u.email, u.first_name, u.last_name, u.phone, u.city, u.postal_code, u.gender, u.date_of_birth, u.is_verified, u.created_at ORDER BY u.created_at DESC `; const result = await pool.query(query); res.json({ success: true, data: { users: result.rows, total: result.rows.length, segment: segment || 'all', exportedAt: new Date().toISOString(), }, }); }); export default { getStatistics, getAllPrizes, createPrize, updatePrize, deletePrize, getAllUsers, getUserById, createEmployee, updateUser, deleteUser, getAllTickets, generateTickets, exportMarketingData, };