- Add activeClients and inactiveClients to /api/admin/statistics response - Count clients with is_active = TRUE/FALSE 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
918 lines
26 KiB
JavaScript
918 lines
26 KiB
JavaScript
/**
|
|
* 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,
|
|
};
|