From 3a0f725159d6a8fbc345bef12b7992965f625da6 Mon Sep 17 00:00:00 2001 From: Xor290 Date: Sun, 14 Jun 2026 17:50:35 +0200 Subject: [PATCH] chore: build --- backend/gestion/db/db_basket.go | 461 ++++----- backend/gestion/db/db_cancel_command.go | 46 +- backend/gestion/db/db_clients.go | 279 +++--- backend/gestion/db/db_command_items.go | 239 ++--- backend/gestion/db/db_command_priority.go | 91 +- backend/gestion/db/db_commands.go | 56 +- backend/gestion/db/db_contact.go | 28 + backend/gestion/db/db_delivery.go | 16 - backend/gestion/db/db_delivery_manage.go | 76 -- backend/gestion/db/db_history.go | 113 --- backend/gestion/db/db_init.go | 49 + backend/gestion/db/db_notifications.go | 30 +- backend/gestion/db/db_payments.go | 16 +- backend/gestion/db/db_product.go | 78 +- backend/gestion/db/db_product_price.go | 25 +- backend/gestion/db/db_referral.go | 14 - backend/gestion/db/db_settings.go | 26 + backend/gestion/db/db_telegram.go | 73 ++ backend/gestion/db/helpers.go | 1 - .../db/redis_deliveryman_assignment.go | 51 - backend/gestion/db/redis_eta_management.go | 4 - backend/gestion/db/redis_queue_assignment.go | 29 - backend/gestion/db/redis_queue_clean_up.go | 61 -- backend/gestion/db/redis_queue_management.go | 282 ------ .../gestion/db/redis_queue_optimization.go | 4 - backend/gestion/db/redis_session.go | 219 ----- backend/gestion/go.mod | 2 +- backend/gestion/handlers/auth.go | 264 +++-- backend/gestion/handlers/cabine.go | 512 +--------- backend/gestion/handlers/cancel_command.go | 22 +- backend/gestion/handlers/client_tracking.go | 1 + backend/gestion/handlers/commands.go | 24 +- backend/gestion/handlers/deleviry.go | 175 +++- backend/gestion/handlers/delivery_admin.go | 41 +- backend/gestion/handlers/eta.go | 28 +- backend/gestion/handlers/geoloca.go | 56 +- backend/gestion/handlers/gps.go | 51 - backend/gestion/handlers/history.go | 26 +- backend/gestion/handlers/panier.go | 148 +-- backend/gestion/handlers/points.go | 254 +++++ backend/gestion/handlers/product.go | 236 +++-- backend/gestion/handlers/redis_services.go | 239 +---- backend/gestion/handlers/settings.go | 34 +- backend/gestion/handlers/stats.go | 260 +++++ backend/gestion/handlers/telegram.go | 103 +- backend/gestion/handlers/traffic.go | 2 +- backend/gestion/handlers/update_profile.go | 12 - .../gestion/handlers/validation_deleviry.go | 256 ----- backend/gestion/main.go | 31 +- .../gestion/middleware/block_middleware.go | 3 +- .../gestion/middleware/clock_middleware.go | 1 - .../gestion/middleware/session_middleware.go | 103 +- backend/gestion/models/auth.go | 2 +- backend/gestion/models/client.go | 2 + backend/gestion/models/commands.go | 18 +- backend/gestion/models/contact.go | 6 + backend/gestion/models/panier.go | 2 + backend/gestion/models/product.go | 30 +- backend/gestion/models/settings.go | 63 +- backend/gestion/models/stats.go | 44 + backend/gestion/routes/routes.go | 44 +- .../gestion/services/adresses_correction.go | 536 +++++++++++ backend/gestion/services/geo_services.go | 114 ++- backend/gestion/services/lbtelegram.go | 89 ++ backend/gestion/services/telegram.go | 49 +- backend/gestion/services/tomtom.go | 32 +- backend/gestion/services/tomtom_keys.go | 96 ++ backend/gestion/workers/redis_worker.go | 4 +- frontend-prep/package-lock.json | 15 - frontend-prep/src/api/api.ts | 221 ++++- frontend-prep/src/api/api_types.ts | 19 +- frontend-prep/src/components/Navbar.css | 125 ++- frontend-prep/src/components/Navbar.tsx | 125 +-- frontend-prep/src/components/ProductCard.css | 26 +- frontend-prep/src/components/ProductCard.tsx | 88 +- frontend-prep/src/context/CartContext.tsx | 2 + .../pages/ChangePassword/ChangePassword.css | 106 ++ .../pages/ChangePassword/ChangePassword.tsx | 74 +- frontend-prep/src/pages/LoginClient/Login.css | 24 +- frontend-prep/src/pages/LoginClient/Login.tsx | 121 ++- frontend-prep/src/pages/User/Accueil.tsx | 10 +- frontend-prep/src/pages/User/Cart.tsx | 18 +- frontend-prep/src/pages/User/Checkout.css | 9 + frontend-prep/src/pages/User/Checkout.tsx | 50 +- .../src/pages/User/ConsultationHistorique.css | 155 +++ .../src/pages/User/ConsultationHistorique.tsx | 745 +++++++++----- frontend-prep/src/pages/User/OrderDetails.tsx | 4 +- frontend-prep/src/pages/User/Parrainage.tsx | 33 +- .../src/pages/User/ProductDetail.css | 34 + .../src/pages/User/ProductDetail.tsx | 89 +- frontend-prep/src/pages/User/ProfilePage.css | 97 ++ frontend-prep/src/pages/User/ProfilePage.tsx | 911 ++++++++++-------- .../src/pages/User/SuiviLivraison.tsx | 52 +- 93 files changed, 5311 insertions(+), 4224 deletions(-) create mode 100644 backend/gestion/db/db_contact.go create mode 100644 backend/gestion/handlers/points.go create mode 100644 backend/gestion/handlers/stats.go create mode 100644 backend/gestion/models/contact.go create mode 100644 backend/gestion/models/stats.go create mode 100644 backend/gestion/services/adresses_correction.go create mode 100644 backend/gestion/services/lbtelegram.go create mode 100644 backend/gestion/services/tomtom_keys.go diff --git a/backend/gestion/db/db_basket.go b/backend/gestion/db/db_basket.go index a970a73e..a68d2623 100644 --- a/backend/gestion/db/db_basket.go +++ b/backend/gestion/db/db_basket.go @@ -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 } diff --git a/backend/gestion/db/db_cancel_command.go b/backend/gestion/db/db_cancel_command.go index 5736ed4b..40c23c03 100644 --- a/backend/gestion/db/db_cancel_command.go +++ b/backend/gestion/db/db_cancel_command.go @@ -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 + }) +} diff --git a/backend/gestion/db/db_clients.go b/backend/gestion/db/db_clients.go index cefd85e6..2afb93ca 100644 --- a/backend/gestion/db/db_clients.go +++ b/backend/gestion/db/db_clients.go @@ -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 +} diff --git a/backend/gestion/db/db_command_items.go b/backend/gestion/db/db_command_items.go index 512f5e7d..ed5e9865 100644 --- a/backend/gestion/db/db_command_items.go +++ b/backend/gestion/db/db_command_items.go @@ -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 diff --git a/backend/gestion/db/db_command_priority.go b/backend/gestion/db/db_command_priority.go index 2092b5a2..61b42bbf 100644 --- a/backend/gestion/db/db_command_priority.go +++ b/backend/gestion/db/db_command_priority.go @@ -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(` diff --git a/backend/gestion/db/db_commands.go b/backend/gestion/db/db_commands.go index f1e05617..0275fd4e 100644 --- a/backend/gestion/db/db_commands.go +++ b/backend/gestion/db/db_commands.go @@ -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 +} diff --git a/backend/gestion/db/db_contact.go b/backend/gestion/db/db_contact.go new file mode 100644 index 00000000..1517885c --- /dev/null +++ b/backend/gestion/db/db_contact.go @@ -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 +} diff --git a/backend/gestion/db/db_delivery.go b/backend/gestion/db/db_delivery.go index e364d6cf..810b8458 100644 --- a/backend/gestion/db/db_delivery.go +++ b/backend/gestion/db/db_delivery.go @@ -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 { diff --git a/backend/gestion/db/db_delivery_manage.go b/backend/gestion/db/db_delivery_manage.go index 57947a1e..8796cf7e 100644 --- a/backend/gestion/db/db_delivery_manage.go +++ b/backend/gestion/db/db_delivery_manage.go @@ -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 -} diff --git a/backend/gestion/db/db_history.go b/backend/gestion/db/db_history.go index 70d8c437..c510b639 100644 --- a/backend/gestion/db/db_history.go +++ b/backend/gestion/db/db_history.go @@ -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 -} diff --git a/backend/gestion/db/db_init.go b/backend/gestion/db/db_init.go index 6b98ac48..12b50c2f 100644 --- a/backend/gestion/db/db_init.go +++ b/backend/gestion/db/db_init.go @@ -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 { diff --git a/backend/gestion/db/db_notifications.go b/backend/gestion/db/db_notifications.go index 45dc0b55..8bd09d3c 100644 --- a/backend/gestion/db/db_notifications.go +++ b/backend/gestion/db/db_notifications.go @@ -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("🔔 Notification\n\n%s", message)) + go sendTelegramNotif(chatID, fmt.Sprintf("🔔 Notification\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("🔔 Notification\n\n%s", message)) + go sendTelegramNotif(chatID, fmt.Sprintf("🔔 Notification\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("🔔 Nouvelle commande\n\n%s", capturedMsg)) + go sendTelegramNotif(capturedChatID, fmt.Sprintf("🔔 Nouvelle commande\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("🚨 Alerte livreur\n\n%s", capturedBody)) + go sendTelegramNotif(capturedChatID, fmt.Sprintf("🚨 Alerte livreur\n\n%s", body)) } } count++ diff --git a/backend/gestion/db/db_payments.go b/backend/gestion/db/db_payments.go index 83f94ba1..5975223e 100644 --- a/backend/gestion/db/db_payments.go +++ b/backend/gestion/db/db_payments.go @@ -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 }) } diff --git a/backend/gestion/db/db_product.go b/backend/gestion/db/db_product.go index acea1534..e8cb11b8 100644 --- a/backend/gestion/db/db_product.go +++ b/backend/gestion/db/db_product.go @@ -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) diff --git a/backend/gestion/db/db_product_price.go b/backend/gestion/db/db_product_price.go index f9cd7949..5643184f 100644 --- a/backend/gestion/db/db_product_price.go +++ b/backend/gestion/db/db_product_price.go @@ -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") diff --git a/backend/gestion/db/db_referral.go b/backend/gestion/db/db_referral.go index 5a276d20..fb5eec09 100644 --- a/backend/gestion/db/db_referral.go +++ b/backend/gestion/db/db_referral.go @@ -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 -} diff --git a/backend/gestion/db/db_settings.go b/backend/gestion/db/db_settings.go index 508df618..996bf74e 100644 --- a/backend/gestion/db/db_settings.go +++ b/backend/gestion/db/db_settings.go @@ -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 (?, ?) diff --git a/backend/gestion/db/db_telegram.go b/backend/gestion/db/db_telegram.go index c8ecef2b..c521def1 100644 --- a/backend/gestion/db/db_telegram.go +++ b/backend/gestion/db/db_telegram.go @@ -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 +} diff --git a/backend/gestion/db/helpers.go b/backend/gestion/db/helpers.go index e4f6ae69..5dd4d663 100644 --- a/backend/gestion/db/helpers.go +++ b/backend/gestion/db/helpers.go @@ -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 } diff --git a/backend/gestion/db/redis_deliveryman_assignment.go b/backend/gestion/db/redis_deliveryman_assignment.go index e9d1b308..9c513522 100644 --- a/backend/gestion/db/redis_deliveryman_assignment.go +++ b/backend/gestion/db/redis_deliveryman_assignment.go @@ -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() diff --git a/backend/gestion/db/redis_eta_management.go b/backend/gestion/db/redis_eta_management.go index 15672ce6..fc76f158 100644 --- a/backend/gestion/db/redis_eta_management.go +++ b/backend/gestion/db/redis_eta_management.go @@ -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) diff --git a/backend/gestion/db/redis_queue_assignment.go b/backend/gestion/db/redis_queue_assignment.go index 8ace1e37..60f26b77 100644 --- a/backend/gestion/db/redis_queue_assignment.go +++ b/backend/gestion/db/redis_queue_assignment.go @@ -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) diff --git a/backend/gestion/db/redis_queue_clean_up.go b/backend/gestion/db/redis_queue_clean_up.go index 918c51fa..9b5fb81f 100644 --- a/backend/gestion/db/redis_queue_clean_up.go +++ b/backend/gestion/db/redis_queue_clean_up.go @@ -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 -} diff --git a/backend/gestion/db/redis_queue_management.go b/backend/gestion/db/redis_queue_management.go index 427b1a9b..ae5097ce 100644 --- a/backend/gestion/db/redis_queue_management.go +++ b/backend/gestion/db/redis_queue_management.go @@ -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() diff --git a/backend/gestion/db/redis_queue_optimization.go b/backend/gestion/db/redis_queue_optimization.go index 7d6b1231..750f4475 100644 --- a/backend/gestion/db/redis_queue_optimization.go +++ b/backend/gestion/db/redis_queue_optimization.go @@ -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) diff --git a/backend/gestion/db/redis_session.go b/backend/gestion/db/redis_session.go index bb484301..78cdb951 100644 --- a/backend/gestion/db/redis_session.go +++ b/backend/gestion/db/redis_session.go @@ -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 -} diff --git a/backend/gestion/go.mod b/backend/gestion/go.mod index 3f409a19..79eef9b8 100644 --- a/backend/gestion/go.mod +++ b/backend/gestion/go.mod @@ -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 ) diff --git a/backend/gestion/handlers/auth.go b/backend/gestion/handlers/auth.go index e22c49c4..ff51915e 100644 --- a/backend/gestion/handlers/auth.go +++ b/backend/gestion/handlers/auth.go @@ -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 : %s\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éé"}) } diff --git a/backend/gestion/handlers/cabine.go b/backend/gestion/handlers/cabine.go index 6a57d4ac..76ccab24 100644 --- a/backend/gestion/handlers/cabine.go +++ b/backend/gestion/handlers/cabine.go @@ -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⚠️ VALIDE LA RÉCEPTION DE TA COMMANDE DANS LA RUBRIQUE SUIVI POUR RÉCUPÉRER TES POINTS DE FIDÉLITÉ ⚠️", 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 != "", - }) -} diff --git a/backend/gestion/handlers/cancel_command.go b/backend/gestion/handlers/cancel_command.go index 5dd4cb02..ee437f8f 100644 --- a/backend/gestion/handlers/cancel_command.go +++ b/backend/gestion/handlers/cancel_command.go @@ -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, diff --git a/backend/gestion/handlers/client_tracking.go b/backend/gestion/handlers/client_tracking.go index 7bee7851..ddaf52a4 100644 --- a/backend/gestion/handlers/client_tracking.go +++ b/backend/gestion/handlers/client_tracking.go @@ -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, diff --git a/backend/gestion/handlers/commands.go b/backend/gestion/handlers/commands.go index 68063d03..56174424 100644 --- a/backend/gestion/handlers/commands.go +++ b/backend/gestion/handlers/commands.go @@ -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 diff --git a/backend/gestion/handlers/deleviry.go b/backend/gestion/handlers/deleviry.go index 04e12972..81546cc2 100644 --- a/backend/gestion/handlers/deleviry.go +++ b/backend/gestion/handlers/deleviry.go @@ -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, + }) +} diff --git a/backend/gestion/handlers/delivery_admin.go b/backend/gestion/handlers/delivery_admin.go index d026df9f..a905908a 100644 --- a/backend/gestion/handlers/delivery_admin.go +++ b/backend/gestion/handlers/delivery_admin.go @@ -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") diff --git a/backend/gestion/handlers/eta.go b/backend/gestion/handlers/eta.go index e1565463..d385db66 100644 --- a/backend/gestion/handlers/eta.go +++ b/backend/gestion/handlers/eta.go @@ -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) diff --git a/backend/gestion/handlers/geoloca.go b/backend/gestion/handlers/geoloca.go index 8423bff7..70245fe5 100644 --- a/backend/gestion/handlers/geoloca.go +++ b/backend/gestion/handlers/geoloca.go @@ -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) diff --git a/backend/gestion/handlers/gps.go b/backend/gestion/handlers/gps.go index 21d8343d..4897de64 100644 --- a/backend/gestion/handlers/gps.go +++ b/backend/gestion/handlers/gps.go @@ -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) { diff --git a/backend/gestion/handlers/history.go b/backend/gestion/handlers/history.go index d05ab4ce..8fbc858b 100644 --- a/backend/gestion/handlers/history.go +++ b/backend/gestion/handlers/history.go @@ -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) diff --git a/backend/gestion/handlers/panier.go b/backend/gestion/handlers/panier.go index 38ddc1db..bcc3175a 100644 --- a/backend/gestion/handlers/panier.go +++ b/backend/gestion/handlers/panier.go @@ -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) diff --git a/backend/gestion/handlers/points.go b/backend/gestion/handlers/points.go new file mode 100644 index 00000000..526cbb8a --- /dev/null +++ b/backend/gestion/handlers/points.go @@ -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}) +} diff --git a/backend/gestion/handlers/product.go b/backend/gestion/handlers/product.go index 6b7fd506..ab291227 100644 --- a/backend/gestion/handlers/product.go +++ b/backend/gestion/handlers/product.go @@ -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 +} diff --git a/backend/gestion/handlers/redis_services.go b/backend/gestion/handlers/redis_services.go index 687837c7..6ded3e41 100644 --- a/backend/gestion/handlers/redis_services.go +++ b/backend/gestion/handlers/redis_services.go @@ -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() diff --git a/backend/gestion/handlers/settings.go b/backend/gestion/handlers/settings.go index c78a1aaf..3482d2cb 100644 --- a/backend/gestion/handlers/settings.go +++ b/backend/gestion/handlers/settings.go @@ -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)) } } } diff --git a/backend/gestion/handlers/stats.go b/backend/gestion/handlers/stats.go new file mode 100644 index 00000000..65c8b83b --- /dev/null +++ b/backend/gestion/handlers/stats.go @@ -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, + }) +} diff --git a/backend/gestion/handlers/telegram.go b/backend/gestion/handlers/telegram.go index fcf94917..b3ff4f8e 100644 --- a/backend/gestion/handlers/telegram.go +++ b/backend/gestion/handlers/telegram.go @@ -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, - "✅ Compte lié avec succès !\n\nVous recevrez désormais vos notifications ici.") + if services.LBTelegram != nil && services.LBTelegram.Bot1Username != "" { + if err := services.TelegramBot.SendMessageWithButtons(chatID, + "✅ Compte lié avec succès !\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, + "✅ Compte lié avec succès !\n\nVous recevrez désormais vos notifications ici.") + } + } else { + services.TelegramBot.SendMessage(chatID, + "✅ Compte lié avec succès !\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) +} diff --git a/backend/gestion/handlers/traffic.go b/backend/gestion/handlers/traffic.go index 40f65650..92d88281 100644 --- a/backend/gestion/handlers/traffic.go +++ b/backend/gestion/handlers/traffic.go @@ -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 diff --git a/backend/gestion/handlers/update_profile.go b/backend/gestion/handlers/update_profile.go index 7465f1d8..337b6fba 100644 --- a/backend/gestion/handlers/update_profile.go +++ b/backend/gestion/handlers/update_profile.go @@ -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, diff --git a/backend/gestion/handlers/validation_deleviry.go b/backend/gestion/handlers/validation_deleviry.go index 8aee1110..9d8abfbb 100644 --- a/backend/gestion/handlers/validation_deleviry.go +++ b/backend/gestion/handlers/validation_deleviry.go @@ -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 -} diff --git a/backend/gestion/main.go b/backend/gestion/main.go index 9bce433a..1c5c65b5 100644 --- a/backend/gestion/main.go +++ b/backend/gestion/main.go @@ -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() diff --git a/backend/gestion/middleware/block_middleware.go b/backend/gestion/middleware/block_middleware.go index fbce4149..d6d88093 100644 --- a/backend/gestion/middleware/block_middleware.go +++ b/backend/gestion/middleware/block_middleware.go @@ -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, }) diff --git a/backend/gestion/middleware/clock_middleware.go b/backend/gestion/middleware/clock_middleware.go index 6c6c7af5..81cb6e5f 100644 --- a/backend/gestion/middleware/clock_middleware.go +++ b/backend/gestion/middleware/clock_middleware.go @@ -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 { diff --git a/backend/gestion/middleware/session_middleware.go b/backend/gestion/middleware/session_middleware.go index 0fbc8685..180523cb 100644 --- a/backend/gestion/middleware/session_middleware.go +++ b/backend/gestion/middleware/session_middleware.go @@ -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 ", - }) - c.Abort() - return - } - - // Vérifier le format "Bearer " - 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 ", - }) - 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() - } -} diff --git a/backend/gestion/models/auth.go b/backend/gestion/models/auth.go index 990f8853..3f8a344e 100644 --- a/backend/gestion/models/auth.go +++ b/backend/gestion/models/auth.go @@ -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 { diff --git a/backend/gestion/models/client.go b/backend/gestion/models/client.go index 6f302fc2..0c990a14 100644 --- a/backend/gestion/models/client.go +++ b/backend/gestion/models/client.go @@ -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" } diff --git a/backend/gestion/models/commands.go b/backend/gestion/models/commands.go index 9de1102c..bb74ff91 100644 --- a/backend/gestion/models/commands.go +++ b/backend/gestion/models/commands.go @@ -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 { diff --git a/backend/gestion/models/contact.go b/backend/gestion/models/contact.go new file mode 100644 index 00000000..56ee97e9 --- /dev/null +++ b/backend/gestion/models/contact.go @@ -0,0 +1,6 @@ +package models + +type Contact struct { + ID int `json:"id" gorm:"primaryKey"` + Name string `json:"name" gorm:"not null"` +} diff --git a/backend/gestion/models/panier.go b/backend/gestion/models/panier.go index 6e47601a..695f3fe5 100644 --- a/backend/gestion/models/panier.go +++ b/backend/gestion/models/panier.go @@ -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"` } diff --git a/backend/gestion/models/product.go b/backend/gestion/models/product.go index b75ccdad..83b8edcc 100644 --- a/backend/gestion/models/product.go +++ b/backend/gestion/models/product.go @@ -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" } diff --git a/backend/gestion/models/settings.go b/backend/gestion/models/settings.go index 933a601d..90529d54 100644 --- a/backend/gestion/models/settings.go +++ b/backend/gestion/models/settings.go @@ -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 } diff --git a/backend/gestion/models/stats.go b/backend/gestion/models/stats.go new file mode 100644 index 00000000..c8b3a2ee --- /dev/null +++ b/backend/gestion/models/stats.go @@ -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"` +} diff --git a/backend/gestion/routes/routes.go b/backend/gestion/routes/routes.go index 3a06b49f..cd3121d4 100644 --- a/backend/gestion/routes/routes.go +++ b/backend/gestion/routes/routes.go @@ -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 diff --git a/backend/gestion/services/adresses_correction.go b/backend/gestion/services/adresses_correction.go new file mode 100644 index 00000000..b879d6a6 --- /dev/null +++ b/backend/gestion/services/adresses_correction.go @@ -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 +} diff --git a/backend/gestion/services/geo_services.go b/backend/gestion/services/geo_services.go index 29d85f86..fc7e1092 100644 --- a/backend/gestion/services/geo_services.go +++ b/backend/gestion/services/geo_services.go @@ -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 +} diff --git a/backend/gestion/services/lbtelegram.go b/backend/gestion/services/lbtelegram.go new file mode 100644 index 00000000..094d01d9 --- /dev/null +++ b/backend/gestion/services/lbtelegram.go @@ -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 +} diff --git a/backend/gestion/services/telegram.go b/backend/gestion/services/telegram.go index dd157f75..6a035ab4 100644 --- a/backend/gestion/services/telegram.go +++ b/backend/gestion/services/telegram.go @@ -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 != "" { diff --git a/backend/gestion/services/tomtom.go b/backend/gestion/services/tomtom.go index 912ccc5c..01c7021a 100644 --- a/backend/gestion/services/tomtom.go +++ b/backend/gestion/services/tomtom.go @@ -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 } diff --git a/backend/gestion/services/tomtom_keys.go b/backend/gestion/services/tomtom_keys.go new file mode 100644 index 00000000..ce1f483f --- /dev/null +++ b/backend/gestion/services/tomtom_keys.go @@ -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) +} diff --git a/backend/gestion/workers/redis_worker.go b/backend/gestion/workers/redis_worker.go index 0a42b3b3..26ac29e7 100644 --- a/backend/gestion/workers/redis_worker.go +++ b/backend/gestion/workers/redis_worker.go @@ -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) + } } } } diff --git a/frontend-prep/package-lock.json b/frontend-prep/package-lock.json index ad85e5b9..4d4dd89f 100644 --- a/frontend-prep/package-lock.json +++ b/frontend-prep/package-lock.json @@ -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" } diff --git a/frontend-prep/src/api/api.ts b/frontend-prep/src/api/api.ts index 9bdae554..63d18acc 100644 --- a/frontend-prep/src/api/api.ts +++ b/frontend-prep/src/api/api.ts @@ -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 => { @@ -1880,6 +1877,9 @@ export const getPublicSettings = async (): Promise => { 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 => { 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 => { + 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 => { /* 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" }; + } +}; diff --git a/frontend-prep/src/api/api_types.ts b/frontend-prep/src/api/api_types.ts index ff8a1728..a671d763 100644 --- a/frontend-prep/src/api/api_types.ts +++ b/frontend-prep/src/api/api_types.ts @@ -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; }; } diff --git a/frontend-prep/src/components/Navbar.css b/frontend-prep/src/components/Navbar.css index f87e4f0f..49f0c6a8 100644 --- a/frontend-prep/src/components/Navbar.css +++ b/frontend-prep/src/components/Navbar.css @@ -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; } /* ============================================================ diff --git a/frontend-prep/src/components/Navbar.tsx b/frontend-prep/src/components/Navbar.tsx index c7b39a04..5e6adfd7 100644 --- a/frontend-prep/src/components/Navbar.tsx +++ b/frontend-prep/src/components/Navbar.tsx @@ -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(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>(new Set()); const isFirstLoadRef = useRef(true); - const notifPanelRef = useRef(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() { - Milieu‑Nantais + {shopName}
{/* Theme toggle */} @@ -169,46 +180,18 @@ function Navbar() { {/* Notifications */} -
- - - {showNotifPanel && ( -
-
Notifications
- {notifications.length === 0 ? ( -
Aucune notification
- ) : ( -
    - {notifications.map((n, i) => ( -
  • - {n.message} - - {new Date(n.created_at).toLocaleTimeString("fr-FR", { - hour: "2-digit", - minute: "2-digit", - })} - -
  • - ))} -
- )} -
+
+ {/* Panier */} +
+
+ {notifications.length === 0 ? ( +
+ +

Aucune notification

+
+ ) : ( + notifications.map((n, i) => ( +
+ {n.message} + {formatNotifDate(n.created_at)} +
+ )) + )} +
+ + + )} + {/* ── Overlay ─────────────────────────────────── */} {isMenuOpen &&
} @@ -235,7 +250,7 @@ function Navbar() {
-

Milieu-Nantais

+

{shopName}

Mon espace

diff --git a/frontend-prep/src/components/ProductCard.css b/frontend-prep/src/components/ProductCard.css index 4dbe3ae0..23b4ae1a 100644 --- a/frontend-prep/src/components/ProductCard.css +++ b/frontend-prep/src/components/ProductCard.css @@ -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; } diff --git a/frontend-prep/src/components/ProductCard.tsx b/frontend-prep/src/components/ProductCard.tsx index 1b233c45..707c5efc 100644 --- a/frontend-prep/src/components/ProductCard.tsx +++ b/frontend-prep/src/components/ProductCard.tsx @@ -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 && (
SOLD OUT
)} + {isComingSoon && ( +
COMMING SOON
+ )}
@@ -177,18 +189,23 @@ function ProductCard({ ) : ( <> {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)}{" "} + € ))} @@ -220,32 +238,34 @@ function ProductCard({
{/* ✨ Modal vidéo — rendu via Portal pour éviter le clipping du transform:scale sur .product-card */} - {showVideo && videoUrl && createPortal( -
-
e.stopPropagation()} - > - - -
-
, - document.body - )} + + + + , + document.body, + )} ); } diff --git a/frontend-prep/src/context/CartContext.tsx b/frontend-prep/src/context/CartContext.tsx index c45fb7db..105a79e0 100644 --- a/frontend-prep/src/context/CartContext.tsx +++ b/frontend-prep/src/context/CartContext.tsx @@ -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, }; }); diff --git a/frontend-prep/src/pages/ChangePassword/ChangePassword.css b/frontend-prep/src/pages/ChangePassword/ChangePassword.css index 92bc854f..da333f4f 100644 --- a/frontend-prep/src/pages/ChangePassword/ChangePassword.css +++ b/frontend-prep/src/pages/ChangePassword/ChangePassword.css @@ -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); +} diff --git a/frontend-prep/src/pages/ChangePassword/ChangePassword.tsx b/frontend-prep/src/pages/ChangePassword/ChangePassword.tsx index 615c43df..a072a01f 100644 --- a/frontend-prep/src/pages/ChangePassword/ChangePassword.tsx +++ b/frontend-prep/src/pages/ChangePassword/ChangePassword.tsx @@ -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) => { 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 ( + <>
@@ -65,20 +67,7 @@ const ChangePasswordPage = () => {
- {success ? ( -
- -

Mot de passe mis à jour avec succès !

-

Redirection en cours…

-
- ) : (
- {error && ( -
- ⚠️ {error} -
- )} - {/* Mot de passe actuel */}
@@ -87,7 +76,7 @@ const ChangePasswordPage = () => { { setCurrentPassword(e.target.value); setError(""); }} + onChange={(e) => { setCurrentPassword(e.target.value); }} className="cp-input" placeholder="••••••••" disabled={isLoading} @@ -116,7 +105,7 @@ const ChangePasswordPage = () => { { 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 = () => { { 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"} - )}
+ + {/* Modal succès */} + {showSuccessModal && ( +
navigate('/user/accueil')}> +
e.stopPropagation()}> +
+ +
+

Mot de passe mis à jour

+

Votre mot de passe a été changé avec succès.

+ +
+
+ )} + + {/* Modal erreur */} + {showErrorModal && ( +
setShowErrorModal(false)}> +
e.stopPropagation()}> + +
+ +
+

Une erreur est survenue

+

{errorMsg}

+ +
+
+ )} + ); }; diff --git a/frontend-prep/src/pages/LoginClient/Login.css b/frontend-prep/src/pages/LoginClient/Login.css index 2dc07329..a5abb8b7 100644 --- a/frontend-prep/src/pages/LoginClient/Login.css +++ b/frontend-prep/src/pages/LoginClient/Login.css @@ -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 { diff --git a/frontend-prep/src/pages/LoginClient/Login.tsx b/frontend-prep/src/pages/LoginClient/Login.tsx index bad91717..58dc824d 100644 --- a/frontend-prep/src/pages/LoginClient/Login.tsx +++ b/frontend-prep/src/pages/LoginClient/Login.tsx @@ -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({ username: "", @@ -21,6 +23,10 @@ const LoginClient = () => { const [isLoading, setIsLoading] = useState(false); const [apiError, setApiError] = useState(""); + 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) => { + 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 ( +
+ +
+
+
+ +
+

Vérification 2FA

+

+ Entrez le code envoyé sur votre Telegram +

+
+
+
+ {apiError && ( +
⚠️ {apiError}
+ )} +
+ +
+ 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" }} + /> +
+
+ + +
+
+
+
+ ); + } + return (
+
diff --git a/frontend-prep/src/pages/User/Accueil.tsx b/frontend-prep/src/pages/User/Accueil.tsx index 6be4e40b..d3ac7734 100644 --- a/frontend-prep/src/pages/User/Accueil.tsx +++ b/frontend-prep/src/pages/User/Accueil.tsx @@ -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} />
))} diff --git a/frontend-prep/src/pages/User/Cart.tsx b/frontend-prep/src/pages/User/Cart.tsx index 57516a7a..08e3ec56 100644 --- a/frontend-prep/src/pages/User/Cart.tsx +++ b/frontend-prep/src/pages/User/Cart.tsx @@ -17,6 +17,7 @@ interface CartItemWithMedia { image: string; hasVideo: boolean; videoUrl?: string; + is_reward?: boolean; } function Cart() { @@ -176,9 +177,22 @@ function Cart() { {/* Infos */}
-

{item.name_product}

+

+ {item.name_product} + {item.is_reward && ( + + 🎁 Récompense + + )} +

{item.quantity}g

-

{item.price.toFixed(2)} €

+

+ {item.is_reward ? ( + Offert + ) : ( + `${item.price.toFixed(2)} €` + )} +

{/* Bouton supprimer */} diff --git a/frontend-prep/src/pages/User/Checkout.css b/frontend-prep/src/pages/User/Checkout.css index a8e2cd0e..45a2185e 100644 --- a/frontend-prep/src/pages/User/Checkout.css +++ b/frontend-prep/src/pages/User/Checkout.css @@ -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); diff --git a/frontend-prep/src/pages/User/Checkout.tsx b/frontend-prep/src/pages/User/Checkout.tsx index 5546653f..5b00ccf2 100644 --- a/frontend-prep/src/pages/User/Checkout.tsx +++ b/frontend-prep/src/pages/User/Checkout.tsx @@ -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).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).referral_used as number | undefined, + referral_used: response.referral_used, clientInfo: { first_name: firstName, last_name: lastName, @@ -417,8 +424,19 @@ function Checkout() {
Total: - {total.toFixed(2)} € + + {(useReferral && referralBalance > 0 + ? Math.max(0, total - referralBalance) + : total + ).toFixed(2)} € +
+ {useReferral && referralBalance > 0 && ( +
+ Dont crédit parrainage : + -{Math.min(referralBalance, total).toFixed(2)} € +
+ )}
{/* Formulaire */} @@ -567,16 +585,18 @@ function Checkout() {
)} -
- -
- Compte Telegram requis -

- Votre commande ne pourra être validée que si votre compte Telegram est lié. - Rendez-vous dans votre profil pour le lier avant de confirmer. -

+ {!telegramLinked && ( +
+ +
+ Compte Telegram requis +

+ Votre commande ne pourra être validée que si votre compte Telegram est lié. + Rendez-vous dans votre profil pour le lier avant de confirmer. +

+
-
+ )}
-
{confirmationData.total.toFixed(2)} €
+
+ {(confirmationData.total - (confirmationData.referral_used ?? 0)).toFixed(2)} € +
diff --git a/frontend-prep/src/pages/User/ConsultationHistorique.css b/frontend-prep/src/pages/User/ConsultationHistorique.css index 2675b9b6..49cd40c6 100644 --- a/frontend-prep/src/pages/User/ConsultationHistorique.css +++ b/frontend-prep/src/pages/User/ConsultationHistorique.css @@ -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; diff --git a/frontend-prep/src/pages/User/ConsultationHistorique.tsx b/frontend-prep/src/pages/User/ConsultationHistorique.tsx index 09b26bb6..f30ba7c7 100644 --- a/frontend-prep/src/pages/User/ConsultationHistorique.tsx +++ b/frontend-prep/src/pages/User/ConsultationHistorique.tsx @@ -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([]); - const [clientStats, setClientStats] = useState(null); - const [penalties, setPenalties] = useState(null); - const [appSettings, setAppSettings] = useState({ 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(0); - const [isLoading, setIsLoading] = useState(true); - const [error, setError] = useState(''); - - 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([]); + const [clientStats, setClientStats] = useState(null); + const [penalties, setPenalties] = useState(null); + const [appSettings, setAppSettings] = useState({ + 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(0); + const [pointsRewards, setPointsRewards] = useState<{ + enabled: boolean; + pools: PointsPoolInfo[]; + reward: PointsRewardConfig | null; + } | null>(null); + const [claimingPool, setClaimingPool] = useState(null); + const [claimFeedback, setClaimFeedback] = useState<{ pool: string; type: "success" | "error"; text: string } | null>(null); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(""); - 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 ( + <> + +
+
+
+
+
+

+ Chargement de l'historique... +

+
+
+ + ); } - }; - 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 ( - <> - -
-
-
-

Chargement de l'historique...

-
-
- - ); - } + <> + +
+

Historique des commandes

- 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 */} +
+ {/* Total commandes */} +
+
+ +
+

+ {clientStats?.total_commands ?? orders.length} +

+

Commandes

+
- return ( - <> - -
+ {/* Points */} + {appSettings.points_enabled && + (poolNames.length <= 1 ? ( +
+
+ +
+

+ {poolPoints[0] || 0} +

+

+ Pts {poolNames[0] ?? "Points"} +

+
+ ) : ( + <> + {poolNames.map((name, i) => ( +
+
+ +
+

+ {poolPoints[i] || 0} +

+

Pts {name}

+
+ ))} + {totalPoints > 0 && ( +
+
+ +
+

+ {totalPoints} +

+

+ Total Points +

+
+ )} + + ))} -

Historique des commandes

- - {/* Stats grid */} -
- - {/* Total commandes */} -
-
- -
-

{clientStats?.total_commands ?? orders.length}

-

Commandes

-
- - {/* Points */} - {appSettings.points_enabled && ( - poolNames.length <= 1 ? ( -
-
- + {/* Score amendes */} + {appSettings.show_amende_score && ( +
= 3 ? " stat-card-danger" : penaltyCount > 0 ? " stat-card-warning" : ""}`} + > +
= 3 ? "icon-penalty-critical" : penaltyCount > 0 ? "icon-penalty-warning" : "icon-penalty-ok"}`} + > + 0 + ? faExclamationTriangle + : faShieldAlt + } + /> +
+

= 3 ? "value-danger" : penaltyCount > 0 ? "value-warning" : ""}`} + > + {penaltyCount} +

+

Score amendes

+
+ )}
-

{poolPoints[0] || 0}

-

Pts {poolNames[0] ?? 'Points'}

-
- ) : ( - <> - {poolNames.map((name, i) => ( -
-
- + + {/* Section récompenses par palier */} + {pointsRewards?.enabled && pointsRewards.reward && pointsRewards.pools.length > 0 && ( +
+

+ + Récompenses +

+ {pointsRewards.reward.description && ( +

{pointsRewards.reward.description}

+ )} + {(pointsRewards.reward.reward_items ?? []).filter((it: RewardItemConfig) => it.product_name).length > 0 && ( +
+ {(pointsRewards.reward.reward_items ?? []).map((it: RewardItemConfig, idx: number) => ( + + + {it.product_name}{it.quantity > 0 && it.quantity !== 1 ? ` ×${it.quantity}` : ""}{it.price > 0 ? ` — ${it.price}€` : ""} + + ))} +
+ )} +
+ {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 ( +
+
+ {pool.name} + {pool.points} pts +
+ {pool.eligible_configs.length > 0 && ( +
+ {pool.eligible_configs.flatMap((cfg) => + cfg.all_products + ? [ + {cfg.category} + ] + : (cfg.product_names ?? []).map((name) => ( + + {name} + + )) + )} +
+ )} +
+
+
+
+ {pool.rewards_available > 0 ? ( + {pool.rewards_available} récompense{pool.rewards_available > 1 ? "s" : ""} disponible{pool.rewards_available > 1 ? "s" : ""} + ) : ( + Encore {remaining} pts pour une récompense + )} +
+ {feedback && ( +

{feedback.text}

+ )} + {pool.rewards_available > 0 && ( + + )} +
+ ); + })} +
-

{poolPoints[i] || 0}

-

Pts {name}

-
- ))} - {totalPoints > 0 && ( -
-
- -
-

{totalPoints}

-

Total Points

-
)} - - ) - )} - {/* Score amendes */} - {appSettings.show_amende_score && ( -
= 3 ? ' stat-card-danger' : penaltyCount > 0 ? ' stat-card-warning' : ''}`}> -
= 3 ? 'icon-penalty-critical' : penaltyCount > 0 ? 'icon-penalty-warning' : 'icon-penalty-ok'}`}> - 0 ? faExclamationTriangle : faShieldAlt} /> -
-

= 3 ? 'value-danger' : penaltyCount > 0 ? 'value-warning' : ''}`}>{penaltyCount}

-

Score amendes

-
- )} -
- - {/* Bouton parrainage */} - {appSettings.referral_enabled && ( -
navigate('/user/parrainage')}> - - - Parrainage{referralBalance > 0 ? ` — ${referralBalance.toFixed(2)} €` : ''} - - -
- )} - - {error && ( -
- -

{error}

-
- )} - - {orders.length === 0 ? ( -
- -

Aucun historique

-

Vos commandes terminées apparaîtront ici

- -
- ) : ( - <> -

Historique des commandes

-
- {orders.map((order) => ( -
viewOrderDetails(order)} - > -
- - Commande #{(order.client_order_number ?? 0).toString().padStart(4, '0')} - - - - Livrée - -
- -
- - - {order.adresse ? (order.adresse.length > 50 ? order.adresse.substring(0, 50) + '…' : order.adresse) : 'N/A'} - -
- -
- - - {formatDate(order.created_at)} · {getOrderAge(order.created_at)} - -
- - {order.livreur_assign && ( -
- - {order.livreur_assign} + {/* Bouton parrainage */} + {appSettings.referral_enabled && ( +
navigate("/user/parrainage")} + > + + + Parrainage + {referralBalance > 0 + ? ` — ${referralBalance.toFixed(2)} €` + : ""} + +
- )} + )} -
- {formatPrice(order.total_prix || 0)} - -
-
- ))} + {error && ( +
+ +

{error}

+
+ )} + + {orders.length === 0 ? ( +
+ +

Aucun historique

+

Vos commandes terminées apparaîtront ici

+ +
+ ) : ( + <> +

+ Historique des commandes +

+
+ {orders.map((order) => ( +
viewOrderDetails(order)} + > +
+ + Commande # + {(order.client_order_number ?? 0) + .toString() + .padStart(4, "0")} + + + + Livrée + +
+ +
+ + + {order.adresse + ? order.adresse.length > 50 + ? order.adresse.substring( + 0, + 50, + ) + "…" + : order.adresse + : "N/A"} + +
+ +
+ + + {formatDate(order.created_at)} ·{" "} + {getOrderAge(order.created_at)} + +
+ + {order.livreur_assign && ( +
+ + + {order.livreur_assign} + +
+ )} + +
+ + {formatPrice((order.total_prix || 0) - (order.referral_used || 0))} + {(order.referral_used || 0) > 0 && ( + + {" "}— dont {formatPrice(order.referral_used ?? 0)} parrainage + + )} + + +
+
+ ))} +
+ + )}
- - )} - -
- - ); + + ); } export default ConsultationHistorique; diff --git a/frontend-prep/src/pages/User/OrderDetails.tsx b/frontend-prep/src/pages/User/OrderDetails.tsx index ca780c7f..e0f00f42 100644 --- a/frontend-prep/src/pages/User/OrderDetails.tsx +++ b/frontend-prep/src/pages/User/OrderDetails.tsx @@ -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(null); const [enrichedProducts, setEnrichedProducts] = useState< diff --git a/frontend-prep/src/pages/User/Parrainage.tsx b/frontend-prep/src/pages/User/Parrainage.tsx index c5cf9b5e..5f9855d1 100644 --- a/frontend-prep/src/pages/User/Parrainage.tsx +++ b/frontend-prep/src/pages/User/Parrainage.tsx @@ -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() {
{/* Bouton Telegram */} -
-

- Prêt à parrainer ? Contactez-nous sur Telegram pour enregistrer votre - filleul. -

- - - Contacter sur Telegram - -
+ {settings?.contact_telegram && ( +
+

+ Prêt à parrainer ? Contactez-nous sur Telegram pour enregistrer votre + filleul. +

+ + + Contacter @{settings.contact_telegram} + +
+ )}
); diff --git a/frontend-prep/src/pages/User/ProductDetail.css b/frontend-prep/src/pages/User/ProductDetail.css index 3a4c81ed..1a08706f 100644 --- a/frontend-prep/src/pages/User/ProductDetail.css +++ b/frontend-prep/src/pages/User/ProductDetail.css @@ -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; diff --git a/frontend-prep/src/pages/User/ProductDetail.tsx b/frontend-prep/src/pages/User/ProductDetail.tsx index 328faec7..00396572 100644 --- a/frontend-prep/src/pages/User/ProductDetail.tsx +++ b/frontend-prep/src/pages/User/ProductDetail.tsx @@ -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() {
-
+
m.type === "image")?.url || ""} + src={ + product.media?.find((m) => m.type === "image") + ?.url || "" + } alt={product.name} className="product-detail-image" /> - {isOutOfStock &&
SOLD OUT
} + {isOutOfStock && ( +
SOLD OUT
+ )} + {isComingSoon && ( +
+ COMMING SOON +
+ )}
@@ -253,7 +296,8 @@ function ProductDetail() { {selectedPrice > 0 && (

{selectedPrice.toFixed(2)} €{" "} - {selectedGrams && `pour ${selectedGrams}${product.unit || "g"}`} + {selectedGrams && + `pour ${selectedGrams}${product.unit || "g"}`}

)} @@ -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)} € ))} @@ -301,13 +346,19 @@ function ProductDetail() {
diff --git a/frontend-prep/src/pages/User/ProfilePage.css b/frontend-prep/src/pages/User/ProfilePage.css index 83413ae4..fec5c0d8 100644 --- a/frontend-prep/src/pages/User/ProfilePage.css +++ b/frontend-prep/src/pages/User/ProfilePage.css @@ -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; +} diff --git a/frontend-prep/src/pages/User/ProfilePage.tsx b/frontend-prep/src/pages/User/ProfilePage.tsx index 6133d074..24442f30 100644 --- a/frontend-prep/src/pages/User/ProfilePage.tsx +++ b/frontend-prep/src/pages/User/ProfilePage.tsx @@ -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 ( - <> - -
-
-
-
-
- - ); + 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 ( - <> - -
-
-
- -
-
-

Mon Profil

-

@{username}

-
-
- - {successMsg && ( -
- {successMsg} -
- )} - {errorMsg && ( -
- {" "} - {errorMsg} -
- )} - - {/* Section compte */} -
-

- - Mon compte -

-
-
-
- - setPrenom(e.target.value)} - placeholder="Votre prénom" - /> -
-
- - setNom(e.target.value)} - placeholder="Votre nom" - /> -
-
-
- - setTelephone(e.target.value)} - placeholder="+33 6 12 34 56 78" - /> -
-
- - -
- - {/* Section adresse par défaut */} -
-

- - Adresse par défaut -

-

- Sera pré-remplie dans le formulaire de commande. Vous - pourrez la modifier si vous n'êtes pas à cette adresse. -

-
- - setDefaultAddress(e.target.value)} - placeholder="Numéro, rue, ville, code postal" - /> -
-
- - {/* Section contact commande */} -
-

- - Contact livraison -

-

- Numéro utilisé par le livreur lors de la livraison. Peut - être différent du numéro de votre compte. -

-
- - setDefaultPhone(e.target.value)} - placeholder="+33 6 12 34 56 78" - /> -
-
- - setSignalPseudo(e.target.value)} - placeholder="@votre.pseudo.signal" - /> -
- -
- {/* Section Telegram */} - {tgEnabled && ( -
-

- - Notifications Telegram -

-

- Recevez vos notifications sur Telegram, même quand - le site est fermé. -

- {tgLinked ? ( -
- - {" "} - Compte Telegram lié - - -
- ) : ( - - )} -
- )} -
- - {showSaveModal && ( -
setShowSaveModal(false)} - > -
e.stopPropagation()} - > - -
- -
-

- Enregistrer les infos par défaut ? -

-

- Adresse, téléphone de livraison et pseudo Signal - seront sauvegardés localement et pré-remplis lors de - vos prochaines commandes. -

-
- - -
-
-
- )} - + <> + +
+
+
+ ); + } + + return ( + <> + +
+
+
+ +
+
+

Mon Profil

+

@{username}

+
+
+ + {/* Section compte */} +
+

+ + Mon compte +

+
+
+
+ + setPrenom(e.target.value)} + placeholder="Votre prénom" + /> +
+
+ + setNom(e.target.value)} + placeholder="Votre nom" + /> +
+
+
+ + setTelephone(e.target.value)} + placeholder="+33 6 12 34 56 78" + /> +
+
+ + +
+ + {/* Section adresse par défaut */} +
+

+ + Adresse par défaut +

+

+ Sera pré-remplie dans le formulaire de commande. Vous pourrez la modifier si vous n'êtes pas à cette adresse. +

+
+ + setDefaultAddress(e.target.value)} + placeholder="Numéro, rue, ville, code postal" + /> +
+ +
+ + {/* Section contact commande */} +
+

+ + Contact livraison +

+

+ Numéro utilisé par le livreur lors de la livraison. Peut être différent du numéro de votre compte. +

+
+ + setDefaultPhone(e.target.value)} + placeholder="+33 6 12 34 56 78" + /> +
+
+ + setSignalPseudo(e.target.value)} + placeholder="@votre.pseudo.signal" + /> +
+ +
+ {/* Section Telegram */} + {tgEnabled && ( +
+

+ + Notifications Telegram +

+

+ Recevez vos notifications sur Telegram, même quand le site est fermé. +

+ {tgLinked ? ( +
+ + Compte Telegram lié + + +
+ ) : ( + + )} +
+ )} + + {/* Section 2FA — visible uniquement si l'admin l'a activé ET Telegram est lié */} + {twoFAAdminEnabled && tgLinked && ( +
+

+ + Double authentification (2FA) +

+

+ À chaque connexion, un code à 6 chiffres vous sera envoyé sur Telegram avant d'accéder à votre compte. +

+
+
+ {twoFAEnabled ? 'Activée' : 'Désactivée'} + {twoFAEnabled && ( + + Protection active + + )} +
+ {twoFALoading ? ( +
+ ) : ( + + )} +
+
+ )} +
+ + {showSaveModal && ( +
setShowSaveModal(false)}> +
e.stopPropagation()}> + +
+ +
+

Enregistrer les infos par défaut ?

+

+ Adresse, téléphone de livraison et pseudo Signal seront sauvegardés localement et pré-remplis lors de vos prochaines commandes. +

+
+ + +
+
+
+ )} + + {showConfirmAddressModal && ( +
setShowConfirmAddressModal(false)}> +
e.stopPropagation()}> + +
+ +
+

Enregistrer l'adresse ?

+

+ Cette adresse sera sauvegardée localement et pré-remplie lors de vos prochaines commandes. +

+
+ + +
+
+
+ )} + + {showConfirmContactModal && ( +
setShowConfirmContactModal(false)}> +
e.stopPropagation()}> + +
+ +
+

Enregistrer le compte ?

+

+ Vos informations (prénom, nom, téléphone) seront mises à jour sur votre compte. +

+
+ + +
+
+
+ )} + + {showUnlinkModal && ( +
setShowUnlinkModal(false)}> +
e.stopPropagation()}> + +
+ +
+

Délier Telegram ?

+

+ Vous ne recevrez plus de notifications Telegram. La double authentification sera également désactivée. +

+
+ + +
+
+
+ )} + + {/* Modal succès */} + {showSuccessModal && ( +
setShowSuccessModal(false)}> +
e.stopPropagation()}> + +
+ +
+

{successTitle}

+

{successMsg}

+
+ +
+
+
+ )} + + {/* Modal erreur */} + {showErrorModal && ( +
setShowErrorModal(false)}> +
e.stopPropagation()}> + +
+ +
+

Une erreur est survenue

+

{errorMsg}

+
+ +
+
+
+ )} + + {showUnlinkSuccessModal && ( +
setShowUnlinkSuccessModal(false)}> +
e.stopPropagation()}> + +
+ +
+

Compte délié

+

+ Votre compte Telegram a été délié avec succès. Vous ne recevrez plus de notifications via Telegram. +

+
+ +
+
+
+ )} + + ); } diff --git a/frontend-prep/src/pages/User/SuiviLivraison.tsx b/frontend-prep/src/pages/User/SuiviLivraison.tsx index e57357c3..14d5f192 100644 --- a/frontend-prep/src/pages/User/SuiviLivraison.tsx +++ b/frontend-prep/src/pages/User/SuiviLivraison.tsx @@ -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 + {(order.referral_used ?? 0) > 0 && ( +

+ Brut : {(order.total_prix ?? 0).toFixed(2)} € +

+ )}

- {getTotalAmount( - order, - ).toFixed(2)}{" "} - € + {Math.max(0, getTotalAmount(order) - (order.referral_used ?? 0)).toFixed(2)} €

+ {(order.referral_used ?? 0) > 0 && ( +

+ — dont {(order.referral_used!).toFixed(2)} € parrainage déduit +

+ )}