chore: build
This commit is contained in:
+166
-295
@@ -3,136 +3,10 @@ package db
|
||||
import (
|
||||
"fmt"
|
||||
"gestion/models"
|
||||
"log"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// AddProductInBasket ajoute un produit au panier de l'utilisateur
|
||||
func (d *Database) AddProductInBasket(username, nameProduct string, quantity float64, category string) (*models.Panier, error) {
|
||||
var productResult struct {
|
||||
ID int `gorm:"column:id"`
|
||||
}
|
||||
err := d.GDB.Raw(`SELECT id FROM products WHERE LOWER(name) = LOWER(?) AND LOWER(category) = LOWER(?)`,
|
||||
nameProduct, category).Scan(&productResult).Error
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("erreur lors de la recherche du produit: %w", err)
|
||||
}
|
||||
if productResult.ID == 0 {
|
||||
return nil, fmt.Errorf("produit '%s' non trouvé dans la catégorie '%s'", nameProduct, category)
|
||||
}
|
||||
productID := productResult.ID
|
||||
|
||||
price, err := d.GetProductPrice(nameProduct, category, quantity)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("erreur récupération prix: %w", err)
|
||||
}
|
||||
|
||||
var existing struct {
|
||||
ID int `gorm:"column:id"`
|
||||
Quantity float64 `gorm:"column:quantity"`
|
||||
Price float64 `gorm:"column:price"`
|
||||
}
|
||||
d.GDB.Raw(`SELECT id, quantity, price FROM baskets WHERE username = ? AND product_id = ?`,
|
||||
username, productID).Scan(&existing)
|
||||
|
||||
var basket models.Panier
|
||||
if existing.ID != 0 {
|
||||
newQuantity := existing.Quantity + quantity
|
||||
newPrice := existing.Price + price
|
||||
err = d.GDB.Raw(`
|
||||
UPDATE baskets SET quantity = ?, price = ?, created_at = CURRENT_TIMESTAMP
|
||||
WHERE id = ? RETURNING id, username, product_id, quantity, price, created_at`,
|
||||
newQuantity, newPrice, existing.ID).Scan(&basket).Error
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("erreur lors de la mise à jour du panier: %w", err)
|
||||
}
|
||||
} else {
|
||||
err = d.GDB.Raw(`
|
||||
INSERT INTO baskets (username, product_id, quantity, price, created_at)
|
||||
VALUES (?, ?, ?, ?, CURRENT_TIMESTAMP)
|
||||
RETURNING id, username, product_id, quantity, price, created_at`,
|
||||
username, productID, quantity, price).Scan(&basket).Error
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("erreur lors de l'ajout au panier: %w", err)
|
||||
}
|
||||
}
|
||||
return &basket, nil
|
||||
}
|
||||
|
||||
// GetProductPriceByID récupère le prix d'un produit par son ID et quantité
|
||||
func (d *Database) GetProductPriceByID(productID int, quantity float64) (float64, error) {
|
||||
var result struct {
|
||||
Price float64 `gorm:"column:price"`
|
||||
}
|
||||
|
||||
err := d.GDB.Raw(`
|
||||
SELECT price FROM product_prices
|
||||
WHERE product_id = ? AND quantity = ROUND(?::NUMERIC, 3)
|
||||
LIMIT 1`, productID, quantity).Scan(&result).Error
|
||||
if err == nil && result.Price > 0 {
|
||||
return result.Price, nil
|
||||
}
|
||||
|
||||
err = d.GDB.Raw(`
|
||||
SELECT price FROM product_prices
|
||||
WHERE product_id = ? AND quantity <= ROUND(?::NUMERIC, 3)
|
||||
ORDER BY quantity DESC LIMIT 1`, productID, quantity).Scan(&result).Error
|
||||
if err != nil || result.Price == 0 {
|
||||
return 0, fmt.Errorf("aucun prix trouvé pour product_id=%d qty=%.3f", productID, quantity)
|
||||
}
|
||||
return result.Price, nil
|
||||
}
|
||||
|
||||
// GetProductStockByID récupère le stock d'un produit par son ID
|
||||
func (d *Database) GetProductStockByID(productID int) (float64, error) {
|
||||
var result struct {
|
||||
Stock float64 `gorm:"column:stock"`
|
||||
}
|
||||
err := d.GDB.Raw(`SELECT stock FROM products WHERE id = ?`, productID).Scan(&result).Error
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("produit %d non trouvé: %w", productID, err)
|
||||
}
|
||||
return result.Stock, nil
|
||||
}
|
||||
|
||||
// AddProductInBasketByID ajoute un produit au panier en utilisant son ID directement
|
||||
func (d *Database) AddProductInBasketByID(username string, productID int, quantity float64) (*models.Panier, error) {
|
||||
price, err := d.GetProductPriceByID(productID, quantity)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("erreur récupération prix: %w", err)
|
||||
}
|
||||
|
||||
var existing struct {
|
||||
ID int `gorm:"column:id"`
|
||||
Quantity float64 `gorm:"column:quantity"`
|
||||
Price float64 `gorm:"column:price"`
|
||||
}
|
||||
d.GDB.Raw(`SELECT id, quantity, price FROM baskets WHERE username = ? AND product_id = ?`,
|
||||
username, productID).Scan(&existing)
|
||||
|
||||
var basket models.Panier
|
||||
if existing.ID != 0 {
|
||||
newQuantity := existing.Quantity + quantity
|
||||
newPrice := existing.Price + price
|
||||
err = d.GDB.Raw(`
|
||||
UPDATE baskets SET quantity = ?, price = ?, created_at = CURRENT_TIMESTAMP
|
||||
WHERE id = ? RETURNING id, username, product_id, quantity, price, created_at`,
|
||||
newQuantity, newPrice, existing.ID).Scan(&basket).Error
|
||||
} else {
|
||||
err = d.GDB.Raw(`
|
||||
INSERT INTO baskets (username, product_id, quantity, price, created_at)
|
||||
VALUES (?, ?, ?, ?, CURRENT_TIMESTAMP)
|
||||
RETURNING id, username, product_id, quantity, price, created_at`,
|
||||
username, productID, quantity, price).Scan(&basket).Error
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("erreur panier: %w", err)
|
||||
}
|
||||
return &basket, nil
|
||||
}
|
||||
|
||||
// GetProductPrice récupère le prix réel d'un produit pour une quantité donnée (legacy)
|
||||
func (d *Database) GetProductPrice(name, category string, quantity float64) (float64, error) {
|
||||
var result struct {
|
||||
@@ -153,37 +27,11 @@ func (d *Database) GetProductPrice(name, category string, quantity float64) (flo
|
||||
return result.Price, nil
|
||||
}
|
||||
|
||||
func (d *Database) GetProductStock(name, category string) (float64, error) {
|
||||
var result struct {
|
||||
Stock float64 `gorm:"column:stock"`
|
||||
}
|
||||
err := d.GDB.Raw(`SELECT stock FROM products WHERE LOWER(name) = LOWER(?) AND LOWER(category) = LOWER(?)`,
|
||||
name, category).Scan(&result).Error
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("produit non trouvé: %w", err)
|
||||
}
|
||||
return result.Stock, nil
|
||||
}
|
||||
|
||||
func (d *Database) DecrementProductStock(name, category string, quantity float64) error {
|
||||
result := d.GDB.Exec(`
|
||||
UPDATE products SET stock = stock - ?
|
||||
WHERE LOWER(name) = LOWER(?) AND LOWER(category) = LOWER(?) AND stock >= ?`,
|
||||
quantity, name, category, quantity)
|
||||
if result.Error != nil {
|
||||
return fmt.Errorf("erreur mise à jour stock: %w", result.Error)
|
||||
}
|
||||
if result.RowsAffected == 0 {
|
||||
return fmt.Errorf("stock insuffisant pour le produit")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetAllProductsInBasket récupère tous les produits du panier d'un utilisateur
|
||||
func (d *Database) GetAllProductsInBasket(username string) ([]models.Panier, error) {
|
||||
var baskets []models.Panier
|
||||
err := d.GDB.Raw(`
|
||||
SELECT b.id, b.username, b.product_id, b.quantity, b.price, b.created_at,
|
||||
SELECT b.id, b.username, b.product_id, b.quantity, b.price, b.is_reward, b.created_at,
|
||||
p.name as product_name, p.category, p.description
|
||||
FROM baskets b
|
||||
INNER JOIN products p ON b.product_id = p.id
|
||||
@@ -195,107 +43,114 @@ func (d *Database) GetAllProductsInBasket(username string) ([]models.Panier, err
|
||||
return baskets, nil
|
||||
}
|
||||
|
||||
// DecrementProductStockByID décrémente le stock d'un produit par son ID
|
||||
func (d *Database) DecrementProductStockByID(productID int, quantity float64) error {
|
||||
result := d.GDB.Exec(`
|
||||
UPDATE products SET stock = stock - ?
|
||||
WHERE id = ? AND stock >= ?`, quantity, productID, quantity)
|
||||
if result.Error != nil {
|
||||
return fmt.Errorf("erreur lors de la mise à jour du stock: %w", result.Error)
|
||||
// AddRewardsToBasket ajoute plusieurs produits récompense au panier (prix = 0, is_reward = true).
|
||||
// Supprime les anciens items récompense avant d'insérer les nouveaux.
|
||||
// Pas de vérification de stock — les récompenses sont gérées par l'admin.
|
||||
func (d *Database) AddRewardsToBasket(username string, items []models.RewardItem, poolKey string) ([]models.Panier, error) {
|
||||
var baskets []models.Panier
|
||||
err := d.GDB.Transaction(func(tx *gorm.DB) error {
|
||||
// Supprimer tout article récompense existant (remplacement)
|
||||
tx.Exec(`DELETE FROM baskets WHERE username = ? AND is_reward = true`, username)
|
||||
for _, item := range items {
|
||||
if item.ProductID <= 0 || item.Quantity <= 0 {
|
||||
continue
|
||||
}
|
||||
var productName string
|
||||
if err := tx.Raw(`SELECT name FROM products WHERE id = ?`, item.ProductID).Scan(&productName).Error; err != nil || productName == "" {
|
||||
return fmt.Errorf("produit récompense introuvable (id=%d)", item.ProductID)
|
||||
}
|
||||
var basket models.Panier
|
||||
if err := tx.Raw(`
|
||||
INSERT INTO baskets (username, product_id, quantity, price, is_reward, reward_pool_key, created_at)
|
||||
VALUES (?, ?, ?, 0, true, ?, CURRENT_TIMESTAMP)
|
||||
RETURNING id, username, product_id, quantity, price, is_reward, reward_pool_key, created_at`,
|
||||
username, item.ProductID, item.Quantity, poolKey).Scan(&basket).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
baskets = append(baskets, basket)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if result.RowsAffected == 0 {
|
||||
return fmt.Errorf("stock insuffisant pour le produit %d", productID)
|
||||
}
|
||||
return nil
|
||||
return baskets, nil
|
||||
}
|
||||
|
||||
// DeleteProductFromBasket supprime un produit spécifique du panier et restitue le stock.
|
||||
// HasOnlyRewardItems retourne true si le panier ne contient que des articles récompense.
|
||||
func (d *Database) HasOnlyRewardItems(username string) (bool, error) {
|
||||
var counts struct {
|
||||
Total int `gorm:"column:total"`
|
||||
Normal int `gorm:"column:normal"`
|
||||
}
|
||||
err := d.GDB.Raw(`
|
||||
SELECT COUNT(*) as total,
|
||||
COUNT(*) FILTER (WHERE is_reward = false) as normal
|
||||
FROM baskets WHERE username = ?`, username).Scan(&counts).Error
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return counts.Total > 0 && counts.Normal == 0, nil
|
||||
}
|
||||
|
||||
// AddToBasket vérifie le stock disponible et ajoute l'article au panier.
|
||||
// Le stock n'est pas décrémenté ici — il l'est uniquement au checkout.
|
||||
func (d *Database) AddToBasket(username string, productID int, quantity float64) (*models.Panier, error) {
|
||||
var basket models.Panier
|
||||
err := d.GDB.Transaction(func(tx *gorm.DB) error {
|
||||
var currentStock float64
|
||||
if err := tx.Raw(`SELECT stock FROM products WHERE id = ? FOR UPDATE`, productID).Scan(¤tStock).Error; err != nil {
|
||||
return fmt.Errorf("erreur lecture stock: %w", err)
|
||||
}
|
||||
if currentStock < quantity {
|
||||
return fmt.Errorf("stock insuffisant")
|
||||
}
|
||||
|
||||
var priceResult struct {
|
||||
Price float64 `gorm:"column:price"`
|
||||
}
|
||||
if err := tx.Raw(`
|
||||
SELECT price FROM product_prices
|
||||
WHERE product_id = ? AND quantity <= ? AND active_price = true
|
||||
ORDER BY quantity DESC LIMIT 1`,
|
||||
productID, quantity).Scan(&priceResult).Error; err != nil || priceResult.Price == 0 {
|
||||
return fmt.Errorf("prix introuvable pour product_id=%d qty=%.3f", productID, quantity)
|
||||
}
|
||||
|
||||
var existing struct {
|
||||
ID int `gorm:"column:id"`
|
||||
Quantity float64 `gorm:"column:quantity"`
|
||||
Price float64 `gorm:"column:price"`
|
||||
}
|
||||
// Chercher uniquement un item normal (non-récompense) pour ce produit
|
||||
tx.Raw(`SELECT id, quantity, price FROM baskets WHERE username = ? AND product_id = ? AND is_reward = false`,
|
||||
username, productID).Scan(&existing)
|
||||
|
||||
if existing.ID != 0 {
|
||||
return tx.Raw(`
|
||||
UPDATE baskets SET quantity = ?, price = ?, created_at = CURRENT_TIMESTAMP
|
||||
WHERE id = ? AND is_reward = false RETURNING id, username, product_id, quantity, price, is_reward, created_at`,
|
||||
existing.Quantity+quantity, existing.Price+priceResult.Price,
|
||||
existing.ID).Scan(&basket).Error
|
||||
}
|
||||
return tx.Raw(`
|
||||
INSERT INTO baskets (username, product_id, quantity, price, is_reward, created_at)
|
||||
VALUES (?, ?, ?, ?, false, CURRENT_TIMESTAMP)
|
||||
RETURNING id, username, product_id, quantity, price, is_reward, created_at`,
|
||||
username, productID, quantity, priceResult.Price).Scan(&basket).Error
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &basket, nil
|
||||
}
|
||||
|
||||
// DeleteProductFromBasket supprime un produit spécifique du panier.
|
||||
// Le stock n'est pas restitué car il n'a pas été décrémenté à l'ajout.
|
||||
func (d *Database) DeleteProductFromBasket(basketID int) error {
|
||||
return d.GDB.Transaction(func(tx *gorm.DB) error {
|
||||
var item struct {
|
||||
ProductID int `gorm:"column:product_id"`
|
||||
Quantity float64 `gorm:"column:quantity"`
|
||||
}
|
||||
if err := tx.Raw(`SELECT product_id, quantity FROM baskets WHERE id = ?`, basketID).Scan(&item).Error; err != nil {
|
||||
return fmt.Errorf("produit non trouvé dans le panier")
|
||||
}
|
||||
if item.ProductID == 0 {
|
||||
return fmt.Errorf("produit non trouvé dans le panier")
|
||||
}
|
||||
|
||||
if err := tx.Exec(`UPDATE products SET stock = stock + ? WHERE id = ?`,
|
||||
item.Quantity, item.ProductID).Error; err != nil {
|
||||
return fmt.Errorf("erreur restitution stock: %w", err)
|
||||
}
|
||||
|
||||
result := tx.Exec(`DELETE FROM baskets WHERE id = ?`, basketID)
|
||||
if result.Error != nil {
|
||||
return fmt.Errorf("erreur lors de la suppression du produit: %w", result.Error)
|
||||
}
|
||||
if result.RowsAffected == 0 {
|
||||
return fmt.Errorf("produit non trouvé dans le panier")
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
// ClearBasket vide complètement le panier d'un utilisateur et restitue les stocks.
|
||||
func (d *Database) ClearBasket(username string) error {
|
||||
return d.GDB.Transaction(func(tx *gorm.DB) error {
|
||||
if err := tx.Exec(`
|
||||
UPDATE products p
|
||||
SET stock = stock + b.quantity
|
||||
FROM baskets b
|
||||
WHERE b.username = ? AND b.product_id = p.id`, username).Error; err != nil {
|
||||
return fmt.Errorf("erreur restitution stock: %w", err)
|
||||
}
|
||||
if err := tx.Exec(`DELETE FROM baskets WHERE username = ?`, username).Error; err != nil {
|
||||
return fmt.Errorf("erreur lors du vidage du panier: %w", err)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
// ClearBasketOnCheckout vide le panier après commande validée SANS restituer le stock.
|
||||
func (d *Database) ClearBasketOnCheckout(username string) error {
|
||||
return d.GDB.Exec(`DELETE FROM baskets WHERE username = ?`, username).Error
|
||||
}
|
||||
|
||||
// GetBasketTotal calcule le montant total du panier d'un utilisateur
|
||||
func (d *Database) GetBasketTotal(username string) (float64, error) {
|
||||
var result struct {
|
||||
Total float64 `gorm:"column:total"`
|
||||
}
|
||||
err := d.GDB.Raw(`SELECT COALESCE(SUM(price), 0) as total FROM baskets WHERE username = ?`,
|
||||
username).Scan(&result).Error
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("erreur lors du calcul du total: %w", err)
|
||||
}
|
||||
return result.Total, nil
|
||||
}
|
||||
|
||||
// GetBasketItemCount compte le nombre d'items dans le panier
|
||||
func (d *Database) GetBasketItemCount(username string) (int, error) {
|
||||
var result struct {
|
||||
Count int `gorm:"column:count"`
|
||||
}
|
||||
err := d.GDB.Raw(`SELECT COUNT(*) as count FROM baskets WHERE username = ?`,
|
||||
username).Scan(&result).Error
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("erreur lors du comptage des items: %w", err)
|
||||
}
|
||||
return result.Count, nil
|
||||
}
|
||||
|
||||
// UpdateBasketItemQuantity met à jour la quantité d'un item du panier
|
||||
func (d *Database) UpdateBasketItemQuantity(basketID int, quantity float64) error {
|
||||
if quantity <= 0 {
|
||||
return fmt.Errorf("la quantité doit être supérieure à 0")
|
||||
}
|
||||
result := d.GDB.Exec(`UPDATE baskets SET quantity = ?, created_at = CURRENT_TIMESTAMP WHERE id = ?`,
|
||||
quantity, basketID)
|
||||
result := d.GDB.Exec(`DELETE FROM baskets WHERE id = ?`, basketID)
|
||||
if result.Error != nil {
|
||||
return fmt.Errorf("erreur lors de la mise à jour de la quantité: %w", result.Error)
|
||||
return fmt.Errorf("erreur lors de la suppression du produit: %w", result.Error)
|
||||
}
|
||||
if result.RowsAffected == 0 {
|
||||
return fmt.Errorf("produit non trouvé dans le panier")
|
||||
@@ -303,53 +158,39 @@ func (d *Database) UpdateBasketItemQuantity(basketID int, quantity float64) erro
|
||||
return nil
|
||||
}
|
||||
|
||||
// ExtendBasketReservations prolonge les réservations
|
||||
func (d *Database) ExtendBasketReservations(username string) error {
|
||||
var items []struct {
|
||||
ProductID int `gorm:"column:product_id"`
|
||||
Quantity float64 `gorm:"column:quantity"`
|
||||
}
|
||||
if err := d.GDB.Raw(`SELECT product_id, quantity FROM baskets WHERE username = ?`, username).Scan(&items).Error; err != nil {
|
||||
return fmt.Errorf("erreur récupération panier: %w", err)
|
||||
}
|
||||
|
||||
for _, item := range items {
|
||||
var stockResult struct {
|
||||
Stock float64 `gorm:"column:stock"`
|
||||
}
|
||||
if err := d.GDB.Raw(`SELECT stock FROM products WHERE id = ?`, item.ProductID).Scan(&stockResult).Error; err != nil {
|
||||
return fmt.Errorf("produit %d non trouvé: %w", item.ProductID, err)
|
||||
}
|
||||
if stockResult.Stock < item.Quantity {
|
||||
return fmt.Errorf("stock insuffisant pour le produit %d (demandé: %g, disponible: %g)",
|
||||
item.ProductID, item.Quantity, stockResult.Stock)
|
||||
}
|
||||
}
|
||||
|
||||
newReservation := time.Now().Add(15 * time.Minute)
|
||||
if err := d.GDB.Exec(`UPDATE baskets SET reserved_until = ? WHERE username = ?`,
|
||||
newReservation, username).Error; err != nil {
|
||||
return fmt.Errorf("erreur prolongation: %w", err)
|
||||
}
|
||||
|
||||
log.Printf("✅ Réservations prolongées pour %s jusqu'à %s",
|
||||
username, newReservation.Format("15:04:05"))
|
||||
return nil
|
||||
// ClearBasket vide complètement le panier d'un utilisateur.
|
||||
// Le stock n'est pas restitué car il n'a pas été décrémenté à l'ajout.
|
||||
func (d *Database) ClearBasket(username string) error {
|
||||
return d.GDB.Exec(`DELETE FROM baskets WHERE username = ?`, username).Error
|
||||
}
|
||||
|
||||
// CheckBasketReservations vérifie si les réservations sont expirées
|
||||
func (d *Database) CheckBasketReservations(username string) (bool, error) {
|
||||
var result struct {
|
||||
Count int `gorm:"column:count"`
|
||||
}
|
||||
err := d.GDB.Raw(`
|
||||
SELECT COUNT(*) as count FROM baskets
|
||||
WHERE username = ? AND (reserved_until IS NULL OR reserved_until < CURRENT_TIMESTAMP)`,
|
||||
username).Scan(&result).Error
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return result.Count > 0, nil
|
||||
// ClearBasketOnCheckout décrémente le stock pour chaque article du panier puis vide le panier.
|
||||
// C'est ici que le stock est effectivement consommé, au moment de la validation de la commande.
|
||||
func (d *Database) ClearBasketOnCheckout(username string) error {
|
||||
return d.GDB.Transaction(func(tx *gorm.DB) error {
|
||||
var items []struct {
|
||||
ProductID int `gorm:"column:product_id"`
|
||||
Quantity float64 `gorm:"column:quantity"`
|
||||
}
|
||||
if err := tx.Raw(`SELECT product_id, quantity FROM baskets WHERE username = ? FOR UPDATE`, username).Scan(&items).Error; err != nil {
|
||||
return fmt.Errorf("erreur lecture panier: %w", err)
|
||||
}
|
||||
|
||||
for _, item := range items {
|
||||
var currentStock float64
|
||||
if err := tx.Raw(`SELECT stock FROM products WHERE id = ? FOR UPDATE`, item.ProductID).Scan(¤tStock).Error; err != nil {
|
||||
return fmt.Errorf("erreur lecture stock produit %d: %w", item.ProductID, err)
|
||||
}
|
||||
if currentStock < item.Quantity {
|
||||
return fmt.Errorf("stock insuffisant pour le produit %d", item.ProductID)
|
||||
}
|
||||
if err := tx.Exec(`UPDATE products SET stock = stock - ? WHERE id = ?`, item.Quantity, item.ProductID).Error; err != nil {
|
||||
return fmt.Errorf("erreur décrémentation stock produit %d: %w", item.ProductID, err)
|
||||
}
|
||||
}
|
||||
|
||||
return tx.Exec(`DELETE FROM baskets WHERE username = ?`, username).Error
|
||||
})
|
||||
}
|
||||
|
||||
func (d *Database) GetBasketItemOwner(basketID int) (string, error) {
|
||||
@@ -364,9 +205,39 @@ func (d *Database) GetBasketItemOwner(basketID int) (string, error) {
|
||||
return username, nil
|
||||
}
|
||||
|
||||
// GetUnavailableBasketItems retourne les noms des produits du panier dont tous les prix ont été désactivés.
|
||||
func (d *Database) GetUnavailableBasketItems(username string) ([]string, error) {
|
||||
var names []string
|
||||
err := d.GDB.Raw(`
|
||||
SELECT DISTINCT p.name
|
||||
FROM baskets b
|
||||
INNER JOIN products p ON b.product_id = p.id
|
||||
WHERE b.username = ?
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM product_prices pp
|
||||
WHERE pp.product_id = b.product_id
|
||||
AND pp.quantity <= b.quantity
|
||||
AND pp.active_price = true
|
||||
)`, username).Scan(&names).Error
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("erreur vérification disponibilité: %w", err)
|
||||
}
|
||||
return names, nil
|
||||
}
|
||||
|
||||
// GetReservedQuantityInBaskets retourne la somme des quantités d'un produit dans tous les paniers actifs.
|
||||
func (d *Database) GetReservedQuantityInBaskets(productID int) (float64, error) {
|
||||
var total float64
|
||||
err := d.GDB.Raw(`SELECT COALESCE(SUM(quantity), 0) FROM baskets WHERE product_id = ?`, productID).Scan(&total).Error
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("erreur lecture réservations panier: %w", err)
|
||||
}
|
||||
return total, nil
|
||||
}
|
||||
|
||||
func (d *Database) GetBasketItems(username string) ([]map[string]any, error) {
|
||||
var items []map[string]any
|
||||
if err := d.GDB.Raw(`SELECT product_id, quantity::float8 as quantity, price::float8 as price FROM baskets WHERE username = ?`,
|
||||
if err := d.GDB.Raw(`SELECT product_id, quantity::float8 as quantity, price::float8 as price, is_reward FROM baskets WHERE username = ?`,
|
||||
username).Scan(&items).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -86,10 +86,9 @@ func (d *Database) CancelCommandAtomic(commandID int, username, reason string, f
|
||||
SET stock = stock + ci.quantite, updated_at = CURRENT_TIMESTAMP
|
||||
FROM command_items ci
|
||||
WHERE ci.command_id = ? AND ci.product_id = p.id`, commandID).Error; err != nil {
|
||||
log.Printf("⚠️ [CancelAtomic] Erreur remboursement stock: %v", err)
|
||||
} else {
|
||||
log.Printf("✅ [CancelAtomic] Stock remboursé")
|
||||
return fmt.Errorf("erreur remboursement stock: %w", err)
|
||||
}
|
||||
log.Printf("✅ [CancelAtomic] Stock remboursé")
|
||||
|
||||
if err := tx.Exec(`
|
||||
UPDATE clients
|
||||
@@ -179,8 +178,6 @@ func (d *Database) CheckCommandETAExistsAndValid(commandID int) bool {
|
||||
}
|
||||
|
||||
func (d *Database) DeleteCommandAtomic(commandID int, deletedBy, role string) error {
|
||||
log.Printf("🔒 [DeleteAtomic] START - cmd=%d, by=%s (%s)", commandID, deletedBy, role)
|
||||
|
||||
return d.GDB.Transaction(func(tx *gorm.DB) error {
|
||||
var cmdResult struct {
|
||||
Status string `gorm:"column:status"`
|
||||
@@ -188,8 +185,8 @@ func (d *Database) DeleteCommandAtomic(commandID int, deletedBy, role string) er
|
||||
LivreurAssign string `gorm:"column:livreur_assign"`
|
||||
}
|
||||
err := tx.Raw(`
|
||||
SELECT status, username, COALESCE(livreur_assign, '') as livreur_assign
|
||||
FROM commandes WHERE id = ? FOR UPDATE`, commandID).Scan(&cmdResult).Error
|
||||
SELECT status, username, COALESCE(livreur_assign, '') as livreur_assign
|
||||
FROM commandes WHERE id = ? FOR UPDATE`, commandID).Scan(&cmdResult).Error
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -199,19 +196,26 @@ func (d *Database) DeleteCommandAtomic(commandID int, deletedBy, role string) er
|
||||
|
||||
log.Printf("📋 [DeleteAtomic] Trouvée - status=%s, client=%s", cmdResult.Status, cmdResult.Username)
|
||||
|
||||
if err := tx.Exec(`
|
||||
UPDATE products p
|
||||
SET stock = stock + ci.quantite, updated_at = CURRENT_TIMESTAMP
|
||||
FROM command_items ci
|
||||
WHERE ci.command_id = ? AND ci.product_id = p.id`, commandID).Error; err != nil {
|
||||
log.Printf("⚠️ [DeleteAtomic] Erreur remboursement: %v", err)
|
||||
// ✅ Ne restitue le stock QUE si pas déjà fait
|
||||
stockAlreadyRestored := cmdResult.Status == "cancelled" || cmdResult.Status == "approved"
|
||||
if !stockAlreadyRestored {
|
||||
if err := tx.Exec(`
|
||||
UPDATE products p
|
||||
SET stock = stock + ci.quantite, updated_at = CURRENT_TIMESTAMP
|
||||
FROM command_items ci
|
||||
WHERE ci.command_id = ? AND ci.product_id = p.id`, commandID).Error; err != nil {
|
||||
log.Printf("⚠️ [DeleteAtomic] Erreur remboursement: %v", err)
|
||||
} else {
|
||||
log.Printf("✅ [DeleteAtomic] Stock remboursé (statut: %s)", cmdResult.Status)
|
||||
}
|
||||
} else {
|
||||
log.Printf("✅ [DeleteAtomic] Stock remboursé")
|
||||
log.Printf("⏭️ [DeleteAtomic] Stock NON restitué - statut=%s", cmdResult.Status)
|
||||
}
|
||||
|
||||
// ✅ Log suppression
|
||||
tx.Exec(`
|
||||
INSERT INTO command_logs (command_id, status, message, author, created_at)
|
||||
VALUES (?, ?, ?, ?, CURRENT_TIMESTAMP)`,
|
||||
INSERT INTO command_logs (command_id, status, message, author, created_at)
|
||||
VALUES (?, ?, ?, ?, CURRENT_TIMESTAMP)`,
|
||||
commandID, "deleted",
|
||||
fmt.Sprintf("Supprimée par %s (%s) - Ancien statut: %s", deletedBy, role, cmdResult.Status),
|
||||
deletedBy)
|
||||
@@ -316,3 +320,13 @@ func (d *Database) AddClientPenalty(username string, points int) error {
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *Database) RestoreCommandStock(commandID int) error {
|
||||
return d.GDB.Transaction(func(tx *gorm.DB) error {
|
||||
return tx.Exec(`
|
||||
UPDATE products p
|
||||
SET stock = stock + ci.quantite, updated_at = CURRENT_TIMESTAMP
|
||||
FROM command_items ci
|
||||
WHERE ci.command_id = ? AND ci.product_id = p.id`, commandID).Error
|
||||
})
|
||||
}
|
||||
|
||||
+162
-117
@@ -35,16 +35,16 @@ func (d *Database) CreateClient(client *models.Client) error {
|
||||
// GetClientByID récupère un client par son ID
|
||||
func (d *Database) GetClientByID(id int) (*models.Client, error) {
|
||||
var row struct {
|
||||
ID int `gorm:"column:id"`
|
||||
Username string `gorm:"column:username"`
|
||||
Password string `gorm:"column:password"`
|
||||
Nom string `gorm:"column:nom"`
|
||||
Prenom string `gorm:"column:prenom"`
|
||||
Telephone string `gorm:"column:telephone"`
|
||||
Command int `gorm:"column:command"`
|
||||
Amende float64 `gorm:"column:amende"`
|
||||
PointsExtraJSON []byte `gorm:"column:points_extra"`
|
||||
CreatedAt time.Time `gorm:"column:created_at"`
|
||||
ID int `gorm:"column:id"`
|
||||
Username string `gorm:"column:username"`
|
||||
Password string `gorm:"column:password"`
|
||||
Nom string `gorm:"column:nom"`
|
||||
Prenom string `gorm:"column:prenom"`
|
||||
Telephone string `gorm:"column:telephone"`
|
||||
Command int `gorm:"column:command"`
|
||||
Amende float64 `gorm:"column:amende"`
|
||||
PointsExtraJSON []byte `gorm:"column:points_extra"`
|
||||
CreatedAt time.Time `gorm:"column:created_at"`
|
||||
}
|
||||
err := d.GDB.Raw(`
|
||||
SELECT id, username, password, nom, prenom, telephone, command, amende,
|
||||
@@ -190,44 +190,6 @@ func (d *Database) UpdateClientPasswordAndClearFlag(clientID int, hashedPassword
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetClientStats récupère les statistiques d'un client
|
||||
func (d *Database) GetClientStats(clientID int) (map[string]interface{}, error) {
|
||||
client, err := d.GetClientByID(clientID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var statsResult struct {
|
||||
Total int `gorm:"column:total"`
|
||||
Pending int `gorm:"column:pending"`
|
||||
Completed int `gorm:"column:completed"`
|
||||
}
|
||||
if err := d.GDB.Raw(`
|
||||
SELECT
|
||||
COUNT(*) as total,
|
||||
COALESCE(SUM(CASE WHEN status = 'pending' OR status = 'livre' THEN 1 ELSE 0 END), 0) as pending,
|
||||
COALESCE(SUM(CASE WHEN status = 'approved' THEN 1 ELSE 0 END), 0) as completed
|
||||
FROM commandes WHERE username = ?`, client.Username).Scan(&statsResult).Error; err != nil {
|
||||
log.Printf("⚠️ Erreur calcul stats: %v", err)
|
||||
}
|
||||
|
||||
stats := map[string]interface{}{
|
||||
"id": clientID,
|
||||
"username": client.Username,
|
||||
"nom": client.Nom,
|
||||
"prenom": client.Prenom,
|
||||
"telephone": client.Telephone,
|
||||
"total_commands": statsResult.Total,
|
||||
"pending_commands": statsResult.Pending,
|
||||
"completed_commands": statsResult.Completed,
|
||||
"points_extra": client.PointsExtra,
|
||||
"amende": client.Amende,
|
||||
"member_since": client.CreatedAt,
|
||||
}
|
||||
|
||||
return stats, nil
|
||||
}
|
||||
|
||||
func (d *Database) GetClientAmende(username string) (float64, error) {
|
||||
var result struct {
|
||||
Amende float64 `gorm:"column:amende"`
|
||||
@@ -242,37 +204,6 @@ func (d *Database) GetClientAmende(username string) (float64, error) {
|
||||
return result.Amende, nil
|
||||
}
|
||||
|
||||
func (d *Database) PayClientPenalties(username string, amountPaid float64) error {
|
||||
log.Printf("💳 [PayClientPenalties] Paiement de %.2f points pour %s", amountPaid, username)
|
||||
|
||||
currentAmount, err := d.GetClientAmende(username)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if currentAmount <= 0 {
|
||||
return fmt.Errorf("aucune pénalité à payer")
|
||||
}
|
||||
|
||||
if amountPaid < currentAmount {
|
||||
return fmt.Errorf("montant insuffisant: %.2f payé, %.2f requis", amountPaid, currentAmount)
|
||||
}
|
||||
|
||||
result := d.GDB.Model(&models.Client{}).Where("username = ?", username).Update("amende", 0.0)
|
||||
if result.Error != nil {
|
||||
log.Printf("❌ [PayClientPenalties] Erreur UPDATE: %v", result.Error)
|
||||
return fmt.Errorf("erreur paiement pénalités: %w", result.Error)
|
||||
}
|
||||
if result.RowsAffected == 0 {
|
||||
return fmt.Errorf("client non trouvé")
|
||||
}
|
||||
|
||||
cacheKey := fmt.Sprintf("client:%s", username)
|
||||
Redis.Del(RedisCtx, cacheKey)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// IncrementClientCommandCount incrémente le compteur de commandes du client
|
||||
func (d *Database) IncrementClientCommandCount(username string) error {
|
||||
result := d.GDB.Model(&models.Client{}).Where("username = ?", username).UpdateColumn("command", gorm.Expr("command + 1"))
|
||||
@@ -374,10 +305,12 @@ func (d *Database) GetClientByUsername(username string) (*models.Client, error)
|
||||
MustChangePassword bool `gorm:"column:must_change_password"`
|
||||
PointsExtraJSON []byte `gorm:"column:points_extra"`
|
||||
CreatedAt time.Time `gorm:"column:created_at"`
|
||||
TwoFAEnabled bool `gorm:"column:two_fa_enabled"`
|
||||
}
|
||||
err := d.GDB.Raw(`
|
||||
SELECT id, username, password, nom, prenom, telephone, command, amende,
|
||||
must_change_password, COALESCE(points_extra, '{}'::jsonb) as points_extra, created_at
|
||||
must_change_password, COALESCE(points_extra, '{}'::jsonb) as points_extra, created_at,
|
||||
two_fa_enabled
|
||||
FROM clients WHERE username = ?`, username).Scan(&row).Error
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("erreur lors de la récupération du client: %w", err)
|
||||
@@ -397,6 +330,7 @@ func (d *Database) GetClientByUsername(username string) (*models.Client, error)
|
||||
Amende: row.Amende,
|
||||
MustChangePassword: row.MustChangePassword,
|
||||
CreatedAt: row.CreatedAt,
|
||||
TwoFAEnabled: row.TwoFAEnabled,
|
||||
}
|
||||
client.PointsExtra = map[string]int{}
|
||||
if len(row.PointsExtraJSON) > 0 {
|
||||
@@ -406,7 +340,11 @@ func (d *Database) GetClientByUsername(username string) (*models.Client, error)
|
||||
return client, nil
|
||||
}
|
||||
|
||||
func (d *Database) GetClientPenaltiesInfo(username string) (map[string]interface{}, error) {
|
||||
func (d *Database) SetClientTwoFAEnabled(clientID int, enabled bool) error {
|
||||
return d.GDB.Model(&models.Client{}).Where("id = ?", clientID).Update("two_fa_enabled", enabled).Error
|
||||
}
|
||||
|
||||
func (d *Database) GetClientPenaltiesInfo(username string) (map[string]any, error) {
|
||||
amende, err := d.GetClientAmende(username)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -421,13 +359,13 @@ func (d *Database) GetClientPenaltiesInfo(username string) (map[string]interface
|
||||
cancellationHistory, err := d.GetClientCancellationHistory(username)
|
||||
if err != nil {
|
||||
log.Printf("⚠️ [GetClientPenaltiesInfo] Erreur récup historique: %v", err)
|
||||
cancellationHistory = map[string]interface{}{
|
||||
cancellationHistory = map[string]any{
|
||||
"cancellations_count": cancellationsCount,
|
||||
"next_penalty": 20,
|
||||
}
|
||||
}
|
||||
|
||||
info := map[string]interface{}{
|
||||
info := map[string]any{
|
||||
"username": username,
|
||||
"total_penalty": amende,
|
||||
"cancellations_count": cancellationsCount,
|
||||
@@ -438,21 +376,6 @@ func (d *Database) GetClientPenaltiesInfo(username string) (map[string]interface
|
||||
return info, nil
|
||||
}
|
||||
|
||||
// CheckClientCanOrder vérifie si un client peut passer commande (pas de pénalités impayées)
|
||||
func (d *Database) CheckClientCanOrder(username string) (bool, float64, error) {
|
||||
amende, err := d.GetClientAmende(username)
|
||||
if err != nil {
|
||||
return false, 0, err
|
||||
}
|
||||
|
||||
if amende > 0 {
|
||||
log.Printf("⚠️ [CheckClientCanOrder] Client %s bloqué: %.2f points de pénalités", username, amende)
|
||||
return false, amende, fmt.Errorf("pénalités impayées: %.2f points", amende)
|
||||
}
|
||||
|
||||
return true, 0, nil
|
||||
}
|
||||
|
||||
// ResetClientPoint réinitialise les points d'un client.
|
||||
// extraPoolKey != "" → reset points_extra[extraPoolKey] uniquement
|
||||
// extraPoolKey == "" (poolIdx=-1) → reset total points_extra
|
||||
@@ -488,17 +411,13 @@ func (d *Database) ResetClientPoint(username string, poolIdx int, extraPoolKey s
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *Database) ResetClientPenalties(username string, resetCancellationsCount bool) error {
|
||||
log.Printf("🔄 [ResetClientPenalties] Reset pour %s (reset_count=%v)", username, resetCancellationsCount)
|
||||
func (d *Database) ResetClientPenalties(username string, _ bool) error {
|
||||
log.Printf("🔄 [ResetClientPenalties] Reset amende + cancellations_count pour %s", username)
|
||||
|
||||
var query string
|
||||
if resetCancellationsCount {
|
||||
query = `UPDATE clients SET amende = 0, cancellations_count = 0, updated_at = CURRENT_TIMESTAMP WHERE username = ?`
|
||||
} else {
|
||||
query = `UPDATE clients SET amende = 0, updated_at = CURRENT_TIMESTAMP WHERE username = ?`
|
||||
}
|
||||
|
||||
result := d.GDB.Exec(query, username)
|
||||
result := d.GDB.Exec(
|
||||
`UPDATE clients SET amende = 0, cancellations_count = 0, updated_at = CURRENT_TIMESTAMP WHERE username = ?`,
|
||||
username,
|
||||
)
|
||||
if result.Error != nil {
|
||||
log.Printf("❌ [ResetClientPenalties] Erreur UPDATE: %v", result.Error)
|
||||
return fmt.Errorf("erreur reset pénalités: %w", result.Error)
|
||||
@@ -513,12 +432,12 @@ func (d *Database) ResetClientPenalties(username string, resetCancellationsCount
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *Database) GetAllClientsWithPenalties() ([]map[string]interface{}, error) {
|
||||
func (d *Database) GetAllClientsWithPenalties() ([]map[string]any, error) {
|
||||
var rows []struct {
|
||||
Username string `gorm:"column:username"`
|
||||
Amende float64 `gorm:"column:amende"`
|
||||
CancellationsCount int `gorm:"column:cancellations_count"`
|
||||
UpdatedAt interface{} `gorm:"column:updated_at"`
|
||||
Username string `gorm:"column:username"`
|
||||
Amende float64 `gorm:"column:amende"`
|
||||
CancellationsCount int `gorm:"column:cancellations_count"`
|
||||
UpdatedAt any `gorm:"column:updated_at"`
|
||||
}
|
||||
err := d.GDB.Raw(`
|
||||
SELECT username, amende, COALESCE(cancellations_count, 0) as cancellations_count, updated_at
|
||||
@@ -530,9 +449,9 @@ func (d *Database) GetAllClientsWithPenalties() ([]map[string]interface{}, error
|
||||
return nil, fmt.Errorf("erreur récupération clients: %w", err)
|
||||
}
|
||||
|
||||
clients := make([]map[string]interface{}, 0, len(rows))
|
||||
clients := make([]map[string]any, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
clients = append(clients, map[string]interface{}{
|
||||
clients = append(clients, map[string]any{
|
||||
"username": row.Username,
|
||||
"total_penalty": row.Amende,
|
||||
"cancellations_count": row.CancellationsCount,
|
||||
@@ -545,7 +464,7 @@ func (d *Database) GetAllClientsWithPenalties() ([]map[string]interface{}, error
|
||||
return clients, nil
|
||||
}
|
||||
|
||||
func (d *Database) GetClientPenaltiesStats() (map[string]interface{}, error) {
|
||||
func (d *Database) GetClientPenaltiesStats() (map[string]any, error) {
|
||||
var result struct {
|
||||
ClientsWithPenalties int `gorm:"column:clients_with_penalties"`
|
||||
TotalPenalties float64 `gorm:"column:total_penalties"`
|
||||
@@ -567,7 +486,7 @@ func (d *Database) GetClientPenaltiesStats() (map[string]interface{}, error) {
|
||||
return nil, fmt.Errorf("erreur récupération stats: %w", err)
|
||||
}
|
||||
|
||||
stats := map[string]interface{}{
|
||||
stats := map[string]any{
|
||||
"clients_with_penalties": result.ClientsWithPenalties,
|
||||
"total_penalties": result.TotalPenalties,
|
||||
"average_penalty": result.AvgPenalty,
|
||||
@@ -690,6 +609,51 @@ func (d *Database) CalculateAndAddPointsForCommandTx(tx *gorm.DB, commandID int,
|
||||
|
||||
log.Printf("🎉 [CalcPointsTx] SUCCÈS - %d points [%s] → %s", totalPoints, pointCategory, username)
|
||||
|
||||
// ✅ ÉTAPE 3: Déduire les points des récompenses reçues dans cette commande
|
||||
var rewardItems []struct {
|
||||
RewardPoolKey string `gorm:"column:reward_pool_key"`
|
||||
}
|
||||
if err := tx.Raw(`
|
||||
SELECT reward_pool_key FROM command_items
|
||||
WHERE command_id = ? AND is_reward = true AND reward_pool_key != ''
|
||||
`, commandID).Scan(&rewardItems).Error; err != nil {
|
||||
log.Printf("⚠️ [CalcPointsTx] Erreur query reward items: %v", err)
|
||||
}
|
||||
|
||||
for _, ri := range rewardItems {
|
||||
if settings.PointsReward == nil || settings.PointsReward.Threshold <= 0 {
|
||||
break
|
||||
}
|
||||
threshold := settings.PointsReward.Threshold
|
||||
poolKey := ri.RewardPoolKey
|
||||
// Déduire threshold points de points_extra[poolKey] (plancher à 0)
|
||||
if err := tx.Exec(`
|
||||
UPDATE clients
|
||||
SET points_extra = jsonb_set(
|
||||
COALESCE(points_extra, '{}'::jsonb),
|
||||
ARRAY[?],
|
||||
to_jsonb(GREATEST(0, COALESCE((points_extra->>?)::int, 0) - ?))
|
||||
), updated_at = CURRENT_TIMESTAMP
|
||||
WHERE username = ?
|
||||
`, poolKey, poolKey, threshold, username).Error; err != nil {
|
||||
log.Printf("⚠️ [CalcPointsTx] Erreur déduction points reward pool=%s: %v", poolKey, err)
|
||||
} else {
|
||||
log.Printf("🎁 [CalcPointsTx] Récompense reçue: -%d pts pool=%s → %s", threshold, poolKey, username)
|
||||
}
|
||||
// Décrémenter points_redeemed[poolKey] (plancher à 0)
|
||||
if err := tx.Exec(`
|
||||
UPDATE clients
|
||||
SET points_redeemed = jsonb_set(
|
||||
COALESCE(points_redeemed, '{}'::jsonb),
|
||||
ARRAY[?],
|
||||
to_jsonb(GREATEST(0, COALESCE((points_redeemed->>?)::int, 0) - 1))
|
||||
), updated_at = CURRENT_TIMESTAMP
|
||||
WHERE username = ?
|
||||
`, poolKey, poolKey, username).Error; err != nil {
|
||||
log.Printf("⚠️ [CalcPointsTx] Erreur décrément redeemed pool=%s: %v", poolKey, err)
|
||||
}
|
||||
}
|
||||
|
||||
return totalPoints, pointCategory, nil
|
||||
}
|
||||
|
||||
@@ -727,3 +691,84 @@ func (d *Database) CanUserAccessCommand(
|
||||
|
||||
return exists, err
|
||||
}
|
||||
|
||||
// GetClientPointsAndRewards retourne les points cumulés et les récompenses réclamées pour un client.
|
||||
func (d *Database) GetClientPointsAndRewards(username string) (pointsExtra map[string]int, pointsRedeemed map[string]int, err error) {
|
||||
var row struct {
|
||||
PointsExtraJSON []byte `gorm:"column:points_extra"`
|
||||
PointsRedeemedJSON []byte `gorm:"column:points_redeemed"`
|
||||
}
|
||||
if err = d.GDB.Raw(`
|
||||
SELECT COALESCE(points_extra, '{}'::jsonb) as points_extra,
|
||||
COALESCE(points_redeemed, '{}'::jsonb) as points_redeemed
|
||||
FROM clients WHERE username = ?`, username).Scan(&row).Error; err != nil {
|
||||
return nil, nil, fmt.Errorf("erreur lecture points client: %w", err)
|
||||
}
|
||||
pointsExtra = map[string]int{}
|
||||
pointsRedeemed = map[string]int{}
|
||||
if len(row.PointsExtraJSON) > 0 {
|
||||
json.Unmarshal(row.PointsExtraJSON, &pointsExtra)
|
||||
}
|
||||
if len(row.PointsRedeemedJSON) > 0 {
|
||||
json.Unmarshal(row.PointsRedeemedJSON, &pointsRedeemed)
|
||||
}
|
||||
return pointsExtra, pointsRedeemed, nil
|
||||
}
|
||||
|
||||
// ClaimPoolReward réclame une récompense pour un pool donné si le client a assez de points.
|
||||
// Retourne le nombre de récompenses disponibles restantes après la réclamation.
|
||||
func (d *Database) ClaimPoolReward(username, poolKey string, threshold int) (remainingAvailable int, err error) {
|
||||
var points, redeemed int
|
||||
|
||||
err = d.GDB.Transaction(func(tx *gorm.DB) error {
|
||||
var row struct {
|
||||
Points int `gorm:"column:pts"`
|
||||
Redeemed int `gorm:"column:redeemed"`
|
||||
}
|
||||
if err := tx.Raw(`
|
||||
SELECT
|
||||
COALESCE((points_extra->>?)::int, 0) as pts,
|
||||
COALESCE((points_redeemed->>?)::int, 0) as redeemed
|
||||
FROM clients WHERE username = ? FOR UPDATE`,
|
||||
poolKey, poolKey, username).Scan(&row).Error; err != nil {
|
||||
return fmt.Errorf("erreur lecture: %w", err)
|
||||
}
|
||||
points = row.Points
|
||||
redeemed = row.Redeemed
|
||||
|
||||
earned := points / threshold
|
||||
available := earned - redeemed
|
||||
if available <= 0 {
|
||||
return fmt.Errorf("pas de récompense disponible pour ce pool")
|
||||
}
|
||||
|
||||
return tx.Exec(`
|
||||
UPDATE clients
|
||||
SET points_redeemed = jsonb_set(
|
||||
COALESCE(points_redeemed, '{}'::jsonb),
|
||||
ARRAY[?],
|
||||
to_jsonb(COALESCE((points_redeemed->>?)::int, 0) + 1)
|
||||
), updated_at = CURRENT_TIMESTAMP
|
||||
WHERE username = ?`,
|
||||
poolKey, poolKey, username).Error
|
||||
})
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
earned := points / threshold
|
||||
remainingAvailable = earned - (redeemed + 1)
|
||||
return remainingAvailable, nil
|
||||
}
|
||||
|
||||
// ResetClientRedeemed remet à zéro les récompenses réclamées (admin).
|
||||
func (d *Database) ResetClientRedeemed(username, poolKey string) error {
|
||||
if poolKey != "" {
|
||||
return d.GDB.Exec(`
|
||||
UPDATE clients SET points_redeemed = points_redeemed - ?, updated_at = CURRENT_TIMESTAMP
|
||||
WHERE username = ?`, poolKey, username).Error
|
||||
}
|
||||
return d.GDB.Exec(`
|
||||
UPDATE clients SET points_redeemed = '{}'::jsonb, updated_at = CURRENT_TIMESTAMP
|
||||
WHERE username = ?`, username).Error
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ package db
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"slices"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
@@ -79,13 +80,11 @@ func validateItemStatus(status string) error {
|
||||
|
||||
status = strings.ToLower(strings.TrimSpace(status))
|
||||
|
||||
for _, valid := range validStatuses {
|
||||
if status == valid {
|
||||
return nil
|
||||
}
|
||||
if !slices.Contains(validStatuses, status) {
|
||||
return fmt.Errorf("statut invalide: %s", status)
|
||||
}
|
||||
|
||||
return fmt.Errorf("statut invalide: %s", status)
|
||||
return nil
|
||||
}
|
||||
|
||||
// ============================================
|
||||
@@ -98,6 +97,8 @@ func (d *Database) InsertCommandItemWithClientInfo(
|
||||
productID int,
|
||||
quantite float64,
|
||||
prix float64,
|
||||
isReward bool,
|
||||
rewardPoolKey string,
|
||||
clientUsername, clientNom, clientPrenom, clientTelephone, deliveryAddress string,
|
||||
) error {
|
||||
log.Printf("📝 [InsertCommandItemWithClientInfo] START - commandID=%d, produit=%s", commandID, produit)
|
||||
@@ -115,8 +116,11 @@ func (d *Database) InsertCommandItemWithClientInfo(
|
||||
return err
|
||||
}
|
||||
|
||||
if err := validatePrix(prix); err != nil {
|
||||
return err
|
||||
// Les articles récompense ont prix=0, on saute la validation de prix pour eux
|
||||
if !isReward {
|
||||
if err := validatePrix(prix); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if err := validateUsername(clientUsername); err != nil {
|
||||
@@ -162,10 +166,12 @@ func (d *Database) InsertCommandItemWithClientInfo(
|
||||
err := d.GDB.Exec(`
|
||||
INSERT INTO command_items (
|
||||
command_id, produit, product_id, quantite, prix,
|
||||
is_reward, reward_pool_key,
|
||||
client_username, client_nom, client_prenom, client_telephone, delivery_address,
|
||||
status, created_at, updated_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'pending', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)`,
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'pending', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)`,
|
||||
commandID, produit, productID, quantite, prix,
|
||||
isReward, rewardPoolKey,
|
||||
clientUsername, clientNom, clientPrenom, clientTelephone, deliveryAddress,
|
||||
).Error
|
||||
if err != nil {
|
||||
@@ -181,7 +187,7 @@ func (d *Database) InsertCommandItemWithClientInfo(
|
||||
// GET COMMAND ITEMS - VERSION SÉCURISÉE + FIX NULL
|
||||
// ============================================
|
||||
|
||||
func (d *Database) GetCommandItems(commandID int) ([]map[string]interface{}, error) {
|
||||
func (d *Database) GetCommandItems(commandID int) ([]map[string]any, error) {
|
||||
log.Printf("📦 [GetCommandItems] START - commandID=%d", commandID)
|
||||
|
||||
// ✅ VALIDATION
|
||||
@@ -191,28 +197,31 @@ func (d *Database) GetCommandItems(commandID int) ([]map[string]interface{}, err
|
||||
}
|
||||
|
||||
var rows []struct {
|
||||
ID int `gorm:"column:id"`
|
||||
CommandID int `gorm:"column:command_id"`
|
||||
Produit string `gorm:"column:produit"`
|
||||
ProductID *int64 `gorm:"column:product_id"`
|
||||
Quantite float64 `gorm:"column:quantite"`
|
||||
Prix float64 `gorm:"column:prix"`
|
||||
ClientUsername string `gorm:"column:client_username"`
|
||||
ClientNom string `gorm:"column:client_nom"`
|
||||
ClientPrenom string `gorm:"column:client_prenom"`
|
||||
ClientTelephone string `gorm:"column:client_telephone"`
|
||||
DeliveryAddress *string `gorm:"column:delivery_address"`
|
||||
Status *string `gorm:"column:status"`
|
||||
CreatedAt time.Time `gorm:"column:created_at"`
|
||||
UpdatedAt time.Time `gorm:"column:updated_at"`
|
||||
CommandStatus *string `gorm:"column:command_status"`
|
||||
CommandAddress *string `gorm:"column:command_address"`
|
||||
TotalPrix float64 `gorm:"column:total_prix"`
|
||||
ReferralUsed float64 `gorm:"column:referral_used"`
|
||||
LivreurAssign *string `gorm:"column:livreur_assign"`
|
||||
CommandCreatedAt *time.Time `gorm:"column:command_created_at"`
|
||||
Category string `gorm:"column:category"`
|
||||
ClientOrderNumber int `gorm:"column:client_order_number"`
|
||||
ID int `gorm:"column:id"`
|
||||
CommandID int `gorm:"column:command_id"`
|
||||
Produit string `gorm:"column:produit"`
|
||||
ProductID *int64 `gorm:"column:product_id"`
|
||||
Quantite float64 `gorm:"column:quantite"`
|
||||
Prix float64 `gorm:"column:prix"`
|
||||
IsReward bool `gorm:"column:is_reward"`
|
||||
RewardPoolKey string `gorm:"column:reward_pool_key"`
|
||||
ClientUsername string `gorm:"column:client_username"`
|
||||
ClientNom string `gorm:"column:client_nom"`
|
||||
ClientPrenom string `gorm:"column:client_prenom"`
|
||||
ClientTelephone string `gorm:"column:client_telephone"`
|
||||
DeliveryAddress *string `gorm:"column:delivery_address"`
|
||||
Status *string `gorm:"column:status"`
|
||||
CreatedAt time.Time `gorm:"column:created_at"`
|
||||
UpdatedAt time.Time `gorm:"column:updated_at"`
|
||||
CommandStatus *string `gorm:"column:command_status"`
|
||||
CommandAddress *string `gorm:"column:command_address"`
|
||||
TotalPrix float64 `gorm:"column:total_prix"`
|
||||
ReferralUsed float64 `gorm:"column:referral_used"`
|
||||
LivreurAssign *string `gorm:"column:livreur_assign"`
|
||||
CommandCreatedAt *time.Time `gorm:"column:command_created_at"`
|
||||
Category string `gorm:"column:category"`
|
||||
Unit string `gorm:"column:unit"`
|
||||
ClientOrderNumber int `gorm:"column:client_order_number"`
|
||||
}
|
||||
|
||||
err := d.GDB.Raw(`
|
||||
@@ -223,6 +232,8 @@ func (d *Database) GetCommandItems(commandID int) ([]map[string]interface{}, err
|
||||
ci.product_id,
|
||||
ci.quantite,
|
||||
ci.prix,
|
||||
ci.is_reward,
|
||||
ci.reward_pool_key,
|
||||
ci.client_username,
|
||||
ci.client_nom,
|
||||
ci.client_prenom,
|
||||
@@ -237,7 +248,8 @@ func (d *Database) GetCommandItems(commandID int) ([]map[string]interface{}, err
|
||||
c.referral_used,
|
||||
c.livreur_assign,
|
||||
c.created_at as command_created_at,
|
||||
p.category,
|
||||
COALESCE(p.category, '') as category,
|
||||
COALESCE(p.unit, '') as unit,
|
||||
c.client_order_id as client_order_number
|
||||
FROM command_items ci
|
||||
LEFT JOIN commandes c ON ci.command_id = c.id
|
||||
@@ -249,25 +261,27 @@ func (d *Database) GetCommandItems(commandID int) ([]map[string]interface{}, err
|
||||
return nil, fmt.Errorf("erreur récupération items: %w", err)
|
||||
}
|
||||
|
||||
items := make([]map[string]interface{}, 0, len(rows))
|
||||
items := make([]map[string]any, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
productIDValue := 0
|
||||
if row.ProductID != nil {
|
||||
productIDValue = int(*row.ProductID)
|
||||
}
|
||||
|
||||
var commandCreatedAt interface{}
|
||||
var commandCreatedAt any
|
||||
if row.CommandCreatedAt != nil {
|
||||
commandCreatedAt = *row.CommandCreatedAt
|
||||
}
|
||||
|
||||
item := map[string]interface{}{
|
||||
item := map[string]any{
|
||||
"id": row.ID,
|
||||
"command_id": row.CommandID,
|
||||
"produit": row.Produit,
|
||||
"product_id": productIDValue,
|
||||
"quantite": row.Quantite,
|
||||
"prix": row.Prix,
|
||||
"is_reward": row.IsReward,
|
||||
"reward_pool_key": row.RewardPoolKey,
|
||||
"client_username": row.ClientUsername,
|
||||
"client_nom": row.ClientNom,
|
||||
"client_prenom": row.ClientPrenom,
|
||||
@@ -277,13 +291,14 @@ func (d *Database) GetCommandItems(commandID int) ([]map[string]interface{}, err
|
||||
"created_at": row.CreatedAt,
|
||||
"updated_at": row.UpdatedAt,
|
||||
// Infos commande
|
||||
"command_status": ptrStr(row.CommandStatus),
|
||||
"command_address": ptrStr(row.CommandAddress),
|
||||
"total_prix": row.TotalPrix,
|
||||
"referral_used": row.ReferralUsed,
|
||||
"livreur_assign": ptrStr(row.LivreurAssign),
|
||||
"command_status": ptrStr(row.CommandStatus),
|
||||
"command_address": ptrStr(row.CommandAddress),
|
||||
"total_prix": row.TotalPrix,
|
||||
"referral_used": row.ReferralUsed,
|
||||
"livreur_assign": ptrStr(row.LivreurAssign),
|
||||
"command_created_at": commandCreatedAt,
|
||||
"category": row.Category,
|
||||
"unit": row.Unit,
|
||||
"client_order_number": row.ClientOrderNumber,
|
||||
}
|
||||
items = append(items, item)
|
||||
@@ -301,104 +316,6 @@ func ptrStr(s *string) string {
|
||||
return *s
|
||||
}
|
||||
|
||||
func (d *Database) GetCommandItemsByUsername(username string) ([]map[string]interface{}, error) {
|
||||
if err := validateUsername(username); err != nil {
|
||||
log.Printf("❌ [GetCommandItemsByUsername] %v", err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var rows []struct {
|
||||
ID int `gorm:"column:id"`
|
||||
CommandID int `gorm:"column:command_id"`
|
||||
Produit string `gorm:"column:produit"`
|
||||
ProductID *int64 `gorm:"column:product_id"`
|
||||
Quantite float64 `gorm:"column:quantite"`
|
||||
Prix float64 `gorm:"column:prix"`
|
||||
ClientUsername string `gorm:"column:client_username"`
|
||||
ClientNom string `gorm:"column:client_nom"`
|
||||
ClientPrenom string `gorm:"column:client_prenom"`
|
||||
ClientTelephone string `gorm:"column:client_telephone"`
|
||||
DeliveryAddress *string `gorm:"column:delivery_address"`
|
||||
Status *string `gorm:"column:status"`
|
||||
CreatedAt time.Time `gorm:"column:created_at"`
|
||||
UpdatedAt time.Time `gorm:"column:updated_at"`
|
||||
CommandStatus *string `gorm:"column:command_status"`
|
||||
CommandAddress *string `gorm:"column:command_address"`
|
||||
TotalPrix float64 `gorm:"column:total_prix"`
|
||||
LivreurAssign *string `gorm:"column:livreur_assign"`
|
||||
CommandCreatedAt *time.Time `gorm:"column:command_created_at"`
|
||||
}
|
||||
|
||||
err := d.GDB.Raw(`
|
||||
SELECT
|
||||
ci.id,
|
||||
ci.command_id,
|
||||
ci.produit,
|
||||
ci.product_id,
|
||||
ci.quantite,
|
||||
ci.prix,
|
||||
ci.client_username,
|
||||
ci.client_nom,
|
||||
ci.client_prenom,
|
||||
ci.client_telephone,
|
||||
ci.delivery_address,
|
||||
ci.status,
|
||||
ci.created_at,
|
||||
ci.updated_at,
|
||||
c.status as command_status,
|
||||
c.adresse as command_address,
|
||||
c.total_prix,
|
||||
c.livreur_assign,
|
||||
c.created_at as command_created_at
|
||||
FROM command_items ci
|
||||
LEFT JOIN commandes c ON ci.command_id = c.id
|
||||
WHERE ci.client_username = ?
|
||||
ORDER BY ci.command_id DESC, ci.id ASC`, username).Scan(&rows).Error
|
||||
if err != nil {
|
||||
log.Printf("❌ Erreur query: %v", err)
|
||||
return nil, fmt.Errorf("erreur récupération items: %w", err)
|
||||
}
|
||||
|
||||
items := make([]map[string]interface{}, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
productIDValue := 0
|
||||
if row.ProductID != nil {
|
||||
productIDValue = int(*row.ProductID)
|
||||
}
|
||||
|
||||
var commandCreatedAt interface{}
|
||||
if row.CommandCreatedAt != nil {
|
||||
commandCreatedAt = *row.CommandCreatedAt
|
||||
}
|
||||
|
||||
item := map[string]interface{}{
|
||||
"id": row.ID,
|
||||
"command_id": row.CommandID,
|
||||
"produit": row.Produit,
|
||||
"product_id": productIDValue,
|
||||
"quantite": row.Quantite,
|
||||
"prix": row.Prix,
|
||||
"client_username": row.ClientUsername,
|
||||
"client_nom": row.ClientNom,
|
||||
"client_prenom": row.ClientPrenom,
|
||||
"client_telephone": row.ClientTelephone,
|
||||
"delivery_address": ptrStr(row.DeliveryAddress),
|
||||
"status": ptrStr(row.Status),
|
||||
"created_at": row.CreatedAt,
|
||||
"updated_at": row.UpdatedAt,
|
||||
"command_status": ptrStr(row.CommandStatus),
|
||||
"command_address": ptrStr(row.CommandAddress),
|
||||
"total_prix": row.TotalPrix,
|
||||
"livreur_assign": ptrStr(row.LivreurAssign),
|
||||
"command_created_at": commandCreatedAt,
|
||||
}
|
||||
items = append(items, item)
|
||||
}
|
||||
|
||||
log.Printf("✅ %d items récupérés pour l'utilisateur %s", len(items), username)
|
||||
return items, nil
|
||||
}
|
||||
|
||||
func (d *Database) DeleteCommandItem(commandID, itemID int) error {
|
||||
log.Printf("🗑️ [DeleteCommandItem] START - commandID=%d, itemID=%d", commandID, itemID)
|
||||
|
||||
@@ -409,30 +326,58 @@ func (d *Database) DeleteCommandItem(commandID, itemID int) error {
|
||||
return err
|
||||
}
|
||||
|
||||
// Récupérer le prix et la quantité avant suppression pour mettre à jour le total
|
||||
var result struct {
|
||||
Prix float64 `gorm:"column:prix"`
|
||||
Quantite float64 `gorm:"column:quantite"`
|
||||
Prix float64 `gorm:"column:prix"`
|
||||
Quantite float64 `gorm:"column:quantite"`
|
||||
ProductID int `gorm:"column:product_id"`
|
||||
}
|
||||
if err := d.GDB.Raw(`SELECT prix, quantite FROM command_items WHERE id = ? AND command_id = ?`, itemID, commandID).Scan(&result).Error; err != nil {
|
||||
if err := d.GDB.Raw(`SELECT prix, quantite, product_id FROM command_items WHERE id = ? AND command_id = ?`, itemID, commandID).Scan(&result).Error; err != nil {
|
||||
return fmt.Errorf("erreur vérification item: %w", err)
|
||||
}
|
||||
if result.Prix == 0 && result.Quantite == 0 {
|
||||
return fmt.Errorf("item %d non trouvé dans la commande %d", itemID, commandID)
|
||||
}
|
||||
|
||||
// Supprimer l'item
|
||||
if err := d.GDB.Exec(`DELETE FROM command_items WHERE id = ?`, itemID).Error; err != nil {
|
||||
var cmdStatus string
|
||||
d.GDB.Raw(`SELECT status FROM commandes WHERE id = ?`, commandID).Scan(&cmdStatus)
|
||||
|
||||
noRestoreStatuses := []string{"cancelled", "approved", "livre"}
|
||||
restoreStock := result.ProductID != 0 && !slices.Contains(noRestoreStatuses, cmdStatus)
|
||||
|
||||
tx := d.GDB.Begin()
|
||||
if tx.Error != nil {
|
||||
return fmt.Errorf("erreur démarrage transaction: %w", tx.Error)
|
||||
}
|
||||
|
||||
if err := tx.Exec(`DELETE FROM command_items WHERE id = ?`, itemID).Error; err != nil {
|
||||
tx.Rollback()
|
||||
log.Printf("❌ Erreur DELETE command_items: %v", err)
|
||||
return fmt.Errorf("erreur suppression item: %w", err)
|
||||
}
|
||||
|
||||
// Recalculer le total de la commande
|
||||
if err := d.GDB.Exec(
|
||||
if err := tx.Exec(
|
||||
`UPDATE commandes SET total_prix = GREATEST(0, total_prix - ?) WHERE id = ?`,
|
||||
result.Prix*result.Quantite, commandID,
|
||||
).Error; err != nil {
|
||||
log.Printf("⚠️ [DeleteCommandItem] Erreur maj total commande: %v", err)
|
||||
tx.Rollback()
|
||||
log.Printf("❌ [DeleteCommandItem] Erreur maj total commande: %v", err)
|
||||
return fmt.Errorf("erreur mise à jour total commande: %w", err)
|
||||
}
|
||||
|
||||
if restoreStock {
|
||||
if err := tx.Exec(
|
||||
`UPDATE products SET stock = stock + ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?`,
|
||||
result.Quantite, result.ProductID,
|
||||
).Error; err != nil {
|
||||
tx.Rollback()
|
||||
log.Printf("❌ [DeleteCommandItem] Erreur restauration stock: %v", err)
|
||||
return fmt.Errorf("erreur restauration stock: %w", err)
|
||||
}
|
||||
log.Printf("✅ [DeleteCommandItem] Stock restauré: +%.3f pour produit %d", result.Quantite, result.ProductID)
|
||||
}
|
||||
|
||||
if err := tx.Commit().Error; err != nil {
|
||||
return fmt.Errorf("erreur commit transaction: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
|
||||
@@ -7,7 +7,6 @@ package db
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"gestion/models"
|
||||
"log"
|
||||
"slices"
|
||||
)
|
||||
@@ -44,95 +43,13 @@ func (d *Database) GetAllCommandsOldestFirst(status, username string) ([]map[str
|
||||
return commands, nil
|
||||
}
|
||||
|
||||
// GetOldestPendingCommand récupère la commande pending la plus ancienne
|
||||
func (d *Database) GetOldestPendingCommand() (map[string]any, error) {
|
||||
var commands []map[string]any
|
||||
err := d.GDB.Raw(`
|
||||
SELECT c.id, c.username, c.status, c.adresse, c.total_prix,
|
||||
c.livreur_assign, c.created_at, c.updated_at
|
||||
FROM commandes c
|
||||
WHERE c.status = 'pending'
|
||||
ORDER BY c.created_at ASC
|
||||
LIMIT 1`).Scan(&commands).Error
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("erreur récupération commande la plus ancienne: %w", err)
|
||||
}
|
||||
if len(commands) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
return commands[0], nil
|
||||
}
|
||||
|
||||
// GetPendingCommandsWithPriority récupère les commandes pending avec calcul de priorité
|
||||
func (d *Database) GetPendingCommandsWithPriority() ([]*models.CommandPriority, error) {
|
||||
var rows []struct {
|
||||
ID int `gorm:"column:id"`
|
||||
Username string `gorm:"column:username"`
|
||||
Status string `gorm:"column:status"`
|
||||
Adresse string `gorm:"column:adresse"`
|
||||
TotalPrix float64 `gorm:"column:total_prix"`
|
||||
CreatedAt string `gorm:"column:created_at"`
|
||||
UpdatedAt string `gorm:"column:updated_at"`
|
||||
WaitingSeconds float64 `gorm:"column:waiting_seconds"`
|
||||
}
|
||||
|
||||
err := d.GDB.Raw(`
|
||||
SELECT c.id, c.username, c.status, c.adresse, c.total_prix,
|
||||
c.created_at, c.updated_at,
|
||||
EXTRACT(EPOCH FROM (CURRENT_TIMESTAMP - c.created_at)) as waiting_seconds
|
||||
FROM commandes c
|
||||
WHERE c.status = 'pending'
|
||||
ORDER BY c.created_at ASC`).Scan(&rows).Error
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("erreur récupération commandes avec priorité: %w", err)
|
||||
}
|
||||
|
||||
commands := make([]*models.CommandPriority, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
cmd := &models.CommandPriority{
|
||||
ID: row.ID,
|
||||
Username: row.Username,
|
||||
Status: row.Status,
|
||||
Address: row.Adresse,
|
||||
TotalPrice: row.TotalPrix,
|
||||
WaitingSeconds: int(row.WaitingSeconds),
|
||||
WaitingMinutes: int(row.WaitingSeconds / 60),
|
||||
}
|
||||
commands = append(commands, cmd)
|
||||
}
|
||||
|
||||
return commands, nil
|
||||
}
|
||||
|
||||
// GetCommandWaitingTime récupère le temps d'attente d'une commande
|
||||
func (d *Database) GetCommandWaitingTime(commandID int) (int, error) {
|
||||
var result struct {
|
||||
WaitingSeconds int `gorm:"column:waiting_seconds"`
|
||||
}
|
||||
err := d.GDB.Raw(`
|
||||
SELECT EXTRACT(EPOCH FROM (CURRENT_TIMESTAMP - created_at))::INTEGER as waiting_seconds
|
||||
FROM commandes WHERE id = ?`, commandID).Scan(&result).Error
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("erreur récupération temps d'attente: %w", err)
|
||||
}
|
||||
if result.WaitingSeconds == 0 {
|
||||
// Vérifie si la commande existe vraiment
|
||||
var exists bool
|
||||
d.GDB.Raw(`SELECT EXISTS(SELECT 1 FROM commandes WHERE id = ?)`, commandID).Scan(&exists)
|
||||
if !exists {
|
||||
return 0, fmt.Errorf("commande non trouvée")
|
||||
}
|
||||
}
|
||||
return result.WaitingSeconds, nil
|
||||
}
|
||||
|
||||
// GetPendingCommandsStats récupère des statistiques sur les commandes en attente
|
||||
func (d *Database) GetPendingCommandsStats() (map[string]any, error) {
|
||||
var result struct {
|
||||
TotalPending int `gorm:"column:total_pending"`
|
||||
AvgWaitingSeconds *float64 `gorm:"column:avg_waiting_seconds"`
|
||||
OldestCommandDate *string `gorm:"column:oldest_command_date"`
|
||||
NewestCommandDate *string `gorm:"column:newest_command_date"`
|
||||
TotalPending int `gorm:"column:total_pending"`
|
||||
AvgWaitingSeconds *float64 `gorm:"column:avg_waiting_seconds"`
|
||||
OldestCommandDate *string `gorm:"column:oldest_command_date"`
|
||||
NewestCommandDate *string `gorm:"column:newest_command_date"`
|
||||
}
|
||||
|
||||
err := d.GDB.Raw(`
|
||||
|
||||
@@ -45,14 +45,16 @@ func validateAddress(address string) error {
|
||||
}
|
||||
|
||||
type basketItem struct {
|
||||
ProductID int `gorm:"column:product_id"`
|
||||
Quantity float64 `gorm:"column:quantity"`
|
||||
Price float64 `gorm:"column:price"`
|
||||
ProductID int `gorm:"column:product_id"`
|
||||
Quantity float64 `gorm:"column:quantity"`
|
||||
Price float64 `gorm:"column:price"`
|
||||
IsReward bool `gorm:"column:is_reward"`
|
||||
RewardPoolKey string `gorm:"column:reward_pool_key"`
|
||||
}
|
||||
|
||||
func (d *Database) fetchBasketItems(username string) ([]basketItem, float64, error) {
|
||||
var items []basketItem
|
||||
if err := d.GDB.Table("baskets").Select("product_id, quantity, price").Where("username = ?", username).Scan(&items).Error; err != nil {
|
||||
if err := d.GDB.Table("baskets").Select("product_id, quantity, price, is_reward, reward_pool_key").Where("username = ?", username).Scan(&items).Error; err != nil {
|
||||
return nil, 0, fmt.Errorf("erreur récupération panier: %w", err)
|
||||
}
|
||||
total := 0.0
|
||||
@@ -122,11 +124,13 @@ func (d *Database) CreateCommand(username string) (*models.Command, error) {
|
||||
}
|
||||
|
||||
cmdItem := models.CommandItem{
|
||||
CommandID: commandID,
|
||||
Produit: productName,
|
||||
ProductID: item.ProductID,
|
||||
Quantity: item.Quantity,
|
||||
Price: item.Price,
|
||||
CommandID: commandID,
|
||||
Produit: productName,
|
||||
ProductID: item.ProductID,
|
||||
Quantity: item.Quantity,
|
||||
Price: item.Price,
|
||||
IsReward: item.IsReward,
|
||||
RewardPoolKey: item.RewardPoolKey,
|
||||
}
|
||||
if err := d.GDB.Create(&cmdItem).Error; err != nil {
|
||||
return nil, fmt.Errorf("erreur lors de l'insertion des items: %w", err)
|
||||
@@ -220,6 +224,8 @@ func (d *Database) CreateCommandWithAddress(username, deliveryAddress string) (*
|
||||
item.ProductID,
|
||||
item.Quantity,
|
||||
item.Price,
|
||||
item.IsReward,
|
||||
item.RewardPoolKey,
|
||||
username,
|
||||
clientNom,
|
||||
clientPrenom,
|
||||
@@ -349,14 +355,6 @@ func (d *Database) GetAllCommands(status, username string) ([]map[string]any, er
|
||||
return commands, nil
|
||||
}
|
||||
|
||||
func (d *Database) GetCommandCount() (int, error) {
|
||||
var count int64
|
||||
if err := d.GDB.Model(&models.Command{}).Count(&count).Error; err != nil {
|
||||
return 0, fmt.Errorf("erreur récupération count commandes: %w", err)
|
||||
}
|
||||
return int(count), nil
|
||||
}
|
||||
|
||||
func (d *Database) SetCommandReferralUsed(commandID int, amount float64) error {
|
||||
return d.GDB.Exec(`UPDATE commandes SET referral_used = ? WHERE id = ?`, amount, commandID).Error
|
||||
}
|
||||
@@ -432,20 +430,6 @@ func (d *Database) GetClientOrderID(commandID int) int {
|
||||
return result.ClientOrderID
|
||||
}
|
||||
|
||||
func (d *Database) GetCommandAddress(commandID int) (string, error) {
|
||||
var result struct {
|
||||
Adresse string `gorm:"column:adresse"`
|
||||
}
|
||||
if err := d.GDB.Model(&models.Command{}).Select("adresse").Where("id = ?", commandID).First(&result).Error; err != nil {
|
||||
return "", fmt.Errorf("erreur lors de la récupération de l'adresse: %w", err)
|
||||
}
|
||||
if result.Adresse == "" {
|
||||
return "", fmt.Errorf("commande non trouvée")
|
||||
}
|
||||
|
||||
return result.Adresse, nil
|
||||
}
|
||||
|
||||
func (d *Database) UpdateCommandAddress(commandID int, deliveryAddress string) error {
|
||||
if len(deliveryAddress) > 500 {
|
||||
return fmt.Errorf("adresse trop longue (max 500 caractères)")
|
||||
@@ -925,3 +909,13 @@ func (d *Database) ApproveDeliveryAtomicByStaff(commandID int, staffUsername str
|
||||
|
||||
return totalPoints, pointCategory, clientUsernameOut, nil
|
||||
}
|
||||
|
||||
func (d *Database) SetCommandCancelReason(commandID int, reason string) error {
|
||||
if len(reason) > 500 {
|
||||
reason = reason[:500]
|
||||
}
|
||||
return d.GDB.Exec(
|
||||
`UPDATE commandes SET cancel_reason = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?`,
|
||||
reason, commandID,
|
||||
).Error
|
||||
}
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"gestion/models"
|
||||
)
|
||||
|
||||
func (d *Database) AddContact(contact *models.Contact) error {
|
||||
err := d.GDB.Create(contact).Error
|
||||
return err
|
||||
}
|
||||
|
||||
func (d *Database) GetContact(id uint) (*models.Contact, error) {
|
||||
var contact models.Contact
|
||||
if err := d.GDB.First(&contact, id).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &contact, nil
|
||||
}
|
||||
|
||||
func (d *Database) UpdateContact(contact *models.Contact) error {
|
||||
err := d.GDB.Save(contact).Error
|
||||
return err
|
||||
}
|
||||
|
||||
func (d *Database) DeleteContact(id uint) error {
|
||||
err := d.GDB.Delete(&models.Contact{}, id).Error
|
||||
return err
|
||||
}
|
||||
@@ -117,22 +117,6 @@ func (d *Database) GetDeliveryPersonCommands(livreurUsername string, status stri
|
||||
return commands, nil
|
||||
}
|
||||
|
||||
func (d *Database) IncrementLivreurDeliveryCount(livreurUsername string) error {
|
||||
result := d.GDB.Exec(`
|
||||
UPDATE users
|
||||
SET livraison = livraison + 1,
|
||||
total = total + 1,
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
WHERE username = ? AND role = 'livreur'`, livreurUsername)
|
||||
if result.Error != nil {
|
||||
return fmt.Errorf("erreur lors de l'incrémentation des livraisons: %w", result.Error)
|
||||
}
|
||||
if result.RowsAffected == 0 {
|
||||
return fmt.Errorf("livreur non trouvé")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *Database) ApproveDelivery(commandID int, clientUsername string) error {
|
||||
return d.GDB.Transaction(func(tx *gorm.DB) error {
|
||||
var cmdResult struct {
|
||||
|
||||
@@ -221,79 +221,3 @@ func (d *Database) UpdateCommandLivreur(commandID int, livreurUsername string) e
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetAllDeliveryPersonsStats récupère les stats de tous les livreurs
|
||||
func (d *Database) GetAllDeliveryPersonsStats() ([]map[string]any, error) {
|
||||
livreurs, err := d.GetAvailableDeliveryPersons()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("erreur récupération livreurs: %w", err)
|
||||
}
|
||||
|
||||
var stats []map[string]any
|
||||
for _, livreur := range livreurs {
|
||||
username := livreur["username"].(string)
|
||||
|
||||
totalDeliveries, _ := d.CountDeliveriesByStatus(username, "")
|
||||
completedDeliveries, _ := d.CountDeliveriesByStatus(username, "approved")
|
||||
queueSize, _ := d.GetDeliverymanQueueSize(username)
|
||||
status, _ := d.GetDeliveryPersonStatus(username)
|
||||
|
||||
stats = append(stats, map[string]any{
|
||||
"username": username,
|
||||
"total_deliveries": totalDeliveries,
|
||||
"completed_deliveries": completedDeliveries,
|
||||
"queue_size": queueSize,
|
||||
"status": status,
|
||||
})
|
||||
}
|
||||
return stats, nil
|
||||
}
|
||||
|
||||
// GetDeliveryPersonsByStatus récupère les livreurs par statut
|
||||
func (d *Database) GetDeliveryPersonsByStatus(status string) ([]string, error) {
|
||||
livreurs, err := d.GetAvailableDeliveryPersons()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("erreur récupération livreurs: %w", err)
|
||||
}
|
||||
|
||||
var filteredLivreurs []string
|
||||
for _, livreur := range livreurs {
|
||||
username := livreur["username"].(string)
|
||||
currentStatus, _ := d.GetDeliveryPersonStatus(username)
|
||||
if currentStatus == status {
|
||||
filteredLivreurs = append(filteredLivreurs, username)
|
||||
}
|
||||
}
|
||||
return filteredLivreurs, nil
|
||||
}
|
||||
|
||||
// GetAvailableDeliveryPersonsCount compte les livreurs disponibles
|
||||
func (d *Database) GetAvailableDeliveryPersonsCount() (int, error) {
|
||||
availableLivreurs, err := d.GetDeliveryPersonsByStatus("available")
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return len(availableLivreurs), nil
|
||||
}
|
||||
|
||||
// ClearDeliveryPersonData supprime toutes les données d'un livreur (admin uniquement)
|
||||
func (d *Database) ClearDeliveryPersonData(livreurUsername string) error {
|
||||
log.Printf("🗑️ [ClearDeliveryData] Nettoyage données pour: %s", livreurUsername)
|
||||
|
||||
keys := []string{
|
||||
fmt.Sprintf("delivery:status:%s", livreurUsername),
|
||||
fmt.Sprintf("delivery:location:%s", livreurUsername),
|
||||
fmt.Sprintf("delivery:queue:%s", livreurUsername),
|
||||
fmt.Sprintf("delivery:queue:size:%s", livreurUsername),
|
||||
fmt.Sprintf("delivery:current:%s", livreurUsername),
|
||||
}
|
||||
|
||||
for _, key := range keys {
|
||||
if err := Redis.Del(RedisCtx, key).Err(); err != nil {
|
||||
log.Printf("⚠️ [ClearDeliveryData] Erreur suppression clé %s: %v", key, err)
|
||||
}
|
||||
}
|
||||
|
||||
log.Printf("✅ [ClearDeliveryData] Données nettoyées pour %s", livreurUsername)
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -8,8 +8,6 @@ package db
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"maps"
|
||||
"strconv"
|
||||
)
|
||||
|
||||
// GetCompletedCommandsByUsername récupère toutes les commandes terminées (approved) d'un utilisateur
|
||||
@@ -37,114 +35,3 @@ func (d *Database) GetCompletedCommandsByUsername(username string) ([]map[string
|
||||
}
|
||||
return commands, nil
|
||||
}
|
||||
|
||||
// GetCompletedCommandsWithItems récupère les commandes terminées avec leurs items
|
||||
func (d *Database) GetCompletedCommandsWithItems(username string) ([]map[string]any, error) {
|
||||
commands, err := d.GetCompletedCommandsByUsername(username)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var enrichedCommands []map[string]any
|
||||
|
||||
for _, command := range commands {
|
||||
commandID, _ := strconv.Atoi(fmt.Sprintf("%v", command["id"]))
|
||||
if commandID == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
items, err := d.GetCommandItems(commandID)
|
||||
if err != nil {
|
||||
log.Printf("⚠️ [GetCompletedWithItems] Erreur items pour cmd %d: %v", commandID, err)
|
||||
items = []map[string]any{}
|
||||
}
|
||||
|
||||
enrichedCommand := make(map[string]any)
|
||||
maps.Copy(enrichedCommand, command)
|
||||
enrichedCommand["items"] = items
|
||||
enrichedCommand["items_count"] = len(items)
|
||||
|
||||
enrichedCommands = append(enrichedCommands, enrichedCommand)
|
||||
}
|
||||
|
||||
return enrichedCommands, nil
|
||||
}
|
||||
|
||||
// GetCommandsStatsByUsername récupère les statistiques des commandes d'un utilisateur
|
||||
func (d *Database) GetCommandsStatsByUsername(username string) (map[string]any, error) {
|
||||
query := `
|
||||
SELECT
|
||||
COUNT(*) FILTER (WHERE status = 'approved') as approved_count,
|
||||
COUNT(*) FILTER (WHERE status = 'pending') as pending_count,
|
||||
COUNT(*) FILTER (WHERE status = 'assigned') as assigned_count,
|
||||
COUNT(*) FILTER (WHERE status = 'en_route') as en_route_count,
|
||||
COUNT(*) FILTER (WHERE status = 'livre') as livre_count,
|
||||
COUNT(*) FILTER (WHERE status = 'cancelled') as cancelled_count,
|
||||
COUNT(*) as total_count,
|
||||
COALESCE(SUM(total_prix) FILTER (WHERE status = 'approved'), 0) as total_spent
|
||||
FROM commandes
|
||||
WHERE username = ?
|
||||
`
|
||||
|
||||
var result map[string]any
|
||||
if err := d.GDB.Raw(query, username).Scan(&result).Error; err != nil {
|
||||
log.Printf("❌ [GetCommandsStats] Erreur: %v", err)
|
||||
return nil, fmt.Errorf("erreur lors de la récupération des statistiques: %w", err)
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// GetCommandsByStatus récupère les commandes d'un utilisateur par statut
|
||||
func (d *Database) GetCommandsByStatus(username, status string) ([]map[string]any, error) {
|
||||
log.Printf("📋 [GetCommandsByStatus] START - username=%s, status=%s", username, status)
|
||||
|
||||
query := `
|
||||
SELECT
|
||||
id,
|
||||
client_order_id AS client_order_number,
|
||||
username,
|
||||
status,
|
||||
adresse,
|
||||
total_prix::float8 as total_prix,
|
||||
livreur_assign,
|
||||
created_at,
|
||||
updated_at
|
||||
FROM commandes
|
||||
WHERE username = ? AND status = ?
|
||||
ORDER BY created_at DESC
|
||||
`
|
||||
|
||||
var commands []map[string]any
|
||||
if err := d.GDB.Raw(query, username, status).Scan(&commands).Error; err != nil {
|
||||
return nil, fmt.Errorf("erreur lors de la récupération des commandes: %w", err)
|
||||
}
|
||||
return commands, nil
|
||||
}
|
||||
|
||||
// GetRecentCompletedOrders récupère les N dernières commandes terminées d'un utilisateur
|
||||
func (d *Database) GetRecentCompletedOrders(username string, limit int) ([]map[string]any, error) {
|
||||
query := `
|
||||
SELECT
|
||||
id,
|
||||
client_order_id AS client_order_number,
|
||||
username,
|
||||
status,
|
||||
adresse,
|
||||
total_prix::float8 as total_prix,
|
||||
livreur_assign,
|
||||
created_at,
|
||||
updated_at
|
||||
FROM commandes
|
||||
WHERE username = ? AND status = 'approved'
|
||||
ORDER BY created_at DESC
|
||||
LIMIT ?
|
||||
`
|
||||
|
||||
var commands []map[string]any
|
||||
if err := d.GDB.Raw(query, username, limit).Scan(&commands).Error; err != nil {
|
||||
log.Printf("❌ [GetRecentCompleted] Erreur query: %v", err)
|
||||
return nil, fmt.Errorf("erreur lors de la récupération: %w", err)
|
||||
}
|
||||
return commands, nil
|
||||
}
|
||||
|
||||
@@ -120,6 +120,24 @@ func InitDB() *Database {
|
||||
log.Fatalf("❌ Erreur migration baskets.quantity: %v", err)
|
||||
}
|
||||
|
||||
// Migration: baskets.is_reward — marquer les articles issus d'une récompense points
|
||||
if _, err = database.Exec(`ALTER TABLE baskets ADD COLUMN IF NOT EXISTS is_reward BOOLEAN NOT NULL DEFAULT FALSE`); err != nil {
|
||||
log.Fatalf("❌ Erreur migration baskets.is_reward: %v", err)
|
||||
}
|
||||
|
||||
// Migration: baskets.reward_pool_key — pool de points utilisé pour la récompense
|
||||
if _, err = database.Exec(`ALTER TABLE baskets ADD COLUMN IF NOT EXISTS reward_pool_key VARCHAR(100) NOT NULL DEFAULT ''`); err != nil {
|
||||
log.Fatalf("❌ Erreur migration baskets.reward_pool_key: %v", err)
|
||||
}
|
||||
|
||||
// Migration: command_items.is_reward + reward_pool_key
|
||||
if _, err = database.Exec(`ALTER TABLE command_items ADD COLUMN IF NOT EXISTS is_reward BOOLEAN NOT NULL DEFAULT FALSE`); err != nil {
|
||||
log.Fatalf("❌ Erreur migration command_items.is_reward: %v", err)
|
||||
}
|
||||
if _, err = database.Exec(`ALTER TABLE command_items ADD COLUMN IF NOT EXISTS reward_pool_key VARCHAR(100) NOT NULL DEFAULT ''`); err != nil {
|
||||
log.Fatalf("❌ Erreur migration command_items.reward_pool_key: %v", err)
|
||||
}
|
||||
|
||||
// Migration: command_items.quantite INTEGER → NUMERIC(10,3) pour supporter les quantités fractionnaires
|
||||
if _, err = database.Exec(`
|
||||
DO $$
|
||||
@@ -236,6 +254,29 @@ func InitDB() *Database {
|
||||
log.Fatalf("❌ Erreur migration clients.points_extra: %v", err)
|
||||
}
|
||||
|
||||
// Migration: récompenses réclamées par pool (nb de fois que la récompense a été obtenue)
|
||||
if _, err = database.Exec(`ALTER TABLE clients ADD COLUMN IF NOT EXISTS points_redeemed JSONB NOT NULL DEFAULT '{}'::jsonb`); err != nil {
|
||||
log.Fatalf("❌ Erreur migration clients.points_redeemed: %v", err)
|
||||
}
|
||||
|
||||
// Migration: flag "à venir" sur les produits
|
||||
if _, err = database.Exec(`ALTER TABLE products ADD COLUMN IF NOT EXISTS coming_soon BOOLEAN NOT NULL DEFAULT FALSE`); err != nil {
|
||||
log.Fatalf("❌ Erreur migration products.coming_soon: %v", err)
|
||||
}
|
||||
|
||||
// Migration: prix actif/inactif sur les prix de produits
|
||||
if _, err = database.Exec(`ALTER TABLE product_prices ADD COLUMN IF NOT EXISTS active_price BOOLEAN NOT NULL DEFAULT TRUE`); err != nil {
|
||||
log.Fatalf("❌ Erreur migration product_prices.active_price: %v", err)
|
||||
}
|
||||
|
||||
// Migration: table contacts (SAV Telegram)
|
||||
if _, err = database.Exec(`CREATE TABLE IF NOT EXISTS contacts (
|
||||
id SERIAL PRIMARY KEY,
|
||||
name VARCHAR(255) NOT NULL
|
||||
)`); err != nil {
|
||||
log.Fatalf("❌ Erreur migration contacts: %v", err)
|
||||
}
|
||||
|
||||
// Lancer le nettoyage périodique des tokens expirés
|
||||
go database.cleanExpiredTokensPeriodically()
|
||||
|
||||
@@ -464,6 +505,14 @@ func (db *Database) createTables() error {
|
||||
`CREATE INDEX IF NOT EXISTS idx_issues_command ON delivery_issues(command_id);`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_issues_status ON delivery_issues(status);`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_issues_reported_by ON delivery_issues(reported_by);`,
|
||||
|
||||
// ============================
|
||||
// TABLE contacts
|
||||
// ============================
|
||||
`CREATE TABLE IF NOT EXISTS contacts (
|
||||
id SERIAL PRIMARY KEY,
|
||||
name VARCHAR(255) NOT NULL
|
||||
);`,
|
||||
}
|
||||
|
||||
for _, query := range queries {
|
||||
|
||||
@@ -9,6 +9,18 @@ import (
|
||||
"time"
|
||||
)
|
||||
|
||||
// sendTelegramNotif envoie via le bot principal, puis lbtelegram (BOT1/BOT2) en fallback.
|
||||
func sendTelegramNotif(chatID int64, text string) {
|
||||
if err := services.TelegramBot.SendMessage(chatID, text); err != nil {
|
||||
log.Printf("⚠️ [NOTIF] bot principal échoué: %v — fallback lbtelegram", err)
|
||||
if services.LBTelegram != nil && services.LBTelegram.IsConfigured() {
|
||||
if err2 := services.LBTelegram.SendNotification(chatID, text); err2 != nil {
|
||||
log.Printf("⚠️ [NOTIF] lbtelegram aussi échoué: %v", err2)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (d *Database) NotifyClient(username string, commandID int, notifType, message string) error {
|
||||
notifKey := fmt.Sprintf("notifications:%s", username)
|
||||
|
||||
@@ -24,9 +36,9 @@ func (d *Database) NotifyClient(username string, commandID int, notifType, messa
|
||||
Redis.LPush(RedisCtx, notifKey, notifJSON)
|
||||
Redis.Expire(RedisCtx, notifKey, 7*24*time.Hour)
|
||||
|
||||
if services.TelegramBot != nil && services.TelegramBot.IsConfigured() && services.TelegramBot.IsNotificationsEnabled() {
|
||||
if services.TelegramBot != nil && services.TelegramBot.IsNotificationsEnabled() {
|
||||
if chatID, ok, err := d.GetClientTelegramChatID(username); err == nil && ok {
|
||||
go services.TelegramBot.SendMessage(chatID, fmt.Sprintf("🔔 <b>Notification</b>\n\n%s", message))
|
||||
go sendTelegramNotif(chatID, fmt.Sprintf("🔔 <b>Notification</b>\n\n%s", message))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -50,9 +62,9 @@ func (d *Database) NotifyLivreur(username string, commandID int, notifType, mess
|
||||
Redis.LPush(RedisCtx, notifKey, notifJSON)
|
||||
Redis.Expire(RedisCtx, notifKey, 7*24*time.Hour)
|
||||
|
||||
if services.TelegramBot != nil && services.TelegramBot.IsConfigured() && services.TelegramBot.IsNotificationsEnabled() {
|
||||
if services.TelegramBot != nil && services.TelegramBot.IsNotificationsEnabled() {
|
||||
if chatID, ok, err := d.GetUserTelegramChatID(username); err == nil && ok {
|
||||
go services.TelegramBot.SendMessage(chatID, fmt.Sprintf("🔔 <b>Notification</b>\n\n%s", message))
|
||||
go sendTelegramNotif(chatID, fmt.Sprintf("🔔 <b>Notification</b>\n\n%s", message))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -87,11 +99,10 @@ func (d *Database) NotifyAllAdminCabine(commandID int, clientUsername, deliveryA
|
||||
Redis.LPush(RedisCtx, notifKey, notifJSON)
|
||||
Redis.Expire(RedisCtx, notifKey, 7*24*time.Hour)
|
||||
|
||||
if services.TelegramBot != nil && services.TelegramBot.IsConfigured() && services.TelegramBot.IsNotificationsEnabled() {
|
||||
if services.TelegramBot != nil && services.TelegramBot.IsNotificationsEnabled() {
|
||||
if chatID, ok, err := d.GetUserTelegramChatID(u.Username); err == nil && ok {
|
||||
capturedChatID := chatID
|
||||
capturedMsg := msg
|
||||
go services.TelegramBot.SendMessage(capturedChatID, fmt.Sprintf("🔔 <b>Nouvelle commande</b>\n\n%s", capturedMsg))
|
||||
go sendTelegramNotif(capturedChatID, fmt.Sprintf("🔔 <b>Nouvelle commande</b>\n\n%s", msg))
|
||||
}
|
||||
}
|
||||
count++
|
||||
@@ -126,11 +137,10 @@ func (d *Database) NotifyAllAdminCabineAlert(alertID int, livreurUsername, alert
|
||||
Redis.LPush(RedisCtx, notifKey, notifJSON)
|
||||
Redis.Expire(RedisCtx, notifKey, 7*24*time.Hour)
|
||||
|
||||
if services.TelegramBot != nil && services.TelegramBot.IsConfigured() && services.TelegramBot.IsNotificationsEnabled() {
|
||||
if services.TelegramBot != nil && services.TelegramBot.IsNotificationsEnabled() {
|
||||
if chatID, ok, err := d.GetUserTelegramChatID(u.Username); err == nil && ok {
|
||||
capturedChatID := chatID
|
||||
capturedBody := body
|
||||
go services.TelegramBot.SendMessage(capturedChatID, fmt.Sprintf("🚨 <b>Alerte livreur</b>\n\n%s", capturedBody))
|
||||
go sendTelegramNotif(capturedChatID, fmt.Sprintf("🚨 <b>Alerte livreur</b>\n\n%s", body))
|
||||
}
|
||||
}
|
||||
count++
|
||||
|
||||
@@ -3,7 +3,6 @@ package db
|
||||
import (
|
||||
"fmt"
|
||||
"gestion/models"
|
||||
"log"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
@@ -67,6 +66,17 @@ func (d *Database) ActivateCryptoCommand(commandID int) error {
|
||||
|
||||
func (d *Database) CancelCryptoCommand(commandID int) error {
|
||||
return d.GDB.Transaction(func(tx *gorm.DB) error {
|
||||
var cmdStatus string
|
||||
if err := tx.Raw(`SELECT status FROM commandes WHERE id = ? FOR UPDATE`, commandID).Scan(&cmdStatus).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if cmdStatus == "" {
|
||||
return fmt.Errorf("commande non trouvée")
|
||||
}
|
||||
if cmdStatus != "pending_payment" {
|
||||
return fmt.Errorf("commande non annulable (statut: %s)", cmdStatus)
|
||||
}
|
||||
|
||||
type item struct {
|
||||
ProductID int
|
||||
Quantite float64
|
||||
@@ -77,9 +87,9 @@ func (d *Database) CancelCryptoCommand(commandID int) error {
|
||||
}
|
||||
for _, it := range items {
|
||||
if err := tx.Exec(`UPDATE products SET stock = stock + ? WHERE id = ?`, it.Quantite, it.ProductID).Error; err != nil {
|
||||
log.Printf("[CANCEL CRYPTO] erreur restauration stock produit %d: %v", it.ProductID, err)
|
||||
return fmt.Errorf("erreur restauration stock produit %d: %w", it.ProductID, err)
|
||||
}
|
||||
}
|
||||
return tx.Exec(`UPDATE commandes SET status = 'cancelled', updated_at = NOW() WHERE id = ? AND status = 'pending_payment'`, commandID).Error
|
||||
return tx.Exec(`UPDATE commandes SET status = 'cancelled', updated_at = NOW() WHERE id = ?`, commandID).Error
|
||||
})
|
||||
}
|
||||
|
||||
@@ -52,10 +52,15 @@ func (d *Database) CreateProduct(product any) error {
|
||||
UpdatedAt time.Time `gorm:"column:updated_at"`
|
||||
}
|
||||
|
||||
comingSoonVal := false
|
||||
if prodModel, ok2 := product.(*models.Product); ok2 {
|
||||
comingSoonVal = prodModel.ComingSoon
|
||||
}
|
||||
|
||||
err := d.GDB.Raw(`
|
||||
INSERT INTO products (name, category, description, stock, unit, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?) RETURNING id, created_at, updated_at`,
|
||||
p.GetName(), p.GetCategory(), p.GetDescription(), p.GetStock(), p.GetUnit(), now, now,
|
||||
INSERT INTO products (name, category, description, stock, unit, coming_soon, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?) RETURNING id, created_at, updated_at`,
|
||||
p.GetName(), p.GetCategory(), p.GetDescription(), p.GetStock(), p.GetUnit(), comingSoonVal, now, now,
|
||||
).Scan(&result).Error
|
||||
if err != nil {
|
||||
log.Printf("❌ [DB CreateProduct] Erreur INSERT: %v", err)
|
||||
@@ -69,8 +74,8 @@ func (d *Database) CreateProduct(product any) error {
|
||||
p.SetUpdatedAt(result.UpdatedAt)
|
||||
|
||||
for i, price := range p.GetPrices() {
|
||||
err := d.GDB.Exec(`INSERT INTO product_prices (product_id, quantity, price) VALUES (?, ?, ?)`,
|
||||
result.ID, price.Quantity, price.Price).Error
|
||||
err := d.GDB.Exec(`INSERT INTO product_prices (product_id, quantity, price, active_price) VALUES (?, ?, ?, ?)`,
|
||||
result.ID, price.Quantity, price.Price, price.ActivePrice).Error
|
||||
if err != nil {
|
||||
log.Printf("❌ [DB CreateProduct] Erreur insertion prix[%d]: %v", i, err)
|
||||
return fmt.Errorf("erreur insertion prix: %v", err)
|
||||
@@ -88,7 +93,7 @@ func (d *Database) GetProductByID(id int) (models.Product, error) {
|
||||
|
||||
var p models.Product
|
||||
err := d.GDB.Raw(`
|
||||
SELECT id, name, category, description, stock, unit, created_at, updated_at
|
||||
SELECT id, name, category, description, stock, unit, coming_soon, created_at, updated_at
|
||||
FROM products
|
||||
WHERE id = ?`, id).Scan(&p).Error
|
||||
if err != nil {
|
||||
@@ -110,12 +115,33 @@ func (d *Database) GetProductByID(id int) (models.Product, error) {
|
||||
return p, nil
|
||||
}
|
||||
|
||||
// GetProductNamesByIDs retourne un map id→name pour une liste d'IDs.
|
||||
func (d *Database) GetProductNamesByIDs(ids []int) (map[int]string, error) {
|
||||
result := make(map[int]string, len(ids))
|
||||
if len(ids) == 0 {
|
||||
return result, nil
|
||||
}
|
||||
rows, err := d.GDB.Raw(`SELECT id, name FROM products WHERE id IN ?`, ids).Rows()
|
||||
if err != nil {
|
||||
return result, err
|
||||
}
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
var id int
|
||||
var name string
|
||||
if err := rows.Scan(&id, &name); err == nil {
|
||||
result[id] = name
|
||||
}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (d *Database) GetAllProducts() ([]models.Product, error) {
|
||||
log.Println("📦 [GetAllProducts] START")
|
||||
|
||||
var products []models.Product
|
||||
err := d.GDB.Raw(`
|
||||
SELECT id, name, category, description, stock, unit, created_at, updated_at
|
||||
SELECT id, name, category, description, stock, unit, coming_soon, created_at, updated_at
|
||||
FROM products
|
||||
ORDER BY id ASC`).Scan(&products).Error
|
||||
if err != nil {
|
||||
@@ -153,7 +179,7 @@ func (d *Database) GetProductsByCategory(category string) ([]models.Product, err
|
||||
|
||||
var products []models.Product
|
||||
err := d.GDB.Raw(`
|
||||
SELECT id, name, category, description, stock, unit, created_at, updated_at
|
||||
SELECT id, name, category, description, stock, unit, coming_soon, created_at, updated_at
|
||||
FROM products
|
||||
WHERE category = ?
|
||||
ORDER BY created_at DESC`, category).Scan(&products).Error
|
||||
@@ -177,12 +203,12 @@ func (d *Database) GetProductsByCategory(category string) ([]models.Product, err
|
||||
return products, nil
|
||||
}
|
||||
|
||||
func (d *Database) UpdateProduct(productID int, name, category, description, unit string, stock float64, prices []models.ProductPrice) error {
|
||||
func (d *Database) UpdateProduct(productID int, name, category, description, unit string, comingSoon bool, prices []models.ProductPrice) error {
|
||||
err := d.GDB.Exec(`
|
||||
UPDATE products
|
||||
SET name = ?, category = ?, description = ?, stock = ?, unit = ?, updated_at = ?
|
||||
SET name = ?, category = ?, description = ?, unit = ?, coming_soon = ?, updated_at = ?
|
||||
WHERE id = ?`,
|
||||
name, category, description, stock, unit, time.Now(), productID).Error
|
||||
name, category, description, unit, comingSoon, time.Now(), productID).Error
|
||||
if err != nil {
|
||||
return fmt.Errorf("erreur mise à jour produit: %w", err)
|
||||
}
|
||||
@@ -190,15 +216,39 @@ func (d *Database) UpdateProduct(productID int, name, category, description, uni
|
||||
d.GDB.Exec(`DELETE FROM product_prices WHERE product_id = ?`, productID)
|
||||
|
||||
for _, price := range prices {
|
||||
if err := d.GDB.Exec(`INSERT INTO product_prices (product_id, quantity, price) VALUES (?, ?, ?)`,
|
||||
productID, price.Quantity, price.Price).Error; err != nil {
|
||||
log.Printf("❌ [UpdateProduct] Erreur prix: %v", err)
|
||||
if err := d.GDB.Exec(`INSERT INTO product_prices (product_id, quantity, price, active_price) VALUES (?, ?, ?, ?)`,
|
||||
productID, price.Quantity, price.Price, price.ActivePrice).Error; err != nil {
|
||||
return fmt.Errorf("erreur insertion prix: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *Database) SetProductStock(productID int, stock float64) error {
|
||||
result := d.GDB.Exec(`UPDATE products SET stock = ?, updated_at = ? WHERE id = ?`,
|
||||
stock, time.Now(), productID)
|
||||
if result.Error != nil {
|
||||
return fmt.Errorf("erreur mise à jour stock: %w", result.Error)
|
||||
}
|
||||
if result.RowsAffected == 0 {
|
||||
return fmt.Errorf("produit non trouvé")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *Database) SetProductComingSoon(productID int, comingSoon bool) error {
|
||||
result := d.GDB.Exec(`UPDATE products SET coming_soon = ?, updated_at = ? WHERE id = ?`,
|
||||
comingSoon, time.Now(), productID)
|
||||
if result.Error != nil {
|
||||
return fmt.Errorf("erreur mise à jour coming_soon: %w", result.Error)
|
||||
}
|
||||
if result.RowsAffected == 0 {
|
||||
return fmt.Errorf("produit non trouvé")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeleteProduct supprime un produit
|
||||
func (d *Database) DeleteProduct(productID int) error {
|
||||
result := d.GDB.Exec(`DELETE FROM products WHERE id = ?`, productID)
|
||||
|
||||
@@ -13,19 +13,13 @@ func (d *Database) GetProductPrices(productID int) ([]models.ProductPrice, error
|
||||
return prices, nil
|
||||
}
|
||||
|
||||
func (d *Database) CreateProductPrice(productID int, quantity float64, price float64) error {
|
||||
p := models.ProductPrice{ProductID: productID, Quantity: quantity, Price: price}
|
||||
if err := d.GDB.Create(&p).Error; err != nil {
|
||||
return fmt.Errorf("erreur création prix: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
func (d *Database) AddActivePrice(priceID int) error {
|
||||
result := d.GDB.Model(&models.ProductPrice{}).
|
||||
Where("id = ?", priceID).
|
||||
Update("active_price", true)
|
||||
|
||||
func (d *Database) UpdateProductPrice(priceID int, quantity float64, price float64) error {
|
||||
result := d.GDB.Model(&models.ProductPrice{}).Where("id = ?", priceID).
|
||||
Updates(map[string]any{"quantity": quantity, "price": price})
|
||||
if result.Error != nil {
|
||||
return fmt.Errorf("erreur mise à jour prix: %w", result.Error)
|
||||
return fmt.Errorf("erreur lors de l'activation du prix: %w", result.Error)
|
||||
}
|
||||
if result.RowsAffected == 0 {
|
||||
return fmt.Errorf("prix introuvable")
|
||||
@@ -33,10 +27,13 @@ func (d *Database) UpdateProductPrice(priceID int, quantity float64, price float
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *Database) DeleteProductPrice(priceID int) error {
|
||||
result := d.GDB.Delete(&models.ProductPrice{}, priceID)
|
||||
func (d *Database) DeActivePrice(priceID int) error {
|
||||
result := d.GDB.Model(&models.ProductPrice{}).
|
||||
Where("id = ?", priceID).
|
||||
Update("active_price", false)
|
||||
|
||||
if result.Error != nil {
|
||||
return fmt.Errorf("erreur suppression prix: %w", result.Error)
|
||||
return fmt.Errorf("erreur lors de l'activation du prix: %w", result.Error)
|
||||
}
|
||||
if result.RowsAffected == 0 {
|
||||
return fmt.Errorf("prix introuvable")
|
||||
|
||||
@@ -58,17 +58,3 @@ func (d *Database) ResetClientReferralBalance(username string) error {
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *Database) UseClientReferralBalance(tx *gorm.DB, username string, amount float64) error {
|
||||
if amount <= 0 {
|
||||
return nil
|
||||
}
|
||||
var balance float64
|
||||
if err := tx.Raw(`SELECT referral_balance FROM clients WHERE username = ? FOR UPDATE`, username).Scan(&balance).Error; err != nil {
|
||||
return fmt.Errorf("client non trouvé")
|
||||
}
|
||||
if balance < amount {
|
||||
return fmt.Errorf("solde parrainage insuffisant (disponible: %.2f€)", balance)
|
||||
}
|
||||
return tx.Exec(`UPDATE clients SET referral_balance = referral_balance - ? WHERE username = ?`, amount, username).Error
|
||||
}
|
||||
|
||||
@@ -66,6 +66,8 @@ func DefaultSettings() models.AppSettings {
|
||||
},
|
||||
},
|
||||
},
|
||||
ShopName: "Milieu-Nantais",
|
||||
ContactTelegram: "MLN44LA",
|
||||
DeliveryMode: models.DeliveryModeConfig{
|
||||
Mode: "single",
|
||||
CategoryRoutes: []models.CategoryRoute{},
|
||||
@@ -81,6 +83,7 @@ func DefaultSettings() models.AppSettings {
|
||||
"44860", "44220", "44118", "44710", "44690", "44119",
|
||||
}},
|
||||
},
|
||||
Telegram2FAEnabled: false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -109,6 +112,11 @@ func (d *Database) GetSettings() (models.AppSettings, error) {
|
||||
if err := json.Unmarshal([]byte(row.Value), &pools); err == nil {
|
||||
settings.PointsPools = pools
|
||||
}
|
||||
case "points_reward":
|
||||
var reward models.PointsReward
|
||||
if err := json.Unmarshal([]byte(row.Value), &reward); err == nil {
|
||||
settings.PointsReward = &reward
|
||||
}
|
||||
case "referral_enabled":
|
||||
settings.ReferralEnabled = row.Value == "true"
|
||||
case "referral_amount":
|
||||
@@ -138,6 +146,8 @@ func (d *Database) GetSettings() (models.AppSettings, error) {
|
||||
if err := json.Unmarshal([]byte(row.Value), &zones); err == nil {
|
||||
settings.PostalZones = zones
|
||||
}
|
||||
case "contact_telegram":
|
||||
settings.ContactTelegram = row.Value
|
||||
case "telegram_bot_token":
|
||||
settings.TelegramBotToken = row.Value
|
||||
case "telegram_bot_username":
|
||||
@@ -149,6 +159,10 @@ func (d *Database) GetSettings() (models.AppSettings, error) {
|
||||
if err := json.Unmarshal([]byte(row.Value), &mode); err == nil {
|
||||
settings.DeliveryMode = mode
|
||||
}
|
||||
case "telegram_2fa_enabled":
|
||||
settings.Telegram2FAEnabled = row.Value == "true"
|
||||
case "shop_name":
|
||||
settings.ShopName = row.Value
|
||||
}
|
||||
}
|
||||
return settings, nil
|
||||
@@ -180,6 +194,11 @@ func (d *Database) UpdateSettings(s models.AppSettings) error {
|
||||
return fmt.Errorf("erreur sérialisation pools: %w", err)
|
||||
}
|
||||
|
||||
rewardJSON, err := json.Marshal(s.PointsReward)
|
||||
if err != nil {
|
||||
return fmt.Errorf("erreur sérialisation points_reward: %w", err)
|
||||
}
|
||||
|
||||
if s.NowPaymentsCurrencies == nil {
|
||||
s.NowPaymentsCurrencies = []string{}
|
||||
}
|
||||
@@ -209,11 +228,15 @@ func (d *Database) UpdateSettings(s models.AppSettings) error {
|
||||
return fmt.Errorf("erreur sérialisation delivery_mode: %w", err)
|
||||
}
|
||||
|
||||
if s.ContactTelegram == "" {
|
||||
s.ContactTelegram = "MLN44LA"
|
||||
}
|
||||
pairs := [][2]string{
|
||||
{"penalties_enabled", boolStr(s.PenaltiesEnabled)},
|
||||
{"show_amende_score", boolStr(s.ShowAmendeScore)},
|
||||
{"points_enabled", boolStr(s.PointsEnabled)},
|
||||
{"points_pools", string(poolsJSON)},
|
||||
{"points_reward", string(rewardJSON)},
|
||||
{"referral_enabled", boolStr(s.ReferralEnabled)},
|
||||
{"referral_amount", strconv.FormatFloat(s.ReferralAmount, 'f', 2, 64)},
|
||||
{"crypto_payment_enabled", boolStr(s.CryptoPaymentEnabled)},
|
||||
@@ -226,7 +249,10 @@ func (d *Database) UpdateSettings(s models.AppSettings) error {
|
||||
{"telegram_bot_token", s.TelegramBotToken},
|
||||
{"telegram_bot_username", s.TelegramBotUsername},
|
||||
{"telegram_notifications_enabled", boolStr(s.TelegramNotificationsEnabled)},
|
||||
{"telegram_2fa_enabled", boolStr(s.Telegram2FAEnabled)},
|
||||
{"delivery_mode", string(deliveryModeJSON)},
|
||||
{"shop_name", s.ShopName},
|
||||
{"contact_telegram", s.ContactTelegram},
|
||||
}
|
||||
|
||||
upsert := `INSERT INTO app_settings (key, value) VALUES (?, ?)
|
||||
|
||||
@@ -15,6 +15,7 @@ func (d *Database) MigrateAddTelegramColumns() {
|
||||
migrations := []string{
|
||||
`ALTER TABLE clients ADD COLUMN IF NOT EXISTS telegram_chat_id BIGINT`,
|
||||
`ALTER TABLE users ADD COLUMN IF NOT EXISTS telegram_chat_id BIGINT`,
|
||||
`ALTER TABLE clients ADD COLUMN IF NOT EXISTS two_fa_enabled BOOLEAN NOT NULL DEFAULT FALSE`,
|
||||
}
|
||||
for _, q := range migrations {
|
||||
if err := d.GDB.Exec(q).Error; err != nil {
|
||||
@@ -108,6 +109,45 @@ func (d *Database) DeleteUserTelegramChatID(username string) error {
|
||||
return d.GDB.Model(&models.User{}).Where("username = ?", username).Update("telegram_chat_id", nil).Error
|
||||
}
|
||||
|
||||
// GetAllLinkedTelegramAccounts retourne tous les comptes ayant un telegram_chat_id non-null.
|
||||
func (d *Database) GetAllLinkedTelegramAccounts() ([]struct {
|
||||
ChatID int64
|
||||
Username string
|
||||
Role string
|
||||
}, error) {
|
||||
type row struct {
|
||||
ChatID int64 `gorm:"column:telegram_chat_id"`
|
||||
Username string `gorm:"column:username"`
|
||||
Role string `gorm:"column:role"`
|
||||
}
|
||||
|
||||
var results []row
|
||||
|
||||
var clients []row
|
||||
if err := d.GDB.Raw(`SELECT telegram_chat_id, username, 'client' AS role FROM clients WHERE telegram_chat_id IS NOT NULL`).Scan(&clients).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
results = append(results, clients...)
|
||||
|
||||
var users []row
|
||||
if err := d.GDB.Raw(`SELECT telegram_chat_id, username, role FROM users WHERE telegram_chat_id IS NOT NULL`).Scan(&users).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
results = append(results, users...)
|
||||
|
||||
out := make([]struct {
|
||||
ChatID int64
|
||||
Username string
|
||||
Role string
|
||||
}, len(results))
|
||||
for i, r := range results {
|
||||
out[i].ChatID = r.ChatID
|
||||
out[i].Username = r.Username
|
||||
out[i].Role = r.Role
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// GetUserByTelegramChatID retrouve un utilisateur (clients + users) par chat_id
|
||||
func (d *Database) GetUserByTelegramChatID(chatID int64) (username, role string, err error) {
|
||||
var clientResult struct {
|
||||
@@ -127,3 +167,36 @@ func (d *Database) GetUserByTelegramChatID(chatID int64) (username, role string,
|
||||
|
||||
return "", "", fmt.Errorf("aucun compte lié à ce chat_id")
|
||||
}
|
||||
|
||||
// ── 2FA sessions ─────────────────────────────────────────────────────────────
|
||||
|
||||
const twoFASessionTTL = 5 * time.Minute
|
||||
|
||||
type twoFASessionData struct {
|
||||
Username string `json:"username"`
|
||||
Code string `json:"code"`
|
||||
}
|
||||
|
||||
func Store2FASession(sessionToken, username, code string) error {
|
||||
data, err := json.Marshal(twoFASessionData{Username: username, Code: code})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return Redis.Set(RedisCtx, "2fa:session:"+sessionToken, data, twoFASessionTTL).Err()
|
||||
}
|
||||
|
||||
// Verify2FASession valide le code et retourne le username. GETDEL = atomique (anti-replay).
|
||||
func Verify2FASession(sessionToken, code string) (string, error) {
|
||||
val, err := Redis.GetDel(RedisCtx, "2fa:session:"+sessionToken).Bytes()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("session invalide ou expirée")
|
||||
}
|
||||
var d twoFASessionData
|
||||
if err := json.Unmarshal(val, &d); err != nil {
|
||||
return "", fmt.Errorf("données corrompues")
|
||||
}
|
||||
if d.Code != code {
|
||||
return "", fmt.Errorf("code incorrect")
|
||||
}
|
||||
return d.Username, nil
|
||||
}
|
||||
|
||||
@@ -2,7 +2,6 @@ package db
|
||||
|
||||
import "gorm.io/gorm"
|
||||
|
||||
// isNotFound retourne true si l'erreur GORM est un "record not found"
|
||||
func isNotFound(err error) bool {
|
||||
return err == gorm.ErrRecordNotFound
|
||||
}
|
||||
|
||||
@@ -67,57 +67,6 @@ func (d *Database) FindLeastLoadedDeliveryman() (string, error) {
|
||||
return leastLoaded, nil
|
||||
}
|
||||
|
||||
// FindAvailableOrLeastLoadedDeliveryman trouve un livreur disponible ou le moins chargé
|
||||
func (d *Database) FindAvailableOrLeastLoadedDeliveryman() (string, string, int, error) {
|
||||
keys, err := Redis.Keys(RedisCtx, "delivery:status:*").Result()
|
||||
if err != nil || len(keys) == 0 {
|
||||
return "", "", 0, fmt.Errorf("aucun livreur trouvé")
|
||||
}
|
||||
|
||||
var bestDeliveryman string
|
||||
var bestStatus string
|
||||
bestQueueSize := int64(MAX_COMMANDS_PER_DELIVERYMAN + 1)
|
||||
|
||||
for _, key := range keys {
|
||||
username := key[len("delivery:status:"):]
|
||||
|
||||
data, err := Redis.Get(RedisCtx, key).Result()
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
var status models.DeliveryPersonStatus
|
||||
json.Unmarshal([]byte(data), &status)
|
||||
|
||||
if status.Status == "offline" {
|
||||
continue
|
||||
}
|
||||
|
||||
if !d.CanDeliverymanAcceptCommands(username) {
|
||||
continue
|
||||
}
|
||||
|
||||
queueKey := fmt.Sprintf("queue:deliveryman:%s", username)
|
||||
queueSize, _ := Redis.ZCard(RedisCtx, queueKey).Result()
|
||||
|
||||
if status.Status == "available" && queueSize == 0 {
|
||||
return username, "available", 0, nil
|
||||
}
|
||||
|
||||
if queueSize < bestQueueSize {
|
||||
bestQueueSize = queueSize
|
||||
bestDeliveryman = username
|
||||
bestStatus = status.Status
|
||||
}
|
||||
}
|
||||
|
||||
if bestDeliveryman == "" {
|
||||
return "", "", 0, fmt.Errorf("tous les livreurs sont au maximum de leur capacité")
|
||||
}
|
||||
|
||||
return bestDeliveryman, bestStatus, int(bestQueueSize), nil
|
||||
}
|
||||
|
||||
// GetLeastLoadedDeliverymanForced retourne le livreur avec le moins de commandes (SANS limite)
|
||||
func (d *Database) GetLeastLoadedDeliverymanForced() (string, int64, error) {
|
||||
activeUsernames, err := d.GetAllActiveDeliverymenUsernames()
|
||||
|
||||
@@ -156,10 +156,6 @@ func (d *Database) CalculateETABetweenPoints(lat1, lng1, lat2, lng2 float64) int
|
||||
return services.CalculateETA(distance)
|
||||
}
|
||||
|
||||
func (d *Database) GetDeliverymanQueueStats(deliveryman string) (map[string]any, error) {
|
||||
return d.GetDeliverymanQueueInfo(deliveryman)
|
||||
}
|
||||
|
||||
func (d *Database) SetCommandETAWithDetails(commandID, totalETA, queuePosition int) error {
|
||||
key := fmt.Sprintf("command:eta:%d", commandID)
|
||||
|
||||
|
||||
@@ -87,35 +87,6 @@ func (d *Database) AssignCommandToDeliverymanQueue(commandID int, deliveryman st
|
||||
return nil
|
||||
}
|
||||
|
||||
// AssignCommandToDeliverymanQueueUnlimited assigne sans limite (pour un seul livreur)
|
||||
func (d *Database) AssignCommandToDeliverymanQueueUnlimited(deliveryman string, queueItem models.CommandQueue) error {
|
||||
queueKey := fmt.Sprintf("queue:deliveryman:%s", deliveryman)
|
||||
currentQueueSize, _ := Redis.ZCard(RedisCtx, queueKey).Result()
|
||||
|
||||
travelTime := d.CalculateETAForDeliveryman(deliveryman, queueItem.Lat, queueItem.Lng)
|
||||
|
||||
queueItem.EstimatedETA = travelTime
|
||||
|
||||
err := d.AddToDeliverymanQueue(deliveryman, queueItem)
|
||||
if err != nil {
|
||||
return fmt.Errorf("erreur ajout à la queue: %w", err)
|
||||
}
|
||||
|
||||
d.UpdateCommandStatus(queueItem.CommandID, "assigned")
|
||||
d.AssignDeliveryPerson(queueItem.CommandID, deliveryman)
|
||||
d.SetCommandETAWithDetails(queueItem.CommandID, travelTime, int(currentQueueSize)+1)
|
||||
|
||||
d.AddCommandLog(queueItem.CommandID, "queued",
|
||||
fmt.Sprintf("Assigné au seul livreur actif %s (position: %d, ETA trajet: %d min)",
|
||||
deliveryman, currentQueueSize+1, travelTime),
|
||||
"system")
|
||||
|
||||
log.Printf("✅ Commande %d -> Queue %s (SANS LIMITE - pos: %d, ETA trajet: %d min)",
|
||||
queueItem.CommandID, deliveryman, currentQueueSize+1, travelTime)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// AssignCommandToDeliverymanQueueWithCoords assigne une commande avec les coordonnées GPS
|
||||
func (d *Database) AssignCommandToDeliverymanQueueWithCoords(commandID int, deliveryman string, estimatedTravelTime int, lat, lng float64, address string) error {
|
||||
command, err := d.GetCommandByID(commandID)
|
||||
|
||||
@@ -203,64 +203,3 @@ func (d *Database) StartQueueCleanupScheduler() {
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// RAPPORT DE VALIDATION
|
||||
// ============================================
|
||||
|
||||
// GetQueueValidationReport génère un rapport de validation sans supprimer
|
||||
func (d *Database) GetQueueValidationReport() (map[string]any, error) {
|
||||
keys, err := Redis.Keys(RedisCtx, "queue:pending:*").Result()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
report := map[string]any{
|
||||
"total_commands": len(keys),
|
||||
"valid_commands": 0,
|
||||
"invalid_commands": 0,
|
||||
"invalid_details": []map[string]any{},
|
||||
"validation_results": []string{},
|
||||
}
|
||||
|
||||
for _, key := range keys {
|
||||
data, err := Redis.Get(RedisCtx, key).Result()
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
var queueItem models.CommandQueue
|
||||
if err := json.Unmarshal([]byte(data), &queueItem); err != nil {
|
||||
report["invalid_commands"] = report["invalid_commands"].(int) + 1
|
||||
continue
|
||||
}
|
||||
|
||||
// Validation
|
||||
issues := []string{}
|
||||
if queueItem.Username == "" {
|
||||
issues = append(issues, "username vide")
|
||||
}
|
||||
if queueItem.Address == "" {
|
||||
issues = append(issues, "adresse vide")
|
||||
}
|
||||
if queueItem.Lat == 0 || queueItem.Lng == 0 {
|
||||
issues = append(issues, "GPS manquant")
|
||||
}
|
||||
if queueItem.CreatedAt.IsZero() {
|
||||
issues = append(issues, "date invalide")
|
||||
}
|
||||
|
||||
if len(issues) > 0 {
|
||||
report["invalid_commands"] = report["invalid_commands"].(int) + 1
|
||||
report["invalid_details"] = append(report["invalid_details"].([]map[string]any), map[string]any{
|
||||
"command_id": queueItem.CommandID,
|
||||
"issues": issues,
|
||||
"data": queueItem,
|
||||
})
|
||||
} else {
|
||||
report["valid_commands"] = report["valid_commands"].(int) + 1
|
||||
}
|
||||
}
|
||||
|
||||
return report, nil
|
||||
}
|
||||
|
||||
@@ -86,40 +86,6 @@ func (d *Database) CanDeliverymanAcceptCommands(deliveryman string) bool {
|
||||
return true
|
||||
}
|
||||
|
||||
// GetAvailableDeliveryPersonsForAssignment récupère UNIQUEMENT les livreurs pouvant accepter
|
||||
func (d *Database) GetAvailableDeliveryPersonsForAssignment() ([]models.DeliveryPersonStatus, error) {
|
||||
keys, err := Redis.Keys(RedisCtx, "delivery:status:*").Result()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var available []models.DeliveryPersonStatus
|
||||
|
||||
for _, key := range keys {
|
||||
data, err := Redis.Get(RedisCtx, key).Result()
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
var status models.DeliveryPersonStatus
|
||||
if err := json.Unmarshal([]byte(data), &status); err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
if d.CanDeliverymanAcceptCommands(status.Username) {
|
||||
available = append(available, status)
|
||||
}
|
||||
}
|
||||
|
||||
log.Printf("📊 [AVAILABLE] %d livreur(s) disponible(s) pour assignation", len(available))
|
||||
|
||||
return available, nil
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// 🔄 FONCTIONS MODIFIÉES AVEC AUTO-STATUS
|
||||
// ============================================
|
||||
|
||||
// AddToDeliverymanQueue - VERSION MISE À JOUR avec auto-update du statut
|
||||
func (d *Database) AddToDeliverymanQueue(deliveryman string, queueItem models.CommandQueue) error {
|
||||
data, err := json.Marshal(queueItem)
|
||||
@@ -150,111 +116,6 @@ func (d *Database) AddToDeliverymanQueue(deliveryman string, queueItem models.Co
|
||||
return nil
|
||||
}
|
||||
|
||||
// AddCommandToQueue ajoute une commande à la file d'attente Redis (version simple)
|
||||
func (d *Database) AddCommandToQueue(commandID int) error {
|
||||
if err := d.ValidateCommandBeforeQueue(commandID); err != nil {
|
||||
log.Printf("❌ [QUEUE] Commande %d REFUSÉE: %v", commandID, err)
|
||||
return fmt.Errorf("validation échouée: %w", err)
|
||||
}
|
||||
|
||||
command, err := d.GetCommandByID(commandID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("commande introuvable: %w", err)
|
||||
}
|
||||
var lat, lng float64
|
||||
if command["dest_latitude"] != nil {
|
||||
if latVal, ok := command["dest_latitude"].(float64); ok {
|
||||
lat = latVal
|
||||
}
|
||||
}
|
||||
if command["dest_longitude"] != nil {
|
||||
if lngVal, ok := command["dest_longitude"].(float64); ok {
|
||||
lng = lngVal
|
||||
}
|
||||
}
|
||||
|
||||
var totalPrice float64
|
||||
if tp, ok := command["total_prix"].(float64); ok {
|
||||
totalPrice = tp
|
||||
}
|
||||
|
||||
var address string
|
||||
if addr, ok := command["delivery_address"].(string); ok {
|
||||
address = addr
|
||||
}
|
||||
|
||||
queueItem := models.CommandQueue{
|
||||
CommandID: commandID,
|
||||
Username: command["username"].(string),
|
||||
TotalPrice: totalPrice,
|
||||
Address: address,
|
||||
Lat: lat,
|
||||
Lng: lng,
|
||||
CreatedAt: time.Now(),
|
||||
EstimatedETA: 0,
|
||||
}
|
||||
|
||||
return d.AddToGeneralQueue(queueItem)
|
||||
}
|
||||
|
||||
// AddCommandToSmartQueue - Ajoute une commande avec attribution au livreur le moins chargé
|
||||
func (d *Database) AddCommandToSmartQueue(commandID int, address string) error {
|
||||
if err := d.ValidateCommandBeforeQueue(commandID); err != nil {
|
||||
log.Printf("❌ [QUEUE] Commande %d REFUSÉE: %v", commandID, err)
|
||||
return fmt.Errorf("validation échouée: %w", err)
|
||||
}
|
||||
command, err := d.GetCommandByID(commandID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("commande introuvable: %w", err)
|
||||
}
|
||||
var lat, lng float64
|
||||
if command["dest_latitude"] != nil {
|
||||
if latVal, ok := command["dest_latitude"].(float64); ok {
|
||||
lat = latVal
|
||||
}
|
||||
}
|
||||
if command["dest_longitude"] != nil {
|
||||
if lngVal, ok := command["dest_longitude"].(float64); ok {
|
||||
lng = lngVal
|
||||
}
|
||||
}
|
||||
|
||||
var totalPrice float64
|
||||
if tp, ok := command["total_prix"].(float64); ok {
|
||||
totalPrice = tp
|
||||
}
|
||||
|
||||
queueItem := models.CommandQueue{
|
||||
CommandID: commandID,
|
||||
Username: command["username"].(string),
|
||||
TotalPrice: totalPrice,
|
||||
Address: address,
|
||||
Lat: lat,
|
||||
Lng: lng,
|
||||
CreatedAt: time.Now(),
|
||||
EstimatedETA: 0,
|
||||
}
|
||||
|
||||
// ✅ MODIFIÉ: Utiliser FindLeastLoadedDeliveryman qui respecte maintenant le statut
|
||||
assignedDeliveryman, err := d.FindLeastLoadedDeliveryman()
|
||||
if err != nil {
|
||||
log.Printf("⚠️ Aucun livreur trouvé, ajout à la queue générale")
|
||||
return d.AddToGeneralQueue(queueItem)
|
||||
}
|
||||
|
||||
err = d.AddToDeliverymanQueue(assignedDeliveryman, queueItem)
|
||||
if err != nil {
|
||||
return fmt.Errorf("erreur ajout à la queue du livreur: %w", err)
|
||||
}
|
||||
|
||||
log.Printf("📋 Commande %d assignée à la queue de %s", commandID, assignedDeliveryman)
|
||||
|
||||
d.PublishCommandEvent(commandID, "queued",
|
||||
fmt.Sprintf("En attente dans la queue de %s", assignedDeliveryman))
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// AddToGeneralQueue ajoute une commande à la queue générale (fallback)
|
||||
func (d *Database) AddToGeneralQueue(queueItem models.CommandQueue) error {
|
||||
data, err := json.Marshal(queueItem)
|
||||
@@ -355,109 +216,6 @@ func (d *Database) GetNextCommandInQueue() (*models.CommandQueue, error) {
|
||||
return &queue, nil
|
||||
}
|
||||
|
||||
// GetLastCommandInQueue récupère la dernière commande dans la queue d'un livreur
|
||||
func (d *Database) GetLastCommandInQueue(deliveryman string) (*models.CommandQueue, error) {
|
||||
queueKey := fmt.Sprintf("queue:deliveryman:%s", deliveryman)
|
||||
|
||||
// Récupérer la dernière commande (index -1)
|
||||
commandIDs, err := Redis.ZRange(RedisCtx, queueKey, -1, -1).Result()
|
||||
if err != nil || len(commandIDs) == 0 {
|
||||
return nil, fmt.Errorf("queue vide")
|
||||
}
|
||||
|
||||
commandID := extractCommandID(commandIDs[0])
|
||||
if commandID <= 0 {
|
||||
return nil, fmt.Errorf("ID invalide")
|
||||
}
|
||||
|
||||
commandKey := fmt.Sprintf("queue:pending:%d", commandID)
|
||||
data, err := Redis.Get(RedisCtx, commandKey).Result()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var queueItem models.CommandQueue
|
||||
if err := json.Unmarshal([]byte(data), &queueItem); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &queueItem, nil
|
||||
}
|
||||
|
||||
// GetCommandQueuePosition récupère la position d'une commande dans la queue
|
||||
func (d *Database) GetCommandQueuePosition(commandID int) (int, error) {
|
||||
commandIDStr := strconv.Itoa(commandID)
|
||||
|
||||
// Chercher d'abord dans les queues des livreurs
|
||||
keys, _ := Redis.Keys(RedisCtx, "queue:deliveryman:*").Result()
|
||||
|
||||
for _, queueKey := range keys {
|
||||
// Éviter les clés de compteur
|
||||
if len(queueKey) > 6 && queueKey[len(queueKey)-6:] == ":count" {
|
||||
continue
|
||||
}
|
||||
|
||||
rank, err := Redis.ZRank(RedisCtx, queueKey, commandIDStr).Result()
|
||||
if err == nil {
|
||||
return int(rank) + 1, nil
|
||||
}
|
||||
}
|
||||
|
||||
// Chercher dans la queue générale
|
||||
rank, err := Redis.ZRank(RedisCtx, "queue:pending:sorted", commandIDStr).Result()
|
||||
if err == nil {
|
||||
return int(rank) + 1, nil
|
||||
}
|
||||
|
||||
return 0, fmt.Errorf("commande non trouvée dans les queues")
|
||||
}
|
||||
|
||||
func (d *Database) ClearDeliverymanQueue(deliveryman string) error {
|
||||
queueKey := fmt.Sprintf("queue:deliveryman:%s", deliveryman)
|
||||
|
||||
// Récupérer toutes les commandes
|
||||
commandIDs, _ := Redis.ZRange(RedisCtx, queueKey, 0, -1).Result()
|
||||
|
||||
// Redistribuer chaque commande
|
||||
for _, cmdIDStr := range commandIDs {
|
||||
commandID := extractCommandID(cmdIDStr)
|
||||
if commandID <= 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
commandKey := fmt.Sprintf("queue:pending:%d", commandID)
|
||||
data, err := Redis.Get(RedisCtx, commandKey).Result()
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
var queueItem models.CommandQueue
|
||||
if err := json.Unmarshal([]byte(data), &queueItem); err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
newDeliveryman, err := d.FindLeastLoadedDeliveryman()
|
||||
if err != nil {
|
||||
d.AddToGeneralQueue(queueItem)
|
||||
continue
|
||||
}
|
||||
|
||||
if newDeliveryman != deliveryman {
|
||||
d.AddToDeliverymanQueue(newDeliveryman, queueItem)
|
||||
log.Printf("🔄 Commande %d réassignée de %s à %s",
|
||||
commandID, deliveryman, newDeliveryman)
|
||||
}
|
||||
}
|
||||
|
||||
// Vider la queue
|
||||
Redis.Del(RedisCtx, queueKey)
|
||||
Redis.Del(RedisCtx, fmt.Sprintf("queue:deliveryman:%s:count", deliveryman))
|
||||
|
||||
go d.UpdateDeliverymanStatusBasedOnQueue(deliveryman)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *Database) SetDeliveryPersonStatus(username, status string, commandID int) error {
|
||||
key := fmt.Sprintf("delivery:status:%s", username)
|
||||
|
||||
@@ -543,46 +301,6 @@ func (d *Database) SyncAllDeliverymanStatuses() error {
|
||||
})
|
||||
}
|
||||
|
||||
// GetDeliverymanCapacityReport génère un rapport détaillé
|
||||
func (d *Database) GetDeliverymanCapacityReport() (map[string]any, error) {
|
||||
report := map[string]any{
|
||||
"total_deliverymen": 0,
|
||||
"available": 0,
|
||||
"busy_full": 0,
|
||||
"busy_delivering": 0,
|
||||
"offline": 0,
|
||||
"details": []map[string]any{},
|
||||
}
|
||||
|
||||
err := d.iterDeliveryStatuses(func(s models.DeliveryPersonStatus) {
|
||||
queueKey := fmt.Sprintf("queue:deliveryman:%s", s.Username)
|
||||
queueSize, _ := Redis.ZCard(RedisCtx, queueKey).Result()
|
||||
canAccept := d.CanDeliverymanAcceptCommands(s.Username)
|
||||
|
||||
report["total_deliverymen"] = report["total_deliverymen"].(int) + 1
|
||||
switch {
|
||||
case s.Status == "offline":
|
||||
report["offline"] = report["offline"].(int) + 1
|
||||
case s.Status == "busy" && queueSize >= MAX_COMMANDS_PER_DELIVERYMAN:
|
||||
report["busy_full"] = report["busy_full"].(int) + 1
|
||||
case s.Status == "busy":
|
||||
report["busy_delivering"] = report["busy_delivering"].(int) + 1
|
||||
case canAccept:
|
||||
report["available"] = report["available"].(int) + 1
|
||||
}
|
||||
|
||||
report["details"] = append(report["details"].([]map[string]any), map[string]any{
|
||||
"username": s.Username,
|
||||
"status": s.Status,
|
||||
"queue_size": queueSize,
|
||||
"capacity": fmt.Sprintf("%d/10", queueSize),
|
||||
"can_accept": canAccept,
|
||||
"current_order": s.CurrentCommand,
|
||||
})
|
||||
})
|
||||
return report, err
|
||||
}
|
||||
|
||||
// iterDeliveryStatuses itère sur tous les statuts Redis des livreurs et appelle fn pour chacun.
|
||||
func (d *Database) iterDeliveryStatuses(fn func(models.DeliveryPersonStatus)) error {
|
||||
keys, err := Redis.Keys(RedisCtx, "delivery:status:*").Result()
|
||||
|
||||
@@ -288,10 +288,6 @@ func (d *Database) RecalculateQueueETAs(deliveryman string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *Database) UpdateQueueETAsAfterCompletion(deliveryman string) error {
|
||||
return d.RecalculateQueueETAs(deliveryman)
|
||||
}
|
||||
|
||||
// FindNearestCommandInQueue trouve la commande la plus proche du livreur
|
||||
func (d *Database) FindNearestCommandInQueue(deliveryman string) (*models.CommandQueue, int, error) {
|
||||
livreurLat, livreurLng, err := d.GetDeliveryPersonLocation(deliveryman)
|
||||
|
||||
@@ -3,7 +3,6 @@ package db
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"gestion/models"
|
||||
"log"
|
||||
"time"
|
||||
)
|
||||
@@ -183,221 +182,3 @@ func (d *Database) InvalidateSession(clientID int) error {
|
||||
log.Printf("✅ [SESSION] Session invalidée pour client %d", clientID)
|
||||
return nil
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// PANIER EN CACHE REDIS
|
||||
// ============================================
|
||||
|
||||
// BasketItemCache représente un item du panier en cache
|
||||
type BasketItemCache struct {
|
||||
ID int `json:"id"`
|
||||
ProductID int `json:"product_id"`
|
||||
ProductName string `json:"product_name"`
|
||||
Quantity int `json:"quantity"`
|
||||
Price float64 `json:"price"`
|
||||
Category string `json:"category"`
|
||||
AddedAt int64 `json:"added_at"`
|
||||
}
|
||||
|
||||
// GetSessionBasket récupère le panier en cache Redis
|
||||
// Retourne les items du panier avec total
|
||||
func (d *Database) GetSessionBasket(clientID int) ([]BasketItemCache, float64, error) {
|
||||
basketKey := fmt.Sprintf("session:basket:%d", clientID)
|
||||
|
||||
// Récupérer tous les items du panier
|
||||
items, err := Redis.HGetAll(RedisCtx, basketKey).Result()
|
||||
if err != nil {
|
||||
log.Printf("⚠️ [BASKET] Pas de panier en cache pour client %d", clientID)
|
||||
return []BasketItemCache{}, 0, nil
|
||||
}
|
||||
|
||||
var basketItems []BasketItemCache
|
||||
var totalPrice float64
|
||||
|
||||
for _, itemJSON := range items {
|
||||
var item BasketItemCache
|
||||
if err := json.Unmarshal([]byte(itemJSON), &item); err != nil {
|
||||
log.Printf("⚠️ [BASKET] Erreur parsing item: %v", err)
|
||||
continue
|
||||
}
|
||||
basketItems = append(basketItems, item)
|
||||
totalPrice += item.Price * float64(item.Quantity)
|
||||
}
|
||||
|
||||
return basketItems, totalPrice, nil
|
||||
}
|
||||
|
||||
// UpdateSessionBasket met à jour le panier en cache Redis
|
||||
// Appelé après ajout/modification d'un produit au panier
|
||||
func (d *Database) UpdateSessionBasket(clientID int, basketItems []BasketItemCache) error {
|
||||
basketKey := fmt.Sprintf("session:basket:%d", clientID)
|
||||
|
||||
// Vider le panier existant
|
||||
Redis.Del(RedisCtx, basketKey)
|
||||
|
||||
// Ajouter tous les items
|
||||
for _, item := range basketItems {
|
||||
itemJSON, _ := json.Marshal(item)
|
||||
if err := Redis.HSet(RedisCtx, basketKey, item.ProductID, itemJSON).Err(); err != nil {
|
||||
log.Printf("⚠️ [BASKET] Erreur ajout item: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TTL: 24 heures
|
||||
if err := Redis.Expire(RedisCtx, basketKey, 24*time.Hour).Err(); err != nil {
|
||||
log.Printf("⚠️ [BASKET] Erreur TTL: %v", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ClearSessionBasket vide le panier en cache Redis
|
||||
// Appelé après validation de commande (checkout)
|
||||
func (d *Database) ClearSessionBasket(clientID int) error {
|
||||
basketKey := fmt.Sprintf("session:basket:%d", clientID)
|
||||
if err := Redis.Del(RedisCtx, basketKey).Err(); err != nil {
|
||||
log.Printf("⚠️ [BASKET] Erreur clear: %v", err)
|
||||
return nil
|
||||
}
|
||||
log.Printf("✅ [BASKET] Panier vidé pour client %d", clientID)
|
||||
return nil
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// UTILITAIRES SESSION
|
||||
// ============================================
|
||||
|
||||
// GetAllActiveSessions récupère toutes les sessions actives
|
||||
// Utile pour admin/stats
|
||||
func (d *Database) GetAllActiveSessions() ([]SessionData, error) {
|
||||
clientIDs, err := Redis.SMembers(RedisCtx, "session:active:clients").Result()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("erreur récupération sessions: %w", err)
|
||||
}
|
||||
|
||||
var sessions []SessionData
|
||||
for _, clientIDStr := range clientIDs {
|
||||
var clientID int
|
||||
if _, err := fmt.Sscanf(clientIDStr, "%d", &clientID); err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
if session, err := d.GetClientSession(clientID); err == nil {
|
||||
sessions = append(sessions, *session)
|
||||
}
|
||||
}
|
||||
|
||||
return sessions, nil
|
||||
}
|
||||
|
||||
// GetSessionCount retourne le nombre de sessions actives
|
||||
func (d *Database) GetSessionCount() (int64, error) {
|
||||
count, err := Redis.SCard(RedisCtx, "session:active:clients").Result()
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("erreur comptage sessions: %w", err)
|
||||
}
|
||||
return count, nil
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// CACHE PROFIL CLIENT
|
||||
// ============================================
|
||||
|
||||
// CacheClientProfile met en cache les infos du client (pour 1h)
|
||||
func (d *Database) CacheClientProfile(client interface{}) error {
|
||||
// Récupérer le client depuis DB si c'est un username
|
||||
var clientData *models.Client
|
||||
|
||||
// Si c'est un username string
|
||||
if username, ok := client.(string); ok {
|
||||
var err error
|
||||
clientData, err = d.GetClientByUsername(username)
|
||||
if err != nil {
|
||||
return fmt.Errorf("client non trouvé: %w", err)
|
||||
}
|
||||
} else {
|
||||
// Si c'est déjà un *models.Client
|
||||
clientData = client.(*models.Client)
|
||||
}
|
||||
|
||||
cacheKey := fmt.Sprintf("cache:client:profile:%d", clientData.ID)
|
||||
|
||||
// Sérialiser
|
||||
profileJSON, err := json.Marshal(clientData)
|
||||
if err != nil {
|
||||
return fmt.Errorf("erreur sérialisation: %w", err)
|
||||
}
|
||||
|
||||
// Sauvegarder avec TTL 1h
|
||||
if err := Redis.Set(RedisCtx, cacheKey, profileJSON, 1*time.Hour).Err(); err != nil {
|
||||
return fmt.Errorf("erreur cache: %w", err)
|
||||
}
|
||||
|
||||
log.Printf("✅ [CACHE] Profil client %d mis en cache (1h)", clientData.ID)
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetCachedClientProfile récupère le profil en cache
|
||||
func (d *Database) GetCachedClientProfile(clientID int) (*models.Client, error) {
|
||||
cacheKey := fmt.Sprintf("cache:client:profile:%d", clientID)
|
||||
|
||||
data, err := Redis.Get(RedisCtx, cacheKey).Result()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cache miss")
|
||||
}
|
||||
|
||||
var client models.Client
|
||||
if err := json.Unmarshal([]byte(data), &client); err != nil {
|
||||
return nil, fmt.Errorf("erreur désérialisation: %w", err)
|
||||
}
|
||||
|
||||
return &client, nil
|
||||
}
|
||||
|
||||
// InvalidateClientCache invalide le cache du client
|
||||
func (d *Database) InvalidateClientCache(clientID int) error {
|
||||
cacheKey := fmt.Sprintf("cache:client:profile:%d", clientID)
|
||||
if err := Redis.Del(RedisCtx, cacheKey).Err(); err != nil {
|
||||
return fmt.Errorf("erreur invalidation: %w", err)
|
||||
}
|
||||
log.Printf("✅ [CACHE] Profil client %d invalidé", clientID)
|
||||
return nil
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// COMMANDES EN CACHE (POUR TRACKING)
|
||||
// ============================================
|
||||
|
||||
// CacheCommandInfo met en cache les infos d'une commande
|
||||
func (d *Database) CacheCommandInfo(commandID int, command map[string]interface{}) error {
|
||||
cacheKey := fmt.Sprintf("cache:command:%d", commandID)
|
||||
|
||||
commandJSON, err := json.Marshal(command)
|
||||
if err != nil {
|
||||
return fmt.Errorf("erreur sérialisation: %w", err)
|
||||
}
|
||||
|
||||
// TTL: 4 heures
|
||||
if err := Redis.Set(RedisCtx, cacheKey, commandJSON, 4*time.Hour).Err(); err != nil {
|
||||
return fmt.Errorf("erreur cache: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetCachedCommand récupère une commande en cache
|
||||
func (d *Database) GetCachedCommand(commandID int) (map[string]interface{}, error) {
|
||||
cacheKey := fmt.Sprintf("cache:command:%d", commandID)
|
||||
|
||||
data, err := Redis.Get(RedisCtx, cacheKey).Result()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cache miss")
|
||||
}
|
||||
|
||||
var command map[string]interface{}
|
||||
if err := json.Unmarshal([]byte(data), &command); err != nil {
|
||||
return nil, fmt.Errorf("erreur désérialisation: %w", err)
|
||||
}
|
||||
|
||||
return command, nil
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ require (
|
||||
github.com/lib/pq v1.10.9
|
||||
github.com/redis/go-redis/v9 v9.17.0
|
||||
golang.org/x/crypto v0.40.0
|
||||
golang.org/x/text v0.27.0
|
||||
gorm.io/driver/postgres v1.6.0
|
||||
gorm.io/gorm v1.31.1
|
||||
)
|
||||
@@ -55,7 +56,6 @@ require (
|
||||
golang.org/x/net v0.42.0 // indirect
|
||||
golang.org/x/sync v0.16.0 // indirect
|
||||
golang.org/x/sys v0.35.0 // indirect
|
||||
golang.org/x/text v0.27.0 // indirect
|
||||
golang.org/x/tools v0.34.0 // indirect
|
||||
google.golang.org/protobuf v1.36.9 // indirect
|
||||
)
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"fmt"
|
||||
"gestion/db"
|
||||
"gestion/models"
|
||||
"gestion/services"
|
||||
"gestion/utils"
|
||||
"log"
|
||||
"net/http"
|
||||
@@ -76,7 +79,7 @@ func RegisterClient(c *gin.Context) {
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
log.Printf("❌ [REGISTER_CLIENT] Erreur binding: %v", err)
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"error": "Données invalides",
|
||||
"error": "Données invalides",
|
||||
})
|
||||
return
|
||||
}
|
||||
@@ -178,6 +181,11 @@ func RegisterClient(c *gin.Context) {
|
||||
|
||||
// AdminCreateClient crée un client depuis l'interface admin (sans session ni token)
|
||||
func AdminCreateClient(c *gin.Context) {
|
||||
if userRole := c.GetString("role"); userRole != "admin" {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "Seul un administrateur peut créer des clients"})
|
||||
return
|
||||
}
|
||||
|
||||
var req models.RegisterClientRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
log.Printf("❌ [ADMIN_CREATE_CLIENT] Binding error: %v | body: username=%q nom=%q prenom=%q tel=%q", err, req.Username, req.Nom, req.Prenom, req.Telephone)
|
||||
@@ -242,8 +250,16 @@ func AdminCreateClient(c *gin.Context) {
|
||||
})
|
||||
}
|
||||
|
||||
func cryptoRandInt() int {
|
||||
b := make([]byte, 4)
|
||||
rand.Read(b)
|
||||
return int(b[0])<<24 | int(b[1])<<16 | int(b[2])<<8 | int(b[3])
|
||||
}
|
||||
|
||||
// LoginClient authentifie un client
|
||||
func LoginClient(c *gin.Context) {
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
|
||||
var req models.LoginRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
log.Printf("❌ [LOGIN_CLIENT] Erreur binding: %v", err)
|
||||
@@ -251,8 +267,6 @@ func LoginClient(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
|
||||
client, err := database.GetClientByUsername(req.Username)
|
||||
if err != nil || client == nil {
|
||||
log.Printf("❌ [LOGIN_CLIENT] Client non trouvé: %s", req.Username)
|
||||
@@ -266,6 +280,33 @@ func LoginClient(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
settings, err := database.GetSettings()
|
||||
if err != nil {
|
||||
log.Printf("❌ [LOGIN_CLIENT] Erreur récupération des paramètres: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur interne"})
|
||||
return
|
||||
}
|
||||
|
||||
if settings.Telegram2FAEnabled && client.TwoFAEnabled {
|
||||
chatID, linked, _ := database.GetClientTelegramChatID(client.Username)
|
||||
if linked {
|
||||
code := fmt.Sprintf("%06d", cryptoRandInt()%1000000)
|
||||
sessionToken := uuid.New().String()
|
||||
if err := db.Store2FASession(sessionToken, client.Username, code); err != nil {
|
||||
log.Printf("❌ [2FA] Erreur stockage session Redis: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur interne"})
|
||||
return
|
||||
}
|
||||
msg := fmt.Sprintf("🔐 Code de vérification : <b>%s</b>\n\nValable 5 minutes.", code)
|
||||
services.TelegramBot.SendMessage(chatID, msg)
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"requires_2fa": true,
|
||||
"session_token": sessionToken,
|
||||
})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
token, err := generateClientToken(client)
|
||||
if err != nil {
|
||||
log.Printf("❌ [LOGIN_CLIENT] Erreur génération token: %v", err)
|
||||
@@ -280,7 +321,6 @@ func LoginClient(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// Créer la session Redis
|
||||
sessionID := uuid.New().String()
|
||||
if err := database.CreateClientSession(client.ID, client.Username, sessionID); err != nil {
|
||||
log.Printf("⚠️ [LOGIN_CLIENT] Erreur session Redis: %v", err)
|
||||
@@ -303,6 +343,125 @@ func LoginClient(c *gin.Context) {
|
||||
})
|
||||
}
|
||||
|
||||
func Verify2FAClient(c *gin.Context) {
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
|
||||
var req struct {
|
||||
SessionToken string `json:"session_token" binding:"required"`
|
||||
Code string `json:"code" binding:"required"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Données invalides"})
|
||||
return
|
||||
}
|
||||
|
||||
username, err := db.Verify2FASession(req.SessionToken, req.Code)
|
||||
if err != nil {
|
||||
log.Printf("❌ [2FA] Échec vérification: %v", err)
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
client, err := database.GetClientByUsername(username)
|
||||
if err != nil || client == nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur interne"})
|
||||
return
|
||||
}
|
||||
|
||||
token, err := generateClientToken(client)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur génération token"})
|
||||
return
|
||||
}
|
||||
|
||||
expiresAt := time.Now().Add(clientTokenDuration)
|
||||
if err := database.SaveToken(client.ID, "client", token, expiresAt); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur enregistrement token"})
|
||||
return
|
||||
}
|
||||
|
||||
sessionID := uuid.New().String()
|
||||
if err := database.CreateClientSession(client.ID, client.Username, sessionID); err != nil {
|
||||
log.Printf("⚠️ [2FA] Erreur session Redis: %v", err)
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, models.LoginResponse{
|
||||
AccessToken: token,
|
||||
TokenType: "Bearer",
|
||||
ExpiresIn: int(clientTokenDuration.Seconds()),
|
||||
User: gin.H{
|
||||
"id": client.ID,
|
||||
"username": client.Username,
|
||||
"nom": client.Nom,
|
||||
"prenom": client.Prenom,
|
||||
"telephone": client.Telephone,
|
||||
"role": "client",
|
||||
"session_id": sessionID,
|
||||
"must_change_password": client.MustChangePassword,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func GetClient2FAStatus(c *gin.Context) {
|
||||
clientID := c.GetInt("client_id")
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
|
||||
client, err := database.GetClientByID(clientID)
|
||||
if err != nil || client == nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Client non trouvé"})
|
||||
return
|
||||
}
|
||||
|
||||
_, tgLinked, _ := database.GetClientTelegramChatID(client.Username)
|
||||
|
||||
settings, _ := database.GetSettings()
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"two_fa_enabled": client.TwoFAEnabled,
|
||||
"telegram_linked": tgLinked,
|
||||
"admin_2fa_enabled": settings.Telegram2FAEnabled,
|
||||
})
|
||||
}
|
||||
|
||||
func ToggleClient2FA(c *gin.Context) {
|
||||
clientID := c.GetInt("client_id")
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
|
||||
var req struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Données invalides"})
|
||||
return
|
||||
}
|
||||
|
||||
client, err := database.GetClientByID(clientID)
|
||||
if err != nil || client == nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Client non trouvé"})
|
||||
return
|
||||
}
|
||||
|
||||
if req.Enabled {
|
||||
_, linked, _ := database.GetClientTelegramChatID(client.Username)
|
||||
if !linked {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Telegram non lié — impossible d'activer la 2FA"})
|
||||
return
|
||||
}
|
||||
settings, _ := database.GetSettings()
|
||||
if !settings.Telegram2FAEnabled {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "La 2FA n'est pas activée par l'administrateur"})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if err := database.SetClientTwoFAEnabled(clientID, req.Enabled); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur mise à jour"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "two_fa_enabled": req.Enabled})
|
||||
}
|
||||
|
||||
// ChangePassword permet à un client de changer son mot de passe
|
||||
func ChangePassword(c *gin.Context) {
|
||||
var req struct {
|
||||
@@ -366,60 +525,6 @@ func LogoutClient(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"message": "Déconnexion réussie"})
|
||||
}
|
||||
|
||||
// RegisterAdmin crée un nouvel utilisateur admin/cabine/livreur
|
||||
func RegisterAdmin(c *gin.Context) {
|
||||
var req models.RegisterAdminRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
log.Printf("❌ [REGISTER_ADMIN] Erreur binding: %v", err)
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Données invalides"})
|
||||
return
|
||||
}
|
||||
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
|
||||
if existingUser, _ := database.GetUserByUsername(req.Username); existingUser != nil {
|
||||
log.Printf("❌ [REGISTER_ADMIN] Username déjà utilisé: %s", req.Username)
|
||||
c.JSON(http.StatusConflict, gin.H{"error": "Nom d'utilisateur déjà utilisé"})
|
||||
return
|
||||
}
|
||||
|
||||
hashed, _ := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost)
|
||||
user := &models.User{
|
||||
Username: req.Username,
|
||||
Password: string(hashed),
|
||||
Role: req.Role,
|
||||
}
|
||||
|
||||
if err := database.CreateUser(user); err != nil {
|
||||
log.Printf("❌ [REGISTER_ADMIN] Erreur création: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur création utilisateur"})
|
||||
return
|
||||
}
|
||||
|
||||
token, err := generateAdminToken(user)
|
||||
if err != nil {
|
||||
log.Printf("❌ [REGISTER_ADMIN] Erreur génération token: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur génération token"})
|
||||
return
|
||||
}
|
||||
|
||||
expiresAt := time.Now().Add(adminTokenDuration)
|
||||
if err := database.SaveToken(user.ID, user.Role, token, expiresAt); err != nil {
|
||||
log.Printf("❌ [REGISTER_ADMIN] Erreur SaveToken: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur enregistrement token"})
|
||||
return
|
||||
}
|
||||
|
||||
user.Password = ""
|
||||
|
||||
c.JSON(http.StatusCreated, models.LoginResponse{
|
||||
AccessToken: token,
|
||||
TokenType: "Bearer",
|
||||
ExpiresIn: int(adminTokenDuration.Seconds()),
|
||||
User: user,
|
||||
})
|
||||
}
|
||||
|
||||
// LoginAdmin authentifie un admin/cabine/livreur
|
||||
func LoginAdmin(c *gin.Context) {
|
||||
var req models.LoginRequest
|
||||
@@ -553,30 +658,6 @@ func GetCurrentAdmin(c *gin.Context) {
|
||||
})
|
||||
}
|
||||
|
||||
// HealthCheck vérifie la santé de l'API
|
||||
// GET /api/v1/health
|
||||
func HealthCheck(c *gin.Context) {
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
if err := database.DB.Ping(); err != nil {
|
||||
log.Printf("⚠️ [HEALTH] Database down: %v", err)
|
||||
c.JSON(http.StatusServiceUnavailable, gin.H{
|
||||
"status": "unhealthy",
|
||||
"database": "disconnected",
|
||||
"timestamp": time.Now().Unix(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("✅ [HEALTH] API healthy")
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"status": "healthy",
|
||||
"database": "connected",
|
||||
"timestamp": time.Now().Unix(),
|
||||
"version": "2.0.0",
|
||||
})
|
||||
}
|
||||
|
||||
// GetAllUsers récupère tous les utilisateurs (Admin only)
|
||||
func GetAllUsers(c *gin.Context) {
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
@@ -744,18 +825,25 @@ func CreateUser(c *gin.Context) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Erreur de liaison JSON"})
|
||||
return
|
||||
}
|
||||
userRole := c.GetString("role")
|
||||
if userRole != "cabine" && userRole != "admin" {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "Accès réservé aux livreurs et aux administrateurs"})
|
||||
if c.GetString("role") != "admin" {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "Seul un administrateur peut créer des utilisateurs"})
|
||||
return
|
||||
}
|
||||
err := database.CreateUser(&user)
|
||||
if err != nil {
|
||||
if user.Role == "admin" {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "La création d'un compte administrateur n'est pas autorisée via l'application"})
|
||||
return
|
||||
}
|
||||
if user.Role != "livreur" && user.Role != "cabine" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Rôle invalide, valeurs acceptées : livreur, cabine"})
|
||||
return
|
||||
}
|
||||
|
||||
if err := database.CreateUser(&user); err != nil {
|
||||
log.Printf("❌ [CREATE_USER] Erreur: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur création"})
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("✅ [CREATE_USER] Utilisateur %d créé", user.ID)
|
||||
log.Printf("✅ [CREATE_USER] Utilisateur %s (%s) créé", user.Username, user.Role)
|
||||
c.JSON(http.StatusCreated, gin.H{"message": "Utilisateur créé"})
|
||||
}
|
||||
|
||||
@@ -1,244 +1,13 @@
|
||||
// ============================================
|
||||
// handlers/cabine_handlers.go - COMPLET
|
||||
// INCLUT: SetCommandDestinationCoordinates
|
||||
// ============================================
|
||||
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"gestion/db"
|
||||
"gestion/utils"
|
||||
"log"
|
||||
"net/http"
|
||||
"slices"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// ============================================
|
||||
// 0️⃣ FONCTION ADMIN: SET DESTINATION COORDINATES
|
||||
// ============================================
|
||||
|
||||
// SetCommandDestinationCoordinates stocke les coordonnées destination en Redis
|
||||
func SetCommandDestinationCoordinates(c *gin.Context) {
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
|
||||
userRole := c.GetString("role")
|
||||
if userRole != "admin" {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "Accès réservé aux administrateurs"})
|
||||
return
|
||||
}
|
||||
|
||||
adminUsername := c.GetString("username")
|
||||
|
||||
commandID, err := strconv.Atoi(c.Param("id"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "ID de commande invalide"})
|
||||
return
|
||||
}
|
||||
|
||||
var req struct {
|
||||
Latitude float64 `json:"latitude" binding:"required"`
|
||||
Longitude float64 `json:"longitude" binding:"required"`
|
||||
}
|
||||
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"error": "Latitude et longitude requises",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Validation des coordonnées GPS
|
||||
if req.Latitude < -90 || req.Latitude > 90 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"error": "Latitude invalide (doit être entre -90 et 90)",
|
||||
"value": req.Latitude,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if req.Longitude < -180 || req.Longitude > 180 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"error": "Longitude invalide (doit être entre -180 et 180)",
|
||||
"value": req.Longitude,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if !utils.CheckCommand(commandID, database) {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Commande non trouvée"})
|
||||
}
|
||||
|
||||
destCacheKey := fmt.Sprintf("command:destination:%d", commandID)
|
||||
coordsJSON, _ := json.Marshal(map[string]float64{
|
||||
"lat": req.Latitude,
|
||||
"lon": req.Longitude,
|
||||
})
|
||||
|
||||
ttlSeconds := 24 * 60 * 60 // 24 heures
|
||||
err = db.Redis.Set(db.RedisCtx, destCacheKey, coordsJSON, time.Duration(ttlSeconds)*time.Second).Err()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"error": "Erreur stockage Redis",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Ajouter un log
|
||||
database.AddCommandLog(commandID, "destination_set",
|
||||
fmt.Sprintf("Coordonnées destination définies par admin %s: (%.6f, %.6f) via Redis",
|
||||
adminUsername, req.Latitude, req.Longitude),
|
||||
adminUsername)
|
||||
|
||||
log.Printf("✅ [ADMIN %s] Coordonnées destination définies pour CMD %d: (%.6f, %.6f) en Redis",
|
||||
adminUsername, commandID, req.Latitude, req.Longitude)
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"message": "Coordonnées définies avec succès en Redis",
|
||||
"command_id": commandID,
|
||||
})
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// 1. CLIENT PROFILE
|
||||
// ============================================
|
||||
|
||||
func GetClientProfile(c *gin.Context) {
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
username := c.Param("username")
|
||||
|
||||
if username == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Username requis"})
|
||||
return
|
||||
}
|
||||
|
||||
client, err := database.GetClientByUsername(username)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Client non trouvé"})
|
||||
return
|
||||
}
|
||||
|
||||
client.Password = ""
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"client": gin.H{
|
||||
"id": client.ID,
|
||||
"username": client.Username,
|
||||
"command": client.Command,
|
||||
"amende": client.Amende,
|
||||
"points_extra": client.PointsExtra,
|
||||
"created_at": client.CreatedAt,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func GetClientFullHistory(c *gin.Context) {
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
username := c.Param("username")
|
||||
|
||||
if username == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Username requis"})
|
||||
return
|
||||
}
|
||||
|
||||
client, err := database.GetClientByUsername(username)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Client non trouvé"})
|
||||
return
|
||||
}
|
||||
|
||||
commands, err := database.GetAllCommands("", username)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur récupération historique"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"client": gin.H{
|
||||
"username": client.Username,
|
||||
"total_commands": client.Command,
|
||||
"amende": client.Amende,
|
||||
"points_extra": client.PointsExtra,
|
||||
},
|
||||
"commands": commands,
|
||||
"count": len(commands),
|
||||
})
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// 2. UPDATE ADDRESS
|
||||
// ============================================
|
||||
|
||||
func UpdateCommandAddressCabine(c *gin.Context) {
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
|
||||
commandID, err := strconv.Atoi(c.Param("id"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "ID de commande invalide"})
|
||||
return
|
||||
}
|
||||
|
||||
var req struct {
|
||||
DeliveryAddress string `json:"delivery_address" binding:"required"`
|
||||
Reason string `json:"reason"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil || req.DeliveryAddress == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Adresse de livraison requise"})
|
||||
return
|
||||
}
|
||||
|
||||
command, err := database.GetCommandByID(commandID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Commande non trouvée"})
|
||||
return
|
||||
}
|
||||
|
||||
status, _ := command["status"].(string)
|
||||
|
||||
allowedStatuses := []string{"pending", "", "assigned"}
|
||||
if !slices.Contains(allowedStatuses, status) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"error": "Impossible de modifier l'adresse d'une commande en cours ou terminée",
|
||||
"current_status": status,
|
||||
"allowed_statuses": allowedStatuses,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if err := database.UpdateCommandAddress(commandID, req.DeliveryAddress); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"error": "Erreur lors de la mise à jour de l'adresse",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
cabineUsername, _ := c.Get("username")
|
||||
message := fmt.Sprintf("Adresse modifiée par cabine: %s", req.DeliveryAddress)
|
||||
if req.Reason != "" {
|
||||
message += fmt.Sprintf(" (Raison: %s)", req.Reason)
|
||||
}
|
||||
database.AddCommandLog(commandID, "address_updated", message, cabineUsername.(string))
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"message": "Adresse de livraison mise à jour",
|
||||
"command_id": commandID,
|
||||
"delivery_address": req.DeliveryAddress,
|
||||
})
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// 3. LIVREUR POSITION
|
||||
// ============================================
|
||||
|
||||
func GetLivreurPosition(c *gin.Context) {
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
livreurUsername := c.Param("username")
|
||||
@@ -273,108 +42,6 @@ func GetLivreurPosition(c *gin.Context) {
|
||||
})
|
||||
}
|
||||
|
||||
func GetDeliveryTrackingClient(c *gin.Context) {
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
|
||||
username, exists := c.Get("username")
|
||||
if !exists {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Non authentifié"})
|
||||
return
|
||||
}
|
||||
|
||||
commandID, err := strconv.Atoi(c.Param("id"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "ID de commande invalide"})
|
||||
return
|
||||
}
|
||||
|
||||
command, err := database.GetCommandByID(commandID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Commande non trouvée"})
|
||||
return
|
||||
}
|
||||
|
||||
if command["username"].(string) != username.(string) {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "Cette commande ne vous appartient pas"})
|
||||
return
|
||||
}
|
||||
|
||||
livreurAssign, _ := command["livreur_assign"].(string)
|
||||
logs, _ := database.GetCommandLogs(commandID)
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"command_id": commandID,
|
||||
"status": command["status"],
|
||||
"livreur": livreurAssign,
|
||||
"address": command["adresse"],
|
||||
"logs": logs,
|
||||
"message": "Suivi en cours - ETA disponible via /api/v1/orders/:id/eta",
|
||||
})
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// 5. DELIVERY TRACKING ADMIN (AVEC GPS)
|
||||
// ============================================
|
||||
|
||||
func GetDeliveryTracking(c *gin.Context) {
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
|
||||
userRole := c.GetString("role")
|
||||
if userRole != "admin" && userRole != "cabine" {
|
||||
c.JSON(http.StatusForbidden, gin.H{
|
||||
"error": "Accès refusé - Réservé aux administrateurs et cabines",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
commandID, err := strconv.Atoi(c.Param("id"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "ID de commande invalide"})
|
||||
return
|
||||
}
|
||||
|
||||
command, err := database.GetCommandByID(commandID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Commande non trouvée"})
|
||||
return
|
||||
}
|
||||
|
||||
livreurAssign, _ := command["livreur_assign"].(string)
|
||||
if livreurAssign == "" {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"command": command,
|
||||
"status": "Aucun livreur assigné",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
position, err := database.GetLivreurPosition(livreurAssign)
|
||||
logs, _ := database.GetCommandLogs(commandID)
|
||||
|
||||
response := gin.H{
|
||||
"success": true,
|
||||
"command": command,
|
||||
"livreur": livreurAssign,
|
||||
"logs": logs,
|
||||
}
|
||||
|
||||
status, _ := command["status"].(string)
|
||||
if err != nil && (status == "livre" || status == "approved") {
|
||||
response["livreur_position"] = nil
|
||||
response["position_status"] = "Livraison terminée - Position non suivie"
|
||||
} else if err != nil {
|
||||
response["livreur_position"] = nil
|
||||
response["position_status"] = "Position non disponible (GPS peut-être désactivé)"
|
||||
} else {
|
||||
response["livreur_position"] = position
|
||||
response["position_status"] = "Position en temps réel"
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, response)
|
||||
}
|
||||
|
||||
func GetDeliveryIssues(c *gin.Context) {
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
|
||||
@@ -390,7 +57,7 @@ func GetDeliveryIssues(c *gin.Context) {
|
||||
issues, err := database.GetDeliveryIssues(status)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"error": "Erreur récupération problèmes",
|
||||
"error": "Erreur récupération problèmes",
|
||||
})
|
||||
return
|
||||
}
|
||||
@@ -426,7 +93,7 @@ func CreateDeliveryIssue(c *gin.Context) {
|
||||
)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"error": "Erreur création problème",
|
||||
"error": "Erreur création problème",
|
||||
})
|
||||
return
|
||||
}
|
||||
@@ -462,7 +129,7 @@ func UpdateDeliveryIssue(c *gin.Context) {
|
||||
err = database.UpdateDeliveryIssue(issueID, req.Status, req.Resolution, cabineUsername.(string))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"error": "Erreur mise à jour",
|
||||
"error": "Erreur mise à jour",
|
||||
})
|
||||
return
|
||||
}
|
||||
@@ -473,53 +140,6 @@ func UpdateDeliveryIssue(c *gin.Context) {
|
||||
})
|
||||
}
|
||||
|
||||
func AddDeliverySupport(c *gin.Context) {
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
|
||||
commandID, err := strconv.Atoi(c.Param("id"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "ID commande invalide"})
|
||||
return
|
||||
}
|
||||
|
||||
var req struct {
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
c.ShouldBindJSON(&req)
|
||||
|
||||
if req.Message == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"error": "Message requis",
|
||||
"example": gin.H{
|
||||
"message": "Votre message de support ici",
|
||||
},
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
cabineUsername, _ := c.Get("username")
|
||||
|
||||
err = database.AddCommandLog(
|
||||
commandID,
|
||||
"note",
|
||||
fmt.Sprintf("Note cabine: %s", req.Message),
|
||||
cabineUsername.(string),
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"error": "Erreur ajout support",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"message": "Support ajouté",
|
||||
})
|
||||
}
|
||||
|
||||
func GetCommandLogs(c *gin.Context) {
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
|
||||
@@ -532,7 +152,7 @@ func GetCommandLogs(c *gin.Context) {
|
||||
logs, err := database.GetCommandLogs(commandID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"error": "Erreur récupération logs",
|
||||
"error": "Erreur récupération logs",
|
||||
})
|
||||
return
|
||||
}
|
||||
@@ -543,127 +163,3 @@ func GetCommandLogs(c *gin.Context) {
|
||||
"count": len(logs),
|
||||
})
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// 7. FORCE VALIDATE DELIVERY
|
||||
// ============================================
|
||||
|
||||
func ForceValidateDelivery(c *gin.Context) {
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
|
||||
userRole := c.GetString("role")
|
||||
if userRole != "admin" {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "Accès refusé - Admin seulement"})
|
||||
return
|
||||
}
|
||||
|
||||
adminUsername, _ := c.Get("username")
|
||||
|
||||
commandID, err := strconv.Atoi(c.Param("id"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "ID de commande invalide"})
|
||||
return
|
||||
}
|
||||
|
||||
var req struct {
|
||||
Reason string `json:"reason" binding:"required"`
|
||||
}
|
||||
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"error": "Raison requise pour validation forcée",
|
||||
"example": gin.H{
|
||||
"reason": "Client confirmé par téléphone",
|
||||
},
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if req.Reason == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"error": "Veuillez fournir une raison pour la validation forcée",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
command, err := database.GetCommandByID(commandID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{
|
||||
"error": "Commande non trouvée",
|
||||
"command_id": commandID,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
status, ok := command["status"].(string)
|
||||
if !ok {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Statut de commande invalide"})
|
||||
return
|
||||
}
|
||||
|
||||
if status == "livre" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"error": "Cette commande a déjà été validée",
|
||||
"current_status": status,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
validStatuses := []string{"assigned", "en_route", "pending", "priority"}
|
||||
if !slices.Contains(validStatuses, status) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"error": "Commande ne peut pas être validée de force dans ce statut",
|
||||
"current_status": status,
|
||||
"valid_statuses": validStatuses,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
err = database.UpdateCommandStatus(commandID, "livre")
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"error": "Erreur lors de la validation forcée",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
clientUsername, _ := command["username"].(string)
|
||||
livreurAssign, _ := command["livreur_assign"].(string)
|
||||
|
||||
if clientUsername != "" {
|
||||
clientMsg := fmt.Sprintf("Ta commande #%d a bien été livrée ! Bonne dégustation l'ami et à bientôt 😊\n\n<b>⚠️ VALIDE LA RÉCEPTION DE TA COMMANDE DANS LA RUBRIQUE SUIVI POUR RÉCUPÉRER TES POINTS DE FIDÉLITÉ ⚠️</b>", database.GetClientOrderID(commandID))
|
||||
database.NotifyClient(clientUsername, commandID, "livre", clientMsg)
|
||||
}
|
||||
|
||||
if err := database.IncrementClientCommandCount(clientUsername); err != nil {
|
||||
log.Printf("⚠️ Erreur compteur commandes: %v", err)
|
||||
}
|
||||
|
||||
if err := database.AddClientPointsByCategory(clientUsername, 10, ""); err != nil {
|
||||
log.Printf("⚠️ Erreur ajout points: %v", err)
|
||||
}
|
||||
|
||||
if livreurAssign != "" {
|
||||
err := database.CompleteDeliveryAndProcessNext(livreurAssign, commandID)
|
||||
if err != nil {
|
||||
log.Printf("⚠️ Erreur optimisation: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
message := fmt.Sprintf("VALIDATION FORCÉE par admin %s - Raison: %s", adminUsername.(string), req.Reason)
|
||||
database.AddCommandLog(commandID, "livre", message, adminUsername.(string))
|
||||
|
||||
log.Printf("🔴 Commande %d validée de force par %s - Raison: %s", commandID, adminUsername.(string), req.Reason)
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"message": "Livraison validée de force (sans vérification GPS)",
|
||||
"command_id": commandID,
|
||||
"validation_type": "forced",
|
||||
"reason": req.Reason,
|
||||
"validated_by": adminUsername.(string),
|
||||
"new_status": "livre",
|
||||
"points_awarded": 10,
|
||||
"queue_optimized": livreurAssign != "",
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,9 +1,3 @@
|
||||
// ============================================
|
||||
// handlers/cancel_command_handler.go
|
||||
// ANNULATION DE COMMANDES AVEC SANCTIONS ÉVOLUTIVES
|
||||
// VERSION SÉCURISÉE - FIX ETA CHECK
|
||||
// ============================================
|
||||
|
||||
package handlers
|
||||
|
||||
import (
|
||||
@@ -218,9 +212,6 @@ func CancelCommandByClient(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// SUCCÈS
|
||||
// ============================================
|
||||
log.Printf("✅ [CANCEL_CLIENT] Commande %d annulée", commandID)
|
||||
|
||||
response := gin.H{
|
||||
@@ -242,10 +233,6 @@ func CancelCommandByClient(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, response)
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// HISTORIQUE DES ANNULATIONS - VERSION SÉCURISÉE
|
||||
// ============================================
|
||||
|
||||
func GetMyCancellationHistory(c *gin.Context) {
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
|
||||
@@ -266,9 +253,12 @@ func GetMyCancellationHistory(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
var totalPenalty int
|
||||
penaltyQuery := `SELECT COALESCE(amende, 0) FROM clients WHERE username = $1`
|
||||
database.QueryRow(penaltyQuery).Scan(&totalPenalty)
|
||||
var penaltyResult struct {
|
||||
Amende int `gorm:"column:amende"`
|
||||
}
|
||||
database.GDB.Raw(`SELECT COALESCE(amende, 0) as amende FROM clients WHERE username = ?`,
|
||||
username).Scan(&penaltyResult)
|
||||
totalPenalty := penaltyResult.Amende
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
|
||||
@@ -123,6 +123,7 @@ func GetMyCommandsWithTracking(c *gin.Context) {
|
||||
"status_message": getStatusMessage(cmd["status"].(string)),
|
||||
"adresse": cmd["adresse"],
|
||||
"total_prix": cmd["total_prix"],
|
||||
"referral_used": cmd["referral_used"],
|
||||
"created_at": cmd["created_at"],
|
||||
"livreur": livreurInfo,
|
||||
"eta": etaData,
|
||||
|
||||
@@ -595,10 +595,6 @@ func ValidateDelivery(c *gin.Context) {
|
||||
})
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// GESTION ADMIN
|
||||
// ============================================
|
||||
|
||||
// GetAvailableDeliveryPersons récupère les livreurs disponibles
|
||||
// GET /api/v1/admin/delivery-persons/available
|
||||
func GetAvailableDeliveryPersons(c *gin.Context) {
|
||||
@@ -778,13 +774,6 @@ func GetClientCommandsHistory(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, resp)
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// NOTIFICATIONS CLIENT
|
||||
// ============================================
|
||||
|
||||
// NotifyClientToDescend envoie une notification push au client pour descendre récupérer sa commande
|
||||
// POST /api/v2/admin/protected/orders/:id/notify-client
|
||||
// POST /api/v1/cabine/commands/:id/notify-client
|
||||
func NotifyClientToDescend(c *gin.Context) {
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
|
||||
@@ -1119,6 +1108,19 @@ func UpdateCommandStatusAdmin(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
if req.Status == "cancelled" {
|
||||
current, errCmd := database.GetCommandByID(commandID)
|
||||
if errCmd == nil {
|
||||
currentStatus, _ := current["status"].(string)
|
||||
alreadyDone := currentStatus == "cancelled" || currentStatus == "approved" || currentStatus == "livre"
|
||||
if !alreadyDone {
|
||||
if err := database.RestoreCommandStock(commandID); err != nil {
|
||||
log.Printf("⚠️ [STATUS_ADMIN] Erreur restauration stock cmd %d: %v", commandID, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if err := database.UpdateCommandStatus(commandID, req.Status); err != nil {
|
||||
utils.ServerErr(c, "Impossible de mettre à jour le statut", err)
|
||||
return
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"net/http"
|
||||
"slices"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
@@ -33,7 +34,7 @@ func GetMyDeliveries(c *gin.Context) {
|
||||
commands, err := database.GetDeliveryPersonCommands(usernameStr, status)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"error": "Erreur récupération",
|
||||
"error": "Erreur récupération",
|
||||
})
|
||||
return
|
||||
}
|
||||
@@ -58,24 +59,26 @@ func GetMyDeliveries(c *gin.Context) {
|
||||
itemsSummary := make([]gin.H, len(items))
|
||||
for j, item := range items {
|
||||
itemsSummary[j] = gin.H{
|
||||
"produit": item["produit"],
|
||||
"quantite": item["quantite"],
|
||||
"prix": item["prix"],
|
||||
"produit": item["produit"],
|
||||
"quantite": item["quantite"],
|
||||
"prix": item["prix"],
|
||||
"is_reward": item["is_reward"],
|
||||
}
|
||||
}
|
||||
|
||||
etaData, _ := database.GetCommandETA(commandID)
|
||||
|
||||
filteredCommands[i] = gin.H{
|
||||
"id": cmd["id"],
|
||||
"status": cmd["status"],
|
||||
"adresse": cmd["adresse"],
|
||||
"total_prix": cmd["total_prix"],
|
||||
"created_at": cmd["created_at"],
|
||||
"client_info": clientInfo,
|
||||
"items": itemsSummary,
|
||||
"items_count": len(items),
|
||||
"eta": etaData,
|
||||
"id": cmd["id"],
|
||||
"status": cmd["status"],
|
||||
"adresse": cmd["adresse"],
|
||||
"total_prix": cmd["total_prix"],
|
||||
"referral_used": cmd["referral_used"],
|
||||
"created_at": cmd["created_at"],
|
||||
"client_info": clientInfo,
|
||||
"items": itemsSummary,
|
||||
"items_count": len(items),
|
||||
"eta": etaData,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -188,7 +191,7 @@ func UpdateDeliveryStatus(c *gin.Context) {
|
||||
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"error": "Données invalides",
|
||||
"error": "Données invalides",
|
||||
})
|
||||
return
|
||||
}
|
||||
@@ -258,11 +261,31 @@ func UpdateDeliveryStatus(c *gin.Context) {
|
||||
// Mettre à jour le statut
|
||||
if err := database.UpdateCommandStatus(commandID, req.Status); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"error": "Erreur mise à jour",
|
||||
"error": "Erreur mise à jour",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if req.Status == "cancelled" {
|
||||
cancelMsg := req.Notes
|
||||
if cancelMsg == "" {
|
||||
cancelMsg = "Annulé par le livreur"
|
||||
}
|
||||
database.SetCommandCancelReason(commandID, fmt.Sprintf("[Livreur: %s] %s", usernameStr, cancelMsg))
|
||||
|
||||
prevStatus, _ := command["status"].(string)
|
||||
if prevStatus == "arrived" || prevStatus == "livre" {
|
||||
clientUsername, _ := command["username"].(string)
|
||||
if clientUsername != "" {
|
||||
if penalty, err := database.ApplyCancellationPenalty(clientUsername); err == nil {
|
||||
log.Printf("⚠️ [CANCEL_LIVREUR] Amende %d appliquée à %s (client absent)", penalty, clientUsername)
|
||||
} else {
|
||||
log.Printf("⚠️ [CANCEL_LIVREUR] Erreur application amende pour %s: %v", clientUsername, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ✅ SI PASSAGE EN "EN_ROUTE" → CALCULER ET DÉFINIR L'ETA
|
||||
var etaMinutes int
|
||||
var etaMessage string
|
||||
@@ -392,8 +415,12 @@ func UpdateDeliveryStatus(c *gin.Context) {
|
||||
database.CompleteDeliveryAndProcessNext(usernameStr, commandID)
|
||||
|
||||
case "cancelled":
|
||||
// Annulation par le livreur - Nettoyer la queue
|
||||
log.Printf("🚫 Livraison annulée par livreur - Nettoyage queue cmd %d", commandID)
|
||||
if err := database.RestoreCommandStock(commandID); err != nil {
|
||||
log.Printf("⚠️ [STATUS_LIVREUR] Erreur restauration stock cmd %d: %v", commandID, err)
|
||||
} else {
|
||||
log.Printf("✅ [STATUS_LIVREUR] Stock restauré pour cmd %d", commandID)
|
||||
}
|
||||
database.CompleteDeliveryAndProcessNext(usernameStr, commandID)
|
||||
|
||||
case "arrived":
|
||||
@@ -472,3 +499,119 @@ func ReportDeliveryIssue(c *gin.Context) {
|
||||
log.Printf("📋 [ISSUE] Créé par %s pour commande #%d: %s", username, commandID, req.IssueType)
|
||||
c.JSON(http.StatusCreated, gin.H{"success": true, "issue": issue})
|
||||
}
|
||||
|
||||
// GET /api/v1/livreur/stats
|
||||
func GetMyDeliveryStats(c *gin.Context) {
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
|
||||
username, exists := c.Get("username")
|
||||
if !exists {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Non authentifié"})
|
||||
return
|
||||
}
|
||||
if c.GetString("role") != "livreur" {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "Accès réservé aux livreurs"})
|
||||
return
|
||||
}
|
||||
usernameStr := username.(string)
|
||||
gdb := database.GDB
|
||||
|
||||
type DayRow struct {
|
||||
Day time.Time `gorm:"column:day"`
|
||||
Count int `gorm:"column:count"`
|
||||
Revenue float64 `gorm:"column:revenue"`
|
||||
}
|
||||
type WeekRow struct {
|
||||
WeekNum int `gorm:"column:week_num"`
|
||||
Year int `gorm:"column:year"`
|
||||
Count int `gorm:"column:count"`
|
||||
Revenue float64 `gorm:"column:revenue"`
|
||||
}
|
||||
type MonthRow struct {
|
||||
MonthNum int `gorm:"column:month_num"`
|
||||
Year int `gorm:"column:year"`
|
||||
Count int `gorm:"column:count"`
|
||||
Revenue float64 `gorm:"column:revenue"`
|
||||
}
|
||||
|
||||
var dayRows []DayRow
|
||||
gdb.Raw(`
|
||||
SELECT DATE(updated_at) AS day,
|
||||
COUNT(*) AS count,
|
||||
COALESCE(SUM(total_prix - COALESCE(referral_used, 0)), 0) AS revenue
|
||||
FROM commandes
|
||||
WHERE livreur_assign = ?
|
||||
AND status IN ('livre', 'approved')
|
||||
AND updated_at >= NOW() - INTERVAL '30 days'
|
||||
GROUP BY DATE(updated_at)
|
||||
ORDER BY day
|
||||
`, usernameStr).Scan(&dayRows)
|
||||
|
||||
var weekRows []WeekRow
|
||||
gdb.Raw(`
|
||||
SELECT EXTRACT(WEEK FROM updated_at)::int AS week_num,
|
||||
EXTRACT(YEAR FROM updated_at)::int AS year,
|
||||
COUNT(*) AS count,
|
||||
COALESCE(SUM(total_prix - COALESCE(referral_used, 0)), 0) AS revenue
|
||||
FROM commandes
|
||||
WHERE livreur_assign = ?
|
||||
AND status IN ('livre', 'approved')
|
||||
AND updated_at >= NOW() - INTERVAL '12 weeks'
|
||||
GROUP BY week_num, year
|
||||
ORDER BY year, week_num
|
||||
`, usernameStr).Scan(&weekRows)
|
||||
|
||||
var monthRows []MonthRow
|
||||
gdb.Raw(`
|
||||
SELECT EXTRACT(MONTH FROM updated_at)::int AS month_num,
|
||||
EXTRACT(YEAR FROM updated_at)::int AS year,
|
||||
COUNT(*) AS count,
|
||||
COALESCE(SUM(total_prix - COALESCE(referral_used, 0)), 0) AS revenue
|
||||
FROM commandes
|
||||
WHERE livreur_assign = ?
|
||||
AND status IN ('livre', 'approved')
|
||||
AND updated_at >= NOW() - INTERVAL '12 months'
|
||||
GROUP BY month_num, year
|
||||
ORDER BY year, month_num
|
||||
`, usernameStr).Scan(&monthRows)
|
||||
|
||||
monthNames := [13]string{"", "Jan", "Fév", "Mar", "Avr", "Mai", "Jun", "Jul", "Aoû", "Sep", "Oct", "Nov", "Déc"}
|
||||
|
||||
byDay := make([]gin.H, len(dayRows))
|
||||
for i, r := range dayRows {
|
||||
byDay[i] = gin.H{
|
||||
"label": r.Day.Format("02/01"),
|
||||
"count": r.Count,
|
||||
"revenue": r.Revenue,
|
||||
}
|
||||
}
|
||||
|
||||
byWeek := make([]gin.H, len(weekRows))
|
||||
for i, r := range weekRows {
|
||||
byWeek[i] = gin.H{
|
||||
"label": fmt.Sprintf("S%d", r.WeekNum),
|
||||
"count": r.Count,
|
||||
"revenue": r.Revenue,
|
||||
}
|
||||
}
|
||||
|
||||
byMonth := make([]gin.H, len(monthRows))
|
||||
for i, r := range monthRows {
|
||||
label := "?"
|
||||
if r.MonthNum >= 1 && r.MonthNum <= 12 {
|
||||
label = monthNames[r.MonthNum]
|
||||
}
|
||||
byMonth[i] = gin.H{
|
||||
"label": label,
|
||||
"count": r.Count,
|
||||
"revenue": r.Revenue,
|
||||
}
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"by_day": byDay,
|
||||
"by_week": byWeek,
|
||||
"by_month": byMonth,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
"gestion/utils"
|
||||
"log"
|
||||
"net/http"
|
||||
"slices"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
@@ -39,7 +40,7 @@ func GetDeliveryPersonDetails(c *gin.Context) {
|
||||
if err != nil {
|
||||
log.Printf("❌ [GET_DELIVERY_DETAILS] Livreur non trouvé: %v", err)
|
||||
c.JSON(http.StatusNotFound, gin.H{
|
||||
"error": "Livreur non trouvé",
|
||||
"error": "Livreur non trouvé",
|
||||
})
|
||||
return
|
||||
}
|
||||
@@ -116,22 +117,14 @@ func UpdateDeliveryPersonStatusAdmin(c *gin.Context) {
|
||||
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"error": "Statut requis",
|
||||
"error": "Statut requis",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Valider le statut
|
||||
validStatuses := []string{"available", "busy", "offline"}
|
||||
isValid := false
|
||||
for _, vs := range validStatuses {
|
||||
if req.Status == vs {
|
||||
isValid = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if !isValid {
|
||||
if !slices.Contains(validStatuses, req.Status) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"error": "Statut invalide",
|
||||
"valid_statuses": validStatuses,
|
||||
@@ -160,7 +153,7 @@ func UpdateDeliveryPersonStatusAdmin(c *gin.Context) {
|
||||
if err != nil {
|
||||
log.Printf("❌ [UPDATE_DELIVERY_STATUS] Erreur mise à jour: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"error": "Erreur mise à jour statut",
|
||||
"error": "Erreur mise à jour statut",
|
||||
})
|
||||
return
|
||||
}
|
||||
@@ -321,7 +314,7 @@ func GetDeliveryPersonHistory(c *gin.Context) {
|
||||
if err != nil {
|
||||
log.Printf("❌ [GET_DELIVERY_HISTORY] Erreur récupération: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"error": "Erreur récupération historique",
|
||||
"error": "Erreur récupération historique",
|
||||
})
|
||||
return
|
||||
}
|
||||
@@ -368,7 +361,7 @@ func UpdateDeliveryPersonLocationAdmin(c *gin.Context) {
|
||||
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"error": "Coordonnées GPS requises",
|
||||
"error": "Coordonnées GPS requises",
|
||||
})
|
||||
return
|
||||
}
|
||||
@@ -415,7 +408,7 @@ func UpdateDeliveryPersonLocationAdmin(c *gin.Context) {
|
||||
if err != nil {
|
||||
log.Printf("❌ [UPDATE_DELIVERY_LOCATION] Erreur mise à jour: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"error": "Erreur mise à jour position",
|
||||
"error": "Erreur mise à jour position",
|
||||
})
|
||||
return
|
||||
}
|
||||
@@ -437,12 +430,6 @@ func UpdateDeliveryPersonLocationAdmin(c *gin.Context) {
|
||||
})
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// 🗑️ REMOVE COMMAND FROM QUEUE
|
||||
// ============================================
|
||||
|
||||
// RemoveCommandFromQueue retire une commande de la queue d'un livreur
|
||||
// DELETE /api/v2/admin/protected/delivery-persons/:username/queue/:command_id
|
||||
func RemoveCommandFromQueue(c *gin.Context) {
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
|
||||
@@ -474,9 +461,6 @@ func RemoveCommandFromQueue(c *gin.Context) {
|
||||
|
||||
log.Printf("🗑️ [REMOVE_FROM_QUEUE] Suppression: cmd %d de la queue de %s", commandID, username)
|
||||
|
||||
// ============================================
|
||||
// Vérifier que le livreur existe
|
||||
// ============================================
|
||||
livreur, err := database.GetUserByUsername(username)
|
||||
if err != nil {
|
||||
log.Printf("❌ [REMOVE_FROM_QUEUE] Livreur non trouvé")
|
||||
@@ -491,9 +475,6 @@ func RemoveCommandFromQueue(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Vérifier que la commande existe
|
||||
// ============================================
|
||||
command, err := database.GetCommandByID(commandID)
|
||||
if err != nil {
|
||||
log.Printf("❌ [REMOVE_FROM_QUEUE] Commande non trouvée")
|
||||
@@ -501,19 +482,15 @@ func RemoveCommandFromQueue(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Retirer de la queue
|
||||
// ============================================
|
||||
err = database.RemoveCommandFromDeliverymanQueue(username, commandID)
|
||||
if err != nil {
|
||||
log.Printf("❌ [REMOVE_FROM_QUEUE] Erreur suppression: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"error": "Erreur suppression de la queue",
|
||||
"error": "Erreur suppression de la queue",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Optionnel: Réassigner la commande en "pending"
|
||||
currentStatus, _ := command["status"].(string)
|
||||
if currentStatus == "assigned" || currentStatus == "en_route" {
|
||||
err = database.UpdateCommandStatus(commandID, "pending")
|
||||
|
||||
@@ -1,8 +1,3 @@
|
||||
// ============================================
|
||||
// handlers/eta_handler_corrected.go
|
||||
// CORRECTION: ETA visible UNIQUEMENT après en_route
|
||||
// ============================================
|
||||
|
||||
package handlers
|
||||
|
||||
import (
|
||||
@@ -98,19 +93,32 @@ func GetOrderETA(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// Pour pending: aucune estimation disponible
|
||||
if cmdStatus == "pending" {
|
||||
log.Printf("⏳ [ETA] Commande en attente d'assignation - pas d'ETA")
|
||||
// Pour pending/assigned: pas encore de position livreur disponible
|
||||
if cmdStatus == "pending" || cmdStatus == "assigned" {
|
||||
log.Printf("⏳ [ETA] Commande %s - pas d'ETA disponible", cmdStatus)
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"command_id": commandID,
|
||||
"status": cmdStatus,
|
||||
"eta_available": false,
|
||||
"message": "En attente d'assignation d'un livreur",
|
||||
"message": "En attente de démarrage de la livraison",
|
||||
})
|
||||
return
|
||||
}
|
||||
// Pour assigned/en_route/arrived: calcul ETA réel via position du livreur
|
||||
|
||||
// Pour arrived: livreur sur place, ETA non pertinent
|
||||
if cmdStatus == "arrived" {
|
||||
log.Printf("ℹ️ [ETA] Commande arrived - livreur déjà sur place")
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"command_id": commandID,
|
||||
"status": cmdStatus,
|
||||
"eta_available": false,
|
||||
"message": "Le livreur est arrivé à destination",
|
||||
})
|
||||
return
|
||||
}
|
||||
// Pour en_route: calcul ETA réel via position du livreur
|
||||
|
||||
// 6️⃣ VÉRIFIER LE CACHE REDIS POUR ETA
|
||||
etaKey := fmt.Sprintf("command:eta:%d", commandID)
|
||||
|
||||
@@ -1,7 +1,3 @@
|
||||
// ============================================
|
||||
// handlers/geo_handlers.go - VERSION CORRIGÉE COMPLÈTE
|
||||
// ============================================
|
||||
|
||||
package handlers
|
||||
|
||||
import (
|
||||
@@ -17,10 +13,6 @@ import (
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// ============================================
|
||||
// GÉOCODAGE D'ADRESSES
|
||||
// ============================================
|
||||
|
||||
func GeocodeAddress(c *gin.Context) {
|
||||
geoService := c.MustGet("geoService").(*services.GeoService)
|
||||
|
||||
@@ -34,19 +26,40 @@ func GeocodeAddress(c *gin.Context) {
|
||||
|
||||
location, err := geoService.GeocodeAddress(req.Address)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Impossible de géocoder cette adresse"})
|
||||
// Tentative de correction — resolveAddress ne touche pas à c.JSON
|
||||
suggestion, err := resolveAddress(geoService, req.Address)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Adresse introuvable, vérifiez l'orthographe"})
|
||||
return
|
||||
}
|
||||
log.Printf("✅ Adresse corrigée: '%s' → '%s' (confiance %.0f%%)",
|
||||
req.Address, suggestion.CorrectedAddress, suggestion.Confidence*100)
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"latitude": suggestion.Coordinates.Latitude,
|
||||
"longitude": suggestion.Coordinates.Longitude,
|
||||
"display_name": suggestion.CorrectedAddress,
|
||||
"correction_applied": suggestion.CorrectionApplied,
|
||||
"confidence": suggestion.Confidence,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("📍 Adresse géocodée: %s -> (%.6f, %.6f)", req.Address, location.Latitude, location.Longitude)
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"latitude": location.Latitude,
|
||||
"longitude": location.Longitude,
|
||||
"display_name": location.DisplayName,
|
||||
"success": true,
|
||||
"latitude": location.Latitude,
|
||||
"longitude": location.Longitude,
|
||||
"display_name": location.DisplayName,
|
||||
"correction_applied": false,
|
||||
})
|
||||
}
|
||||
|
||||
// resolveAddress : logique pure, sans toucher à gin.Context
|
||||
func resolveAddress(geoService *services.GeoService, address string) (*services.AddressSuggestion, error) {
|
||||
return geoService.CorrectionService().ResolveAddress(address)
|
||||
}
|
||||
|
||||
// FindNearestDeliveryPerson trouve le livreur le plus proche d'une adresse
|
||||
func FindNearestDeliveryPerson(c *gin.Context) {
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
@@ -305,9 +318,6 @@ func AutoAssignNearestDeliveryPerson(c *gin.Context) {
|
||||
|
||||
log.Printf("📍 Adresse géocodée: %s -> (%.6f, %.6f)", address, location.Latitude, location.Longitude)
|
||||
|
||||
// ============================================
|
||||
// 🔹 SAUVEGARDER LES COORDONNÉES DANS LE CACHE REDIS
|
||||
// ============================================
|
||||
destCacheKey := fmt.Sprintf("command:destination:%d", commandID)
|
||||
coordsJSON, _ := json.Marshal(map[string]float64{
|
||||
"lat": location.Latitude,
|
||||
@@ -537,10 +547,6 @@ func AutoAssignNearestDeliveryPerson(c *gin.Context) {
|
||||
})
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// ASSIGNATION EN MASSE (TOUTES LES COMMANDES PENDING)
|
||||
// ============================================
|
||||
|
||||
// AutoAssignAllPendingCommands assigne toutes les commandes en attente
|
||||
// POST /api/v2/admin/protected/commands/auto-assign-all
|
||||
func AutoAssignAllPendingCommands(c *gin.Context) {
|
||||
@@ -698,10 +704,6 @@ func AutoAssignAllPendingCommands(c *gin.Context) {
|
||||
})
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// RÉCUPÉRER L'ÉTAT DES QUEUES DES LIVREURS
|
||||
// ============================================
|
||||
|
||||
// GetAllDeliveryQueues retourne l'état de toutes les queues des livreurs
|
||||
// GET /api/v2/admin/protected/delivery/queues
|
||||
func GetAllDeliveryQueues(c *gin.Context) {
|
||||
@@ -751,12 +753,6 @@ func GetAllDeliveryQueues(c *gin.Context) {
|
||||
})
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// RÉCUPÉRER LA QUEUE D'UN LIVREUR SPÉCIFIQUE
|
||||
// ============================================
|
||||
|
||||
// GetDeliverymanQueue retourne la queue d'un livreur spécifique
|
||||
// GET /api/v2/admin/protected/delivery/:username/queue
|
||||
func GetDeliverymanQueue(c *gin.Context) {
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
|
||||
|
||||
@@ -13,7 +13,6 @@ import (
|
||||
"net/url"
|
||||
"strconv"
|
||||
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
@@ -79,56 +78,6 @@ func GetDeliveryPersonMapLinks(c *gin.Context) {
|
||||
})
|
||||
}
|
||||
|
||||
// GetCommandNavigationLinks génère les liens de navigation pour une commande
|
||||
// GET /api/v2/admin/protected/commands/:id/navigation-links
|
||||
func GetCommandNavigationLinks(c *gin.Context) {
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
|
||||
userRole := c.GetString("role")
|
||||
if userRole != "admin" {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "Accès refusé"})
|
||||
return
|
||||
}
|
||||
|
||||
commandID, err := strconv.Atoi(c.Param("id"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "ID de commande invalide"})
|
||||
return
|
||||
}
|
||||
|
||||
// Récupérer la commande
|
||||
command, err := database.GetCommandByID(commandID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Commande non trouvée"})
|
||||
return
|
||||
}
|
||||
|
||||
// Vérifier qu'un livreur est assigné
|
||||
livreurAssign, ok := command["livreur_assign"].(string)
|
||||
if !ok || livreurAssign == "" {
|
||||
c.JSON(http.StatusNotFound, gin.H{
|
||||
"error": "Aucun livreur assigné à cette commande",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Générer les liens de navigation
|
||||
links, err := database.GenerateMapLinksForCommand(commandID, livreurAssign)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"error": "Erreur génération des liens",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"command_id": commandID,
|
||||
"deliveryman": livreurAssign,
|
||||
"navigation_links": links,
|
||||
})
|
||||
}
|
||||
|
||||
// GetLivreurNavLink retourne le lien Waze App pour une livraison assignée au livreur connecté
|
||||
// GET /api/v1/livreur/deliveries/:id/nav-link
|
||||
func GetLivreurNavLink(c *gin.Context) {
|
||||
|
||||
@@ -1,8 +1,3 @@
|
||||
// ============================================
|
||||
// handlers/history_handlers.go
|
||||
// ============================================
|
||||
// Gestion de l'historique des commandes terminées
|
||||
|
||||
package handlers
|
||||
|
||||
import (
|
||||
@@ -12,14 +7,9 @@ import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// GetMyCompletedOrders récupère l'historique des commandes terminées du client
|
||||
// GET /api/v1/my-commands/history
|
||||
// ✅ Authentification requise (ClientMiddleware)
|
||||
// ✅ Retourne uniquement les commandes avec status = "approved"
|
||||
func GetMyCompletedOrders(c *gin.Context) {
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
|
||||
@@ -41,7 +31,7 @@ func GetMyCompletedOrders(c *gin.Context) {
|
||||
if err != nil {
|
||||
log.Printf("❌ [HISTORY] Erreur: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"error": "Erreur lors de la récupération de l'historique",
|
||||
"error": "Erreur lors de la récupération de l'historique",
|
||||
})
|
||||
return
|
||||
}
|
||||
@@ -96,8 +86,6 @@ func GetMyCompletedOrders(c *gin.Context) {
|
||||
|
||||
// GetMyCompletedOrdersWithItems récupère l'historique avec les détails des items
|
||||
// GET /api/v1/my-commands/history/detailed
|
||||
// ✅ Authentification requise (ClientMiddleware)
|
||||
// ✅ Retourne les commandes approved avec tous les items
|
||||
func GetMyCompletedOrdersWithItems(c *gin.Context) {
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
|
||||
@@ -119,13 +107,13 @@ func GetMyCompletedOrdersWithItems(c *gin.Context) {
|
||||
if err != nil {
|
||||
log.Printf("❌ [HISTORY_DETAILED] Erreur: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"error": "Erreur lors de la récupération de l'historique",
|
||||
"error": "Erreur lors de la récupération de l'historique",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// ✅ Enrichir chaque commande avec ses items
|
||||
var enrichedCommands []map[string]interface{}
|
||||
var enrichedCommands []map[string]any
|
||||
|
||||
for _, command := range commands {
|
||||
commandID, _ := strconv.Atoi(fmt.Sprintf("%v", command["id"]))
|
||||
@@ -137,11 +125,11 @@ func GetMyCompletedOrdersWithItems(c *gin.Context) {
|
||||
items, err := database.GetCommandItems(commandID)
|
||||
if err != nil {
|
||||
log.Printf("⚠️ [HISTORY_DETAILED] Erreur items pour cmd %d: %v", commandID, err)
|
||||
items = []map[string]interface{}{}
|
||||
items = []map[string]any{}
|
||||
}
|
||||
|
||||
// Ajouter les items à la commande
|
||||
enrichedCommand := make(map[string]interface{})
|
||||
enrichedCommand := make(map[string]any)
|
||||
for k, v := range command {
|
||||
enrichedCommand[k] = v
|
||||
}
|
||||
@@ -177,10 +165,6 @@ func GetMyCompletedOrdersWithItems(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, response)
|
||||
}
|
||||
|
||||
// GetOrderHistory récupère l'historique d'une commande spécifique avec logs
|
||||
// GET /api/v1/commands/:id/history
|
||||
// ✅ Authentification requise
|
||||
// ✅ Vérifie que la commande appartient au client
|
||||
func GetOrderHistory(c *gin.Context) {
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
|
||||
|
||||
@@ -1,7 +1,3 @@
|
||||
// ============================================
|
||||
// handlers/basket_handlers_CORRIGES.go
|
||||
// ============================================
|
||||
|
||||
package handlers
|
||||
|
||||
import (
|
||||
@@ -12,6 +8,8 @@ import (
|
||||
"gestion/utils"
|
||||
"log"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
@@ -48,41 +46,38 @@ func AddProductsBasket(c *gin.Context) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Quantité invalide"})
|
||||
return
|
||||
}
|
||||
|
||||
// Si product_id fourni par le mobile, on l'utilise directement (plus fiable)
|
||||
if req.ProductID > 0 || req.NameProduct == "" || req.Category == "" {
|
||||
stock, err := database.GetProductStockByID(req.ProductID)
|
||||
if err != nil {
|
||||
log.Printf("❌ [ADD_PANIER] Produit %d non trouvé: %v", req.ProductID, err)
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Produit non trouvé"})
|
||||
return
|
||||
}
|
||||
if stock < req.Quantity {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Stock insuffisant", "available": stock})
|
||||
return
|
||||
}
|
||||
if err := database.DecrementProductStockByID(req.ProductID, req.Quantity); err != nil {
|
||||
log.Printf("❌ [ADD_PANIER] Erreur décrement stock product_id=%d: %v", req.ProductID, err)
|
||||
utils.ServerErr(c, "Impossible d'ajouter le produit au panier", err)
|
||||
return
|
||||
}
|
||||
panier, err := database.AddProductInBasketByID(req.Username, req.ProductID, req.Quantity)
|
||||
if err != nil {
|
||||
log.Printf("❌ [ADD_PANIER] Erreur ajout product_id=%d qty=%.3f: %v", req.ProductID, req.Quantity, err)
|
||||
utils.ServerErr(c, "Impossible d'ajouter le produit au panier", err)
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "message": "Produit ajouté au panier avec succès", "panier": panier})
|
||||
if req.ProductID <= 0 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "product_id requis"})
|
||||
return
|
||||
}
|
||||
|
||||
if p, err := database.GetProductByID(req.ProductID); err == nil && p.ComingSoon {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Ce produit n'est pas encore disponible"})
|
||||
return
|
||||
}
|
||||
|
||||
panier, err := database.AddToBasket(req.Username, req.ProductID, req.Quantity)
|
||||
if err != nil {
|
||||
log.Printf("❌ [ADD_PANIER] product_id=%d qty=%.3f: %v", req.ProductID, req.Quantity, err)
|
||||
if err.Error() == "stock insuffisant" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Stock insuffisant"})
|
||||
return
|
||||
}
|
||||
if strings.Contains(err.Error(), "prix introuvable") {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Aucun prix configuré pour ce produit"})
|
||||
return
|
||||
}
|
||||
utils.ServerErr(c, "Impossible d'ajouter le produit au panier", err)
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"message": "Produit ajouté au panier avec succès",
|
||||
"panier": panier,
|
||||
})
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// ============================================
|
||||
// GET /api/v1/panier/:username
|
||||
// Récupère le panier du client authentifié
|
||||
func GetAllBaskets(c *gin.Context) {
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
username := c.Param("username")
|
||||
@@ -149,11 +144,6 @@ func GetAllBaskets(c *gin.Context) {
|
||||
})
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// ✅ SÉCURISÉ: DeleteProductFromBasket
|
||||
// ============================================
|
||||
// DELETE /api/v1/panier/remove
|
||||
// Supprime un produit du panier
|
||||
func DeleteProductFromBasket(c *gin.Context) {
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
|
||||
@@ -206,18 +196,12 @@ func DeleteProductFromBasket(c *gin.Context) {
|
||||
|
||||
log.Printf("✅ [DEL_PANIER] Article %d supprimé", req.ID)
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"message": "Produit supprimé du panier avec succès",
|
||||
"item_id": req.ID,
|
||||
"stock_released": true,
|
||||
"success": true,
|
||||
"message": "Produit supprimé du panier avec succès",
|
||||
"item_id": req.ID,
|
||||
})
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// ✅ SÉCURISÉ: ClearBasket
|
||||
// ============================================
|
||||
// DELETE /api/v1/panier/clear
|
||||
// Vide le panier du client
|
||||
func ClearBasket(c *gin.Context) {
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
|
||||
@@ -250,9 +234,8 @@ func ClearBasket(c *gin.Context) {
|
||||
|
||||
log.Printf("✅ [CLEAR_PANIER] Panier %s vidé: %d articles supprimés", authUsernameStr, len(baskets))
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"message": "Panier vidé avec succès",
|
||||
"stock_released": len(baskets),
|
||||
"success": true,
|
||||
"message": "Panier vidé avec succès",
|
||||
})
|
||||
}
|
||||
|
||||
@@ -268,6 +251,14 @@ func ValidateBasket(c *gin.Context) {
|
||||
}
|
||||
usernameStr := username.(string)
|
||||
|
||||
lockKey := fmt.Sprintf("checkout_lock:%s", usernameStr)
|
||||
locked, errLock := db.Redis.SetNX(db.RedisCtx, lockKey, "1", 30*time.Second).Result()
|
||||
if errLock != nil || !locked {
|
||||
c.JSON(http.StatusConflict, gin.H{"error": "Un checkout est déjà en cours pour ce compte"})
|
||||
return
|
||||
}
|
||||
defer db.Redis.Del(db.RedisCtx, lockKey)
|
||||
|
||||
var req struct {
|
||||
DeliveryAddress string `json:"delivery_address" binding:"required"`
|
||||
UseReferralBalance bool `json:"use_referral_balance"`
|
||||
@@ -314,7 +305,7 @@ func ValidateBasket(c *gin.Context) {
|
||||
log.Printf("🛒 [CHECKOUT] Panier: %d articles", len(items))
|
||||
|
||||
// ============================================
|
||||
// 1️⃣b Vérifier le minimum de commande selon la zone
|
||||
// 1️⃣b Calculer le total et vérifier la présence d'au moins un article payant
|
||||
// ============================================
|
||||
var cartTotal float64
|
||||
for _, item := range items {
|
||||
@@ -323,6 +314,21 @@ func ValidateBasket(c *gin.Context) {
|
||||
}
|
||||
}
|
||||
|
||||
// Détecter si le panier contient un article récompense (prix 0)
|
||||
hasRewardItem := false
|
||||
for _, item := range items {
|
||||
if price, ok := item["price"].(float64); ok && price == 0 {
|
||||
hasRewardItem = true
|
||||
break
|
||||
}
|
||||
}
|
||||
// Si récompense présente mais aucun produit payant → refuser
|
||||
if hasRewardItem && cartTotal <= 0 {
|
||||
log.Printf("❌ [CHECKOUT] Panier contient uniquement des récompenses pour %s", usernameStr)
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Vous devez commander au moins un produit de la boutique pour bénéficier de votre récompense"})
|
||||
return
|
||||
}
|
||||
|
||||
// Récupérer les paramètres globaux (zones + parrainage)
|
||||
appSettings, _ := database.GetSettings()
|
||||
|
||||
@@ -384,9 +390,6 @@ func ValidateBasket(c *gin.Context) {
|
||||
|
||||
log.Printf("✅ [CHECKOUT] Zone OK: %s, total=%.2f€ >= %.2f€, crédit parrainage utilisé: %.2f€", zoneResult.ZoneName, cartTotal, zoneResult.MinAmount, referralUsed)
|
||||
|
||||
// ============================================
|
||||
// 2️⃣ Débiter le parrainage AVANT la commande (évite double-spend)
|
||||
// ============================================
|
||||
if referralUsed > 0 {
|
||||
if err := database.DebitReferralBalance(usernameStr, referralUsed); err != nil {
|
||||
log.Printf("❌ [CHECKOUT] Solde parrainage insuffisant pour %s: %v", usernameStr, err)
|
||||
@@ -396,6 +399,21 @@ func ValidateBasket(c *gin.Context) {
|
||||
log.Printf("✅ [CHECKOUT] Crédit parrainage -%.2f€ débité pour %s", referralUsed, usernameStr)
|
||||
}
|
||||
|
||||
// Vérifier que tous les produits du panier ont encore un prix actif
|
||||
unavailable, err := database.GetUnavailableBasketItems(usernameStr)
|
||||
if err != nil {
|
||||
utils.ServerErr(c, "Erreur vérification produits", err)
|
||||
return
|
||||
}
|
||||
if len(unavailable) > 0 {
|
||||
log.Printf("❌ [CHECKOUT] Produits sans prix actif: %v", unavailable)
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"error": "Certains produits de votre panier ne sont plus disponibles",
|
||||
"products": unavailable,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Vérification option crypto
|
||||
isCrypto := req.PaymentMethod == "crypto"
|
||||
if isCrypto {
|
||||
@@ -429,9 +447,7 @@ func ValidateBasket(c *gin.Context) {
|
||||
|
||||
log.Printf("✅ [CHECKOUT] Commande %d créée", commandID)
|
||||
|
||||
// ============================================
|
||||
// PAIEMENT CRYPTO - créer le paiement NowPayments
|
||||
// ============================================
|
||||
// Pour le paiement crypto, le panier/stock sera décrémenté à la confirmation du webhook NowPayments.
|
||||
if isCrypto {
|
||||
np := c.MustGet("nowpayments").(*services.NowPaymentsClient)
|
||||
ipnURL := fmt.Sprintf("%s/api/v1/webhooks/nowpayments", getBaseURL(c))
|
||||
@@ -483,14 +499,24 @@ func ValidateBasket(c *gin.Context) {
|
||||
go database.NotifyAllAdminCabine(commandID, usernameStr, req.DeliveryAddress)
|
||||
|
||||
// ============================================
|
||||
// 3️⃣ Vider le panier (sans restituer le stock — déjà déduit à l'ajout)
|
||||
// 3️⃣ Décrémenter le stock et vider le panier
|
||||
// ============================================
|
||||
err = database.ClearBasketOnCheckout(usernameStr)
|
||||
if err != nil {
|
||||
utils.ServerErr(c, "Impossible de vider le panier", err)
|
||||
// Stock insuffisant au moment du checkout (concurrent) → annuler la commande
|
||||
if strings.Contains(err.Error(), "stock insuffisant") {
|
||||
_ = database.CancelCryptoCommand(commandID)
|
||||
if referralUsed > 0 {
|
||||
_ = database.CreditClientReferral(usernameStr, referralUsed)
|
||||
}
|
||||
log.Printf("❌ [CHECKOUT] Stock insuffisant au moment de la validation: %v", err)
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Désolé ! Tu as trop attendu pour passer commande! Le stock ou le produit n'est plus disponible, repasse commande"})
|
||||
return
|
||||
}
|
||||
utils.ServerErr(c, "Impossible de valider le panier", err)
|
||||
return
|
||||
}
|
||||
log.Printf("🧹 [CHECKOUT] Panier vidé")
|
||||
log.Printf("🧹 [CHECKOUT] Stock décrémenté et panier vidé")
|
||||
|
||||
// ============================================
|
||||
// 4️⃣ Auto-assignation livreur (optionnel)
|
||||
|
||||
@@ -0,0 +1,254 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"gestion/db"
|
||||
"gestion/utils"
|
||||
"log"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// GetMyPointsRewards retourne les points et les récompenses disponibles du client connecté.
|
||||
// La récompense est globale : son seuil s'applique indépendamment à chaque pool.
|
||||
func GetMyPointsRewards(c *gin.Context) {
|
||||
username := c.GetString("username")
|
||||
if username == "" {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Non authentifié"})
|
||||
return
|
||||
}
|
||||
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
|
||||
settings, err := database.GetSettings()
|
||||
if err != nil {
|
||||
utils.ServerErr(c, "Erreur lecture paramètres", err)
|
||||
return
|
||||
}
|
||||
|
||||
if !settings.PointsEnabled || len(settings.PointsPools) == 0 {
|
||||
c.JSON(http.StatusOK, gin.H{"enabled": false, "pools": []gin.H{}, "reward": nil})
|
||||
return
|
||||
}
|
||||
|
||||
pointsExtra, pointsRedeemed, err := database.GetClientPointsAndRewards(username)
|
||||
if err != nil {
|
||||
utils.ServerErr(c, "Erreur lecture points", err)
|
||||
return
|
||||
}
|
||||
|
||||
reward := settings.PointsReward
|
||||
|
||||
type EligibleConfigResponse struct {
|
||||
Category string `json:"category"`
|
||||
AllProducts bool `json:"all_products"`
|
||||
ProductIDs []int `json:"product_ids"`
|
||||
ProductNames []string `json:"product_names"`
|
||||
}
|
||||
|
||||
type PoolInfo struct {
|
||||
Key string `json:"key"`
|
||||
Name string `json:"name"`
|
||||
Points int `json:"points"`
|
||||
RewardsEarned int `json:"rewards_earned"`
|
||||
RewardsClaimed int `json:"rewards_claimed"`
|
||||
RewardsAvailable int `json:"rewards_available"`
|
||||
EligibleConfigs []EligibleConfigResponse `json:"eligible_configs"`
|
||||
}
|
||||
|
||||
// Collecter tous les product_ids nécessaires en un seul passage
|
||||
allProductIDs := make([]int, 0)
|
||||
if reward != nil {
|
||||
for _, cfg := range reward.CategoryConfigs {
|
||||
if !cfg.AllProducts {
|
||||
allProductIDs = append(allProductIDs, cfg.ProductIDs...)
|
||||
}
|
||||
}
|
||||
for _, item := range reward.RewardItems {
|
||||
if item.ProductID > 0 {
|
||||
allProductIDs = append(allProductIDs, item.ProductID)
|
||||
}
|
||||
}
|
||||
}
|
||||
productNames, _ := database.GetProductNamesByIDs(allProductIDs)
|
||||
|
||||
pools := make([]PoolInfo, 0, len(settings.PointsPools))
|
||||
for _, pool := range settings.PointsPools {
|
||||
pts := pointsExtra[pool.Key]
|
||||
redeemed := pointsRedeemed[pool.Key]
|
||||
|
||||
var earned, available int
|
||||
if reward != nil && reward.Threshold > 0 {
|
||||
earned = pts / reward.Threshold
|
||||
available = earned - redeemed
|
||||
if available < 0 {
|
||||
available = 0
|
||||
}
|
||||
}
|
||||
|
||||
// Filtrer les category_configs aux seules catégories du pool
|
||||
poolCats := make(map[string]bool, len(pool.Categories))
|
||||
for _, c := range pool.Categories {
|
||||
poolCats[c] = true
|
||||
}
|
||||
eligibleConfigs := make([]EligibleConfigResponse, 0)
|
||||
if reward != nil {
|
||||
for _, cfg := range reward.CategoryConfigs {
|
||||
if !poolCats[cfg.Category] {
|
||||
continue
|
||||
}
|
||||
names := make([]string, 0, len(cfg.ProductIDs))
|
||||
for _, pid := range cfg.ProductIDs {
|
||||
if n, ok := productNames[pid]; ok {
|
||||
names = append(names, n)
|
||||
}
|
||||
}
|
||||
eligibleConfigs = append(eligibleConfigs, EligibleConfigResponse{
|
||||
Category: cfg.Category,
|
||||
AllProducts: cfg.AllProducts,
|
||||
ProductIDs: cfg.ProductIDs,
|
||||
ProductNames: names,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
pools = append(pools, PoolInfo{
|
||||
Key: pool.Key,
|
||||
Name: pool.Name,
|
||||
Points: pts,
|
||||
RewardsEarned: earned,
|
||||
RewardsClaimed: redeemed,
|
||||
RewardsAvailable: available,
|
||||
EligibleConfigs: eligibleConfigs,
|
||||
})
|
||||
}
|
||||
|
||||
// Construire la liste des produits récompense avec leurs noms
|
||||
type RewardItemResponse struct {
|
||||
ProductID int `json:"product_id"`
|
||||
ProductName string `json:"product_name"`
|
||||
Quantity float64 `json:"quantity"`
|
||||
Price float64 `json:"price"`
|
||||
}
|
||||
var rewardMeta gin.H
|
||||
if reward != nil {
|
||||
rewardItems := make([]RewardItemResponse, 0, len(reward.RewardItems))
|
||||
for _, item := range reward.RewardItems {
|
||||
if item.ProductID <= 0 {
|
||||
continue
|
||||
}
|
||||
name := productNames[item.ProductID]
|
||||
rewardItems = append(rewardItems, RewardItemResponse{
|
||||
ProductID: item.ProductID,
|
||||
ProductName: name,
|
||||
Quantity: item.Quantity,
|
||||
Price: item.Price,
|
||||
})
|
||||
}
|
||||
rewardMeta = gin.H{
|
||||
"threshold": reward.Threshold,
|
||||
"type": reward.Type,
|
||||
"description": reward.Description,
|
||||
"reward_items": rewardItems,
|
||||
}
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"enabled": true, "pools": pools, "reward": rewardMeta})
|
||||
}
|
||||
|
||||
// ClaimMyReward réclame une récompense sur un pool donné si le client a atteint le seuil.
|
||||
func ClaimMyReward(c *gin.Context) {
|
||||
username := c.GetString("username")
|
||||
if username == "" {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Non authentifié"})
|
||||
return
|
||||
}
|
||||
|
||||
var req struct {
|
||||
PoolKey string `json:"pool_key" binding:"required"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "pool_key requis"})
|
||||
return
|
||||
}
|
||||
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
|
||||
settings, err := database.GetSettings()
|
||||
if err != nil {
|
||||
utils.ServerErr(c, "Erreur lecture paramètres", err)
|
||||
return
|
||||
}
|
||||
|
||||
if !settings.PointsEnabled {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "Système de points désactivé"})
|
||||
return
|
||||
}
|
||||
|
||||
reward := settings.PointsReward
|
||||
if reward == nil || reward.Threshold <= 0 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Aucune récompense configurée"})
|
||||
return
|
||||
}
|
||||
|
||||
// Vérifier que le pool existe
|
||||
poolExists := false
|
||||
for _, p := range settings.PointsPools {
|
||||
if p.Key == req.PoolKey {
|
||||
poolExists = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !poolExists {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Pool introuvable"})
|
||||
return
|
||||
}
|
||||
|
||||
remaining, err := database.ClaimPoolReward(username, req.PoolKey, reward.Threshold)
|
||||
if err != nil {
|
||||
if strings.Contains(err.Error(), "pas de récompense disponible") {
|
||||
c.JSON(http.StatusConflict, gin.H{"error": "Pas assez de points pour réclamer une récompense"})
|
||||
return
|
||||
}
|
||||
utils.ServerErr(c, "Erreur réclamation récompense", err)
|
||||
return
|
||||
}
|
||||
|
||||
// Ajouter les produits récompense au panier si configurés
|
||||
productAdded := false
|
||||
var productNames []string
|
||||
if len(reward.RewardItems) > 0 {
|
||||
if added, addErr := database.AddRewardsToBasket(username, reward.RewardItems, req.PoolKey); addErr == nil && len(added) > 0 {
|
||||
productAdded = true
|
||||
for _, item := range added {
|
||||
productNames = append(productNames, item.ProductName)
|
||||
}
|
||||
log.Printf("✅ [CLAIM] %d produit(s) récompense ajoutés au panier de %s", len(added), username)
|
||||
} else if addErr != nil {
|
||||
log.Printf("⚠️ [CLAIM] Impossible d'ajouter produits récompense: %v", addErr)
|
||||
}
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"description": reward.Description,
|
||||
"remaining_rewards": remaining,
|
||||
"product_added": productAdded,
|
||||
"product_names": productNames,
|
||||
})
|
||||
}
|
||||
|
||||
// AdminResetClientRedeemed remet à zéro les récompenses réclamées d'un client (admin).
|
||||
func AdminResetClientRedeemed(c *gin.Context) {
|
||||
username := c.Param("username")
|
||||
poolKey := c.Query("pool_key")
|
||||
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
if err := database.ResetClientRedeemed(username, poolKey); err != nil {
|
||||
utils.ServerErr(c, "Erreur reset récompenses", err)
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"success": true})
|
||||
}
|
||||
@@ -17,10 +17,6 @@ import (
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// ============================================
|
||||
// CONFIGURATION & LIMITES
|
||||
// ============================================
|
||||
|
||||
const (
|
||||
MaxFileSize = 10 * 1024 * 1024 // 10MB par fichier
|
||||
MaxTotalUploadSize = 50 * 1024 * 1024 // 50MB total
|
||||
@@ -30,7 +26,6 @@ const (
|
||||
MaxProductsPerUser = 100 // Limite pour éviter spam
|
||||
)
|
||||
|
||||
// ✅ MIME types autorisés (vérification réelle du contenu)
|
||||
var allowedMimeTypes = map[string]bool{
|
||||
"image/jpeg": true,
|
||||
"image/png": true,
|
||||
@@ -41,28 +36,6 @@ var allowedMimeTypes = map[string]bool{
|
||||
"video/quicktime": true,
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// MIDDLEWARE D'AUTHORIZATION
|
||||
// ============================================
|
||||
|
||||
func RequireAdminOrCabine() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
role := c.GetString("role")
|
||||
if role != "admin" && role != "cabine" {
|
||||
c.JSON(http.StatusForbidden, gin.H{
|
||||
"error": "Accès refusé - Admin ou Cabine requis",
|
||||
})
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// HELPERS DE VALIDATION
|
||||
// ============================================
|
||||
|
||||
func validateProductName(name string) error {
|
||||
if len(name) == 0 {
|
||||
return fmt.Errorf("nom requis")
|
||||
@@ -187,10 +160,6 @@ func sanitizeFilePath(path string) (string, error) {
|
||||
return cleaned, nil
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// CREATE PRODUCT - VERSION SÉCURISÉE
|
||||
// ============================================
|
||||
|
||||
func CreateProduct(c *gin.Context) {
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
|
||||
@@ -269,6 +238,7 @@ func CreateProduct(c *gin.Context) {
|
||||
for priceIndex < 100 { // Limite anti-spam
|
||||
quantityKey := fmt.Sprintf("prices[%d][quantity]", priceIndex)
|
||||
priceKey := fmt.Sprintf("prices[%d][price]", priceIndex)
|
||||
activePriceKey := fmt.Sprintf("prices[%d][active_price]", priceIndex)
|
||||
|
||||
quantityStr := c.PostForm(quantityKey)
|
||||
priceStr := c.PostForm(priceKey)
|
||||
@@ -294,9 +264,13 @@ func CreateProduct(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
activePriceStr := c.PostForm(activePriceKey)
|
||||
activePrice := activePriceStr != "false"
|
||||
|
||||
prices = append(prices, models.ProductPrice{
|
||||
Quantity: quantity,
|
||||
Price: price,
|
||||
Quantity: quantity,
|
||||
Price: price,
|
||||
ActivePrice: activePrice,
|
||||
})
|
||||
|
||||
priceIndex++
|
||||
@@ -309,6 +283,8 @@ func CreateProduct(c *gin.Context) {
|
||||
|
||||
log.Printf("✅ [CreateProduct] %s crée produit: %s", username, name)
|
||||
|
||||
comingSoon := c.PostForm("coming_soon") == "true"
|
||||
|
||||
// ✅ CRÉER LE PRODUIT
|
||||
product := models.Product{
|
||||
Name: name,
|
||||
@@ -316,6 +292,7 @@ func CreateProduct(c *gin.Context) {
|
||||
Description: description,
|
||||
Stock: stock,
|
||||
Unit: unit,
|
||||
ComingSoon: comingSoon,
|
||||
Prices: prices,
|
||||
}
|
||||
|
||||
@@ -415,7 +392,7 @@ func CreateProduct(c *gin.Context) {
|
||||
|
||||
// ✅ CRÉER LE DOSSIER DE MANIÈRE SÉCURISÉE
|
||||
destFolder := filepath.Join("uploads", mediaType+"s")
|
||||
if err := os.MkdirAll(destFolder, 0755); err != nil {
|
||||
if err := os.MkdirAll(destFolder, 0750); err != nil {
|
||||
log.Printf("❌ [CreateProduct] Erreur création dossier: %v", err)
|
||||
rollbackFiles(savedFiles)
|
||||
database.DeleteProduct(product.ID)
|
||||
@@ -475,10 +452,6 @@ func CreateProduct(c *gin.Context) {
|
||||
})
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// GET ENDPOINTS - SÉCURISÉS (lecture publique OK)
|
||||
// ============================================
|
||||
|
||||
func GetAllProducts(c *gin.Context) {
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
|
||||
@@ -491,7 +464,10 @@ func GetAllProducts(c *gin.Context) {
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
role := c.GetString("role")
|
||||
if role != "admin" && role != "cabine" {
|
||||
products = filterActivePrices(products)
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"data": products,
|
||||
@@ -528,7 +504,10 @@ func GetProductsByCategory(c *gin.Context) {
|
||||
media, _ := database.GetMediaByProductID(products[i].ID)
|
||||
products[i].Media = media
|
||||
}
|
||||
|
||||
roleCtx := c.GetString("role")
|
||||
if roleCtx != "admin" && roleCtx != "cabine" {
|
||||
products = filterActivePrices(products)
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"data": products,
|
||||
@@ -538,7 +517,6 @@ func GetProductsByCategory(c *gin.Context) {
|
||||
|
||||
func GetProductByID(c *gin.Context) {
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
|
||||
id, err := strconv.Atoi(c.Param("id"))
|
||||
if err != nil || id <= 0 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
@@ -547,7 +525,6 @@ func GetProductByID(c *gin.Context) {
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
product, err := database.GetProductByID(id)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{
|
||||
@@ -556,21 +533,22 @@ func GetProductByID(c *gin.Context) {
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// ✅ Charger les médias
|
||||
media, _ := database.GetMediaByProductID(product.ID)
|
||||
product.Media = media
|
||||
|
||||
// ✅ Filtrer les prix désactivés (sauf pour admin/cabine)
|
||||
role := c.GetString("role")
|
||||
if role != "admin" && role != "cabine" {
|
||||
filterActivepricesSingle(&product)
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"data": product,
|
||||
})
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// UPDATE PRODUCT - VERSION SÉCURISÉE
|
||||
// ============================================
|
||||
|
||||
func UpdateProduct(c *gin.Context) {
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
|
||||
@@ -600,9 +578,10 @@ func UpdateProduct(c *gin.Context) {
|
||||
Name string `json:"name"`
|
||||
Category string `json:"category"`
|
||||
Description string `json:"description"`
|
||||
Stock float64 `json:"stock"`
|
||||
Unit string `json:"unit"`
|
||||
Prices []models.ProductPrice `json:"prices"`
|
||||
Stock *float64 `json:"stock"`
|
||||
ComingSoon *bool `json:"coming_soon"`
|
||||
}
|
||||
|
||||
if err := c.ShouldBindJSON(&updateData); err != nil {
|
||||
@@ -629,12 +608,8 @@ func UpdateProduct(c *gin.Context) {
|
||||
if updateData.Unit == "" {
|
||||
updateData.Unit = "u"
|
||||
}
|
||||
if err := validateUnit(updateData.Unit); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
if err := validateStock(updateData.Stock); err != nil {
|
||||
if err := validateUnit(updateData.Unit); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
@@ -651,14 +626,32 @@ func UpdateProduct(c *gin.Context) {
|
||||
}
|
||||
}
|
||||
|
||||
if updateData.Stock != nil {
|
||||
if err := validateStock(*updateData.Stock); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
log.Printf("🔄 [UpdateProduct] %s met à jour produit #%d", username, id)
|
||||
|
||||
if err := database.UpdateProduct(id, updateData.Name, updateData.Category, updateData.Description, updateData.Unit, updateData.Stock, updateData.Prices); err != nil {
|
||||
comingSoon := false
|
||||
if updateData.ComingSoon != nil {
|
||||
comingSoon = *updateData.ComingSoon
|
||||
}
|
||||
|
||||
if err := database.UpdateProduct(id, updateData.Name, updateData.Category, updateData.Description, updateData.Unit, comingSoon, updateData.Prices); err != nil {
|
||||
log.Printf("❌ [UpdateProduct] Erreur UPDATE: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur mise à jour"})
|
||||
return
|
||||
}
|
||||
|
||||
if updateData.Stock != nil {
|
||||
if err := database.SetProductStock(id, *updateData.Stock); err != nil {
|
||||
log.Printf("⚠️ [UpdateProduct] Erreur mise à jour stock: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// ✅ RÉCUPÉRER LE PRODUIT MIS À JOUR
|
||||
updatedProduct, _ := database.GetProductByID(id)
|
||||
media, _ := database.GetMediaByProductID(id)
|
||||
@@ -672,6 +665,72 @@ func UpdateProduct(c *gin.Context) {
|
||||
})
|
||||
}
|
||||
|
||||
func UpdateStock(c *gin.Context) {
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
|
||||
role := c.GetString("role")
|
||||
if role != "admin" {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "Accès refusé"})
|
||||
return
|
||||
}
|
||||
|
||||
username, _ := safeGetUsername(c)
|
||||
|
||||
id, err := strconv.Atoi(c.Param("id"))
|
||||
if err != nil || id <= 0 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "ID invalide"})
|
||||
return
|
||||
}
|
||||
|
||||
// ✅ VÉRIFIER QUE LE PRODUIT EXISTE
|
||||
_, err = database.GetProductByID(id)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Produit non trouvé"})
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
Stock float64 `json:"stock"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Données invalides"})
|
||||
return
|
||||
}
|
||||
if err := validateStock(req.Stock); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
reserved, err := database.GetReservedQuantityInBaskets(id)
|
||||
if err != nil {
|
||||
utils.ServerErr(c, "Erreur lecture réservations", err)
|
||||
return
|
||||
}
|
||||
if req.Stock+reserved < reserved {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Stock invalide"})
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("🔄 [UpdateStock] %s met à jour le stock #%d (réservé en paniers: %.3f)", username, id, reserved)
|
||||
|
||||
if err := database.SetProductStock(id, req.Stock); err != nil {
|
||||
log.Printf("❌ [UpdateProduct] Erreur UPDATE: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur mise à jour"})
|
||||
return
|
||||
}
|
||||
updatedProduct, _ := database.GetProductByID(id)
|
||||
media, _ := database.GetMediaByProductID(id)
|
||||
updatedProduct.Media = media
|
||||
|
||||
log.Printf("✅ [UpdateStock] le stock #%d est mis à jour", id)
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"product": updatedProduct,
|
||||
"reserved_in_baskets": reserved,
|
||||
})
|
||||
|
||||
}
|
||||
|
||||
func DeleteMedia(c *gin.Context) {
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
|
||||
@@ -803,7 +862,7 @@ func UploadMedia(c *gin.Context) {
|
||||
|
||||
// ✅ CRÉER LE DOSSIER
|
||||
destFolder := filepath.Join("uploads", fileType+"s")
|
||||
if err := os.MkdirAll(destFolder, 0755); err != nil {
|
||||
if err := os.MkdirAll(destFolder, 0750); err != nil {
|
||||
log.Printf("❌ [UploadMedia] Erreur création dossier: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur création dossier"})
|
||||
return
|
||||
@@ -849,6 +908,50 @@ func UploadMedia(c *gin.Context) {
|
||||
})
|
||||
}
|
||||
|
||||
func ActivePrice(c *gin.Context) {
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
role := c.GetString("role")
|
||||
if role != "admin" {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "Accès refusé"})
|
||||
return
|
||||
}
|
||||
|
||||
id, err := strconv.Atoi(c.Param("id"))
|
||||
if err != nil || id <= 0 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "ID invalide"})
|
||||
return
|
||||
}
|
||||
|
||||
if err := database.AddActivePrice(id); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"message": "Prix activé avec succès"})
|
||||
}
|
||||
|
||||
func DesActivePrice(c *gin.Context) {
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
role := c.GetString("role")
|
||||
if role != "admin" {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "Accès refusé"})
|
||||
return
|
||||
}
|
||||
|
||||
id, err := strconv.Atoi(c.Param("id"))
|
||||
if err != nil || id <= 0 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "ID invalide"})
|
||||
return
|
||||
}
|
||||
|
||||
if err := database.DeActivePrice(id); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"message": "Prix désactivé avec succès"})
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// DELETE PRODUCT - VERSION SÉCURISÉE
|
||||
// ============================================
|
||||
@@ -947,3 +1050,26 @@ func cleanFileName(name string) string {
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
func filterActivePrices(products []models.Product) []models.Product {
|
||||
for i := range products {
|
||||
activePrices := []models.ProductPrice{}
|
||||
for _, p := range products[i].Prices {
|
||||
if p.ActivePrice {
|
||||
activePrices = append(activePrices, p)
|
||||
}
|
||||
}
|
||||
products[i].Prices = activePrices
|
||||
}
|
||||
return products
|
||||
}
|
||||
|
||||
func filterActivepricesSingle(product *models.Product) {
|
||||
activePrices := []models.ProductPrice{}
|
||||
for _, p := range product.Prices {
|
||||
if p.ActivePrice {
|
||||
activePrices = append(activePrices, p)
|
||||
}
|
||||
}
|
||||
product.Prices = activePrices
|
||||
}
|
||||
|
||||
@@ -1,8 +1,3 @@
|
||||
// ============================================
|
||||
// handlers/redis_handlers.go - VERSION FINALE
|
||||
// UTILISE UNIQUEMENT LES MÉTHODES DB PostgreSQL
|
||||
// ============================================
|
||||
|
||||
package handlers
|
||||
|
||||
import (
|
||||
@@ -13,6 +8,7 @@ import (
|
||||
"gestion/utils"
|
||||
"log"
|
||||
"net/http"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -20,10 +16,6 @@ import (
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// ============================================
|
||||
// GESTION DE LA FILE DE COMMANDES
|
||||
// ============================================
|
||||
|
||||
func validatePenaltyPoints(points int) error {
|
||||
if points <= 0 {
|
||||
return fmt.Errorf("points invalides: %d (doit être > 0)", points)
|
||||
@@ -47,72 +39,6 @@ func sanitizeReason(reason string) string {
|
||||
return strings.TrimSpace(reason)
|
||||
}
|
||||
|
||||
// GetCommandQueue récupère toutes les commandes en attente dans la file Redis
|
||||
// GET /api/v2/admin/protected/queue/pending
|
||||
func GetCommandQueue(c *gin.Context) {
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
|
||||
userRole := c.GetString("role")
|
||||
if userRole != "admin" {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "Accès refusé"})
|
||||
return
|
||||
}
|
||||
|
||||
nextCommand, err := database.GetNextCommandInQueue()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"message": "Aucune commande en attente",
|
||||
"queue": []interface{}{},
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"next_command": nextCommand,
|
||||
})
|
||||
}
|
||||
|
||||
// AutoAssignNextCommand assigne automatiquement la prochaine commande en file
|
||||
// POST /api/v2/admin/protected/queue/auto-assign
|
||||
func AutoAssignNextCommand(c *gin.Context) {
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
|
||||
userRole := c.GetString("role")
|
||||
if userRole != "admin" {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "Accès refusé"})
|
||||
return
|
||||
}
|
||||
|
||||
nextCommand, err := database.GetNextCommandInQueue()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{
|
||||
"error": "Aucune commande en attente",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
err = database.AutoAssignCommand(nextCommand.CommandID)
|
||||
if err != nil {
|
||||
utils.ServerErr(c, "Erreur lors de l'assignation automatique", err)
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"message": "Commande assignée automatiquement",
|
||||
"command_id": nextCommand.CommandID,
|
||||
})
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// GESTION DES LIVREURS - LOCALISATION
|
||||
// ============================================
|
||||
|
||||
// UpdateLivreurLocation met à jour la position GPS du livreur
|
||||
// POST /api/v1/livreur/location/update
|
||||
// Body: {"latitude": 48.8566, "longitude": 2.3522}
|
||||
func UpdateLivreurLocation(c *gin.Context) {
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
|
||||
@@ -171,7 +97,7 @@ func UpdateLivreurLocation(c *gin.Context) {
|
||||
usernameStr, req.Latitude, req.Longitude)
|
||||
|
||||
// ✅ Recalculer l'ETA en temps réel si livreur en_route
|
||||
go refreshETAForActivDelivery(database, usernameStr, req.Latitude, req.Longitude)
|
||||
go refreshETAForActivDelivery(usernameStr, req.Latitude, req.Longitude)
|
||||
|
||||
// ✅ 2. Vérifier/Initialiser le statut du livreur
|
||||
statusKey := fmt.Sprintf("delivery:status:%s", usernameStr)
|
||||
@@ -290,14 +216,6 @@ func GetDeliveryPersonLocation(c *gin.Context) {
|
||||
})
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// LOCALISATION DU LIVREUR POUR UNE COMMANDE
|
||||
// ============================================
|
||||
|
||||
// GetDeliverymanLocationForCommand récupère la position GPS du livreur assigné à une commande
|
||||
// GET /api/v2/admin/protected/commands/:id/deliveryman/location (ADMIN)
|
||||
// GET /api/v1/cabine/commands/:id/deliveryman/location (CABINE)
|
||||
// Accessible uniquement par les admins et la cabine
|
||||
func GetDeliverymanLocationForCommand(c *gin.Context) {
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
|
||||
@@ -460,13 +378,6 @@ func GetDeliverymanLocationForCommand(c *gin.Context) {
|
||||
})
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// GESTION DES LIVREURS - STATUT
|
||||
// ============================================
|
||||
|
||||
// UpdateDeliveryPersonStatus met à jour le statut de disponibilité du livreur
|
||||
// POST /api/v1/livreur/status
|
||||
// Body: {"status": "available" | "busy" | "offline"}
|
||||
func UpdateDeliveryPersonStatus(c *gin.Context) {
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
|
||||
@@ -490,18 +401,9 @@ func UpdateDeliveryPersonStatus(c *gin.Context) {
|
||||
utils.BindErr(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
// Validation du statut
|
||||
validStatuses := []string{"available", "busy", "offline"}
|
||||
isValid := false
|
||||
for _, s := range validStatuses {
|
||||
if req.Status == s {
|
||||
isValid = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if !isValid {
|
||||
if !slices.Contains(validStatuses, req.Status) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"error": "Statut invalide",
|
||||
"valid_statuses": validStatuses,
|
||||
@@ -509,7 +411,6 @@ func UpdateDeliveryPersonStatus(c *gin.Context) {
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
usernameStr := username.(string)
|
||||
|
||||
err := database.SetDeliveryPersonStatus(usernameStr, req.Status, 0)
|
||||
@@ -604,118 +505,6 @@ func GetMyQueue(c *gin.Context) {
|
||||
})
|
||||
}
|
||||
|
||||
// GetAvailableDeliveryPersonsRealtime récupère les livreurs disponibles depuis Redis
|
||||
// GET /api/v2/admin/protected/delivery/available-realtime
|
||||
func GetAvailableDeliveryPersonsRealtime(c *gin.Context) {
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
|
||||
userRole := c.GetString("role")
|
||||
if userRole != "admin" {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "Accès refusé"})
|
||||
return
|
||||
}
|
||||
|
||||
livreurs, err := database.GetAvailableDeliveryPersonsRedis()
|
||||
if err != nil {
|
||||
utils.ServerErr(c, "Erreur lors de la récupération des livreurs", err)
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"livreurs": livreurs,
|
||||
"count": len(livreurs),
|
||||
})
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// GESTION ETA (Estimated Time of Arrival)
|
||||
// ============================================
|
||||
|
||||
// SetCommandETAHandler permet au livreur de définir l'ETA d'une livraison
|
||||
// POST /api/v1/livreur/deliveries/:id/set-eta
|
||||
// Body: {"eta_minutes": 25}
|
||||
func SetCommandETAHandler(c *gin.Context) {
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
|
||||
username, exists := c.Get("username")
|
||||
if !exists {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Non authentifié"})
|
||||
return
|
||||
}
|
||||
|
||||
userRole := c.GetString("role")
|
||||
if userRole != "livreur" {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "Accès réservé aux livreurs"})
|
||||
return
|
||||
}
|
||||
|
||||
commandID, err := strconv.Atoi(c.Param("id"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "ID de commande invalide"})
|
||||
return
|
||||
}
|
||||
|
||||
var req struct {
|
||||
ETAMinutes int `json:"eta_minutes" binding:"required"`
|
||||
}
|
||||
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
utils.BindErr(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
// Validation de l'ETA
|
||||
if req.ETAMinutes < 1 || req.ETAMinutes > 120 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"error": "L'ETA doit être entre 1 et 120 minutes",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
usernameStr := username.(string)
|
||||
|
||||
// Vérifier que la commande existe et est assignée au livreur
|
||||
command, err := database.GetCommandByID(commandID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{
|
||||
"error": "Commande non trouvée",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
livreurAssign, ok := command["livreur_assign"].(string)
|
||||
if !ok || livreurAssign != usernameStr {
|
||||
c.JSON(http.StatusForbidden, gin.H{
|
||||
"error": "Cette commande ne vous est pas assignée",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Mettre à jour l'ETA dans Redis
|
||||
err = database.SetCommandETA(commandID, req.ETAMinutes)
|
||||
if err != nil {
|
||||
utils.ServerErr(c, "Erreur lors de la mise à jour de l'ETA", err)
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("⏱️ ETA défini pour commande %d par %s: %d minutes", commandID, usernameStr, req.ETAMinutes)
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"message": "ETA mis à jour avec succès",
|
||||
"command_id": commandID,
|
||||
"eta_minutes": req.ETAMinutes,
|
||||
})
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// PÉNALITÉS - UTILISE PostgreSQL
|
||||
// ============================================
|
||||
|
||||
// ApplyClientPenalty applique une pénalité à un client (Admin seulement)
|
||||
// POST /api/v2/admin/protected/penalty
|
||||
// Body: {"username": "john", "points": 50, "reason": "Retard paiement"}
|
||||
func ApplyClientPenalty(c *gin.Context) {
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
|
||||
@@ -993,9 +782,6 @@ func ResetClientPenaltiesAdmin(c *gin.Context) {
|
||||
})
|
||||
}
|
||||
|
||||
// AddClientPointsAdmin ajoute des points à un client dans un pool donné (Admin/Cabine)
|
||||
// POST /api/v2/admin/protected/client/:username/points/add
|
||||
// Body: {"pool_key": "pool_0", "points": 10}
|
||||
func AddClientPointsAdmin(c *gin.Context) {
|
||||
userRole := c.GetString("role")
|
||||
if userRole != "admin" && userRole != "cabine" {
|
||||
@@ -1040,7 +826,7 @@ func AddClientPointsAdmin(c *gin.Context) {
|
||||
}
|
||||
if !poolExists {
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"error": "Pool de points invalide",
|
||||
"error": "Pool de points invalide",
|
||||
"pools_valides": func() []string {
|
||||
keys := make([]string, 0, len(settings.PointsPools))
|
||||
for _, p := range settings.PointsPools {
|
||||
@@ -1073,9 +859,6 @@ func AddClientPointsAdmin(c *gin.Context) {
|
||||
})
|
||||
}
|
||||
|
||||
// SubtractClientPointsAdmin retire des points à un client (plancher à 0)
|
||||
// POST /api/v2/admin/protected/client/:username/points/subtract
|
||||
// Body: {"pool_key": "pool_0", "points": 10}
|
||||
func SubtractClientPointsAdmin(c *gin.Context) {
|
||||
userRole := c.GetString("role")
|
||||
if userRole != "admin" && userRole != "cabine" {
|
||||
@@ -1164,12 +947,6 @@ func SubtractClientPointsAdmin(c *gin.Context) {
|
||||
})
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// STATISTIQUES TEMPS RÉEL
|
||||
// ============================================
|
||||
|
||||
// GetRealtimeStats récupère les statistiques en temps réel
|
||||
// GET /api/v2/admin/protected/stats/realtime
|
||||
func GetRealtimeStats(c *gin.Context) {
|
||||
userRole := c.GetString("role")
|
||||
if userRole != "admin" {
|
||||
@@ -1200,13 +977,7 @@ func GetRealtimeStats(c *gin.Context) {
|
||||
})
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// RECALCUL ETA EN TEMPS RÉEL (appelé à chaque update GPS)
|
||||
// ============================================
|
||||
|
||||
// refreshETAForActivDelivery recalcule l'ETA depuis la position actuelle du livreur.
|
||||
// Appelé en goroutine à chaque mise à jour GPS (toutes les ~15s).
|
||||
func refreshETAForActivDelivery(database *db.Database, username string, lat, lon float64) {
|
||||
func refreshETAForActivDelivery(username string, lat, lon float64) {
|
||||
// 1. Récupérer le statut actuel du livreur
|
||||
statusKey := fmt.Sprintf("delivery:status:%s", username)
|
||||
statusData, err := db.Redis.Get(db.RedisCtx, statusKey).Result()
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
@@ -30,20 +31,23 @@ func GetPublicSettings(c *gin.Context) {
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"penalties_enabled": settings.PenaltiesEnabled,
|
||||
"show_amende_score": settings.ShowAmendeScore,
|
||||
"points_enabled": settings.PointsEnabled,
|
||||
"points_separated": len(settings.PointsPools) > 1,
|
||||
"pool_names": poolNames,
|
||||
"pool_keys": poolKeys,
|
||||
"referral_enabled": settings.ReferralEnabled,
|
||||
"referral_amount": settings.ReferralAmount,
|
||||
"delivery_schedule": settings.DeliverySchedule,
|
||||
"crypto_payment_enabled": settings.CryptoPaymentEnabled,
|
||||
"crypto_only": settings.CryptoOnly,
|
||||
"nowpayments_currencies": settings.NowPaymentsCurrencies,
|
||||
"telegram_notifications_enabled": settings.TelegramNotificationsEnabled,
|
||||
"success": true,
|
||||
"penalties_enabled": settings.PenaltiesEnabled,
|
||||
"show_amende_score": settings.ShowAmendeScore,
|
||||
"points_enabled": settings.PointsEnabled,
|
||||
"points_separated": len(settings.PointsPools) > 1,
|
||||
"pool_names": poolNames,
|
||||
"pool_keys": poolKeys,
|
||||
"referral_enabled": settings.ReferralEnabled,
|
||||
"referral_amount": settings.ReferralAmount,
|
||||
"delivery_schedule": settings.DeliverySchedule,
|
||||
"crypto_payment_enabled": settings.CryptoPaymentEnabled,
|
||||
"crypto_only": settings.CryptoOnly,
|
||||
"nowpayments_currencies": settings.NowPaymentsCurrencies,
|
||||
"telegram_notifications_enabled": settings.TelegramNotificationsEnabled,
|
||||
"shop_name": settings.ShopName,
|
||||
"two_fa_enabled": settings.Telegram2FAEnabled,
|
||||
"contact_telegram": settings.ContactTelegram,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -87,7 +91,7 @@ func UpdateSettings(c *gin.Context) {
|
||||
if err := services.TelegramBot.SetWebhook(webhookURL); err != nil {
|
||||
log.Printf("⚠️ [SETTINGS] Erreur enregistrement webhook Telegram: %v", err)
|
||||
} else {
|
||||
log.Printf("✅ [SETTINGS] Webhook Telegram enregistré: %s", webhookURL)
|
||||
log.Printf("✅ [SETTINGS] Webhook Telegram enregistré: %s", strings.NewReplacer("\n", "", "\r", "").Replace(webhookURL))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,260 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"gestion/db"
|
||||
"gestion/models"
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
var weekdayNames = []string{"Dimanche", "Lundi", "Mardi", "Mercredi", "Jeudi", "Vendredi", "Samedi"}
|
||||
|
||||
// GetAdminStats returns aggregated order & product statistics for the admin dashboard.
|
||||
func GetAdminStats(c *gin.Context) {
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
gdb := database.GDB
|
||||
|
||||
// ── Commandes par jour de la semaine (all time, non annulées) ──────────────
|
||||
var wdRows []models.WeekdayRow
|
||||
gdb.Raw(`
|
||||
SELECT EXTRACT(DOW FROM created_at)::int AS dow, COUNT(*) AS count
|
||||
FROM commandes
|
||||
WHERE status != 'cancelled'
|
||||
GROUP BY dow
|
||||
ORDER BY dow
|
||||
`).Scan(&wdRows)
|
||||
|
||||
byWeekday := make([]gin.H, 7)
|
||||
wdMap := make(map[int]int, len(wdRows))
|
||||
for _, r := range wdRows {
|
||||
wdMap[r.DOW] = r.Count
|
||||
}
|
||||
peakCount, peakWeekday := 0, ""
|
||||
for i := 0; i < 7; i++ {
|
||||
cnt := wdMap[i]
|
||||
byWeekday[i] = gin.H{"weekday": weekdayNames[i], "count": cnt}
|
||||
if cnt > peakCount {
|
||||
peakCount = cnt
|
||||
peakWeekday = weekdayNames[i]
|
||||
}
|
||||
}
|
||||
|
||||
// ── Commandes par jour sur 30 jours ───────────────────────────────────────
|
||||
var dayRows []models.DayRow
|
||||
gdb.Raw(`
|
||||
SELECT DATE(created_at) AS day, COUNT(*) AS count
|
||||
FROM commandes
|
||||
WHERE created_at >= NOW() - INTERVAL '30 days'
|
||||
AND status != 'cancelled'
|
||||
GROUP BY DATE(created_at)
|
||||
ORDER BY day
|
||||
`).Scan(&dayRows)
|
||||
|
||||
byDay := make([]gin.H, len(dayRows))
|
||||
for i, r := range dayRows {
|
||||
byDay[i] = gin.H{
|
||||
"day": r.Day.Format("2006-01-02"),
|
||||
"label": r.Day.Format("02/01"),
|
||||
"count": r.Count,
|
||||
}
|
||||
}
|
||||
|
||||
// ── Revenus par jour sur 30 jours (commandes approuvées) ─────────────────
|
||||
var dayRevRows []models.DayRevenueRow
|
||||
gdb.Raw(`
|
||||
SELECT DATE(created_at) AS day, COALESCE(SUM(total_prix - COALESCE(referral_used, 0)), 0) AS revenue
|
||||
FROM commandes
|
||||
WHERE created_at >= NOW() - INTERVAL '30 days'
|
||||
AND status = 'approved'
|
||||
GROUP BY DATE(created_at)
|
||||
ORDER BY day
|
||||
`).Scan(&dayRevRows)
|
||||
|
||||
byDayRevenue := make([]gin.H, len(dayRevRows))
|
||||
for i, r := range dayRevRows {
|
||||
byDayRevenue[i] = gin.H{
|
||||
"day": r.Day.Format("2006-01-02"),
|
||||
"label": r.Day.Format("02/01"),
|
||||
"revenue": r.Revenue,
|
||||
}
|
||||
}
|
||||
|
||||
// ── Commandes & revenus par heure (all time, non annulées) ───────────────
|
||||
var hourRows []models.HourRow
|
||||
gdb.Raw(`
|
||||
SELECT
|
||||
EXTRACT(HOUR FROM created_at)::int AS hour,
|
||||
COUNT(*) AS count,
|
||||
COALESCE(SUM(total_prix - COALESCE(referral_used, 0)), 0) AS revenue
|
||||
FROM commandes
|
||||
WHERE status != 'cancelled'
|
||||
GROUP BY hour
|
||||
ORDER BY hour
|
||||
`).Scan(&hourRows)
|
||||
|
||||
hourMap := make(map[int]models.HourRow, len(hourRows))
|
||||
for _, r := range hourRows {
|
||||
hourMap[r.Hour] = r
|
||||
}
|
||||
byHour := make([]gin.H, 24)
|
||||
for h := 0; h < 24; h++ {
|
||||
r := hourMap[h]
|
||||
byHour[h] = gin.H{
|
||||
"hour": h,
|
||||
"label": fmt.Sprintf("%02dh", h),
|
||||
"count": r.Count,
|
||||
"revenue": r.Revenue,
|
||||
}
|
||||
}
|
||||
|
||||
// ── Top produits (quantité vendue, commandes terminées) ───────────────────
|
||||
var prodRows []models.ProductRow
|
||||
gdb.Raw(`
|
||||
SELECT
|
||||
ci.product_id,
|
||||
ci.produit AS name,
|
||||
SUM(ci.quantite) AS total_quantity,
|
||||
COUNT(DISTINCT ci.command_id) AS order_count,
|
||||
SUM(ci.prix) AS revenue,
|
||||
COALESCE(p.category, '') AS category,
|
||||
COALESCE(cat.color, '#7c3aed') AS category_color
|
||||
FROM command_items ci
|
||||
JOIN commandes c ON c.id = ci.command_id
|
||||
LEFT JOIN products p ON p.id = ci.product_id
|
||||
LEFT JOIN categories cat ON cat.name = p.category
|
||||
WHERE c.status != 'cancelled'
|
||||
GROUP BY ci.product_id, ci.produit, p.category, cat.color
|
||||
ORDER BY total_quantity DESC
|
||||
LIMIT 15
|
||||
`).Scan(&prodRows)
|
||||
|
||||
topProducts := make([]gin.H, len(prodRows))
|
||||
topProductName := ""
|
||||
for i, r := range prodRows {
|
||||
topProducts[i] = gin.H{
|
||||
"product_id": r.ProductID,
|
||||
"name": r.Name,
|
||||
"quantity": r.Quantity,
|
||||
"order_count": r.OrderCount,
|
||||
"revenue": r.Revenue,
|
||||
"category": r.Category,
|
||||
"category_color": r.CategoryColor,
|
||||
}
|
||||
if i == 0 {
|
||||
topProductName = r.Name
|
||||
}
|
||||
}
|
||||
|
||||
// ── Répartition des doses/quantités par produit ───────────────────────────
|
||||
var qtyRows []models.QuantityBreakdownRow
|
||||
gdb.Raw(`
|
||||
SELECT
|
||||
ci.product_id,
|
||||
ci.produit AS product_name,
|
||||
ci.quantite AS quantity,
|
||||
COUNT(DISTINCT ci.command_id) AS order_count,
|
||||
SUM(ci.quantite) AS total_sold,
|
||||
SUM(ci.prix) AS revenue,
|
||||
COALESCE(cat.color, '#7c3aed') AS category_color
|
||||
FROM command_items ci
|
||||
JOIN commandes c ON c.id = ci.command_id
|
||||
LEFT JOIN products p ON p.id = ci.product_id
|
||||
LEFT JOIN categories cat ON cat.name = p.category
|
||||
WHERE c.status != 'cancelled'
|
||||
GROUP BY ci.product_id, ci.produit, ci.quantite, cat.color
|
||||
ORDER BY ci.product_id, COUNT(DISTINCT ci.command_id) DESC
|
||||
`).Scan(&qtyRows)
|
||||
|
||||
type productGroup struct {
|
||||
ProductID int
|
||||
Name string
|
||||
CategoryColor string
|
||||
TotalOrders int
|
||||
Quantities []gin.H
|
||||
}
|
||||
var groups []productGroup
|
||||
groupIdx := map[int]int{}
|
||||
for _, r := range qtyRows {
|
||||
idx, ok := groupIdx[r.ProductID]
|
||||
if !ok {
|
||||
idx = len(groups)
|
||||
groups = append(groups, productGroup{
|
||||
ProductID: r.ProductID,
|
||||
Name: r.ProductName,
|
||||
CategoryColor: r.CategoryColor,
|
||||
})
|
||||
groupIdx[r.ProductID] = idx
|
||||
}
|
||||
groups[idx].TotalOrders += r.OrderCount
|
||||
groups[idx].Quantities = append(groups[idx].Quantities, gin.H{
|
||||
"quantity": r.Quantity,
|
||||
"order_count": r.OrderCount,
|
||||
"total_sold": r.TotalSold,
|
||||
"revenue": r.Revenue,
|
||||
})
|
||||
}
|
||||
// Trier par total de commandes décroissant, garder 15 max
|
||||
for i := 0; i < len(groups)-1; i++ {
|
||||
for j := i + 1; j < len(groups); j++ {
|
||||
if groups[j].TotalOrders > groups[i].TotalOrders {
|
||||
groups[i], groups[j] = groups[j], groups[i]
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(groups) > 15 {
|
||||
groups = groups[:15]
|
||||
}
|
||||
byQuantity := make([]gin.H, len(groups))
|
||||
for i, g := range groups {
|
||||
byQuantity[i] = gin.H{
|
||||
"product_id": g.ProductID,
|
||||
"name": g.Name,
|
||||
"category_color": g.CategoryColor,
|
||||
"total_orders": g.TotalOrders,
|
||||
"quantities": g.Quantities,
|
||||
}
|
||||
}
|
||||
|
||||
// ── Résumé global ─────────────────────────────────────────────────────────
|
||||
var totalOrders int64
|
||||
var totalRevenue float64
|
||||
gdb.Raw(`SELECT COUNT(*) FROM commandes WHERE status != 'cancelled'`).Scan(&totalOrders)
|
||||
gdb.Raw(`SELECT COALESCE(SUM(total_prix - COALESCE(referral_used, 0)), 0) FROM commandes WHERE status = 'approved'`).Scan(&totalRevenue)
|
||||
|
||||
avgPerDay := 0.0
|
||||
if totalOrders > 0 {
|
||||
// average over the last 30 days with data
|
||||
var activeDays int64
|
||||
gdb.Raw(`
|
||||
SELECT COUNT(DISTINCT DATE(created_at))
|
||||
FROM commandes
|
||||
WHERE created_at >= NOW() - INTERVAL '30 days' AND status != 'cancelled'
|
||||
`).Scan(&activeDays)
|
||||
if activeDays > 0 {
|
||||
var last30Count int64
|
||||
gdb.Raw(`
|
||||
SELECT COUNT(*) FROM commandes
|
||||
WHERE created_at >= NOW() - INTERVAL '30 days' AND status != 'cancelled'
|
||||
`).Scan(&last30Count)
|
||||
avgPerDay = float64(last30Count) / float64(activeDays)
|
||||
}
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"summary": gin.H{
|
||||
"total_orders": totalOrders,
|
||||
"total_revenue": totalRevenue,
|
||||
"peak_weekday": peakWeekday,
|
||||
"top_product": topProductName,
|
||||
"avg_per_day": avgPerDay,
|
||||
},
|
||||
"by_weekday": byWeekday,
|
||||
"by_day_30": byDay,
|
||||
"by_day_revenue": byDayRevenue,
|
||||
"by_hour": byHour,
|
||||
"top_products": topProducts,
|
||||
"by_quantity": byQuantity,
|
||||
})
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"gestion/services"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
@@ -91,9 +92,29 @@ func handleLinkAccount(c *gin.Context, token string, chatID int64) {
|
||||
}
|
||||
|
||||
log.Printf("✅ [TELEGRAM_LINK] Compte %s (%s) lié au chat_id %d", username, role, chatID)
|
||||
|
||||
// Enrollment lbtelegram (best effort — n'empêche pas l'envoi du bouton)
|
||||
if services.LBTelegram != nil && services.LBTelegram.IsConfigured() {
|
||||
if err := services.LBTelegram.EnrollUser(chatID, username, role); err != nil {
|
||||
log.Printf("⚠️ [LB] enrollment échoué pour %s: %v", username, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Message de confirmation — bouton vers BOT1 si lbtelegram configuré, sinon texte simple
|
||||
if services.TelegramBot != nil {
|
||||
services.TelegramBot.SendMessage(chatID,
|
||||
"✅ <b>Compte lié avec succès !</b>\n\nVous recevrez désormais vos notifications ici.")
|
||||
if services.LBTelegram != nil && services.LBTelegram.Bot1Username != "" {
|
||||
if err := services.TelegramBot.SendMessageWithButtons(chatID,
|
||||
"✅ <b>Compte lié avec succès !</b>\n\nPour activer vos notifications, démarrez le bot ci-dessous :",
|
||||
[][2]string{{"🔔 Activer les notifications", "https://t.me/" + services.LBTelegram.Bot1Username}},
|
||||
); err != nil {
|
||||
log.Printf("⚠️ [TELEGRAM] Envoi bouton BOT1 échoué pour %s: %v", username, err)
|
||||
services.TelegramBot.SendMessage(chatID,
|
||||
"✅ <b>Compte lié avec succès !</b>\n\nVous recevrez désormais vos notifications ici.")
|
||||
}
|
||||
} else {
|
||||
services.TelegramBot.SendMessage(chatID,
|
||||
"✅ <b>Compte lié avec succès !</b>\n\nVous recevrez désormais vos notifications ici.")
|
||||
}
|
||||
}
|
||||
|
||||
c.Status(http.StatusOK)
|
||||
@@ -118,9 +139,11 @@ func GenerateClientLinkToken(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
botUsername := services.TelegramBot.BotUsername
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"token": token,
|
||||
"link_url": "https://t.me/" + services.TelegramBot.BotUsername + "?start=" + token,
|
||||
"link_url": "https://t.me/" + botUsername + "?start=" + token,
|
||||
"message": "/start " + token,
|
||||
"expires_in": 600,
|
||||
})
|
||||
@@ -145,9 +168,11 @@ func GenerateLivreurLinkToken(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
botUsername := services.TelegramBot.BotUsername
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"token": token,
|
||||
"link_url": "https://t.me/" + services.TelegramBot.BotUsername + "?start=" + token,
|
||||
"link_url": "https://t.me/" + botUsername + "?start=" + token,
|
||||
"message": "/start " + token,
|
||||
"expires_in": 600,
|
||||
})
|
||||
@@ -180,9 +205,11 @@ func GenerateAdminLinkToken(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
botUsername := services.TelegramBot.BotUsername
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"token": token,
|
||||
"link_url": "https://t.me/" + services.TelegramBot.BotUsername + "?start=" + token,
|
||||
"link_url": "https://t.me/" + botUsername + "?start=" + token,
|
||||
"message": "/start " + token,
|
||||
"expires_in": 600,
|
||||
})
|
||||
@@ -242,13 +269,20 @@ func UnlinkClientTelegram(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
clientID := c.GetInt("client_id")
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
|
||||
if err := database.DeleteClientTelegramChatID(username); err != nil {
|
||||
log.Printf("❌ [TELEGRAM_UNLINK] Erreur pour %s: %v", username, err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur déliaison"})
|
||||
return
|
||||
}
|
||||
|
||||
// Désactiver la 2FA si Telegram est délié
|
||||
if clientID > 0 {
|
||||
_ = database.SetClientTwoFAEnabled(clientID, false)
|
||||
}
|
||||
|
||||
log.Printf("✅ [TELEGRAM_UNLINK] Compte client %s délié", username)
|
||||
c.JSON(http.StatusOK, gin.H{"success": true})
|
||||
}
|
||||
@@ -290,3 +324,62 @@ func UnlinkAdminTelegram(c *gin.Context) {
|
||||
log.Printf("✅ [TELEGRAM_UNLINK] Compte admin %s délié", username)
|
||||
c.JSON(http.StatusOK, gin.H{"success": true})
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// LIAISON INTERNE (appelée par LBTelegram)
|
||||
// ============================================
|
||||
|
||||
// POST /api/internal/telegram/link
|
||||
// Appelée par LBTelegram quand Bot1 reçoit /start TOKEN.
|
||||
// Valide le token, enregistre le chat_id, déclenche l'enrollment.
|
||||
func InternalTelegramLink(c *gin.Context) {
|
||||
secret := c.GetHeader("X-Internal-Secret")
|
||||
expected := os.Getenv("BACKEND_LINK_SECRET")
|
||||
if expected == "" || secret != expected {
|
||||
c.Status(http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
var req struct {
|
||||
ChatID int64 `json:"chat_id" binding:"required"`
|
||||
Token string `json:"token" binding:"required"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
|
||||
username, role, err := db.ValidateAndConsumeLinkToken(req.Token)
|
||||
if err != nil {
|
||||
log.Printf("⚠️ [TELEGRAM_LINK_INTERNAL] Token invalide: %v", err)
|
||||
c.Status(http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
var saveErr error
|
||||
switch role {
|
||||
case "client":
|
||||
saveErr = database.SaveClientTelegramChatID(username, req.ChatID)
|
||||
default:
|
||||
saveErr = database.SaveUserTelegramChatID(username, req.ChatID)
|
||||
}
|
||||
if saveErr != nil {
|
||||
log.Printf("❌ [TELEGRAM_LINK_INTERNAL] Erreur sauvegarde pour %s: %v", username, saveErr)
|
||||
c.Status(http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("✅ [TELEGRAM_LINK_INTERNAL] Compte %s (%s) lié via Bot1 (chat_id %d)", username, role, req.ChatID)
|
||||
|
||||
if services.LBTelegram != nil && services.LBTelegram.IsConfigured() {
|
||||
if err := services.LBTelegram.EnrollUser(req.ChatID, username, role); err != nil {
|
||||
log.Printf("⚠️ [LB] enrollment échoué pour %s: %v", username, err)
|
||||
c.Status(http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
c.Status(http.StatusOK)
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@ import (
|
||||
)
|
||||
|
||||
// getFloatFromMap récupère un float64 depuis une map avec différents types
|
||||
func getFloatFromMap(m map[string]interface{}, key string) (float64, bool) {
|
||||
func getFloatFromMap(m map[string]any, key string) (float64, bool) {
|
||||
value, exists := m[key]
|
||||
if !exists || value == nil {
|
||||
return 0, false
|
||||
|
||||
@@ -169,10 +169,6 @@ func GetMyProfile(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "client": sanitizeClient(client)})
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// MODIFICATION PROFIL CLIENT (PAR ADMIN)
|
||||
// ============================================
|
||||
|
||||
// UpdateClientByAdmin permet à un admin de modifier n'importe quel profil client
|
||||
// PUT /api/v2/admin/protected/clients/:id
|
||||
func UpdateClientByAdmin(c *gin.Context) {
|
||||
@@ -349,10 +345,6 @@ func UpdateClientByAdmin(c *gin.Context) {
|
||||
})
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// MODIFICATION PROFIL USER (PAR ADMIN)
|
||||
// ============================================
|
||||
|
||||
// UpdateUserByAdmin permet à un admin de modifier n'importe quel profil user
|
||||
// PUT /api/v2/admin/protected/users/:id
|
||||
func UpdateUserByAdmin(c *gin.Context) {
|
||||
@@ -468,10 +460,6 @@ func UpdateUserByAdmin(c *gin.Context) {
|
||||
})
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// UTILITAIRES
|
||||
// ============================================
|
||||
|
||||
func sanitizeClient(client *models.Client) gin.H {
|
||||
return gin.H{
|
||||
"id": client.ID,
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"gestion/db"
|
||||
"gestion/services"
|
||||
"log"
|
||||
"net/http"
|
||||
"strconv"
|
||||
@@ -24,249 +22,6 @@ const (
|
||||
MAX_DELIVERY_VALIDATION_DISTANCE_KM = 0.1 // 100 mètres = 0.1 km
|
||||
)
|
||||
|
||||
// ============================================
|
||||
// 1️⃣ VALIDATION LIVRAISON PAR LE LIVREUR (AVEC VÉRIFICATION GPS)
|
||||
// ============================================
|
||||
|
||||
func ValidateDeliveryByLivreur(c *gin.Context) {
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
|
||||
// ✅ SÉCURITÉ: Livreur seulement
|
||||
username, exists := c.Get("username")
|
||||
if !exists || c.GetString("role") != "livreur" {
|
||||
log.Printf("❌ [VALIDATE_LIVREUR] Accès refusé")
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "Accès réservé aux livreurs"})
|
||||
return
|
||||
}
|
||||
|
||||
usernameStr := username.(string)
|
||||
|
||||
commandID, err := strconv.Atoi(c.Param("id"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "ID de commande invalide"})
|
||||
return
|
||||
}
|
||||
|
||||
var req struct {
|
||||
Latitude float64 `json:"latitude" binding:"required"`
|
||||
Longitude float64 `json:"longitude" binding:"required"`
|
||||
}
|
||||
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
log.Printf("❌ [VALIDATE_LIVREUR] Erreur JSON: %v", err)
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"error": "Coordonnées GPS requises",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("📍 [VALIDATE_LIVREUR] Livreur %s valide cmd %d avec GPS: (%.6f, %.6f)",
|
||||
usernameStr, commandID, req.Latitude, req.Longitude)
|
||||
|
||||
// ✅ ÉTAPE 1: Récupérer la commande
|
||||
command, err := database.GetCommandByID(commandID)
|
||||
if err != nil {
|
||||
log.Printf("❌ [VALIDATE_LIVREUR] Commande non trouvée")
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Commande non trouvée"})
|
||||
return
|
||||
}
|
||||
|
||||
// ✅ ÉTAPE 2: VÉRIFIER PROPRIÉTÉ
|
||||
livreurAssign, _ := command["livreur_assign"].(string)
|
||||
if livreurAssign != usernameStr {
|
||||
log.Printf("❌ [VALIDATE_LIVREUR] ⚠️ TENTATIVE D'ACCÈS NON AUTORISÉ!")
|
||||
c.JSON(http.StatusForbidden, gin.H{
|
||||
"error": "Cette commande ne vous est pas assignée",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// ÉTAPE 3: Coordonnées GPS reçues et valides
|
||||
log.Printf("📍 [VALIDATE_LIVREUR] GPS reçu: (%.6f, %.6f)", req.Latitude, req.Longitude)
|
||||
|
||||
// ÉTAPE 4: Sauvegarder les coordonnées du livreur
|
||||
_, err = database.Exec(
|
||||
"UPDATE commandes SET livreur_latitude = $1, livreur_longitude = $2 WHERE id = $3",
|
||||
req.Latitude, req.Longitude, commandID,
|
||||
)
|
||||
if err != nil {
|
||||
log.Printf("⚠️ [VALIDATE_LIVREUR] Erreur sauvegarde GPS: %v", err)
|
||||
}
|
||||
|
||||
// ✅ ÉTAPE 5: Marquer la livraison comme "livre"
|
||||
if err := database.UpdateCommandStatus(commandID, "livre"); err != nil {
|
||||
log.Printf("❌ [VALIDATE_LIVREUR] Erreur update statut: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"error": "Erreur validation",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// ✅ ÉTAPE 6: Ajouter un log
|
||||
database.AddCommandLog(commandID, "livre",
|
||||
fmt.Sprintf("Livraison confirmée par livreur - GPS: (%.6f, %.6f)", req.Latitude, req.Longitude),
|
||||
usernameStr)
|
||||
|
||||
// ✅ ÉTAPE 7: Optimiser la queue
|
||||
log.Printf("📦 [VALIDATE_LIVREUR] Optimisation queue de %s...", usernameStr)
|
||||
err = database.CompleteDeliveryAndProcessNext(usernameStr, commandID)
|
||||
if err != nil {
|
||||
log.Printf("⚠️ [VALIDATE_LIVREUR] Erreur optimisation: %v", err)
|
||||
}
|
||||
|
||||
log.Printf("✅ [VALIDATE_LIVREUR] Commande %d validée et marquée 'livre'", commandID)
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"message": "Livraison validée avec succès",
|
||||
"command_id": commandID,
|
||||
"new_status": "livre",
|
||||
"gps_verified": true,
|
||||
})
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// 2️⃣ VÉRIFIER SI LE LIVREUR PEUT VALIDER (SANS VALIDER)
|
||||
// ============================================
|
||||
|
||||
// CheckDeliveryValidationEligibility vérifie si le livreur peut valider une livraison
|
||||
// GET /api/v1/deliveries/:id/can-validate
|
||||
func CheckDeliveryValidationEligibility(c *gin.Context) {
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
|
||||
username, exists := c.Get("username")
|
||||
if !exists {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Non authentifié"})
|
||||
return
|
||||
}
|
||||
|
||||
userRole := c.GetString("role")
|
||||
if userRole != "livreur" {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "Accès réservé aux livreurs"})
|
||||
return
|
||||
}
|
||||
|
||||
commandID, err := strconv.Atoi(c.Param("id"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "ID de commande invalide"})
|
||||
return
|
||||
}
|
||||
|
||||
command, err := database.GetCommandByID(commandID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Commande non trouvée"})
|
||||
return
|
||||
}
|
||||
|
||||
// Vérifier l'assignation
|
||||
livreurAssign, _ := command["livreur_assign"].(string)
|
||||
if livreurAssign != username.(string) {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"can_validate": false,
|
||||
"reason": "Commande non assignée à vous",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Récupérer la position du livreur
|
||||
livreurLat, livreurLon, err := database.GetDeliveryPersonLocation(username.(string))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"can_validate": false,
|
||||
"reason": "Position GPS non disponible",
|
||||
"action": "Mettez à jour votre position GPS",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Récupérer les coordonnées de destination (même priorité que ValidateDeliveryByLivreur)
|
||||
var destLat, destLon float64
|
||||
var coordsSource string
|
||||
|
||||
// ✅ PRIORITÉ 1: Cache Redis
|
||||
destCacheKey := fmt.Sprintf("command:destination:%d", commandID)
|
||||
destData, redisErr := db.Redis.Get(db.RedisCtx, destCacheKey).Result()
|
||||
if redisErr == nil && destData != "" {
|
||||
var coords struct {
|
||||
Lat float64 `json:"lat"`
|
||||
Lon float64 `json:"lon"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(destData), &coords); err == nil && coords.Lat != 0 && coords.Lon != 0 {
|
||||
destLat = coords.Lat
|
||||
destLon = coords.Lon
|
||||
coordsSource = "REDIS"
|
||||
log.Printf("📍 [CAN-VALIDATE] Coords depuis Redis: (%.6f, %.6f)", destLat, destLon)
|
||||
}
|
||||
}
|
||||
|
||||
// ✅ PRIORITÉ 2: DB
|
||||
if coordsSource == "" {
|
||||
if dLat, ok := getFloatFromMap(command, "dest_latitude"); ok && dLat != 0 {
|
||||
destLat = dLat
|
||||
}
|
||||
if dLon, ok := getFloatFromMap(command, "dest_longitude"); ok && dLon != 0 {
|
||||
destLon = dLon
|
||||
}
|
||||
if destLat != 0 && destLon != 0 {
|
||||
coordsSource = "DB"
|
||||
}
|
||||
}
|
||||
|
||||
// ✅ PRIORITÉ 3: Géocodage
|
||||
if coordsSource == "" {
|
||||
geoService := c.MustGet("geoService").(*services.GeoService)
|
||||
address, _ := command["adresse"].(string)
|
||||
if address != "" && address != "Adresse non spécifiée" {
|
||||
location, err := geoService.GeocodeAddress(address)
|
||||
if err == nil {
|
||||
destLat = location.Latitude
|
||||
destLon = location.Longitude
|
||||
coordsSource = "GEOCODING"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if destLat == 0 || destLon == 0 {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"can_validate": false,
|
||||
"reason": "Coordonnées de destination non disponibles",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Calculer la distance
|
||||
distance := services.CalculateDistance(
|
||||
services.Coordinates{Latitude: livreurLat, Longitude: livreurLon},
|
||||
services.Coordinates{Latitude: destLat, Longitude: destLon},
|
||||
)
|
||||
|
||||
distanceMeters := distance * 1000
|
||||
canValidate := distance <= MAX_DELIVERY_VALIDATION_DISTANCE_KM
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"can_validate": canValidate,
|
||||
"your_position": gin.H{
|
||||
"latitude": livreurLat,
|
||||
"longitude": livreurLon,
|
||||
},
|
||||
"destination": gin.H{
|
||||
"latitude": destLat,
|
||||
"longitude": destLon,
|
||||
"address": command["adresse"],
|
||||
"source": coordsSource,
|
||||
},
|
||||
"distance_meters": int(distanceMeters),
|
||||
"max_allowed_meters": MAX_DELIVERY_VALIDATION_DISTANCE_METERS,
|
||||
"remaining_meters": maxInt(0, int(distanceMeters)-MAX_DELIVERY_VALIDATION_DISTANCE_METERS),
|
||||
"message": func() string {
|
||||
if canValidate {
|
||||
return "Vous pouvez valider cette livraison"
|
||||
}
|
||||
return fmt.Sprintf("Rapprochez-vous de %.0f mètres pour valider", distanceMeters-float64(MAX_DELIVERY_VALIDATION_DISTANCE_METERS))
|
||||
}(),
|
||||
})
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// 3️⃣ DÉMARRER UNE LIVRAISON (PASSER EN IN_ROUTE)
|
||||
// ============================================
|
||||
@@ -395,14 +150,3 @@ func StartDelivery(c *gin.Context) {
|
||||
"status": "en_route",
|
||||
})
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// HELPERS
|
||||
// ============================================
|
||||
|
||||
func maxInt(a, b int) int {
|
||||
if a > b {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
+27
-4
@@ -1,7 +1,3 @@
|
||||
// ============================================
|
||||
// main.go - VERSION SIMPLIFIÉE AVEC CLEANUP AUTO
|
||||
// ============================================
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
@@ -42,6 +38,13 @@ func main() {
|
||||
geoService := services.NewGeoService(db.Redis, db.RedisCtx)
|
||||
log.Println("✅ Service de géolocalisation initialisé")
|
||||
|
||||
lbService := services.NewLBTelegramService()
|
||||
if lbService.IsConfigured() {
|
||||
log.Println("✅ Service LBTelegram initialisé")
|
||||
} else {
|
||||
log.Println("ℹ️ Service LBTelegram désactivé (LBTELEGRAM_URL non défini)")
|
||||
}
|
||||
|
||||
telegramService := services.NewTelegramService()
|
||||
if telegramService.IsConfigured() {
|
||||
log.Println("✅ Service Telegram initialisé")
|
||||
@@ -69,6 +72,26 @@ func main() {
|
||||
}
|
||||
}
|
||||
|
||||
// Ré-enrôler tous les comptes déjà liés dans lbtelegram (au cas où lbtelegram a redémarré)
|
||||
if lbService.IsConfigured() {
|
||||
go func() {
|
||||
accounts, err := database.GetAllLinkedTelegramAccounts()
|
||||
if err != nil {
|
||||
log.Printf("⚠️ [LB_SYNC] Erreur lecture comptes liés: %v", err)
|
||||
return
|
||||
}
|
||||
ok, fail := 0, 0
|
||||
for _, a := range accounts {
|
||||
if err := lbService.EnrollUser(a.ChatID, a.Username, a.Role); err != nil {
|
||||
fail++
|
||||
} else {
|
||||
ok++
|
||||
}
|
||||
}
|
||||
log.Printf("✅ [LB_SYNC] Re-enrollment terminé: %d OK, %d échecs (total %d comptes)", ok, fail, len(accounts))
|
||||
}()
|
||||
}
|
||||
|
||||
log.Println("")
|
||||
log.Println("🧹 Démarrage du nettoyage des commandes invalides...")
|
||||
removed, err := database.CleanupInvalidQueueCommands()
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"gestion/db"
|
||||
"log"
|
||||
"net/http"
|
||||
@@ -60,7 +61,7 @@ func BlockClientIfPenalty(c *gin.Context) {
|
||||
if amende > 0 {
|
||||
log.Printf("🚫 [PENALTY] Checkout bloqué pour %s (amende=%.2f via DB)", usernameStr, amende)
|
||||
c.JSON(http.StatusForbidden, gin.H{
|
||||
"error": "Commande bloquée : vous avez une amende en attente de paiement",
|
||||
"error": fmt.Sprintf("Commande bloquée : vous avez une amende de %.0f€ en attente de paiement. Prenez attache avec Milieu Nantais sur signal pour régulariser votre situation..", amende),
|
||||
"amende": amende,
|
||||
"blocked": true,
|
||||
})
|
||||
|
||||
@@ -21,7 +21,6 @@ func OrderHoursMiddleware(c *gin.Context) {
|
||||
hour := now.Hour()
|
||||
min := now.Minute()
|
||||
|
||||
// Récupérer le planning depuis les settings DB
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
settings, err := database.GetSettings()
|
||||
if err != nil {
|
||||
|
||||
@@ -54,7 +54,7 @@ var (
|
||||
// ============================================
|
||||
|
||||
// validateClientToken valide un token client
|
||||
func validateClientToken(tokenString string, database *db.Database) (*ClientClaims, error) {
|
||||
func validateClientToken(tokenString string) (*ClientClaims, error) {
|
||||
tokenString = strings.TrimSpace(tokenString)
|
||||
if tokenString == "" {
|
||||
return nil, fmt.Errorf("token vide")
|
||||
@@ -103,7 +103,7 @@ func validateClientToken(tokenString string, database *db.Database) (*ClientClai
|
||||
}
|
||||
|
||||
// validateAdminToken valide un token admin
|
||||
func validateAdminToken(tokenString string, database *db.Database) (*AdminClaims, error) {
|
||||
func validateAdminToken(tokenString string) (*AdminClaims, error) {
|
||||
tokenString = strings.TrimSpace(tokenString)
|
||||
if tokenString == "" {
|
||||
return nil, fmt.Errorf("token vide")
|
||||
@@ -161,7 +161,7 @@ func ClientMiddleware(c *gin.Context) {
|
||||
tokenStr := strings.TrimPrefix(authHeader, "Bearer ")
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
|
||||
claims, err := validateClientToken(tokenStr, database)
|
||||
claims, err := validateClientToken(tokenStr)
|
||||
if err != nil {
|
||||
log.Printf("❌ [CLIENT-MWARE] Token invalide: %v", err)
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Token invalide"})
|
||||
@@ -205,7 +205,7 @@ func AdminMiddleware(c *gin.Context) {
|
||||
tokenStr := strings.TrimPrefix(authHeader, "Bearer ")
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
|
||||
claims, err := validateAdminToken(tokenStr, database)
|
||||
claims, err := validateAdminToken(tokenStr)
|
||||
if err != nil {
|
||||
log.Printf("❌ [ADMIN-MWARE] Token invalide: %v", err)
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Token admin invalide"})
|
||||
@@ -258,7 +258,7 @@ func CabineMiddleware(c *gin.Context) {
|
||||
tokenStr := strings.TrimPrefix(authHeader, "Bearer ")
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
|
||||
claims, err := validateAdminToken(tokenStr, database)
|
||||
claims, err := validateAdminToken(tokenStr)
|
||||
if err != nil {
|
||||
log.Printf("❌ [CABINE-MWARE] Token invalide: %v", err)
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Token invalide"})
|
||||
@@ -312,7 +312,7 @@ func LivreurMiddleware(c *gin.Context) {
|
||||
tokenStr := strings.TrimPrefix(authHeader, "Bearer ")
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
|
||||
claims, err := validateAdminToken(tokenStr, database)
|
||||
claims, err := validateAdminToken(tokenStr)
|
||||
if err != nil {
|
||||
log.Printf("❌ [LIVREUR-MWARE] Token invalide: %v", err)
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Token invalide"})
|
||||
@@ -507,94 +507,3 @@ func LoginRateLimitMiddleware(c *gin.Context) {
|
||||
c.Header("X-RateLimit-Remaining", strconv.FormatInt(10-count, 10))
|
||||
c.Next()
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// HELPER MIDDLEWARE
|
||||
// ============================================
|
||||
|
||||
// VerifyAuthHeader vérifie que le header Authorization est valide
|
||||
func VerifyAuthHeader(c *gin.Context) {
|
||||
authHeader := c.GetHeader("Authorization")
|
||||
|
||||
if authHeader == "" {
|
||||
log.Printf("❌ [AUTH-HEADER] Authorization header manquant")
|
||||
c.JSON(http.StatusUnauthorized, gin.H{
|
||||
"error": "Authorization header manquant",
|
||||
"hint": "Utilisez: Authorization: Bearer <token>",
|
||||
})
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
// Vérifier le format "Bearer <token>"
|
||||
parts := strings.Split(authHeader, " ")
|
||||
if len(parts) != 2 || parts[0] != "Bearer" {
|
||||
log.Printf("❌ [AUTH-HEADER] Format invalide: %s", authHeader)
|
||||
c.JSON(http.StatusUnauthorized, gin.H{
|
||||
"error": "Format Authorization invalide",
|
||||
"hint": "Utilisez: Authorization: Bearer <token>",
|
||||
})
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("✅ [AUTH-HEADER] Format valide")
|
||||
c.Next()
|
||||
}
|
||||
|
||||
// SessionErrorRecovery récupère les erreurs de session
|
||||
func SessionErrorRecovery(c *gin.Context) {
|
||||
defer func() {
|
||||
if err := recover(); err != nil {
|
||||
log.Printf("❌ [SESSION-ERROR] Erreur système: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"error": "Erreur serveur - Session compromise",
|
||||
})
|
||||
}
|
||||
}()
|
||||
|
||||
c.Next()
|
||||
|
||||
if len(c.Errors) > 0 {
|
||||
log.Printf("⚠️ [SESSION] Erreur handler: %v", c.Errors)
|
||||
}
|
||||
}
|
||||
|
||||
// LogSessionMiddleware log toutes les infos de session
|
||||
func LogSessionMiddleware(c *gin.Context) {
|
||||
username, _ := c.Get("username")
|
||||
clientID, _ := c.Get("client_id")
|
||||
sessionID, _ := c.Get("session_id")
|
||||
|
||||
log.Printf("📊 [SESSION-LOG] %s %s | user=%v | client_id=%v | session=%v",
|
||||
c.Request.Method, c.Request.URL.Path, username, clientID, sessionID)
|
||||
|
||||
c.Next()
|
||||
|
||||
log.Printf("📊 [SESSION-LOG] Response: %d", c.Writer.Status())
|
||||
}
|
||||
|
||||
// LoadClientContext charge les infos du client en contexte
|
||||
func LoadClientContext(c *gin.Context, database *db.Database) (*db.SessionData, error) {
|
||||
clientID, ok := c.Get("client_id")
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("client_id manquant du contexte")
|
||||
}
|
||||
|
||||
clientIDInt := clientID.(int)
|
||||
|
||||
// Récupérer la session
|
||||
session, err := database.GetClientSession(clientIDInt)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return session, nil
|
||||
}
|
||||
|
||||
func DatabaseMiddleware(db *db.Database) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
c.Set("database", db)
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -38,7 +38,7 @@ type RegisterClientRequest struct {
|
||||
type RegisterAdminRequest struct {
|
||||
Username string `json:"username" binding:"required,min=3,max=50"`
|
||||
Password string `json:"password" binding:"required,min=8"`
|
||||
Role string `json:"role" binding:"required,oneof=admin cabine livreur"`
|
||||
Role string `json:"role" binding:"required,oneof=cabine livreur"`
|
||||
}
|
||||
|
||||
type LoginResponse struct {
|
||||
|
||||
@@ -18,9 +18,11 @@ type Client struct {
|
||||
MustChangePassword bool `gorm:"column:must_change_password;default:false" json:"must_change_password"`
|
||||
ReferralBalance float64 `gorm:"column:referral_balance;default:0" json:"referral_balance"`
|
||||
PointsExtra map[string]int `gorm:"-" json:"points_extra"`
|
||||
PointsRedeemed map[string]int `gorm:"-" json:"points_redeemed"`
|
||||
Parrain string `gorm:"column:parrain" json:"parrain"`
|
||||
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"`
|
||||
UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"`
|
||||
TwoFAEnabled bool `gorm:"column:two_fa_enabled;default:false" json:"two_fa_enabled"`
|
||||
}
|
||||
|
||||
func (Client) TableName() string { return "clients" }
|
||||
|
||||
@@ -19,14 +19,16 @@ func (Command) TableName() string { return "commandes" }
|
||||
|
||||
// CommandItem représente un produit dans une commande
|
||||
type CommandItem struct {
|
||||
ID int `gorm:"primaryKey;autoIncrement" json:"id"`
|
||||
CommandID int `gorm:"column:command_id" json:"command_id"`
|
||||
Produit string `gorm:"column:produit" json:"produit"`
|
||||
ProductID int `gorm:"column:product_id" json:"product_id"`
|
||||
Quantity float64 `gorm:"column:quantite" json:"quantity"`
|
||||
Price float64 `gorm:"column:prix" json:"price"`
|
||||
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"`
|
||||
UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"`
|
||||
ID int `gorm:"primaryKey;autoIncrement" json:"id"`
|
||||
CommandID int `gorm:"column:command_id" json:"command_id"`
|
||||
Produit string `gorm:"column:produit" json:"produit"`
|
||||
ProductID int `gorm:"column:product_id" json:"product_id"`
|
||||
Quantity float64 `gorm:"column:quantite" json:"quantity"`
|
||||
Price float64 `gorm:"column:prix" json:"price"`
|
||||
IsReward bool `gorm:"column:is_reward" json:"is_reward"`
|
||||
RewardPoolKey string `gorm:"column:reward_pool_key" json:"reward_pool_key,omitempty"`
|
||||
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"`
|
||||
UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"`
|
||||
}
|
||||
|
||||
type CommandLog struct {
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
package models
|
||||
|
||||
type Contact struct {
|
||||
ID int `json:"id" gorm:"primaryKey"`
|
||||
Name string `json:"name" gorm:"not null"`
|
||||
}
|
||||
@@ -11,6 +11,8 @@ type Panier struct {
|
||||
Description string `json:"description"`
|
||||
Quantity float64 `json:"quantity"`
|
||||
Price float64 `json:"price"`
|
||||
IsReward bool `json:"is_reward"`
|
||||
RewardPoolKey string `json:"reward_pool_key,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at,omitempty"`
|
||||
}
|
||||
|
||||
@@ -3,26 +3,28 @@ package models
|
||||
import "time"
|
||||
|
||||
type Product struct {
|
||||
ID int `json:"id" gorm:"primaryKey;autoIncrement"`
|
||||
Name string `json:"name" gorm:"column:name" binding:"required"`
|
||||
Category string `json:"category" gorm:"column:category" binding:"required"`
|
||||
Description string `json:"description" gorm:"column:description"`
|
||||
Stock float64 `json:"stock" gorm:"column:stock"`
|
||||
Unit string `json:"unit" gorm:"column:unit"`
|
||||
Prices []ProductPrice `json:"prices" gorm:"foreignKey:ProductID"`
|
||||
ID int `json:"id" gorm:"primaryKey;autoIncrement"`
|
||||
Name string `json:"name" gorm:"column:name" binding:"required"`
|
||||
Category string `json:"category" gorm:"column:category" binding:"required"`
|
||||
Description string `json:"description" gorm:"column:description"`
|
||||
Stock float64 `json:"stock" gorm:"column:stock"`
|
||||
Unit string `json:"unit" gorm:"column:unit"`
|
||||
ComingSoon bool `json:"coming_soon" gorm:"column:coming_soon;default:false"`
|
||||
Prices []ProductPrice `json:"prices" gorm:"foreignKey:ProductID"`
|
||||
Media []Media `json:"media,omitempty" gorm:"foreignKey:ProductID"`
|
||||
CreatedAt time.Time `json:"created_at" gorm:"autoCreateTime"`
|
||||
UpdatedAt time.Time `json:"updated_at" gorm:"autoUpdateTime"`
|
||||
CreatedAt time.Time `json:"created_at" gorm:"autoCreateTime"`
|
||||
UpdatedAt time.Time `json:"updated_at" gorm:"autoUpdateTime"`
|
||||
}
|
||||
|
||||
func (Product) TableName() string { return "products" }
|
||||
|
||||
type ProductPrice struct {
|
||||
ID int `json:"id" gorm:"primaryKey;autoIncrement"`
|
||||
ProductID int `json:"product_id" gorm:"column:product_id;index"`
|
||||
Quantity float64 `json:"quantity" gorm:"column:quantity" binding:"required"`
|
||||
Price float64 `json:"price" gorm:"column:price" binding:"required"`
|
||||
CreatedAt time.Time `json:"created_at" gorm:"autoCreateTime"`
|
||||
ID int `json:"id" gorm:"primaryKey;autoIncrement"`
|
||||
ProductID int `json:"product_id" gorm:"column:product_id;index"`
|
||||
Quantity float64 `json:"quantity" gorm:"column:quantity" binding:"required"`
|
||||
Price float64 `json:"price" gorm:"column:price" binding:"required"`
|
||||
CreatedAt time.Time `json:"created_at" gorm:"autoCreateTime"`
|
||||
ActivePrice bool `json:"active_price" gorm:"column:active_price;default:true"`
|
||||
}
|
||||
|
||||
func (ProductPrice) TableName() string { return "product_prices" }
|
||||
|
||||
@@ -14,6 +14,29 @@ type PointsTier struct {
|
||||
Points int `json:"points"`
|
||||
}
|
||||
|
||||
// RewardCategoryConfig définit les produits éligibles dans une catégorie pour une récompense
|
||||
type RewardCategoryConfig struct {
|
||||
Category string `json:"category"` // nom de la catégorie
|
||||
AllProducts bool `json:"all_products"` // true = tous les produits de la catégorie
|
||||
ProductIDs []int `json:"product_ids"` // IDs des produits éligibles si AllProducts = false
|
||||
}
|
||||
|
||||
// RewardItem représente un produit offert lors d'une récompense, avec sa quantité et son prix associé
|
||||
type RewardItem struct {
|
||||
ProductID int `json:"product_id"` // ID du produit ajouté au panier
|
||||
Quantity float64 `json:"quantity"` // quantité offerte
|
||||
Price float64 `json:"price"` // valeur indicative affichée au client
|
||||
}
|
||||
|
||||
// PointsReward représente la récompense débloquée à partir d'un seuil de points cumulés
|
||||
type PointsReward struct {
|
||||
Threshold int `json:"threshold"` // points cumulés nécessaires (ex: 20)
|
||||
Type string `json:"type"` // "free_product" | "half_price_product" | "custom"
|
||||
Description string `json:"description"` // description libre affichée au client
|
||||
CategoryConfigs []RewardCategoryConfig `json:"category_configs"` // catégories + produits éligibles
|
||||
RewardItems []RewardItem `json:"reward_items"` // produits ajoutés au panier lors du claim
|
||||
}
|
||||
|
||||
// DaySchedule représente les horaires de livraison pour un jour de la semaine
|
||||
type DaySchedule struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
@@ -61,22 +84,26 @@ type DeliveryModeConfig struct {
|
||||
|
||||
// AppSettings contient les paramètres globaux de l'application
|
||||
type AppSettings struct {
|
||||
PenaltiesEnabled bool `json:"penalties_enabled"`
|
||||
ShowAmendeScore bool `json:"show_amende_score"` // afficher le score d'amendes aux clients/cabine
|
||||
PenaltyTiers []PenaltyTier `json:"penalty_tiers"` // barème des amendes (liste configurable)
|
||||
PointsEnabled bool `json:"points_enabled"` // afficher/activer le système de points
|
||||
PointsPools []PointsPool `json:"points_pools"` // types de points personnalisés
|
||||
ReferralEnabled bool `json:"referral_enabled"` // activer/désactiver le système de parrainage
|
||||
ReferralAmount float64 `json:"referral_amount"` // montant crédité par parrainage
|
||||
CryptoPaymentEnabled bool `json:"crypto_payment_enabled"` // activer/désactiver le paiement crypto
|
||||
CryptoOnly bool `json:"crypto_only"` // forcer le paiement crypto uniquement (pas d'espèces)
|
||||
NowPaymentsAPIKey string `json:"nowpayments_api_key"` // clé API NowPayments
|
||||
NowPaymentsIPNSecret string `json:"nowpayments_ipn_secret"` // secret IPN NowPayments
|
||||
NowPaymentsCurrencies []string `json:"nowpayments_currencies"` // cryptos acceptées (ex: ["btc","eth","ltc"])
|
||||
DeliverySchedule DeliverySchedule `json:"delivery_schedule"` // horaires de livraison par jour
|
||||
PostalZones []PostalZone `json:"postal_zones"` // zones de livraison avec minimum de commande
|
||||
TelegramBotToken string `json:"telegram_bot_token"` // token du bot Telegram (BotFather)
|
||||
TelegramBotUsername string `json:"telegram_bot_username"` // username du bot (sans @)
|
||||
TelegramNotificationsEnabled bool `json:"telegram_notifications_enabled"` // activer/désactiver les notifications Telegram
|
||||
DeliveryMode DeliveryModeConfig `json:"delivery_mode"` // mode d'assignation des livreurs
|
||||
PenaltiesEnabled bool `json:"penalties_enabled"`
|
||||
ShowAmendeScore bool `json:"show_amende_score"` // afficher le score d'amendes aux clients/cabine
|
||||
PenaltyTiers []PenaltyTier `json:"penalty_tiers"` // barème des amendes (liste configurable)
|
||||
PointsEnabled bool `json:"points_enabled"` // afficher/activer le système de points
|
||||
PointsPools []PointsPool `json:"points_pools"` // types de points personnalisés
|
||||
PointsReward *PointsReward `json:"points_reward"` // récompense globale par palier de points
|
||||
ReferralEnabled bool `json:"referral_enabled"` // activer/désactiver le système de parrainage
|
||||
ReferralAmount float64 `json:"referral_amount"` // montant crédité par parrainage
|
||||
CryptoPaymentEnabled bool `json:"crypto_payment_enabled"` // activer/désactiver le paiement crypto
|
||||
CryptoOnly bool `json:"crypto_only"` // forcer le paiement crypto uniquement (pas d'espèces)
|
||||
NowPaymentsAPIKey string `json:"nowpayments_api_key"` // clé API NowPayments
|
||||
NowPaymentsIPNSecret string `json:"nowpayments_ipn_secret"` // secret IPN NowPayments
|
||||
NowPaymentsCurrencies []string `json:"nowpayments_currencies"` // cryptos acceptées (ex: ["btc","eth","ltc"])
|
||||
DeliverySchedule DeliverySchedule `json:"delivery_schedule"` // horaires de livraison par jour
|
||||
PostalZones []PostalZone `json:"postal_zones"` // zones de livraison avec minimum de commande
|
||||
TelegramBotToken string `json:"telegram_bot_token"` // token du bot Telegram (BotFather) — pour le webhook de liaison
|
||||
TelegramBotUsername string `json:"telegram_bot_username"` // username du bot (sans @)
|
||||
TelegramNotificationsEnabled bool `json:"telegram_notifications_enabled"` // activer/désactiver les notifications Telegram
|
||||
DeliveryMode DeliveryModeConfig `json:"delivery_mode"` // mode d'assignation des livreurs
|
||||
ShopName string `json:"shop_name"` // nom affiché dans la sidebar du site client
|
||||
Telegram2FAEnabled bool `json:"telegram_2fa_enabled"` // activer/désactiver l'authentification à deux facteurs
|
||||
ContactTelegram string `json:"contact_telegram"` // numéro de téléphone Telegram du contact
|
||||
}
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
package models
|
||||
|
||||
import "time"
|
||||
|
||||
type WeekdayRow struct {
|
||||
DOW int `gorm:"column:dow"`
|
||||
Count int `gorm:"column:count"`
|
||||
}
|
||||
|
||||
type DayRow struct {
|
||||
Day time.Time `gorm:"column:day"`
|
||||
Count int `gorm:"column:count"`
|
||||
}
|
||||
|
||||
type ProductRow struct {
|
||||
ProductID int `gorm:"column:product_id"`
|
||||
Name string `gorm:"column:name"`
|
||||
Quantity float64 `gorm:"column:total_quantity"`
|
||||
OrderCount int `gorm:"column:order_count"`
|
||||
Revenue float64 `gorm:"column:revenue"`
|
||||
Category string `gorm:"column:category"`
|
||||
CategoryColor string `gorm:"column:category_color"`
|
||||
}
|
||||
|
||||
type HourRow struct {
|
||||
Hour int `gorm:"column:hour"`
|
||||
Count int `gorm:"column:count"`
|
||||
Revenue float64 `gorm:"column:revenue"`
|
||||
}
|
||||
|
||||
type QuantityBreakdownRow struct {
|
||||
ProductID int `gorm:"column:product_id"`
|
||||
ProductName string `gorm:"column:product_name"`
|
||||
Quantity float64 `gorm:"column:quantity"`
|
||||
OrderCount int `gorm:"column:order_count"`
|
||||
TotalSold float64 `gorm:"column:total_sold"`
|
||||
Revenue float64 `gorm:"column:revenue"`
|
||||
CategoryColor string `gorm:"column:category_color"`
|
||||
}
|
||||
|
||||
type DayRevenueRow struct {
|
||||
Day time.Time `gorm:"column:day"`
|
||||
Revenue float64 `gorm:"column:revenue"`
|
||||
}
|
||||
@@ -1,7 +1,3 @@
|
||||
// ============================================
|
||||
// routes/routes.go - VERSION CORRIGÉE COMPLÈTE
|
||||
// ============================================
|
||||
|
||||
package routes
|
||||
|
||||
import (
|
||||
@@ -34,6 +30,7 @@ func SetupRoutes(router *gin.Engine, database *db.Database, geoService *services
|
||||
{
|
||||
authGroupV1.POST("/login", middleware.LoginRateLimitMiddleware, handlers.LoginClient)
|
||||
authGroupV1.POST("/logout", handlers.LogoutClient)
|
||||
authGroupV1.POST("/2fa/verify", middleware.LoginRateLimitMiddleware, handlers.Verify2FAClient)
|
||||
}
|
||||
|
||||
// Route change-password (auth client requise)
|
||||
@@ -107,10 +104,18 @@ func SetupRoutes(router *gin.Engine, database *db.Database, geoService *services
|
||||
cartGroupV1.GET("/profile", handlers.GetMyProfile) // ✅ Récupérer mon profil
|
||||
cartGroupV1.PUT("/profile/update", handlers.UpdateMyProfile) // ✅ Modifier mon profil
|
||||
|
||||
// 🔐 2FA CLIENT
|
||||
cartGroupV1.GET("/two-fa/status", handlers.GetClient2FAStatus)
|
||||
cartGroupV1.POST("/two-fa/toggle", handlers.ToggleClient2FA)
|
||||
|
||||
// 🎁 PARRAINAGE CLIENT
|
||||
cartGroupV1.GET("/referral/balance", handlers.GetMyReferralBalance)
|
||||
cartGroupV1.GET("/parrain", handlers.GetMyParrainInfo)
|
||||
|
||||
// 🏆 POINTS & RÉCOMPENSES CLIENT
|
||||
cartGroupV1.GET("/points/rewards", handlers.GetMyPointsRewards)
|
||||
cartGroupV1.POST("/points/claim", handlers.ClaimMyReward)
|
||||
|
||||
// 💸 STATUT PAIEMENT CRYPTO
|
||||
cartGroupV1.GET("/commands/:id/payment-status", handlers.GetCommandPaymentStatus)
|
||||
}
|
||||
@@ -125,6 +130,11 @@ func SetupRoutes(router *gin.Engine, database *db.Database, geoService *services
|
||||
// ============================================
|
||||
router.POST("/webhook/telegram", handlers.TelegramWebhook)
|
||||
|
||||
// ============================================
|
||||
// 🔗 LIAISON INTERNE TELEGRAM (appelée par LBTelegram)
|
||||
// ============================================
|
||||
router.POST("/api/internal/telegram/link", handlers.InternalTelegramLink)
|
||||
|
||||
// ============================================
|
||||
// 📋 PATTERN v2: ADMIN API
|
||||
// ============================================
|
||||
@@ -134,7 +144,6 @@ func SetupRoutes(router *gin.Engine, database *db.Database, geoService *services
|
||||
// ============================================
|
||||
adminAuthGroupV2 := router.Group("/api/v2/admin/auth")
|
||||
{
|
||||
//adminAuthGroupV2.POST("/register", handlers.RegisterAdmin)
|
||||
adminAuthGroupV2.POST("/login", middleware.LoginRateLimitMiddleware, handlers.LoginAdmin)
|
||||
adminAuthGroupV2.POST("/logout", handlers.LogoutAdmin)
|
||||
}
|
||||
@@ -183,12 +192,21 @@ func SetupRoutes(router *gin.Engine, database *db.Database, geoService *services
|
||||
adminGroupV2.DELETE("/products/:id", handlers.DeleteProduct)
|
||||
adminGroupV2.POST("/products/:id/media", handlers.UploadMedia)
|
||||
adminGroupV2.DELETE("/products/:id/media/:media_id", handlers.DeleteMedia)
|
||||
adminGroupV2.POST("/products/:id/stock", handlers.UpdateStock)
|
||||
// ============================================
|
||||
// CATÉGORIES - GESTION ADMIN
|
||||
// ============================================
|
||||
adminGroupV2.POST("/categories", handlers.CreateCategory)
|
||||
adminGroupV2.PUT("/categories/:id", handlers.UpdateCategory)
|
||||
adminGroupV2.DELETE("/categories/:id", handlers.DeleteCategory)
|
||||
// ============================================
|
||||
// STATISTIQUES ADMIN
|
||||
// ============================================
|
||||
adminGroupV2.GET("/stats", handlers.GetAdminStats)
|
||||
|
||||
adminGroupV2.POST("/active/product/price/:id", handlers.ActivePrice)
|
||||
adminGroupV2.POST("/desactive/product/price/:id", handlers.DesActivePrice)
|
||||
|
||||
// ============================================
|
||||
// COMMANDES - GESTION DE BASE
|
||||
// ============================================
|
||||
@@ -250,9 +268,10 @@ func SetupRoutes(router *gin.Engine, database *db.Database, geoService *services
|
||||
adminGroupV2.POST("/penalty", handlers.ApplyClientPenalty) // Appliquer pénalité
|
||||
adminGroupV2.GET("/client/:username/penalties", handlers.GetClientPenaltiesAdmin) // Voir pénalités client
|
||||
adminGroupV2.POST("/client/:username/penalties/reset", handlers.ResetClientPenaltiesAdmin) // Reset amende
|
||||
adminGroupV2.POST("/client/:username/point/reset", handlers.ResetClientPointAdmin) // Reset points → 0
|
||||
adminGroupV2.POST("/client/:username/points/add", handlers.AddClientPointsAdmin) // Ajouter points par pool
|
||||
adminGroupV2.POST("/client/:username/points/subtract", handlers.SubtractClientPointsAdmin) // Enlever points par pool
|
||||
adminGroupV2.POST("/client/:username/point/reset", handlers.ResetClientPointAdmin) // Reset points → 0
|
||||
adminGroupV2.POST("/client/:username/points/add", handlers.AddClientPointsAdmin) // Ajouter points par pool
|
||||
adminGroupV2.POST("/client/:username/points/subtract", handlers.SubtractClientPointsAdmin) // Enlever points par pool
|
||||
adminGroupV2.POST("/client/:username/rewards/reset", handlers.AdminResetClientRedeemed) // Reset récompenses réclamées
|
||||
adminGroupV2.GET("/penalties/all", handlers.GetAllClientsWithPenalties) // Liste clients avec pénalités
|
||||
adminGroupV2.GET("/penalties/stats", handlers.GetPenaltiesStats)
|
||||
|
||||
@@ -300,6 +319,7 @@ func SetupRoutes(router *gin.Engine, database *db.Database, geoService *services
|
||||
cabineGroupV1.POST("/telegram/link-token", handlers.GenerateAdminLinkToken)
|
||||
cabineGroupV1.DELETE("/telegram/unlink", handlers.UnlinkAdminTelegram)
|
||||
|
||||
cabineGroupV1.GET("/commands", handlers.GetAllCommands)
|
||||
cabineGroupV1.GET("/commands/:id/items", handlers.ShowItems)
|
||||
cabineGroupV1.POST("/commands/:id/confirm-reception", handlers.StaffApproveDelivery)
|
||||
cabineGroupV1.POST("/commands/:id/assign", handlers.AssignDeliveryPerson)
|
||||
@@ -308,9 +328,10 @@ func SetupRoutes(router *gin.Engine, database *db.Database, geoService *services
|
||||
cabineGroupV1.PUT("/items/:item_id/status", handlers.UpdateItemStatus)
|
||||
cabineGroupV1.GET("/commands/:id/deliveryman/location", handlers.GetDeliverymanLocationForCommand)
|
||||
cabineGroupV1.GET("/all/deliveryman", handlers.GetAllDeliveryMen)
|
||||
cabineGroupV1.GET("/delivery-persons/:username", handlers.GetDeliveryPersonDetails)
|
||||
cabineGroupV1.GET("/all/clients", handlers.GetAllClients)
|
||||
cabineGroupV1.DELETE("/commands/:id", handlers.DeleteCommandByCabine)
|
||||
cabineGroupV1.POST("/commands/:id/propose-address", handlers.ProposeAddressChange)
|
||||
// ⭐ NOUVEAU - ANNULATION PAR CABINE
|
||||
cabineGroupV1.GET("/commands/cancelled", handlers.GetAllCancelledOrders)
|
||||
cabineGroupV1.GET("/client/:username/penalties", handlers.GetClientPenaltiesAdmin) // Voir pénalités client
|
||||
cabineGroupV1.POST("/client/:username/penalties/reset", handlers.ResetClientPenaltiesAdmin) // Reset pénalités
|
||||
@@ -338,8 +359,8 @@ func SetupRoutes(router *gin.Engine, database *db.Database, geoService *services
|
||||
livreurGroupV1.GET("/deliveries", handlers.GetMyDeliveries) // ✅ Données filtrées
|
||||
livreurGroupV1.GET("/deliveries/:id", handlers.GetDeliveryDetails) // ✅ Détail filtré
|
||||
livreurGroupV1.POST("/deliveries/:id/start", handlers.StartDelivery)
|
||||
livreurGroupV1.PUT("/deliveries/:id/status", handlers.UpdateDeliveryStatus) // ✅ Avec GPS
|
||||
livreurGroupV1.POST("/deliveries/:id/issue", handlers.ReportDeliveryIssue) // Motif non-livraison
|
||||
livreurGroupV1.PUT("/deliveries/:id/status", handlers.UpdateDeliveryStatus) // ✅ Avec GPS
|
||||
livreurGroupV1.POST("/deliveries/:id/issue", handlers.ReportDeliveryIssue) // Motif non-livraison
|
||||
livreurGroupV1.GET("/deliveries/:id/nav-link", handlers.GetLivreurNavLink) // Lien Waze App
|
||||
|
||||
// ============================================
|
||||
@@ -358,6 +379,7 @@ func SetupRoutes(router *gin.Engine, database *db.Database, geoService *services
|
||||
// QUEUE PERSONNELLE
|
||||
// ============================================
|
||||
livreurGroupV1.GET("/queue", handlers.GetMyQueue)
|
||||
livreurGroupV1.GET("/stats", handlers.GetMyDeliveryStats)
|
||||
|
||||
// ============================================
|
||||
// ALERTES POLICE
|
||||
|
||||
@@ -0,0 +1,536 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"math"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode"
|
||||
|
||||
"golang.org/x/text/runes"
|
||||
"golang.org/x/text/transform"
|
||||
"golang.org/x/text/unicode/norm"
|
||||
)
|
||||
|
||||
// ============================================
|
||||
// TYPES
|
||||
// ============================================
|
||||
|
||||
// AddressSuggestion représente une suggestion de correction
|
||||
type AddressSuggestion struct {
|
||||
OriginalAddress string `json:"original_address"`
|
||||
CorrectedAddress string `json:"corrected_address"`
|
||||
Coordinates Coordinates `json:"coordinates"`
|
||||
Confidence float64 `json:"confidence"` // 0.0 à 1.0
|
||||
CorrectionApplied bool `json:"correction_applied"` // true si une correction a été faite
|
||||
Source string `json:"source"` // "exact", "fuzzy", "structured"
|
||||
}
|
||||
|
||||
// NominatimSuggestion représente une réponse de l'API Nominatim
|
||||
type NominatimSuggestion struct {
|
||||
Latitude float64 `json:"lat,string"`
|
||||
Longitude float64 `json:"lon,string"`
|
||||
DisplayName string `json:"display_name"`
|
||||
Importance float64 `json:"importance"`
|
||||
Type string `json:"type"`
|
||||
Class string `json:"class"`
|
||||
Address struct {
|
||||
HouseNumber string `json:"house_number"`
|
||||
Road string `json:"road"`
|
||||
City string `json:"city"`
|
||||
Town string `json:"town"`
|
||||
Village string `json:"village"`
|
||||
Postcode string `json:"postcode"`
|
||||
Country string `json:"country"`
|
||||
CountryCode string `json:"country_code"`
|
||||
} `json:"address"`
|
||||
}
|
||||
|
||||
// AddressCorrectionService gère la correction des adresses
|
||||
type AddressCorrectionService struct {
|
||||
httpClient *http.Client
|
||||
geoService *GeoService
|
||||
}
|
||||
|
||||
// NewAddressCorrectionService crée une instance du service de correction
|
||||
func NewAddressCorrectionService(geoService *GeoService) *AddressCorrectionService {
|
||||
return &AddressCorrectionService{
|
||||
httpClient: &http.Client{Timeout: 10 * time.Second},
|
||||
geoService: geoService,
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// POINT D'ENTRÉE PRINCIPAL
|
||||
// ============================================
|
||||
|
||||
// ResolveAddress tente de géocoder une adresse avec correction automatique.
|
||||
// Retourne toujours une suggestion, même approximative.
|
||||
// Ordre de résolution :
|
||||
// 1. Géocodage exact → succès immédiat
|
||||
// 2. Nominatim fuzzy search (addressdetails + limit=5)
|
||||
// 3. Décomposition structurée de l'adresse
|
||||
// 4. Erreur explicite avec suggestions si dispo
|
||||
func (acs *AddressCorrectionService) ResolveAddress(rawAddress string) (*AddressSuggestion, error) {
|
||||
rawAddress = strings.TrimSpace(rawAddress)
|
||||
if rawAddress == "" {
|
||||
return nil, fmt.Errorf("adresse vide")
|
||||
}
|
||||
|
||||
// ── Étape 1 : essai exact via GeoService (utilise le cache Redis) ──
|
||||
if loc, err := acs.geoService.GeocodeAddress(rawAddress); err == nil {
|
||||
return &AddressSuggestion{
|
||||
OriginalAddress: rawAddress,
|
||||
CorrectedAddress: rawAddress,
|
||||
Coordinates: Coordinates{Latitude: loc.Latitude, Longitude: loc.Longitude},
|
||||
Confidence: 1.0,
|
||||
CorrectionApplied: false,
|
||||
Source: "exact",
|
||||
}, nil
|
||||
}
|
||||
|
||||
// ── Étape 2 : fuzzy search Nominatim ──
|
||||
if suggestion, err := acs.nominatimFuzzySearch(rawAddress); err == nil {
|
||||
return suggestion, nil
|
||||
}
|
||||
|
||||
// ── Étape 3 : décomposition structurée ──
|
||||
if suggestion, err := acs.structuredSearch(rawAddress); err == nil {
|
||||
return suggestion, nil
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("adresse introuvable : '%s' — vérifiez l'orthographe ou le code postal", rawAddress)
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// ÉTAPE 2 : FUZZY SEARCH NOMINATIM
|
||||
// ============================================
|
||||
|
||||
// nominatimFuzzySearch interroge Nominatim avec plusieurs variantes de l'adresse
|
||||
func (acs *AddressCorrectionService) nominatimFuzzySearch(address string) (*AddressSuggestion, error) {
|
||||
variants := buildAddressVariants(address)
|
||||
|
||||
for _, variant := range variants {
|
||||
suggestions, err := acs.queryNominatim(variant, 5)
|
||||
if err != nil || len(suggestions) == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
best := suggestions[0]
|
||||
confidence := computeConfidence(address, best.DisplayName, best.Importance)
|
||||
|
||||
// On accepte si la confiance est suffisante
|
||||
if confidence >= 0.40 {
|
||||
corrected := formatNominatimAddress(best)
|
||||
return &AddressSuggestion{
|
||||
OriginalAddress: address,
|
||||
CorrectedAddress: corrected,
|
||||
Coordinates: Coordinates{Latitude: best.Latitude, Longitude: best.Longitude},
|
||||
Confidence: confidence,
|
||||
CorrectionApplied: !strings.EqualFold(normalize(address), normalize(corrected)),
|
||||
Source: "fuzzy",
|
||||
}, nil
|
||||
}
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("aucune correspondance fuzzy trouvée")
|
||||
}
|
||||
|
||||
// queryNominatim exécute une requête vers l'API Nominatim
|
||||
func (acs *AddressCorrectionService) queryNominatim(query string, limit int) ([]NominatimSuggestion, error) {
|
||||
query = strings.TrimSpace(query)
|
||||
if query == "" {
|
||||
return nil, fmt.Errorf("requête vide")
|
||||
}
|
||||
|
||||
params := url.Values{}
|
||||
params.Set("q", query)
|
||||
params.Set("format", "json")
|
||||
params.Set("addressdetails", "1")
|
||||
params.Set("limit", fmt.Sprintf("%d", limit))
|
||||
params.Set("accept-language", "fr")
|
||||
|
||||
fullURL := fmt.Sprintf("%s?%s", NominatimBaseURL, params.Encode())
|
||||
|
||||
req, err := http.NewRequest("GET", fullURL, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("User-Agent", "DeliveryApp/1.0 (address-correction)")
|
||||
|
||||
// Respect du rate-limit Nominatim : 1 req/s
|
||||
time.Sleep(1100 * time.Millisecond)
|
||||
|
||||
resp, err := acs.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("Nominatim status %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var results []NominatimSuggestion
|
||||
if err := json.Unmarshal(body, &results); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return results, nil
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// ÉTAPE 3 : RECHERCHE STRUCTURÉE
|
||||
// ============================================
|
||||
|
||||
// structuredSearch décompose l'adresse et cherche les parties clés
|
||||
func (acs *AddressCorrectionService) structuredSearch(address string) (*AddressSuggestion, error) {
|
||||
parts := parseAddressParts(address)
|
||||
|
||||
// Essai 1 : numéro + rue + ville (sans code postal)
|
||||
if parts.streetNumber != "" && parts.streetName != "" && parts.city != "" {
|
||||
q := fmt.Sprintf("%s %s, %s", parts.streetNumber, parts.streetName, parts.city)
|
||||
if s, err := acs.nominatimFuzzySearch(q); err == nil {
|
||||
s.OriginalAddress = address
|
||||
s.Source = "structured"
|
||||
return s, nil
|
||||
}
|
||||
}
|
||||
|
||||
// Essai 2 : rue + code postal uniquement
|
||||
if parts.streetName != "" && parts.postcode != "" {
|
||||
q := fmt.Sprintf("%s, %s", parts.streetName, parts.postcode)
|
||||
if s, err := acs.nominatimFuzzySearch(q); err == nil {
|
||||
s.OriginalAddress = address
|
||||
s.Source = "structured"
|
||||
return s, nil
|
||||
}
|
||||
}
|
||||
|
||||
// Essai 3 : ville + code postal comme zone de repli
|
||||
if parts.city != "" && parts.postcode != "" {
|
||||
q := fmt.Sprintf("%s %s, France", parts.city, parts.postcode)
|
||||
suggestions, err := acs.queryNominatim(q, 3)
|
||||
if err == nil && len(suggestions) > 0 {
|
||||
best := suggestions[0]
|
||||
return &AddressSuggestion{
|
||||
OriginalAddress: address,
|
||||
CorrectedAddress: best.DisplayName,
|
||||
Coordinates: Coordinates{Latitude: best.Latitude, Longitude: best.Longitude},
|
||||
Confidence: 0.30, // faible : seulement ville/CP trouvés
|
||||
CorrectionApplied: true,
|
||||
Source: "structured_partial",
|
||||
}, nil
|
||||
}
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("recherche structurée échouée")
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// VARIANTES D'ADRESSE
|
||||
// ============================================
|
||||
|
||||
// buildAddressVariants génère plusieurs variantes d'une adresse pour maximiser les chances
|
||||
func buildAddressVariants(address string) []string {
|
||||
variants := []string{address}
|
||||
normalized := normalize(address)
|
||||
|
||||
// Variante sans accents
|
||||
if normalized != address {
|
||||
variants = append(variants, normalized)
|
||||
}
|
||||
|
||||
// Variante avec "France" si absent
|
||||
if !strings.Contains(strings.ToLower(address), "france") {
|
||||
variants = append(variants, address+", France")
|
||||
}
|
||||
|
||||
// Variante en corrigeant les abréviations courantes françaises
|
||||
expanded := expandFrenchAbbreviations(address)
|
||||
if expanded != address {
|
||||
variants = append(variants, expanded)
|
||||
variants = append(variants, expanded+", France")
|
||||
}
|
||||
|
||||
// Variante en supprimant les mots de liaison potentiellement mal orthographiés
|
||||
simplified := simplifyStreetName(address)
|
||||
if simplified != address {
|
||||
variants = append(variants, simplified)
|
||||
}
|
||||
|
||||
// Dédoublonnage tout en conservant l'ordre
|
||||
seen := map[string]bool{}
|
||||
unique := make([]string, 0, len(variants))
|
||||
for _, v := range variants {
|
||||
if !seen[v] {
|
||||
seen[v] = true
|
||||
unique = append(unique, v)
|
||||
}
|
||||
}
|
||||
|
||||
return unique
|
||||
}
|
||||
|
||||
// expandFrenchAbbreviations remplace les abréviations courantes
|
||||
func expandFrenchAbbreviations(address string) string {
|
||||
replacements := []struct{ from, to string }{
|
||||
{"Av.", "Avenue"},
|
||||
{"Ave.", "Avenue"},
|
||||
{"Bd.", "Boulevard"},
|
||||
{"Bld.", "Boulevard"},
|
||||
{"Blvd.", "Boulevard"},
|
||||
{"Rte.", "Route"},
|
||||
{"Rte ", "Route "},
|
||||
{"Imp.", "Impasse"},
|
||||
{"Cité", "Cité"},
|
||||
{"Sq.", "Square"},
|
||||
{"Pl.", "Place"},
|
||||
{"Rés.", "Résidence"},
|
||||
}
|
||||
|
||||
result := address
|
||||
for _, r := range replacements {
|
||||
result = strings.ReplaceAll(result, r.from, r.to)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// simplifyStreetName essaie de nettoyer la rue (retire les particules ambiguës)
|
||||
func simplifyStreetName(address string) string {
|
||||
// Ex: "20 Rue Gabriel le Pan de Ligny" → essai sans "le" → "20 Rue Gabriel Pan de Ligny"
|
||||
// Heuristique légère : on ne modifie que si la chaîne est suffisamment longue
|
||||
words := strings.Fields(address)
|
||||
if len(words) < 5 {
|
||||
return address
|
||||
}
|
||||
|
||||
// Retire les articles intégrés dans le nom de rue (heuristique)
|
||||
articles := map[string]bool{"le": true, "la": true, "les": true, "de": true, "du": true, "des": true, "d": true}
|
||||
filtered := make([]string, 0, len(words))
|
||||
for i, w := range words {
|
||||
lower := strings.ToLower(w)
|
||||
// Garder le premier mot (numéro) et les mots non-articles, ou les articles en début de nom de rue
|
||||
if i < 2 || !articles[lower] {
|
||||
filtered = append(filtered, w)
|
||||
}
|
||||
}
|
||||
|
||||
result := strings.Join(filtered, " ")
|
||||
if result == address {
|
||||
return address
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// UTILITAIRES
|
||||
// ============================================
|
||||
|
||||
// addressParts regroupe les composants décomposés d'une adresse
|
||||
type addressParts struct {
|
||||
streetNumber string
|
||||
streetName string
|
||||
postcode string
|
||||
city string
|
||||
}
|
||||
|
||||
// parseAddressParts analyse une adresse libre pour en extraire les composants
|
||||
func parseAddressParts(address string) addressParts {
|
||||
var parts addressParts
|
||||
|
||||
// Extraction du code postal (5 chiffres consécutifs)
|
||||
words := strings.Fields(address)
|
||||
remaining := make([]string, 0, len(words))
|
||||
|
||||
for _, w := range words {
|
||||
if isPostcode(w) {
|
||||
parts.postcode = w
|
||||
} else {
|
||||
remaining = append(remaining, w)
|
||||
}
|
||||
}
|
||||
|
||||
if len(remaining) == 0 {
|
||||
return parts
|
||||
}
|
||||
|
||||
// Premier mot numérique → numéro de rue
|
||||
if isNumeric(remaining[0]) {
|
||||
parts.streetNumber = remaining[0]
|
||||
remaining = remaining[1:]
|
||||
}
|
||||
|
||||
// Détection de la ville : dernier groupe après le code postal
|
||||
// Heuristique : si le dernier mot est une ville connue ou commence par une maj
|
||||
if len(remaining) > 0 {
|
||||
last := remaining[len(remaining)-1]
|
||||
if len(last) > 2 && last[0] >= 'A' && last[0] <= 'Z' {
|
||||
parts.city = last
|
||||
remaining = remaining[:len(remaining)-1]
|
||||
}
|
||||
}
|
||||
|
||||
parts.streetName = strings.Join(remaining, " ")
|
||||
|
||||
return parts
|
||||
}
|
||||
|
||||
// computeConfidence calcule un score de similarité entre l'adresse originale et la suggestion
|
||||
func computeConfidence(original, suggested string, nominatimImportance float64) float64 {
|
||||
origNorm := normalize(strings.ToLower(original))
|
||||
suggNorm := normalize(strings.ToLower(suggested))
|
||||
|
||||
// Score de similarité sur les mots communs
|
||||
origWords := strings.Fields(origNorm)
|
||||
suggWords := strings.Fields(suggNorm)
|
||||
|
||||
commonCount := 0
|
||||
for _, ow := range origWords {
|
||||
if len(ow) < 3 {
|
||||
continue // ignorer les petits mots
|
||||
}
|
||||
for _, sw := range suggWords {
|
||||
if strings.Contains(sw, ow) || strings.Contains(ow, sw) || levenshteinRatio(ow, sw) > 0.75 {
|
||||
commonCount++
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var wordScore float64
|
||||
if len(origWords) > 0 {
|
||||
wordScore = float64(commonCount) / float64(len(origWords))
|
||||
}
|
||||
|
||||
// Combinaison : 70% similarité textuelle + 30% importance Nominatim
|
||||
importance := math.Min(nominatimImportance, 1.0)
|
||||
return wordScore*0.70 + importance*0.30
|
||||
}
|
||||
|
||||
// formatNominatimAddress formate l'adresse complète depuis une suggestion Nominatim
|
||||
func formatNominatimAddress(s NominatimSuggestion) string {
|
||||
addr := s.Address
|
||||
var parts []string
|
||||
|
||||
if addr.HouseNumber != "" && addr.Road != "" {
|
||||
parts = append(parts, addr.HouseNumber+" "+addr.Road)
|
||||
} else if addr.Road != "" {
|
||||
parts = append(parts, addr.Road)
|
||||
}
|
||||
|
||||
city := addr.City
|
||||
if city == "" {
|
||||
city = addr.Town
|
||||
}
|
||||
if city == "" {
|
||||
city = addr.Village
|
||||
}
|
||||
|
||||
if addr.Postcode != "" {
|
||||
parts = append(parts, addr.Postcode)
|
||||
}
|
||||
if city != "" {
|
||||
parts = append(parts, city)
|
||||
}
|
||||
|
||||
if len(parts) == 0 {
|
||||
return s.DisplayName
|
||||
}
|
||||
return strings.Join(parts, ", ")
|
||||
}
|
||||
|
||||
// normalize supprime les accents et normalise les espaces
|
||||
func normalize(s string) string {
|
||||
t := transform.Chain(norm.NFD, runes.Remove(runes.In(unicode.Mn)), norm.NFC)
|
||||
result, _, _ := transform.String(t, s)
|
||||
return strings.Join(strings.Fields(result), " ")
|
||||
}
|
||||
|
||||
// isPostcode retourne true si le mot ressemble à un code postal français
|
||||
func isPostcode(s string) bool {
|
||||
if len(s) != 5 {
|
||||
return false
|
||||
}
|
||||
for _, c := range s {
|
||||
if c < '0' || c > '9' {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// isNumeric retourne true si la chaîne est entièrement numérique
|
||||
func isNumeric(s string) bool {
|
||||
for _, c := range s {
|
||||
if c < '0' || c > '9' {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return len(s) > 0
|
||||
}
|
||||
|
||||
// levenshteinRatio retourne un ratio de similarité entre 0 et 1
|
||||
func levenshteinRatio(a, b string) float64 {
|
||||
d := levenshtein(a, b)
|
||||
maxLen := math.Max(float64(len(a)), float64(len(b)))
|
||||
if maxLen == 0 {
|
||||
return 1.0
|
||||
}
|
||||
return 1.0 - float64(d)/maxLen
|
||||
}
|
||||
|
||||
// levenshtein calcule la distance de Levenshtein entre deux chaînes
|
||||
func levenshtein(a, b string) int {
|
||||
ra, rb := []rune(a), []rune(b)
|
||||
la, lb := len(ra), len(rb)
|
||||
|
||||
if la == 0 {
|
||||
return lb
|
||||
}
|
||||
if lb == 0 {
|
||||
return la
|
||||
}
|
||||
|
||||
dp := make([][]int, la+1)
|
||||
for i := range dp {
|
||||
dp[i] = make([]int, lb+1)
|
||||
dp[i][0] = i
|
||||
}
|
||||
for j := 0; j <= lb; j++ {
|
||||
dp[0][j] = j
|
||||
}
|
||||
|
||||
for i := 1; i <= la; i++ {
|
||||
for j := 1; j <= lb; j++ {
|
||||
cost := 1
|
||||
if ra[i-1] == rb[j-1] {
|
||||
cost = 0
|
||||
}
|
||||
dp[i][j] = min3(dp[i-1][j]+1, dp[i][j-1]+1, dp[i-1][j-1]+cost)
|
||||
}
|
||||
}
|
||||
return dp[la][lb]
|
||||
}
|
||||
|
||||
func min3(a, b, c int) int {
|
||||
if a < b {
|
||||
if a < c {
|
||||
return a
|
||||
}
|
||||
return c
|
||||
}
|
||||
if b < c {
|
||||
return b
|
||||
}
|
||||
return c
|
||||
}
|
||||
@@ -6,10 +6,10 @@ import (
|
||||
"fmt"
|
||||
"gestion/models"
|
||||
"io"
|
||||
"log"
|
||||
"math"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -28,10 +28,6 @@ const (
|
||||
LocationTTL = 1 * time.Hour
|
||||
)
|
||||
|
||||
// ============================================
|
||||
// STRUCTURES
|
||||
// ============================================
|
||||
|
||||
type GeoLocation struct {
|
||||
Latitude float64 `json:"lat,string"`
|
||||
Longitude float64 `json:"lon,string"`
|
||||
@@ -51,43 +47,68 @@ type DeliveryDistance struct {
|
||||
}
|
||||
|
||||
type GeoService struct {
|
||||
redis *redis.Client
|
||||
ctx context.Context
|
||||
httpClient *http.Client
|
||||
redis *redis.Client
|
||||
ctx context.Context
|
||||
httpClient *http.Client
|
||||
correctionService *AddressCorrectionService
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// CONSTRUCTEUR
|
||||
// ============================================
|
||||
|
||||
func NewGeoService(redisClient *redis.Client, ctx context.Context) *GeoService {
|
||||
return &GeoService{
|
||||
gs := &GeoService{
|
||||
redis: redisClient,
|
||||
ctx: ctx,
|
||||
httpClient: &http.Client{
|
||||
Timeout: 10 * time.Second,
|
||||
},
|
||||
}
|
||||
// Le correctionService est initialisé après, car il a besoin de gs lui-même
|
||||
gs.correctionService = NewAddressCorrectionService(gs)
|
||||
return gs
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// GÉOCODAGE - API NOMINATIM
|
||||
// ============================================
|
||||
|
||||
func (gs *GeoService) GeocodeAddress(address string) (*GeoLocation, error) {
|
||||
// 1. Vérifier le cache Redis
|
||||
location, err := gs.getFromCache(address)
|
||||
if err == nil {
|
||||
// 1. Cache Redis (adresse originale)
|
||||
if location, err := gs.getFromCache(address); err == nil {
|
||||
return location, nil
|
||||
}
|
||||
|
||||
location, err = gs.fetchFromNominatim(address)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
// 2. Tentative directe via Nominatim
|
||||
if location, err := gs.fetchFromNominatim(address); err == nil {
|
||||
gs.saveToCache(address, location)
|
||||
return location, nil
|
||||
}
|
||||
|
||||
// 3. Sauvegarder en cache
|
||||
// 3. ── NOUVEAU : correction automatique de l'adresse ──────────────────
|
||||
// Déclenché uniquement si le géocodage direct a échoué.
|
||||
log.Printf("🔍 [GEO] Géocodage direct échoué pour '%s', tentative de correction...", address)
|
||||
|
||||
suggestion, err := gs.correctionService.ResolveAddress(address)
|
||||
if err != nil {
|
||||
log.Printf("❌ [GEO] Correction impossible pour '%s': %v", address, err)
|
||||
return nil, fmt.Errorf("adresse introuvable : '%s'", address)
|
||||
}
|
||||
|
||||
if suggestion.CorrectionApplied {
|
||||
log.Printf(
|
||||
"✅ [GEO] Correction appliquée (confiance %.0f%%) : '%s' → '%s'",
|
||||
suggestion.Confidence*100,
|
||||
address,
|
||||
suggestion.CorrectedAddress,
|
||||
)
|
||||
}
|
||||
|
||||
location := &GeoLocation{
|
||||
Latitude: suggestion.Coordinates.Latitude,
|
||||
Longitude: suggestion.Coordinates.Longitude,
|
||||
DisplayName: suggestion.CorrectedAddress,
|
||||
}
|
||||
|
||||
// Mettre en cache avec l'adresse originale pour les prochains appels
|
||||
gs.saveToCache(address, location)
|
||||
// Mettre en cache aussi avec l'adresse corrigée
|
||||
if suggestion.CorrectionApplied {
|
||||
gs.saveToCache(suggestion.CorrectedAddress, location)
|
||||
}
|
||||
|
||||
return location, nil
|
||||
}
|
||||
@@ -244,32 +265,37 @@ func CalculateETA(distanceKm float64) int {
|
||||
// CalculateETAWithTomTom calcule l'ETA via TomTom API (précis avec trafic réel)
|
||||
// Retourne (etaMinutes, distanceKm, error)
|
||||
func CalculateETAWithTomTom(from, to Coordinates) (int, float64, error) {
|
||||
apiKey := os.Getenv("TOMTOM_API_KEY")
|
||||
if apiKey == "" {
|
||||
// Fallback sur calcul local si pas de clé API
|
||||
if len(tomTomKeys.keys) == 0 {
|
||||
distance := CalculateDistance(from, to)
|
||||
return CalculateETA(distance), distance, nil
|
||||
}
|
||||
|
||||
// API TomTom Routing: Calculate Route avec trafic
|
||||
apiURL := fmt.Sprintf(
|
||||
"https://api.tomtom.com/routing/1/calculateRoute/%f,%f:%f,%f/json?key=%s&traffic=true&travelMode=car",
|
||||
from.Latitude, from.Longitude, to.Latitude, to.Longitude, apiKey,
|
||||
)
|
||||
|
||||
client := &http.Client{Timeout: 8 * time.Second}
|
||||
resp, err := client.Get(apiURL)
|
||||
|
||||
buildReq := func(key string) (*http.Request, error) {
|
||||
u := &url.URL{
|
||||
Scheme: "https",
|
||||
Host: "api.tomtom.com",
|
||||
Path: fmt.Sprintf("/routing/1/calculateRoute/%f,%f:%f,%f/json", from.Latitude, from.Longitude, to.Latitude, to.Longitude),
|
||||
}
|
||||
q := url.Values{}
|
||||
q.Set("key", key)
|
||||
q.Set("traffic", "true")
|
||||
q.Set("travelMode", "car")
|
||||
u.RawQuery = q.Encode()
|
||||
return http.NewRequest(http.MethodGet, u.String(), nil)
|
||||
}
|
||||
|
||||
resp, err := tomTomKeys.Do(client, buildReq)
|
||||
if err != nil {
|
||||
// Fallback sur calcul local en cas d'erreur réseau
|
||||
distance := CalculateDistance(from, to)
|
||||
eta := CalculateETA(distance)
|
||||
fmt.Printf("⚠️ TomTom timeout, fallback: %.2f km -> %d min\n", distance, eta)
|
||||
fmt.Printf("⚠️ TomTom indisponible, fallback: %.2f km -> %d min (%v)\n", distance, eta, err)
|
||||
return eta, distance, nil
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
// Fallback sur calcul local en cas d'erreur API
|
||||
distance := CalculateDistance(from, to)
|
||||
eta := CalculateETA(distance)
|
||||
fmt.Printf("⚠️ TomTom API error %d, fallback: %.2f km -> %d min\n", resp.StatusCode, distance, eta)
|
||||
@@ -294,18 +320,14 @@ func CalculateETAWithTomTom(from, to Coordinates) (int, float64, error) {
|
||||
}
|
||||
|
||||
summary := routeResponse.Routes[0].Summary
|
||||
|
||||
// Calculer ETA en minutes (arrondi supérieur)
|
||||
etaMinutes := (summary.TravelTimeInSeconds + 59) / 60
|
||||
distanceKm := float64(summary.LengthInMeters) / 1000.0
|
||||
|
||||
// Appliquer minimum
|
||||
if etaMinutes < MinETA {
|
||||
etaMinutes = MinETA
|
||||
}
|
||||
|
||||
fmt.Printf("🛣️ TomTom: %.2f km -> %d min (trafic réel)\n", distanceKm, etaMinutes)
|
||||
|
||||
return etaMinutes, distanceKm, nil
|
||||
}
|
||||
|
||||
@@ -503,13 +525,13 @@ func (gs *GeoService) GetAllDeliveryDistances(target Coordinates, availableUsern
|
||||
// ============================================
|
||||
|
||||
// GetDeliveryHeatmap retourne toutes les positions des livreurs
|
||||
func (gs *GeoService) GetDeliveryHeatmap() ([]map[string]interface{}, error) {
|
||||
func (gs *GeoService) GetDeliveryHeatmap() ([]map[string]any, error) {
|
||||
keys, err := gs.redis.Keys(gs.ctx, "delivery:location:*").Result()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var heatmap []map[string]interface{}
|
||||
var heatmap []map[string]any
|
||||
|
||||
for _, key := range keys {
|
||||
data, err := gs.redis.Get(gs.ctx, key).Result()
|
||||
@@ -517,7 +539,7 @@ func (gs *GeoService) GetDeliveryHeatmap() ([]map[string]interface{}, error) {
|
||||
continue
|
||||
}
|
||||
|
||||
var location map[string]interface{}
|
||||
var location map[string]any
|
||||
json.Unmarshal([]byte(data), &location)
|
||||
|
||||
username := key[len("delivery:location:"):]
|
||||
@@ -528,3 +550,7 @@ func (gs *GeoService) GetDeliveryHeatmap() ([]map[string]interface{}, error) {
|
||||
|
||||
return heatmap, nil
|
||||
}
|
||||
|
||||
func (gs *GeoService) CorrectionService() *AddressCorrectionService {
|
||||
return gs.correctionService
|
||||
}
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"time"
|
||||
)
|
||||
|
||||
var LBTelegram *LBTelegramService
|
||||
|
||||
type LBTelegramService struct {
|
||||
gatewayURL string
|
||||
Bot1Username string
|
||||
Bot2Username string
|
||||
client *http.Client
|
||||
}
|
||||
|
||||
func NewLBTelegramService() *LBTelegramService {
|
||||
url := os.Getenv("LBTELEGRAM_URL")
|
||||
if url == "" {
|
||||
url = "http://lbtelegram:8081"
|
||||
}
|
||||
svc := &LBTelegramService{
|
||||
gatewayURL: url,
|
||||
Bot1Username: os.Getenv("LBTELEGRAM_BOT1_USERNAME"),
|
||||
Bot2Username: os.Getenv("LBTELEGRAM_BOT2_USERNAME"),
|
||||
client: &http.Client{Timeout: 10 * time.Second},
|
||||
}
|
||||
LBTelegram = svc
|
||||
return svc
|
||||
}
|
||||
|
||||
func (s *LBTelegramService) IsConfigured() bool {
|
||||
return os.Getenv("LBTELEGRAM_URL") != ""
|
||||
}
|
||||
|
||||
// EnrollUser enrôle un utilisateur auprès de LBTelegram après liaison du compte.
|
||||
// LBTelegram envoie lui-même le message de confirmation (chaîne Bot1→Bot2→Bot3).
|
||||
func (s *LBTelegramService) EnrollUser(chatID int64, username, role string) error {
|
||||
payload := map[string]interface{}{
|
||||
"user_id": chatID,
|
||||
"username": username,
|
||||
"role": role,
|
||||
"chat_id": chatID,
|
||||
}
|
||||
|
||||
body, _ := json.Marshal(payload)
|
||||
resp, err := s.client.Post(s.gatewayURL+"/enrollment/begin", "application/json", bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return fmt.Errorf("enrollment: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
b, _ := io.ReadAll(resp.Body)
|
||||
return fmt.Errorf("enrollment HTTP %d: %s", resp.StatusCode, string(b))
|
||||
}
|
||||
|
||||
log.Printf("✅ [LB] Enrollment OK pour %s (%s)", username, role)
|
||||
return nil
|
||||
}
|
||||
|
||||
// SendNotification envoie un message via la gateway LBTelegram.
|
||||
// Le bot est choisi automatiquement selon la stratégie configurée (failover/roundrobin/leastconn).
|
||||
func (s *LBTelegramService) SendNotification(userID int64, message string) error {
|
||||
payload := map[string]interface{}{
|
||||
"user_id": userID,
|
||||
"message": message,
|
||||
}
|
||||
|
||||
body, _ := json.Marshal(payload)
|
||||
resp, err := s.client.Post(s.gatewayURL+"/notify", "application/json", bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return fmt.Errorf("notify: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
b, _ := io.ReadAll(resp.Body)
|
||||
return fmt.Errorf("notify HTTP %d: %s", resp.StatusCode, string(b))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -10,7 +10,6 @@ import (
|
||||
"time"
|
||||
)
|
||||
|
||||
// TelegramBot est l'instance globale accessible depuis le package db
|
||||
var TelegramBot *TelegramService
|
||||
|
||||
type TelegramService struct {
|
||||
@@ -96,6 +95,52 @@ func (t *TelegramService) SendMessage(chatID int64, text string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// SendMessageWithButtons envoie un message HTML avec des boutons inline (URL buttons).
|
||||
// buttons est une liste de paires [texte, url].
|
||||
func (t *TelegramService) SendMessageWithButtons(chatID int64, text string, buttons [][2]string) error {
|
||||
if !t.IsConfigured() {
|
||||
return fmt.Errorf("telegram non configuré")
|
||||
}
|
||||
|
||||
row := make([]map[string]string, 0, len(buttons))
|
||||
for _, b := range buttons {
|
||||
row = append(row, map[string]string{"text": b[0], "url": b[1]})
|
||||
}
|
||||
|
||||
payload := map[string]interface{}{
|
||||
"chat_id": chatID,
|
||||
"text": text,
|
||||
"parse_mode": "HTML",
|
||||
"reply_markup": map[string]interface{}{
|
||||
"inline_keyboard": [][]map[string]string{row},
|
||||
},
|
||||
}
|
||||
|
||||
body, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshal: %w", err)
|
||||
}
|
||||
|
||||
url := fmt.Sprintf("https://api.telegram.org/bot%s/sendMessage", t.botToken)
|
||||
req, err := http.NewRequest("POST", url, bytes.NewBuffer(body))
|
||||
if err != nil {
|
||||
return fmt.Errorf("création requête: %w", err)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
client := &http.Client{Timeout: 10 * time.Second}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("envoi: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("telegram API status %d", resp.StatusCode)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// SetWebhook enregistre l'URL webhook auprès de Telegram
|
||||
func (t *TelegramService) SetWebhook(webhookURL string) error {
|
||||
if !t.IsConfigured() {
|
||||
@@ -103,7 +148,7 @@ func (t *TelegramService) SetWebhook(webhookURL string) error {
|
||||
}
|
||||
|
||||
payload := map[string]interface{}{
|
||||
"url": webhookURL,
|
||||
"url": webhookURL,
|
||||
"allowed_updates": []string{"message"},
|
||||
}
|
||||
if t.webhookSecret != "" {
|
||||
|
||||
@@ -11,25 +11,30 @@ import (
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"net/url"
|
||||
"time"
|
||||
)
|
||||
|
||||
func GetETAWithTraffic(from, to Coordinates) (etaMinutes int, distanceKm float64, err error) {
|
||||
apiKey := os.Getenv("TOMTOM_API_KEY")
|
||||
if apiKey == "" {
|
||||
return 0, 0, fmt.Errorf("TOMTOM_API_KEY non configurée")
|
||||
client := &http.Client{Timeout: 10 * time.Second}
|
||||
|
||||
buildReq := func(key string) (*http.Request, error) {
|
||||
u := &url.URL{
|
||||
Scheme: "https",
|
||||
Host: "api.tomtom.com",
|
||||
Path: fmt.Sprintf("/routing/1/calculateRoute/%f,%f:%f,%f/json", from.Latitude, from.Longitude, to.Latitude, to.Longitude),
|
||||
}
|
||||
q := url.Values{}
|
||||
q.Set("key", key)
|
||||
q.Set("traffic", "true")
|
||||
q.Set("travelMode", "car")
|
||||
u.RawQuery = q.Encode()
|
||||
return http.NewRequest(http.MethodGet, u.String(), nil)
|
||||
}
|
||||
|
||||
url := fmt.Sprintf(
|
||||
"https://api.tomtom.com/routing/1/calculateRoute/%f,%f:%f,%f/json?key=%s&traffic=true&travelMode=car",
|
||||
from.Latitude, from.Longitude, to.Latitude, to.Longitude, apiKey,
|
||||
)
|
||||
|
||||
client := &http.Client{Timeout: 10 * time.Second}
|
||||
resp, err := client.Get(url)
|
||||
resp, err := tomTomKeys.Do(client, buildReq)
|
||||
if err != nil {
|
||||
return 0, 0, fmt.Errorf("erreur requête TomTom: %w", err)
|
||||
return 0, 0, fmt.Errorf("erreur TomTom: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
@@ -53,12 +58,9 @@ func GetETAWithTraffic(from, to Coordinates) (etaMinutes int, distanceKm float64
|
||||
}
|
||||
|
||||
summary := routeResponse.Routes[0].Summary
|
||||
|
||||
// Calculer ETA en minutes (arrondi supérieur)
|
||||
etaMinutes = (summary.TravelTimeInSeconds + 59) / 60
|
||||
distanceKm = float64(summary.LengthInMeters) / 1000.0
|
||||
|
||||
log.Printf("🛣️ TomTom Routing: %.2f km → %d min (trafic inclus)", distanceKm, etaMinutes)
|
||||
|
||||
return etaMinutes, distanceKm, nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"sync/atomic"
|
||||
)
|
||||
|
||||
type tomTomKeyManager struct {
|
||||
keys []string
|
||||
current atomic.Int32
|
||||
}
|
||||
|
||||
var tomTomKeys = initTomTomKeyManager()
|
||||
|
||||
func initTomTomKeyManager() *tomTomKeyManager {
|
||||
m := &tomTomKeyManager{}
|
||||
seen := map[string]bool{}
|
||||
|
||||
candidates := []string{
|
||||
os.Getenv("TOMTOM_API_KEY"),
|
||||
os.Getenv("TOMTOM_API_KEY_1"),
|
||||
os.Getenv("TOMTOM_API_KEY_2"),
|
||||
os.Getenv("TOMTOM_API_KEY_3"),
|
||||
}
|
||||
for _, k := range candidates {
|
||||
if k != "" && !seen[k] {
|
||||
seen[k] = true
|
||||
m.keys = append(m.keys, k)
|
||||
}
|
||||
}
|
||||
|
||||
log.Printf("🔑 [TOMTOM] %d clé(s) API configurée(s)", len(m.keys))
|
||||
return m
|
||||
}
|
||||
|
||||
// currentKey retourne la clé active et son index.
|
||||
func (m *tomTomKeyManager) currentKey() (string, int) {
|
||||
n := len(m.keys)
|
||||
if n == 0 {
|
||||
return "", -1
|
||||
}
|
||||
idx := int(m.current.Load()) % n
|
||||
return m.keys[idx], idx
|
||||
}
|
||||
|
||||
// rotate passe à la clé suivante.
|
||||
func (m *tomTomKeyManager) rotate(fromIdx int) {
|
||||
n := len(m.keys)
|
||||
if n <= 1 {
|
||||
return
|
||||
}
|
||||
next := int32((fromIdx + 1) % n)
|
||||
m.current.CompareAndSwap(int32(fromIdx), next)
|
||||
log.Printf("🔄 [TOMTOM] Rotation clé %d → clé %d (quota atteint)", fromIdx+1, next+1)
|
||||
}
|
||||
|
||||
// Do exécute la requête en rotant automatiquement sur 403/429.
|
||||
// buildReq doit construire une nouvelle *http.Request pour la clé donnée.
|
||||
func (m *tomTomKeyManager) Do(client *http.Client, buildReq func(key string) (*http.Request, error)) (*http.Response, error) {
|
||||
n := len(m.keys)
|
||||
if n == 0 {
|
||||
return nil, fmt.Errorf("aucune clé TomTom configurée (TOMTOM_API_KEY / TOMTOM_API_KEY_1..3)")
|
||||
}
|
||||
|
||||
_, startIdx := m.currentKey()
|
||||
|
||||
for attempt := 0; attempt < n; attempt++ {
|
||||
idx := (startIdx + attempt) % n
|
||||
key := m.keys[idx]
|
||||
|
||||
req, err := buildReq(key)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if resp.StatusCode == http.StatusForbidden || resp.StatusCode == http.StatusTooManyRequests {
|
||||
io.Copy(io.Discard, resp.Body)
|
||||
resp.Body.Close()
|
||||
m.rotate(idx)
|
||||
continue
|
||||
}
|
||||
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("toutes les clés TomTom ont atteint leur quota (%d clé(s) testée(s))", n)
|
||||
}
|
||||
@@ -68,7 +68,9 @@ func AutoAssignWorker(database *db.Database) {
|
||||
nextCommand.CommandID, err)
|
||||
} else {
|
||||
log.Printf("✅ Commande %d auto-assignée", nextCommand.CommandID)
|
||||
database.RemoveCommandFromQueue(nextCommand.CommandID)
|
||||
if err := database.RemoveCommandFromQueue(nextCommand.CommandID); err != nil {
|
||||
log.Printf("⚠️ Impossible de retirer la commande %d de la queue: %v", nextCommand.CommandID, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Generated
-15
@@ -62,7 +62,6 @@
|
||||
"resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.5.tgz",
|
||||
"integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==",
|
||||
"dev": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/code-frame": "^7.27.1",
|
||||
"@babel/generator": "^7.28.5",
|
||||
@@ -874,7 +873,6 @@
|
||||
"resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-svg-core/-/fontawesome-svg-core-7.1.0.tgz",
|
||||
"integrity": "sha512-fNxRUk1KhjSbnbuBxlWSnBLKLBNun52ZBTcs22H/xEEzM6Ap81ZFTQ4bZBxVQGQgVY0xugKGoRcCbaKjLQ3XZA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@fortawesome/fontawesome-common-types": "7.1.0"
|
||||
},
|
||||
@@ -1463,7 +1461,6 @@
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.10.1.tgz",
|
||||
"integrity": "sha512-GNWcUTRBgIRJD5zj+Tq0fKOJ5XZajIiBroOF0yvj2bSU1WvNdYS/dn9UxwsujGW4JX06dnHyjV2y9rRaybH0iQ==",
|
||||
"dev": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"undici-types": "~7.16.0"
|
||||
}
|
||||
@@ -1473,7 +1470,6 @@
|
||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.7.tgz",
|
||||
"integrity": "sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg==",
|
||||
"dev": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"csstype": "^3.2.2"
|
||||
}
|
||||
@@ -1530,7 +1526,6 @@
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.48.0.tgz",
|
||||
"integrity": "sha512-jCzKdm/QK0Kg4V4IK/oMlRZlY+QOcdjv89U2NgKHZk1CYTj82/RVSx1mV/0gqCVMJ/DA+Zf/S4NBWNF8GQ+eqQ==",
|
||||
"dev": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@typescript-eslint/scope-manager": "8.48.0",
|
||||
"@typescript-eslint/types": "8.48.0",
|
||||
@@ -1769,7 +1764,6 @@
|
||||
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
|
||||
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
|
||||
"dev": true,
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"acorn": "bin/acorn"
|
||||
},
|
||||
@@ -1869,7 +1863,6 @@
|
||||
"url": "https://github.com/sponsors/ai"
|
||||
}
|
||||
],
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"baseline-browser-mapping": "^2.8.25",
|
||||
"caniuse-lite": "^1.0.30001754",
|
||||
@@ -2108,7 +2101,6 @@
|
||||
"resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.1.tgz",
|
||||
"integrity": "sha512-BhHmn2yNOFA9H9JmmIVKJmd288g9hrVRDkdoIgRCRuSySRUHH7r/DI6aAXW9T1WwUuY3DFgrcaqB+deURBLR5g==",
|
||||
"dev": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@eslint-community/eslint-utils": "^4.8.0",
|
||||
"@eslint-community/regexpp": "^4.12.1",
|
||||
@@ -2677,7 +2669,6 @@
|
||||
"version": "1.13.2",
|
||||
"resolved": "https://registry.npmjs.org/mapbox-gl/-/mapbox-gl-1.13.2.tgz",
|
||||
"integrity": "sha512-CPjtWygL+f7naL+sGHoC2JQR0DG7u+9ik6WdkjjVmz2uy0kBC2l+aKfdi3ZzUR7VKSQJ6Mc/CeCN+6iVNah+ww==",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@mapbox/geojson-rewind": "^0.5.0",
|
||||
"@mapbox/geojson-types": "^1.0.2",
|
||||
@@ -2887,7 +2878,6 @@
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
|
||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||
"dev": true,
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
@@ -2960,7 +2950,6 @@
|
||||
"version": "19.2.0",
|
||||
"resolved": "https://registry.npmjs.org/react/-/react-19.2.0.tgz",
|
||||
"integrity": "sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ==",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
@@ -2969,7 +2958,6 @@
|
||||
"version": "19.2.0",
|
||||
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.0.tgz",
|
||||
"integrity": "sha512-UlbRu4cAiGaIewkPyiRGJk0imDN2T3JjieT6spoL2UeSf5od4n5LB/mQ4ejmxhCFT1tYe8IvaFulzynWovsEFQ==",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"scheduler": "^0.27.0"
|
||||
},
|
||||
@@ -3228,7 +3216,6 @@
|
||||
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
"tsserver": "bin/tsserver"
|
||||
@@ -3319,7 +3306,6 @@
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-7.2.4.tgz",
|
||||
"integrity": "sha512-NL8jTlbo0Tn4dUEXEsUg8KeyG/Lkmc4Fnzb8JXN/Ykm9G4HNImjtABMJgkQoVjOBN/j2WAwDTRytdqJbZsah7w==",
|
||||
"dev": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"esbuild": "^0.25.0",
|
||||
"fdir": "^6.5.0",
|
||||
@@ -3460,7 +3446,6 @@
|
||||
"resolved": "https://registry.npmjs.org/zod/-/zod-4.1.13.tgz",
|
||||
"integrity": "sha512-AvvthqfqrAhNH9dnfmrfKzX5upOdjUVJYFqNSlkmGf64gRaTzlPwz99IHYnVs28qYAybvAlBV+H7pn0saFY4Ig==",
|
||||
"dev": true,
|
||||
"peer": true,
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/colinhacks"
|
||||
}
|
||||
|
||||
+198
-23
@@ -1,9 +1,3 @@
|
||||
// ============================================
|
||||
// api/api.ts - VERSION CORRIGÉE
|
||||
// ============================================
|
||||
// ✅ AuthResponse inclut access_token
|
||||
// ✅ loginUser et registerUser retournent AuthResponse
|
||||
// ✅ sessionStorage (pas localStorage)
|
||||
const API_URL = "/api/v1";
|
||||
const BACKEND_URL = "";
|
||||
export function getMediaUrl(url: string): string {
|
||||
@@ -28,19 +22,15 @@ import type {
|
||||
CancelCommandResponse,
|
||||
PenaltiesResponse,
|
||||
} from "./api_types";
|
||||
// ============================================
|
||||
// 🔐 TYPES - AUTHRESPONSE COMPLETE
|
||||
// ============================================
|
||||
|
||||
/**
|
||||
* ✅ TYPE CORRECT - Inclut access_token!
|
||||
*/
|
||||
export interface AuthResponse {
|
||||
success: boolean;
|
||||
message?: string;
|
||||
access_token?: string; // ✅ CRITICAL!
|
||||
access_token?: string;
|
||||
token_type?: string;
|
||||
expires_in?: number;
|
||||
requires_2fa?: boolean;
|
||||
session_token?: string;
|
||||
user?: {
|
||||
id: number;
|
||||
username: string;
|
||||
@@ -53,17 +43,8 @@ export interface AuthResponse {
|
||||
};
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// 🔐 GESTION CENTRALISÉE DU JWT
|
||||
// ============================================
|
||||
|
||||
/**
|
||||
* ✅ Extraire username du JWT
|
||||
* Source de vérité UNIQUE pour le username
|
||||
*/
|
||||
export const extractUsernameFromToken = (): string | null => {
|
||||
try {
|
||||
// ✅ CRITICAL: sessionStorage (pas localStorage!)
|
||||
const token = sessionStorage.getItem("token");
|
||||
|
||||
if (!token) {
|
||||
@@ -210,6 +191,15 @@ export const loginUser = async (
|
||||
const data = await safeJson(response);
|
||||
console.log("📋 [LOGIN] Réponse:", data);
|
||||
|
||||
// 2FA requis — retourner sans token
|
||||
if (data.requires_2fa) {
|
||||
return {
|
||||
success: true,
|
||||
requires_2fa: true,
|
||||
session_token: data.session_token,
|
||||
};
|
||||
}
|
||||
|
||||
// ✅ Vérifier access_token
|
||||
if (!data.access_token) {
|
||||
console.error("❌ [LOGIN] Pas de access_token");
|
||||
@@ -728,6 +718,9 @@ export const createCheckout = async (checkoutData: CheckoutData) => {
|
||||
pay_currency: data.pay_currency as string | undefined,
|
||||
price_amount: data.price_amount as number | undefined,
|
||||
price_currency: data.price_currency as string | undefined,
|
||||
referral_used: data.referral_used as number | undefined,
|
||||
referral_balance: data.referral_balance as number | undefined,
|
||||
client_order_number: data.client_order_number as number | undefined,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("❌ [CHECKOUT] Erreur:", error);
|
||||
@@ -804,8 +797,9 @@ export interface Product {
|
||||
category: string;
|
||||
unit?: string;
|
||||
stock: number;
|
||||
prices?: Array<{ quantity: number; price: number }>;
|
||||
prices?: Array<{ quantity: number; price: number; active_price?: boolean }>;
|
||||
media?: MediaItem[]; // ✅ CHANGÉ: string[] → MediaItem[]
|
||||
coming_soon?: boolean;
|
||||
}
|
||||
|
||||
export interface Category {
|
||||
@@ -1866,6 +1860,9 @@ export interface PublicSettings {
|
||||
crypto_payment_enabled: boolean;
|
||||
crypto_only: boolean;
|
||||
nowpayments_currencies: string[];
|
||||
shop_name: string;
|
||||
two_fa_enabled: boolean;
|
||||
contact_telegram: string;
|
||||
}
|
||||
|
||||
export const getPublicSettings = async (): Promise<PublicSettings> => {
|
||||
@@ -1880,6 +1877,9 @@ export const getPublicSettings = async (): Promise<PublicSettings> => {
|
||||
crypto_payment_enabled: false,
|
||||
crypto_only: false,
|
||||
nowpayments_currencies: [],
|
||||
shop_name: "Milieu-Nantais",
|
||||
two_fa_enabled: false,
|
||||
contact_telegram: "",
|
||||
};
|
||||
try {
|
||||
const response = await fetch(`${API_URL}/app-settings`);
|
||||
@@ -1901,12 +1901,93 @@ export const getPublicSettings = async (): Promise<PublicSettings> => {
|
||||
nowpayments_currencies: Array.isArray(data.nowpayments_currencies)
|
||||
? data.nowpayments_currencies
|
||||
: [],
|
||||
shop_name: data.shop_name || "Milieu-Nantais",
|
||||
two_fa_enabled: data.two_fa_enabled ?? false,
|
||||
contact_telegram: data.contact_telegram || "",
|
||||
};
|
||||
} catch {
|
||||
return defaults;
|
||||
}
|
||||
};
|
||||
|
||||
export const verify2FA = async (
|
||||
sessionToken: string,
|
||||
code: string,
|
||||
): Promise<AuthResponse> => {
|
||||
try {
|
||||
const response = await fetch(`${API_URL}/auth/2fa/verify`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ session_token: sessionToken, code }),
|
||||
});
|
||||
const data = await safeJson(response);
|
||||
if (!response.ok) {
|
||||
return { success: false, message: data.error || "Code invalide" };
|
||||
}
|
||||
sessionStorage.setItem("token", data.access_token);
|
||||
syncUsernameFromJWT();
|
||||
return {
|
||||
success: true,
|
||||
access_token: data.access_token,
|
||||
token_type: data.token_type,
|
||||
expires_in: data.expires_in,
|
||||
user: data.user,
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
message: error instanceof Error ? error.message : "Erreur",
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
export const get2FAStatus = async (): Promise<{
|
||||
two_fa_enabled: boolean;
|
||||
telegram_linked: boolean;
|
||||
admin_2fa_enabled: boolean;
|
||||
}> => {
|
||||
const token = sessionStorage.getItem("token");
|
||||
try {
|
||||
const response = await fetch(`${API_URL}/two-fa/status`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
if (!response.ok)
|
||||
return {
|
||||
two_fa_enabled: false,
|
||||
telegram_linked: false,
|
||||
admin_2fa_enabled: false,
|
||||
};
|
||||
return await safeJson(response);
|
||||
} catch {
|
||||
return {
|
||||
two_fa_enabled: false,
|
||||
telegram_linked: false,
|
||||
admin_2fa_enabled: false,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
export const toggle2FA = async (
|
||||
enabled: boolean,
|
||||
): Promise<{ success: boolean; error?: string }> => {
|
||||
const token = sessionStorage.getItem("token");
|
||||
try {
|
||||
const response = await fetch(`${API_URL}/two-fa/toggle`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
body: JSON.stringify({ enabled }),
|
||||
});
|
||||
const data = await safeJson(response);
|
||||
if (!response.ok) return { success: false, error: data.error };
|
||||
return { success: true };
|
||||
} catch {
|
||||
return { success: false, error: "Erreur réseau" };
|
||||
}
|
||||
};
|
||||
|
||||
export interface CryptoPaymentStatus {
|
||||
command_id: number;
|
||||
client_order_number?: number;
|
||||
@@ -2058,3 +2139,97 @@ export const unlinkTelegram = async (): Promise<void> => {
|
||||
/* silencieux */
|
||||
}
|
||||
};
|
||||
|
||||
// ============================================
|
||||
// 🏆 POINTS — RÉCOMPENSES
|
||||
// ============================================
|
||||
|
||||
export type RewardCategoryConfig = {
|
||||
category: string;
|
||||
all_products: boolean;
|
||||
product_ids: number[];
|
||||
product_names: string[];
|
||||
amount: number;
|
||||
};
|
||||
|
||||
export type PointsPoolInfo = {
|
||||
key: string;
|
||||
name: string;
|
||||
points: number;
|
||||
rewards_earned: number;
|
||||
rewards_claimed: number;
|
||||
rewards_available: number;
|
||||
eligible_configs: RewardCategoryConfig[];
|
||||
};
|
||||
|
||||
export type RewardItemConfig = {
|
||||
product_id: number;
|
||||
product_name: string;
|
||||
quantity: number;
|
||||
price: number;
|
||||
};
|
||||
|
||||
export type PointsRewardConfig = {
|
||||
threshold: number;
|
||||
type: string;
|
||||
description: string;
|
||||
reward_items: RewardItemConfig[];
|
||||
};
|
||||
|
||||
export const getMyPointsRewards = async (): Promise<{
|
||||
success: boolean;
|
||||
enabled: boolean;
|
||||
pools: PointsPoolInfo[];
|
||||
reward: PointsRewardConfig | null;
|
||||
}> => {
|
||||
const token = getAuthToken();
|
||||
if (!token) return { success: false, enabled: false, pools: [], reward: null };
|
||||
try {
|
||||
const response = await fetch(`${API_URL}/points/rewards`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
if (!response.ok) return { success: false, enabled: false, pools: [], reward: null };
|
||||
const data = await safeJson(response);
|
||||
return {
|
||||
success: true,
|
||||
enabled: data.enabled ?? false,
|
||||
pools: data.pools ?? [],
|
||||
reward: data.reward ?? null,
|
||||
};
|
||||
} catch {
|
||||
return { success: false, enabled: false, pools: [], reward: null };
|
||||
}
|
||||
};
|
||||
|
||||
export const claimMyReward = async (poolKey: string): Promise<{
|
||||
success: boolean;
|
||||
description?: string;
|
||||
remaining_rewards?: number;
|
||||
product_added?: boolean;
|
||||
product_name?: string;
|
||||
error?: string;
|
||||
}> => {
|
||||
const token = getAuthToken();
|
||||
if (!token) return { success: false, error: "Non authentifié" };
|
||||
try {
|
||||
const response = await fetch(`${API_URL}/points/claim`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
body: JSON.stringify({ pool_key: poolKey }),
|
||||
});
|
||||
const data = await safeJson(response);
|
||||
if (!response.ok) return { success: false, error: data.error || "Erreur" };
|
||||
return {
|
||||
success: true,
|
||||
description: data.description,
|
||||
remaining_rewards: data.remaining_rewards,
|
||||
product_added: data.product_added,
|
||||
product_name: data.product_name,
|
||||
};
|
||||
} catch {
|
||||
return { success: false, error: "Erreur de connexion" };
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,16 +1,7 @@
|
||||
// ============================================
|
||||
// api/api_TYPES.ts - TOUTES LES INTERFACES
|
||||
// ============================================
|
||||
// Interfaces complètes pour le frontend
|
||||
// À importer dans les composants
|
||||
|
||||
// ============================================
|
||||
// 🔐 AUTHENTIFICATION - TYPES
|
||||
// ============================================
|
||||
|
||||
/**
|
||||
* Réponse générique de l'API
|
||||
*/
|
||||
export interface ApiResponse {
|
||||
success: boolean;
|
||||
message?: string;
|
||||
@@ -19,8 +10,7 @@ export interface ApiResponse {
|
||||
token_type?: string;
|
||||
expires_in?: number;
|
||||
user?: UserResponse;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
[key: string]: any; // Pour les champs additionnels
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -58,6 +48,8 @@ export interface LoginResponse {
|
||||
token_type?: string;
|
||||
expires_in?: number;
|
||||
user?: UserResponse;
|
||||
requires_2fa?: boolean;
|
||||
session_token?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -118,6 +110,7 @@ export interface CartItem {
|
||||
quantity: number;
|
||||
category: string;
|
||||
image?: string;
|
||||
is_reward?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -364,6 +357,7 @@ export interface TrackingResponse {
|
||||
export interface ProductPrice {
|
||||
quantity: number;
|
||||
price: number;
|
||||
active_price?: boolean;
|
||||
}
|
||||
export interface Product {
|
||||
id: number;
|
||||
@@ -580,6 +574,7 @@ export interface CompletedOrder {
|
||||
status: string;
|
||||
adresse: string;
|
||||
total_prix: number;
|
||||
referral_used?: number;
|
||||
livreur_assign?: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
@@ -776,6 +771,6 @@ export interface ConfirmReceptionResponse {
|
||||
category?: string;
|
||||
points_earned?: number;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
[key: string]: any;
|
||||
[key: string]: any;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -443,59 +443,106 @@ body {
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
Notification panel
|
||||
Notification bottom-sheet modal
|
||||
============================================================ */
|
||||
.notif-panel {
|
||||
position: absolute;
|
||||
top: calc(var(--topbar-h) - 4px);
|
||||
right: 0;
|
||||
width: 300px;
|
||||
max-height: 380px;
|
||||
.notif-modal-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 1100;
|
||||
background: rgba(0, 0, 0, 0.55);
|
||||
backdrop-filter: blur(3px);
|
||||
-webkit-backdrop-filter: blur(3px);
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
justify-content: center;
|
||||
animation: fadeIn 0.18s ease;
|
||||
}
|
||||
|
||||
.notif-modal {
|
||||
width: 100%;
|
||||
max-width: 640px;
|
||||
height: 70vh;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 14px;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.55);
|
||||
border-top-left-radius: 20px;
|
||||
border-top-right-radius: 20px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
animation: slideDown 0.18s ease;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 -8px 40px rgba(0, 0, 0, 0.5);
|
||||
animation: slideUp 0.26s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
@keyframes slideDown {
|
||||
from { opacity: 0; transform: translateY(-6px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
@keyframes slideUp {
|
||||
from { transform: translateY(100%); }
|
||||
to { transform: translateY(0); }
|
||||
}
|
||||
|
||||
.notif-panel-header {
|
||||
padding: 0.7rem 1rem;
|
||||
.notif-modal-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 1.1rem 1.25rem;
|
||||
border-bottom: 1px solid var(--border);
|
||||
color: var(--text);
|
||||
font-weight: 600;
|
||||
font-size: 0.85rem;
|
||||
letter-spacing: 0.02em;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.notif-modal-title {
|
||||
color: var(--text);
|
||||
font-size: 1.05rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.notif-modal-close {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: transparent;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
color: var(--text-muted);
|
||||
font-size: 0.9rem;
|
||||
cursor: pointer;
|
||||
transition: all var(--transition);
|
||||
}
|
||||
|
||||
.notif-modal-close:hover {
|
||||
background: var(--surface-2);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.notif-modal-body {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: var(--border) transparent;
|
||||
}
|
||||
|
||||
.notif-modal-body::-webkit-scrollbar { width: 4px; }
|
||||
.notif-modal-body::-webkit-scrollbar-thumb { background: var(--border); border-radius: 2px; }
|
||||
|
||||
.notif-empty {
|
||||
padding: 1.5rem;
|
||||
text-align: center;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 3rem 1.5rem;
|
||||
color: var(--text-muted);
|
||||
font-size: 0.85rem;
|
||||
font-size: 0.9rem;
|
||||
gap: 0.5rem;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.notif-list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
overflow-y: auto;
|
||||
max-height: 320px;
|
||||
}
|
||||
.notif-empty p { margin: 0; }
|
||||
|
||||
.notif-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.2rem;
|
||||
padding: 0.7rem 1rem;
|
||||
gap: 0.3rem;
|
||||
padding: 0.9rem 1.25rem;
|
||||
border-bottom: 1px solid var(--border);
|
||||
border-left: 3px solid transparent;
|
||||
transition: background var(--transition);
|
||||
}
|
||||
|
||||
@@ -503,20 +550,22 @@ body {
|
||||
|
||||
.notif-unread {
|
||||
background: var(--primary-soft);
|
||||
border-left: 3px solid var(--primary);
|
||||
border-left-color: var(--primary);
|
||||
}
|
||||
|
||||
.notif-read { opacity: 0.55; }
|
||||
.notif-read {
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
.notif-message {
|
||||
color: var(--text);
|
||||
font-size: 0.83rem;
|
||||
line-height: 1.45;
|
||||
font-size: 0.88rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.notif-time {
|
||||
color: var(--text-muted);
|
||||
font-size: 0.72rem;
|
||||
font-size: 0.74rem;
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
|
||||
@@ -31,6 +31,22 @@ interface MenuItem {
|
||||
path: string;
|
||||
}
|
||||
|
||||
function formatNotifDate(dateStr: string): string {
|
||||
try {
|
||||
const diffMs = Date.now() - new Date(dateStr).getTime();
|
||||
const diffMin = Math.floor(diffMs / 60000);
|
||||
if (diffMin < 1) return "À l'instant";
|
||||
if (diffMin < 60) return `Il y a ${diffMin} min`;
|
||||
const diffH = Math.floor(diffMin / 60);
|
||||
if (diffH < 24) return `Il y a ${diffH}h`;
|
||||
const diffD = Math.floor(diffH / 24);
|
||||
if (diffD === 1) return "Hier";
|
||||
return `Il y a ${diffD} jours`;
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
function Navbar() {
|
||||
const { theme, toggleTheme } = useTheme();
|
||||
const [isMenuOpen, setIsMenuOpen] = useState<boolean>(false);
|
||||
@@ -39,9 +55,9 @@ function Navbar() {
|
||||
const [unreadCount, setUnreadCount] = useState(0);
|
||||
const [showNotifPanel, setShowNotifPanel] = useState(false);
|
||||
const [referralEnabled, setReferralEnabled] = useState(true);
|
||||
const [shopName, setShopName] = useState("Milieu-Nantais");
|
||||
const seenKeysRef = useRef<Set<string>>(new Set());
|
||||
const isFirstLoadRef = useRef(true);
|
||||
const notifPanelRef = useRef<HTMLDivElement>(null);
|
||||
const { cartCount } = useCart();
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
@@ -74,28 +90,23 @@ function Navbar() {
|
||||
}, [fetchNotifications]);
|
||||
|
||||
useEffect(() => {
|
||||
getPublicSettings().then((s) => setReferralEnabled(s.referral_enabled));
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (e: MouseEvent) => {
|
||||
if (notifPanelRef.current && !notifPanelRef.current.contains(e.target as Node)) {
|
||||
setShowNotifPanel(false);
|
||||
}
|
||||
};
|
||||
document.addEventListener("mousedown", handleClickOutside);
|
||||
return () => document.removeEventListener("mousedown", handleClickOutside);
|
||||
getPublicSettings().then((s) => {
|
||||
setReferralEnabled(s.referral_enabled);
|
||||
if (s.shop_name) setShopName(s.shop_name);
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleNotifBellClick = async () => {
|
||||
setShowNotifPanel((prev) => !prev);
|
||||
if (!showNotifPanel && unreadCount > 0) {
|
||||
setShowNotifPanel(true);
|
||||
if (unreadCount > 0) {
|
||||
await markNotificationsRead();
|
||||
setUnreadCount(0);
|
||||
setNotifications((prev) => prev.map((n) => ({ ...n, read: true })));
|
||||
}
|
||||
};
|
||||
|
||||
const closeNotifPanel = () => setShowNotifPanel(false);
|
||||
|
||||
const menuItems: MenuItem[] = [
|
||||
{ id: "accueil", label: "Accueil", icon: faHome, path: "/user/accueil" },
|
||||
{ id: "produits", label: "Nos Produits", icon: faBox, path: "/user/nos-produits" },
|
||||
@@ -156,7 +167,7 @@ function Navbar() {
|
||||
<FontAwesomeIcon icon={isMenuOpen ? faTimes : faBars} />
|
||||
</button>
|
||||
|
||||
<span className="topbar-brand">Milieu‑Nantais</span>
|
||||
<span className="topbar-brand">{shopName}</span>
|
||||
|
||||
<div className="topbar-actions">
|
||||
{/* Theme toggle */}
|
||||
@@ -169,46 +180,18 @@ function Navbar() {
|
||||
</button>
|
||||
|
||||
{/* Notifications */}
|
||||
<div className="notif-wrapper" ref={notifPanelRef}>
|
||||
<button
|
||||
className="topbar-icon-btn"
|
||||
onClick={handleNotifBellClick}
|
||||
aria-label="Notifications"
|
||||
>
|
||||
<FontAwesomeIcon icon={faBell} />
|
||||
{unreadCount > 0 && (
|
||||
<span className="topbar-badge">
|
||||
{unreadCount > 99 ? "99+" : unreadCount}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{showNotifPanel && (
|
||||
<div className="notif-panel">
|
||||
<div className="notif-panel-header">Notifications</div>
|
||||
{notifications.length === 0 ? (
|
||||
<div className="notif-empty">Aucune notification</div>
|
||||
) : (
|
||||
<ul className="notif-list">
|
||||
{notifications.map((n, i) => (
|
||||
<li
|
||||
key={i}
|
||||
className={`notif-item ${n.read ? "notif-read" : "notif-unread"}`}
|
||||
>
|
||||
<span className="notif-message">{n.message}</span>
|
||||
<span className="notif-time">
|
||||
{new Date(n.created_at).toLocaleTimeString("fr-FR", {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
})}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
className="topbar-icon-btn"
|
||||
onClick={handleNotifBellClick}
|
||||
aria-label="Notifications"
|
||||
>
|
||||
<FontAwesomeIcon icon={faBell} />
|
||||
{unreadCount > 0 && (
|
||||
<span className="topbar-badge">
|
||||
{unreadCount > 99 ? "99+" : unreadCount}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{/* Panier */}
|
||||
<button
|
||||
@@ -224,6 +207,38 @@ function Navbar() {
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* ── Notifications modal ─────────────────────── */}
|
||||
{showNotifPanel && (
|
||||
<div className="notif-modal-overlay" onClick={closeNotifPanel}>
|
||||
<div className="notif-modal" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="notif-modal-header">
|
||||
<span className="notif-modal-title">Notifications</span>
|
||||
<button className="notif-modal-close" onClick={closeNotifPanel} aria-label="Fermer">
|
||||
<FontAwesomeIcon icon={faTimes} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="notif-modal-body">
|
||||
{notifications.length === 0 ? (
|
||||
<div className="notif-empty">
|
||||
<FontAwesomeIcon icon={faBell} style={{ fontSize: "2rem", marginBottom: "0.75rem", opacity: 0.3 }} />
|
||||
<p>Aucune notification</p>
|
||||
</div>
|
||||
) : (
|
||||
notifications.map((n, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className={`notif-item ${n.read ? "notif-read" : "notif-unread"}`}
|
||||
>
|
||||
<span className="notif-message">{n.message}</span>
|
||||
<span className="notif-time">{formatNotifDate(n.created_at)}</span>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Overlay ─────────────────────────────────── */}
|
||||
{isMenuOpen && <div className="sidebar-overlay" onClick={closeMenu} />}
|
||||
|
||||
@@ -235,7 +250,7 @@ function Navbar() {
|
||||
<FontAwesomeIcon icon={faShoppingCart} />
|
||||
</div>
|
||||
<div>
|
||||
<p className="sidebar-brand-name">Milieu-Nantais</p>
|
||||
<p className="sidebar-brand-name">{shopName}</p>
|
||||
<p className="sidebar-brand-sub">Mon espace</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -14,7 +14,8 @@
|
||||
|
||||
.product-card:hover {
|
||||
transform: scale(1.08);
|
||||
box-shadow: 0 8px 25px color-mix(in srgb, var(--category-color, white) 40%, transparent);
|
||||
box-shadow: 0 8px 25px
|
||||
color-mix(in srgb, var(--category-color, white) 40%, transparent);
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
@@ -62,6 +63,29 @@
|
||||
0 0 10px rgba(255, 0, 0, 0.5);
|
||||
}
|
||||
|
||||
.coming-soon-overlay {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%) rotate(-15deg);
|
||||
text-align: center;
|
||||
z-index: 5;
|
||||
pointer-events: none;
|
||||
background-color: rgba(0, 0, 0, 0.8);
|
||||
color: rgba(34, 197, 94, 0.95);
|
||||
border: 6px solid rgba(34, 197, 94, 0.95);
|
||||
padding: clamp(0.5rem, 2vw, 0.8rem) clamp(1.5rem, 5vw, 2.5rem);
|
||||
font-size: clamp(1.1rem, 4.5vw, 1.8rem);
|
||||
font-weight: 900;
|
||||
letter-spacing: 3px;
|
||||
text-transform: uppercase;
|
||||
white-space: nowrap;
|
||||
box-shadow: 0 0 8px rgba(34, 197, 94, 0.6);
|
||||
text-shadow:
|
||||
2px 2px 6px rgba(0, 0, 0, 0.9),
|
||||
-2px -2px 6px rgba(0, 0, 0, 0.9);
|
||||
}
|
||||
|
||||
.product-card.out-of-stock {
|
||||
opacity: 0.75;
|
||||
}
|
||||
|
||||
@@ -7,7 +7,13 @@ import "./ProductCard.css";
|
||||
|
||||
function getTextColor(hex: string): string {
|
||||
const h = hex.replace("#", "");
|
||||
const full = h.length === 3 ? h.split("").map((c) => c + c).join("") : h;
|
||||
const full =
|
||||
h.length === 3
|
||||
? h
|
||||
.split("")
|
||||
.map((c) => c + c)
|
||||
.join("")
|
||||
: h;
|
||||
const r = parseInt(full.slice(0, 2), 16);
|
||||
const g = parseInt(full.slice(2, 4), 16);
|
||||
const b = parseInt(full.slice(4, 6), 16);
|
||||
@@ -22,10 +28,11 @@ interface ProductCardProps {
|
||||
image: string;
|
||||
stock: number;
|
||||
category: string;
|
||||
prices?: Array<{ quantity: number; price: number }>;
|
||||
prices?: Array<{ quantity: number; price: number; active_price?: boolean }>;
|
||||
hasVideo?: boolean;
|
||||
videoUrl?: string; // ✨ Nouveau prop pour l'URL de la vidéo
|
||||
categoryColor?: string;
|
||||
coming_soon?: boolean;
|
||||
}
|
||||
|
||||
function ProductCard({
|
||||
@@ -40,6 +47,7 @@ function ProductCard({
|
||||
hasVideo = false,
|
||||
videoUrl,
|
||||
categoryColor,
|
||||
coming_soon,
|
||||
}: ProductCardProps) {
|
||||
const navigate = useNavigate();
|
||||
const { addToCart } = useCart();
|
||||
@@ -52,6 +60,7 @@ function ProductCard({
|
||||
const [showVideo, setShowVideo] = useState(false); // ✨ État pour afficher/masquer la vidéo
|
||||
|
||||
const isOutOfStock = stock === 0;
|
||||
const isComingSoon = coming_soon === true;
|
||||
const normalizedCategory = (category || "autre").toLowerCase().trim();
|
||||
|
||||
const handleDetailsClick = (e: React.MouseEvent) => {
|
||||
@@ -156,6 +165,9 @@ function ProductCard({
|
||||
{isOutOfStock && (
|
||||
<div className="sold-out-overlay">SOLD OUT</div>
|
||||
)}
|
||||
{isComingSoon && (
|
||||
<div className="coming-soon-overlay">COMMING SOON</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="product-info">
|
||||
@@ -177,18 +189,23 @@ function ProductCard({
|
||||
) : (
|
||||
<>
|
||||
<button
|
||||
className={`quick-add-btn ${isOutOfStock ? "disabled" : ""}`}
|
||||
className={`quick-add-btn ${isOutOfStock || isComingSoon ? "disabled" : ""}`}
|
||||
onClick={handleQuickAddClick}
|
||||
disabled={isOutOfStock}
|
||||
disabled={isOutOfStock || isComingSoon}
|
||||
style={
|
||||
categoryColor && !isOutOfStock
|
||||
? { background: categoryColor, color: getTextColor(categoryColor) }
|
||||
categoryColor && !isOutOfStock && !isComingSoon
|
||||
? {
|
||||
background: categoryColor,
|
||||
color: getTextColor(categoryColor),
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{isOutOfStock
|
||||
? "Rupture de stock"
|
||||
: "Ajouter rapidement"}
|
||||
: isComingSoon
|
||||
? "BIENTÔT DISPONIBLE"
|
||||
: "Ajouter rapidement"}
|
||||
</button>
|
||||
|
||||
{showQuantitySelect && prices && prices.length > 0 && (
|
||||
@@ -209,8 +226,9 @@ function ProductCard({
|
||||
key={priceOption.quantity}
|
||||
value={priceOption.quantity}
|
||||
>
|
||||
{priceOption.quantity}{unit} -{" "}
|
||||
{priceOption.price.toFixed(2)} €
|
||||
{priceOption.quantity}
|
||||
{unit} - {priceOption.price.toFixed(2)}{" "}
|
||||
€
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
@@ -220,32 +238,34 @@ function ProductCard({
|
||||
</div>
|
||||
|
||||
{/* ✨ Modal vidéo — rendu via Portal pour éviter le clipping du transform:scale sur .product-card */}
|
||||
{showVideo && videoUrl && createPortal(
|
||||
<div className="video-modal" onClick={handleCloseVideo}>
|
||||
<div
|
||||
className="video-modal-content"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<button
|
||||
className="video-close-btn"
|
||||
onClick={handleCloseVideo}
|
||||
aria-label="Fermer la vidéo"
|
||||
{showVideo &&
|
||||
videoUrl &&
|
||||
createPortal(
|
||||
<div className="video-modal" onClick={handleCloseVideo}>
|
||||
<div
|
||||
className="video-modal-content"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<X size={18} />
|
||||
</button>
|
||||
<video
|
||||
src={videoUrl}
|
||||
controls
|
||||
autoPlay
|
||||
className="video-player"
|
||||
>
|
||||
Votre navigateur ne supporte pas la lecture de
|
||||
vidéos.
|
||||
</video>
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
)}
|
||||
<button
|
||||
className="video-close-btn"
|
||||
onClick={handleCloseVideo}
|
||||
aria-label="Fermer la vidéo"
|
||||
>
|
||||
<X size={18} />
|
||||
</button>
|
||||
<video
|
||||
src={videoUrl}
|
||||
controls
|
||||
autoPlay
|
||||
className="video-player"
|
||||
>
|
||||
Votre navigateur ne supporte pas la lecture de
|
||||
vidéos.
|
||||
</video>
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -30,6 +30,7 @@ export interface CartItem {
|
||||
quantity: number; // ✨ En GRAMMES (pas "combien de fois")
|
||||
category: string;
|
||||
image?: string;
|
||||
is_reward?: boolean;
|
||||
}
|
||||
|
||||
interface CartContextType {
|
||||
@@ -129,6 +130,7 @@ export function CartProvider({ children }: { children: ReactNode }) {
|
||||
quantity: item.quantity as number,
|
||||
category: category,
|
||||
image: item.image as string | undefined,
|
||||
is_reward: item.is_reward as boolean | undefined,
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
@@ -216,3 +216,109 @@
|
||||
font-size: 0.85rem;
|
||||
font-weight: 400 !important;
|
||||
}
|
||||
|
||||
/* Modal feedback */
|
||||
.cp-modal-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
backdrop-filter: blur(4px);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.cp-modal {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 1rem;
|
||||
padding: 2rem 1.75rem 1.75rem;
|
||||
width: 100%;
|
||||
max-width: 22rem;
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
text-align: center;
|
||||
box-shadow: 0 25px 60px rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
|
||||
.cp-modal-close {
|
||||
position: absolute;
|
||||
top: 0.75rem;
|
||||
right: 0.75rem;
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
padding: 0.25rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
border-radius: 0.375rem;
|
||||
transition: color 0.2s, background 0.2s;
|
||||
}
|
||||
.cp-modal-close:hover { color: var(--text); background: var(--bg); }
|
||||
|
||||
.cp-modal-icon {
|
||||
width: 3.5rem;
|
||||
height: 3.5rem;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
.cp-modal-icon--success {
|
||||
background: rgba(34, 197, 94, 0.12);
|
||||
border: 2px solid rgba(34, 197, 94, 0.35);
|
||||
color: #4ade80;
|
||||
}
|
||||
.cp-modal-icon--error {
|
||||
background: rgba(239, 68, 68, 0.12);
|
||||
border: 2px solid rgba(239, 68, 68, 0.35);
|
||||
color: #f87171;
|
||||
}
|
||||
|
||||
.cp-modal-title {
|
||||
font-size: 1.1rem;
|
||||
font-weight: 700;
|
||||
color: var(--text);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.cp-modal-body {
|
||||
font-size: 0.9rem;
|
||||
color: var(--text-muted);
|
||||
margin: 0;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.cp-modal-btn {
|
||||
margin-top: 0.5rem;
|
||||
width: 100%;
|
||||
padding: 0.7rem 1rem;
|
||||
border: none;
|
||||
border-radius: 0.5rem;
|
||||
font-weight: 600;
|
||||
font-size: 0.9rem;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.4rem;
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
.cp-modal-btn:hover { opacity: 0.88; }
|
||||
.cp-modal-btn--success {
|
||||
background: linear-gradient(135deg, #7c3aed, #6d28d9);
|
||||
color: #fff;
|
||||
box-shadow: 0 4px 14px rgba(109, 40, 217, 0.4);
|
||||
}
|
||||
.cp-modal-btn--error {
|
||||
background: rgba(239, 68, 68, 0.12);
|
||||
color: #f87171;
|
||||
border: 1px solid rgba(239, 68, 68, 0.3);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useState } from "react";
|
||||
import { Lock, Eye, EyeOff, ShieldCheck } from "lucide-react";
|
||||
import { Lock, Eye, EyeOff, ShieldCheck, CheckCircle, AlertTriangle, X } from "lucide-react";
|
||||
import "./ChangePassword.css";
|
||||
import { changePassword } from "../../api/api";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
@@ -14,8 +14,9 @@ const ChangePasswordPage = () => {
|
||||
const [showNew, setShowNew] = useState(false);
|
||||
const [showConfirm, setShowConfirm] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const [success, setSuccess] = useState(false);
|
||||
const [showSuccessModal, setShowSuccessModal] = useState(false);
|
||||
const [showErrorModal, setShowErrorModal] = useState(false);
|
||||
const [errorMsg, setErrorMsg] = useState('');
|
||||
|
||||
const validate = (): string | null => {
|
||||
if (!currentPassword) return "Le mot de passe actuel est requis";
|
||||
@@ -28,11 +29,11 @@ const ChangePasswordPage = () => {
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
setError("");
|
||||
|
||||
const validationError = validate();
|
||||
if (validationError) {
|
||||
setError(validationError);
|
||||
setErrorMsg(validationError);
|
||||
setShowErrorModal(true);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -40,10 +41,10 @@ const ChangePasswordPage = () => {
|
||||
try {
|
||||
const result = await changePassword(currentPassword, newPassword);
|
||||
if (result.success) {
|
||||
setSuccess(true);
|
||||
setTimeout(() => navigate("/user/accueil"), 1800);
|
||||
setShowSuccessModal(true);
|
||||
} else {
|
||||
setError(result.message);
|
||||
setErrorMsg(result.message || 'Erreur lors du changement de mot de passe.');
|
||||
setShowErrorModal(true);
|
||||
}
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
@@ -51,6 +52,7 @@ const ChangePasswordPage = () => {
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="cp-container">
|
||||
<div className="cp-content">
|
||||
<div className="cp-header">
|
||||
@@ -65,20 +67,7 @@ const ChangePasswordPage = () => {
|
||||
</div>
|
||||
|
||||
<div className="cp-card">
|
||||
{success ? (
|
||||
<div className="cp-success">
|
||||
<span className="cp-success-icon">✓</span>
|
||||
<p>Mot de passe mis à jour avec succès !</p>
|
||||
<p className="cp-success-sub">Redirection en cours…</p>
|
||||
</div>
|
||||
) : (
|
||||
<form className="cp-form" onSubmit={handleSubmit}>
|
||||
{error && (
|
||||
<div className="cp-error-banner">
|
||||
⚠️ {error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Mot de passe actuel */}
|
||||
<div className="cp-form-group">
|
||||
<label className="cp-label">Mot de passe actuel</label>
|
||||
@@ -87,7 +76,7 @@ const ChangePasswordPage = () => {
|
||||
<input
|
||||
type={showCurrent ? "text" : "password"}
|
||||
value={currentPassword}
|
||||
onChange={(e) => { setCurrentPassword(e.target.value); setError(""); }}
|
||||
onChange={(e) => { setCurrentPassword(e.target.value); }}
|
||||
className="cp-input"
|
||||
placeholder="••••••••"
|
||||
disabled={isLoading}
|
||||
@@ -116,7 +105,7 @@ const ChangePasswordPage = () => {
|
||||
<input
|
||||
type={showNew ? "text" : "password"}
|
||||
value={newPassword}
|
||||
onChange={(e) => { setNewPassword(e.target.value); setError(""); }}
|
||||
onChange={(e) => { setNewPassword(e.target.value); }}
|
||||
className="cp-input"
|
||||
placeholder="Minimum 8 caractères"
|
||||
disabled={isLoading}
|
||||
@@ -148,7 +137,7 @@ const ChangePasswordPage = () => {
|
||||
<input
|
||||
type={showConfirm ? "text" : "password"}
|
||||
value={confirmPassword}
|
||||
onChange={(e) => { setConfirmPassword(e.target.value); setError(""); }}
|
||||
onChange={(e) => { setConfirmPassword(e.target.value); }}
|
||||
className={`cp-input ${confirmPassword && confirmPassword !== newPassword ? "cp-input-error" : ""}`}
|
||||
placeholder="••••••••"
|
||||
disabled={isLoading}
|
||||
@@ -180,10 +169,45 @@ const ChangePasswordPage = () => {
|
||||
{isLoading ? "Mise à jour…" : "Confirmer le nouveau mot de passe"}
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Modal succès */}
|
||||
{showSuccessModal && (
|
||||
<div className="cp-modal-overlay" onClick={() => navigate('/user/accueil')}>
|
||||
<div className="cp-modal" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="cp-modal-icon cp-modal-icon--success">
|
||||
<CheckCircle size={28} />
|
||||
</div>
|
||||
<h3 className="cp-modal-title">Mot de passe mis à jour</h3>
|
||||
<p className="cp-modal-body">Votre mot de passe a été changé avec succès.</p>
|
||||
<button className="cp-modal-btn cp-modal-btn--success" onClick={() => navigate('/user/accueil')}>
|
||||
<CheckCircle size={16} /> Continuer
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Modal erreur */}
|
||||
{showErrorModal && (
|
||||
<div className="cp-modal-overlay" onClick={() => setShowErrorModal(false)}>
|
||||
<div className="cp-modal" onClick={(e) => e.stopPropagation()}>
|
||||
<button className="cp-modal-close" onClick={() => setShowErrorModal(false)}>
|
||||
<X size={18} />
|
||||
</button>
|
||||
<div className="cp-modal-icon cp-modal-icon--error">
|
||||
<AlertTriangle size={28} />
|
||||
</div>
|
||||
<h3 className="cp-modal-title">Une erreur est survenue</h3>
|
||||
<p className="cp-modal-body">{errorMsg}</p>
|
||||
<button className="cp-modal-btn cp-modal-btn--error" onClick={() => setShowErrorModal(false)}>
|
||||
<X size={16} /> Fermer
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -12,6 +12,28 @@
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.login-theme-btn {
|
||||
position: absolute;
|
||||
top: 1rem;
|
||||
right: 1rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 2.25rem;
|
||||
height: 2.25rem;
|
||||
border-radius: 0.5rem;
|
||||
border: 1px solid var(--border);
|
||||
background-color: var(--surface);
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.login-theme-btn:hover {
|
||||
color: var(--text);
|
||||
background-color: var(--surface-2);
|
||||
}
|
||||
|
||||
.login-content {
|
||||
width: 100%;
|
||||
max-width: 28rem;
|
||||
@@ -136,7 +158,7 @@
|
||||
.error-message {
|
||||
margin-top: 0.5rem;
|
||||
font-size: 0.875rem;
|
||||
color: #f87171;
|
||||
color: var(--red);
|
||||
}
|
||||
|
||||
.error-banner {
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
import { useState } from "react";
|
||||
import { Lock, Mail, Eye, EyeOff, User } from "lucide-react";
|
||||
import { Lock, Mail, Eye, EyeOff, User, Shield, Sun, Moon } from "lucide-react";
|
||||
import "./Login.css";
|
||||
import { loginUser, syncUsernameFromJWT } from "../../api/api";
|
||||
import { loginUser, verify2FA, syncUsernameFromJWT } from "../../api/api";
|
||||
import type { LoginRequest } from "../../api/api_types";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useTheme } from "../../context/ThemeContext";
|
||||
|
||||
const LoginClient = () => {
|
||||
const navigate = useNavigate();
|
||||
const { theme, toggleTheme } = useTheme();
|
||||
|
||||
const [formData, setFormData] = useState<LoginRequest>({
|
||||
username: "",
|
||||
@@ -21,6 +23,10 @@ const LoginClient = () => {
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [apiError, setApiError] = useState<string>("");
|
||||
|
||||
const [twoFAStep, setTwoFAStep] = useState(false);
|
||||
const [sessionToken, setSessionToken] = useState("");
|
||||
const [twoFACode, setTwoFACode] = useState("");
|
||||
|
||||
/**
|
||||
* ✅ Valider le formulaire
|
||||
*/
|
||||
@@ -79,30 +85,19 @@ const LoginClient = () => {
|
||||
hasToken: !!result.access_token,
|
||||
});
|
||||
|
||||
if (result.success && result.access_token) {
|
||||
console.log("✅ [LOGIN] Connexion réussie!");
|
||||
|
||||
// ✅ La synchronisation JWT est AUTOMATIQUE dans loginUser()
|
||||
// Pas besoin de le faire ici
|
||||
console.log("✅ [LOGIN] Token et username synchronisés");
|
||||
|
||||
// ✅ Vérifier la synchronisation
|
||||
if (result.success && result.requires_2fa) {
|
||||
setSessionToken(result.session_token || "");
|
||||
setTwoFAStep(true);
|
||||
} else if (result.success && result.access_token) {
|
||||
const syncedUsername = syncUsernameFromJWT();
|
||||
console.log("✅ [LOGIN] Username synchronisé:", syncedUsername);
|
||||
|
||||
// ✅ Redirection
|
||||
if (result.user?.must_change_password) {
|
||||
console.log("✅ [LOGIN] Première connexion - changement de mot de passe requis");
|
||||
navigate("/user/change-password");
|
||||
} else {
|
||||
console.log("✅ [LOGIN] Redirection vers /user/accueil");
|
||||
navigate("/user/accueil");
|
||||
}
|
||||
} else {
|
||||
// ❌ Erreur API
|
||||
const errorMessage =
|
||||
result.message || "Identifiants incorrects";
|
||||
console.error("❌ [LOGIN] Erreur API:", errorMessage);
|
||||
const errorMessage = result.message || "Identifiants incorrects";
|
||||
setApiError(errorMessage);
|
||||
setErrors({ username: errorMessage });
|
||||
}
|
||||
@@ -117,6 +112,30 @@ const LoginClient = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const handle2FASubmit = async (e: React.FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
if (!twoFACode.trim()) return;
|
||||
setIsLoading(true);
|
||||
setApiError("");
|
||||
try {
|
||||
const result = await verify2FA(sessionToken, twoFACode.trim());
|
||||
if (result.success && result.access_token) {
|
||||
syncUsernameFromJWT();
|
||||
if (result.user?.must_change_password) {
|
||||
navigate("/user/change-password");
|
||||
} else {
|
||||
navigate("/user/accueil");
|
||||
}
|
||||
} else {
|
||||
setApiError(result.message || "Code invalide");
|
||||
}
|
||||
} catch {
|
||||
setApiError("Erreur de vérification");
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* ✅ Gérer les changements d'input
|
||||
*/
|
||||
@@ -141,8 +160,74 @@ const LoginClient = () => {
|
||||
}
|
||||
};
|
||||
|
||||
if (twoFAStep) {
|
||||
return (
|
||||
<div className="login-container">
|
||||
<button className="login-theme-btn" onClick={toggleTheme} aria-label="Changer le thème">
|
||||
{theme === "dark" ? <Sun size={18} /> : <Moon size={18} />}
|
||||
</button>
|
||||
<div className="login-content">
|
||||
<div className="login-header">
|
||||
<div className="login-logo">
|
||||
<Shield className="w-8 h-8 text-white" />
|
||||
</div>
|
||||
<h1 className="login-title">Vérification 2FA</h1>
|
||||
<p className="login-subtitle">
|
||||
Entrez le code envoyé sur votre Telegram
|
||||
</p>
|
||||
</div>
|
||||
<div className="login-card">
|
||||
<form className="login-form" onSubmit={handle2FASubmit}>
|
||||
{apiError && (
|
||||
<div className="error-banner">⚠️ {apiError}</div>
|
||||
)}
|
||||
<div className="form-group">
|
||||
<label htmlFor="twoFACode" className="form-label">
|
||||
Code de vérification
|
||||
</label>
|
||||
<div className="input-wrapper">
|
||||
<input
|
||||
type="text"
|
||||
id="twoFACode"
|
||||
value={twoFACode}
|
||||
onChange={(e) => setTwoFACode(e.target.value)}
|
||||
className="form-input"
|
||||
placeholder="000000"
|
||||
maxLength={6}
|
||||
disabled={isLoading}
|
||||
autoComplete="one-time-code"
|
||||
style={{ letterSpacing: "0.3em", textAlign: "center", fontSize: "1.5rem" }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isLoading || twoFACode.length < 6}
|
||||
className="submit-button"
|
||||
style={{ opacity: isLoading ? 0.6 : 1, cursor: isLoading ? "not-allowed" : "pointer" }}
|
||||
>
|
||||
{isLoading ? "Vérification..." : "Confirmer"}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { setTwoFAStep(false); setTwoFACode(""); setApiError(""); }}
|
||||
className="submit-button"
|
||||
style={{ marginTop: "0.5rem", background: "transparent", border: "1px solid #555", color: "#aaa" }}
|
||||
>
|
||||
Retour
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="login-container">
|
||||
<button className="login-theme-btn" onClick={toggleTheme} aria-label="Changer le thème">
|
||||
{theme === "dark" ? <Sun size={18} /> : <Moon size={18} />}
|
||||
</button>
|
||||
<div className="login-content">
|
||||
<div className="login-header">
|
||||
<div className="login-logo">
|
||||
|
||||
@@ -70,10 +70,9 @@ function UserAccueil() {
|
||||
};
|
||||
|
||||
const getProductPrice = (product: Product): number => {
|
||||
if (!product.prices || product.prices.length === 0) {
|
||||
return 0;
|
||||
}
|
||||
return product.prices[0]?.price || 0;
|
||||
const activePrices = product.prices?.filter(p => p.active_price !== false);
|
||||
if (!activePrices || activePrices.length === 0) return 0;
|
||||
return activePrices[0].price;
|
||||
};
|
||||
const hasProductVideo = (product: Product): boolean => {
|
||||
if (!product.media || product.media.length === 0) {
|
||||
@@ -210,7 +209,7 @@ function UserAccueil() {
|
||||
image={getProductImage(product)}
|
||||
stock={product.stock}
|
||||
category={product.category}
|
||||
prices={product.prices}
|
||||
prices={product.prices?.filter(p => p.active_price !== false)}
|
||||
hasVideo={hasProductVideo(product)}
|
||||
videoUrl={getProductVideoUrl(product)}
|
||||
categoryColor={
|
||||
@@ -220,6 +219,7 @@ function UserAccueil() {
|
||||
product.category?.toLowerCase(),
|
||||
)?.color
|
||||
}
|
||||
coming_soon={product.coming_soon}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
|
||||
@@ -17,6 +17,7 @@ interface CartItemWithMedia {
|
||||
image: string;
|
||||
hasVideo: boolean;
|
||||
videoUrl?: string;
|
||||
is_reward?: boolean;
|
||||
}
|
||||
|
||||
function Cart() {
|
||||
@@ -176,9 +177,22 @@ function Cart() {
|
||||
|
||||
{/* Infos */}
|
||||
<div className="cart-row-info">
|
||||
<p className="cart-row-name">{item.name_product}</p>
|
||||
<p className="cart-row-name">
|
||||
{item.name_product}
|
||||
{item.is_reward && (
|
||||
<span style={{ marginLeft: "6px", fontSize: "0.7rem", fontWeight: 700, color: "#f59e0b", background: "rgba(245,158,11,0.12)", borderRadius: "4px", padding: "1px 6px" }}>
|
||||
🎁 Récompense
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
<p className="cart-row-qty">{item.quantity}g</p>
|
||||
<p className="cart-row-price">{item.price.toFixed(2)} €</p>
|
||||
<p className="cart-row-price">
|
||||
{item.is_reward ? (
|
||||
<span style={{ color: "#10b981", fontWeight: 700 }}>Offert</span>
|
||||
) : (
|
||||
`${item.price.toFixed(2)} €`
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Bouton supprimer */}
|
||||
|
||||
@@ -181,6 +181,15 @@
|
||||
margin-top: clamp(1rem, 3vw, 1.5rem);
|
||||
}
|
||||
|
||||
.summary-referral-deduction {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
font-size: clamp(0.85rem, 2.5vw, 0.95rem);
|
||||
color: #16a34a;
|
||||
margin-top: 0.35rem;
|
||||
}
|
||||
|
||||
.total-price {
|
||||
color: #6d28d9;
|
||||
font-size: clamp(1.2rem, 5vw, 1.5rem);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useCart } from '../../context/useCart';
|
||||
import { createCheckout, clearCart, extractUsernameFromToken, isUserAuthenticated, getReferralBalance, getPublicSettings, getMyProfile, getCryptoPaymentStatus, getProductById, getMediaUrl } from '../../api/api';
|
||||
import { createCheckout, clearCart, extractUsernameFromToken, isUserAuthenticated, getReferralBalance, getPublicSettings, getMyProfile, getCryptoPaymentStatus, getProductById, getMediaUrl, getTelegramStatus } from '../../api/api';
|
||||
import type { CheckoutData, CryptoPaymentStatus } from '../../api/api';
|
||||
import type { Product } from '../../api/api';
|
||||
import Navbar from '../../components/Navbar';
|
||||
@@ -80,6 +80,9 @@ function Checkout() {
|
||||
const [referralEnabled, setReferralEnabled] = useState(false);
|
||||
const [useReferral, setUseReferral] = useState(false);
|
||||
|
||||
// Telegram
|
||||
const [telegramLinked, setTelegramLinked] = useState(false);
|
||||
|
||||
// Crypto
|
||||
const [cryptoEnabled, setCryptoEnabled] = useState(false);
|
||||
const [cryptoOnly, setCryptoOnly] = useState(false);
|
||||
@@ -129,6 +132,10 @@ function Checkout() {
|
||||
if (res.client.prenom) setFirstName(res.client.prenom);
|
||||
}
|
||||
});
|
||||
|
||||
getTelegramStatus().then((res) => {
|
||||
if (res.linked) setTelegramLinked(true);
|
||||
});
|
||||
}, []);
|
||||
|
||||
// Charger settings publics (parrainage + crypto)
|
||||
@@ -348,13 +355,13 @@ function Checkout() {
|
||||
// ✅ Préparer les données pour le modal
|
||||
setConfirmationData({
|
||||
command_id,
|
||||
client_order_number: (response as Record<string, unknown>).client_order_number as number | undefined,
|
||||
client_order_number: response.client_order_number,
|
||||
assigned_to,
|
||||
queue_info,
|
||||
delivery_address: delivery_address || address,
|
||||
arrivalTime,
|
||||
total: frontendTotal,
|
||||
referral_used: (response as Record<string, unknown>).referral_used as number | undefined,
|
||||
referral_used: response.referral_used,
|
||||
clientInfo: {
|
||||
first_name: firstName,
|
||||
last_name: lastName,
|
||||
@@ -417,8 +424,19 @@ function Checkout() {
|
||||
</div>
|
||||
<div className="summary-total">
|
||||
<span>Total:</span>
|
||||
<span className="total-price">{total.toFixed(2)} €</span>
|
||||
<span className="total-price">
|
||||
{(useReferral && referralBalance > 0
|
||||
? Math.max(0, total - referralBalance)
|
||||
: total
|
||||
).toFixed(2)} €
|
||||
</span>
|
||||
</div>
|
||||
{useReferral && referralBalance > 0 && (
|
||||
<div className="summary-referral-deduction">
|
||||
<span>Dont crédit parrainage :</span>
|
||||
<span>-{Math.min(referralBalance, total).toFixed(2)} €</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Formulaire */}
|
||||
@@ -567,16 +585,18 @@ function Checkout() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="checkout-telegram-note">
|
||||
<i className="fab fa-telegram" />
|
||||
<div>
|
||||
<strong>Compte Telegram requis</strong>
|
||||
<p>
|
||||
Votre commande ne pourra être validée que si votre compte Telegram est lié.
|
||||
Rendez-vous dans votre <a href="/user/profil">profil</a> pour le lier avant de confirmer.
|
||||
</p>
|
||||
{!telegramLinked && (
|
||||
<div className="checkout-telegram-note">
|
||||
<i className="fab fa-telegram" />
|
||||
<div>
|
||||
<strong>Compte Telegram requis</strong>
|
||||
<p>
|
||||
Votre commande ne pourra être validée que si votre compte Telegram est lié.
|
||||
Rendez-vous dans votre <a href="/user/profil">profil</a> pour le lier avant de confirmer.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="form-actions">
|
||||
<button
|
||||
@@ -787,7 +807,9 @@ function Checkout() {
|
||||
<div className="confirmation-total-label">
|
||||
<i className="fas fa-euro-sign"></i> TOTAL
|
||||
</div>
|
||||
<div className="confirmation-total-amount">{confirmationData.total.toFixed(2)} €</div>
|
||||
<div className="confirmation-total-amount">
|
||||
{(confirmationData.total - (confirmationData.referral_used ?? 0)).toFixed(2)} €
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -151,6 +151,154 @@
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
RÉCOMPENSES PAR PALIER
|
||||
============================================ */
|
||||
|
||||
.rewards-section {
|
||||
background: var(--surface);
|
||||
border: 1px solid rgba(245, 158, 11, 0.3);
|
||||
border-radius: 12px;
|
||||
padding: 1rem;
|
||||
margin-bottom: 1.25rem;
|
||||
}
|
||||
|
||||
.rewards-section-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
color: #f59e0b;
|
||||
font-size: 0.78rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.8px;
|
||||
margin: 0 0 0.4rem 0;
|
||||
}
|
||||
|
||||
.rewards-section-icon {
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.rewards-section-desc {
|
||||
color: var(--text-muted);
|
||||
font-size: 0.85rem;
|
||||
font-style: italic;
|
||||
margin: 0 0 0.875rem 0;
|
||||
}
|
||||
|
||||
.rewards-pools {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.reward-pool-card {
|
||||
background: rgba(245, 158, 11, 0.07);
|
||||
border: 1px solid rgba(245, 158, 11, 0.2);
|
||||
border-radius: 8px;
|
||||
padding: 0.75rem;
|
||||
}
|
||||
|
||||
.reward-pool-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.reward-pool-name {
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.reward-pool-pts {
|
||||
font-size: 0.85rem;
|
||||
font-weight: 700;
|
||||
color: #f59e0b;
|
||||
}
|
||||
|
||||
.reward-eligible-cats {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.35rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.reward-eligible-cat {
|
||||
font-size: 0.78rem;
|
||||
font-weight: 600;
|
||||
color: #10b981;
|
||||
background: rgba(16, 185, 129, 0.12);
|
||||
border-radius: 4px;
|
||||
padding: 2px 7px;
|
||||
}
|
||||
|
||||
.reward-progress-bar {
|
||||
height: 6px;
|
||||
background: rgba(245, 158, 11, 0.15);
|
||||
border-radius: 3px;
|
||||
overflow: hidden;
|
||||
margin-bottom: 0.4rem;
|
||||
}
|
||||
|
||||
.reward-progress-fill {
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, #f59e0b, #fbbf24);
|
||||
border-radius: 3px;
|
||||
transition: width 0.4s ease;
|
||||
}
|
||||
|
||||
.reward-pool-info {
|
||||
font-size: 0.8rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.reward-available {
|
||||
color: #10b981;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.reward-remaining {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.reward-feedback {
|
||||
font-size: 0.82rem;
|
||||
font-style: italic;
|
||||
margin: 0.25rem 0 0.4rem;
|
||||
}
|
||||
|
||||
.reward-feedback-success {
|
||||
color: #10b981;
|
||||
}
|
||||
|
||||
.reward-feedback-error {
|
||||
color: #ef4444;
|
||||
}
|
||||
|
||||
.reward-claim-btn {
|
||||
width: 100%;
|
||||
background: linear-gradient(135deg, #f59e0b, #d97706);
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
padding: 0.55rem 1rem;
|
||||
font-size: 0.88rem;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
|
||||
.reward-claim-btn:hover:not(:disabled) {
|
||||
opacity: 0.88;
|
||||
}
|
||||
|
||||
.reward-claim-btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
SECTION TITLE
|
||||
============================================ */
|
||||
@@ -261,6 +409,13 @@
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.order-card-referral {
|
||||
font-size: 0.72rem;
|
||||
font-weight: 500;
|
||||
color: #10b981;
|
||||
opacity: 0.75;
|
||||
}
|
||||
|
||||
.order-card-chevron {
|
||||
color: var(--text-muted);
|
||||
font-size: 0.9rem;
|
||||
|
||||
@@ -1,278 +1,519 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import Navbar from '../../components/Navbar';
|
||||
import './ConsultationHistorique.css';
|
||||
import { useState, useEffect } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import Navbar from "../../components/Navbar";
|
||||
import "./ConsultationHistorique.css";
|
||||
import {
|
||||
getMyCompletedOrders,
|
||||
formatPrice,
|
||||
getOrderAge,
|
||||
getMyPenalties,
|
||||
isUserAuthenticated,
|
||||
getPublicSettings,
|
||||
getReferralBalance,
|
||||
} from '../../api/api';
|
||||
import type { PublicSettings } from '../../api/api';
|
||||
import type { CompletedOrder, ClientStats, PenaltyInfo } from "../../api/api_types";
|
||||
getMyCompletedOrders,
|
||||
formatPrice,
|
||||
getOrderAge,
|
||||
getMyPenalties,
|
||||
isUserAuthenticated,
|
||||
getPublicSettings,
|
||||
getReferralBalance,
|
||||
getMyPointsRewards,
|
||||
claimMyReward,
|
||||
} from "../../api/api";
|
||||
import type { PublicSettings, PointsPoolInfo, PointsRewardConfig, RewardItemConfig } from "../../api/api";
|
||||
import type {
|
||||
CompletedOrder,
|
||||
ClientStats,
|
||||
PenaltyInfo,
|
||||
} from "../../api/api_types";
|
||||
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import {
|
||||
faCannabis,
|
||||
faPills,
|
||||
faFlask,
|
||||
faMortarPestle,
|
||||
faStar,
|
||||
faTrophy,
|
||||
faExclamationTriangle,
|
||||
faShieldAlt,
|
||||
faGift,
|
||||
faReceipt,
|
||||
faMapMarkerAlt,
|
||||
faClock,
|
||||
faBicycle,
|
||||
faChevronRight,
|
||||
faCheckCircle,
|
||||
faHistory,
|
||||
} from '@fortawesome/free-solid-svg-icons';
|
||||
faCannabis,
|
||||
faPills,
|
||||
faFlask,
|
||||
faMortarPestle,
|
||||
faStar,
|
||||
faTrophy,
|
||||
faExclamationTriangle,
|
||||
faShieldAlt,
|
||||
faGift,
|
||||
faReceipt,
|
||||
faMapMarkerAlt,
|
||||
faClock,
|
||||
faBicycle,
|
||||
faChevronRight,
|
||||
faCheckCircle,
|
||||
faHistory,
|
||||
} from "@fortawesome/free-solid-svg-icons";
|
||||
|
||||
function ConsultationHistorique() {
|
||||
const navigate = useNavigate();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const [orders, setOrders] = useState<CompletedOrder[]>([]);
|
||||
const [clientStats, setClientStats] = useState<ClientStats | null>(null);
|
||||
const [penalties, setPenalties] = useState<PenaltyInfo | null>(null);
|
||||
const [appSettings, setAppSettings] = useState<PublicSettings>({ penalties_enabled: true, show_amende_score: true, points_enabled: true, points_separated: true, referral_enabled: true, referral_amount: 0, pool_names: ['Pool 1', 'Pool 2'], crypto_payment_enabled: false, crypto_only: false, nowpayments_currencies: [] });
|
||||
const [referralBalance, setReferralBalance] = useState<number>(0);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string>('');
|
||||
|
||||
useEffect(() => {
|
||||
if (!isUserAuthenticated()) navigate('/login/client', { replace: true });
|
||||
}, [navigate]);
|
||||
|
||||
useEffect(() => {
|
||||
const interval = setInterval(() => {
|
||||
if (!isUserAuthenticated()) navigate('/login/client', { replace: true });
|
||||
}, 5000);
|
||||
return () => clearInterval(interval);
|
||||
}, [navigate]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchHistory();
|
||||
fetchPenalties();
|
||||
getPublicSettings().then((s) => {
|
||||
setAppSettings(s);
|
||||
if (s.referral_enabled) {
|
||||
getReferralBalance().then((r) => { if (r.success) setReferralBalance(r.balance); });
|
||||
}
|
||||
const [orders, setOrders] = useState<CompletedOrder[]>([]);
|
||||
const [clientStats, setClientStats] = useState<ClientStats | null>(null);
|
||||
const [penalties, setPenalties] = useState<PenaltyInfo | null>(null);
|
||||
const [appSettings, setAppSettings] = useState<PublicSettings>({
|
||||
penalties_enabled: true,
|
||||
show_amende_score: true,
|
||||
points_enabled: true,
|
||||
points_separated: true,
|
||||
referral_enabled: true,
|
||||
referral_amount: 0,
|
||||
pool_names: ["Pool 1", "Pool 2"],
|
||||
crypto_payment_enabled: false,
|
||||
crypto_only: false,
|
||||
nowpayments_currencies: [],
|
||||
shop_name: "Milieu-Nantais",
|
||||
two_fa_enabled: false,
|
||||
contact_telegram: "",
|
||||
});
|
||||
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
const [referralBalance, setReferralBalance] = useState<number>(0);
|
||||
const [pointsRewards, setPointsRewards] = useState<{
|
||||
enabled: boolean;
|
||||
pools: PointsPoolInfo[];
|
||||
reward: PointsRewardConfig | null;
|
||||
} | null>(null);
|
||||
const [claimingPool, setClaimingPool] = useState<string | null>(null);
|
||||
const [claimFeedback, setClaimFeedback] = useState<{ pool: string; type: "success" | "error"; text: string } | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string>("");
|
||||
|
||||
const fetchHistory = async () => {
|
||||
if (!isUserAuthenticated()) { navigate('/login/client', { replace: true }); return; }
|
||||
setIsLoading(true);
|
||||
setError('');
|
||||
try {
|
||||
const result = await getMyCompletedOrders();
|
||||
if (result.success) {
|
||||
setOrders(result.commands);
|
||||
setClientStats(result.client_stats || null);
|
||||
} else {
|
||||
setError(result.message || 'Erreur lors du chargement');
|
||||
}
|
||||
} catch {
|
||||
setError('Erreur de connexion');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
useEffect(() => {
|
||||
if (!isUserAuthenticated())
|
||||
navigate("/login/client", { replace: true });
|
||||
}, [navigate]);
|
||||
|
||||
useEffect(() => {
|
||||
const interval = setInterval(() => {
|
||||
if (!isUserAuthenticated())
|
||||
navigate("/login/client", { replace: true });
|
||||
}, 5000);
|
||||
return () => clearInterval(interval);
|
||||
}, [navigate]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchHistory();
|
||||
fetchPenalties();
|
||||
getPublicSettings().then((s) => {
|
||||
setAppSettings(s);
|
||||
if (s.referral_enabled) {
|
||||
getReferralBalance().then((r) => {
|
||||
if (r.success) setReferralBalance(r.balance);
|
||||
});
|
||||
}
|
||||
});
|
||||
getMyPointsRewards().then((r) => {
|
||||
if (r.success && r.enabled) setPointsRewards(r);
|
||||
});
|
||||
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
const fetchHistory = async () => {
|
||||
if (!isUserAuthenticated()) {
|
||||
navigate("/login/client", { replace: true });
|
||||
return;
|
||||
}
|
||||
setIsLoading(true);
|
||||
setError("");
|
||||
try {
|
||||
const result = await getMyCompletedOrders();
|
||||
if (result.success) {
|
||||
setOrders(result.commands);
|
||||
setClientStats(result.client_stats || null);
|
||||
} else {
|
||||
setError(result.message || "Erreur lors du chargement");
|
||||
}
|
||||
} catch {
|
||||
setError("Erreur de connexion");
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const fetchPenalties = async () => {
|
||||
if (!isUserAuthenticated()) return;
|
||||
try {
|
||||
const result = await getMyPenalties();
|
||||
if (result.success && result.data) setPenalties(result.data);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
};
|
||||
|
||||
const viewOrderDetails = (order: CompletedOrder) => {
|
||||
if (!isUserAuthenticated()) {
|
||||
navigate("/login/client", { replace: true });
|
||||
return;
|
||||
}
|
||||
navigate(`/user/commande/${order.client_order_number ?? order.id}`, {
|
||||
state: { commandId: order.id },
|
||||
});
|
||||
};
|
||||
|
||||
const formatDate = (dateStr: string) => {
|
||||
const d = new Date(dateStr);
|
||||
return d.toLocaleDateString("fr-FR", {
|
||||
day: "2-digit",
|
||||
month: "short",
|
||||
year: "numeric",
|
||||
});
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<>
|
||||
<Navbar />
|
||||
<div className="history-container">
|
||||
<div className="loading-container">
|
||||
<div className="loading-spinner">
|
||||
<div className="spinner"></div>
|
||||
</div>
|
||||
<p className="loading-text">
|
||||
Chargement de l'historique...
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const fetchPenalties = async () => {
|
||||
if (!isUserAuthenticated()) return;
|
||||
try {
|
||||
const result = await getMyPenalties();
|
||||
if (result.success && result.data) setPenalties(result.data);
|
||||
} catch { /* ignore */ }
|
||||
};
|
||||
const handleClaim = async (poolKey: string) => {
|
||||
setClaimingPool(poolKey);
|
||||
setClaimFeedback(null);
|
||||
const res = await claimMyReward(poolKey);
|
||||
setClaimingPool(null);
|
||||
if (res.success) {
|
||||
const text = res.product_added && res.product_name
|
||||
? `🎁 ${res.product_name} ajouté à votre panier ! Commandez au moins un produit pour en profiter.`
|
||||
: res.description || "Récompense réclamée !";
|
||||
setClaimFeedback({ pool: poolKey, type: "success", text });
|
||||
getMyPointsRewards().then((r) => { if (r.success) setPointsRewards(r); });
|
||||
} else {
|
||||
setClaimFeedback({ pool: poolKey, type: "error", text: res.error || "Erreur" });
|
||||
}
|
||||
};
|
||||
|
||||
const viewOrderDetails = (order: CompletedOrder) => {
|
||||
if (!isUserAuthenticated()) { navigate('/login/client', { replace: true }); return; }
|
||||
navigate(`/user/commande/${order.client_order_number ?? order.id}`, {
|
||||
state: { commandId: order.id },
|
||||
});
|
||||
};
|
||||
const poolNames = clientStats?.pool_names?.length
|
||||
? clientStats.pool_names
|
||||
: appSettings.pool_names;
|
||||
const poolPoints = clientStats?.pool_points ?? [clientStats?.points ?? 0];
|
||||
const totalPoints = poolPoints.reduce((s, v) => s + (v || 0), 0);
|
||||
const penaltyCount =
|
||||
penalties?.total_penalty || clientStats?.penalties || 0;
|
||||
const poolIcons = [faCannabis, faPills, faFlask, faMortarPestle, faStar];
|
||||
const poolIconColors = [
|
||||
"#10b981",
|
||||
"#e879f9",
|
||||
"#fb923c",
|
||||
"#38bdf8",
|
||||
"#7c3aed",
|
||||
];
|
||||
|
||||
const formatDate = (dateStr: string) => {
|
||||
const d = new Date(dateStr);
|
||||
return d.toLocaleDateString('fr-FR', { day: '2-digit', month: 'short', year: 'numeric' });
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<>
|
||||
<Navbar />
|
||||
<div className="history-container">
|
||||
<div className="loading-container">
|
||||
<div className="loading-spinner"><div className="spinner"></div></div>
|
||||
<p className="loading-text">Chargement de l'historique...</p>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
<>
|
||||
<Navbar />
|
||||
<div className="history-container">
|
||||
<h1 className="history-title">Historique des commandes</h1>
|
||||
|
||||
const poolNames = clientStats?.pool_names?.length ? clientStats.pool_names : appSettings.pool_names;
|
||||
const poolPoints = clientStats?.pool_points ?? [clientStats?.points ?? 0];
|
||||
const totalPoints = poolPoints.reduce((s, v) => s + (v || 0), 0);
|
||||
const penaltyCount = penalties?.total_penalty || clientStats?.penalties || 0;
|
||||
const poolIcons = [faCannabis, faPills, faFlask, faMortarPestle, faStar];
|
||||
const poolIconColors = ['#10b981', '#e879f9', '#fb923c', '#38bdf8', '#7c3aed'];
|
||||
{/* Stats grid */}
|
||||
<div className="stats-grid">
|
||||
{/* Total commandes */}
|
||||
<div className="stat-card2">
|
||||
<div className="stat-icon icon-total-orders">
|
||||
<FontAwesomeIcon icon={faReceipt} />
|
||||
</div>
|
||||
<p className="stat-value">
|
||||
{clientStats?.total_commands ?? orders.length}
|
||||
</p>
|
||||
<p className="stat-label">Commandes</p>
|
||||
</div>
|
||||
|
||||
return (
|
||||
<>
|
||||
<Navbar />
|
||||
<div className="history-container">
|
||||
{/* Points */}
|
||||
{appSettings.points_enabled &&
|
||||
(poolNames.length <= 1 ? (
|
||||
<div className="stat-card2">
|
||||
<div className="stat-icon icon-total">
|
||||
<FontAwesomeIcon icon={faTrophy} />
|
||||
</div>
|
||||
<p className="stat-value">
|
||||
{poolPoints[0] || 0}
|
||||
</p>
|
||||
<p className="stat-label">
|
||||
Pts {poolNames[0] ?? "Points"}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{poolNames.map((name, i) => (
|
||||
<div key={i} className="stat-card2">
|
||||
<div
|
||||
className="stat-icon"
|
||||
style={{
|
||||
background: `linear-gradient(135deg, ${poolIconColors[i] ?? "#7c3aed"}cc, ${poolIconColors[i] ?? "#7c3aed"})`,
|
||||
}}
|
||||
>
|
||||
<FontAwesomeIcon
|
||||
icon={poolIcons[i] ?? faStar}
|
||||
/>
|
||||
</div>
|
||||
<p className="stat-value">
|
||||
{poolPoints[i] || 0}
|
||||
</p>
|
||||
<p className="stat-label">Pts {name}</p>
|
||||
</div>
|
||||
))}
|
||||
{totalPoints > 0 && (
|
||||
<div className="stat-card2">
|
||||
<div className="stat-icon icon-total">
|
||||
<FontAwesomeIcon icon={faTrophy} />
|
||||
</div>
|
||||
<p className="stat-value">
|
||||
{totalPoints}
|
||||
</p>
|
||||
<p className="stat-label">
|
||||
Total Points
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
))}
|
||||
|
||||
<h1 className="history-title">Historique des commandes</h1>
|
||||
|
||||
{/* Stats grid */}
|
||||
<div className="stats-grid">
|
||||
|
||||
{/* Total commandes */}
|
||||
<div className="stat-card2">
|
||||
<div className="stat-icon icon-total-orders">
|
||||
<FontAwesomeIcon icon={faReceipt} />
|
||||
</div>
|
||||
<p className="stat-value">{clientStats?.total_commands ?? orders.length}</p>
|
||||
<p className="stat-label">Commandes</p>
|
||||
</div>
|
||||
|
||||
{/* Points */}
|
||||
{appSettings.points_enabled && (
|
||||
poolNames.length <= 1 ? (
|
||||
<div className="stat-card2">
|
||||
<div className="stat-icon icon-total">
|
||||
<FontAwesomeIcon icon={faTrophy} />
|
||||
{/* Score amendes */}
|
||||
{appSettings.show_amende_score && (
|
||||
<div
|
||||
className={`stat-card2${penaltyCount >= 3 ? " stat-card-danger" : penaltyCount > 0 ? " stat-card-warning" : ""}`}
|
||||
>
|
||||
<div
|
||||
className={`stat-icon ${penaltyCount >= 3 ? "icon-penalty-critical" : penaltyCount > 0 ? "icon-penalty-warning" : "icon-penalty-ok"}`}
|
||||
>
|
||||
<FontAwesomeIcon
|
||||
icon={
|
||||
penaltyCount > 0
|
||||
? faExclamationTriangle
|
||||
: faShieldAlt
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<p
|
||||
className={`stat-value ${penaltyCount >= 3 ? "value-danger" : penaltyCount > 0 ? "value-warning" : ""}`}
|
||||
>
|
||||
{penaltyCount}
|
||||
</p>
|
||||
<p className="stat-label">Score amendes</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<p className="stat-value">{poolPoints[0] || 0}</p>
|
||||
<p className="stat-label">Pts {poolNames[0] ?? 'Points'}</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{poolNames.map((name, i) => (
|
||||
<div key={i} className="stat-card2">
|
||||
<div className="stat-icon" style={{ background: `linear-gradient(135deg, ${poolIconColors[i] ?? '#7c3aed'}cc, ${poolIconColors[i] ?? '#7c3aed'})` }}>
|
||||
<FontAwesomeIcon icon={poolIcons[i] ?? faStar} />
|
||||
|
||||
{/* Section récompenses par palier */}
|
||||
{pointsRewards?.enabled && pointsRewards.reward && pointsRewards.pools.length > 0 && (
|
||||
<div className="rewards-section">
|
||||
<p className="rewards-section-title">
|
||||
<FontAwesomeIcon icon={faTrophy} className="rewards-section-icon" />
|
||||
Récompenses
|
||||
</p>
|
||||
{pointsRewards.reward.description && (
|
||||
<p className="rewards-section-desc">{pointsRewards.reward.description}</p>
|
||||
)}
|
||||
{(pointsRewards.reward.reward_items ?? []).filter((it: RewardItemConfig) => it.product_name).length > 0 && (
|
||||
<div className="reward-eligible-cats">
|
||||
{(pointsRewards.reward.reward_items ?? []).map((it: RewardItemConfig, idx: number) => (
|
||||
<span key={idx} className="reward-eligible-cat">
|
||||
<FontAwesomeIcon icon={faGift} style={{ marginRight: 4 }} />
|
||||
{it.product_name}{it.quantity > 0 && it.quantity !== 1 ? ` ×${it.quantity}` : ""}{it.price > 0 ? ` — ${it.price}€` : ""}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className="rewards-pools">
|
||||
{pointsRewards.pools.map((pool) => {
|
||||
const threshold = pointsRewards.reward!.threshold;
|
||||
const progress = Math.min(100, Math.round((pool.points % threshold) / threshold * 100));
|
||||
const remaining = threshold - (pool.points % threshold);
|
||||
const isClaiming = claimingPool === pool.key;
|
||||
const feedback = claimFeedback?.pool === pool.key ? claimFeedback : null;
|
||||
return (
|
||||
<div key={pool.key} className="reward-pool-card">
|
||||
<div className="reward-pool-header">
|
||||
<span className="reward-pool-name">{pool.name}</span>
|
||||
<span className="reward-pool-pts">{pool.points} pts</span>
|
||||
</div>
|
||||
{pool.eligible_configs.length > 0 && (
|
||||
<div className="reward-eligible-cats">
|
||||
{pool.eligible_configs.flatMap((cfg) =>
|
||||
cfg.all_products
|
||||
? [<span key={cfg.category} className="reward-eligible-cat">
|
||||
{cfg.category}
|
||||
</span>]
|
||||
: (cfg.product_names ?? []).map((name) => (
|
||||
<span key={`${cfg.category}-${name}`} className="reward-eligible-cat">
|
||||
{name}
|
||||
</span>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className="reward-progress-bar">
|
||||
<div className="reward-progress-fill" style={{ width: `${progress}%` }} />
|
||||
</div>
|
||||
<div className="reward-pool-info">
|
||||
{pool.rewards_available > 0 ? (
|
||||
<span className="reward-available">{pool.rewards_available} récompense{pool.rewards_available > 1 ? "s" : ""} disponible{pool.rewards_available > 1 ? "s" : ""}</span>
|
||||
) : (
|
||||
<span className="reward-remaining">Encore {remaining} pts pour une récompense</span>
|
||||
)}
|
||||
</div>
|
||||
{feedback && (
|
||||
<p className={`reward-feedback reward-feedback-${feedback.type}`}>{feedback.text}</p>
|
||||
)}
|
||||
{pool.rewards_available > 0 && (
|
||||
<button
|
||||
className="reward-claim-btn"
|
||||
onClick={() => handleClaim(pool.key)}
|
||||
disabled={isClaiming}
|
||||
>
|
||||
{isClaiming ? "..." : "Réclamer ma récompense"}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
<p className="stat-value">{poolPoints[i] || 0}</p>
|
||||
<p className="stat-label">Pts {name}</p>
|
||||
</div>
|
||||
))}
|
||||
{totalPoints > 0 && (
|
||||
<div className="stat-card2">
|
||||
<div className="stat-icon icon-total">
|
||||
<FontAwesomeIcon icon={faTrophy} />
|
||||
</div>
|
||||
<p className="stat-value">{totalPoints}</p>
|
||||
<p className="stat-label">Total Points</p>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
)}
|
||||
|
||||
{/* Score amendes */}
|
||||
{appSettings.show_amende_score && (
|
||||
<div className={`stat-card2${penaltyCount >= 3 ? ' stat-card-danger' : penaltyCount > 0 ? ' stat-card-warning' : ''}`}>
|
||||
<div className={`stat-icon ${penaltyCount >= 3 ? 'icon-penalty-critical' : penaltyCount > 0 ? 'icon-penalty-warning' : 'icon-penalty-ok'}`}>
|
||||
<FontAwesomeIcon icon={penaltyCount > 0 ? faExclamationTriangle : faShieldAlt} />
|
||||
</div>
|
||||
<p className={`stat-value ${penaltyCount >= 3 ? 'value-danger' : penaltyCount > 0 ? 'value-warning' : ''}`}>{penaltyCount}</p>
|
||||
<p className="stat-label">Score amendes</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Bouton parrainage */}
|
||||
{appSettings.referral_enabled && (
|
||||
<div className="referral-btn-row" onClick={() => navigate('/user/parrainage')}>
|
||||
<FontAwesomeIcon icon={faGift} className="referral-btn-icon" />
|
||||
<span className="referral-btn-text">
|
||||
Parrainage{referralBalance > 0 ? ` — ${referralBalance.toFixed(2)} €` : ''}
|
||||
</span>
|
||||
<FontAwesomeIcon icon={faChevronRight} className="referral-btn-chevron" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="error-banner">
|
||||
<FontAwesomeIcon icon={faExclamationTriangle} size="2x" />
|
||||
<p>{error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{orders.length === 0 ? (
|
||||
<div className="empty-history">
|
||||
<FontAwesomeIcon icon={faHistory} className="empty-icon" />
|
||||
<h2>Aucun historique</h2>
|
||||
<p>Vos commandes terminées apparaîtront ici</p>
|
||||
<button className="browse-button" onClick={() => navigate('/user/accueil')}>
|
||||
Découvrir nos produits
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<p className="section-title">Historique des commandes</p>
|
||||
<div className="orders-list">
|
||||
{orders.map((order) => (
|
||||
<div
|
||||
key={order.id}
|
||||
className="order-card"
|
||||
onClick={() => viewOrderDetails(order)}
|
||||
>
|
||||
<div className="order-card-header">
|
||||
<span className="order-card-number">
|
||||
Commande #{(order.client_order_number ?? 0).toString().padStart(4, '0')}
|
||||
</span>
|
||||
<span className="order-card-badge">
|
||||
<FontAwesomeIcon icon={faCheckCircle} style={{ marginRight: '0.35rem' }} />
|
||||
Livrée
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="order-card-row">
|
||||
<FontAwesomeIcon icon={faMapMarkerAlt} className="order-card-row-icon" />
|
||||
<span className="order-card-row-text">
|
||||
{order.adresse ? (order.adresse.length > 50 ? order.adresse.substring(0, 50) + '…' : order.adresse) : 'N/A'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="order-card-row">
|
||||
<FontAwesomeIcon icon={faClock} className="order-card-row-icon" />
|
||||
<span className="order-card-row-text">
|
||||
{formatDate(order.created_at)} · {getOrderAge(order.created_at)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{order.livreur_assign && (
|
||||
<div className="order-card-row">
|
||||
<FontAwesomeIcon icon={faBicycle} className="order-card-row-icon" />
|
||||
<span className="order-card-row-text">{order.livreur_assign}</span>
|
||||
{/* Bouton parrainage */}
|
||||
{appSettings.referral_enabled && (
|
||||
<div
|
||||
className="referral-btn-row"
|
||||
onClick={() => navigate("/user/parrainage")}
|
||||
>
|
||||
<FontAwesomeIcon
|
||||
icon={faGift}
|
||||
className="referral-btn-icon"
|
||||
/>
|
||||
<span className="referral-btn-text">
|
||||
Parrainage
|
||||
{referralBalance > 0
|
||||
? ` — ${referralBalance.toFixed(2)} €`
|
||||
: ""}
|
||||
</span>
|
||||
<FontAwesomeIcon
|
||||
icon={faChevronRight}
|
||||
className="referral-btn-chevron"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
)}
|
||||
|
||||
<div className="order-card-footer">
|
||||
<span className="order-card-total">{formatPrice(order.total_prix || 0)}</span>
|
||||
<FontAwesomeIcon icon={faChevronRight} className="order-card-chevron" />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{error && (
|
||||
<div className="error-banner">
|
||||
<FontAwesomeIcon
|
||||
icon={faExclamationTriangle}
|
||||
size="2x"
|
||||
/>
|
||||
<p>{error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{orders.length === 0 ? (
|
||||
<div className="empty-history">
|
||||
<FontAwesomeIcon
|
||||
icon={faHistory}
|
||||
className="empty-icon"
|
||||
/>
|
||||
<h2>Aucun historique</h2>
|
||||
<p>Vos commandes terminées apparaîtront ici</p>
|
||||
<button
|
||||
className="browse-button"
|
||||
onClick={() => navigate("/user/accueil")}
|
||||
>
|
||||
Découvrir nos produits
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<p className="section-title">
|
||||
Historique des commandes
|
||||
</p>
|
||||
<div className="orders-list">
|
||||
{orders.map((order) => (
|
||||
<div
|
||||
key={order.id}
|
||||
className="order-card"
|
||||
onClick={() => viewOrderDetails(order)}
|
||||
>
|
||||
<div className="order-card-header">
|
||||
<span className="order-card-number">
|
||||
Commande #
|
||||
{(order.client_order_number ?? 0)
|
||||
.toString()
|
||||
.padStart(4, "0")}
|
||||
</span>
|
||||
<span className="order-card-badge">
|
||||
<FontAwesomeIcon
|
||||
icon={faCheckCircle}
|
||||
style={{
|
||||
marginRight: "0.35rem",
|
||||
}}
|
||||
/>
|
||||
Livrée
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="order-card-row">
|
||||
<FontAwesomeIcon
|
||||
icon={faMapMarkerAlt}
|
||||
className="order-card-row-icon"
|
||||
/>
|
||||
<span className="order-card-row-text">
|
||||
{order.adresse
|
||||
? order.adresse.length > 50
|
||||
? order.adresse.substring(
|
||||
0,
|
||||
50,
|
||||
) + "…"
|
||||
: order.adresse
|
||||
: "N/A"}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="order-card-row">
|
||||
<FontAwesomeIcon
|
||||
icon={faClock}
|
||||
className="order-card-row-icon"
|
||||
/>
|
||||
<span className="order-card-row-text">
|
||||
{formatDate(order.created_at)} ·{" "}
|
||||
{getOrderAge(order.created_at)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{order.livreur_assign && (
|
||||
<div className="order-card-row">
|
||||
<FontAwesomeIcon
|
||||
icon={faBicycle}
|
||||
className="order-card-row-icon"
|
||||
/>
|
||||
<span className="order-card-row-text">
|
||||
{order.livreur_assign}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="order-card-footer">
|
||||
<span className="order-card-total">
|
||||
{formatPrice((order.total_prix || 0) - (order.referral_used || 0))}
|
||||
{(order.referral_used || 0) > 0 && (
|
||||
<span className="order-card-referral">
|
||||
{" "}— dont {formatPrice(order.referral_used ?? 0)} parrainage
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<FontAwesomeIcon
|
||||
icon={faChevronRight}
|
||||
className="order-card-chevron"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default ConsultationHistorique;
|
||||
|
||||
@@ -60,7 +60,9 @@ function OrderDetails() {
|
||||
const location = useLocation();
|
||||
// commandId = ID global pour l'API (passé en state depuis l'historique)
|
||||
// fallback sur orderId si navigation directe via URL
|
||||
const commandId = (location.state as { commandId?: number } | null)?.commandId ?? parseInt(orderId ?? "0");
|
||||
const commandId =
|
||||
(location.state as { commandId?: number } | null)?.commandId ??
|
||||
parseInt(orderId ?? "0");
|
||||
|
||||
const [order, setOrder] = useState<OrderDetailsData | null>(null);
|
||||
const [enrichedProducts, setEnrichedProducts] = useState<
|
||||
|
||||
@@ -5,7 +5,6 @@ import { getReferralBalance, isUserAuthenticated, getPublicSettings } from '../.
|
||||
import type { PublicSettings } from '../../api/api';
|
||||
import './Parrainage.css';
|
||||
|
||||
const TELEGRAM_URL = 'https://t.me/';
|
||||
|
||||
const steps = [
|
||||
{
|
||||
@@ -140,21 +139,23 @@ export default function Parrainage() {
|
||||
</div>
|
||||
|
||||
{/* Bouton Telegram */}
|
||||
<div className="parrainage-cta">
|
||||
<p className="cta-text">
|
||||
Prêt à parrainer ? Contactez-nous sur Telegram pour enregistrer votre
|
||||
filleul.
|
||||
</p>
|
||||
<a
|
||||
href={TELEGRAM_URL}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="telegram-btn"
|
||||
>
|
||||
<i className="fab fa-telegram telegram-icon"></i>
|
||||
Contacter sur Telegram
|
||||
</a>
|
||||
</div>
|
||||
{settings?.contact_telegram && (
|
||||
<div className="parrainage-cta">
|
||||
<p className="cta-text">
|
||||
Prêt à parrainer ? Contactez-nous sur Telegram pour enregistrer votre
|
||||
filleul.
|
||||
</p>
|
||||
<a
|
||||
href={`https://t.me/${settings.contact_telegram}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="telegram-btn"
|
||||
>
|
||||
<i className="fab fa-telegram telegram-icon"></i>
|
||||
Contacter @{settings.contact_telegram}
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -133,6 +133,40 @@
|
||||
}
|
||||
}
|
||||
|
||||
/* Coming Soon Badge */
|
||||
.coming-soon-badge {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%) rotate(-15deg);
|
||||
background: rgba(0, 0, 0, 0.8);
|
||||
color: rgba(34, 197, 94, 0.95);
|
||||
border: 4px solid rgba(34, 197, 94, 0.95);
|
||||
padding: clamp(1rem, 4vw, 1.5rem) clamp(2.5rem, 8vw, 4rem);
|
||||
font-size: clamp(2rem, 8vw, 3.5rem);
|
||||
font-weight: 900;
|
||||
letter-spacing: 6px;
|
||||
text-transform: uppercase;
|
||||
white-space: nowrap;
|
||||
box-shadow:
|
||||
0 0 40px rgba(34, 197, 94, 0.6),
|
||||
0 8px 32px rgba(0, 0, 0, 0.6);
|
||||
text-shadow:
|
||||
2px 2px 12px rgba(0, 0, 0, 0.9),
|
||||
0 0 20px rgba(34, 197, 94, 0.3);
|
||||
z-index: 10;
|
||||
animation: pulseGreen 2s infinite;
|
||||
}
|
||||
|
||||
@keyframes pulseGreen {
|
||||
0%, 100% {
|
||||
transform: translate(-50%, -50%) rotate(-15deg) scale(1);
|
||||
}
|
||||
50% {
|
||||
transform: translate(-50%, -50%) rotate(-15deg) scale(1.05);
|
||||
}
|
||||
}
|
||||
|
||||
/* Info Section */
|
||||
.product-info-section {
|
||||
display: flex;
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import { useParams, useNavigate } from "react-router-dom";
|
||||
import { useState, useEffect } from "react";
|
||||
import { getProductById, getCategories, isUserAuthenticated } from "../../api/api";
|
||||
import {
|
||||
getProductById,
|
||||
getCategories,
|
||||
isUserAuthenticated,
|
||||
} from "../../api/api";
|
||||
import type { Product } from "../../api/api";
|
||||
import { useCart } from "../../context/useCart";
|
||||
import Navbar from "../../components/Navbar";
|
||||
@@ -86,12 +90,25 @@ function ProductDetail() {
|
||||
const fixedProduct = {
|
||||
...response.data,
|
||||
prices:
|
||||
response.data.prices?.map(
|
||||
(p: { quantity: number; price: number }) => ({
|
||||
quantity: parseFloat(String(p.quantity)),
|
||||
price: parseFloat(String(p.price)),
|
||||
}),
|
||||
) || [],
|
||||
response.data.prices
|
||||
?.filter(
|
||||
(p: {
|
||||
quantity: number;
|
||||
price: number;
|
||||
active_price?: boolean;
|
||||
}) => p.active_price !== false,
|
||||
)
|
||||
.map(
|
||||
(p: {
|
||||
quantity: number;
|
||||
price: number;
|
||||
active_price?: boolean;
|
||||
}) => ({
|
||||
quantity: parseFloat(String(p.quantity)),
|
||||
price: parseFloat(String(p.price)),
|
||||
active_price: p.active_price,
|
||||
}),
|
||||
) || [],
|
||||
};
|
||||
|
||||
setProduct(fixedProduct);
|
||||
@@ -104,14 +121,20 @@ function ProductDetail() {
|
||||
|
||||
// Couleur de la catégorie depuis la DB
|
||||
const matched = categories.find(
|
||||
(c) => c.name.toLowerCase() === (response.data.category || "").toLowerCase(),
|
||||
(c) =>
|
||||
c.name.toLowerCase() ===
|
||||
(response.data.category || "").toLowerCase(),
|
||||
);
|
||||
if (matched?.color) setCatColor(matched.color);
|
||||
} else {
|
||||
setError(response.message || "Produit non trouvé");
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
setError(err instanceof Error ? err.message : "Erreur lors du chargement du produit");
|
||||
setError(
|
||||
err instanceof Error
|
||||
? err.message
|
||||
: "Erreur lors du chargement du produit",
|
||||
);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
@@ -197,6 +220,7 @@ function ProductDetail() {
|
||||
}
|
||||
|
||||
const isOutOfStock = product.stock === 0;
|
||||
const isComingSoon = product.coming_soon === true;
|
||||
const hasValidPrices = product.prices && product.prices.length > 0;
|
||||
|
||||
// Convertir la couleur hex en valeurs RGB pour les CSS rgba()
|
||||
@@ -213,7 +237,8 @@ function ProductDetail() {
|
||||
const r = parseInt(h.slice(0, 2), 16);
|
||||
const g = parseInt(h.slice(2, 4), 16);
|
||||
const b = parseInt(h.slice(4, 6), 16);
|
||||
const catTextColor = (r * 299 + g * 587 + b * 114) / 1000 > 128 ? "#000000" : "#ffffff";
|
||||
const catTextColor =
|
||||
(r * 299 + g * 587 + b * 114) / 1000 > 128 ? "#000000" : "#ffffff";
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -231,20 +256,38 @@ function ProductDetail() {
|
||||
|
||||
<div
|
||||
className="product-detail-container"
|
||||
style={{ "--cat-color": catColor, "--cat-color-rgb": catColorRgb, "--cat-text-color": catTextColor } as React.CSSProperties}
|
||||
style={
|
||||
{
|
||||
"--cat-color": catColor,
|
||||
"--cat-color-rgb": catColorRgb,
|
||||
"--cat-text-color": catTextColor,
|
||||
} as React.CSSProperties
|
||||
}
|
||||
>
|
||||
<button onClick={() => navigate(-1)} className="back-button">
|
||||
← Retour
|
||||
</button>
|
||||
|
||||
<div className="product-detail-content">
|
||||
<div className={`product-image-section ${isOutOfStock ? "out-of-stock" : ""}`}>
|
||||
<div
|
||||
className={`product-image-section ${isOutOfStock ? "out-of-stock" : ""}`}
|
||||
>
|
||||
<img
|
||||
src={product.media?.find(m => m.type === "image")?.url || ""}
|
||||
src={
|
||||
product.media?.find((m) => m.type === "image")
|
||||
?.url || ""
|
||||
}
|
||||
alt={product.name}
|
||||
className="product-detail-image"
|
||||
/>
|
||||
{isOutOfStock && <div className="sold-out-badge">SOLD OUT</div>}
|
||||
{isOutOfStock && (
|
||||
<div className="sold-out-badge">SOLD OUT</div>
|
||||
)}
|
||||
{isComingSoon && (
|
||||
<div className="coming-soon-badge">
|
||||
COMMING SOON
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="product-info-section">
|
||||
@@ -253,7 +296,8 @@ function ProductDetail() {
|
||||
{selectedPrice > 0 && (
|
||||
<p className="product-detail-price">
|
||||
{selectedPrice.toFixed(2)} €{" "}
|
||||
{selectedGrams && `pour ${selectedGrams}${product.unit || "g"}`}
|
||||
{selectedGrams &&
|
||||
`pour ${selectedGrams}${product.unit || "g"}`}
|
||||
</p>
|
||||
)}
|
||||
|
||||
@@ -291,7 +335,8 @@ function ProductDetail() {
|
||||
key={p.quantity}
|
||||
value={p.quantity}
|
||||
>
|
||||
{p.quantity}{product.unit || "g"} -{" "}
|
||||
{p.quantity}
|
||||
{product.unit || "g"} -{" "}
|
||||
{p.price.toFixed(2)} €
|
||||
</option>
|
||||
))}
|
||||
@@ -301,13 +346,19 @@ function ProductDetail() {
|
||||
</div>
|
||||
|
||||
<button
|
||||
className={`add-to-cart-button ${isOutOfStock || selectedGrams === null ? "disabled" : ""}`}
|
||||
className={`add-to-cart-button ${isOutOfStock || isComingSoon || selectedGrams === null ? "disabled" : ""}`}
|
||||
onClick={handleAddToCart}
|
||||
disabled={isOutOfStock || selectedGrams === null}
|
||||
disabled={
|
||||
isOutOfStock ||
|
||||
isComingSoon ||
|
||||
selectedGrams === null
|
||||
}
|
||||
>
|
||||
{isOutOfStock
|
||||
? "Rupture de stock"
|
||||
: "Ajouter au panier"}
|
||||
: isComingSoon
|
||||
? "Bientôt disponible"
|
||||
: "Ajouter au panier"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -179,6 +179,79 @@
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* Toggle 2FA */
|
||||
.profile-2fa-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding-top: 0.4rem;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.profile-2fa-label {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.profile-2fa-badge {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.3rem;
|
||||
font-size: 0.78rem;
|
||||
font-weight: 500;
|
||||
color: #10b981;
|
||||
}
|
||||
|
||||
.profile-toggle {
|
||||
position: relative;
|
||||
width: 48px;
|
||||
height: 26px;
|
||||
border-radius: 13px;
|
||||
border: none;
|
||||
background: var(--border);
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
flex-shrink: 0;
|
||||
transition: background 0.25s;
|
||||
}
|
||||
|
||||
.profile-toggle--on {
|
||||
background: #6366f155;
|
||||
border: 1px solid #6366f1;
|
||||
}
|
||||
|
||||
.profile-toggle-thumb {
|
||||
position: absolute;
|
||||
top: 3px;
|
||||
left: 3px;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: 50%;
|
||||
background: var(--text-muted);
|
||||
transition: transform 0.25s, background 0.25s;
|
||||
}
|
||||
|
||||
.profile-toggle--on .profile-toggle-thumb {
|
||||
transform: translateX(22px);
|
||||
background: #6366f1;
|
||||
}
|
||||
|
||||
.profile-2fa-spinner {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border: 2px solid #6366f133;
|
||||
border-top-color: #6366f1;
|
||||
border-radius: 50%;
|
||||
animation: spin 0.7s linear infinite;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.profile-row { grid-template-columns: 1fr; }
|
||||
}
|
||||
@@ -284,3 +357,27 @@
|
||||
border: 1px solid rgba(37, 99, 235, 0.27);
|
||||
color: #60a5fa;
|
||||
}
|
||||
|
||||
.profile-modal-btn--danger {
|
||||
background: rgba(239, 68, 68, 0.1);
|
||||
border: 1px solid rgba(239, 68, 68, 0.35);
|
||||
color: #ef4444;
|
||||
}
|
||||
|
||||
.profile-modal-icon--danger {
|
||||
background: rgba(239, 68, 68, 0.1);
|
||||
border-color: rgba(239, 68, 68, 0.3);
|
||||
color: #ef4444;
|
||||
}
|
||||
|
||||
.profile-modal-icon--success {
|
||||
background: rgba(16, 185, 129, 0.1);
|
||||
border-color: rgba(16, 185, 129, 0.3);
|
||||
color: #10b981;
|
||||
}
|
||||
|
||||
.profile-modal-btn--success {
|
||||
background: transparent;
|
||||
border: 1px solid rgba(16, 185, 129, 0.4);
|
||||
color: #10b981;
|
||||
}
|
||||
|
||||
@@ -1,433 +1,514 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import Navbar from "../../components/Navbar";
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import Navbar from '../../components/Navbar';
|
||||
import { isUserAuthenticated, extractUsernameFromToken, getMyProfile, updateMyProfile, getTelegramStatus, generateTelegramLinkToken, unlinkTelegram, get2FAStatus, toggle2FA, getPublicSettings } from '../../api/api';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import {
|
||||
isUserAuthenticated,
|
||||
extractUsernameFromToken,
|
||||
getMyProfile,
|
||||
updateMyProfile,
|
||||
getTelegramStatus,
|
||||
generateTelegramLinkToken,
|
||||
unlinkTelegram,
|
||||
} from "../../api/api";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import {
|
||||
faUser,
|
||||
faMapMarkerAlt,
|
||||
faPhone,
|
||||
faCommentDots,
|
||||
faSave,
|
||||
faCheckCircle,
|
||||
faExclamationTriangle,
|
||||
faPaperPlane,
|
||||
faUnlink,
|
||||
faTimes,
|
||||
faLock,
|
||||
} from "@fortawesome/free-solid-svg-icons";
|
||||
import "./ProfilePage.css";
|
||||
faUser, faMapMarkerAlt, faPhone, faCommentDots,
|
||||
faSave, faCheckCircle, faExclamationTriangle, faPaperPlane, faUnlink, faTimes, faLock, faShieldAlt,
|
||||
} from '@fortawesome/free-solid-svg-icons';
|
||||
import './ProfilePage.css';
|
||||
|
||||
const STORAGE_ADDRESS = "profile_default_address";
|
||||
const STORAGE_PHONE = "profile_default_phone";
|
||||
const STORAGE_SIGNAL = "profile_signal_pseudo";
|
||||
const STORAGE_ADDRESS = 'profile_default_address';
|
||||
const STORAGE_PHONE = 'profile_default_phone';
|
||||
const STORAGE_SIGNAL = 'profile_signal_pseudo';
|
||||
|
||||
export default function ProfilePage() {
|
||||
const navigate = useNavigate();
|
||||
const navigate = useNavigate();
|
||||
|
||||
// Données compte (backend)
|
||||
const [nom, setNom] = useState("");
|
||||
const [prenom, setPrenom] = useState("");
|
||||
const [telephone, setTelephone] = useState("");
|
||||
const [loadingProfile, setLoadingProfile] = useState(true);
|
||||
// Données compte (backend)
|
||||
const [nom, setNom] = useState('');
|
||||
const [prenom, setPrenom] = useState('');
|
||||
const [telephone, setTelephone] = useState('');
|
||||
const [loadingProfile, setLoadingProfile] = useState(true);
|
||||
|
||||
// Données locales (localStorage)
|
||||
const [defaultAddress, setDefaultAddress] = useState(
|
||||
() => localStorage.getItem(STORAGE_ADDRESS) ?? "",
|
||||
);
|
||||
const [defaultPhone, setDefaultPhone] = useState(
|
||||
() => localStorage.getItem(STORAGE_PHONE) ?? "",
|
||||
);
|
||||
const [signalPseudo, setSignalPseudo] = useState(
|
||||
() => localStorage.getItem(STORAGE_SIGNAL) ?? "",
|
||||
);
|
||||
// Données locales (localStorage)
|
||||
const [defaultAddress, setDefaultAddress] = useState(() => localStorage.getItem(STORAGE_ADDRESS) ?? '');
|
||||
const [defaultPhone, setDefaultPhone] = useState(() => localStorage.getItem(STORAGE_PHONE) ?? '');
|
||||
const [signalPseudo, setSignalPseudo] = useState(() => localStorage.getItem(STORAGE_SIGNAL) ?? '');
|
||||
|
||||
// Feedback
|
||||
const [savingContact, setSavingContact] = useState(false);
|
||||
const [successMsg, setSuccessMsg] = useState("");
|
||||
const [errorMsg, setErrorMsg] = useState("");
|
||||
const [savingContact, setSavingContact] = useState(false);
|
||||
|
||||
// Telegram
|
||||
const [tgLinked, setTgLinked] = useState(false);
|
||||
const [tgEnabled, setTgEnabled] = useState(false);
|
||||
const [tgLoading, setTgLoading] = useState(false);
|
||||
// Modals succès / erreur (pattern identique à l'app mobile)
|
||||
const [showSuccessModal, setShowSuccessModal] = useState(false);
|
||||
const [successTitle, setSuccessTitle] = useState('');
|
||||
const [successMsg, setSuccessMsg] = useState('');
|
||||
const [showErrorModal, setShowErrorModal] = useState(false);
|
||||
const [errorMsg, setErrorMsg] = useState('');
|
||||
|
||||
// Modal confirmation infos par défaut
|
||||
const [showSaveModal, setShowSaveModal] = useState(false);
|
||||
// Telegram
|
||||
const [tgLinked, setTgLinked] = useState(false);
|
||||
const [tgEnabled, setTgEnabled] = useState(false);
|
||||
const [tgLoading, setTgLoading] = useState(false);
|
||||
|
||||
const username = extractUsernameFromToken() ?? "";
|
||||
// 2FA
|
||||
const [twoFAEnabled, setTwoFAEnabled] = useState(false);
|
||||
const [twoFAAdminEnabled, setTwoFAAdminEnabled] = useState(false);
|
||||
const [twoFALoading, setTwoFALoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isUserAuthenticated()) {
|
||||
navigate("/login/client", { replace: true });
|
||||
return;
|
||||
}
|
||||
// Statut Telegram
|
||||
getTelegramStatus().then((s) => {
|
||||
setTgLinked(s.linked);
|
||||
setTgEnabled(s.enabled);
|
||||
});
|
||||
// Modals confirmation
|
||||
const [showSaveModal, setShowSaveModal] = useState(false);
|
||||
const [showConfirmAddressModal, setShowConfirmAddressModal] = useState(false);
|
||||
const [showConfirmContactModal, setShowConfirmContactModal] = useState(false);
|
||||
const [showUnlinkModal, setShowUnlinkModal] = useState(false);
|
||||
const [showUnlinkSuccessModal, setShowUnlinkSuccessModal] = useState(false);
|
||||
|
||||
// Charger depuis backend
|
||||
getMyProfile().then((res) => {
|
||||
if (res.success && res.client) {
|
||||
setNom(res.client.nom ?? "");
|
||||
setPrenom(res.client.prenom ?? "");
|
||||
setTelephone(res.client.telephone ?? "");
|
||||
// Initialiser le téléphone par défaut si pas encore défini
|
||||
if (
|
||||
!localStorage.getItem(STORAGE_PHONE) &&
|
||||
res.client.telephone
|
||||
) {
|
||||
setDefaultPhone(res.client.telephone);
|
||||
}
|
||||
}
|
||||
setLoadingProfile(false);
|
||||
});
|
||||
}, [navigate]);
|
||||
const username = extractUsernameFromToken() ?? '';
|
||||
|
||||
const showSuccess = (msg: string) => {
|
||||
setSuccessMsg(msg);
|
||||
setErrorMsg("");
|
||||
setTimeout(() => setSuccessMsg(""), 3000);
|
||||
};
|
||||
const showError = (msg: string) => {
|
||||
setErrorMsg(msg);
|
||||
setSuccessMsg("");
|
||||
setTimeout(() => setErrorMsg(""), 4000);
|
||||
};
|
||||
|
||||
const saveLocal = () => {
|
||||
localStorage.setItem(STORAGE_ADDRESS, defaultAddress.trim());
|
||||
localStorage.setItem(STORAGE_PHONE, defaultPhone.trim());
|
||||
localStorage.setItem(STORAGE_SIGNAL, signalPseudo.trim());
|
||||
setShowSaveModal(false);
|
||||
showSuccess("Informations par défaut enregistrées");
|
||||
};
|
||||
|
||||
const handleLinkTelegram = async () => {
|
||||
setTgLoading(true);
|
||||
|
||||
// ✅ Ouvrir AVANT le await
|
||||
const newWindow = window.open("", "_blank");
|
||||
|
||||
const res = await generateTelegramLinkToken();
|
||||
setTgLoading(false);
|
||||
|
||||
if (res.error || !res.link_url) {
|
||||
newWindow?.close();
|
||||
showError(res.error || "Service Telegram non disponible");
|
||||
return;
|
||||
}
|
||||
|
||||
const isIOS = /iPad|iPhone|iPod/.test(navigator.userAgent);
|
||||
|
||||
if (isIOS) {
|
||||
const botUsername = res.link_url.split("t.me/")[1]?.split("?")[0];
|
||||
const token = new URL(res.link_url).searchParams.get("start");
|
||||
newWindow!.location.href = `tg://resolve?domain=${botUsername}&start=${token}`;
|
||||
setTimeout(() => {
|
||||
newWindow!.location.href = res.link_url!;
|
||||
}, 1500);
|
||||
} else {
|
||||
newWindow!.location.href = res.link_url;
|
||||
}
|
||||
};
|
||||
|
||||
const handleUnlinkTelegram = async () => {
|
||||
if (
|
||||
!window.confirm(
|
||||
"Délier votre compte Telegram ? Vous ne recevrez plus de notifications.",
|
||||
)
|
||||
)
|
||||
return;
|
||||
await unlinkTelegram();
|
||||
setTgLinked(false);
|
||||
showSuccess("Compte Telegram délié");
|
||||
};
|
||||
|
||||
const saveContact = async () => {
|
||||
setSavingContact(true);
|
||||
const res = await updateMyProfile({
|
||||
nom: nom.trim(),
|
||||
prenom: prenom.trim(),
|
||||
telephone: telephone.trim(),
|
||||
});
|
||||
setSavingContact(false);
|
||||
if (res.success) {
|
||||
showSuccess("Profil mis à jour");
|
||||
} else {
|
||||
showError(res.message ?? "Erreur lors de la mise à jour");
|
||||
}
|
||||
};
|
||||
|
||||
if (loadingProfile) {
|
||||
return (
|
||||
<>
|
||||
<Navbar />
|
||||
<div className="profile-container">
|
||||
<div className="profile-loading">
|
||||
<div className="spinner" />
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
useEffect(() => {
|
||||
if (!isUserAuthenticated()) {
|
||||
navigate('/login/client', { replace: true });
|
||||
return;
|
||||
}
|
||||
// Statut Telegram
|
||||
getTelegramStatus().then((s) => { setTgLinked(s.linked); setTgEnabled(s.enabled); });
|
||||
|
||||
// Statut 2FA
|
||||
Promise.all([get2FAStatus(), getPublicSettings()]).then(([status, pub]) => {
|
||||
setTwoFAEnabled(status.two_fa_enabled);
|
||||
setTwoFAAdminEnabled(pub.two_fa_enabled);
|
||||
});
|
||||
|
||||
// Charger depuis backend
|
||||
getMyProfile().then((res) => {
|
||||
if (res.success && res.client) {
|
||||
setNom(res.client.nom ?? '');
|
||||
setPrenom(res.client.prenom ?? '');
|
||||
setTelephone(res.client.telephone ?? '');
|
||||
// Initialiser le téléphone par défaut si pas encore défini
|
||||
if (!localStorage.getItem(STORAGE_PHONE) && res.client.telephone) {
|
||||
setDefaultPhone(res.client.telephone);
|
||||
}
|
||||
}
|
||||
setLoadingProfile(false);
|
||||
});
|
||||
}, [navigate]);
|
||||
|
||||
const showSuccess = (title: string, msg: string) => {
|
||||
setSuccessTitle(title); setSuccessMsg(msg); setShowSuccessModal(true);
|
||||
};
|
||||
const showError = (msg: string) => {
|
||||
setErrorMsg(msg); setShowErrorModal(true);
|
||||
};
|
||||
|
||||
const saveAddress = () => {
|
||||
localStorage.setItem(STORAGE_ADDRESS, defaultAddress.trim());
|
||||
setShowConfirmAddressModal(false);
|
||||
showSuccess('Adresse enregistrée', 'Votre adresse par défaut a été sauvegardée et sera pré-remplie à votre prochaine commande.');
|
||||
};
|
||||
|
||||
const saveLocal = () => {
|
||||
localStorage.setItem(STORAGE_ADDRESS, defaultAddress.trim());
|
||||
localStorage.setItem(STORAGE_PHONE, defaultPhone.trim());
|
||||
localStorage.setItem(STORAGE_SIGNAL, signalPseudo.trim());
|
||||
setShowSaveModal(false);
|
||||
showSuccess('Infos enregistrées', 'Adresse, téléphone et pseudo Signal sauvegardés. Ils seront pré-remplis à votre prochaine commande.');
|
||||
};
|
||||
|
||||
const handleLinkTelegram = async () => {
|
||||
setTgLoading(true);
|
||||
const res = await generateTelegramLinkToken();
|
||||
setTgLoading(false);
|
||||
if (res.error || !res.link_url) {
|
||||
showError(res.error || 'Service Telegram non disponible');
|
||||
return;
|
||||
}
|
||||
window.open(res.link_url, '_blank');
|
||||
};
|
||||
|
||||
const handleUnlinkTelegram = () => {
|
||||
setShowUnlinkModal(true);
|
||||
};
|
||||
|
||||
const confirmUnlinkTelegram = async () => {
|
||||
setShowUnlinkModal(false);
|
||||
await unlinkTelegram();
|
||||
setTgLinked(false);
|
||||
setTwoFAEnabled(false);
|
||||
setShowUnlinkSuccessModal(true);
|
||||
};
|
||||
|
||||
const handleToggle2FA = async () => {
|
||||
const newVal = !twoFAEnabled;
|
||||
setTwoFALoading(true);
|
||||
const res = await toggle2FA(newVal);
|
||||
setTwoFALoading(false);
|
||||
if (res.success) {
|
||||
setTwoFAEnabled(newVal);
|
||||
showSuccess(newVal ? '2FA activée' : '2FA désactivée', newVal ? 'Un code vous sera envoyé sur Telegram à chaque connexion.' : 'La double authentification a été désactivée.');
|
||||
} else {
|
||||
showError(res.error || 'Erreur lors de la modification');
|
||||
}
|
||||
};
|
||||
|
||||
const saveContact = async () => {
|
||||
setShowConfirmContactModal(false);
|
||||
setSavingContact(true);
|
||||
const res = await updateMyProfile({ nom: nom.trim(), prenom: prenom.trim(), telephone: telephone.trim() });
|
||||
setSavingContact(false);
|
||||
if (res.success) {
|
||||
showSuccess('Profil mis à jour', 'Vos informations de compte ont été enregistrées avec succès.');
|
||||
} else {
|
||||
showError(res.message ?? 'Erreur lors de la mise à jour du profil.');
|
||||
}
|
||||
};
|
||||
|
||||
if (loadingProfile) {
|
||||
return (
|
||||
<>
|
||||
<Navbar />
|
||||
<div className="profile-container">
|
||||
<div className="profile-header">
|
||||
<div className="profile-avatar">
|
||||
<FontAwesomeIcon icon={faUser} />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="profile-title">Mon Profil</h1>
|
||||
<p className="profile-username">@{username}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{successMsg && (
|
||||
<div className="profile-alert profile-alert--success">
|
||||
<FontAwesomeIcon icon={faCheckCircle} /> {successMsg}
|
||||
</div>
|
||||
)}
|
||||
{errorMsg && (
|
||||
<div className="profile-alert profile-alert--error">
|
||||
<FontAwesomeIcon icon={faExclamationTriangle} />{" "}
|
||||
{errorMsg}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Section compte */}
|
||||
<div className="profile-card">
|
||||
<h2 className="profile-card-title">
|
||||
<FontAwesomeIcon
|
||||
icon={faUser}
|
||||
className="profile-card-icon"
|
||||
/>
|
||||
Mon compte
|
||||
</h2>
|
||||
<div className="profile-fields">
|
||||
<div className="profile-row">
|
||||
<div className="profile-group">
|
||||
<label>Prénom</label>
|
||||
<input
|
||||
type="text"
|
||||
value={prenom}
|
||||
onChange={(e) => setPrenom(e.target.value)}
|
||||
placeholder="Votre prénom"
|
||||
/>
|
||||
</div>
|
||||
<div className="profile-group">
|
||||
<label>Nom</label>
|
||||
<input
|
||||
type="text"
|
||||
value={nom}
|
||||
onChange={(e) => setNom(e.target.value)}
|
||||
placeholder="Votre nom"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="profile-group">
|
||||
<label>Téléphone (compte)</label>
|
||||
<input
|
||||
type="tel"
|
||||
value={telephone}
|
||||
onChange={(e) => setTelephone(e.target.value)}
|
||||
placeholder="+33 6 12 34 56 78"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
className="profile-btn"
|
||||
onClick={saveContact}
|
||||
disabled={savingContact}
|
||||
>
|
||||
<FontAwesomeIcon icon={faSave} />
|
||||
{savingContact
|
||||
? " Enregistrement..."
|
||||
: " Enregistrer le compte"}
|
||||
</button>
|
||||
<button
|
||||
className="profile-btn profile-btn--secondary"
|
||||
onClick={() => navigate("/user/change-password")}
|
||||
style={{ marginTop: "0.6rem" }}
|
||||
>
|
||||
<FontAwesomeIcon icon={faLock} /> Changer le mot de
|
||||
passe
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Section adresse par défaut */}
|
||||
<div className="profile-card">
|
||||
<h2 className="profile-card-title">
|
||||
<FontAwesomeIcon
|
||||
icon={faMapMarkerAlt}
|
||||
className="profile-card-icon profile-card-icon--address"
|
||||
/>
|
||||
Adresse par défaut
|
||||
</h2>
|
||||
<p className="profile-hint">
|
||||
Sera pré-remplie dans le formulaire de commande. Vous
|
||||
pourrez la modifier si vous n'êtes pas à cette adresse.
|
||||
</p>
|
||||
<div className="profile-group">
|
||||
<label>Adresse</label>
|
||||
<input
|
||||
type="text"
|
||||
value={defaultAddress}
|
||||
onChange={(e) => setDefaultAddress(e.target.value)}
|
||||
placeholder="Numéro, rue, ville, code postal"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Section contact commande */}
|
||||
<div className="profile-card">
|
||||
<h2 className="profile-card-title">
|
||||
<FontAwesomeIcon
|
||||
icon={faPhone}
|
||||
className="profile-card-icon profile-card-icon--phone"
|
||||
/>
|
||||
Contact livraison
|
||||
</h2>
|
||||
<p className="profile-hint">
|
||||
Numéro utilisé par le livreur lors de la livraison. Peut
|
||||
être différent du numéro de votre compte.
|
||||
</p>
|
||||
<div className="profile-group">
|
||||
<label>Téléphone par défaut</label>
|
||||
<input
|
||||
type="tel"
|
||||
value={defaultPhone}
|
||||
onChange={(e) => setDefaultPhone(e.target.value)}
|
||||
placeholder="+33 6 12 34 56 78"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
className="profile-group"
|
||||
style={{ marginTop: "1rem" }}
|
||||
>
|
||||
<label>
|
||||
<FontAwesomeIcon
|
||||
icon={faCommentDots}
|
||||
style={{ marginRight: "0.4rem" }}
|
||||
/>
|
||||
Pseudo Signal (optionnel)
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={signalPseudo}
|
||||
onChange={(e) => setSignalPseudo(e.target.value)}
|
||||
placeholder="@votre.pseudo.signal"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
className="profile-btn profile-btn--secondary"
|
||||
onClick={() => setShowSaveModal(true)}
|
||||
>
|
||||
<FontAwesomeIcon icon={faSave} /> Enregistrer les infos
|
||||
par défaut
|
||||
</button>
|
||||
</div>
|
||||
{/* Section Telegram */}
|
||||
{tgEnabled && (
|
||||
<div className="profile-card">
|
||||
<h2 className="profile-card-title">
|
||||
<FontAwesomeIcon
|
||||
icon={faPaperPlane}
|
||||
className="profile-card-icon profile-card-icon--telegram"
|
||||
/>
|
||||
Notifications Telegram
|
||||
</h2>
|
||||
<p className="profile-hint">
|
||||
Recevez vos notifications sur Telegram, même quand
|
||||
le site est fermé.
|
||||
</p>
|
||||
{tgLinked ? (
|
||||
<div className="profile-telegram-linked">
|
||||
<span className="profile-telegram-status">
|
||||
<FontAwesomeIcon icon={faCheckCircle} />{" "}
|
||||
Compte Telegram lié
|
||||
</span>
|
||||
<button
|
||||
className="profile-btn profile-btn--danger"
|
||||
onClick={handleUnlinkTelegram}
|
||||
>
|
||||
<FontAwesomeIcon icon={faUnlink} /> Délier
|
||||
Telegram
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
className="profile-btn profile-btn--telegram"
|
||||
onClick={handleLinkTelegram}
|
||||
disabled={tgLoading}
|
||||
>
|
||||
<FontAwesomeIcon icon={faPaperPlane} />
|
||||
{tgLoading
|
||||
? " Génération du lien..."
|
||||
: " Lier mon compte Telegram"}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{showSaveModal && (
|
||||
<div
|
||||
className="profile-modal-overlay"
|
||||
onClick={() => setShowSaveModal(false)}
|
||||
>
|
||||
<div
|
||||
className="profile-modal"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<button
|
||||
className="profile-modal-close"
|
||||
onClick={() => setShowSaveModal(false)}
|
||||
>
|
||||
<FontAwesomeIcon icon={faTimes} />
|
||||
</button>
|
||||
<div className="profile-modal-icon">
|
||||
<FontAwesomeIcon icon={faSave} />
|
||||
</div>
|
||||
<h3 className="profile-modal-title">
|
||||
Enregistrer les infos par défaut ?
|
||||
</h3>
|
||||
<p className="profile-modal-body">
|
||||
Adresse, téléphone de livraison et pseudo Signal
|
||||
seront sauvegardés localement et pré-remplis lors de
|
||||
vos prochaines commandes.
|
||||
</p>
|
||||
<div className="profile-modal-actions">
|
||||
<button
|
||||
className="profile-modal-btn profile-modal-btn--cancel"
|
||||
onClick={() => setShowSaveModal(false)}
|
||||
>
|
||||
Annuler
|
||||
</button>
|
||||
<button
|
||||
className="profile-modal-btn profile-modal-btn--confirm"
|
||||
onClick={saveLocal}
|
||||
>
|
||||
<FontAwesomeIcon icon={faCheckCircle} />{" "}
|
||||
Confirmer
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
<>
|
||||
<Navbar />
|
||||
<div className="profile-container">
|
||||
<div className="profile-loading"><div className="spinner" /></div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Navbar />
|
||||
<div className="profile-container">
|
||||
<div className="profile-header">
|
||||
<div className="profile-avatar">
|
||||
<FontAwesomeIcon icon={faUser} />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="profile-title">Mon Profil</h1>
|
||||
<p className="profile-username">@{username}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Section compte */}
|
||||
<div className="profile-card">
|
||||
<h2 className="profile-card-title">
|
||||
<FontAwesomeIcon icon={faUser} className="profile-card-icon" />
|
||||
Mon compte
|
||||
</h2>
|
||||
<div className="profile-fields">
|
||||
<div className="profile-row">
|
||||
<div className="profile-group">
|
||||
<label>Prénom</label>
|
||||
<input
|
||||
type="text"
|
||||
value={prenom}
|
||||
onChange={(e) => setPrenom(e.target.value)}
|
||||
placeholder="Votre prénom"
|
||||
/>
|
||||
</div>
|
||||
<div className="profile-group">
|
||||
<label>Nom</label>
|
||||
<input
|
||||
type="text"
|
||||
value={nom}
|
||||
onChange={(e) => setNom(e.target.value)}
|
||||
placeholder="Votre nom"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="profile-group">
|
||||
<label>Téléphone (compte)</label>
|
||||
<input
|
||||
type="tel"
|
||||
value={telephone}
|
||||
onChange={(e) => setTelephone(e.target.value)}
|
||||
placeholder="+33 6 12 34 56 78"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<button className="profile-btn" onClick={() => setShowConfirmContactModal(true)} disabled={savingContact}>
|
||||
<FontAwesomeIcon icon={faSave} />
|
||||
{savingContact ? ' Enregistrement...' : ' Enregistrer le compte'}
|
||||
</button>
|
||||
<button className="profile-btn profile-btn--secondary" onClick={() => navigate('/user/change-password')} style={{ marginTop: '0.6rem' }}>
|
||||
<FontAwesomeIcon icon={faLock} /> Changer le mot de passe
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Section adresse par défaut */}
|
||||
<div className="profile-card">
|
||||
<h2 className="profile-card-title">
|
||||
<FontAwesomeIcon icon={faMapMarkerAlt} className="profile-card-icon profile-card-icon--address" />
|
||||
Adresse par défaut
|
||||
</h2>
|
||||
<p className="profile-hint">
|
||||
Sera pré-remplie dans le formulaire de commande. Vous pourrez la modifier si vous n'êtes pas à cette adresse.
|
||||
</p>
|
||||
<div className="profile-group">
|
||||
<label>Adresse</label>
|
||||
<input
|
||||
type="text"
|
||||
value={defaultAddress}
|
||||
onChange={(e) => setDefaultAddress(e.target.value)}
|
||||
placeholder="Numéro, rue, ville, code postal"
|
||||
/>
|
||||
</div>
|
||||
<button className="profile-btn profile-btn--secondary" onClick={() => setShowConfirmAddressModal(true)} style={{ marginTop: '0.8rem' }}>
|
||||
<FontAwesomeIcon icon={faSave} /> Enregistrer l'adresse
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Section contact commande */}
|
||||
<div className="profile-card">
|
||||
<h2 className="profile-card-title">
|
||||
<FontAwesomeIcon icon={faPhone} className="profile-card-icon profile-card-icon--phone" />
|
||||
Contact livraison
|
||||
</h2>
|
||||
<p className="profile-hint">
|
||||
Numéro utilisé par le livreur lors de la livraison. Peut être différent du numéro de votre compte.
|
||||
</p>
|
||||
<div className="profile-group">
|
||||
<label>Téléphone par défaut</label>
|
||||
<input
|
||||
type="tel"
|
||||
value={defaultPhone}
|
||||
onChange={(e) => setDefaultPhone(e.target.value)}
|
||||
placeholder="+33 6 12 34 56 78"
|
||||
/>
|
||||
</div>
|
||||
<div className="profile-group" style={{ marginTop: '1rem' }}>
|
||||
<label>
|
||||
<FontAwesomeIcon icon={faCommentDots} style={{ marginRight: '0.4rem' }} />
|
||||
Pseudo Signal (optionnel)
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={signalPseudo}
|
||||
onChange={(e) => setSignalPseudo(e.target.value)}
|
||||
placeholder="@votre.pseudo.signal"
|
||||
/>
|
||||
</div>
|
||||
<button className="profile-btn profile-btn--secondary" onClick={() => setShowSaveModal(true)}>
|
||||
<FontAwesomeIcon icon={faSave} /> Enregistrer les infos par défaut
|
||||
</button>
|
||||
</div>
|
||||
{/* Section Telegram */}
|
||||
{tgEnabled && (
|
||||
<div className="profile-card">
|
||||
<h2 className="profile-card-title">
|
||||
<FontAwesomeIcon icon={faPaperPlane} className="profile-card-icon profile-card-icon--telegram" />
|
||||
Notifications Telegram
|
||||
</h2>
|
||||
<p className="profile-hint">
|
||||
Recevez vos notifications sur Telegram, même quand le site est fermé.
|
||||
</p>
|
||||
{tgLinked ? (
|
||||
<div className="profile-telegram-linked">
|
||||
<span className="profile-telegram-status">
|
||||
<FontAwesomeIcon icon={faCheckCircle} /> Compte Telegram lié
|
||||
</span>
|
||||
<button className="profile-btn profile-btn--danger" onClick={handleUnlinkTelegram}>
|
||||
<FontAwesomeIcon icon={faUnlink} /> Délier Telegram
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<button className="profile-btn profile-btn--telegram" onClick={handleLinkTelegram} disabled={tgLoading}>
|
||||
<FontAwesomeIcon icon={faPaperPlane} />
|
||||
{tgLoading ? ' Génération du lien...' : ' Lier mon compte Telegram'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Section 2FA — visible uniquement si l'admin l'a activé ET Telegram est lié */}
|
||||
{twoFAAdminEnabled && tgLinked && (
|
||||
<div className="profile-card">
|
||||
<h2 className="profile-card-title">
|
||||
<FontAwesomeIcon icon={faShieldAlt} className="profile-card-icon" style={{ color: '#6366f1' }} />
|
||||
Double authentification (2FA)
|
||||
</h2>
|
||||
<p className="profile-hint">
|
||||
À chaque connexion, un code à 6 chiffres vous sera envoyé sur Telegram avant d'accéder à votre compte.
|
||||
</p>
|
||||
<div className="profile-2fa-row">
|
||||
<div className="profile-2fa-label">
|
||||
<span>{twoFAEnabled ? 'Activée' : 'Désactivée'}</span>
|
||||
{twoFAEnabled && (
|
||||
<span className="profile-2fa-badge">
|
||||
<FontAwesomeIcon icon={faCheckCircle} /> Protection active
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{twoFALoading ? (
|
||||
<div className="profile-2fa-spinner" />
|
||||
) : (
|
||||
<button
|
||||
role="switch"
|
||||
aria-checked={twoFAEnabled}
|
||||
className={`profile-toggle ${twoFAEnabled ? 'profile-toggle--on' : ''}`}
|
||||
onClick={handleToggle2FA}
|
||||
aria-label="Activer ou désactiver la double authentification"
|
||||
>
|
||||
<span className="profile-toggle-thumb" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{showSaveModal && (
|
||||
<div className="profile-modal-overlay" onClick={() => setShowSaveModal(false)}>
|
||||
<div className="profile-modal" onClick={(e) => e.stopPropagation()}>
|
||||
<button className="profile-modal-close" onClick={() => setShowSaveModal(false)}>
|
||||
<FontAwesomeIcon icon={faTimes} />
|
||||
</button>
|
||||
<div className="profile-modal-icon">
|
||||
<FontAwesomeIcon icon={faSave} />
|
||||
</div>
|
||||
<h3 className="profile-modal-title">Enregistrer les infos par défaut ?</h3>
|
||||
<p className="profile-modal-body">
|
||||
Adresse, téléphone de livraison et pseudo Signal seront sauvegardés localement et pré-remplis lors de vos prochaines commandes.
|
||||
</p>
|
||||
<div className="profile-modal-actions">
|
||||
<button className="profile-modal-btn profile-modal-btn--cancel" onClick={() => setShowSaveModal(false)}>
|
||||
Annuler
|
||||
</button>
|
||||
<button className="profile-modal-btn profile-modal-btn--confirm" onClick={saveLocal}>
|
||||
<FontAwesomeIcon icon={faCheckCircle} /> Confirmer
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showConfirmAddressModal && (
|
||||
<div className="profile-modal-overlay" onClick={() => setShowConfirmAddressModal(false)}>
|
||||
<div className="profile-modal" onClick={(e) => e.stopPropagation()}>
|
||||
<button className="profile-modal-close" onClick={() => setShowConfirmAddressModal(false)}>
|
||||
<FontAwesomeIcon icon={faTimes} />
|
||||
</button>
|
||||
<div className="profile-modal-icon">
|
||||
<FontAwesomeIcon icon={faMapMarkerAlt} />
|
||||
</div>
|
||||
<h3 className="profile-modal-title">Enregistrer l'adresse ?</h3>
|
||||
<p className="profile-modal-body">
|
||||
Cette adresse sera sauvegardée localement et pré-remplie lors de vos prochaines commandes.
|
||||
</p>
|
||||
<div className="profile-modal-actions">
|
||||
<button className="profile-modal-btn profile-modal-btn--cancel" onClick={() => setShowConfirmAddressModal(false)}>
|
||||
Annuler
|
||||
</button>
|
||||
<button className="profile-modal-btn profile-modal-btn--confirm" onClick={saveAddress}>
|
||||
<FontAwesomeIcon icon={faCheckCircle} /> Confirmer
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showConfirmContactModal && (
|
||||
<div className="profile-modal-overlay" onClick={() => setShowConfirmContactModal(false)}>
|
||||
<div className="profile-modal" onClick={(e) => e.stopPropagation()}>
|
||||
<button className="profile-modal-close" onClick={() => setShowConfirmContactModal(false)}>
|
||||
<FontAwesomeIcon icon={faTimes} />
|
||||
</button>
|
||||
<div className="profile-modal-icon">
|
||||
<FontAwesomeIcon icon={faUser} />
|
||||
</div>
|
||||
<h3 className="profile-modal-title">Enregistrer le compte ?</h3>
|
||||
<p className="profile-modal-body">
|
||||
Vos informations (prénom, nom, téléphone) seront mises à jour sur votre compte.
|
||||
</p>
|
||||
<div className="profile-modal-actions">
|
||||
<button className="profile-modal-btn profile-modal-btn--cancel" onClick={() => setShowConfirmContactModal(false)}>
|
||||
Annuler
|
||||
</button>
|
||||
<button className="profile-modal-btn profile-modal-btn--confirm" onClick={saveContact} disabled={savingContact}>
|
||||
<FontAwesomeIcon icon={faCheckCircle} /> {savingContact ? 'Enregistrement...' : 'Confirmer'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showUnlinkModal && (
|
||||
<div className="profile-modal-overlay" onClick={() => setShowUnlinkModal(false)}>
|
||||
<div className="profile-modal" onClick={(e) => e.stopPropagation()}>
|
||||
<button className="profile-modal-close" onClick={() => setShowUnlinkModal(false)}>
|
||||
<FontAwesomeIcon icon={faTimes} />
|
||||
</button>
|
||||
<div className="profile-modal-icon profile-modal-icon--danger">
|
||||
<FontAwesomeIcon icon={faUnlink} />
|
||||
</div>
|
||||
<h3 className="profile-modal-title">Délier Telegram ?</h3>
|
||||
<p className="profile-modal-body">
|
||||
Vous ne recevrez plus de notifications Telegram. La double authentification sera également désactivée.
|
||||
</p>
|
||||
<div className="profile-modal-actions">
|
||||
<button className="profile-modal-btn profile-modal-btn--cancel" onClick={() => setShowUnlinkModal(false)}>
|
||||
Annuler
|
||||
</button>
|
||||
<button className="profile-modal-btn profile-modal-btn--danger" onClick={confirmUnlinkTelegram}>
|
||||
<FontAwesomeIcon icon={faUnlink} /> Délier
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Modal succès */}
|
||||
{showSuccessModal && (
|
||||
<div className="profile-modal-overlay" onClick={() => setShowSuccessModal(false)}>
|
||||
<div className="profile-modal" onClick={(e) => e.stopPropagation()}>
|
||||
<button className="profile-modal-close" onClick={() => setShowSuccessModal(false)}>
|
||||
<FontAwesomeIcon icon={faTimes} />
|
||||
</button>
|
||||
<div className="profile-modal-icon profile-modal-icon--success">
|
||||
<FontAwesomeIcon icon={faCheckCircle} />
|
||||
</div>
|
||||
<h3 className="profile-modal-title">{successTitle}</h3>
|
||||
<p className="profile-modal-body">{successMsg}</p>
|
||||
<div className="profile-modal-actions">
|
||||
<button className="profile-modal-btn profile-modal-btn--confirm profile-modal-btn--success" onClick={() => setShowSuccessModal(false)}>
|
||||
Fermer
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Modal erreur */}
|
||||
{showErrorModal && (
|
||||
<div className="profile-modal-overlay" onClick={() => setShowErrorModal(false)}>
|
||||
<div className="profile-modal" onClick={(e) => e.stopPropagation()}>
|
||||
<button className="profile-modal-close" onClick={() => setShowErrorModal(false)}>
|
||||
<FontAwesomeIcon icon={faTimes} />
|
||||
</button>
|
||||
<div className="profile-modal-icon profile-modal-icon--danger">
|
||||
<FontAwesomeIcon icon={faExclamationTriangle} />
|
||||
</div>
|
||||
<h3 className="profile-modal-title">Une erreur est survenue</h3>
|
||||
<p className="profile-modal-body">{errorMsg}</p>
|
||||
<div className="profile-modal-actions">
|
||||
<button className="profile-modal-btn profile-modal-btn--danger" onClick={() => setShowErrorModal(false)}>
|
||||
Fermer
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showUnlinkSuccessModal && (
|
||||
<div className="profile-modal-overlay" onClick={() => setShowUnlinkSuccessModal(false)}>
|
||||
<div className="profile-modal" onClick={(e) => e.stopPropagation()}>
|
||||
<button className="profile-modal-close" onClick={() => setShowUnlinkSuccessModal(false)}>
|
||||
<FontAwesomeIcon icon={faTimes} />
|
||||
</button>
|
||||
<div className="profile-modal-icon profile-modal-icon--success">
|
||||
<FontAwesomeIcon icon={faCheckCircle} />
|
||||
</div>
|
||||
<h3 className="profile-modal-title">Compte délié</h3>
|
||||
<p className="profile-modal-body">
|
||||
Votre compte Telegram a été délié avec succès. Vous ne recevrez plus de notifications via Telegram.
|
||||
</p>
|
||||
<div className="profile-modal-actions">
|
||||
<button className="profile-modal-btn profile-modal-btn--confirm" onClick={() => setShowUnlinkSuccessModal(false)}>
|
||||
<FontAwesomeIcon icon={faCheckCircle} /> Fermer
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -73,29 +73,18 @@ interface ToastMessage {
|
||||
* Somme des prix individuels (pas de multiplication)
|
||||
*/
|
||||
const getTotalAmount = (order: OrderWithTracking): number => {
|
||||
// 1. Priorité: champ total stocké en DB
|
||||
if (typeof order.total === "number" && order.total > 0) {
|
||||
return order.total;
|
||||
}
|
||||
|
||||
// 2. Fallback: total_prix
|
||||
if (typeof order.total_prix === "number" && order.total_prix > 0) {
|
||||
return order.total_prix;
|
||||
}
|
||||
|
||||
// 3. Calcul depuis items (comme dans Checkout: somme des prix)
|
||||
if (typeof order.total === "number" && order.total > 0) {
|
||||
return order.total;
|
||||
}
|
||||
if (order.items && order.items.length > 0) {
|
||||
const calculatedTotal = order.items.reduce((sum, item) => {
|
||||
return order.items.reduce((sum, item) => {
|
||||
const itemPrice = item.prix || item.price || 0;
|
||||
return sum + itemPrice; // ✅ Somme simple (pas de × quantity)
|
||||
return sum + itemPrice;
|
||||
}, 0);
|
||||
|
||||
console.log(
|
||||
`💰 [getTotalAmount] Commande ${order.id}: ${calculatedTotal.toFixed(2)}€`,
|
||||
);
|
||||
return calculatedTotal;
|
||||
}
|
||||
|
||||
return 0;
|
||||
};
|
||||
|
||||
@@ -336,20 +325,17 @@ function SuiviLivraison() {
|
||||
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
const loadOrders = async () => {
|
||||
// ✅ Vérifier l'auth avant de charger les commandes
|
||||
if (!isUserAuthenticated()) {
|
||||
console.log("❌ [loadOrders] Non authentifié");
|
||||
navigate("/login/client", { replace: true });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setLoading(true);
|
||||
const response = await getMyOrders();
|
||||
|
||||
if (response.success && response.commands) {
|
||||
if (response.success) {
|
||||
const ordersWithTracking = await Promise.all(
|
||||
response.commands.map(async (order: OrderDetail) => {
|
||||
(response.commands || []).map(async (order: OrderDetail) => {
|
||||
const normalizedOrder = {
|
||||
...order,
|
||||
total: getTotalAmount(order),
|
||||
@@ -361,18 +347,12 @@ function SuiviLivraison() {
|
||||
try {
|
||||
tracking = await getOrderTracking(order.id);
|
||||
} catch {
|
||||
console.warn(
|
||||
`Tracking non disponible pour commande ${order.id}`,
|
||||
);
|
||||
tracking = undefined;
|
||||
}
|
||||
|
||||
try {
|
||||
eta = await getOrderETA(order.id);
|
||||
} catch {
|
||||
console.warn(
|
||||
`ETA non disponible pour commande ${order.id}`,
|
||||
);
|
||||
eta = undefined;
|
||||
}
|
||||
|
||||
@@ -386,9 +366,6 @@ function SuiviLivraison() {
|
||||
|
||||
setOrders(ordersWithTracking);
|
||||
setError("");
|
||||
} else {
|
||||
setError("Impossible de charger les commandes");
|
||||
showToast("Impossible de charger les commandes", "error");
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
console.error("Erreur loadOrders:", err);
|
||||
@@ -945,14 +922,21 @@ function SuiviLivraison() {
|
||||
/>{" "}
|
||||
Montant total
|
||||
</h4>
|
||||
{(order.referral_used ?? 0) > 0 && (
|
||||
<p style={{ margin: "0 0 2px", fontSize: "0.85rem", color: "var(--text-muted)" }}>
|
||||
Brut : {(order.total_prix ?? 0).toFixed(2)} €
|
||||
</p>
|
||||
)}
|
||||
<p className="total-amount">
|
||||
<strong>
|
||||
{getTotalAmount(
|
||||
order,
|
||||
).toFixed(2)}{" "}
|
||||
€
|
||||
{Math.max(0, getTotalAmount(order) - (order.referral_used ?? 0)).toFixed(2)} €
|
||||
</strong>
|
||||
</p>
|
||||
{(order.referral_used ?? 0) > 0 && (
|
||||
<p style={{ margin: "4px 0 0", fontSize: "0.82rem", color: "#10b981", fontWeight: 500 }}>
|
||||
— dont {(order.referral_used!).toFixed(2)} € parrainage déduit
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="detail-section">
|
||||
|
||||
Reference in New Issue
Block a user