chore: build

This commit is contained in:
2026-06-14 17:50:35 +02:00
parent 3c92a0371f
commit 3a0f725159
93 changed files with 5311 additions and 4224 deletions
+145 -274
View File
@@ -3,136 +3,10 @@ package db
import ( import (
"fmt" "fmt"
"gestion/models" "gestion/models"
"log"
"time"
"gorm.io/gorm" "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) // 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) { func (d *Database) GetProductPrice(name, category string, quantity float64) (float64, error) {
var result struct { var result struct {
@@ -153,37 +27,11 @@ func (d *Database) GetProductPrice(name, category string, quantity float64) (flo
return result.Price, nil 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 // GetAllProductsInBasket récupère tous les produits du panier d'un utilisateur
func (d *Database) GetAllProductsInBasket(username string) ([]models.Panier, error) { func (d *Database) GetAllProductsInBasket(username string) ([]models.Panier, error) {
var baskets []models.Panier var baskets []models.Panier
err := d.GDB.Raw(` 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 p.name as product_name, p.category, p.description
FROM baskets b FROM baskets b
INNER JOIN products p ON b.product_id = p.id INNER JOIN products p ON b.product_id = p.id
@@ -195,40 +43,112 @@ func (d *Database) GetAllProductsInBasket(username string) ([]models.Panier, err
return baskets, nil return baskets, nil
} }
// DecrementProductStockByID décrémente le stock d'un produit par son ID // AddRewardsToBasket ajoute plusieurs produits récompense au panier (prix = 0, is_reward = true).
func (d *Database) DecrementProductStockByID(productID int, quantity float64) error { // Supprime les anciens items récompense avant d'insérer les nouveaux.
result := d.GDB.Exec(` // Pas de vérification de stock — les récompenses sont gérées par l'admin.
UPDATE products SET stock = stock - ? func (d *Database) AddRewardsToBasket(username string, items []models.RewardItem, poolKey string) ([]models.Panier, error) {
WHERE id = ? AND stock >= ?`, quantity, productID, quantity) var baskets []models.Panier
if result.Error != nil { err := d.GDB.Transaction(func(tx *gorm.DB) error {
return fmt.Errorf("erreur lors de la mise à jour du stock: %w", result.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
} }
if result.RowsAffected == 0 { var productName string
return fmt.Errorf("stock insuffisant pour le produit %d", productID) 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 return nil
})
if err != nil {
return nil, err
}
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) DeleteProductFromBasket(basketID int) error { func (d *Database) HasOnlyRewardItems(username string) (bool, error) {
return d.GDB.Transaction(func(tx *gorm.DB) error { var counts struct {
var item struct { Total int `gorm:"column:total"`
ProductID int `gorm:"column:product_id"` 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(&currentStock).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"` Quantity float64 `gorm:"column:quantity"`
Price float64 `gorm:"column:price"`
} }
if err := tx.Raw(`SELECT product_id, quantity FROM baskets WHERE id = ?`, basketID).Scan(&item).Error; err != nil { // Chercher uniquement un item normal (non-récompense) pour ce produit
return fmt.Errorf("produit non trouvé dans le panier") tx.Raw(`SELECT id, quantity, price FROM baskets WHERE username = ? AND product_id = ? AND is_reward = false`,
} username, productID).Scan(&existing)
if item.ProductID == 0 {
return fmt.Errorf("produit non trouvé dans le panier")
}
if err := tx.Exec(`UPDATE products SET stock = stock + ? WHERE id = ?`, if existing.ID != 0 {
item.Quantity, item.ProductID).Error; err != nil { return tx.Raw(`
return fmt.Errorf("erreur restitution stock: %w", err) 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
}
result := tx.Exec(`DELETE FROM baskets WHERE id = ?`, basketID) // 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 {
result := d.GDB.Exec(`DELETE FROM baskets WHERE id = ?`, basketID)
if result.Error != nil { if result.Error != nil {
return fmt.Errorf("erreur lors de la suppression du produit: %w", result.Error) return fmt.Errorf("erreur lors de la suppression du produit: %w", result.Error)
} }
@@ -236,120 +156,41 @@ func (d *Database) DeleteProductFromBasket(basketID int) error {
return fmt.Errorf("produit non trouvé dans le panier") return fmt.Errorf("produit non trouvé dans le panier")
} }
return nil return nil
})
} }
// ClearBasket vide complètement le panier d'un utilisateur et restitue les stocks. // 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 { 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 return d.GDB.Exec(`DELETE FROM baskets WHERE username = ?`, username).Error
} }
// GetBasketTotal calcule le montant total du panier d'un utilisateur // ClearBasketOnCheckout décrémente le stock pour chaque article du panier puis vide le panier.
func (d *Database) GetBasketTotal(username string) (float64, error) { // C'est ici que le stock est effectivement consommé, au moment de la validation de la commande.
var result struct { func (d *Database) ClearBasketOnCheckout(username string) error {
Total float64 `gorm:"column:total"` return d.GDB.Transaction(func(tx *gorm.DB) error {
}
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)
if result.Error != nil {
return fmt.Errorf("erreur lors de la mise à jour de la quantité: %w", result.Error)
}
if result.RowsAffected == 0 {
return fmt.Errorf("produit non trouvé dans le panier")
}
return nil
}
// ExtendBasketReservations prolonge les réservations
func (d *Database) ExtendBasketReservations(username string) error {
var items []struct { var items []struct {
ProductID int `gorm:"column:product_id"` ProductID int `gorm:"column:product_id"`
Quantity float64 `gorm:"column:quantity"` Quantity float64 `gorm:"column:quantity"`
} }
if err := d.GDB.Raw(`SELECT product_id, quantity FROM baskets WHERE username = ?`, username).Scan(&items).Error; err != nil { if err := tx.Raw(`SELECT product_id, quantity FROM baskets WHERE username = ? FOR UPDATE`, username).Scan(&items).Error; err != nil {
return fmt.Errorf("erreur récupération panier: %w", err) return fmt.Errorf("erreur lecture panier: %w", err)
} }
for _, item := range items { for _, item := range items {
var stockResult struct { var currentStock float64
Stock float64 `gorm:"column:stock"` if err := tx.Raw(`SELECT stock FROM products WHERE id = ? FOR UPDATE`, item.ProductID).Scan(&currentStock).Error; err != nil {
return fmt.Errorf("erreur lecture stock produit %d: %w", item.ProductID, err)
} }
if err := d.GDB.Raw(`SELECT stock FROM products WHERE id = ?`, item.ProductID).Scan(&stockResult).Error; err != nil { if currentStock < item.Quantity {
return fmt.Errorf("produit %d non trouvé: %w", item.ProductID, err) return fmt.Errorf("stock insuffisant pour le produit %d", item.ProductID)
} }
if stockResult.Stock < item.Quantity { if err := tx.Exec(`UPDATE products SET stock = stock - ? WHERE id = ?`, item.Quantity, item.ProductID).Error; err != nil {
return fmt.Errorf("stock insuffisant pour le produit %d (demandé: %g, disponible: %g)", return fmt.Errorf("erreur décrémentation stock produit %d: %w", item.ProductID, err)
item.ProductID, item.Quantity, stockResult.Stock)
} }
} }
newReservation := time.Now().Add(15 * time.Minute) return tx.Exec(`DELETE FROM baskets WHERE username = ?`, username).Error
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
}
// 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
} }
func (d *Database) GetBasketItemOwner(basketID int) (string, error) { func (d *Database) GetBasketItemOwner(basketID int) (string, error) {
@@ -364,9 +205,39 @@ func (d *Database) GetBasketItemOwner(basketID int) (string, error) {
return username, nil 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) { func (d *Database) GetBasketItems(username string) ([]map[string]any, error) {
var items []map[string]any 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 { username).Scan(&items).Error; err != nil {
return nil, err return nil, err
} }
+20 -6
View File
@@ -86,10 +86,9 @@ func (d *Database) CancelCommandAtomic(commandID int, username, reason string, f
SET stock = stock + ci.quantite, updated_at = CURRENT_TIMESTAMP SET stock = stock + ci.quantite, updated_at = CURRENT_TIMESTAMP
FROM command_items ci FROM command_items ci
WHERE ci.command_id = ? AND ci.product_id = p.id`, commandID).Error; err != nil { WHERE ci.command_id = ? AND ci.product_id = p.id`, commandID).Error; err != nil {
log.Printf("⚠️ [CancelAtomic] Erreur remboursement stock: %v", err) return fmt.Errorf("erreur remboursement stock: %w", err)
} else {
log.Printf("✅ [CancelAtomic] Stock remboursé")
} }
log.Printf("✅ [CancelAtomic] Stock remboursé")
if err := tx.Exec(` if err := tx.Exec(`
UPDATE clients UPDATE clients
@@ -179,8 +178,6 @@ func (d *Database) CheckCommandETAExistsAndValid(commandID int) bool {
} }
func (d *Database) DeleteCommandAtomic(commandID int, deletedBy, role string) error { 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 { return d.GDB.Transaction(func(tx *gorm.DB) error {
var cmdResult struct { var cmdResult struct {
Status string `gorm:"column:status"` Status string `gorm:"column:status"`
@@ -199,6 +196,9 @@ func (d *Database) DeleteCommandAtomic(commandID int, deletedBy, role string) er
log.Printf("📋 [DeleteAtomic] Trouvée - status=%s, client=%s", cmdResult.Status, cmdResult.Username) log.Printf("📋 [DeleteAtomic] Trouvée - status=%s, client=%s", cmdResult.Status, cmdResult.Username)
// ✅ Ne restitue le stock QUE si pas déjà fait
stockAlreadyRestored := cmdResult.Status == "cancelled" || cmdResult.Status == "approved"
if !stockAlreadyRestored {
if err := tx.Exec(` if err := tx.Exec(`
UPDATE products p UPDATE products p
SET stock = stock + ci.quantite, updated_at = CURRENT_TIMESTAMP SET stock = stock + ci.quantite, updated_at = CURRENT_TIMESTAMP
@@ -206,9 +206,13 @@ func (d *Database) DeleteCommandAtomic(commandID int, deletedBy, role string) er
WHERE ci.command_id = ? AND ci.product_id = p.id`, commandID).Error; err != nil { WHERE ci.command_id = ? AND ci.product_id = p.id`, commandID).Error; err != nil {
log.Printf("⚠️ [DeleteAtomic] Erreur remboursement: %v", err) log.Printf("⚠️ [DeleteAtomic] Erreur remboursement: %v", err)
} else { } else {
log.Printf("✅ [DeleteAtomic] Stock remboursé") log.Printf("✅ [DeleteAtomic] Stock remboursé (statut: %s)", cmdResult.Status)
}
} else {
log.Printf("⏭️ [DeleteAtomic] Stock NON restitué - statut=%s", cmdResult.Status)
} }
// ✅ Log suppression
tx.Exec(` tx.Exec(`
INSERT INTO command_logs (command_id, status, message, author, created_at) INSERT INTO command_logs (command_id, status, message, author, created_at)
VALUES (?, ?, ?, ?, CURRENT_TIMESTAMP)`, VALUES (?, ?, ?, ?, CURRENT_TIMESTAMP)`,
@@ -316,3 +320,13 @@ func (d *Database) AddClientPenalty(username string, points int) error {
return nil 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
})
}
+149 -104
View File
@@ -190,44 +190,6 @@ func (d *Database) UpdateClientPasswordAndClearFlag(clientID int, hashedPassword
return nil 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) { func (d *Database) GetClientAmende(username string) (float64, error) {
var result struct { var result struct {
Amende float64 `gorm:"column:amende"` Amende float64 `gorm:"column:amende"`
@@ -242,37 +204,6 @@ func (d *Database) GetClientAmende(username string) (float64, error) {
return result.Amende, nil 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 // IncrementClientCommandCount incrémente le compteur de commandes du client
func (d *Database) IncrementClientCommandCount(username string) error { func (d *Database) IncrementClientCommandCount(username string) error {
result := d.GDB.Model(&models.Client{}).Where("username = ?", username).UpdateColumn("command", gorm.Expr("command + 1")) 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"` MustChangePassword bool `gorm:"column:must_change_password"`
PointsExtraJSON []byte `gorm:"column:points_extra"` PointsExtraJSON []byte `gorm:"column:points_extra"`
CreatedAt time.Time `gorm:"column:created_at"` CreatedAt time.Time `gorm:"column:created_at"`
TwoFAEnabled bool `gorm:"column:two_fa_enabled"`
} }
err := d.GDB.Raw(` err := d.GDB.Raw(`
SELECT id, username, password, nom, prenom, telephone, command, amende, 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 FROM clients WHERE username = ?`, username).Scan(&row).Error
if err != nil { if err != nil {
return nil, fmt.Errorf("erreur lors de la récupération du client: %w", err) 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, Amende: row.Amende,
MustChangePassword: row.MustChangePassword, MustChangePassword: row.MustChangePassword,
CreatedAt: row.CreatedAt, CreatedAt: row.CreatedAt,
TwoFAEnabled: row.TwoFAEnabled,
} }
client.PointsExtra = map[string]int{} client.PointsExtra = map[string]int{}
if len(row.PointsExtraJSON) > 0 { if len(row.PointsExtraJSON) > 0 {
@@ -406,7 +340,11 @@ func (d *Database) GetClientByUsername(username string) (*models.Client, error)
return client, nil 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) amende, err := d.GetClientAmende(username)
if err != nil { if err != nil {
return nil, err return nil, err
@@ -421,13 +359,13 @@ func (d *Database) GetClientPenaltiesInfo(username string) (map[string]interface
cancellationHistory, err := d.GetClientCancellationHistory(username) cancellationHistory, err := d.GetClientCancellationHistory(username)
if err != nil { if err != nil {
log.Printf("⚠️ [GetClientPenaltiesInfo] Erreur récup historique: %v", err) log.Printf("⚠️ [GetClientPenaltiesInfo] Erreur récup historique: %v", err)
cancellationHistory = map[string]interface{}{ cancellationHistory = map[string]any{
"cancellations_count": cancellationsCount, "cancellations_count": cancellationsCount,
"next_penalty": 20, "next_penalty": 20,
} }
} }
info := map[string]interface{}{ info := map[string]any{
"username": username, "username": username,
"total_penalty": amende, "total_penalty": amende,
"cancellations_count": cancellationsCount, "cancellations_count": cancellationsCount,
@@ -438,21 +376,6 @@ func (d *Database) GetClientPenaltiesInfo(username string) (map[string]interface
return info, nil 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. // ResetClientPoint réinitialise les points d'un client.
// extraPoolKey != "" → reset points_extra[extraPoolKey] uniquement // extraPoolKey != "" → reset points_extra[extraPoolKey] uniquement
// extraPoolKey == "" (poolIdx=-1) → reset total points_extra // extraPoolKey == "" (poolIdx=-1) → reset total points_extra
@@ -488,17 +411,13 @@ func (d *Database) ResetClientPoint(username string, poolIdx int, extraPoolKey s
return nil return nil
} }
func (d *Database) ResetClientPenalties(username string, resetCancellationsCount bool) error { func (d *Database) ResetClientPenalties(username string, _ bool) error {
log.Printf("🔄 [ResetClientPenalties] Reset pour %s (reset_count=%v)", username, resetCancellationsCount) log.Printf("🔄 [ResetClientPenalties] Reset amende + cancellations_count pour %s", username)
var query string result := d.GDB.Exec(
if resetCancellationsCount { `UPDATE clients SET amende = 0, cancellations_count = 0, updated_at = CURRENT_TIMESTAMP WHERE username = ?`,
query = `UPDATE clients SET amende = 0, cancellations_count = 0, updated_at = CURRENT_TIMESTAMP WHERE username = ?` username,
} else { )
query = `UPDATE clients SET amende = 0, updated_at = CURRENT_TIMESTAMP WHERE username = ?`
}
result := d.GDB.Exec(query, username)
if result.Error != nil { if result.Error != nil {
log.Printf("❌ [ResetClientPenalties] Erreur UPDATE: %v", result.Error) log.Printf("❌ [ResetClientPenalties] Erreur UPDATE: %v", result.Error)
return fmt.Errorf("erreur reset pénalités: %w", 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 return nil
} }
func (d *Database) GetAllClientsWithPenalties() ([]map[string]interface{}, error) { func (d *Database) GetAllClientsWithPenalties() ([]map[string]any, error) {
var rows []struct { var rows []struct {
Username string `gorm:"column:username"` Username string `gorm:"column:username"`
Amende float64 `gorm:"column:amende"` Amende float64 `gorm:"column:amende"`
CancellationsCount int `gorm:"column:cancellations_count"` CancellationsCount int `gorm:"column:cancellations_count"`
UpdatedAt interface{} `gorm:"column:updated_at"` UpdatedAt any `gorm:"column:updated_at"`
} }
err := d.GDB.Raw(` err := d.GDB.Raw(`
SELECT username, amende, COALESCE(cancellations_count, 0) as cancellations_count, updated_at 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) 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 { for _, row := range rows {
clients = append(clients, map[string]interface{}{ clients = append(clients, map[string]any{
"username": row.Username, "username": row.Username,
"total_penalty": row.Amende, "total_penalty": row.Amende,
"cancellations_count": row.CancellationsCount, "cancellations_count": row.CancellationsCount,
@@ -545,7 +464,7 @@ func (d *Database) GetAllClientsWithPenalties() ([]map[string]interface{}, error
return clients, nil return clients, nil
} }
func (d *Database) GetClientPenaltiesStats() (map[string]interface{}, error) { func (d *Database) GetClientPenaltiesStats() (map[string]any, error) {
var result struct { var result struct {
ClientsWithPenalties int `gorm:"column:clients_with_penalties"` ClientsWithPenalties int `gorm:"column:clients_with_penalties"`
TotalPenalties float64 `gorm:"column:total_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) return nil, fmt.Errorf("erreur récupération stats: %w", err)
} }
stats := map[string]interface{}{ stats := map[string]any{
"clients_with_penalties": result.ClientsWithPenalties, "clients_with_penalties": result.ClientsWithPenalties,
"total_penalties": result.TotalPenalties, "total_penalties": result.TotalPenalties,
"average_penalty": result.AvgPenalty, "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) 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 return totalPoints, pointCategory, nil
} }
@@ -727,3 +691,84 @@ func (d *Database) CanUserAccessCommand(
return exists, err 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
}
+61 -116
View File
@@ -3,6 +3,7 @@ package db
import ( import (
"fmt" "fmt"
"log" "log"
"slices"
"strings" "strings"
"time" "time"
) )
@@ -79,13 +80,11 @@ func validateItemStatus(status string) error {
status = strings.ToLower(strings.TrimSpace(status)) status = strings.ToLower(strings.TrimSpace(status))
for _, valid := range validStatuses { if !slices.Contains(validStatuses, status) {
if status == valid { return fmt.Errorf("statut invalide: %s", status)
return nil
}
} }
return fmt.Errorf("statut invalide: %s", status) return nil
} }
// ============================================ // ============================================
@@ -98,6 +97,8 @@ func (d *Database) InsertCommandItemWithClientInfo(
productID int, productID int,
quantite float64, quantite float64,
prix float64, prix float64,
isReward bool,
rewardPoolKey string,
clientUsername, clientNom, clientPrenom, clientTelephone, deliveryAddress string, clientUsername, clientNom, clientPrenom, clientTelephone, deliveryAddress string,
) error { ) error {
log.Printf("📝 [InsertCommandItemWithClientInfo] START - commandID=%d, produit=%s", commandID, produit) log.Printf("📝 [InsertCommandItemWithClientInfo] START - commandID=%d, produit=%s", commandID, produit)
@@ -115,9 +116,12 @@ func (d *Database) InsertCommandItemWithClientInfo(
return err 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 { if err := validatePrix(prix); err != nil {
return err return err
} }
}
if err := validateUsername(clientUsername); err != nil { if err := validateUsername(clientUsername); err != nil {
return err return err
@@ -162,10 +166,12 @@ func (d *Database) InsertCommandItemWithClientInfo(
err := d.GDB.Exec(` err := d.GDB.Exec(`
INSERT INTO command_items ( INSERT INTO command_items (
command_id, produit, product_id, quantite, prix, command_id, produit, product_id, quantite, prix,
is_reward, reward_pool_key,
client_username, client_nom, client_prenom, client_telephone, delivery_address, client_username, client_nom, client_prenom, client_telephone, delivery_address,
status, created_at, updated_at status, created_at, updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'pending', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)`, ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'pending', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)`,
commandID, produit, productID, quantite, prix, commandID, produit, productID, quantite, prix,
isReward, rewardPoolKey,
clientUsername, clientNom, clientPrenom, clientTelephone, deliveryAddress, clientUsername, clientNom, clientPrenom, clientTelephone, deliveryAddress,
).Error ).Error
if err != nil { if err != nil {
@@ -181,7 +187,7 @@ func (d *Database) InsertCommandItemWithClientInfo(
// GET COMMAND ITEMS - VERSION SÉCURISÉE + FIX NULL // 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) log.Printf("📦 [GetCommandItems] START - commandID=%d", commandID)
// ✅ VALIDATION // ✅ VALIDATION
@@ -197,6 +203,8 @@ func (d *Database) GetCommandItems(commandID int) ([]map[string]interface{}, err
ProductID *int64 `gorm:"column:product_id"` ProductID *int64 `gorm:"column:product_id"`
Quantite float64 `gorm:"column:quantite"` Quantite float64 `gorm:"column:quantite"`
Prix float64 `gorm:"column:prix"` Prix float64 `gorm:"column:prix"`
IsReward bool `gorm:"column:is_reward"`
RewardPoolKey string `gorm:"column:reward_pool_key"`
ClientUsername string `gorm:"column:client_username"` ClientUsername string `gorm:"column:client_username"`
ClientNom string `gorm:"column:client_nom"` ClientNom string `gorm:"column:client_nom"`
ClientPrenom string `gorm:"column:client_prenom"` ClientPrenom string `gorm:"column:client_prenom"`
@@ -212,6 +220,7 @@ func (d *Database) GetCommandItems(commandID int) ([]map[string]interface{}, err
LivreurAssign *string `gorm:"column:livreur_assign"` LivreurAssign *string `gorm:"column:livreur_assign"`
CommandCreatedAt *time.Time `gorm:"column:command_created_at"` CommandCreatedAt *time.Time `gorm:"column:command_created_at"`
Category string `gorm:"column:category"` Category string `gorm:"column:category"`
Unit string `gorm:"column:unit"`
ClientOrderNumber int `gorm:"column:client_order_number"` ClientOrderNumber int `gorm:"column:client_order_number"`
} }
@@ -223,6 +232,8 @@ func (d *Database) GetCommandItems(commandID int) ([]map[string]interface{}, err
ci.product_id, ci.product_id,
ci.quantite, ci.quantite,
ci.prix, ci.prix,
ci.is_reward,
ci.reward_pool_key,
ci.client_username, ci.client_username,
ci.client_nom, ci.client_nom,
ci.client_prenom, ci.client_prenom,
@@ -237,7 +248,8 @@ func (d *Database) GetCommandItems(commandID int) ([]map[string]interface{}, err
c.referral_used, c.referral_used,
c.livreur_assign, c.livreur_assign,
c.created_at as command_created_at, 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 c.client_order_id as client_order_number
FROM command_items ci FROM command_items ci
LEFT JOIN commandes c ON ci.command_id = c.id 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) 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 { for _, row := range rows {
productIDValue := 0 productIDValue := 0
if row.ProductID != nil { if row.ProductID != nil {
productIDValue = int(*row.ProductID) productIDValue = int(*row.ProductID)
} }
var commandCreatedAt interface{} var commandCreatedAt any
if row.CommandCreatedAt != nil { if row.CommandCreatedAt != nil {
commandCreatedAt = *row.CommandCreatedAt commandCreatedAt = *row.CommandCreatedAt
} }
item := map[string]interface{}{ item := map[string]any{
"id": row.ID, "id": row.ID,
"command_id": row.CommandID, "command_id": row.CommandID,
"produit": row.Produit, "produit": row.Produit,
"product_id": productIDValue, "product_id": productIDValue,
"quantite": row.Quantite, "quantite": row.Quantite,
"prix": row.Prix, "prix": row.Prix,
"is_reward": row.IsReward,
"reward_pool_key": row.RewardPoolKey,
"client_username": row.ClientUsername, "client_username": row.ClientUsername,
"client_nom": row.ClientNom, "client_nom": row.ClientNom,
"client_prenom": row.ClientPrenom, "client_prenom": row.ClientPrenom,
@@ -284,6 +298,7 @@ func (d *Database) GetCommandItems(commandID int) ([]map[string]interface{}, err
"livreur_assign": ptrStr(row.LivreurAssign), "livreur_assign": ptrStr(row.LivreurAssign),
"command_created_at": commandCreatedAt, "command_created_at": commandCreatedAt,
"category": row.Category, "category": row.Category,
"unit": row.Unit,
"client_order_number": row.ClientOrderNumber, "client_order_number": row.ClientOrderNumber,
} }
items = append(items, item) items = append(items, item)
@@ -301,104 +316,6 @@ func ptrStr(s *string) string {
return *s 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 { func (d *Database) DeleteCommandItem(commandID, itemID int) error {
log.Printf("🗑️ [DeleteCommandItem] START - commandID=%d, itemID=%d", commandID, itemID) log.Printf("🗑️ [DeleteCommandItem] START - commandID=%d, itemID=%d", commandID, itemID)
@@ -409,30 +326,58 @@ func (d *Database) DeleteCommandItem(commandID, itemID int) error {
return err return err
} }
// Récupérer le prix et la quantité avant suppression pour mettre à jour le total
var result struct { var result struct {
Prix float64 `gorm:"column:prix"` Prix float64 `gorm:"column:prix"`
Quantite float64 `gorm:"column:quantite"` 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) return fmt.Errorf("erreur vérification item: %w", err)
} }
if result.Prix == 0 && result.Quantite == 0 { if result.Prix == 0 && result.Quantite == 0 {
return fmt.Errorf("item %d non trouvé dans la commande %d", itemID, commandID) return fmt.Errorf("item %d non trouvé dans la commande %d", itemID, commandID)
} }
// Supprimer l'item var cmdStatus string
if err := d.GDB.Exec(`DELETE FROM command_items WHERE id = ?`, itemID).Error; err != nil { 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) log.Printf("❌ Erreur DELETE command_items: %v", err)
return fmt.Errorf("erreur suppression item: %w", err) return fmt.Errorf("erreur suppression item: %w", err)
} }
// Recalculer le total de la commande if err := tx.Exec(
if err := d.GDB.Exec(
`UPDATE commandes SET total_prix = GREATEST(0, total_prix - ?) WHERE id = ?`, `UPDATE commandes SET total_prix = GREATEST(0, total_prix - ?) WHERE id = ?`,
result.Prix*result.Quantite, commandID, result.Prix*result.Quantite, commandID,
).Error; err != nil { ).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 return nil
-83
View File
@@ -7,7 +7,6 @@ package db
import ( import (
"fmt" "fmt"
"gestion/models"
"log" "log"
"slices" "slices"
) )
@@ -44,88 +43,6 @@ func (d *Database) GetAllCommandsOldestFirst(status, username string) ([]map[str
return commands, nil 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 // GetPendingCommandsStats récupère des statistiques sur les commandes en attente
func (d *Database) GetPendingCommandsStats() (map[string]any, error) { func (d *Database) GetPendingCommandsStats() (map[string]any, error) {
var result struct { var result struct {
+17 -23
View File
@@ -48,11 +48,13 @@ type basketItem struct {
ProductID int `gorm:"column:product_id"` ProductID int `gorm:"column:product_id"`
Quantity float64 `gorm:"column:quantity"` Quantity float64 `gorm:"column:quantity"`
Price float64 `gorm:"column:price"` 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) { func (d *Database) fetchBasketItems(username string) ([]basketItem, float64, error) {
var items []basketItem 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) return nil, 0, fmt.Errorf("erreur récupération panier: %w", err)
} }
total := 0.0 total := 0.0
@@ -127,6 +129,8 @@ func (d *Database) CreateCommand(username string) (*models.Command, error) {
ProductID: item.ProductID, ProductID: item.ProductID,
Quantity: item.Quantity, Quantity: item.Quantity,
Price: item.Price, Price: item.Price,
IsReward: item.IsReward,
RewardPoolKey: item.RewardPoolKey,
} }
if err := d.GDB.Create(&cmdItem).Error; err != nil { if err := d.GDB.Create(&cmdItem).Error; err != nil {
return nil, fmt.Errorf("erreur lors de l'insertion des items: %w", err) 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.ProductID,
item.Quantity, item.Quantity,
item.Price, item.Price,
item.IsReward,
item.RewardPoolKey,
username, username,
clientNom, clientNom,
clientPrenom, clientPrenom,
@@ -349,14 +355,6 @@ func (d *Database) GetAllCommands(status, username string) ([]map[string]any, er
return commands, nil 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 { func (d *Database) SetCommandReferralUsed(commandID int, amount float64) error {
return d.GDB.Exec(`UPDATE commandes SET referral_used = ? WHERE id = ?`, amount, commandID).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 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 { func (d *Database) UpdateCommandAddress(commandID int, deliveryAddress string) error {
if len(deliveryAddress) > 500 { if len(deliveryAddress) > 500 {
return fmt.Errorf("adresse trop longue (max 500 caractères)") 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 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
}
+28
View File
@@ -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
}
-16
View File
@@ -117,22 +117,6 @@ func (d *Database) GetDeliveryPersonCommands(livreurUsername string, status stri
return commands, nil 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 { func (d *Database) ApproveDelivery(commandID int, clientUsername string) error {
return d.GDB.Transaction(func(tx *gorm.DB) error { return d.GDB.Transaction(func(tx *gorm.DB) error {
var cmdResult struct { var cmdResult struct {
-76
View File
@@ -221,79 +221,3 @@ func (d *Database) UpdateCommandLivreur(commandID int, livreurUsername string) e
} }
return nil 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
}
-113
View File
@@ -8,8 +8,6 @@ package db
import ( import (
"fmt" "fmt"
"log" "log"
"maps"
"strconv"
) )
// GetCompletedCommandsByUsername récupère toutes les commandes terminées (approved) d'un utilisateur // 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 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
}
+49
View File
@@ -120,6 +120,24 @@ func InitDB() *Database {
log.Fatalf("❌ Erreur migration baskets.quantity: %v", err) 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 // Migration: command_items.quantite INTEGER → NUMERIC(10,3) pour supporter les quantités fractionnaires
if _, err = database.Exec(` if _, err = database.Exec(`
DO $$ DO $$
@@ -236,6 +254,29 @@ func InitDB() *Database {
log.Fatalf("❌ Erreur migration clients.points_extra: %v", err) 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 // Lancer le nettoyage périodique des tokens expirés
go database.cleanExpiredTokensPeriodically() 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_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_status ON delivery_issues(status);`,
`CREATE INDEX IF NOT EXISTS idx_issues_reported_by ON delivery_issues(reported_by);`, `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 { for _, query := range queries {
+20 -10
View File
@@ -9,6 +9,18 @@ import (
"time" "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 { func (d *Database) NotifyClient(username string, commandID int, notifType, message string) error {
notifKey := fmt.Sprintf("notifications:%s", username) 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.LPush(RedisCtx, notifKey, notifJSON)
Redis.Expire(RedisCtx, notifKey, 7*24*time.Hour) 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 { if chatID, ok, err := d.GetClientTelegramChatID(username); err == nil && ok {
go services.TelegramBot.SendMessage(chatID, fmt.Sprintf("🔔 <b>Notification</b>\n\n%s", message)) go sendTelegramNotif(chatID, fmt.Sprintf("🔔 <b>Notification</b>\n\n%s", message))
} }
} }
@@ -50,9 +62,9 @@ func (d *Database) NotifyLivreur(username string, commandID int, notifType, mess
Redis.LPush(RedisCtx, notifKey, notifJSON) Redis.LPush(RedisCtx, notifKey, notifJSON)
Redis.Expire(RedisCtx, notifKey, 7*24*time.Hour) 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 { if chatID, ok, err := d.GetUserTelegramChatID(username); err == nil && ok {
go services.TelegramBot.SendMessage(chatID, fmt.Sprintf("🔔 <b>Notification</b>\n\n%s", message)) go sendTelegramNotif(chatID, fmt.Sprintf("🔔 <b>Notification</b>\n\n%s", message))
} }
} }
@@ -87,11 +99,10 @@ func (d *Database) NotifyAllAdminCabine(commandID int, clientUsername, deliveryA
Redis.LPush(RedisCtx, notifKey, notifJSON) Redis.LPush(RedisCtx, notifKey, notifJSON)
Redis.Expire(RedisCtx, notifKey, 7*24*time.Hour) 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 { if chatID, ok, err := d.GetUserTelegramChatID(u.Username); err == nil && ok {
capturedChatID := chatID capturedChatID := chatID
capturedMsg := msg go sendTelegramNotif(capturedChatID, fmt.Sprintf("🔔 <b>Nouvelle commande</b>\n\n%s", msg))
go services.TelegramBot.SendMessage(capturedChatID, fmt.Sprintf("🔔 <b>Nouvelle commande</b>\n\n%s", capturedMsg))
} }
} }
count++ count++
@@ -126,11 +137,10 @@ func (d *Database) NotifyAllAdminCabineAlert(alertID int, livreurUsername, alert
Redis.LPush(RedisCtx, notifKey, notifJSON) Redis.LPush(RedisCtx, notifKey, notifJSON)
Redis.Expire(RedisCtx, notifKey, 7*24*time.Hour) 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 { if chatID, ok, err := d.GetUserTelegramChatID(u.Username); err == nil && ok {
capturedChatID := chatID capturedChatID := chatID
capturedBody := body go sendTelegramNotif(capturedChatID, fmt.Sprintf("🚨 <b>Alerte livreur</b>\n\n%s", body))
go services.TelegramBot.SendMessage(capturedChatID, fmt.Sprintf("🚨 <b>Alerte livreur</b>\n\n%s", capturedBody))
} }
} }
count++ count++
+13 -3
View File
@@ -3,7 +3,6 @@ package db
import ( import (
"fmt" "fmt"
"gestion/models" "gestion/models"
"log"
"gorm.io/gorm" "gorm.io/gorm"
) )
@@ -67,6 +66,17 @@ func (d *Database) ActivateCryptoCommand(commandID int) error {
func (d *Database) CancelCryptoCommand(commandID int) error { func (d *Database) CancelCryptoCommand(commandID int) error {
return d.GDB.Transaction(func(tx *gorm.DB) 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 { type item struct {
ProductID int ProductID int
Quantite float64 Quantite float64
@@ -77,9 +87,9 @@ func (d *Database) CancelCryptoCommand(commandID int) error {
} }
for _, it := range items { for _, it := range items {
if err := tx.Exec(`UPDATE products SET stock = stock + ? WHERE id = ?`, it.Quantite, it.ProductID).Error; err != nil { 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
}) })
} }
+64 -14
View File
@@ -52,10 +52,15 @@ func (d *Database) CreateProduct(product any) error {
UpdatedAt time.Time `gorm:"column:updated_at"` UpdatedAt time.Time `gorm:"column:updated_at"`
} }
comingSoonVal := false
if prodModel, ok2 := product.(*models.Product); ok2 {
comingSoonVal = prodModel.ComingSoon
}
err := d.GDB.Raw(` err := d.GDB.Raw(`
INSERT INTO products (name, category, description, stock, unit, created_at, updated_at) INSERT INTO products (name, category, description, stock, unit, coming_soon, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?) RETURNING id, created_at, updated_at`, VALUES (?, ?, ?, ?, ?, ?, ?, ?) RETURNING id, created_at, updated_at`,
p.GetName(), p.GetCategory(), p.GetDescription(), p.GetStock(), p.GetUnit(), now, now, p.GetName(), p.GetCategory(), p.GetDescription(), p.GetStock(), p.GetUnit(), comingSoonVal, now, now,
).Scan(&result).Error ).Scan(&result).Error
if err != nil { if err != nil {
log.Printf("❌ [DB CreateProduct] Erreur INSERT: %v", err) log.Printf("❌ [DB CreateProduct] Erreur INSERT: %v", err)
@@ -69,8 +74,8 @@ func (d *Database) CreateProduct(product any) error {
p.SetUpdatedAt(result.UpdatedAt) p.SetUpdatedAt(result.UpdatedAt)
for i, price := range p.GetPrices() { for i, price := range p.GetPrices() {
err := d.GDB.Exec(`INSERT INTO product_prices (product_id, quantity, price) VALUES (?, ?, ?)`, err := d.GDB.Exec(`INSERT INTO product_prices (product_id, quantity, price, active_price) VALUES (?, ?, ?, ?)`,
result.ID, price.Quantity, price.Price).Error result.ID, price.Quantity, price.Price, price.ActivePrice).Error
if err != nil { if err != nil {
log.Printf("❌ [DB CreateProduct] Erreur insertion prix[%d]: %v", i, err) log.Printf("❌ [DB CreateProduct] Erreur insertion prix[%d]: %v", i, err)
return fmt.Errorf("erreur insertion prix: %v", 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 var p models.Product
err := d.GDB.Raw(` 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 FROM products
WHERE id = ?`, id).Scan(&p).Error WHERE id = ?`, id).Scan(&p).Error
if err != nil { if err != nil {
@@ -110,12 +115,33 @@ func (d *Database) GetProductByID(id int) (models.Product, error) {
return p, nil 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) { func (d *Database) GetAllProducts() ([]models.Product, error) {
log.Println("📦 [GetAllProducts] START") log.Println("📦 [GetAllProducts] START")
var products []models.Product var products []models.Product
err := d.GDB.Raw(` 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 FROM products
ORDER BY id ASC`).Scan(&products).Error ORDER BY id ASC`).Scan(&products).Error
if err != nil { if err != nil {
@@ -153,7 +179,7 @@ func (d *Database) GetProductsByCategory(category string) ([]models.Product, err
var products []models.Product var products []models.Product
err := d.GDB.Raw(` 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 FROM products
WHERE category = ? WHERE category = ?
ORDER BY created_at DESC`, category).Scan(&products).Error 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 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(` err := d.GDB.Exec(`
UPDATE products UPDATE products
SET name = ?, category = ?, description = ?, stock = ?, unit = ?, updated_at = ? SET name = ?, category = ?, description = ?, unit = ?, coming_soon = ?, updated_at = ?
WHERE id = ?`, WHERE id = ?`,
name, category, description, stock, unit, time.Now(), productID).Error name, category, description, unit, comingSoon, time.Now(), productID).Error
if err != nil { if err != nil {
return fmt.Errorf("erreur mise à jour produit: %w", err) 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) d.GDB.Exec(`DELETE FROM product_prices WHERE product_id = ?`, productID)
for _, price := range prices { for _, price := range prices {
if err := d.GDB.Exec(`INSERT INTO product_prices (product_id, quantity, price) VALUES (?, ?, ?)`, if err := d.GDB.Exec(`INSERT INTO product_prices (product_id, quantity, price, active_price) VALUES (?, ?, ?, ?)`,
productID, price.Quantity, price.Price).Error; err != nil { productID, price.Quantity, price.Price, price.ActivePrice).Error; err != nil {
log.Printf("❌ [UpdateProduct] Erreur prix: %v", err) return fmt.Errorf("erreur insertion prix: %w", err)
} }
} }
return nil 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 // DeleteProduct supprime un produit
func (d *Database) DeleteProduct(productID int) error { func (d *Database) DeleteProduct(productID int) error {
result := d.GDB.Exec(`DELETE FROM products WHERE id = ?`, productID) result := d.GDB.Exec(`DELETE FROM products WHERE id = ?`, productID)
+11 -14
View File
@@ -13,19 +13,13 @@ func (d *Database) GetProductPrices(productID int) ([]models.ProductPrice, error
return prices, nil return prices, nil
} }
func (d *Database) CreateProductPrice(productID int, quantity float64, price float64) error { func (d *Database) AddActivePrice(priceID int) error {
p := models.ProductPrice{ProductID: productID, Quantity: quantity, Price: price} result := d.GDB.Model(&models.ProductPrice{}).
if err := d.GDB.Create(&p).Error; err != nil { Where("id = ?", priceID).
return fmt.Errorf("erreur création prix: %w", err) Update("active_price", true)
}
return nil
}
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 { 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 { if result.RowsAffected == 0 {
return fmt.Errorf("prix introuvable") return fmt.Errorf("prix introuvable")
@@ -33,10 +27,13 @@ func (d *Database) UpdateProductPrice(priceID int, quantity float64, price float
return nil return nil
} }
func (d *Database) DeleteProductPrice(priceID int) error { func (d *Database) DeActivePrice(priceID int) error {
result := d.GDB.Delete(&models.ProductPrice{}, priceID) result := d.GDB.Model(&models.ProductPrice{}).
Where("id = ?", priceID).
Update("active_price", false)
if result.Error != nil { 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 { if result.RowsAffected == 0 {
return fmt.Errorf("prix introuvable") return fmt.Errorf("prix introuvable")
-14
View File
@@ -58,17 +58,3 @@ func (d *Database) ResetClientReferralBalance(username string) error {
} }
return nil 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
}
+26
View File
@@ -66,6 +66,8 @@ func DefaultSettings() models.AppSettings {
}, },
}, },
}, },
ShopName: "Milieu-Nantais",
ContactTelegram: "MLN44LA",
DeliveryMode: models.DeliveryModeConfig{ DeliveryMode: models.DeliveryModeConfig{
Mode: "single", Mode: "single",
CategoryRoutes: []models.CategoryRoute{}, CategoryRoutes: []models.CategoryRoute{},
@@ -81,6 +83,7 @@ func DefaultSettings() models.AppSettings {
"44860", "44220", "44118", "44710", "44690", "44119", "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 { if err := json.Unmarshal([]byte(row.Value), &pools); err == nil {
settings.PointsPools = pools 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": case "referral_enabled":
settings.ReferralEnabled = row.Value == "true" settings.ReferralEnabled = row.Value == "true"
case "referral_amount": case "referral_amount":
@@ -138,6 +146,8 @@ func (d *Database) GetSettings() (models.AppSettings, error) {
if err := json.Unmarshal([]byte(row.Value), &zones); err == nil { if err := json.Unmarshal([]byte(row.Value), &zones); err == nil {
settings.PostalZones = zones settings.PostalZones = zones
} }
case "contact_telegram":
settings.ContactTelegram = row.Value
case "telegram_bot_token": case "telegram_bot_token":
settings.TelegramBotToken = row.Value settings.TelegramBotToken = row.Value
case "telegram_bot_username": 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 { if err := json.Unmarshal([]byte(row.Value), &mode); err == nil {
settings.DeliveryMode = mode settings.DeliveryMode = mode
} }
case "telegram_2fa_enabled":
settings.Telegram2FAEnabled = row.Value == "true"
case "shop_name":
settings.ShopName = row.Value
} }
} }
return settings, nil return settings, nil
@@ -180,6 +194,11 @@ func (d *Database) UpdateSettings(s models.AppSettings) error {
return fmt.Errorf("erreur sérialisation pools: %w", err) 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 { if s.NowPaymentsCurrencies == nil {
s.NowPaymentsCurrencies = []string{} 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) return fmt.Errorf("erreur sérialisation delivery_mode: %w", err)
} }
if s.ContactTelegram == "" {
s.ContactTelegram = "MLN44LA"
}
pairs := [][2]string{ pairs := [][2]string{
{"penalties_enabled", boolStr(s.PenaltiesEnabled)}, {"penalties_enabled", boolStr(s.PenaltiesEnabled)},
{"show_amende_score", boolStr(s.ShowAmendeScore)}, {"show_amende_score", boolStr(s.ShowAmendeScore)},
{"points_enabled", boolStr(s.PointsEnabled)}, {"points_enabled", boolStr(s.PointsEnabled)},
{"points_pools", string(poolsJSON)}, {"points_pools", string(poolsJSON)},
{"points_reward", string(rewardJSON)},
{"referral_enabled", boolStr(s.ReferralEnabled)}, {"referral_enabled", boolStr(s.ReferralEnabled)},
{"referral_amount", strconv.FormatFloat(s.ReferralAmount, 'f', 2, 64)}, {"referral_amount", strconv.FormatFloat(s.ReferralAmount, 'f', 2, 64)},
{"crypto_payment_enabled", boolStr(s.CryptoPaymentEnabled)}, {"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_token", s.TelegramBotToken},
{"telegram_bot_username", s.TelegramBotUsername}, {"telegram_bot_username", s.TelegramBotUsername},
{"telegram_notifications_enabled", boolStr(s.TelegramNotificationsEnabled)}, {"telegram_notifications_enabled", boolStr(s.TelegramNotificationsEnabled)},
{"telegram_2fa_enabled", boolStr(s.Telegram2FAEnabled)},
{"delivery_mode", string(deliveryModeJSON)}, {"delivery_mode", string(deliveryModeJSON)},
{"shop_name", s.ShopName},
{"contact_telegram", s.ContactTelegram},
} }
upsert := `INSERT INTO app_settings (key, value) VALUES (?, ?) upsert := `INSERT INTO app_settings (key, value) VALUES (?, ?)
+73
View File
@@ -15,6 +15,7 @@ func (d *Database) MigrateAddTelegramColumns() {
migrations := []string{ migrations := []string{
`ALTER TABLE clients ADD COLUMN IF NOT EXISTS telegram_chat_id BIGINT`, `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 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 { for _, q := range migrations {
if err := d.GDB.Exec(q).Error; err != nil { 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 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 // GetUserByTelegramChatID retrouve un utilisateur (clients + users) par chat_id
func (d *Database) GetUserByTelegramChatID(chatID int64) (username, role string, err error) { func (d *Database) GetUserByTelegramChatID(chatID int64) (username, role string, err error) {
var clientResult struct { 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") 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
}
-1
View File
@@ -2,7 +2,6 @@ package db
import "gorm.io/gorm" import "gorm.io/gorm"
// isNotFound retourne true si l'erreur GORM est un "record not found"
func isNotFound(err error) bool { func isNotFound(err error) bool {
return err == gorm.ErrRecordNotFound return err == gorm.ErrRecordNotFound
} }
@@ -67,57 +67,6 @@ func (d *Database) FindLeastLoadedDeliveryman() (string, error) {
return leastLoaded, nil 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) // GetLeastLoadedDeliverymanForced retourne le livreur avec le moins de commandes (SANS limite)
func (d *Database) GetLeastLoadedDeliverymanForced() (string, int64, error) { func (d *Database) GetLeastLoadedDeliverymanForced() (string, int64, error) {
activeUsernames, err := d.GetAllActiveDeliverymenUsernames() activeUsernames, err := d.GetAllActiveDeliverymenUsernames()
@@ -156,10 +156,6 @@ func (d *Database) CalculateETABetweenPoints(lat1, lng1, lat2, lng2 float64) int
return services.CalculateETA(distance) 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 { func (d *Database) SetCommandETAWithDetails(commandID, totalETA, queuePosition int) error {
key := fmt.Sprintf("command:eta:%d", commandID) key := fmt.Sprintf("command:eta:%d", commandID)
@@ -87,35 +87,6 @@ func (d *Database) AssignCommandToDeliverymanQueue(commandID int, deliveryman st
return nil 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 // 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 { func (d *Database) AssignCommandToDeliverymanQueueWithCoords(commandID int, deliveryman string, estimatedTravelTime int, lat, lng float64, address string) error {
command, err := d.GetCommandByID(commandID) command, err := d.GetCommandByID(commandID)
@@ -203,64 +203,3 @@ func (d *Database) StartQueueCleanupScheduler() {
} }
}() }()
} }
// ============================================
// RAPPORT DE VALIDATION
// ============================================
// GetQueueValidationReport génère un rapport de validation sans supprimer
func (d *Database) GetQueueValidationReport() (map[string]any, error) {
keys, err := Redis.Keys(RedisCtx, "queue:pending:*").Result()
if err != nil {
return nil, err
}
report := map[string]any{
"total_commands": len(keys),
"valid_commands": 0,
"invalid_commands": 0,
"invalid_details": []map[string]any{},
"validation_results": []string{},
}
for _, key := range keys {
data, err := Redis.Get(RedisCtx, key).Result()
if err != nil {
continue
}
var queueItem models.CommandQueue
if err := json.Unmarshal([]byte(data), &queueItem); err != nil {
report["invalid_commands"] = report["invalid_commands"].(int) + 1
continue
}
// Validation
issues := []string{}
if queueItem.Username == "" {
issues = append(issues, "username vide")
}
if queueItem.Address == "" {
issues = append(issues, "adresse vide")
}
if queueItem.Lat == 0 || queueItem.Lng == 0 {
issues = append(issues, "GPS manquant")
}
if queueItem.CreatedAt.IsZero() {
issues = append(issues, "date invalide")
}
if len(issues) > 0 {
report["invalid_commands"] = report["invalid_commands"].(int) + 1
report["invalid_details"] = append(report["invalid_details"].([]map[string]any), map[string]any{
"command_id": queueItem.CommandID,
"issues": issues,
"data": queueItem,
})
} else {
report["valid_commands"] = report["valid_commands"].(int) + 1
}
}
return report, nil
}
@@ -86,40 +86,6 @@ func (d *Database) CanDeliverymanAcceptCommands(deliveryman string) bool {
return true 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 // AddToDeliverymanQueue - VERSION MISE À JOUR avec auto-update du statut
func (d *Database) AddToDeliverymanQueue(deliveryman string, queueItem models.CommandQueue) error { func (d *Database) AddToDeliverymanQueue(deliveryman string, queueItem models.CommandQueue) error {
data, err := json.Marshal(queueItem) data, err := json.Marshal(queueItem)
@@ -150,111 +116,6 @@ func (d *Database) AddToDeliverymanQueue(deliveryman string, queueItem models.Co
return nil 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) // AddToGeneralQueue ajoute une commande à la queue générale (fallback)
func (d *Database) AddToGeneralQueue(queueItem models.CommandQueue) error { func (d *Database) AddToGeneralQueue(queueItem models.CommandQueue) error {
data, err := json.Marshal(queueItem) data, err := json.Marshal(queueItem)
@@ -355,109 +216,6 @@ func (d *Database) GetNextCommandInQueue() (*models.CommandQueue, error) {
return &queue, nil 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 { func (d *Database) SetDeliveryPersonStatus(username, status string, commandID int) error {
key := fmt.Sprintf("delivery:status:%s", username) 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. // iterDeliveryStatuses itère sur tous les statuts Redis des livreurs et appelle fn pour chacun.
func (d *Database) iterDeliveryStatuses(fn func(models.DeliveryPersonStatus)) error { func (d *Database) iterDeliveryStatuses(fn func(models.DeliveryPersonStatus)) error {
keys, err := Redis.Keys(RedisCtx, "delivery:status:*").Result() keys, err := Redis.Keys(RedisCtx, "delivery:status:*").Result()
@@ -288,10 +288,6 @@ func (d *Database) RecalculateQueueETAs(deliveryman string) error {
return nil return nil
} }
func (d *Database) UpdateQueueETAsAfterCompletion(deliveryman string) error {
return d.RecalculateQueueETAs(deliveryman)
}
// FindNearestCommandInQueue trouve la commande la plus proche du livreur // FindNearestCommandInQueue trouve la commande la plus proche du livreur
func (d *Database) FindNearestCommandInQueue(deliveryman string) (*models.CommandQueue, int, error) { func (d *Database) FindNearestCommandInQueue(deliveryman string) (*models.CommandQueue, int, error) {
livreurLat, livreurLng, err := d.GetDeliveryPersonLocation(deliveryman) livreurLat, livreurLng, err := d.GetDeliveryPersonLocation(deliveryman)
-219
View File
@@ -3,7 +3,6 @@ package db
import ( import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"gestion/models"
"log" "log"
"time" "time"
) )
@@ -183,221 +182,3 @@ func (d *Database) InvalidateSession(clientID int) error {
log.Printf("✅ [SESSION] Session invalidée pour client %d", clientID) log.Printf("✅ [SESSION] Session invalidée pour client %d", clientID)
return nil 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
}
+1 -1
View File
@@ -13,6 +13,7 @@ require (
github.com/lib/pq v1.10.9 github.com/lib/pq v1.10.9
github.com/redis/go-redis/v9 v9.17.0 github.com/redis/go-redis/v9 v9.17.0
golang.org/x/crypto v0.40.0 golang.org/x/crypto v0.40.0
golang.org/x/text v0.27.0
gorm.io/driver/postgres v1.6.0 gorm.io/driver/postgres v1.6.0
gorm.io/gorm v1.31.1 gorm.io/gorm v1.31.1
) )
@@ -55,7 +56,6 @@ require (
golang.org/x/net v0.42.0 // indirect golang.org/x/net v0.42.0 // indirect
golang.org/x/sync v0.16.0 // indirect golang.org/x/sync v0.16.0 // indirect
golang.org/x/sys v0.35.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 golang.org/x/tools v0.34.0 // indirect
google.golang.org/protobuf v1.36.9 // indirect google.golang.org/protobuf v1.36.9 // indirect
) )
+175 -87
View File
@@ -1,8 +1,11 @@
package handlers package handlers
import ( import (
"crypto/rand"
"fmt"
"gestion/db" "gestion/db"
"gestion/models" "gestion/models"
"gestion/services"
"gestion/utils" "gestion/utils"
"log" "log"
"net/http" "net/http"
@@ -178,6 +181,11 @@ func RegisterClient(c *gin.Context) {
// AdminCreateClient crée un client depuis l'interface admin (sans session ni token) // AdminCreateClient crée un client depuis l'interface admin (sans session ni token)
func AdminCreateClient(c *gin.Context) { 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 var req models.RegisterClientRequest
if err := c.ShouldBindJSON(&req); err != nil { 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) 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 // LoginClient authentifie un client
func LoginClient(c *gin.Context) { func LoginClient(c *gin.Context) {
database := c.MustGet("database").(*db.Database)
var req models.LoginRequest var req models.LoginRequest
if err := c.ShouldBindJSON(&req); err != nil { if err := c.ShouldBindJSON(&req); err != nil {
log.Printf("❌ [LOGIN_CLIENT] Erreur binding: %v", err) log.Printf("❌ [LOGIN_CLIENT] Erreur binding: %v", err)
@@ -251,8 +267,6 @@ func LoginClient(c *gin.Context) {
return return
} }
database := c.MustGet("database").(*db.Database)
client, err := database.GetClientByUsername(req.Username) client, err := database.GetClientByUsername(req.Username)
if err != nil || client == nil { if err != nil || client == nil {
log.Printf("❌ [LOGIN_CLIENT] Client non trouvé: %s", req.Username) log.Printf("❌ [LOGIN_CLIENT] Client non trouvé: %s", req.Username)
@@ -266,6 +280,33 @@ func LoginClient(c *gin.Context) {
return return
} }
settings, err := database.GetSettings()
if err != nil {
log.Printf("❌ [LOGIN_CLIENT] Erreur récupération des paramètres: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur interne"})
return
}
if settings.Telegram2FAEnabled && client.TwoFAEnabled {
chatID, linked, _ := database.GetClientTelegramChatID(client.Username)
if linked {
code := fmt.Sprintf("%06d", cryptoRandInt()%1000000)
sessionToken := uuid.New().String()
if err := db.Store2FASession(sessionToken, client.Username, code); err != nil {
log.Printf("❌ [2FA] Erreur stockage session Redis: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur interne"})
return
}
msg := fmt.Sprintf("🔐 Code de vérification : <b>%s</b>\n\nValable 5 minutes.", code)
services.TelegramBot.SendMessage(chatID, msg)
c.JSON(http.StatusOK, gin.H{
"requires_2fa": true,
"session_token": sessionToken,
})
return
}
}
token, err := generateClientToken(client) token, err := generateClientToken(client)
if err != nil { if err != nil {
log.Printf("❌ [LOGIN_CLIENT] Erreur génération token: %v", err) log.Printf("❌ [LOGIN_CLIENT] Erreur génération token: %v", err)
@@ -280,7 +321,6 @@ func LoginClient(c *gin.Context) {
return return
} }
// Créer la session Redis
sessionID := uuid.New().String() sessionID := uuid.New().String()
if err := database.CreateClientSession(client.ID, client.Username, sessionID); err != nil { if err := database.CreateClientSession(client.ID, client.Username, sessionID); err != nil {
log.Printf("⚠️ [LOGIN_CLIENT] Erreur session Redis: %v", err) 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 // ChangePassword permet à un client de changer son mot de passe
func ChangePassword(c *gin.Context) { func ChangePassword(c *gin.Context) {
var req struct { var req struct {
@@ -366,60 +525,6 @@ func LogoutClient(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"message": "Déconnexion réussie"}) 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 // LoginAdmin authentifie un admin/cabine/livreur
func LoginAdmin(c *gin.Context) { func LoginAdmin(c *gin.Context) {
var req models.LoginRequest 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) // GetAllUsers récupère tous les utilisateurs (Admin only)
func GetAllUsers(c *gin.Context) { func GetAllUsers(c *gin.Context) {
database := c.MustGet("database").(*db.Database) 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"}) c.JSON(http.StatusBadRequest, gin.H{"error": "Erreur de liaison JSON"})
return return
} }
userRole := c.GetString("role") if c.GetString("role") != "admin" {
if userRole != "cabine" && userRole != "admin" { c.JSON(http.StatusForbidden, gin.H{"error": "Seul un administrateur peut créer des utilisateurs"})
c.JSON(http.StatusForbidden, gin.H{"error": "Accès réservé aux livreurs et aux administrateurs"})
return return
} }
err := database.CreateUser(&user) if user.Role == "admin" {
if err != nil { 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) log.Printf("❌ [CREATE_USER] Erreur: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur création"}) c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur création"})
return 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éé"}) c.JSON(http.StatusCreated, gin.H{"message": "Utilisateur créé"})
} }
-504
View File
@@ -1,244 +1,13 @@
// ============================================
// handlers/cabine_handlers.go - COMPLET
// INCLUT: SetCommandDestinationCoordinates
// ============================================
package handlers package handlers
import ( import (
"encoding/json"
"fmt"
"gestion/db" "gestion/db"
"gestion/utils"
"log"
"net/http" "net/http"
"slices"
"strconv" "strconv"
"time"
"github.com/gin-gonic/gin" "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) { func GetLivreurPosition(c *gin.Context) {
database := c.MustGet("database").(*db.Database) database := c.MustGet("database").(*db.Database)
livreurUsername := c.Param("username") 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) { func GetDeliveryIssues(c *gin.Context) {
database := c.MustGet("database").(*db.Database) database := c.MustGet("database").(*db.Database)
@@ -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) { func GetCommandLogs(c *gin.Context) {
database := c.MustGet("database").(*db.Database) database := c.MustGet("database").(*db.Database)
@@ -543,127 +163,3 @@ func GetCommandLogs(c *gin.Context) {
"count": len(logs), "count": len(logs),
}) })
} }
// ============================================
// 7. FORCE VALIDATE DELIVERY
// ============================================
func ForceValidateDelivery(c *gin.Context) {
database := c.MustGet("database").(*db.Database)
userRole := c.GetString("role")
if userRole != "admin" {
c.JSON(http.StatusForbidden, gin.H{"error": "Accès refusé - Admin seulement"})
return
}
adminUsername, _ := c.Get("username")
commandID, err := strconv.Atoi(c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "ID de commande invalide"})
return
}
var req struct {
Reason string `json:"reason" binding:"required"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{
"error": "Raison requise pour validation forcée",
"example": gin.H{
"reason": "Client confirmé par téléphone",
},
})
return
}
if req.Reason == "" {
c.JSON(http.StatusBadRequest, gin.H{
"error": "Veuillez fournir une raison pour la validation forcée",
})
return
}
command, err := database.GetCommandByID(commandID)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{
"error": "Commande non trouvée",
"command_id": commandID,
})
return
}
status, ok := command["status"].(string)
if !ok {
c.JSON(http.StatusBadRequest, gin.H{"error": "Statut de commande invalide"})
return
}
if status == "livre" {
c.JSON(http.StatusBadRequest, gin.H{
"error": "Cette commande a déjà été validée",
"current_status": status,
})
return
}
validStatuses := []string{"assigned", "en_route", "pending", "priority"}
if !slices.Contains(validStatuses, status) {
c.JSON(http.StatusBadRequest, gin.H{
"error": "Commande ne peut pas être validée de force dans ce statut",
"current_status": status,
"valid_statuses": validStatuses,
})
return
}
err = database.UpdateCommandStatus(commandID, "livre")
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{
"error": "Erreur lors de la validation forcée",
})
return
}
clientUsername, _ := command["username"].(string)
livreurAssign, _ := command["livreur_assign"].(string)
if clientUsername != "" {
clientMsg := fmt.Sprintf("Ta commande #%d a bien été livrée ! Bonne dégustation l'ami et à bientôt 😊\n\n<b>⚠️ VALIDE LA RÉCEPTION DE TA COMMANDE DANS LA RUBRIQUE SUIVI POUR RÉCUPÉRER TES POINTS DE FIDÉLITÉ ⚠️</b>", database.GetClientOrderID(commandID))
database.NotifyClient(clientUsername, commandID, "livre", clientMsg)
}
if err := database.IncrementClientCommandCount(clientUsername); err != nil {
log.Printf("⚠️ Erreur compteur commandes: %v", err)
}
if err := database.AddClientPointsByCategory(clientUsername, 10, ""); err != nil {
log.Printf("⚠️ Erreur ajout points: %v", err)
}
if livreurAssign != "" {
err := database.CompleteDeliveryAndProcessNext(livreurAssign, commandID)
if err != nil {
log.Printf("⚠️ Erreur optimisation: %v", err)
}
}
message := fmt.Sprintf("VALIDATION FORCÉE par admin %s - Raison: %s", adminUsername.(string), req.Reason)
database.AddCommandLog(commandID, "livre", message, adminUsername.(string))
log.Printf("🔴 Commande %d validée de force par %s - Raison: %s", commandID, adminUsername.(string), req.Reason)
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "Livraison validée de force (sans vérification GPS)",
"command_id": commandID,
"validation_type": "forced",
"reason": req.Reason,
"validated_by": adminUsername.(string),
"new_status": "livre",
"points_awarded": 10,
"queue_optimized": livreurAssign != "",
})
}
+6 -16
View File
@@ -1,9 +1,3 @@
// ============================================
// handlers/cancel_command_handler.go
// ANNULATION DE COMMANDES AVEC SANCTIONS ÉVOLUTIVES
// VERSION SÉCURISÉE - FIX ETA CHECK
// ============================================
package handlers package handlers
import ( import (
@@ -218,9 +212,6 @@ func CancelCommandByClient(c *gin.Context) {
return return
} }
// ============================================
// SUCCÈS
// ============================================
log.Printf("✅ [CANCEL_CLIENT] Commande %d annulée", commandID) log.Printf("✅ [CANCEL_CLIENT] Commande %d annulée", commandID)
response := gin.H{ response := gin.H{
@@ -242,10 +233,6 @@ func CancelCommandByClient(c *gin.Context) {
c.JSON(http.StatusOK, response) c.JSON(http.StatusOK, response)
} }
// ============================================
// HISTORIQUE DES ANNULATIONS - VERSION SÉCURISÉE
// ============================================
func GetMyCancellationHistory(c *gin.Context) { func GetMyCancellationHistory(c *gin.Context) {
database := c.MustGet("database").(*db.Database) database := c.MustGet("database").(*db.Database)
@@ -266,9 +253,12 @@ func GetMyCancellationHistory(c *gin.Context) {
return return
} }
var totalPenalty int var penaltyResult struct {
penaltyQuery := `SELECT COALESCE(amende, 0) FROM clients WHERE username = $1` Amende int `gorm:"column:amende"`
database.QueryRow(penaltyQuery).Scan(&totalPenalty) }
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{ c.JSON(http.StatusOK, gin.H{
"success": true, "success": true,
@@ -123,6 +123,7 @@ func GetMyCommandsWithTracking(c *gin.Context) {
"status_message": getStatusMessage(cmd["status"].(string)), "status_message": getStatusMessage(cmd["status"].(string)),
"adresse": cmd["adresse"], "adresse": cmd["adresse"],
"total_prix": cmd["total_prix"], "total_prix": cmd["total_prix"],
"referral_used": cmd["referral_used"],
"created_at": cmd["created_at"], "created_at": cmd["created_at"],
"livreur": livreurInfo, "livreur": livreurInfo,
"eta": etaData, "eta": etaData,
+13 -11
View File
@@ -595,10 +595,6 @@ func ValidateDelivery(c *gin.Context) {
}) })
} }
// ============================================
// GESTION ADMIN
// ============================================
// GetAvailableDeliveryPersons récupère les livreurs disponibles // GetAvailableDeliveryPersons récupère les livreurs disponibles
// GET /api/v1/admin/delivery-persons/available // GET /api/v1/admin/delivery-persons/available
func GetAvailableDeliveryPersons(c *gin.Context) { func GetAvailableDeliveryPersons(c *gin.Context) {
@@ -778,13 +774,6 @@ func GetClientCommandsHistory(c *gin.Context) {
c.JSON(http.StatusOK, resp) 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) { func NotifyClientToDescend(c *gin.Context) {
database := c.MustGet("database").(*db.Database) database := c.MustGet("database").(*db.Database)
@@ -1119,6 +1108,19 @@ func UpdateCommandStatusAdmin(c *gin.Context) {
return 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 { if err := database.UpdateCommandStatus(commandID, req.Status); err != nil {
utils.ServerErr(c, "Impossible de mettre à jour le statut", err) utils.ServerErr(c, "Impossible de mettre à jour le statut", err)
return return
+144 -1
View File
@@ -9,6 +9,7 @@ import (
"net/http" "net/http"
"slices" "slices"
"strconv" "strconv"
"time"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
) )
@@ -61,6 +62,7 @@ func GetMyDeliveries(c *gin.Context) {
"produit": item["produit"], "produit": item["produit"],
"quantite": item["quantite"], "quantite": item["quantite"],
"prix": item["prix"], "prix": item["prix"],
"is_reward": item["is_reward"],
} }
} }
@@ -71,6 +73,7 @@ func GetMyDeliveries(c *gin.Context) {
"status": cmd["status"], "status": cmd["status"],
"adresse": cmd["adresse"], "adresse": cmd["adresse"],
"total_prix": cmd["total_prix"], "total_prix": cmd["total_prix"],
"referral_used": cmd["referral_used"],
"created_at": cmd["created_at"], "created_at": cmd["created_at"],
"client_info": clientInfo, "client_info": clientInfo,
"items": itemsSummary, "items": itemsSummary,
@@ -263,6 +266,26 @@ func UpdateDeliveryStatus(c *gin.Context) {
return 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 // ✅ SI PASSAGE EN "EN_ROUTE" → CALCULER ET DÉFINIR L'ETA
var etaMinutes int var etaMinutes int
var etaMessage string var etaMessage string
@@ -392,8 +415,12 @@ func UpdateDeliveryStatus(c *gin.Context) {
database.CompleteDeliveryAndProcessNext(usernameStr, commandID) database.CompleteDeliveryAndProcessNext(usernameStr, commandID)
case "cancelled": case "cancelled":
// Annulation par le livreur - Nettoyer la queue
log.Printf("🚫 Livraison annulée par livreur - Nettoyage queue cmd %d", commandID) 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) database.CompleteDeliveryAndProcessNext(usernameStr, commandID)
case "arrived": 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) log.Printf("📋 [ISSUE] Créé par %s pour commande #%d: %s", username, commandID, req.IssueType)
c.JSON(http.StatusCreated, gin.H{"success": true, "issue": issue}) 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,
})
}
+2 -25
View File
@@ -11,6 +11,7 @@ import (
"gestion/utils" "gestion/utils"
"log" "log"
"net/http" "net/http"
"slices"
"strconv" "strconv"
"time" "time"
@@ -121,17 +122,9 @@ func UpdateDeliveryPersonStatusAdmin(c *gin.Context) {
return return
} }
// Valider le statut
validStatuses := []string{"available", "busy", "offline"} 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{ c.JSON(http.StatusBadRequest, gin.H{
"error": "Statut invalide", "error": "Statut invalide",
"valid_statuses": validStatuses, "valid_statuses": validStatuses,
@@ -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) { func RemoveCommandFromQueue(c *gin.Context) {
database := c.MustGet("database").(*db.Database) 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) 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) livreur, err := database.GetUserByUsername(username)
if err != nil { if err != nil {
log.Printf("❌ [REMOVE_FROM_QUEUE] Livreur non trouvé") log.Printf("❌ [REMOVE_FROM_QUEUE] Livreur non trouvé")
@@ -491,9 +475,6 @@ func RemoveCommandFromQueue(c *gin.Context) {
return return
} }
// ============================================
// Vérifier que la commande existe
// ============================================
command, err := database.GetCommandByID(commandID) command, err := database.GetCommandByID(commandID)
if err != nil { if err != nil {
log.Printf("❌ [REMOVE_FROM_QUEUE] Commande non trouvée") log.Printf("❌ [REMOVE_FROM_QUEUE] Commande non trouvée")
@@ -501,9 +482,6 @@ func RemoveCommandFromQueue(c *gin.Context) {
return return
} }
// ============================================
// Retirer de la queue
// ============================================
err = database.RemoveCommandFromDeliverymanQueue(username, commandID) err = database.RemoveCommandFromDeliverymanQueue(username, commandID)
if err != nil { if err != nil {
log.Printf("❌ [REMOVE_FROM_QUEUE] Erreur suppression: %v", err) log.Printf("❌ [REMOVE_FROM_QUEUE] Erreur suppression: %v", err)
@@ -513,7 +491,6 @@ func RemoveCommandFromQueue(c *gin.Context) {
return return
} }
// Optionnel: Réassigner la commande en "pending"
currentStatus, _ := command["status"].(string) currentStatus, _ := command["status"].(string)
if currentStatus == "assigned" || currentStatus == "en_route" { if currentStatus == "assigned" || currentStatus == "en_route" {
err = database.UpdateCommandStatus(commandID, "pending") err = database.UpdateCommandStatus(commandID, "pending")
+18 -10
View File
@@ -1,8 +1,3 @@
// ============================================
// handlers/eta_handler_corrected.go
// CORRECTION: ETA visible UNIQUEMENT après en_route
// ============================================
package handlers package handlers
import ( import (
@@ -98,19 +93,32 @@ func GetOrderETA(c *gin.Context) {
return return
} }
// Pour pending: aucune estimation disponible // Pour pending/assigned: pas encore de position livreur disponible
if cmdStatus == "pending" { if cmdStatus == "pending" || cmdStatus == "assigned" {
log.Printf("⏳ [ETA] Commande en attente d'assignation - pas d'ETA") log.Printf("⏳ [ETA] Commande %s - pas d'ETA disponible", cmdStatus)
c.JSON(http.StatusOK, gin.H{ c.JSON(http.StatusOK, gin.H{
"success": true, "success": true,
"command_id": commandID, "command_id": commandID,
"status": cmdStatus, "status": cmdStatus,
"eta_available": false, "eta_available": false,
"message": "En attente d'assignation d'un livreur", "message": "En attente de démarrage de la livraison",
}) })
return 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 // 6️⃣ VÉRIFIER LE CACHE REDIS POUR ETA
etaKey := fmt.Sprintf("command:eta:%d", commandID) etaKey := fmt.Sprintf("command:eta:%d", commandID)
+22 -26
View File
@@ -1,7 +1,3 @@
// ============================================
// handlers/geo_handlers.go - VERSION CORRIGÉE COMPLÈTE
// ============================================
package handlers package handlers
import ( import (
@@ -17,10 +13,6 @@ import (
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
) )
// ============================================
// GÉOCODAGE D'ADRESSES
// ============================================
func GeocodeAddress(c *gin.Context) { func GeocodeAddress(c *gin.Context) {
geoService := c.MustGet("geoService").(*services.GeoService) geoService := c.MustGet("geoService").(*services.GeoService)
@@ -34,7 +26,22 @@ func GeocodeAddress(c *gin.Context) {
location, err := geoService.GeocodeAddress(req.Address) location, err := geoService.GeocodeAddress(req.Address)
if err != nil { 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 return
} }
@@ -44,9 +51,15 @@ func GeocodeAddress(c *gin.Context) {
"latitude": location.Latitude, "latitude": location.Latitude,
"longitude": location.Longitude, "longitude": location.Longitude,
"display_name": location.DisplayName, "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 // FindNearestDeliveryPerson trouve le livreur le plus proche d'une adresse
func FindNearestDeliveryPerson(c *gin.Context) { func FindNearestDeliveryPerson(c *gin.Context) {
database := c.MustGet("database").(*db.Database) 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) 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) destCacheKey := fmt.Sprintf("command:destination:%d", commandID)
coordsJSON, _ := json.Marshal(map[string]float64{ coordsJSON, _ := json.Marshal(map[string]float64{
"lat": location.Latitude, "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 // AutoAssignAllPendingCommands assigne toutes les commandes en attente
// POST /api/v2/admin/protected/commands/auto-assign-all // POST /api/v2/admin/protected/commands/auto-assign-all
func AutoAssignAllPendingCommands(c *gin.Context) { 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 // GetAllDeliveryQueues retourne l'état de toutes les queues des livreurs
// GET /api/v2/admin/protected/delivery/queues // GET /api/v2/admin/protected/delivery/queues
func GetAllDeliveryQueues(c *gin.Context) { 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) { func GetDeliverymanQueue(c *gin.Context) {
database := c.MustGet("database").(*db.Database) database := c.MustGet("database").(*db.Database)
-51
View File
@@ -13,7 +13,6 @@ import (
"net/url" "net/url"
"strconv" "strconv"
"github.com/gin-gonic/gin" "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é // GetLivreurNavLink retourne le lien Waze App pour une livraison assignée au livreur connecté
// GET /api/v1/livreur/deliveries/:id/nav-link // GET /api/v1/livreur/deliveries/:id/nav-link
func GetLivreurNavLink(c *gin.Context) { func GetLivreurNavLink(c *gin.Context) {
+3 -19
View File
@@ -1,8 +1,3 @@
// ============================================
// handlers/history_handlers.go
// ============================================
// Gestion de l'historique des commandes terminées
package handlers package handlers
import ( import (
@@ -12,14 +7,9 @@ import (
"net/http" "net/http"
"strconv" "strconv"
"github.com/gin-gonic/gin" "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) { func GetMyCompletedOrders(c *gin.Context) {
database := c.MustGet("database").(*db.Database) database := c.MustGet("database").(*db.Database)
@@ -96,8 +86,6 @@ func GetMyCompletedOrders(c *gin.Context) {
// GetMyCompletedOrdersWithItems récupère l'historique avec les détails des items // GetMyCompletedOrdersWithItems récupère l'historique avec les détails des items
// GET /api/v1/my-commands/history/detailed // GET /api/v1/my-commands/history/detailed
// ✅ Authentification requise (ClientMiddleware)
// ✅ Retourne les commandes approved avec tous les items
func GetMyCompletedOrdersWithItems(c *gin.Context) { func GetMyCompletedOrdersWithItems(c *gin.Context) {
database := c.MustGet("database").(*db.Database) database := c.MustGet("database").(*db.Database)
@@ -125,7 +113,7 @@ func GetMyCompletedOrdersWithItems(c *gin.Context) {
} }
// ✅ Enrichir chaque commande avec ses items // ✅ Enrichir chaque commande avec ses items
var enrichedCommands []map[string]interface{} var enrichedCommands []map[string]any
for _, command := range commands { for _, command := range commands {
commandID, _ := strconv.Atoi(fmt.Sprintf("%v", command["id"])) commandID, _ := strconv.Atoi(fmt.Sprintf("%v", command["id"]))
@@ -137,11 +125,11 @@ func GetMyCompletedOrdersWithItems(c *gin.Context) {
items, err := database.GetCommandItems(commandID) items, err := database.GetCommandItems(commandID)
if err != nil { if err != nil {
log.Printf("⚠️ [HISTORY_DETAILED] Erreur items pour cmd %d: %v", commandID, err) 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 // Ajouter les items à la commande
enrichedCommand := make(map[string]interface{}) enrichedCommand := make(map[string]any)
for k, v := range command { for k, v := range command {
enrichedCommand[k] = v enrichedCommand[k] = v
} }
@@ -177,10 +165,6 @@ func GetMyCompletedOrdersWithItems(c *gin.Context) {
c.JSON(http.StatusOK, response) 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) { func GetOrderHistory(c *gin.Context) {
database := c.MustGet("database").(*db.Database) database := c.MustGet("database").(*db.Database)
+75 -49
View File
@@ -1,7 +1,3 @@
// ============================================
// handlers/basket_handlers_CORRIGES.go
// ============================================
package handlers package handlers
import ( import (
@@ -12,6 +8,8 @@ import (
"gestion/utils" "gestion/utils"
"log" "log"
"net/http" "net/http"
"strings"
"time"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
) )
@@ -48,41 +46,38 @@ func AddProductsBasket(c *gin.Context) {
c.JSON(http.StatusBadRequest, gin.H{"error": "Quantité invalide"}) c.JSON(http.StatusBadRequest, gin.H{"error": "Quantité invalide"})
return return
} }
if req.ProductID <= 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "product_id requis"})
return
}
// Si product_id fourni par le mobile, on l'utilise directement (plus fiable) if p, err := database.GetProductByID(req.ProductID); err == nil && p.ComingSoon {
if req.ProductID > 0 || req.NameProduct == "" || req.Category == "" { c.JSON(http.StatusBadRequest, gin.H{"error": "Ce produit n'est pas encore disponible"})
stock, err := database.GetProductStockByID(req.ProductID) return
}
panier, err := database.AddToBasket(req.Username, req.ProductID, req.Quantity)
if err != nil { if err != nil {
log.Printf("❌ [ADD_PANIER] Produit %d non trouvé: %v", req.ProductID, err) log.Printf("❌ [ADD_PANIER] product_id=%d qty=%.3f: %v", req.ProductID, req.Quantity, err)
c.JSON(http.StatusNotFound, gin.H{"error": "Produit non trouvé"}) if err.Error() == "stock insuffisant" {
c.JSON(http.StatusBadRequest, gin.H{"error": "Stock insuffisant"})
return return
} }
if stock < req.Quantity { if strings.Contains(err.Error(), "prix introuvable") {
c.JSON(http.StatusBadRequest, gin.H{"error": "Stock insuffisant", "available": stock}) c.JSON(http.StatusBadRequest, gin.H{"error": "Aucun prix configuré pour ce produit"})
return 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) utils.ServerErr(c, "Impossible d'ajouter le produit au panier", err)
return return
} }
c.JSON(http.StatusOK, gin.H{"success": true, "message": "Produit ajouté au panier avec succès", "panier": panier}) c.JSON(http.StatusOK, gin.H{
return "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) { func GetAllBaskets(c *gin.Context) {
database := c.MustGet("database").(*db.Database) database := c.MustGet("database").(*db.Database)
username := c.Param("username") 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) { func DeleteProductFromBasket(c *gin.Context) {
database := c.MustGet("database").(*db.Database) database := c.MustGet("database").(*db.Database)
@@ -209,15 +199,9 @@ func DeleteProductFromBasket(c *gin.Context) {
"success": true, "success": true,
"message": "Produit supprimé du panier avec succès", "message": "Produit supprimé du panier avec succès",
"item_id": req.ID, "item_id": req.ID,
"stock_released": true,
}) })
} }
// ============================================
// ✅ SÉCURISÉ: ClearBasket
// ============================================
// DELETE /api/v1/panier/clear
// Vide le panier du client
func ClearBasket(c *gin.Context) { func ClearBasket(c *gin.Context) {
database := c.MustGet("database").(*db.Database) database := c.MustGet("database").(*db.Database)
@@ -252,7 +236,6 @@ func ClearBasket(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{ c.JSON(http.StatusOK, gin.H{
"success": true, "success": true,
"message": "Panier vidé avec succès", "message": "Panier vidé avec succès",
"stock_released": len(baskets),
}) })
} }
@@ -268,6 +251,14 @@ func ValidateBasket(c *gin.Context) {
} }
usernameStr := username.(string) 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 { var req struct {
DeliveryAddress string `json:"delivery_address" binding:"required"` DeliveryAddress string `json:"delivery_address" binding:"required"`
UseReferralBalance bool `json:"use_referral_balance"` UseReferralBalance bool `json:"use_referral_balance"`
@@ -314,7 +305,7 @@ func ValidateBasket(c *gin.Context) {
log.Printf("🛒 [CHECKOUT] Panier: %d articles", len(items)) 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 var cartTotal float64
for _, item := range items { 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) // Récupérer les paramètres globaux (zones + parrainage)
appSettings, _ := database.GetSettings() 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) 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 referralUsed > 0 {
if err := database.DebitReferralBalance(usernameStr, referralUsed); err != nil { if err := database.DebitReferralBalance(usernameStr, referralUsed); err != nil {
log.Printf("❌ [CHECKOUT] Solde parrainage insuffisant pour %s: %v", usernameStr, err) 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) 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 // Vérification option crypto
isCrypto := req.PaymentMethod == "crypto" isCrypto := req.PaymentMethod == "crypto"
if isCrypto { if isCrypto {
@@ -429,9 +447,7 @@ func ValidateBasket(c *gin.Context) {
log.Printf("✅ [CHECKOUT] Commande %d créée", commandID) log.Printf("✅ [CHECKOUT] Commande %d créée", commandID)
// ============================================ // Pour le paiement crypto, le panier/stock sera décrémenté à la confirmation du webhook NowPayments.
// PAIEMENT CRYPTO - créer le paiement NowPayments
// ============================================
if isCrypto { if isCrypto {
np := c.MustGet("nowpayments").(*services.NowPaymentsClient) np := c.MustGet("nowpayments").(*services.NowPaymentsClient)
ipnURL := fmt.Sprintf("%s/api/v1/webhooks/nowpayments", getBaseURL(c)) 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) 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) err = database.ClearBasketOnCheckout(usernameStr)
if err != nil { 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 return
} }
log.Printf("🧹 [CHECKOUT] Panier vidé") utils.ServerErr(c, "Impossible de valider le panier", err)
return
}
log.Printf("🧹 [CHECKOUT] Stock décrémenté et panier vidé")
// ============================================ // ============================================
// 4️⃣ Auto-assignation livreur (optionnel) // 4️⃣ Auto-assignation livreur (optionnel)
+254
View File
@@ -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})
}
+179 -53
View File
@@ -17,10 +17,6 @@ import (
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
) )
// ============================================
// CONFIGURATION & LIMITES
// ============================================
const ( const (
MaxFileSize = 10 * 1024 * 1024 // 10MB par fichier MaxFileSize = 10 * 1024 * 1024 // 10MB par fichier
MaxTotalUploadSize = 50 * 1024 * 1024 // 50MB total MaxTotalUploadSize = 50 * 1024 * 1024 // 50MB total
@@ -30,7 +26,6 @@ const (
MaxProductsPerUser = 100 // Limite pour éviter spam MaxProductsPerUser = 100 // Limite pour éviter spam
) )
// ✅ MIME types autorisés (vérification réelle du contenu)
var allowedMimeTypes = map[string]bool{ var allowedMimeTypes = map[string]bool{
"image/jpeg": true, "image/jpeg": true,
"image/png": true, "image/png": true,
@@ -41,28 +36,6 @@ var allowedMimeTypes = map[string]bool{
"video/quicktime": true, "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 { func validateProductName(name string) error {
if len(name) == 0 { if len(name) == 0 {
return fmt.Errorf("nom requis") return fmt.Errorf("nom requis")
@@ -187,10 +160,6 @@ func sanitizeFilePath(path string) (string, error) {
return cleaned, nil return cleaned, nil
} }
// ============================================
// CREATE PRODUCT - VERSION SÉCURISÉE
// ============================================
func CreateProduct(c *gin.Context) { func CreateProduct(c *gin.Context) {
database := c.MustGet("database").(*db.Database) database := c.MustGet("database").(*db.Database)
@@ -269,6 +238,7 @@ func CreateProduct(c *gin.Context) {
for priceIndex < 100 { // Limite anti-spam for priceIndex < 100 { // Limite anti-spam
quantityKey := fmt.Sprintf("prices[%d][quantity]", priceIndex) quantityKey := fmt.Sprintf("prices[%d][quantity]", priceIndex)
priceKey := fmt.Sprintf("prices[%d][price]", priceIndex) priceKey := fmt.Sprintf("prices[%d][price]", priceIndex)
activePriceKey := fmt.Sprintf("prices[%d][active_price]", priceIndex)
quantityStr := c.PostForm(quantityKey) quantityStr := c.PostForm(quantityKey)
priceStr := c.PostForm(priceKey) priceStr := c.PostForm(priceKey)
@@ -294,9 +264,13 @@ func CreateProduct(c *gin.Context) {
return return
} }
activePriceStr := c.PostForm(activePriceKey)
activePrice := activePriceStr != "false"
prices = append(prices, models.ProductPrice{ prices = append(prices, models.ProductPrice{
Quantity: quantity, Quantity: quantity,
Price: price, Price: price,
ActivePrice: activePrice,
}) })
priceIndex++ priceIndex++
@@ -309,6 +283,8 @@ func CreateProduct(c *gin.Context) {
log.Printf("✅ [CreateProduct] %s crée produit: %s", username, name) log.Printf("✅ [CreateProduct] %s crée produit: %s", username, name)
comingSoon := c.PostForm("coming_soon") == "true"
// ✅ CRÉER LE PRODUIT // ✅ CRÉER LE PRODUIT
product := models.Product{ product := models.Product{
Name: name, Name: name,
@@ -316,6 +292,7 @@ func CreateProduct(c *gin.Context) {
Description: description, Description: description,
Stock: stock, Stock: stock,
Unit: unit, Unit: unit,
ComingSoon: comingSoon,
Prices: prices, Prices: prices,
} }
@@ -415,7 +392,7 @@ func CreateProduct(c *gin.Context) {
// ✅ CRÉER LE DOSSIER DE MANIÈRE SÉCURISÉE // ✅ CRÉER LE DOSSIER DE MANIÈRE SÉCURISÉE
destFolder := filepath.Join("uploads", mediaType+"s") 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) log.Printf("❌ [CreateProduct] Erreur création dossier: %v", err)
rollbackFiles(savedFiles) rollbackFiles(savedFiles)
database.DeleteProduct(product.ID) 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) { func GetAllProducts(c *gin.Context) {
database := c.MustGet("database").(*db.Database) database := c.MustGet("database").(*db.Database)
@@ -491,7 +464,10 @@ func GetAllProducts(c *gin.Context) {
}) })
return return
} }
role := c.GetString("role")
if role != "admin" && role != "cabine" {
products = filterActivePrices(products)
}
c.JSON(http.StatusOK, gin.H{ c.JSON(http.StatusOK, gin.H{
"success": true, "success": true,
"data": products, "data": products,
@@ -528,7 +504,10 @@ func GetProductsByCategory(c *gin.Context) {
media, _ := database.GetMediaByProductID(products[i].ID) media, _ := database.GetMediaByProductID(products[i].ID)
products[i].Media = media products[i].Media = media
} }
roleCtx := c.GetString("role")
if roleCtx != "admin" && roleCtx != "cabine" {
products = filterActivePrices(products)
}
c.JSON(http.StatusOK, gin.H{ c.JSON(http.StatusOK, gin.H{
"success": true, "success": true,
"data": products, "data": products,
@@ -538,7 +517,6 @@ func GetProductsByCategory(c *gin.Context) {
func GetProductByID(c *gin.Context) { func GetProductByID(c *gin.Context) {
database := c.MustGet("database").(*db.Database) database := c.MustGet("database").(*db.Database)
id, err := strconv.Atoi(c.Param("id")) id, err := strconv.Atoi(c.Param("id"))
if err != nil || id <= 0 { if err != nil || id <= 0 {
c.JSON(http.StatusBadRequest, gin.H{ c.JSON(http.StatusBadRequest, gin.H{
@@ -547,7 +525,6 @@ func GetProductByID(c *gin.Context) {
}) })
return return
} }
product, err := database.GetProductByID(id) product, err := database.GetProductByID(id)
if err != nil { if err != nil {
c.JSON(http.StatusNotFound, gin.H{ c.JSON(http.StatusNotFound, gin.H{
@@ -556,21 +533,22 @@ func GetProductByID(c *gin.Context) {
}) })
return return
} }
// ✅ Charger les médias // ✅ Charger les médias
media, _ := database.GetMediaByProductID(product.ID) media, _ := database.GetMediaByProductID(product.ID)
product.Media = media 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{ c.JSON(http.StatusOK, gin.H{
"success": true, "success": true,
"data": product, "data": product,
}) })
} }
// ============================================
// UPDATE PRODUCT - VERSION SÉCURISÉE
// ============================================
func UpdateProduct(c *gin.Context) { func UpdateProduct(c *gin.Context) {
database := c.MustGet("database").(*db.Database) database := c.MustGet("database").(*db.Database)
@@ -600,9 +578,10 @@ func UpdateProduct(c *gin.Context) {
Name string `json:"name"` Name string `json:"name"`
Category string `json:"category"` Category string `json:"category"`
Description string `json:"description"` Description string `json:"description"`
Stock float64 `json:"stock"`
Unit string `json:"unit"` Unit string `json:"unit"`
Prices []models.ProductPrice `json:"prices"` Prices []models.ProductPrice `json:"prices"`
Stock *float64 `json:"stock"`
ComingSoon *bool `json:"coming_soon"`
} }
if err := c.ShouldBindJSON(&updateData); err != nil { if err := c.ShouldBindJSON(&updateData); err != nil {
@@ -629,12 +608,8 @@ func UpdateProduct(c *gin.Context) {
if updateData.Unit == "" { if updateData.Unit == "" {
updateData.Unit = "u" 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()}) c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return 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) 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) log.Printf("❌ [UpdateProduct] Erreur UPDATE: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur mise à jour"}) c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur mise à jour"})
return 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 // ✅ RÉCUPÉRER LE PRODUIT MIS À JOUR
updatedProduct, _ := database.GetProductByID(id) updatedProduct, _ := database.GetProductByID(id)
media, _ := database.GetMediaByProductID(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) { func DeleteMedia(c *gin.Context) {
database := c.MustGet("database").(*db.Database) database := c.MustGet("database").(*db.Database)
@@ -803,7 +862,7 @@ func UploadMedia(c *gin.Context) {
// ✅ CRÉER LE DOSSIER // ✅ CRÉER LE DOSSIER
destFolder := filepath.Join("uploads", fileType+"s") 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) log.Printf("❌ [UploadMedia] Erreur création dossier: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur création dossier"}) c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur création dossier"})
return 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 // DELETE PRODUCT - VERSION SÉCURISÉE
// ============================================ // ============================================
@@ -947,3 +1050,26 @@ func cleanFileName(name string) string {
return result 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
}
+4 -233
View File
@@ -1,8 +1,3 @@
// ============================================
// handlers/redis_handlers.go - VERSION FINALE
// UTILISE UNIQUEMENT LES MÉTHODES DB PostgreSQL
// ============================================
package handlers package handlers
import ( import (
@@ -13,6 +8,7 @@ import (
"gestion/utils" "gestion/utils"
"log" "log"
"net/http" "net/http"
"slices"
"strconv" "strconv"
"strings" "strings"
"time" "time"
@@ -20,10 +16,6 @@ import (
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
) )
// ============================================
// GESTION DE LA FILE DE COMMANDES
// ============================================
func validatePenaltyPoints(points int) error { func validatePenaltyPoints(points int) error {
if points <= 0 { if points <= 0 {
return fmt.Errorf("points invalides: %d (doit être > 0)", points) return fmt.Errorf("points invalides: %d (doit être > 0)", points)
@@ -47,72 +39,6 @@ func sanitizeReason(reason string) string {
return strings.TrimSpace(reason) 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) { func UpdateLivreurLocation(c *gin.Context) {
database := c.MustGet("database").(*db.Database) database := c.MustGet("database").(*db.Database)
@@ -171,7 +97,7 @@ func UpdateLivreurLocation(c *gin.Context) {
usernameStr, req.Latitude, req.Longitude) usernameStr, req.Latitude, req.Longitude)
// ✅ Recalculer l'ETA en temps réel si livreur en_route // ✅ 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 // ✅ 2. Vérifier/Initialiser le statut du livreur
statusKey := fmt.Sprintf("delivery:status:%s", usernameStr) 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) { func GetDeliverymanLocationForCommand(c *gin.Context) {
database := c.MustGet("database").(*db.Database) 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) { func UpdateDeliveryPersonStatus(c *gin.Context) {
database := c.MustGet("database").(*db.Database) database := c.MustGet("database").(*db.Database)
@@ -490,18 +401,9 @@ func UpdateDeliveryPersonStatus(c *gin.Context) {
utils.BindErr(c, err) utils.BindErr(c, err)
return return
} }
// Validation du statut
validStatuses := []string{"available", "busy", "offline"} 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{ c.JSON(http.StatusBadRequest, gin.H{
"error": "Statut invalide", "error": "Statut invalide",
"valid_statuses": validStatuses, "valid_statuses": validStatuses,
@@ -509,7 +411,6 @@ func UpdateDeliveryPersonStatus(c *gin.Context) {
}) })
return return
} }
usernameStr := username.(string) usernameStr := username.(string)
err := database.SetDeliveryPersonStatus(usernameStr, req.Status, 0) 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) { func ApplyClientPenalty(c *gin.Context) {
database := c.MustGet("database").(*db.Database) 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) { func AddClientPointsAdmin(c *gin.Context) {
userRole := c.GetString("role") userRole := c.GetString("role")
if userRole != "admin" && userRole != "cabine" { if userRole != "admin" && userRole != "cabine" {
@@ -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) { func SubtractClientPointsAdmin(c *gin.Context) {
userRole := c.GetString("role") userRole := c.GetString("role")
if userRole != "admin" && userRole != "cabine" { 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) { func GetRealtimeStats(c *gin.Context) {
userRole := c.GetString("role") userRole := c.GetString("role")
if userRole != "admin" { if userRole != "admin" {
@@ -1200,13 +977,7 @@ func GetRealtimeStats(c *gin.Context) {
}) })
} }
// ============================================ func refreshETAForActivDelivery(username string, lat, lon float64) {
// 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) {
// 1. Récupérer le statut actuel du livreur // 1. Récupérer le statut actuel du livreur
statusKey := fmt.Sprintf("delivery:status:%s", username) statusKey := fmt.Sprintf("delivery:status:%s", username)
statusData, err := db.Redis.Get(db.RedisCtx, statusKey).Result() statusData, err := db.Redis.Get(db.RedisCtx, statusKey).Result()
+5 -1
View File
@@ -7,6 +7,7 @@ import (
"log" "log"
"net/http" "net/http"
"os" "os"
"strings"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
) )
@@ -44,6 +45,9 @@ func GetPublicSettings(c *gin.Context) {
"crypto_only": settings.CryptoOnly, "crypto_only": settings.CryptoOnly,
"nowpayments_currencies": settings.NowPaymentsCurrencies, "nowpayments_currencies": settings.NowPaymentsCurrencies,
"telegram_notifications_enabled": settings.TelegramNotificationsEnabled, "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 { if err := services.TelegramBot.SetWebhook(webhookURL); err != nil {
log.Printf("⚠️ [SETTINGS] Erreur enregistrement webhook Telegram: %v", err) log.Printf("⚠️ [SETTINGS] Erreur enregistrement webhook Telegram: %v", err)
} else { } else {
log.Printf("✅ [SETTINGS] Webhook Telegram enregistré: %s", webhookURL) log.Printf("✅ [SETTINGS] Webhook Telegram enregistré: %s", strings.NewReplacer("\n", "", "\r", "").Replace(webhookURL))
} }
} }
} }
+260
View File
@@ -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,
})
}
+96 -3
View File
@@ -6,6 +6,7 @@ import (
"gestion/services" "gestion/services"
"log" "log"
"net/http" "net/http"
"os"
"strings" "strings"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
@@ -91,10 +92,30 @@ func handleLinkAccount(c *gin.Context, token string, chatID int64) {
} }
log.Printf("✅ [TELEGRAM_LINK] Compte %s (%s) lié au chat_id %d", username, role, chatID) 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 { if services.TelegramBot != nil {
if services.LBTelegram != nil && services.LBTelegram.Bot1Username != "" {
if err := services.TelegramBot.SendMessageWithButtons(chatID,
"✅ <b>Compte lié avec succès !</b>\n\nPour activer vos notifications, démarrez le bot ci-dessous :",
[][2]string{{"🔔 Activer les notifications", "https://t.me/" + services.LBTelegram.Bot1Username}},
); err != nil {
log.Printf("⚠️ [TELEGRAM] Envoi bouton BOT1 échoué pour %s: %v", username, err)
services.TelegramBot.SendMessage(chatID, services.TelegramBot.SendMessage(chatID,
"✅ <b>Compte lié avec succès !</b>\n\nVous recevrez désormais vos notifications ici.") "✅ <b>Compte lié avec succès !</b>\n\nVous recevrez désormais vos notifications ici.")
} }
} else {
services.TelegramBot.SendMessage(chatID,
"✅ <b>Compte lié avec succès !</b>\n\nVous recevrez désormais vos notifications ici.")
}
}
c.Status(http.StatusOK) c.Status(http.StatusOK)
} }
@@ -118,9 +139,11 @@ func GenerateClientLinkToken(c *gin.Context) {
return return
} }
botUsername := services.TelegramBot.BotUsername
c.JSON(http.StatusOK, gin.H{ c.JSON(http.StatusOK, gin.H{
"token": token, "token": token,
"link_url": "https://t.me/" + services.TelegramBot.BotUsername + "?start=" + token, "link_url": "https://t.me/" + botUsername + "?start=" + token,
"message": "/start " + token, "message": "/start " + token,
"expires_in": 600, "expires_in": 600,
}) })
@@ -145,9 +168,11 @@ func GenerateLivreurLinkToken(c *gin.Context) {
return return
} }
botUsername := services.TelegramBot.BotUsername
c.JSON(http.StatusOK, gin.H{ c.JSON(http.StatusOK, gin.H{
"token": token, "token": token,
"link_url": "https://t.me/" + services.TelegramBot.BotUsername + "?start=" + token, "link_url": "https://t.me/" + botUsername + "?start=" + token,
"message": "/start " + token, "message": "/start " + token,
"expires_in": 600, "expires_in": 600,
}) })
@@ -180,9 +205,11 @@ func GenerateAdminLinkToken(c *gin.Context) {
return return
} }
botUsername := services.TelegramBot.BotUsername
c.JSON(http.StatusOK, gin.H{ c.JSON(http.StatusOK, gin.H{
"token": token, "token": token,
"link_url": "https://t.me/" + services.TelegramBot.BotUsername + "?start=" + token, "link_url": "https://t.me/" + botUsername + "?start=" + token,
"message": "/start " + token, "message": "/start " + token,
"expires_in": 600, "expires_in": 600,
}) })
@@ -242,13 +269,20 @@ func UnlinkClientTelegram(c *gin.Context) {
return return
} }
clientID := c.GetInt("client_id")
database := c.MustGet("database").(*db.Database) database := c.MustGet("database").(*db.Database)
if err := database.DeleteClientTelegramChatID(username); err != nil { if err := database.DeleteClientTelegramChatID(username); err != nil {
log.Printf("❌ [TELEGRAM_UNLINK] Erreur pour %s: %v", username, err) log.Printf("❌ [TELEGRAM_UNLINK] Erreur pour %s: %v", username, err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur déliaison"}) c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur déliaison"})
return 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) log.Printf("✅ [TELEGRAM_UNLINK] Compte client %s délié", username)
c.JSON(http.StatusOK, gin.H{"success": true}) 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) log.Printf("✅ [TELEGRAM_UNLINK] Compte admin %s délié", username)
c.JSON(http.StatusOK, gin.H{"success": true}) 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)
}
+1 -1
View File
@@ -10,7 +10,7 @@ import (
) )
// getFloatFromMap récupère un float64 depuis une map avec différents types // 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] value, exists := m[key]
if !exists || value == nil { if !exists || value == nil {
return 0, false return 0, false
@@ -169,10 +169,6 @@ func GetMyProfile(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"success": true, "client": sanitizeClient(client)}) 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 // UpdateClientByAdmin permet à un admin de modifier n'importe quel profil client
// PUT /api/v2/admin/protected/clients/:id // PUT /api/v2/admin/protected/clients/:id
func UpdateClientByAdmin(c *gin.Context) { 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 // UpdateUserByAdmin permet à un admin de modifier n'importe quel profil user
// PUT /api/v2/admin/protected/users/:id // PUT /api/v2/admin/protected/users/:id
func UpdateUserByAdmin(c *gin.Context) { func UpdateUserByAdmin(c *gin.Context) {
@@ -468,10 +460,6 @@ func UpdateUserByAdmin(c *gin.Context) {
}) })
} }
// ============================================
// UTILITAIRES
// ============================================
func sanitizeClient(client *models.Client) gin.H { func sanitizeClient(client *models.Client) gin.H {
return gin.H{ return gin.H{
"id": client.ID, "id": client.ID,
@@ -1,10 +1,8 @@
package handlers package handlers
import ( import (
"encoding/json"
"fmt" "fmt"
"gestion/db" "gestion/db"
"gestion/services"
"log" "log"
"net/http" "net/http"
"strconv" "strconv"
@@ -24,249 +22,6 @@ const (
MAX_DELIVERY_VALIDATION_DISTANCE_KM = 0.1 // 100 mètres = 0.1 km 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) // 3️⃣ DÉMARRER UNE LIVRAISON (PASSER EN IN_ROUTE)
// ============================================ // ============================================
@@ -395,14 +150,3 @@ func StartDelivery(c *gin.Context) {
"status": "en_route", "status": "en_route",
}) })
} }
// ============================================
// HELPERS
// ============================================
func maxInt(a, b int) int {
if a > b {
return a
}
return b
}
+27 -4
View File
@@ -1,7 +1,3 @@
// ============================================
// main.go - VERSION SIMPLIFIÉE AVEC CLEANUP AUTO
// ============================================
package main package main
import ( import (
@@ -42,6 +38,13 @@ func main() {
geoService := services.NewGeoService(db.Redis, db.RedisCtx) geoService := services.NewGeoService(db.Redis, db.RedisCtx)
log.Println("✅ Service de géolocalisation initialisé") 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() telegramService := services.NewTelegramService()
if telegramService.IsConfigured() { if telegramService.IsConfigured() {
log.Println("✅ Service Telegram initialisé") 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("")
log.Println("🧹 Démarrage du nettoyage des commandes invalides...") log.Println("🧹 Démarrage du nettoyage des commandes invalides...")
removed, err := database.CleanupInvalidQueueCommands() removed, err := database.CleanupInvalidQueueCommands()
@@ -1,6 +1,7 @@
package middleware package middleware
import ( import (
"fmt"
"gestion/db" "gestion/db"
"log" "log"
"net/http" "net/http"
@@ -60,7 +61,7 @@ func BlockClientIfPenalty(c *gin.Context) {
if amende > 0 { if amende > 0 {
log.Printf("🚫 [PENALTY] Checkout bloqué pour %s (amende=%.2f via DB)", usernameStr, amende) log.Printf("🚫 [PENALTY] Checkout bloqué pour %s (amende=%.2f via DB)", usernameStr, amende)
c.JSON(http.StatusForbidden, gin.H{ 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, "amende": amende,
"blocked": true, "blocked": true,
}) })
@@ -21,7 +21,6 @@ func OrderHoursMiddleware(c *gin.Context) {
hour := now.Hour() hour := now.Hour()
min := now.Minute() min := now.Minute()
// Récupérer le planning depuis les settings DB
database := c.MustGet("database").(*db.Database) database := c.MustGet("database").(*db.Database)
settings, err := database.GetSettings() settings, err := database.GetSettings()
if err != nil { if err != nil {
@@ -54,7 +54,7 @@ var (
// ============================================ // ============================================
// validateClientToken valide un token client // validateClientToken valide un token client
func validateClientToken(tokenString string, database *db.Database) (*ClientClaims, error) { func validateClientToken(tokenString string) (*ClientClaims, error) {
tokenString = strings.TrimSpace(tokenString) tokenString = strings.TrimSpace(tokenString)
if tokenString == "" { if tokenString == "" {
return nil, fmt.Errorf("token vide") return nil, fmt.Errorf("token vide")
@@ -103,7 +103,7 @@ func validateClientToken(tokenString string, database *db.Database) (*ClientClai
} }
// validateAdminToken valide un token admin // validateAdminToken valide un token admin
func validateAdminToken(tokenString string, database *db.Database) (*AdminClaims, error) { func validateAdminToken(tokenString string) (*AdminClaims, error) {
tokenString = strings.TrimSpace(tokenString) tokenString = strings.TrimSpace(tokenString)
if tokenString == "" { if tokenString == "" {
return nil, fmt.Errorf("token vide") return nil, fmt.Errorf("token vide")
@@ -161,7 +161,7 @@ func ClientMiddleware(c *gin.Context) {
tokenStr := strings.TrimPrefix(authHeader, "Bearer ") tokenStr := strings.TrimPrefix(authHeader, "Bearer ")
database := c.MustGet("database").(*db.Database) database := c.MustGet("database").(*db.Database)
claims, err := validateClientToken(tokenStr, database) claims, err := validateClientToken(tokenStr)
if err != nil { if err != nil {
log.Printf("❌ [CLIENT-MWARE] Token invalide: %v", err) log.Printf("❌ [CLIENT-MWARE] Token invalide: %v", err)
c.JSON(http.StatusUnauthorized, gin.H{"error": "Token invalide"}) c.JSON(http.StatusUnauthorized, gin.H{"error": "Token invalide"})
@@ -205,7 +205,7 @@ func AdminMiddleware(c *gin.Context) {
tokenStr := strings.TrimPrefix(authHeader, "Bearer ") tokenStr := strings.TrimPrefix(authHeader, "Bearer ")
database := c.MustGet("database").(*db.Database) database := c.MustGet("database").(*db.Database)
claims, err := validateAdminToken(tokenStr, database) claims, err := validateAdminToken(tokenStr)
if err != nil { if err != nil {
log.Printf("❌ [ADMIN-MWARE] Token invalide: %v", err) log.Printf("❌ [ADMIN-MWARE] Token invalide: %v", err)
c.JSON(http.StatusUnauthorized, gin.H{"error": "Token admin invalide"}) c.JSON(http.StatusUnauthorized, gin.H{"error": "Token admin invalide"})
@@ -258,7 +258,7 @@ func CabineMiddleware(c *gin.Context) {
tokenStr := strings.TrimPrefix(authHeader, "Bearer ") tokenStr := strings.TrimPrefix(authHeader, "Bearer ")
database := c.MustGet("database").(*db.Database) database := c.MustGet("database").(*db.Database)
claims, err := validateAdminToken(tokenStr, database) claims, err := validateAdminToken(tokenStr)
if err != nil { if err != nil {
log.Printf("❌ [CABINE-MWARE] Token invalide: %v", err) log.Printf("❌ [CABINE-MWARE] Token invalide: %v", err)
c.JSON(http.StatusUnauthorized, gin.H{"error": "Token invalide"}) c.JSON(http.StatusUnauthorized, gin.H{"error": "Token invalide"})
@@ -312,7 +312,7 @@ func LivreurMiddleware(c *gin.Context) {
tokenStr := strings.TrimPrefix(authHeader, "Bearer ") tokenStr := strings.TrimPrefix(authHeader, "Bearer ")
database := c.MustGet("database").(*db.Database) database := c.MustGet("database").(*db.Database)
claims, err := validateAdminToken(tokenStr, database) claims, err := validateAdminToken(tokenStr)
if err != nil { if err != nil {
log.Printf("❌ [LIVREUR-MWARE] Token invalide: %v", err) log.Printf("❌ [LIVREUR-MWARE] Token invalide: %v", err)
c.JSON(http.StatusUnauthorized, gin.H{"error": "Token invalide"}) 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.Header("X-RateLimit-Remaining", strconv.FormatInt(10-count, 10))
c.Next() c.Next()
} }
// ============================================
// HELPER MIDDLEWARE
// ============================================
// VerifyAuthHeader vérifie que le header Authorization est valide
func VerifyAuthHeader(c *gin.Context) {
authHeader := c.GetHeader("Authorization")
if authHeader == "" {
log.Printf("❌ [AUTH-HEADER] Authorization header manquant")
c.JSON(http.StatusUnauthorized, gin.H{
"error": "Authorization header manquant",
"hint": "Utilisez: Authorization: Bearer <token>",
})
c.Abort()
return
}
// Vérifier le format "Bearer <token>"
parts := strings.Split(authHeader, " ")
if len(parts) != 2 || parts[0] != "Bearer" {
log.Printf("❌ [AUTH-HEADER] Format invalide: %s", authHeader)
c.JSON(http.StatusUnauthorized, gin.H{
"error": "Format Authorization invalide",
"hint": "Utilisez: Authorization: Bearer <token>",
})
c.Abort()
return
}
log.Printf("✅ [AUTH-HEADER] Format valide")
c.Next()
}
// SessionErrorRecovery récupère les erreurs de session
func SessionErrorRecovery(c *gin.Context) {
defer func() {
if err := recover(); err != nil {
log.Printf("❌ [SESSION-ERROR] Erreur système: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{
"error": "Erreur serveur - Session compromise",
})
}
}()
c.Next()
if len(c.Errors) > 0 {
log.Printf("⚠️ [SESSION] Erreur handler: %v", c.Errors)
}
}
// LogSessionMiddleware log toutes les infos de session
func LogSessionMiddleware(c *gin.Context) {
username, _ := c.Get("username")
clientID, _ := c.Get("client_id")
sessionID, _ := c.Get("session_id")
log.Printf("📊 [SESSION-LOG] %s %s | user=%v | client_id=%v | session=%v",
c.Request.Method, c.Request.URL.Path, username, clientID, sessionID)
c.Next()
log.Printf("📊 [SESSION-LOG] Response: %d", c.Writer.Status())
}
// LoadClientContext charge les infos du client en contexte
func LoadClientContext(c *gin.Context, database *db.Database) (*db.SessionData, error) {
clientID, ok := c.Get("client_id")
if !ok {
return nil, fmt.Errorf("client_id manquant du contexte")
}
clientIDInt := clientID.(int)
// Récupérer la session
session, err := database.GetClientSession(clientIDInt)
if err != nil {
return nil, err
}
return session, nil
}
func DatabaseMiddleware(db *db.Database) gin.HandlerFunc {
return func(c *gin.Context) {
c.Set("database", db)
c.Next()
}
}
+1 -1
View File
@@ -38,7 +38,7 @@ type RegisterClientRequest struct {
type RegisterAdminRequest struct { type RegisterAdminRequest struct {
Username string `json:"username" binding:"required,min=3,max=50"` Username string `json:"username" binding:"required,min=3,max=50"`
Password string `json:"password" binding:"required,min=8"` 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 { type LoginResponse struct {
+2
View File
@@ -18,9 +18,11 @@ type Client struct {
MustChangePassword bool `gorm:"column:must_change_password;default:false" json:"must_change_password"` MustChangePassword bool `gorm:"column:must_change_password;default:false" json:"must_change_password"`
ReferralBalance float64 `gorm:"column:referral_balance;default:0" json:"referral_balance"` ReferralBalance float64 `gorm:"column:referral_balance;default:0" json:"referral_balance"`
PointsExtra map[string]int `gorm:"-" json:"points_extra"` PointsExtra map[string]int `gorm:"-" json:"points_extra"`
PointsRedeemed map[string]int `gorm:"-" json:"points_redeemed"`
Parrain string `gorm:"column:parrain" json:"parrain"` Parrain string `gorm:"column:parrain" json:"parrain"`
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"` CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"`
UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_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" } func (Client) TableName() string { return "clients" }
+2
View File
@@ -25,6 +25,8 @@ type CommandItem struct {
ProductID int `gorm:"column:product_id" json:"product_id"` ProductID int `gorm:"column:product_id" json:"product_id"`
Quantity float64 `gorm:"column:quantite" json:"quantity"` Quantity float64 `gorm:"column:quantite" json:"quantity"`
Price float64 `gorm:"column:prix" json:"price"` 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"` CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"`
UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"` UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"`
} }
+6
View File
@@ -0,0 +1,6 @@
package models
type Contact struct {
ID int `json:"id" gorm:"primaryKey"`
Name string `json:"name" gorm:"not null"`
}
+2
View File
@@ -11,6 +11,8 @@ type Panier struct {
Description string `json:"description"` Description string `json:"description"`
Quantity float64 `json:"quantity"` Quantity float64 `json:"quantity"`
Price float64 `json:"price"` Price float64 `json:"price"`
IsReward bool `json:"is_reward"`
RewardPoolKey string `json:"reward_pool_key,omitempty"`
CreatedAt time.Time `json:"created_at"` CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at,omitempty"` UpdatedAt time.Time `json:"updated_at,omitempty"`
} }
+2
View File
@@ -9,6 +9,7 @@ type Product struct {
Description string `json:"description" gorm:"column:description"` Description string `json:"description" gorm:"column:description"`
Stock float64 `json:"stock" gorm:"column:stock"` Stock float64 `json:"stock" gorm:"column:stock"`
Unit string `json:"unit" gorm:"column:unit"` 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"` Prices []ProductPrice `json:"prices" gorm:"foreignKey:ProductID"`
Media []Media `json:"media,omitempty" gorm:"foreignKey:ProductID"` Media []Media `json:"media,omitempty" gorm:"foreignKey:ProductID"`
CreatedAt time.Time `json:"created_at" gorm:"autoCreateTime"` CreatedAt time.Time `json:"created_at" gorm:"autoCreateTime"`
@@ -23,6 +24,7 @@ type ProductPrice struct {
Quantity float64 `json:"quantity" gorm:"column:quantity" binding:"required"` Quantity float64 `json:"quantity" gorm:"column:quantity" binding:"required"`
Price float64 `json:"price" gorm:"column:price" binding:"required"` Price float64 `json:"price" gorm:"column:price" binding:"required"`
CreatedAt time.Time `json:"created_at" gorm:"autoCreateTime"` 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" } func (ProductPrice) TableName() string { return "product_prices" }
+28 -1
View File
@@ -14,6 +14,29 @@ type PointsTier struct {
Points int `json:"points"` 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 // DaySchedule représente les horaires de livraison pour un jour de la semaine
type DaySchedule struct { type DaySchedule struct {
Enabled bool `json:"enabled"` Enabled bool `json:"enabled"`
@@ -66,6 +89,7 @@ type AppSettings struct {
PenaltyTiers []PenaltyTier `json:"penalty_tiers"` // barème des amendes (liste configurable) PenaltyTiers []PenaltyTier `json:"penalty_tiers"` // barème des amendes (liste configurable)
PointsEnabled bool `json:"points_enabled"` // afficher/activer le système de points PointsEnabled bool `json:"points_enabled"` // afficher/activer le système de points
PointsPools []PointsPool `json:"points_pools"` // types de points personnalisés 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 ReferralEnabled bool `json:"referral_enabled"` // activer/désactiver le système de parrainage
ReferralAmount float64 `json:"referral_amount"` // montant crédité par parrainage ReferralAmount float64 `json:"referral_amount"` // montant crédité par parrainage
CryptoPaymentEnabled bool `json:"crypto_payment_enabled"` // activer/désactiver le paiement crypto CryptoPaymentEnabled bool `json:"crypto_payment_enabled"` // activer/désactiver le paiement crypto
@@ -75,8 +99,11 @@ type AppSettings struct {
NowPaymentsCurrencies []string `json:"nowpayments_currencies"` // cryptos acceptées (ex: ["btc","eth","ltc"]) NowPaymentsCurrencies []string `json:"nowpayments_currencies"` // cryptos acceptées (ex: ["btc","eth","ltc"])
DeliverySchedule DeliverySchedule `json:"delivery_schedule"` // horaires de livraison par jour DeliverySchedule DeliverySchedule `json:"delivery_schedule"` // horaires de livraison par jour
PostalZones []PostalZone `json:"postal_zones"` // zones de livraison avec minimum de commande PostalZones []PostalZone `json:"postal_zones"` // zones de livraison avec minimum de commande
TelegramBotToken string `json:"telegram_bot_token"` // token du bot Telegram (BotFather) 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 @) TelegramBotUsername string `json:"telegram_bot_username"` // username du bot (sans @)
TelegramNotificationsEnabled bool `json:"telegram_notifications_enabled"` // activer/désactiver les notifications Telegram TelegramNotificationsEnabled bool `json:"telegram_notifications_enabled"` // activer/désactiver les notifications Telegram
DeliveryMode DeliveryModeConfig `json:"delivery_mode"` // mode d'assignation des livreurs 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
} }
+44
View File
@@ -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"`
}
+28 -6
View File
@@ -1,7 +1,3 @@
// ============================================
// routes/routes.go - VERSION CORRIGÉE COMPLÈTE
// ============================================
package routes package routes
import ( import (
@@ -34,6 +30,7 @@ func SetupRoutes(router *gin.Engine, database *db.Database, geoService *services
{ {
authGroupV1.POST("/login", middleware.LoginRateLimitMiddleware, handlers.LoginClient) authGroupV1.POST("/login", middleware.LoginRateLimitMiddleware, handlers.LoginClient)
authGroupV1.POST("/logout", handlers.LogoutClient) authGroupV1.POST("/logout", handlers.LogoutClient)
authGroupV1.POST("/2fa/verify", middleware.LoginRateLimitMiddleware, handlers.Verify2FAClient)
} }
// Route change-password (auth client requise) // 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.GET("/profile", handlers.GetMyProfile) // ✅ Récupérer mon profil
cartGroupV1.PUT("/profile/update", handlers.UpdateMyProfile) // ✅ Modifier 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 // 🎁 PARRAINAGE CLIENT
cartGroupV1.GET("/referral/balance", handlers.GetMyReferralBalance) cartGroupV1.GET("/referral/balance", handlers.GetMyReferralBalance)
cartGroupV1.GET("/parrain", handlers.GetMyParrainInfo) cartGroupV1.GET("/parrain", handlers.GetMyParrainInfo)
// 🏆 POINTS & RÉCOMPENSES CLIENT
cartGroupV1.GET("/points/rewards", handlers.GetMyPointsRewards)
cartGroupV1.POST("/points/claim", handlers.ClaimMyReward)
// 💸 STATUT PAIEMENT CRYPTO // 💸 STATUT PAIEMENT CRYPTO
cartGroupV1.GET("/commands/:id/payment-status", handlers.GetCommandPaymentStatus) 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) 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 // 📋 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 := router.Group("/api/v2/admin/auth")
{ {
//adminAuthGroupV2.POST("/register", handlers.RegisterAdmin)
adminAuthGroupV2.POST("/login", middleware.LoginRateLimitMiddleware, handlers.LoginAdmin) adminAuthGroupV2.POST("/login", middleware.LoginRateLimitMiddleware, handlers.LoginAdmin)
adminAuthGroupV2.POST("/logout", handlers.LogoutAdmin) 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.DELETE("/products/:id", handlers.DeleteProduct)
adminGroupV2.POST("/products/:id/media", handlers.UploadMedia) adminGroupV2.POST("/products/:id/media", handlers.UploadMedia)
adminGroupV2.DELETE("/products/:id/media/:media_id", handlers.DeleteMedia) adminGroupV2.DELETE("/products/:id/media/:media_id", handlers.DeleteMedia)
adminGroupV2.POST("/products/:id/stock", handlers.UpdateStock)
// ============================================ // ============================================
// CATÉGORIES - GESTION ADMIN // CATÉGORIES - GESTION ADMIN
// ============================================ // ============================================
adminGroupV2.POST("/categories", handlers.CreateCategory) adminGroupV2.POST("/categories", handlers.CreateCategory)
adminGroupV2.PUT("/categories/:id", handlers.UpdateCategory) adminGroupV2.PUT("/categories/:id", handlers.UpdateCategory)
adminGroupV2.DELETE("/categories/:id", handlers.DeleteCategory) 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 // COMMANDES - GESTION DE BASE
// ============================================ // ============================================
@@ -253,6 +271,7 @@ func SetupRoutes(router *gin.Engine, database *db.Database, geoService *services
adminGroupV2.POST("/client/:username/point/reset", handlers.ResetClientPointAdmin) // Reset points → 0 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/add", handlers.AddClientPointsAdmin) // Ajouter points par pool
adminGroupV2.POST("/client/:username/points/subtract", handlers.SubtractClientPointsAdmin) // Enlever 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/all", handlers.GetAllClientsWithPenalties) // Liste clients avec pénalités
adminGroupV2.GET("/penalties/stats", handlers.GetPenaltiesStats) 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.POST("/telegram/link-token", handlers.GenerateAdminLinkToken)
cabineGroupV1.DELETE("/telegram/unlink", handlers.UnlinkAdminTelegram) cabineGroupV1.DELETE("/telegram/unlink", handlers.UnlinkAdminTelegram)
cabineGroupV1.GET("/commands", handlers.GetAllCommands)
cabineGroupV1.GET("/commands/:id/items", handlers.ShowItems) cabineGroupV1.GET("/commands/:id/items", handlers.ShowItems)
cabineGroupV1.POST("/commands/:id/confirm-reception", handlers.StaffApproveDelivery) cabineGroupV1.POST("/commands/:id/confirm-reception", handlers.StaffApproveDelivery)
cabineGroupV1.POST("/commands/:id/assign", handlers.AssignDeliveryPerson) 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.PUT("/items/:item_id/status", handlers.UpdateItemStatus)
cabineGroupV1.GET("/commands/:id/deliveryman/location", handlers.GetDeliverymanLocationForCommand) cabineGroupV1.GET("/commands/:id/deliveryman/location", handlers.GetDeliverymanLocationForCommand)
cabineGroupV1.GET("/all/deliveryman", handlers.GetAllDeliveryMen) 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.DELETE("/commands/:id", handlers.DeleteCommandByCabine)
cabineGroupV1.POST("/commands/:id/propose-address", handlers.ProposeAddressChange) cabineGroupV1.POST("/commands/:id/propose-address", handlers.ProposeAddressChange)
// ⭐ NOUVEAU - ANNULATION PAR CABINE
cabineGroupV1.GET("/commands/cancelled", handlers.GetAllCancelledOrders) cabineGroupV1.GET("/commands/cancelled", handlers.GetAllCancelledOrders)
cabineGroupV1.GET("/client/:username/penalties", handlers.GetClientPenaltiesAdmin) // Voir pénalités client cabineGroupV1.GET("/client/:username/penalties", handlers.GetClientPenaltiesAdmin) // Voir pénalités client
cabineGroupV1.POST("/client/:username/penalties/reset", handlers.ResetClientPenaltiesAdmin) // Reset pénalités cabineGroupV1.POST("/client/:username/penalties/reset", handlers.ResetClientPenaltiesAdmin) // Reset pénalités
@@ -358,6 +379,7 @@ func SetupRoutes(router *gin.Engine, database *db.Database, geoService *services
// QUEUE PERSONNELLE // QUEUE PERSONNELLE
// ============================================ // ============================================
livreurGroupV1.GET("/queue", handlers.GetMyQueue) livreurGroupV1.GET("/queue", handlers.GetMyQueue)
livreurGroupV1.GET("/stats", handlers.GetMyDeliveryStats)
// ============================================ // ============================================
// ALERTES POLICE // ALERTES POLICE
@@ -0,0 +1,536 @@
package services
import (
"encoding/json"
"fmt"
"io"
"math"
"net/http"
"net/url"
"strings"
"time"
"unicode"
"golang.org/x/text/runes"
"golang.org/x/text/transform"
"golang.org/x/text/unicode/norm"
)
// ============================================
// TYPES
// ============================================
// AddressSuggestion représente une suggestion de correction
type AddressSuggestion struct {
OriginalAddress string `json:"original_address"`
CorrectedAddress string `json:"corrected_address"`
Coordinates Coordinates `json:"coordinates"`
Confidence float64 `json:"confidence"` // 0.0 à 1.0
CorrectionApplied bool `json:"correction_applied"` // true si une correction a été faite
Source string `json:"source"` // "exact", "fuzzy", "structured"
}
// NominatimSuggestion représente une réponse de l'API Nominatim
type NominatimSuggestion struct {
Latitude float64 `json:"lat,string"`
Longitude float64 `json:"lon,string"`
DisplayName string `json:"display_name"`
Importance float64 `json:"importance"`
Type string `json:"type"`
Class string `json:"class"`
Address struct {
HouseNumber string `json:"house_number"`
Road string `json:"road"`
City string `json:"city"`
Town string `json:"town"`
Village string `json:"village"`
Postcode string `json:"postcode"`
Country string `json:"country"`
CountryCode string `json:"country_code"`
} `json:"address"`
}
// AddressCorrectionService gère la correction des adresses
type AddressCorrectionService struct {
httpClient *http.Client
geoService *GeoService
}
// NewAddressCorrectionService crée une instance du service de correction
func NewAddressCorrectionService(geoService *GeoService) *AddressCorrectionService {
return &AddressCorrectionService{
httpClient: &http.Client{Timeout: 10 * time.Second},
geoService: geoService,
}
}
// ============================================
// POINT D'ENTRÉE PRINCIPAL
// ============================================
// ResolveAddress tente de géocoder une adresse avec correction automatique.
// Retourne toujours une suggestion, même approximative.
// Ordre de résolution :
// 1. Géocodage exact → succès immédiat
// 2. Nominatim fuzzy search (addressdetails + limit=5)
// 3. Décomposition structurée de l'adresse
// 4. Erreur explicite avec suggestions si dispo
func (acs *AddressCorrectionService) ResolveAddress(rawAddress string) (*AddressSuggestion, error) {
rawAddress = strings.TrimSpace(rawAddress)
if rawAddress == "" {
return nil, fmt.Errorf("adresse vide")
}
// ── Étape 1 : essai exact via GeoService (utilise le cache Redis) ──
if loc, err := acs.geoService.GeocodeAddress(rawAddress); err == nil {
return &AddressSuggestion{
OriginalAddress: rawAddress,
CorrectedAddress: rawAddress,
Coordinates: Coordinates{Latitude: loc.Latitude, Longitude: loc.Longitude},
Confidence: 1.0,
CorrectionApplied: false,
Source: "exact",
}, nil
}
// ── Étape 2 : fuzzy search Nominatim ──
if suggestion, err := acs.nominatimFuzzySearch(rawAddress); err == nil {
return suggestion, nil
}
// ── Étape 3 : décomposition structurée ──
if suggestion, err := acs.structuredSearch(rawAddress); err == nil {
return suggestion, nil
}
return nil, fmt.Errorf("adresse introuvable : '%s' — vérifiez l'orthographe ou le code postal", rawAddress)
}
// ============================================
// ÉTAPE 2 : FUZZY SEARCH NOMINATIM
// ============================================
// nominatimFuzzySearch interroge Nominatim avec plusieurs variantes de l'adresse
func (acs *AddressCorrectionService) nominatimFuzzySearch(address string) (*AddressSuggestion, error) {
variants := buildAddressVariants(address)
for _, variant := range variants {
suggestions, err := acs.queryNominatim(variant, 5)
if err != nil || len(suggestions) == 0 {
continue
}
best := suggestions[0]
confidence := computeConfidence(address, best.DisplayName, best.Importance)
// On accepte si la confiance est suffisante
if confidence >= 0.40 {
corrected := formatNominatimAddress(best)
return &AddressSuggestion{
OriginalAddress: address,
CorrectedAddress: corrected,
Coordinates: Coordinates{Latitude: best.Latitude, Longitude: best.Longitude},
Confidence: confidence,
CorrectionApplied: !strings.EqualFold(normalize(address), normalize(corrected)),
Source: "fuzzy",
}, nil
}
}
return nil, fmt.Errorf("aucune correspondance fuzzy trouvée")
}
// queryNominatim exécute une requête vers l'API Nominatim
func (acs *AddressCorrectionService) queryNominatim(query string, limit int) ([]NominatimSuggestion, error) {
query = strings.TrimSpace(query)
if query == "" {
return nil, fmt.Errorf("requête vide")
}
params := url.Values{}
params.Set("q", query)
params.Set("format", "json")
params.Set("addressdetails", "1")
params.Set("limit", fmt.Sprintf("%d", limit))
params.Set("accept-language", "fr")
fullURL := fmt.Sprintf("%s?%s", NominatimBaseURL, params.Encode())
req, err := http.NewRequest("GET", fullURL, nil)
if err != nil {
return nil, err
}
req.Header.Set("User-Agent", "DeliveryApp/1.0 (address-correction)")
// Respect du rate-limit Nominatim : 1 req/s
time.Sleep(1100 * time.Millisecond)
resp, err := acs.httpClient.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("Nominatim status %d", resp.StatusCode)
}
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, err
}
var results []NominatimSuggestion
if err := json.Unmarshal(body, &results); err != nil {
return nil, err
}
return results, nil
}
// ============================================
// ÉTAPE 3 : RECHERCHE STRUCTURÉE
// ============================================
// structuredSearch décompose l'adresse et cherche les parties clés
func (acs *AddressCorrectionService) structuredSearch(address string) (*AddressSuggestion, error) {
parts := parseAddressParts(address)
// Essai 1 : numéro + rue + ville (sans code postal)
if parts.streetNumber != "" && parts.streetName != "" && parts.city != "" {
q := fmt.Sprintf("%s %s, %s", parts.streetNumber, parts.streetName, parts.city)
if s, err := acs.nominatimFuzzySearch(q); err == nil {
s.OriginalAddress = address
s.Source = "structured"
return s, nil
}
}
// Essai 2 : rue + code postal uniquement
if parts.streetName != "" && parts.postcode != "" {
q := fmt.Sprintf("%s, %s", parts.streetName, parts.postcode)
if s, err := acs.nominatimFuzzySearch(q); err == nil {
s.OriginalAddress = address
s.Source = "structured"
return s, nil
}
}
// Essai 3 : ville + code postal comme zone de repli
if parts.city != "" && parts.postcode != "" {
q := fmt.Sprintf("%s %s, France", parts.city, parts.postcode)
suggestions, err := acs.queryNominatim(q, 3)
if err == nil && len(suggestions) > 0 {
best := suggestions[0]
return &AddressSuggestion{
OriginalAddress: address,
CorrectedAddress: best.DisplayName,
Coordinates: Coordinates{Latitude: best.Latitude, Longitude: best.Longitude},
Confidence: 0.30, // faible : seulement ville/CP trouvés
CorrectionApplied: true,
Source: "structured_partial",
}, nil
}
}
return nil, fmt.Errorf("recherche structurée échouée")
}
// ============================================
// VARIANTES D'ADRESSE
// ============================================
// buildAddressVariants génère plusieurs variantes d'une adresse pour maximiser les chances
func buildAddressVariants(address string) []string {
variants := []string{address}
normalized := normalize(address)
// Variante sans accents
if normalized != address {
variants = append(variants, normalized)
}
// Variante avec "France" si absent
if !strings.Contains(strings.ToLower(address), "france") {
variants = append(variants, address+", France")
}
// Variante en corrigeant les abréviations courantes françaises
expanded := expandFrenchAbbreviations(address)
if expanded != address {
variants = append(variants, expanded)
variants = append(variants, expanded+", France")
}
// Variante en supprimant les mots de liaison potentiellement mal orthographiés
simplified := simplifyStreetName(address)
if simplified != address {
variants = append(variants, simplified)
}
// Dédoublonnage tout en conservant l'ordre
seen := map[string]bool{}
unique := make([]string, 0, len(variants))
for _, v := range variants {
if !seen[v] {
seen[v] = true
unique = append(unique, v)
}
}
return unique
}
// expandFrenchAbbreviations remplace les abréviations courantes
func expandFrenchAbbreviations(address string) string {
replacements := []struct{ from, to string }{
{"Av.", "Avenue"},
{"Ave.", "Avenue"},
{"Bd.", "Boulevard"},
{"Bld.", "Boulevard"},
{"Blvd.", "Boulevard"},
{"Rte.", "Route"},
{"Rte ", "Route "},
{"Imp.", "Impasse"},
{"Cité", "Cité"},
{"Sq.", "Square"},
{"Pl.", "Place"},
{"Rés.", "Résidence"},
}
result := address
for _, r := range replacements {
result = strings.ReplaceAll(result, r.from, r.to)
}
return result
}
// simplifyStreetName essaie de nettoyer la rue (retire les particules ambiguës)
func simplifyStreetName(address string) string {
// Ex: "20 Rue Gabriel le Pan de Ligny" → essai sans "le" → "20 Rue Gabriel Pan de Ligny"
// Heuristique légère : on ne modifie que si la chaîne est suffisamment longue
words := strings.Fields(address)
if len(words) < 5 {
return address
}
// Retire les articles intégrés dans le nom de rue (heuristique)
articles := map[string]bool{"le": true, "la": true, "les": true, "de": true, "du": true, "des": true, "d": true}
filtered := make([]string, 0, len(words))
for i, w := range words {
lower := strings.ToLower(w)
// Garder le premier mot (numéro) et les mots non-articles, ou les articles en début de nom de rue
if i < 2 || !articles[lower] {
filtered = append(filtered, w)
}
}
result := strings.Join(filtered, " ")
if result == address {
return address
}
return result
}
// ============================================
// UTILITAIRES
// ============================================
// addressParts regroupe les composants décomposés d'une adresse
type addressParts struct {
streetNumber string
streetName string
postcode string
city string
}
// parseAddressParts analyse une adresse libre pour en extraire les composants
func parseAddressParts(address string) addressParts {
var parts addressParts
// Extraction du code postal (5 chiffres consécutifs)
words := strings.Fields(address)
remaining := make([]string, 0, len(words))
for _, w := range words {
if isPostcode(w) {
parts.postcode = w
} else {
remaining = append(remaining, w)
}
}
if len(remaining) == 0 {
return parts
}
// Premier mot numérique → numéro de rue
if isNumeric(remaining[0]) {
parts.streetNumber = remaining[0]
remaining = remaining[1:]
}
// Détection de la ville : dernier groupe après le code postal
// Heuristique : si le dernier mot est une ville connue ou commence par une maj
if len(remaining) > 0 {
last := remaining[len(remaining)-1]
if len(last) > 2 && last[0] >= 'A' && last[0] <= 'Z' {
parts.city = last
remaining = remaining[:len(remaining)-1]
}
}
parts.streetName = strings.Join(remaining, " ")
return parts
}
// computeConfidence calcule un score de similarité entre l'adresse originale et la suggestion
func computeConfidence(original, suggested string, nominatimImportance float64) float64 {
origNorm := normalize(strings.ToLower(original))
suggNorm := normalize(strings.ToLower(suggested))
// Score de similarité sur les mots communs
origWords := strings.Fields(origNorm)
suggWords := strings.Fields(suggNorm)
commonCount := 0
for _, ow := range origWords {
if len(ow) < 3 {
continue // ignorer les petits mots
}
for _, sw := range suggWords {
if strings.Contains(sw, ow) || strings.Contains(ow, sw) || levenshteinRatio(ow, sw) > 0.75 {
commonCount++
break
}
}
}
var wordScore float64
if len(origWords) > 0 {
wordScore = float64(commonCount) / float64(len(origWords))
}
// Combinaison : 70% similarité textuelle + 30% importance Nominatim
importance := math.Min(nominatimImportance, 1.0)
return wordScore*0.70 + importance*0.30
}
// formatNominatimAddress formate l'adresse complète depuis une suggestion Nominatim
func formatNominatimAddress(s NominatimSuggestion) string {
addr := s.Address
var parts []string
if addr.HouseNumber != "" && addr.Road != "" {
parts = append(parts, addr.HouseNumber+" "+addr.Road)
} else if addr.Road != "" {
parts = append(parts, addr.Road)
}
city := addr.City
if city == "" {
city = addr.Town
}
if city == "" {
city = addr.Village
}
if addr.Postcode != "" {
parts = append(parts, addr.Postcode)
}
if city != "" {
parts = append(parts, city)
}
if len(parts) == 0 {
return s.DisplayName
}
return strings.Join(parts, ", ")
}
// normalize supprime les accents et normalise les espaces
func normalize(s string) string {
t := transform.Chain(norm.NFD, runes.Remove(runes.In(unicode.Mn)), norm.NFC)
result, _, _ := transform.String(t, s)
return strings.Join(strings.Fields(result), " ")
}
// isPostcode retourne true si le mot ressemble à un code postal français
func isPostcode(s string) bool {
if len(s) != 5 {
return false
}
for _, c := range s {
if c < '0' || c > '9' {
return false
}
}
return true
}
// isNumeric retourne true si la chaîne est entièrement numérique
func isNumeric(s string) bool {
for _, c := range s {
if c < '0' || c > '9' {
return false
}
}
return len(s) > 0
}
// levenshteinRatio retourne un ratio de similarité entre 0 et 1
func levenshteinRatio(a, b string) float64 {
d := levenshtein(a, b)
maxLen := math.Max(float64(len(a)), float64(len(b)))
if maxLen == 0 {
return 1.0
}
return 1.0 - float64(d)/maxLen
}
// levenshtein calcule la distance de Levenshtein entre deux chaînes
func levenshtein(a, b string) int {
ra, rb := []rune(a), []rune(b)
la, lb := len(ra), len(rb)
if la == 0 {
return lb
}
if lb == 0 {
return la
}
dp := make([][]int, la+1)
for i := range dp {
dp[i] = make([]int, lb+1)
dp[i][0] = i
}
for j := 0; j <= lb; j++ {
dp[0][j] = j
}
for i := 1; i <= la; i++ {
for j := 1; j <= lb; j++ {
cost := 1
if ra[i-1] == rb[j-1] {
cost = 0
}
dp[i][j] = min3(dp[i-1][j]+1, dp[i][j-1]+1, dp[i-1][j-1]+cost)
}
}
return dp[la][lb]
}
func min3(a, b, c int) int {
if a < b {
if a < c {
return a
}
return c
}
if b < c {
return b
}
return c
}
+67 -41
View File
@@ -6,10 +6,10 @@ import (
"fmt" "fmt"
"gestion/models" "gestion/models"
"io" "io"
"log"
"math" "math"
"net/http" "net/http"
"net/url" "net/url"
"os"
"strings" "strings"
"time" "time"
@@ -28,10 +28,6 @@ const (
LocationTTL = 1 * time.Hour LocationTTL = 1 * time.Hour
) )
// ============================================
// STRUCTURES
// ============================================
type GeoLocation struct { type GeoLocation struct {
Latitude float64 `json:"lat,string"` Latitude float64 `json:"lat,string"`
Longitude float64 `json:"lon,string"` Longitude float64 `json:"lon,string"`
@@ -54,40 +50,65 @@ type GeoService struct {
redis *redis.Client redis *redis.Client
ctx context.Context ctx context.Context
httpClient *http.Client httpClient *http.Client
correctionService *AddressCorrectionService
} }
// ============================================
// CONSTRUCTEUR
// ============================================
func NewGeoService(redisClient *redis.Client, ctx context.Context) *GeoService { func NewGeoService(redisClient *redis.Client, ctx context.Context) *GeoService {
return &GeoService{ gs := &GeoService{
redis: redisClient, redis: redisClient,
ctx: ctx, ctx: ctx,
httpClient: &http.Client{ httpClient: &http.Client{
Timeout: 10 * time.Second, 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) { func (gs *GeoService) GeocodeAddress(address string) (*GeoLocation, error) {
// 1. Vérifier le cache Redis // 1. Cache Redis (adresse originale)
location, err := gs.getFromCache(address) if location, err := gs.getFromCache(address); err == nil {
if err == nil {
return location, nil return location, nil
} }
location, err = gs.fetchFromNominatim(address) // 2. Tentative directe via Nominatim
if err != nil { if location, err := gs.fetchFromNominatim(address); err == nil {
return nil, err 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) gs.saveToCache(address, location)
// Mettre en cache aussi avec l'adresse corrigée
if suggestion.CorrectionApplied {
gs.saveToCache(suggestion.CorrectedAddress, location)
}
return location, nil 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) // CalculateETAWithTomTom calcule l'ETA via TomTom API (précis avec trafic réel)
// Retourne (etaMinutes, distanceKm, error) // Retourne (etaMinutes, distanceKm, error)
func CalculateETAWithTomTom(from, to Coordinates) (int, float64, error) { func CalculateETAWithTomTom(from, to Coordinates) (int, float64, error) {
apiKey := os.Getenv("TOMTOM_API_KEY") if len(tomTomKeys.keys) == 0 {
if apiKey == "" {
// Fallback sur calcul local si pas de clé API
distance := CalculateDistance(from, to) distance := CalculateDistance(from, to)
return CalculateETA(distance), distance, nil 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} 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 { if err != nil {
// Fallback sur calcul local en cas d'erreur réseau
distance := CalculateDistance(from, to) distance := CalculateDistance(from, to)
eta := CalculateETA(distance) 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 return eta, distance, nil
} }
defer resp.Body.Close() defer resp.Body.Close()
if resp.StatusCode != http.StatusOK { if resp.StatusCode != http.StatusOK {
// Fallback sur calcul local en cas d'erreur API
distance := CalculateDistance(from, to) distance := CalculateDistance(from, to)
eta := CalculateETA(distance) eta := CalculateETA(distance)
fmt.Printf("⚠️ TomTom API error %d, fallback: %.2f km -> %d min\n", resp.StatusCode, distance, eta) 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 summary := routeResponse.Routes[0].Summary
// Calculer ETA en minutes (arrondi supérieur)
etaMinutes := (summary.TravelTimeInSeconds + 59) / 60 etaMinutes := (summary.TravelTimeInSeconds + 59) / 60
distanceKm := float64(summary.LengthInMeters) / 1000.0 distanceKm := float64(summary.LengthInMeters) / 1000.0
// Appliquer minimum
if etaMinutes < MinETA { if etaMinutes < MinETA {
etaMinutes = MinETA etaMinutes = MinETA
} }
fmt.Printf("🛣️ TomTom: %.2f km -> %d min (trafic réel)\n", distanceKm, etaMinutes) fmt.Printf("🛣️ TomTom: %.2f km -> %d min (trafic réel)\n", distanceKm, etaMinutes)
return etaMinutes, distanceKm, nil return etaMinutes, distanceKm, nil
} }
@@ -503,13 +525,13 @@ func (gs *GeoService) GetAllDeliveryDistances(target Coordinates, availableUsern
// ============================================ // ============================================
// GetDeliveryHeatmap retourne toutes les positions des livreurs // 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() keys, err := gs.redis.Keys(gs.ctx, "delivery:location:*").Result()
if err != nil { if err != nil {
return nil, err return nil, err
} }
var heatmap []map[string]interface{} var heatmap []map[string]any
for _, key := range keys { for _, key := range keys {
data, err := gs.redis.Get(gs.ctx, key).Result() data, err := gs.redis.Get(gs.ctx, key).Result()
@@ -517,7 +539,7 @@ func (gs *GeoService) GetDeliveryHeatmap() ([]map[string]interface{}, error) {
continue continue
} }
var location map[string]interface{} var location map[string]any
json.Unmarshal([]byte(data), &location) json.Unmarshal([]byte(data), &location)
username := key[len("delivery:location:"):] username := key[len("delivery:location:"):]
@@ -528,3 +550,7 @@ func (gs *GeoService) GetDeliveryHeatmap() ([]map[string]interface{}, error) {
return heatmap, nil return heatmap, nil
} }
func (gs *GeoService) CorrectionService() *AddressCorrectionService {
return gs.correctionService
}
+89
View File
@@ -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
}
+46 -1
View File
@@ -10,7 +10,6 @@ import (
"time" "time"
) )
// TelegramBot est l'instance globale accessible depuis le package db
var TelegramBot *TelegramService var TelegramBot *TelegramService
type TelegramService struct { type TelegramService struct {
@@ -96,6 +95,52 @@ func (t *TelegramService) SendMessage(chatID int64, text string) error {
return nil 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 // SetWebhook enregistre l'URL webhook auprès de Telegram
func (t *TelegramService) SetWebhook(webhookURL string) error { func (t *TelegramService) SetWebhook(webhookURL string) error {
if !t.IsConfigured() { if !t.IsConfigured() {
+17 -15
View File
@@ -11,25 +11,30 @@ import (
"io" "io"
"log" "log"
"net/http" "net/http"
"os" "net/url"
"time" "time"
) )
func GetETAWithTraffic(from, to Coordinates) (etaMinutes int, distanceKm float64, err error) { func GetETAWithTraffic(from, to Coordinates) (etaMinutes int, distanceKm float64, err error) {
apiKey := os.Getenv("TOMTOM_API_KEY") client := &http.Client{Timeout: 10 * time.Second}
if apiKey == "" {
return 0, 0, fmt.Errorf("TOMTOM_API_KEY non configurée") 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( resp, err := tomTomKeys.Do(client, buildReq)
"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)
if err != nil { 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() defer resp.Body.Close()
@@ -53,12 +58,9 @@ func GetETAWithTraffic(from, to Coordinates) (etaMinutes int, distanceKm float64
} }
summary := routeResponse.Routes[0].Summary summary := routeResponse.Routes[0].Summary
// Calculer ETA en minutes (arrondi supérieur)
etaMinutes = (summary.TravelTimeInSeconds + 59) / 60 etaMinutes = (summary.TravelTimeInSeconds + 59) / 60
distanceKm = float64(summary.LengthInMeters) / 1000.0 distanceKm = float64(summary.LengthInMeters) / 1000.0
log.Printf("🛣️ TomTom Routing: %.2f km → %d min (trafic inclus)", distanceKm, etaMinutes) log.Printf("🛣️ TomTom Routing: %.2f km → %d min (trafic inclus)", distanceKm, etaMinutes)
return etaMinutes, distanceKm, nil return etaMinutes, distanceKm, nil
} }
+96
View File
@@ -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)
}
+3 -1
View File
@@ -68,7 +68,9 @@ func AutoAssignWorker(database *db.Database) {
nextCommand.CommandID, err) nextCommand.CommandID, err)
} else { } else {
log.Printf("✅ Commande %d auto-assignée", nextCommand.CommandID) 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)
}
} }
} }
} }
-15
View File
@@ -62,7 +62,6 @@
"resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.5.tgz", "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.5.tgz",
"integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==", "integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==",
"dev": true, "dev": true,
"peer": true,
"dependencies": { "dependencies": {
"@babel/code-frame": "^7.27.1", "@babel/code-frame": "^7.27.1",
"@babel/generator": "^7.28.5", "@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", "resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-svg-core/-/fontawesome-svg-core-7.1.0.tgz",
"integrity": "sha512-fNxRUk1KhjSbnbuBxlWSnBLKLBNun52ZBTcs22H/xEEzM6Ap81ZFTQ4bZBxVQGQgVY0xugKGoRcCbaKjLQ3XZA==", "integrity": "sha512-fNxRUk1KhjSbnbuBxlWSnBLKLBNun52ZBTcs22H/xEEzM6Ap81ZFTQ4bZBxVQGQgVY0xugKGoRcCbaKjLQ3XZA==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@fortawesome/fontawesome-common-types": "7.1.0" "@fortawesome/fontawesome-common-types": "7.1.0"
}, },
@@ -1463,7 +1461,6 @@
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.10.1.tgz", "resolved": "https://registry.npmjs.org/@types/node/-/node-24.10.1.tgz",
"integrity": "sha512-GNWcUTRBgIRJD5zj+Tq0fKOJ5XZajIiBroOF0yvj2bSU1WvNdYS/dn9UxwsujGW4JX06dnHyjV2y9rRaybH0iQ==", "integrity": "sha512-GNWcUTRBgIRJD5zj+Tq0fKOJ5XZajIiBroOF0yvj2bSU1WvNdYS/dn9UxwsujGW4JX06dnHyjV2y9rRaybH0iQ==",
"dev": true, "dev": true,
"peer": true,
"dependencies": { "dependencies": {
"undici-types": "~7.16.0" "undici-types": "~7.16.0"
} }
@@ -1473,7 +1470,6 @@
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.7.tgz", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.7.tgz",
"integrity": "sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg==", "integrity": "sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg==",
"dev": true, "dev": true,
"peer": true,
"dependencies": { "dependencies": {
"csstype": "^3.2.2" "csstype": "^3.2.2"
} }
@@ -1530,7 +1526,6 @@
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.48.0.tgz", "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==", "integrity": "sha512-jCzKdm/QK0Kg4V4IK/oMlRZlY+QOcdjv89U2NgKHZk1CYTj82/RVSx1mV/0gqCVMJ/DA+Zf/S4NBWNF8GQ+eqQ==",
"dev": true, "dev": true,
"peer": true,
"dependencies": { "dependencies": {
"@typescript-eslint/scope-manager": "8.48.0", "@typescript-eslint/scope-manager": "8.48.0",
"@typescript-eslint/types": "8.48.0", "@typescript-eslint/types": "8.48.0",
@@ -1769,7 +1764,6 @@
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
"dev": true, "dev": true,
"peer": true,
"bin": { "bin": {
"acorn": "bin/acorn" "acorn": "bin/acorn"
}, },
@@ -1869,7 +1863,6 @@
"url": "https://github.com/sponsors/ai" "url": "https://github.com/sponsors/ai"
} }
], ],
"peer": true,
"dependencies": { "dependencies": {
"baseline-browser-mapping": "^2.8.25", "baseline-browser-mapping": "^2.8.25",
"caniuse-lite": "^1.0.30001754", "caniuse-lite": "^1.0.30001754",
@@ -2108,7 +2101,6 @@
"resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.1.tgz", "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.1.tgz",
"integrity": "sha512-BhHmn2yNOFA9H9JmmIVKJmd288g9hrVRDkdoIgRCRuSySRUHH7r/DI6aAXW9T1WwUuY3DFgrcaqB+deURBLR5g==", "integrity": "sha512-BhHmn2yNOFA9H9JmmIVKJmd288g9hrVRDkdoIgRCRuSySRUHH7r/DI6aAXW9T1WwUuY3DFgrcaqB+deURBLR5g==",
"dev": true, "dev": true,
"peer": true,
"dependencies": { "dependencies": {
"@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/eslint-utils": "^4.8.0",
"@eslint-community/regexpp": "^4.12.1", "@eslint-community/regexpp": "^4.12.1",
@@ -2677,7 +2669,6 @@
"version": "1.13.2", "version": "1.13.2",
"resolved": "https://registry.npmjs.org/mapbox-gl/-/mapbox-gl-1.13.2.tgz", "resolved": "https://registry.npmjs.org/mapbox-gl/-/mapbox-gl-1.13.2.tgz",
"integrity": "sha512-CPjtWygL+f7naL+sGHoC2JQR0DG7u+9ik6WdkjjVmz2uy0kBC2l+aKfdi3ZzUR7VKSQJ6Mc/CeCN+6iVNah+ww==", "integrity": "sha512-CPjtWygL+f7naL+sGHoC2JQR0DG7u+9ik6WdkjjVmz2uy0kBC2l+aKfdi3ZzUR7VKSQJ6Mc/CeCN+6iVNah+ww==",
"peer": true,
"dependencies": { "dependencies": {
"@mapbox/geojson-rewind": "^0.5.0", "@mapbox/geojson-rewind": "^0.5.0",
"@mapbox/geojson-types": "^1.0.2", "@mapbox/geojson-types": "^1.0.2",
@@ -2887,7 +2878,6 @@
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"dev": true, "dev": true,
"peer": true,
"engines": { "engines": {
"node": ">=12" "node": ">=12"
}, },
@@ -2960,7 +2950,6 @@
"version": "19.2.0", "version": "19.2.0",
"resolved": "https://registry.npmjs.org/react/-/react-19.2.0.tgz", "resolved": "https://registry.npmjs.org/react/-/react-19.2.0.tgz",
"integrity": "sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ==", "integrity": "sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ==",
"peer": true,
"engines": { "engines": {
"node": ">=0.10.0" "node": ">=0.10.0"
} }
@@ -2969,7 +2958,6 @@
"version": "19.2.0", "version": "19.2.0",
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.0.tgz", "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.0.tgz",
"integrity": "sha512-UlbRu4cAiGaIewkPyiRGJk0imDN2T3JjieT6spoL2UeSf5od4n5LB/mQ4ejmxhCFT1tYe8IvaFulzynWovsEFQ==", "integrity": "sha512-UlbRu4cAiGaIewkPyiRGJk0imDN2T3JjieT6spoL2UeSf5od4n5LB/mQ4ejmxhCFT1tYe8IvaFulzynWovsEFQ==",
"peer": true,
"dependencies": { "dependencies": {
"scheduler": "^0.27.0" "scheduler": "^0.27.0"
}, },
@@ -3228,7 +3216,6 @@
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"dev": true, "dev": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"peer": true,
"bin": { "bin": {
"tsc": "bin/tsc", "tsc": "bin/tsc",
"tsserver": "bin/tsserver" "tsserver": "bin/tsserver"
@@ -3319,7 +3306,6 @@
"resolved": "https://registry.npmjs.org/vite/-/vite-7.2.4.tgz", "resolved": "https://registry.npmjs.org/vite/-/vite-7.2.4.tgz",
"integrity": "sha512-NL8jTlbo0Tn4dUEXEsUg8KeyG/Lkmc4Fnzb8JXN/Ykm9G4HNImjtABMJgkQoVjOBN/j2WAwDTRytdqJbZsah7w==", "integrity": "sha512-NL8jTlbo0Tn4dUEXEsUg8KeyG/Lkmc4Fnzb8JXN/Ykm9G4HNImjtABMJgkQoVjOBN/j2WAwDTRytdqJbZsah7w==",
"dev": true, "dev": true,
"peer": true,
"dependencies": { "dependencies": {
"esbuild": "^0.25.0", "esbuild": "^0.25.0",
"fdir": "^6.5.0", "fdir": "^6.5.0",
@@ -3460,7 +3446,6 @@
"resolved": "https://registry.npmjs.org/zod/-/zod-4.1.13.tgz", "resolved": "https://registry.npmjs.org/zod/-/zod-4.1.13.tgz",
"integrity": "sha512-AvvthqfqrAhNH9dnfmrfKzX5upOdjUVJYFqNSlkmGf64gRaTzlPwz99IHYnVs28qYAybvAlBV+H7pn0saFY4Ig==", "integrity": "sha512-AvvthqfqrAhNH9dnfmrfKzX5upOdjUVJYFqNSlkmGf64gRaTzlPwz99IHYnVs28qYAybvAlBV+H7pn0saFY4Ig==",
"dev": true, "dev": true,
"peer": true,
"funding": { "funding": {
"url": "https://github.com/sponsors/colinhacks" "url": "https://github.com/sponsors/colinhacks"
} }
+198 -23
View File
@@ -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 API_URL = "/api/v1";
const BACKEND_URL = ""; const BACKEND_URL = "";
export function getMediaUrl(url: string): string { export function getMediaUrl(url: string): string {
@@ -28,19 +22,15 @@ import type {
CancelCommandResponse, CancelCommandResponse,
PenaltiesResponse, PenaltiesResponse,
} from "./api_types"; } from "./api_types";
// ============================================
// 🔐 TYPES - AUTHRESPONSE COMPLETE
// ============================================
/**
* TYPE CORRECT - Inclut access_token!
*/
export interface AuthResponse { export interface AuthResponse {
success: boolean; success: boolean;
message?: string; message?: string;
access_token?: string; // ✅ CRITICAL! access_token?: string;
token_type?: string; token_type?: string;
expires_in?: number; expires_in?: number;
requires_2fa?: boolean;
session_token?: string;
user?: { user?: {
id: number; id: number;
username: string; 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 => { export const extractUsernameFromToken = (): string | null => {
try { try {
// ✅ CRITICAL: sessionStorage (pas localStorage!)
const token = sessionStorage.getItem("token"); const token = sessionStorage.getItem("token");
if (!token) { if (!token) {
@@ -210,6 +191,15 @@ export const loginUser = async (
const data = await safeJson(response); const data = await safeJson(response);
console.log("📋 [LOGIN] Réponse:", data); 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 // ✅ Vérifier access_token
if (!data.access_token) { if (!data.access_token) {
console.error("❌ [LOGIN] Pas de 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, pay_currency: data.pay_currency as string | undefined,
price_amount: data.price_amount as number | undefined, price_amount: data.price_amount as number | undefined,
price_currency: data.price_currency as string | 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) { } catch (error) {
console.error("❌ [CHECKOUT] Erreur:", error); console.error("❌ [CHECKOUT] Erreur:", error);
@@ -804,8 +797,9 @@ export interface Product {
category: string; category: string;
unit?: string; unit?: string;
stock: number; stock: number;
prices?: Array<{ quantity: number; price: number }>; prices?: Array<{ quantity: number; price: number; active_price?: boolean }>;
media?: MediaItem[]; // ✅ CHANGÉ: string[] → MediaItem[] media?: MediaItem[]; // ✅ CHANGÉ: string[] → MediaItem[]
coming_soon?: boolean;
} }
export interface Category { export interface Category {
@@ -1866,6 +1860,9 @@ export interface PublicSettings {
crypto_payment_enabled: boolean; crypto_payment_enabled: boolean;
crypto_only: boolean; crypto_only: boolean;
nowpayments_currencies: string[]; nowpayments_currencies: string[];
shop_name: string;
two_fa_enabled: boolean;
contact_telegram: string;
} }
export const getPublicSettings = async (): Promise<PublicSettings> => { export const getPublicSettings = async (): Promise<PublicSettings> => {
@@ -1880,6 +1877,9 @@ export const getPublicSettings = async (): Promise<PublicSettings> => {
crypto_payment_enabled: false, crypto_payment_enabled: false,
crypto_only: false, crypto_only: false,
nowpayments_currencies: [], nowpayments_currencies: [],
shop_name: "Milieu-Nantais",
two_fa_enabled: false,
contact_telegram: "",
}; };
try { try {
const response = await fetch(`${API_URL}/app-settings`); const response = await fetch(`${API_URL}/app-settings`);
@@ -1901,12 +1901,93 @@ export const getPublicSettings = async (): Promise<PublicSettings> => {
nowpayments_currencies: Array.isArray(data.nowpayments_currencies) nowpayments_currencies: Array.isArray(data.nowpayments_currencies)
? 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 { } catch {
return defaults; return defaults;
} }
}; };
export const verify2FA = async (
sessionToken: string,
code: string,
): Promise<AuthResponse> => {
try {
const response = await fetch(`${API_URL}/auth/2fa/verify`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ session_token: sessionToken, code }),
});
const data = await safeJson(response);
if (!response.ok) {
return { success: false, message: data.error || "Code invalide" };
}
sessionStorage.setItem("token", data.access_token);
syncUsernameFromJWT();
return {
success: true,
access_token: data.access_token,
token_type: data.token_type,
expires_in: data.expires_in,
user: data.user,
};
} catch (error) {
return {
success: false,
message: error instanceof Error ? error.message : "Erreur",
};
}
};
export const get2FAStatus = async (): Promise<{
two_fa_enabled: boolean;
telegram_linked: boolean;
admin_2fa_enabled: boolean;
}> => {
const token = sessionStorage.getItem("token");
try {
const response = await fetch(`${API_URL}/two-fa/status`, {
headers: { Authorization: `Bearer ${token}` },
});
if (!response.ok)
return {
two_fa_enabled: false,
telegram_linked: false,
admin_2fa_enabled: false,
};
return await safeJson(response);
} catch {
return {
two_fa_enabled: false,
telegram_linked: false,
admin_2fa_enabled: false,
};
}
};
export const toggle2FA = async (
enabled: boolean,
): Promise<{ success: boolean; error?: string }> => {
const token = sessionStorage.getItem("token");
try {
const response = await fetch(`${API_URL}/two-fa/toggle`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({ enabled }),
});
const data = await safeJson(response);
if (!response.ok) return { success: false, error: data.error };
return { success: true };
} catch {
return { success: false, error: "Erreur réseau" };
}
};
export interface CryptoPaymentStatus { export interface CryptoPaymentStatus {
command_id: number; command_id: number;
client_order_number?: number; client_order_number?: number;
@@ -2058,3 +2139,97 @@ export const unlinkTelegram = async (): Promise<void> => {
/* silencieux */ /* 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" };
}
};
+6 -11
View File
@@ -1,16 +1,7 @@
// ============================================ // ============================================
// api/api_TYPES.ts - TOUTES LES INTERFACES // 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 { export interface ApiResponse {
success: boolean; success: boolean;
message?: string; message?: string;
@@ -19,8 +10,7 @@ export interface ApiResponse {
token_type?: string; token_type?: string;
expires_in?: number; expires_in?: number;
user?: UserResponse; user?: UserResponse;
// eslint-disable-next-line @typescript-eslint/no-explicit-any [key: string]: unknown;
[key: string]: any; // Pour les champs additionnels
} }
/** /**
@@ -58,6 +48,8 @@ export interface LoginResponse {
token_type?: string; token_type?: string;
expires_in?: number; expires_in?: number;
user?: UserResponse; user?: UserResponse;
requires_2fa?: boolean;
session_token?: string;
} }
/** /**
@@ -118,6 +110,7 @@ export interface CartItem {
quantity: number; quantity: number;
category: string; category: string;
image?: string; image?: string;
is_reward?: boolean;
} }
/** /**
@@ -364,6 +357,7 @@ export interface TrackingResponse {
export interface ProductPrice { export interface ProductPrice {
quantity: number; quantity: number;
price: number; price: number;
active_price?: boolean;
} }
export interface Product { export interface Product {
id: number; id: number;
@@ -580,6 +574,7 @@ export interface CompletedOrder {
status: string; status: string;
adresse: string; adresse: string;
total_prix: number; total_prix: number;
referral_used?: number;
livreur_assign?: string; livreur_assign?: string;
created_at: string; created_at: string;
updated_at: string; updated_at: string;
+87 -38
View File
@@ -443,59 +443,106 @@ body {
} }
/* ============================================================ /* ============================================================
Notification panel Notification bottom-sheet modal
============================================================ */ ============================================================ */
.notif-panel { .notif-modal-overlay {
position: absolute; position: fixed;
top: calc(var(--topbar-h) - 4px); inset: 0;
right: 0; z-index: 1100;
width: 300px; background: rgba(0, 0, 0, 0.55);
max-height: 380px; 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); background: var(--surface);
border: 1px solid var(--border); border-top-left-radius: 20px;
border-radius: 14px; border-top-right-radius: 20px;
overflow: hidden;
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.55);
display: flex; display: flex;
flex-direction: column; 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 { @keyframes slideUp {
from { opacity: 0; transform: translateY(-6px); } from { transform: translateY(100%); }
to { opacity: 1; transform: translateY(0); } to { transform: translateY(0); }
} }
.notif-panel-header { .notif-modal-header {
padding: 0.7rem 1rem; display: flex;
align-items: center;
justify-content: space-between;
padding: 1.1rem 1.25rem;
border-bottom: 1px solid var(--border); border-bottom: 1px solid var(--border);
color: var(--text); flex-shrink: 0;
font-weight: 600;
font-size: 0.85rem;
letter-spacing: 0.02em;
} }
.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 { .notif-empty {
padding: 1.5rem; display: flex;
text-align: center; flex-direction: column;
align-items: center;
justify-content: center;
padding: 3rem 1.5rem;
color: var(--text-muted); color: var(--text-muted);
font-size: 0.85rem; font-size: 0.9rem;
gap: 0.5rem;
height: 100%;
} }
.notif-list { .notif-empty p { margin: 0; }
list-style: none;
margin: 0;
padding: 0;
overflow-y: auto;
max-height: 320px;
}
.notif-item { .notif-item {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 0.2rem; gap: 0.3rem;
padding: 0.7rem 1rem; padding: 0.9rem 1.25rem;
border-bottom: 1px solid var(--border); border-bottom: 1px solid var(--border);
border-left: 3px solid transparent;
transition: background var(--transition); transition: background var(--transition);
} }
@@ -503,20 +550,22 @@ body {
.notif-unread { .notif-unread {
background: var(--primary-soft); 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 { .notif-message {
color: var(--text); color: var(--text);
font-size: 0.83rem; font-size: 0.88rem;
line-height: 1.45; line-height: 1.5;
} }
.notif-time { .notif-time {
color: var(--text-muted); color: var(--text-muted);
font-size: 0.72rem; font-size: 0.74rem;
} }
/* ============================================================ /* ============================================================
+59 -44
View File
@@ -31,6 +31,22 @@ interface MenuItem {
path: string; 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() { function Navbar() {
const { theme, toggleTheme } = useTheme(); const { theme, toggleTheme } = useTheme();
const [isMenuOpen, setIsMenuOpen] = useState<boolean>(false); const [isMenuOpen, setIsMenuOpen] = useState<boolean>(false);
@@ -39,9 +55,9 @@ function Navbar() {
const [unreadCount, setUnreadCount] = useState(0); const [unreadCount, setUnreadCount] = useState(0);
const [showNotifPanel, setShowNotifPanel] = useState(false); const [showNotifPanel, setShowNotifPanel] = useState(false);
const [referralEnabled, setReferralEnabled] = useState(true); const [referralEnabled, setReferralEnabled] = useState(true);
const [shopName, setShopName] = useState("Milieu-Nantais");
const seenKeysRef = useRef<Set<string>>(new Set()); const seenKeysRef = useRef<Set<string>>(new Set());
const isFirstLoadRef = useRef(true); const isFirstLoadRef = useRef(true);
const notifPanelRef = useRef<HTMLDivElement>(null);
const { cartCount } = useCart(); const { cartCount } = useCart();
const navigate = useNavigate(); const navigate = useNavigate();
const location = useLocation(); const location = useLocation();
@@ -74,28 +90,23 @@ function Navbar() {
}, [fetchNotifications]); }, [fetchNotifications]);
useEffect(() => { useEffect(() => {
getPublicSettings().then((s) => setReferralEnabled(s.referral_enabled)); getPublicSettings().then((s) => {
}, []); setReferralEnabled(s.referral_enabled);
if (s.shop_name) setShopName(s.shop_name);
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);
}, []); }, []);
const handleNotifBellClick = async () => { const handleNotifBellClick = async () => {
setShowNotifPanel((prev) => !prev); setShowNotifPanel(true);
if (!showNotifPanel && unreadCount > 0) { if (unreadCount > 0) {
await markNotificationsRead(); await markNotificationsRead();
setUnreadCount(0); setUnreadCount(0);
setNotifications((prev) => prev.map((n) => ({ ...n, read: true }))); setNotifications((prev) => prev.map((n) => ({ ...n, read: true })));
} }
}; };
const closeNotifPanel = () => setShowNotifPanel(false);
const menuItems: MenuItem[] = [ const menuItems: MenuItem[] = [
{ id: "accueil", label: "Accueil", icon: faHome, path: "/user/accueil" }, { id: "accueil", label: "Accueil", icon: faHome, path: "/user/accueil" },
{ id: "produits", label: "Nos Produits", icon: faBox, path: "/user/nos-produits" }, { id: "produits", label: "Nos Produits", icon: faBox, path: "/user/nos-produits" },
@@ -156,7 +167,7 @@ function Navbar() {
<FontAwesomeIcon icon={isMenuOpen ? faTimes : faBars} /> <FontAwesomeIcon icon={isMenuOpen ? faTimes : faBars} />
</button> </button>
<span className="topbar-brand">MilieuNantais</span> <span className="topbar-brand">{shopName}</span>
<div className="topbar-actions"> <div className="topbar-actions">
{/* Theme toggle */} {/* Theme toggle */}
@@ -169,7 +180,6 @@ function Navbar() {
</button> </button>
{/* Notifications */} {/* Notifications */}
<div className="notif-wrapper" ref={notifPanelRef}>
<button <button
className="topbar-icon-btn" className="topbar-icon-btn"
onClick={handleNotifBellClick} onClick={handleNotifBellClick}
@@ -183,33 +193,6 @@ function Navbar() {
)} )}
</button> </button>
{showNotifPanel && (
<div className="notif-panel">
<div className="notif-panel-header">Notifications</div>
{notifications.length === 0 ? (
<div className="notif-empty">Aucune notification</div>
) : (
<ul className="notif-list">
{notifications.map((n, i) => (
<li
key={i}
className={`notif-item ${n.read ? "notif-read" : "notif-unread"}`}
>
<span className="notif-message">{n.message}</span>
<span className="notif-time">
{new Date(n.created_at).toLocaleTimeString("fr-FR", {
hour: "2-digit",
minute: "2-digit",
})}
</span>
</li>
))}
</ul>
)}
</div>
)}
</div>
{/* Panier */} {/* Panier */}
<button <button
className="topbar-icon-btn" className="topbar-icon-btn"
@@ -224,6 +207,38 @@ function Navbar() {
</div> </div>
</header> </header>
{/* ── Notifications modal ─────────────────────── */}
{showNotifPanel && (
<div className="notif-modal-overlay" onClick={closeNotifPanel}>
<div className="notif-modal" onClick={(e) => e.stopPropagation()}>
<div className="notif-modal-header">
<span className="notif-modal-title">Notifications</span>
<button className="notif-modal-close" onClick={closeNotifPanel} aria-label="Fermer">
<FontAwesomeIcon icon={faTimes} />
</button>
</div>
<div className="notif-modal-body">
{notifications.length === 0 ? (
<div className="notif-empty">
<FontAwesomeIcon icon={faBell} style={{ fontSize: "2rem", marginBottom: "0.75rem", opacity: 0.3 }} />
<p>Aucune notification</p>
</div>
) : (
notifications.map((n, i) => (
<div
key={i}
className={`notif-item ${n.read ? "notif-read" : "notif-unread"}`}
>
<span className="notif-message">{n.message}</span>
<span className="notif-time">{formatNotifDate(n.created_at)}</span>
</div>
))
)}
</div>
</div>
</div>
)}
{/* ── Overlay ─────────────────────────────────── */} {/* ── Overlay ─────────────────────────────────── */}
{isMenuOpen && <div className="sidebar-overlay" onClick={closeMenu} />} {isMenuOpen && <div className="sidebar-overlay" onClick={closeMenu} />}
@@ -235,7 +250,7 @@ function Navbar() {
<FontAwesomeIcon icon={faShoppingCart} /> <FontAwesomeIcon icon={faShoppingCart} />
</div> </div>
<div> <div>
<p className="sidebar-brand-name">Milieu-Nantais</p> <p className="sidebar-brand-name">{shopName}</p>
<p className="sidebar-brand-sub">Mon espace</p> <p className="sidebar-brand-sub">Mon espace</p>
</div> </div>
</div> </div>
+25 -1
View File
@@ -14,7 +14,8 @@
.product-card:hover { .product-card:hover {
transform: scale(1.08); 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; z-index: 10;
} }
@@ -62,6 +63,29 @@
0 0 10px rgba(255, 0, 0, 0.5); 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 { .product-card.out-of-stock {
opacity: 0.75; opacity: 0.75;
} }
+30 -10
View File
@@ -7,7 +7,13 @@ import "./ProductCard.css";
function getTextColor(hex: string): string { function getTextColor(hex: string): string {
const h = hex.replace("#", ""); 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 r = parseInt(full.slice(0, 2), 16);
const g = parseInt(full.slice(2, 4), 16); const g = parseInt(full.slice(2, 4), 16);
const b = parseInt(full.slice(4, 6), 16); const b = parseInt(full.slice(4, 6), 16);
@@ -22,10 +28,11 @@ interface ProductCardProps {
image: string; image: string;
stock: number; stock: number;
category: string; category: string;
prices?: Array<{ quantity: number; price: number }>; prices?: Array<{ quantity: number; price: number; active_price?: boolean }>;
hasVideo?: boolean; hasVideo?: boolean;
videoUrl?: string; // ✨ Nouveau prop pour l'URL de la vidéo videoUrl?: string; // ✨ Nouveau prop pour l'URL de la vidéo
categoryColor?: string; categoryColor?: string;
coming_soon?: boolean;
} }
function ProductCard({ function ProductCard({
@@ -40,6 +47,7 @@ function ProductCard({
hasVideo = false, hasVideo = false,
videoUrl, videoUrl,
categoryColor, categoryColor,
coming_soon,
}: ProductCardProps) { }: ProductCardProps) {
const navigate = useNavigate(); const navigate = useNavigate();
const { addToCart } = useCart(); const { addToCart } = useCart();
@@ -52,6 +60,7 @@ function ProductCard({
const [showVideo, setShowVideo] = useState(false); // ✨ État pour afficher/masquer la vidéo const [showVideo, setShowVideo] = useState(false); // ✨ État pour afficher/masquer la vidéo
const isOutOfStock = stock === 0; const isOutOfStock = stock === 0;
const isComingSoon = coming_soon === true;
const normalizedCategory = (category || "autre").toLowerCase().trim(); const normalizedCategory = (category || "autre").toLowerCase().trim();
const handleDetailsClick = (e: React.MouseEvent) => { const handleDetailsClick = (e: React.MouseEvent) => {
@@ -156,6 +165,9 @@ function ProductCard({
{isOutOfStock && ( {isOutOfStock && (
<div className="sold-out-overlay">SOLD OUT</div> <div className="sold-out-overlay">SOLD OUT</div>
)} )}
{isComingSoon && (
<div className="coming-soon-overlay">COMMING SOON</div>
)}
</div> </div>
<div className="product-info"> <div className="product-info">
@@ -177,17 +189,22 @@ function ProductCard({
) : ( ) : (
<> <>
<button <button
className={`quick-add-btn ${isOutOfStock ? "disabled" : ""}`} className={`quick-add-btn ${isOutOfStock || isComingSoon ? "disabled" : ""}`}
onClick={handleQuickAddClick} onClick={handleQuickAddClick}
disabled={isOutOfStock} disabled={isOutOfStock || isComingSoon}
style={ style={
categoryColor && !isOutOfStock categoryColor && !isOutOfStock && !isComingSoon
? { background: categoryColor, color: getTextColor(categoryColor) } ? {
background: categoryColor,
color: getTextColor(categoryColor),
}
: undefined : undefined
} }
> >
{isOutOfStock {isOutOfStock
? "Rupture de stock" ? "Rupture de stock"
: isComingSoon
? "BIENTÔT DISPONIBLE"
: "Ajouter rapidement"} : "Ajouter rapidement"}
</button> </button>
@@ -209,8 +226,9 @@ function ProductCard({
key={priceOption.quantity} key={priceOption.quantity}
value={priceOption.quantity} value={priceOption.quantity}
> >
{priceOption.quantity}{unit} -{" "} {priceOption.quantity}
{priceOption.price.toFixed(2)} {unit} - {priceOption.price.toFixed(2)}{" "}
</option> </option>
))} ))}
</select> </select>
@@ -220,7 +238,9 @@ function ProductCard({
</div> </div>
{/* ✨ Modal vidéo — rendu via Portal pour éviter le clipping du transform:scale sur .product-card */} {/* ✨ Modal vidéo — rendu via Portal pour éviter le clipping du transform:scale sur .product-card */}
{showVideo && videoUrl && createPortal( {showVideo &&
videoUrl &&
createPortal(
<div className="video-modal" onClick={handleCloseVideo}> <div className="video-modal" onClick={handleCloseVideo}>
<div <div
className="video-modal-content" className="video-modal-content"
@@ -244,7 +264,7 @@ function ProductCard({
</video> </video>
</div> </div>
</div>, </div>,
document.body document.body,
)} )}
</div> </div>
); );
@@ -30,6 +30,7 @@ export interface CartItem {
quantity: number; // ✨ En GRAMMES (pas "combien de fois") quantity: number; // ✨ En GRAMMES (pas "combien de fois")
category: string; category: string;
image?: string; image?: string;
is_reward?: boolean;
} }
interface CartContextType { interface CartContextType {
@@ -129,6 +130,7 @@ export function CartProvider({ children }: { children: ReactNode }) {
quantity: item.quantity as number, quantity: item.quantity as number,
category: category, category: category,
image: item.image as string | undefined, image: item.image as string | undefined,
is_reward: item.is_reward as boolean | undefined,
}; };
}); });
@@ -216,3 +216,109 @@
font-size: 0.85rem; font-size: 0.85rem;
font-weight: 400 !important; font-weight: 400 !important;
} }
/* Modal feedback */
.cp-modal-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.6);
backdrop-filter: blur(4px);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
padding: 1rem;
}
.cp-modal {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 1rem;
padding: 2rem 1.75rem 1.75rem;
width: 100%;
max-width: 22rem;
position: relative;
display: flex;
flex-direction: column;
align-items: center;
gap: 0.75rem;
text-align: center;
box-shadow: 0 25px 60px rgba(0, 0, 0, 0.5);
}
.cp-modal-close {
position: absolute;
top: 0.75rem;
right: 0.75rem;
background: transparent;
border: none;
color: var(--text-muted);
cursor: pointer;
padding: 0.25rem;
display: flex;
align-items: center;
border-radius: 0.375rem;
transition: color 0.2s, background 0.2s;
}
.cp-modal-close:hover { color: var(--text); background: var(--bg); }
.cp-modal-icon {
width: 3.5rem;
height: 3.5rem;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 0.25rem;
}
.cp-modal-icon--success {
background: rgba(34, 197, 94, 0.12);
border: 2px solid rgba(34, 197, 94, 0.35);
color: #4ade80;
}
.cp-modal-icon--error {
background: rgba(239, 68, 68, 0.12);
border: 2px solid rgba(239, 68, 68, 0.35);
color: #f87171;
}
.cp-modal-title {
font-size: 1.1rem;
font-weight: 700;
color: var(--text);
margin: 0;
}
.cp-modal-body {
font-size: 0.9rem;
color: var(--text-muted);
margin: 0;
line-height: 1.5;
}
.cp-modal-btn {
margin-top: 0.5rem;
width: 100%;
padding: 0.7rem 1rem;
border: none;
border-radius: 0.5rem;
font-weight: 600;
font-size: 0.9rem;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
gap: 0.4rem;
transition: opacity 0.2s;
}
.cp-modal-btn:hover { opacity: 0.88; }
.cp-modal-btn--success {
background: linear-gradient(135deg, #7c3aed, #6d28d9);
color: #fff;
box-shadow: 0 4px 14px rgba(109, 40, 217, 0.4);
}
.cp-modal-btn--error {
background: rgba(239, 68, 68, 0.12);
color: #f87171;
border: 1px solid rgba(239, 68, 68, 0.3);
}
@@ -1,5 +1,5 @@
import { useState } from "react"; import { 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.css";
import { changePassword } from "../../api/api"; import { changePassword } from "../../api/api";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
@@ -14,8 +14,9 @@ const ChangePasswordPage = () => {
const [showNew, setShowNew] = useState(false); const [showNew, setShowNew] = useState(false);
const [showConfirm, setShowConfirm] = useState(false); const [showConfirm, setShowConfirm] = useState(false);
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState(""); const [showSuccessModal, setShowSuccessModal] = useState(false);
const [success, setSuccess] = useState(false); const [showErrorModal, setShowErrorModal] = useState(false);
const [errorMsg, setErrorMsg] = useState('');
const validate = (): string | null => { const validate = (): string | null => {
if (!currentPassword) return "Le mot de passe actuel est requis"; if (!currentPassword) return "Le mot de passe actuel est requis";
@@ -28,11 +29,11 @@ const ChangePasswordPage = () => {
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => { const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault(); e.preventDefault();
setError("");
const validationError = validate(); const validationError = validate();
if (validationError) { if (validationError) {
setError(validationError); setErrorMsg(validationError);
setShowErrorModal(true);
return; return;
} }
@@ -40,10 +41,10 @@ const ChangePasswordPage = () => {
try { try {
const result = await changePassword(currentPassword, newPassword); const result = await changePassword(currentPassword, newPassword);
if (result.success) { if (result.success) {
setSuccess(true); setShowSuccessModal(true);
setTimeout(() => navigate("/user/accueil"), 1800);
} else { } else {
setError(result.message); setErrorMsg(result.message || 'Erreur lors du changement de mot de passe.');
setShowErrorModal(true);
} }
} finally { } finally {
setIsLoading(false); setIsLoading(false);
@@ -51,6 +52,7 @@ const ChangePasswordPage = () => {
}; };
return ( return (
<>
<div className="cp-container"> <div className="cp-container">
<div className="cp-content"> <div className="cp-content">
<div className="cp-header"> <div className="cp-header">
@@ -65,20 +67,7 @@ const ChangePasswordPage = () => {
</div> </div>
<div className="cp-card"> <div className="cp-card">
{success ? (
<div className="cp-success">
<span className="cp-success-icon"></span>
<p>Mot de passe mis à jour avec succès !</p>
<p className="cp-success-sub">Redirection en cours</p>
</div>
) : (
<form className="cp-form" onSubmit={handleSubmit}> <form className="cp-form" onSubmit={handleSubmit}>
{error && (
<div className="cp-error-banner">
{error}
</div>
)}
{/* Mot de passe actuel */} {/* Mot de passe actuel */}
<div className="cp-form-group"> <div className="cp-form-group">
<label className="cp-label">Mot de passe actuel</label> <label className="cp-label">Mot de passe actuel</label>
@@ -87,7 +76,7 @@ const ChangePasswordPage = () => {
<input <input
type={showCurrent ? "text" : "password"} type={showCurrent ? "text" : "password"}
value={currentPassword} value={currentPassword}
onChange={(e) => { setCurrentPassword(e.target.value); setError(""); }} onChange={(e) => { setCurrentPassword(e.target.value); }}
className="cp-input" className="cp-input"
placeholder="••••••••" placeholder="••••••••"
disabled={isLoading} disabled={isLoading}
@@ -116,7 +105,7 @@ const ChangePasswordPage = () => {
<input <input
type={showNew ? "text" : "password"} type={showNew ? "text" : "password"}
value={newPassword} value={newPassword}
onChange={(e) => { setNewPassword(e.target.value); setError(""); }} onChange={(e) => { setNewPassword(e.target.value); }}
className="cp-input" className="cp-input"
placeholder="Minimum 8 caractères" placeholder="Minimum 8 caractères"
disabled={isLoading} disabled={isLoading}
@@ -148,7 +137,7 @@ const ChangePasswordPage = () => {
<input <input
type={showConfirm ? "text" : "password"} type={showConfirm ? "text" : "password"}
value={confirmPassword} value={confirmPassword}
onChange={(e) => { setConfirmPassword(e.target.value); setError(""); }} onChange={(e) => { setConfirmPassword(e.target.value); }}
className={`cp-input ${confirmPassword && confirmPassword !== newPassword ? "cp-input-error" : ""}`} className={`cp-input ${confirmPassword && confirmPassword !== newPassword ? "cp-input-error" : ""}`}
placeholder="••••••••" placeholder="••••••••"
disabled={isLoading} disabled={isLoading}
@@ -180,10 +169,45 @@ const ChangePasswordPage = () => {
{isLoading ? "Mise à jour…" : "Confirmer le nouveau mot de passe"} {isLoading ? "Mise à jour…" : "Confirmer le nouveau mot de passe"}
</button> </button>
</form> </form>
</div>
</div>
</div>
{/* Modal succès */}
{showSuccessModal && (
<div className="cp-modal-overlay" onClick={() => navigate('/user/accueil')}>
<div className="cp-modal" onClick={(e) => e.stopPropagation()}>
<div className="cp-modal-icon cp-modal-icon--success">
<CheckCircle size={28} />
</div>
<h3 className="cp-modal-title">Mot de passe mis à jour</h3>
<p className="cp-modal-body">Votre mot de passe a é changé avec succès.</p>
<button className="cp-modal-btn cp-modal-btn--success" onClick={() => navigate('/user/accueil')}>
<CheckCircle size={16} /> Continuer
</button>
</div>
</div>
)} )}
{/* Modal erreur */}
{showErrorModal && (
<div className="cp-modal-overlay" onClick={() => setShowErrorModal(false)}>
<div className="cp-modal" onClick={(e) => e.stopPropagation()}>
<button className="cp-modal-close" onClick={() => setShowErrorModal(false)}>
<X size={18} />
</button>
<div className="cp-modal-icon cp-modal-icon--error">
<AlertTriangle size={28} />
</div>
<h3 className="cp-modal-title">Une erreur est survenue</h3>
<p className="cp-modal-body">{errorMsg}</p>
<button className="cp-modal-btn cp-modal-btn--error" onClick={() => setShowErrorModal(false)}>
<X size={16} /> Fermer
</button>
</div> </div>
</div> </div>
</div> )}
</>
); );
}; };
+23 -1
View File
@@ -12,6 +12,28 @@
overflow-y: auto; 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 { .login-content {
width: 100%; width: 100%;
max-width: 28rem; max-width: 28rem;
@@ -136,7 +158,7 @@
.error-message { .error-message {
margin-top: 0.5rem; margin-top: 0.5rem;
font-size: 0.875rem; font-size: 0.875rem;
color: #f87171; color: var(--red);
} }
.error-banner { .error-banner {
+103 -18
View File
@@ -1,12 +1,14 @@
import { useState } from "react"; 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 "./Login.css";
import { loginUser, syncUsernameFromJWT } from "../../api/api"; import { loginUser, verify2FA, syncUsernameFromJWT } from "../../api/api";
import type { LoginRequest } from "../../api/api_types"; import type { LoginRequest } from "../../api/api_types";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { useTheme } from "../../context/ThemeContext";
const LoginClient = () => { const LoginClient = () => {
const navigate = useNavigate(); const navigate = useNavigate();
const { theme, toggleTheme } = useTheme();
const [formData, setFormData] = useState<LoginRequest>({ const [formData, setFormData] = useState<LoginRequest>({
username: "", username: "",
@@ -21,6 +23,10 @@ const LoginClient = () => {
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const [apiError, setApiError] = useState<string>(""); const [apiError, setApiError] = useState<string>("");
const [twoFAStep, setTwoFAStep] = useState(false);
const [sessionToken, setSessionToken] = useState("");
const [twoFACode, setTwoFACode] = useState("");
/** /**
* Valider le formulaire * Valider le formulaire
*/ */
@@ -79,30 +85,19 @@ const LoginClient = () => {
hasToken: !!result.access_token, hasToken: !!result.access_token,
}); });
if (result.success && result.access_token) { if (result.success && result.requires_2fa) {
console.log("✅ [LOGIN] Connexion réussie!"); setSessionToken(result.session_token || "");
setTwoFAStep(true);
// ✅ La synchronisation JWT est AUTOMATIQUE dans loginUser() } else if (result.success && result.access_token) {
// Pas besoin de le faire ici
console.log("✅ [LOGIN] Token et username synchronisés");
// ✅ Vérifier la synchronisation
const syncedUsername = syncUsernameFromJWT(); const syncedUsername = syncUsernameFromJWT();
console.log("✅ [LOGIN] Username synchronisé:", syncedUsername); console.log("✅ [LOGIN] Username synchronisé:", syncedUsername);
// ✅ Redirection
if (result.user?.must_change_password) { if (result.user?.must_change_password) {
console.log("✅ [LOGIN] Première connexion - changement de mot de passe requis");
navigate("/user/change-password"); navigate("/user/change-password");
} else { } else {
console.log("✅ [LOGIN] Redirection vers /user/accueil");
navigate("/user/accueil"); navigate("/user/accueil");
} }
} else { } else {
// ❌ Erreur API const errorMessage = result.message || "Identifiants incorrects";
const errorMessage =
result.message || "Identifiants incorrects";
console.error("❌ [LOGIN] Erreur API:", errorMessage);
setApiError(errorMessage); setApiError(errorMessage);
setErrors({ username: errorMessage }); setErrors({ username: errorMessage });
} }
@@ -117,6 +112,30 @@ const LoginClient = () => {
} }
}; };
const handle2FASubmit = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
if (!twoFACode.trim()) return;
setIsLoading(true);
setApiError("");
try {
const result = await verify2FA(sessionToken, twoFACode.trim());
if (result.success && result.access_token) {
syncUsernameFromJWT();
if (result.user?.must_change_password) {
navigate("/user/change-password");
} else {
navigate("/user/accueil");
}
} else {
setApiError(result.message || "Code invalide");
}
} catch {
setApiError("Erreur de vérification");
} finally {
setIsLoading(false);
}
};
/** /**
* Gérer les changements d'input * Gérer les changements d'input
*/ */
@@ -141,8 +160,74 @@ const LoginClient = () => {
} }
}; };
if (twoFAStep) {
return ( return (
<div className="login-container"> <div className="login-container">
<button className="login-theme-btn" onClick={toggleTheme} aria-label="Changer le thème">
{theme === "dark" ? <Sun size={18} /> : <Moon size={18} />}
</button>
<div className="login-content">
<div className="login-header">
<div className="login-logo">
<Shield className="w-8 h-8 text-white" />
</div>
<h1 className="login-title">Vérification 2FA</h1>
<p className="login-subtitle">
Entrez le code envoyé sur votre Telegram
</p>
</div>
<div className="login-card">
<form className="login-form" onSubmit={handle2FASubmit}>
{apiError && (
<div className="error-banner"> {apiError}</div>
)}
<div className="form-group">
<label htmlFor="twoFACode" className="form-label">
Code de vérification
</label>
<div className="input-wrapper">
<input
type="text"
id="twoFACode"
value={twoFACode}
onChange={(e) => setTwoFACode(e.target.value)}
className="form-input"
placeholder="000000"
maxLength={6}
disabled={isLoading}
autoComplete="one-time-code"
style={{ letterSpacing: "0.3em", textAlign: "center", fontSize: "1.5rem" }}
/>
</div>
</div>
<button
type="submit"
disabled={isLoading || twoFACode.length < 6}
className="submit-button"
style={{ opacity: isLoading ? 0.6 : 1, cursor: isLoading ? "not-allowed" : "pointer" }}
>
{isLoading ? "Vérification..." : "Confirmer"}
</button>
<button
type="button"
onClick={() => { setTwoFAStep(false); setTwoFACode(""); setApiError(""); }}
className="submit-button"
style={{ marginTop: "0.5rem", background: "transparent", border: "1px solid #555", color: "#aaa" }}
>
Retour
</button>
</form>
</div>
</div>
</div>
);
}
return (
<div className="login-container">
<button className="login-theme-btn" onClick={toggleTheme} aria-label="Changer le thème">
{theme === "dark" ? <Sun size={18} /> : <Moon size={18} />}
</button>
<div className="login-content"> <div className="login-content">
<div className="login-header"> <div className="login-header">
<div className="login-logo"> <div className="login-logo">
+5 -5
View File
@@ -70,10 +70,9 @@ function UserAccueil() {
}; };
const getProductPrice = (product: Product): number => { const getProductPrice = (product: Product): number => {
if (!product.prices || product.prices.length === 0) { const activePrices = product.prices?.filter(p => p.active_price !== false);
return 0; if (!activePrices || activePrices.length === 0) return 0;
} return activePrices[0].price;
return product.prices[0]?.price || 0;
}; };
const hasProductVideo = (product: Product): boolean => { const hasProductVideo = (product: Product): boolean => {
if (!product.media || product.media.length === 0) { if (!product.media || product.media.length === 0) {
@@ -210,7 +209,7 @@ function UserAccueil() {
image={getProductImage(product)} image={getProductImage(product)}
stock={product.stock} stock={product.stock}
category={product.category} category={product.category}
prices={product.prices} prices={product.prices?.filter(p => p.active_price !== false)}
hasVideo={hasProductVideo(product)} hasVideo={hasProductVideo(product)}
videoUrl={getProductVideoUrl(product)} videoUrl={getProductVideoUrl(product)}
categoryColor={ categoryColor={
@@ -220,6 +219,7 @@ function UserAccueil() {
product.category?.toLowerCase(), product.category?.toLowerCase(),
)?.color )?.color
} }
coming_soon={product.coming_soon}
/> />
</div> </div>
))} ))}
+16 -2
View File
@@ -17,6 +17,7 @@ interface CartItemWithMedia {
image: string; image: string;
hasVideo: boolean; hasVideo: boolean;
videoUrl?: string; videoUrl?: string;
is_reward?: boolean;
} }
function Cart() { function Cart() {
@@ -176,9 +177,22 @@ function Cart() {
{/* Infos */} {/* Infos */}
<div className="cart-row-info"> <div className="cart-row-info">
<p className="cart-row-name">{item.name_product}</p> <p className="cart-row-name">
{item.name_product}
{item.is_reward && (
<span style={{ marginLeft: "6px", fontSize: "0.7rem", fontWeight: 700, color: "#f59e0b", background: "rgba(245,158,11,0.12)", borderRadius: "4px", padding: "1px 6px" }}>
🎁 Récompense
</span>
)}
</p>
<p className="cart-row-qty">{item.quantity}g</p> <p className="cart-row-qty">{item.quantity}g</p>
<p className="cart-row-price">{item.price.toFixed(2)} </p> <p className="cart-row-price">
{item.is_reward ? (
<span style={{ color: "#10b981", fontWeight: 700 }}>Offert</span>
) : (
`${item.price.toFixed(2)}`
)}
</p>
</div> </div>
{/* Bouton supprimer */} {/* Bouton supprimer */}
@@ -181,6 +181,15 @@
margin-top: clamp(1rem, 3vw, 1.5rem); 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 { .total-price {
color: #6d28d9; color: #6d28d9;
font-size: clamp(1.2rem, 5vw, 1.5rem); font-size: clamp(1.2rem, 5vw, 1.5rem);
+27 -5
View File
@@ -1,7 +1,7 @@
import { useState, useEffect, useRef } from 'react'; import { useState, useEffect, useRef } from 'react';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { useCart } from '../../context/useCart'; 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 { CheckoutData, CryptoPaymentStatus } from '../../api/api';
import type { Product } from '../../api/api'; import type { Product } from '../../api/api';
import Navbar from '../../components/Navbar'; import Navbar from '../../components/Navbar';
@@ -80,6 +80,9 @@ function Checkout() {
const [referralEnabled, setReferralEnabled] = useState(false); const [referralEnabled, setReferralEnabled] = useState(false);
const [useReferral, setUseReferral] = useState(false); const [useReferral, setUseReferral] = useState(false);
// Telegram
const [telegramLinked, setTelegramLinked] = useState(false);
// Crypto // Crypto
const [cryptoEnabled, setCryptoEnabled] = useState(false); const [cryptoEnabled, setCryptoEnabled] = useState(false);
const [cryptoOnly, setCryptoOnly] = useState(false); const [cryptoOnly, setCryptoOnly] = useState(false);
@@ -129,6 +132,10 @@ function Checkout() {
if (res.client.prenom) setFirstName(res.client.prenom); if (res.client.prenom) setFirstName(res.client.prenom);
} }
}); });
getTelegramStatus().then((res) => {
if (res.linked) setTelegramLinked(true);
});
}, []); }, []);
// Charger settings publics (parrainage + crypto) // Charger settings publics (parrainage + crypto)
@@ -348,13 +355,13 @@ function Checkout() {
// ✅ Préparer les données pour le modal // ✅ Préparer les données pour le modal
setConfirmationData({ setConfirmationData({
command_id, command_id,
client_order_number: (response as Record<string, unknown>).client_order_number as number | undefined, client_order_number: response.client_order_number,
assigned_to, assigned_to,
queue_info, queue_info,
delivery_address: delivery_address || address, delivery_address: delivery_address || address,
arrivalTime, arrivalTime,
total: frontendTotal, total: frontendTotal,
referral_used: (response as Record<string, unknown>).referral_used as number | undefined, referral_used: response.referral_used,
clientInfo: { clientInfo: {
first_name: firstName, first_name: firstName,
last_name: lastName, last_name: lastName,
@@ -417,8 +424,19 @@ function Checkout() {
</div> </div>
<div className="summary-total"> <div className="summary-total">
<span>Total:</span> <span>Total:</span>
<span className="total-price">{total.toFixed(2)} </span> <span className="total-price">
{(useReferral && referralBalance > 0
? Math.max(0, total - referralBalance)
: total
).toFixed(2)}
</span>
</div> </div>
{useReferral && referralBalance > 0 && (
<div className="summary-referral-deduction">
<span>Dont crédit parrainage :</span>
<span>-{Math.min(referralBalance, total).toFixed(2)} </span>
</div>
)}
</div> </div>
{/* Formulaire */} {/* Formulaire */}
@@ -567,6 +585,7 @@ function Checkout() {
</div> </div>
)} )}
{!telegramLinked && (
<div className="checkout-telegram-note"> <div className="checkout-telegram-note">
<i className="fab fa-telegram" /> <i className="fab fa-telegram" />
<div> <div>
@@ -577,6 +596,7 @@ function Checkout() {
</p> </p>
</div> </div>
</div> </div>
)}
<div className="form-actions"> <div className="form-actions">
<button <button
@@ -787,7 +807,9 @@ function Checkout() {
<div className="confirmation-total-label"> <div className="confirmation-total-label">
<i className="fas fa-euro-sign"></i> TOTAL <i className="fas fa-euro-sign"></i> TOTAL
</div> </div>
<div className="confirmation-total-amount">{confirmationData.total.toFixed(2)} </div> <div className="confirmation-total-amount">
{(confirmationData.total - (confirmationData.referral_used ?? 0)).toFixed(2)}
</div>
</div> </div>
</div> </div>
@@ -151,6 +151,154 @@
font-size: 0.85rem; 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 SECTION TITLE
============================================ */ ============================================ */
@@ -261,6 +409,13 @@
font-weight: 700; font-weight: 700;
} }
.order-card-referral {
font-size: 0.72rem;
font-weight: 500;
color: #10b981;
opacity: 0.75;
}
.order-card-chevron { .order-card-chevron {
color: var(--text-muted); color: var(--text-muted);
font-size: 0.9rem; font-size: 0.9rem;
@@ -1,7 +1,7 @@
import { useState, useEffect } from 'react'; import { useState, useEffect } from "react";
import { useNavigate } from 'react-router-dom'; import { useNavigate } from "react-router-dom";
import Navbar from '../../components/Navbar'; import Navbar from "../../components/Navbar";
import './ConsultationHistorique.css'; import "./ConsultationHistorique.css";
import { import {
getMyCompletedOrders, getMyCompletedOrders,
formatPrice, formatPrice,
@@ -10,11 +10,17 @@ import {
isUserAuthenticated, isUserAuthenticated,
getPublicSettings, getPublicSettings,
getReferralBalance, getReferralBalance,
} from '../../api/api'; getMyPointsRewards,
import type { PublicSettings } from '../../api/api'; claimMyReward,
import type { CompletedOrder, ClientStats, PenaltyInfo } from "../../api/api_types"; } 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 { import {
faCannabis, faCannabis,
faPills, faPills,
@@ -32,7 +38,7 @@ import {
faChevronRight, faChevronRight,
faCheckCircle, faCheckCircle,
faHistory, faHistory,
} from '@fortawesome/free-solid-svg-icons'; } from "@fortawesome/free-solid-svg-icons";
function ConsultationHistorique() { function ConsultationHistorique() {
const navigate = useNavigate(); const navigate = useNavigate();
@@ -40,18 +46,41 @@ function ConsultationHistorique() {
const [orders, setOrders] = useState<CompletedOrder[]>([]); const [orders, setOrders] = useState<CompletedOrder[]>([]);
const [clientStats, setClientStats] = useState<ClientStats | null>(null); const [clientStats, setClientStats] = useState<ClientStats | null>(null);
const [penalties, setPenalties] = useState<PenaltyInfo | null>(null); const [penalties, setPenalties] = useState<PenaltyInfo | null>(null);
const [appSettings, setAppSettings] = useState<PublicSettings>({ penalties_enabled: true, show_amende_score: true, points_enabled: true, points_separated: true, referral_enabled: true, referral_amount: 0, pool_names: ['Pool 1', 'Pool 2'], crypto_payment_enabled: false, crypto_only: false, nowpayments_currencies: [] }); const [appSettings, setAppSettings] = useState<PublicSettings>({
penalties_enabled: true,
show_amende_score: true,
points_enabled: true,
points_separated: true,
referral_enabled: true,
referral_amount: 0,
pool_names: ["Pool 1", "Pool 2"],
crypto_payment_enabled: false,
crypto_only: false,
nowpayments_currencies: [],
shop_name: "Milieu-Nantais",
two_fa_enabled: false,
contact_telegram: "",
});
const [referralBalance, setReferralBalance] = useState<number>(0); const [referralBalance, setReferralBalance] = useState<number>(0);
const [pointsRewards, setPointsRewards] = useState<{
enabled: boolean;
pools: PointsPoolInfo[];
reward: PointsRewardConfig | null;
} | null>(null);
const [claimingPool, setClaimingPool] = useState<string | null>(null);
const [claimFeedback, setClaimFeedback] = useState<{ pool: string; type: "success" | "error"; text: string } | null>(null);
const [isLoading, setIsLoading] = useState(true); const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string>(''); const [error, setError] = useState<string>("");
useEffect(() => { useEffect(() => {
if (!isUserAuthenticated()) navigate('/login/client', { replace: true }); if (!isUserAuthenticated())
navigate("/login/client", { replace: true });
}, [navigate]); }, [navigate]);
useEffect(() => { useEffect(() => {
const interval = setInterval(() => { const interval = setInterval(() => {
if (!isUserAuthenticated()) navigate('/login/client', { replace: true }); if (!isUserAuthenticated())
navigate("/login/client", { replace: true });
}, 5000); }, 5000);
return () => clearInterval(interval); return () => clearInterval(interval);
}, [navigate]); }, [navigate]);
@@ -62,25 +91,33 @@ function ConsultationHistorique() {
getPublicSettings().then((s) => { getPublicSettings().then((s) => {
setAppSettings(s); setAppSettings(s);
if (s.referral_enabled) { if (s.referral_enabled) {
getReferralBalance().then((r) => { if (r.success) setReferralBalance(r.balance); }); 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 }, []); // eslint-disable-line react-hooks/exhaustive-deps
const fetchHistory = async () => { const fetchHistory = async () => {
if (!isUserAuthenticated()) { navigate('/login/client', { replace: true }); return; } if (!isUserAuthenticated()) {
navigate("/login/client", { replace: true });
return;
}
setIsLoading(true); setIsLoading(true);
setError(''); setError("");
try { try {
const result = await getMyCompletedOrders(); const result = await getMyCompletedOrders();
if (result.success) { if (result.success) {
setOrders(result.commands); setOrders(result.commands);
setClientStats(result.client_stats || null); setClientStats(result.client_stats || null);
} else { } else {
setError(result.message || 'Erreur lors du chargement'); setError(result.message || "Erreur lors du chargement");
} }
} catch { } catch {
setError('Erreur de connexion'); setError("Erreur de connexion");
} finally { } finally {
setIsLoading(false); setIsLoading(false);
} }
@@ -91,11 +128,16 @@ function ConsultationHistorique() {
try { try {
const result = await getMyPenalties(); const result = await getMyPenalties();
if (result.success && result.data) setPenalties(result.data); if (result.success && result.data) setPenalties(result.data);
} catch { /* ignore */ } } catch {
/* ignore */
}
}; };
const viewOrderDetails = (order: CompletedOrder) => { const viewOrderDetails = (order: CompletedOrder) => {
if (!isUserAuthenticated()) { navigate('/login/client', { replace: true }); return; } if (!isUserAuthenticated()) {
navigate("/login/client", { replace: true });
return;
}
navigate(`/user/commande/${order.client_order_number ?? order.id}`, { navigate(`/user/commande/${order.client_order_number ?? order.id}`, {
state: { commandId: order.id }, state: { commandId: order.id },
}); });
@@ -103,7 +145,11 @@ function ConsultationHistorique() {
const formatDate = (dateStr: string) => { const formatDate = (dateStr: string) => {
const d = new Date(dateStr); const d = new Date(dateStr);
return d.toLocaleDateString('fr-FR', { day: '2-digit', month: 'short', year: 'numeric' }); return d.toLocaleDateString("fr-FR", {
day: "2-digit",
month: "short",
year: "numeric",
});
}; };
if (isLoading) { if (isLoading) {
@@ -112,58 +158,100 @@ function ConsultationHistorique() {
<Navbar /> <Navbar />
<div className="history-container"> <div className="history-container">
<div className="loading-container"> <div className="loading-container">
<div className="loading-spinner"><div className="spinner"></div></div> <div className="loading-spinner">
<p className="loading-text">Chargement de l'historique...</p> <div className="spinner"></div>
</div>
<p className="loading-text">
Chargement de l'historique...
</p>
</div> </div>
</div> </div>
</> </>
); );
} }
const poolNames = clientStats?.pool_names?.length ? clientStats.pool_names : appSettings.pool_names; 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 poolNames = clientStats?.pool_names?.length
? clientStats.pool_names
: appSettings.pool_names;
const poolPoints = clientStats?.pool_points ?? [clientStats?.points ?? 0]; const poolPoints = clientStats?.pool_points ?? [clientStats?.points ?? 0];
const totalPoints = poolPoints.reduce((s, v) => s + (v || 0), 0); const totalPoints = poolPoints.reduce((s, v) => s + (v || 0), 0);
const penaltyCount = penalties?.total_penalty || clientStats?.penalties || 0; const penaltyCount =
penalties?.total_penalty || clientStats?.penalties || 0;
const poolIcons = [faCannabis, faPills, faFlask, faMortarPestle, faStar]; const poolIcons = [faCannabis, faPills, faFlask, faMortarPestle, faStar];
const poolIconColors = ['#10b981', '#e879f9', '#fb923c', '#38bdf8', '#7c3aed']; const poolIconColors = [
"#10b981",
"#e879f9",
"#fb923c",
"#38bdf8",
"#7c3aed",
];
return ( return (
<> <>
<Navbar /> <Navbar />
<div className="history-container"> <div className="history-container">
<h1 className="history-title">Historique des commandes</h1> <h1 className="history-title">Historique des commandes</h1>
{/* Stats grid */} {/* Stats grid */}
<div className="stats-grid"> <div className="stats-grid">
{/* Total commandes */} {/* Total commandes */}
<div className="stat-card2"> <div className="stat-card2">
<div className="stat-icon icon-total-orders"> <div className="stat-icon icon-total-orders">
<FontAwesomeIcon icon={faReceipt} /> <FontAwesomeIcon icon={faReceipt} />
</div> </div>
<p className="stat-value">{clientStats?.total_commands ?? orders.length}</p> <p className="stat-value">
{clientStats?.total_commands ?? orders.length}
</p>
<p className="stat-label">Commandes</p> <p className="stat-label">Commandes</p>
</div> </div>
{/* Points */} {/* Points */}
{appSettings.points_enabled && ( {appSettings.points_enabled &&
poolNames.length <= 1 ? ( (poolNames.length <= 1 ? (
<div className="stat-card2"> <div className="stat-card2">
<div className="stat-icon icon-total"> <div className="stat-icon icon-total">
<FontAwesomeIcon icon={faTrophy} /> <FontAwesomeIcon icon={faTrophy} />
</div> </div>
<p className="stat-value">{poolPoints[0] || 0}</p> <p className="stat-value">
<p className="stat-label">Pts {poolNames[0] ?? 'Points'}</p> {poolPoints[0] || 0}
</p>
<p className="stat-label">
Pts {poolNames[0] ?? "Points"}
</p>
</div> </div>
) : ( ) : (
<> <>
{poolNames.map((name, i) => ( {poolNames.map((name, i) => (
<div key={i} className="stat-card2"> <div key={i} className="stat-card2">
<div className="stat-icon" style={{ background: `linear-gradient(135deg, ${poolIconColors[i] ?? '#7c3aed'}cc, ${poolIconColors[i] ?? '#7c3aed'})` }}> <div
<FontAwesomeIcon icon={poolIcons[i] ?? faStar} /> className="stat-icon"
style={{
background: `linear-gradient(135deg, ${poolIconColors[i] ?? "#7c3aed"}cc, ${poolIconColors[i] ?? "#7c3aed"})`,
}}
>
<FontAwesomeIcon
icon={poolIcons[i] ?? faStar}
/>
</div> </div>
<p className="stat-value">{poolPoints[i] || 0}</p> <p className="stat-value">
{poolPoints[i] || 0}
</p>
<p className="stat-label">Pts {name}</p> <p className="stat-label">Pts {name}</p>
</div> </div>
))} ))}
@@ -172,56 +260,173 @@ function ConsultationHistorique() {
<div className="stat-icon icon-total"> <div className="stat-icon icon-total">
<FontAwesomeIcon icon={faTrophy} /> <FontAwesomeIcon icon={faTrophy} />
</div> </div>
<p className="stat-value">{totalPoints}</p> <p className="stat-value">
<p className="stat-label">Total Points</p> {totalPoints}
</p>
<p className="stat-label">
Total Points
</p>
</div> </div>
)} )}
</> </>
) ))}
)}
{/* Score amendes */} {/* Score amendes */}
{appSettings.show_amende_score && ( {appSettings.show_amende_score && (
<div className={`stat-card2${penaltyCount >= 3 ? ' stat-card-danger' : penaltyCount > 0 ? ' stat-card-warning' : ''}`}> <div
<div className={`stat-icon ${penaltyCount >= 3 ? 'icon-penalty-critical' : penaltyCount > 0 ? 'icon-penalty-warning' : 'icon-penalty-ok'}`}> className={`stat-card2${penaltyCount >= 3 ? " stat-card-danger" : penaltyCount > 0 ? " stat-card-warning" : ""}`}
<FontAwesomeIcon icon={penaltyCount > 0 ? faExclamationTriangle : faShieldAlt} /> >
<div
className={`stat-icon ${penaltyCount >= 3 ? "icon-penalty-critical" : penaltyCount > 0 ? "icon-penalty-warning" : "icon-penalty-ok"}`}
>
<FontAwesomeIcon
icon={
penaltyCount > 0
? faExclamationTriangle
: faShieldAlt
}
/>
</div> </div>
<p className={`stat-value ${penaltyCount >= 3 ? 'value-danger' : penaltyCount > 0 ? 'value-warning' : ''}`}>{penaltyCount}</p> <p
className={`stat-value ${penaltyCount >= 3 ? "value-danger" : penaltyCount > 0 ? "value-warning" : ""}`}
>
{penaltyCount}
</p>
<p className="stat-label">Score amendes</p> <p className="stat-label">Score amendes</p>
</div> </div>
)} )}
</div> </div>
{/* Section récompenses par palier */}
{pointsRewards?.enabled && pointsRewards.reward && pointsRewards.pools.length > 0 && (
<div className="rewards-section">
<p className="rewards-section-title">
<FontAwesomeIcon icon={faTrophy} className="rewards-section-icon" />
Récompenses
</p>
{pointsRewards.reward.description && (
<p className="rewards-section-desc">{pointsRewards.reward.description}</p>
)}
{(pointsRewards.reward.reward_items ?? []).filter((it: RewardItemConfig) => it.product_name).length > 0 && (
<div className="reward-eligible-cats">
{(pointsRewards.reward.reward_items ?? []).map((it: RewardItemConfig, idx: number) => (
<span key={idx} className="reward-eligible-cat">
<FontAwesomeIcon icon={faGift} style={{ marginRight: 4 }} />
{it.product_name}{it.quantity > 0 && it.quantity !== 1 ? ` ×${it.quantity}` : ""}{it.price > 0 ? `${it.price}` : ""}
</span>
))}
</div>
)}
<div className="rewards-pools">
{pointsRewards.pools.map((pool) => {
const threshold = pointsRewards.reward!.threshold;
const progress = Math.min(100, Math.round((pool.points % threshold) / threshold * 100));
const remaining = threshold - (pool.points % threshold);
const isClaiming = claimingPool === pool.key;
const feedback = claimFeedback?.pool === pool.key ? claimFeedback : null;
return (
<div key={pool.key} className="reward-pool-card">
<div className="reward-pool-header">
<span className="reward-pool-name">{pool.name}</span>
<span className="reward-pool-pts">{pool.points} pts</span>
</div>
{pool.eligible_configs.length > 0 && (
<div className="reward-eligible-cats">
{pool.eligible_configs.flatMap((cfg) =>
cfg.all_products
? [<span key={cfg.category} className="reward-eligible-cat">
{cfg.category}
</span>]
: (cfg.product_names ?? []).map((name) => (
<span key={`${cfg.category}-${name}`} className="reward-eligible-cat">
{name}
</span>
))
)}
</div>
)}
<div className="reward-progress-bar">
<div className="reward-progress-fill" style={{ width: `${progress}%` }} />
</div>
<div className="reward-pool-info">
{pool.rewards_available > 0 ? (
<span className="reward-available">{pool.rewards_available} récompense{pool.rewards_available > 1 ? "s" : ""} disponible{pool.rewards_available > 1 ? "s" : ""}</span>
) : (
<span className="reward-remaining">Encore {remaining} pts pour une récompense</span>
)}
</div>
{feedback && (
<p className={`reward-feedback reward-feedback-${feedback.type}`}>{feedback.text}</p>
)}
{pool.rewards_available > 0 && (
<button
className="reward-claim-btn"
onClick={() => handleClaim(pool.key)}
disabled={isClaiming}
>
{isClaiming ? "..." : "Réclamer ma récompense"}
</button>
)}
</div>
);
})}
</div>
</div>
)}
{/* Bouton parrainage */} {/* Bouton parrainage */}
{appSettings.referral_enabled && ( {appSettings.referral_enabled && (
<div className="referral-btn-row" onClick={() => navigate('/user/parrainage')}> <div
<FontAwesomeIcon icon={faGift} className="referral-btn-icon" /> className="referral-btn-row"
onClick={() => navigate("/user/parrainage")}
>
<FontAwesomeIcon
icon={faGift}
className="referral-btn-icon"
/>
<span className="referral-btn-text"> <span className="referral-btn-text">
Parrainage{referralBalance > 0 ? `${referralBalance.toFixed(2)}` : ''} Parrainage
{referralBalance > 0
? `${referralBalance.toFixed(2)}`
: ""}
</span> </span>
<FontAwesomeIcon icon={faChevronRight} className="referral-btn-chevron" /> <FontAwesomeIcon
icon={faChevronRight}
className="referral-btn-chevron"
/>
</div> </div>
)} )}
{error && ( {error && (
<div className="error-banner"> <div className="error-banner">
<FontAwesomeIcon icon={faExclamationTriangle} size="2x" /> <FontAwesomeIcon
icon={faExclamationTriangle}
size="2x"
/>
<p>{error}</p> <p>{error}</p>
</div> </div>
)} )}
{orders.length === 0 ? ( {orders.length === 0 ? (
<div className="empty-history"> <div className="empty-history">
<FontAwesomeIcon icon={faHistory} className="empty-icon" /> <FontAwesomeIcon
icon={faHistory}
className="empty-icon"
/>
<h2>Aucun historique</h2> <h2>Aucun historique</h2>
<p>Vos commandes terminées apparaîtront ici</p> <p>Vos commandes terminées apparaîtront ici</p>
<button className="browse-button" onClick={() => navigate('/user/accueil')}> <button
className="browse-button"
onClick={() => navigate("/user/accueil")}
>
Découvrir nos produits Découvrir nos produits
</button> </button>
</div> </div>
) : ( ) : (
<> <>
<p className="section-title">Historique des commandes</p> <p className="section-title">
Historique des commandes
</p>
<div className="orders-list"> <div className="orders-list">
{orders.map((order) => ( {orders.map((order) => (
<div <div
@@ -231,45 +436,81 @@ function ConsultationHistorique() {
> >
<div className="order-card-header"> <div className="order-card-header">
<span className="order-card-number"> <span className="order-card-number">
Commande #{(order.client_order_number ?? 0).toString().padStart(4, '0')} Commande #
{(order.client_order_number ?? 0)
.toString()
.padStart(4, "0")}
</span> </span>
<span className="order-card-badge"> <span className="order-card-badge">
<FontAwesomeIcon icon={faCheckCircle} style={{ marginRight: '0.35rem' }} /> <FontAwesomeIcon
icon={faCheckCircle}
style={{
marginRight: "0.35rem",
}}
/>
Livrée Livrée
</span> </span>
</div> </div>
<div className="order-card-row"> <div className="order-card-row">
<FontAwesomeIcon icon={faMapMarkerAlt} className="order-card-row-icon" /> <FontAwesomeIcon
icon={faMapMarkerAlt}
className="order-card-row-icon"
/>
<span className="order-card-row-text"> <span className="order-card-row-text">
{order.adresse ? (order.adresse.length > 50 ? order.adresse.substring(0, 50) + '…' : order.adresse) : 'N/A'} {order.adresse
? order.adresse.length > 50
? order.adresse.substring(
0,
50,
) + "…"
: order.adresse
: "N/A"}
</span> </span>
</div> </div>
<div className="order-card-row"> <div className="order-card-row">
<FontAwesomeIcon icon={faClock} className="order-card-row-icon" /> <FontAwesomeIcon
icon={faClock}
className="order-card-row-icon"
/>
<span className="order-card-row-text"> <span className="order-card-row-text">
{formatDate(order.created_at)} · {getOrderAge(order.created_at)} {formatDate(order.created_at)} ·{" "}
{getOrderAge(order.created_at)}
</span> </span>
</div> </div>
{order.livreur_assign && ( {order.livreur_assign && (
<div className="order-card-row"> <div className="order-card-row">
<FontAwesomeIcon icon={faBicycle} className="order-card-row-icon" /> <FontAwesomeIcon
<span className="order-card-row-text">{order.livreur_assign}</span> icon={faBicycle}
className="order-card-row-icon"
/>
<span className="order-card-row-text">
{order.livreur_assign}
</span>
</div> </div>
)} )}
<div className="order-card-footer"> <div className="order-card-footer">
<span className="order-card-total">{formatPrice(order.total_prix || 0)}</span> <span className="order-card-total">
<FontAwesomeIcon icon={faChevronRight} className="order-card-chevron" /> {formatPrice((order.total_prix || 0) - (order.referral_used || 0))}
{(order.referral_used || 0) > 0 && (
<span className="order-card-referral">
{" "} dont {formatPrice(order.referral_used ?? 0)} parrainage
</span>
)}
</span>
<FontAwesomeIcon
icon={faChevronRight}
className="order-card-chevron"
/>
</div> </div>
</div> </div>
))} ))}
</div> </div>
</> </>
)} )}
</div> </div>
</> </>
); );
@@ -60,7 +60,9 @@ function OrderDetails() {
const location = useLocation(); const location = useLocation();
// commandId = ID global pour l'API (passé en state depuis l'historique) // commandId = ID global pour l'API (passé en state depuis l'historique)
// fallback sur orderId si navigation directe via URL // fallback sur orderId si navigation directe via URL
const commandId = (location.state as { commandId?: number } | null)?.commandId ?? parseInt(orderId ?? "0"); const commandId =
(location.state as { commandId?: number } | null)?.commandId ??
parseInt(orderId ?? "0");
const [order, setOrder] = useState<OrderDetailsData | null>(null); const [order, setOrder] = useState<OrderDetailsData | null>(null);
const [enrichedProducts, setEnrichedProducts] = useState< const [enrichedProducts, setEnrichedProducts] = useState<
+4 -3
View File
@@ -5,7 +5,6 @@ import { getReferralBalance, isUserAuthenticated, getPublicSettings } from '../.
import type { PublicSettings } from '../../api/api'; import type { PublicSettings } from '../../api/api';
import './Parrainage.css'; import './Parrainage.css';
const TELEGRAM_URL = 'https://t.me/';
const steps = [ const steps = [
{ {
@@ -140,21 +139,23 @@ export default function Parrainage() {
</div> </div>
{/* Bouton Telegram */} {/* Bouton Telegram */}
{settings?.contact_telegram && (
<div className="parrainage-cta"> <div className="parrainage-cta">
<p className="cta-text"> <p className="cta-text">
Prêt à parrainer ? Contactez-nous sur Telegram pour enregistrer votre Prêt à parrainer ? Contactez-nous sur Telegram pour enregistrer votre
filleul. filleul.
</p> </p>
<a <a
href={TELEGRAM_URL} href={`https://t.me/${settings.contact_telegram}`}
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className="telegram-btn" className="telegram-btn"
> >
<i className="fab fa-telegram telegram-icon"></i> <i className="fab fa-telegram telegram-icon"></i>
Contacter sur Telegram Contacter @{settings.contact_telegram}
</a> </a>
</div> </div>
)}
</div> </div>
</> </>
); );
@@ -133,6 +133,40 @@
} }
} }
/* Coming Soon Badge */
.coming-soon-badge {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%) rotate(-15deg);
background: rgba(0, 0, 0, 0.8);
color: rgba(34, 197, 94, 0.95);
border: 4px solid rgba(34, 197, 94, 0.95);
padding: clamp(1rem, 4vw, 1.5rem) clamp(2.5rem, 8vw, 4rem);
font-size: clamp(2rem, 8vw, 3.5rem);
font-weight: 900;
letter-spacing: 6px;
text-transform: uppercase;
white-space: nowrap;
box-shadow:
0 0 40px rgba(34, 197, 94, 0.6),
0 8px 32px rgba(0, 0, 0, 0.6);
text-shadow:
2px 2px 12px rgba(0, 0, 0, 0.9),
0 0 20px rgba(34, 197, 94, 0.3);
z-index: 10;
animation: pulseGreen 2s infinite;
}
@keyframes pulseGreen {
0%, 100% {
transform: translate(-50%, -50%) rotate(-15deg) scale(1);
}
50% {
transform: translate(-50%, -50%) rotate(-15deg) scale(1.05);
}
}
/* Info Section */ /* Info Section */
.product-info-section { .product-info-section {
display: flex; display: flex;
+65 -14
View File
@@ -1,6 +1,10 @@
import { useParams, useNavigate } from "react-router-dom"; import { useParams, useNavigate } from "react-router-dom";
import { useState, useEffect } from "react"; 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 type { Product } from "../../api/api";
import { useCart } from "../../context/useCart"; import { useCart } from "../../context/useCart";
import Navbar from "../../components/Navbar"; import Navbar from "../../components/Navbar";
@@ -86,10 +90,23 @@ function ProductDetail() {
const fixedProduct = { const fixedProduct = {
...response.data, ...response.data,
prices: prices:
response.data.prices?.map( response.data.prices
(p: { quantity: number; price: number }) => ({ ?.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)), quantity: parseFloat(String(p.quantity)),
price: parseFloat(String(p.price)), price: parseFloat(String(p.price)),
active_price: p.active_price,
}), }),
) || [], ) || [],
}; };
@@ -104,14 +121,20 @@ function ProductDetail() {
// Couleur de la catégorie depuis la DB // Couleur de la catégorie depuis la DB
const matched = categories.find( 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); if (matched?.color) setCatColor(matched.color);
} else { } else {
setError(response.message || "Produit non trouvé"); setError(response.message || "Produit non trouvé");
} }
} catch (err: unknown) { } 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 { } finally {
setLoading(false); setLoading(false);
} }
@@ -197,6 +220,7 @@ function ProductDetail() {
} }
const isOutOfStock = product.stock === 0; const isOutOfStock = product.stock === 0;
const isComingSoon = product.coming_soon === true;
const hasValidPrices = product.prices && product.prices.length > 0; const hasValidPrices = product.prices && product.prices.length > 0;
// Convertir la couleur hex en valeurs RGB pour les CSS rgba() // 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 r = parseInt(h.slice(0, 2), 16);
const g = parseInt(h.slice(2, 4), 16); const g = parseInt(h.slice(2, 4), 16);
const b = parseInt(h.slice(4, 6), 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 ( return (
<> <>
@@ -231,20 +256,38 @@ function ProductDetail() {
<div <div
className="product-detail-container" className="product-detail-container"
style={{ "--cat-color": catColor, "--cat-color-rgb": catColorRgb, "--cat-text-color": catTextColor } as React.CSSProperties} style={
{
"--cat-color": catColor,
"--cat-color-rgb": catColorRgb,
"--cat-text-color": catTextColor,
} as React.CSSProperties
}
> >
<button onClick={() => navigate(-1)} className="back-button"> <button onClick={() => navigate(-1)} className="back-button">
Retour Retour
</button> </button>
<div className="product-detail-content"> <div className="product-detail-content">
<div className={`product-image-section ${isOutOfStock ? "out-of-stock" : ""}`}> <div
className={`product-image-section ${isOutOfStock ? "out-of-stock" : ""}`}
>
<img <img
src={product.media?.find(m => m.type === "image")?.url || ""} src={
product.media?.find((m) => m.type === "image")
?.url || ""
}
alt={product.name} alt={product.name}
className="product-detail-image" className="product-detail-image"
/> />
{isOutOfStock && <div className="sold-out-badge">SOLD OUT</div>} {isOutOfStock && (
<div className="sold-out-badge">SOLD OUT</div>
)}
{isComingSoon && (
<div className="coming-soon-badge">
COMMING SOON
</div>
)}
</div> </div>
<div className="product-info-section"> <div className="product-info-section">
@@ -253,7 +296,8 @@ function ProductDetail() {
{selectedPrice > 0 && ( {selectedPrice > 0 && (
<p className="product-detail-price"> <p className="product-detail-price">
{selectedPrice.toFixed(2)} {" "} {selectedPrice.toFixed(2)} {" "}
{selectedGrams && `pour ${selectedGrams}${product.unit || "g"}`} {selectedGrams &&
`pour ${selectedGrams}${product.unit || "g"}`}
</p> </p>
)} )}
@@ -291,7 +335,8 @@ function ProductDetail() {
key={p.quantity} key={p.quantity}
value={p.quantity} value={p.quantity}
> >
{p.quantity}{product.unit || "g"} -{" "} {p.quantity}
{product.unit || "g"} -{" "}
{p.price.toFixed(2)} {p.price.toFixed(2)}
</option> </option>
))} ))}
@@ -301,12 +346,18 @@ function ProductDetail() {
</div> </div>
<button <button
className={`add-to-cart-button ${isOutOfStock || selectedGrams === null ? "disabled" : ""}`} className={`add-to-cart-button ${isOutOfStock || isComingSoon || selectedGrams === null ? "disabled" : ""}`}
onClick={handleAddToCart} onClick={handleAddToCart}
disabled={isOutOfStock || selectedGrams === null} disabled={
isOutOfStock ||
isComingSoon ||
selectedGrams === null
}
> >
{isOutOfStock {isOutOfStock
? "Rupture de stock" ? "Rupture de stock"
: isComingSoon
? "Bientôt disponible"
: "Ajouter au panier"} : "Ajouter au panier"}
</button> </button>
</div> </div>
@@ -179,6 +179,79 @@
font-weight: 500; 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) { @media (max-width: 480px) {
.profile-row { grid-template-columns: 1fr; } .profile-row { grid-template-columns: 1fr; }
} }
@@ -284,3 +357,27 @@
border: 1px solid rgba(37, 99, 235, 0.27); border: 1px solid rgba(37, 99, 235, 0.27);
color: #60a5fa; 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;
}
+292 -211
View File
@@ -1,92 +1,82 @@
import { useState, useEffect } from "react"; import { useState, useEffect } from 'react';
import { useNavigate } from "react-router-dom"; import { useNavigate } from 'react-router-dom';
import Navbar from "../../components/Navbar"; 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 { import {
isUserAuthenticated, faUser, faMapMarkerAlt, faPhone, faCommentDots,
extractUsernameFromToken, faSave, faCheckCircle, faExclamationTriangle, faPaperPlane, faUnlink, faTimes, faLock, faShieldAlt,
getMyProfile, } from '@fortawesome/free-solid-svg-icons';
updateMyProfile, import './ProfilePage.css';
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";
const STORAGE_ADDRESS = "profile_default_address"; const STORAGE_ADDRESS = 'profile_default_address';
const STORAGE_PHONE = "profile_default_phone"; const STORAGE_PHONE = 'profile_default_phone';
const STORAGE_SIGNAL = "profile_signal_pseudo"; const STORAGE_SIGNAL = 'profile_signal_pseudo';
export default function ProfilePage() { export default function ProfilePage() {
const navigate = useNavigate(); const navigate = useNavigate();
// Données compte (backend) // Données compte (backend)
const [nom, setNom] = useState(""); const [nom, setNom] = useState('');
const [prenom, setPrenom] = useState(""); const [prenom, setPrenom] = useState('');
const [telephone, setTelephone] = useState(""); const [telephone, setTelephone] = useState('');
const [loadingProfile, setLoadingProfile] = useState(true); const [loadingProfile, setLoadingProfile] = useState(true);
// Données locales (localStorage) // Données locales (localStorage)
const [defaultAddress, setDefaultAddress] = useState( const [defaultAddress, setDefaultAddress] = useState(() => localStorage.getItem(STORAGE_ADDRESS) ?? '');
() => localStorage.getItem(STORAGE_ADDRESS) ?? "", const [defaultPhone, setDefaultPhone] = useState(() => localStorage.getItem(STORAGE_PHONE) ?? '');
); const [signalPseudo, setSignalPseudo] = useState(() => localStorage.getItem(STORAGE_SIGNAL) ?? '');
const [defaultPhone, setDefaultPhone] = useState(
() => localStorage.getItem(STORAGE_PHONE) ?? "",
);
const [signalPseudo, setSignalPseudo] = useState(
() => localStorage.getItem(STORAGE_SIGNAL) ?? "",
);
// Feedback
const [savingContact, setSavingContact] = useState(false); const [savingContact, setSavingContact] = useState(false);
const [successMsg, setSuccessMsg] = useState("");
const [errorMsg, setErrorMsg] = useState(""); // 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('');
// Telegram // Telegram
const [tgLinked, setTgLinked] = useState(false); const [tgLinked, setTgLinked] = useState(false);
const [tgEnabled, setTgEnabled] = useState(false); const [tgEnabled, setTgEnabled] = useState(false);
const [tgLoading, setTgLoading] = useState(false); const [tgLoading, setTgLoading] = useState(false);
// Modal confirmation infos par défaut // 2FA
const [showSaveModal, setShowSaveModal] = useState(false); const [twoFAEnabled, setTwoFAEnabled] = useState(false);
const [twoFAAdminEnabled, setTwoFAAdminEnabled] = useState(false);
const [twoFALoading, setTwoFALoading] = useState(false);
const username = extractUsernameFromToken() ?? ""; // 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);
const username = extractUsernameFromToken() ?? '';
useEffect(() => { useEffect(() => {
if (!isUserAuthenticated()) { if (!isUserAuthenticated()) {
navigate("/login/client", { replace: true }); navigate('/login/client', { replace: true });
return; return;
} }
// Statut Telegram // Statut Telegram
getTelegramStatus().then((s) => { getTelegramStatus().then((s) => { setTgLinked(s.linked); setTgEnabled(s.enabled); });
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 // Charger depuis backend
getMyProfile().then((res) => { getMyProfile().then((res) => {
if (res.success && res.client) { if (res.success && res.client) {
setNom(res.client.nom ?? ""); setNom(res.client.nom ?? '');
setPrenom(res.client.prenom ?? ""); setPrenom(res.client.prenom ?? '');
setTelephone(res.client.telephone ?? ""); setTelephone(res.client.telephone ?? '');
// Initialiser le téléphone par défaut si pas encore défini // Initialiser le téléphone par défaut si pas encore défini
if ( if (!localStorage.getItem(STORAGE_PHONE) && res.client.telephone) {
!localStorage.getItem(STORAGE_PHONE) &&
res.client.telephone
) {
setDefaultPhone(res.client.telephone); setDefaultPhone(res.client.telephone);
} }
} }
@@ -94,15 +84,17 @@ export default function ProfilePage() {
}); });
}, [navigate]); }, [navigate]);
const showSuccess = (msg: string) => { const showSuccess = (title: string, msg: string) => {
setSuccessMsg(msg); setSuccessTitle(title); setSuccessMsg(msg); setShowSuccessModal(true);
setErrorMsg("");
setTimeout(() => setSuccessMsg(""), 3000);
}; };
const showError = (msg: string) => { const showError = (msg: string) => {
setErrorMsg(msg); setErrorMsg(msg); setShowErrorModal(true);
setSuccessMsg(""); };
setTimeout(() => setErrorMsg(""), 4000);
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 = () => { const saveLocal = () => {
@@ -110,62 +102,54 @@ export default function ProfilePage() {
localStorage.setItem(STORAGE_PHONE, defaultPhone.trim()); localStorage.setItem(STORAGE_PHONE, defaultPhone.trim());
localStorage.setItem(STORAGE_SIGNAL, signalPseudo.trim()); localStorage.setItem(STORAGE_SIGNAL, signalPseudo.trim());
setShowSaveModal(false); setShowSaveModal(false);
showSuccess("Informations par faut enregistrées"); showSuccess('Infos enregistrées', 'Adresse, téléphone et pseudo Signal sauvegardés. Ils seront pré-remplis à votre prochaine commande.');
}; };
const handleLinkTelegram = async () => { const handleLinkTelegram = async () => {
setTgLoading(true); setTgLoading(true);
// ✅ Ouvrir AVANT le await
const newWindow = window.open("", "_blank");
const res = await generateTelegramLinkToken(); const res = await generateTelegramLinkToken();
setTgLoading(false); setTgLoading(false);
if (res.error || !res.link_url) { if (res.error || !res.link_url) {
newWindow?.close(); showError(res.error || 'Service Telegram non disponible');
showError(res.error || "Service Telegram non disponible");
return; return;
} }
window.open(res.link_url, '_blank');
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 () => { const handleUnlinkTelegram = () => {
if ( setShowUnlinkModal(true);
!window.confirm( };
"Délier votre compte Telegram ? Vous ne recevrez plus de notifications.",
) const confirmUnlinkTelegram = async () => {
) setShowUnlinkModal(false);
return;
await unlinkTelegram(); await unlinkTelegram();
setTgLinked(false); setTgLinked(false);
showSuccess("Compte Telegram délié"); 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 () => { const saveContact = async () => {
setShowConfirmContactModal(false);
setSavingContact(true); setSavingContact(true);
const res = await updateMyProfile({ const res = await updateMyProfile({ nom: nom.trim(), prenom: prenom.trim(), telephone: telephone.trim() });
nom: nom.trim(),
prenom: prenom.trim(),
telephone: telephone.trim(),
});
setSavingContact(false); setSavingContact(false);
if (res.success) { if (res.success) {
showSuccess("Profil mis à jour"); showSuccess('Profil mis à jour', 'Vos informations de compte ont été enregistrées avec succès.');
} else { } else {
showError(res.message ?? "Erreur lors de la mise à jour"); showError(res.message ?? 'Erreur lors de la mise à jour du profil.');
} }
}; };
@@ -174,9 +158,7 @@ export default function ProfilePage() {
<> <>
<Navbar /> <Navbar />
<div className="profile-container"> <div className="profile-container">
<div className="profile-loading"> <div className="profile-loading"><div className="spinner" /></div>
<div className="spinner" />
</div>
</div> </div>
</> </>
); );
@@ -196,25 +178,10 @@ export default function ProfilePage() {
</div> </div>
</div> </div>
{successMsg && (
<div className="profile-alert profile-alert--success">
<FontAwesomeIcon icon={faCheckCircle} /> {successMsg}
</div>
)}
{errorMsg && (
<div className="profile-alert profile-alert--error">
<FontAwesomeIcon icon={faExclamationTriangle} />{" "}
{errorMsg}
</div>
)}
{/* Section compte */} {/* Section compte */}
<div className="profile-card"> <div className="profile-card">
<h2 className="profile-card-title"> <h2 className="profile-card-title">
<FontAwesomeIcon <FontAwesomeIcon icon={faUser} className="profile-card-icon" />
icon={faUser}
className="profile-card-icon"
/>
Mon compte Mon compte
</h2> </h2>
<div className="profile-fields"> <div className="profile-fields">
@@ -248,38 +215,23 @@ export default function ProfilePage() {
/> />
</div> </div>
</div> </div>
<button <button className="profile-btn" onClick={() => setShowConfirmContactModal(true)} disabled={savingContact}>
className="profile-btn"
onClick={saveContact}
disabled={savingContact}
>
<FontAwesomeIcon icon={faSave} /> <FontAwesomeIcon icon={faSave} />
{savingContact {savingContact ? ' Enregistrement...' : ' Enregistrer le compte'}
? " Enregistrement..."
: " Enregistrer le compte"}
</button> </button>
<button <button className="profile-btn profile-btn--secondary" onClick={() => navigate('/user/change-password')} style={{ marginTop: '0.6rem' }}>
className="profile-btn profile-btn--secondary" <FontAwesomeIcon icon={faLock} /> Changer le mot de passe
onClick={() => navigate("/user/change-password")}
style={{ marginTop: "0.6rem" }}
>
<FontAwesomeIcon icon={faLock} /> Changer le mot de
passe
</button> </button>
</div> </div>
{/* Section adresse par défaut */} {/* Section adresse par défaut */}
<div className="profile-card"> <div className="profile-card">
<h2 className="profile-card-title"> <h2 className="profile-card-title">
<FontAwesomeIcon <FontAwesomeIcon icon={faMapMarkerAlt} className="profile-card-icon profile-card-icon--address" />
icon={faMapMarkerAlt}
className="profile-card-icon profile-card-icon--address"
/>
Adresse par défaut Adresse par défaut
</h2> </h2>
<p className="profile-hint"> <p className="profile-hint">
Sera pré-remplie dans le formulaire de commande. Vous Sera pré-remplie dans le formulaire de commande. Vous pourrez la modifier si vous n'êtes pas à cette adresse.
pourrez la modifier si vous n'êtes pas à cette adresse.
</p> </p>
<div className="profile-group"> <div className="profile-group">
<label>Adresse</label> <label>Adresse</label>
@@ -290,20 +242,19 @@ export default function ProfilePage() {
placeholder="Numéro, rue, ville, code postal" placeholder="Numéro, rue, ville, code postal"
/> />
</div> </div>
<button className="profile-btn profile-btn--secondary" onClick={() => setShowConfirmAddressModal(true)} style={{ marginTop: '0.8rem' }}>
<FontAwesomeIcon icon={faSave} /> Enregistrer l'adresse
</button>
</div> </div>
{/* Section contact commande */} {/* Section contact commande */}
<div className="profile-card"> <div className="profile-card">
<h2 className="profile-card-title"> <h2 className="profile-card-title">
<FontAwesomeIcon <FontAwesomeIcon icon={faPhone} className="profile-card-icon profile-card-icon--phone" />
icon={faPhone}
className="profile-card-icon profile-card-icon--phone"
/>
Contact livraison Contact livraison
</h2> </h2>
<p className="profile-hint"> <p className="profile-hint">
Numéro utilisé par le livreur lors de la livraison. Peut Numéro utilisé par le livreur lors de la livraison. Peut être différent du numéro de votre compte.
être différent du numéro de votre compte.
</p> </p>
<div className="profile-group"> <div className="profile-group">
<label>Téléphone par défaut</label> <label>Téléphone par défaut</label>
@@ -314,15 +265,9 @@ export default function ProfilePage() {
placeholder="+33 6 12 34 56 78" placeholder="+33 6 12 34 56 78"
/> />
</div> </div>
<div <div className="profile-group" style={{ marginTop: '1rem' }}>
className="profile-group"
style={{ marginTop: "1rem" }}
>
<label> <label>
<FontAwesomeIcon <FontAwesomeIcon icon={faCommentDots} style={{ marginRight: '0.4rem' }} />
icon={faCommentDots}
style={{ marginRight: "0.4rem" }}
/>
Pseudo Signal (optionnel) Pseudo Signal (optionnel)
</label> </label>
<input <input
@@ -332,97 +277,233 @@ export default function ProfilePage() {
placeholder="@votre.pseudo.signal" placeholder="@votre.pseudo.signal"
/> />
</div> </div>
<button <button className="profile-btn profile-btn--secondary" onClick={() => setShowSaveModal(true)}>
className="profile-btn profile-btn--secondary" <FontAwesomeIcon icon={faSave} /> Enregistrer les infos par défaut
onClick={() => setShowSaveModal(true)}
>
<FontAwesomeIcon icon={faSave} /> Enregistrer les infos
par défaut
</button> </button>
</div> </div>
{/* Section Telegram */} {/* Section Telegram */}
{tgEnabled && ( {tgEnabled && (
<div className="profile-card"> <div className="profile-card">
<h2 className="profile-card-title"> <h2 className="profile-card-title">
<FontAwesomeIcon <FontAwesomeIcon icon={faPaperPlane} className="profile-card-icon profile-card-icon--telegram" />
icon={faPaperPlane}
className="profile-card-icon profile-card-icon--telegram"
/>
Notifications Telegram Notifications Telegram
</h2> </h2>
<p className="profile-hint"> <p className="profile-hint">
Recevez vos notifications sur Telegram, même quand Recevez vos notifications sur Telegram, même quand le site est fermé.
le site est fermé.
</p> </p>
{tgLinked ? ( {tgLinked ? (
<div className="profile-telegram-linked"> <div className="profile-telegram-linked">
<span className="profile-telegram-status"> <span className="profile-telegram-status">
<FontAwesomeIcon icon={faCheckCircle} />{" "} <FontAwesomeIcon icon={faCheckCircle} /> Compte Telegram lié
Compte Telegram lié
</span> </span>
<button <button className="profile-btn profile-btn--danger" onClick={handleUnlinkTelegram}>
className="profile-btn profile-btn--danger" <FontAwesomeIcon icon={faUnlink} /> Délier Telegram
onClick={handleUnlinkTelegram}
>
<FontAwesomeIcon icon={faUnlink} /> Délier
Telegram
</button> </button>
</div> </div>
) : ( ) : (
<button <button className="profile-btn profile-btn--telegram" onClick={handleLinkTelegram} disabled={tgLoading}>
className="profile-btn profile-btn--telegram"
onClick={handleLinkTelegram}
disabled={tgLoading}
>
<FontAwesomeIcon icon={faPaperPlane} /> <FontAwesomeIcon icon={faPaperPlane} />
{tgLoading {tgLoading ? ' Génération du lien...' : ' Lier mon compte Telegram'}
? " Génération du lien..."
: " Lier mon compte Telegram"}
</button> </button>
)} )}
</div> </div>
)} )}
{/* Section 2FA — visible uniquement si l'admin l'a activé ET Telegram est lié */}
{twoFAAdminEnabled && tgLinked && (
<div className="profile-card">
<h2 className="profile-card-title">
<FontAwesomeIcon icon={faShieldAlt} className="profile-card-icon" style={{ color: '#6366f1' }} />
Double authentification (2FA)
</h2>
<p className="profile-hint">
À chaque connexion, un code à 6 chiffres vous sera envoyé sur Telegram avant d'accéder à votre compte.
</p>
<div className="profile-2fa-row">
<div className="profile-2fa-label">
<span>{twoFAEnabled ? 'Activée' : 'Désactivée'}</span>
{twoFAEnabled && (
<span className="profile-2fa-badge">
<FontAwesomeIcon icon={faCheckCircle} /> Protection active
</span>
)}
</div>
{twoFALoading ? (
<div className="profile-2fa-spinner" />
) : (
<button
role="switch"
aria-checked={twoFAEnabled}
className={`profile-toggle ${twoFAEnabled ? 'profile-toggle--on' : ''}`}
onClick={handleToggle2FA}
aria-label="Activer ou désactiver la double authentification"
>
<span className="profile-toggle-thumb" />
</button>
)}
</div>
</div>
)}
</div> </div>
{showSaveModal && ( {showSaveModal && (
<div <div className="profile-modal-overlay" onClick={() => setShowSaveModal(false)}>
className="profile-modal-overlay" <div className="profile-modal" onClick={(e) => e.stopPropagation()}>
onClick={() => setShowSaveModal(false)} <button className="profile-modal-close" onClick={() => setShowSaveModal(false)}>
>
<div
className="profile-modal"
onClick={(e) => e.stopPropagation()}
>
<button
className="profile-modal-close"
onClick={() => setShowSaveModal(false)}
>
<FontAwesomeIcon icon={faTimes} /> <FontAwesomeIcon icon={faTimes} />
</button> </button>
<div className="profile-modal-icon"> <div className="profile-modal-icon">
<FontAwesomeIcon icon={faSave} /> <FontAwesomeIcon icon={faSave} />
</div> </div>
<h3 className="profile-modal-title"> <h3 className="profile-modal-title">Enregistrer les infos par défaut ?</h3>
Enregistrer les infos par défaut ?
</h3>
<p className="profile-modal-body"> <p className="profile-modal-body">
Adresse, téléphone de livraison et pseudo Signal Adresse, téléphone de livraison et pseudo Signal seront sauvegardés localement et pré-remplis lors de vos prochaines commandes.
seront sauvegardés localement et pré-remplis lors de
vos prochaines commandes.
</p> </p>
<div className="profile-modal-actions"> <div className="profile-modal-actions">
<button <button className="profile-modal-btn profile-modal-btn--cancel" onClick={() => setShowSaveModal(false)}>
className="profile-modal-btn profile-modal-btn--cancel"
onClick={() => setShowSaveModal(false)}
>
Annuler Annuler
</button> </button>
<button <button className="profile-modal-btn profile-modal-btn--confirm" onClick={saveLocal}>
className="profile-modal-btn profile-modal-btn--confirm" <FontAwesomeIcon icon={faCheckCircle} /> Confirmer
onClick={saveLocal} </button>
> </div>
<FontAwesomeIcon icon={faCheckCircle} />{" "} </div>
Confirmer </div>
)}
{showConfirmAddressModal && (
<div className="profile-modal-overlay" onClick={() => setShowConfirmAddressModal(false)}>
<div className="profile-modal" onClick={(e) => e.stopPropagation()}>
<button className="profile-modal-close" onClick={() => setShowConfirmAddressModal(false)}>
<FontAwesomeIcon icon={faTimes} />
</button>
<div className="profile-modal-icon">
<FontAwesomeIcon icon={faMapMarkerAlt} />
</div>
<h3 className="profile-modal-title">Enregistrer l'adresse ?</h3>
<p className="profile-modal-body">
Cette adresse sera sauvegardée localement et pré-remplie lors de vos prochaines commandes.
</p>
<div className="profile-modal-actions">
<button className="profile-modal-btn profile-modal-btn--cancel" onClick={() => setShowConfirmAddressModal(false)}>
Annuler
</button>
<button className="profile-modal-btn profile-modal-btn--confirm" onClick={saveAddress}>
<FontAwesomeIcon icon={faCheckCircle} /> Confirmer
</button>
</div>
</div>
</div>
)}
{showConfirmContactModal && (
<div className="profile-modal-overlay" onClick={() => setShowConfirmContactModal(false)}>
<div className="profile-modal" onClick={(e) => e.stopPropagation()}>
<button className="profile-modal-close" onClick={() => setShowConfirmContactModal(false)}>
<FontAwesomeIcon icon={faTimes} />
</button>
<div className="profile-modal-icon">
<FontAwesomeIcon icon={faUser} />
</div>
<h3 className="profile-modal-title">Enregistrer le compte ?</h3>
<p className="profile-modal-body">
Vos informations (prénom, nom, téléphone) seront mises à jour sur votre compte.
</p>
<div className="profile-modal-actions">
<button className="profile-modal-btn profile-modal-btn--cancel" onClick={() => setShowConfirmContactModal(false)}>
Annuler
</button>
<button className="profile-modal-btn profile-modal-btn--confirm" onClick={saveContact} disabled={savingContact}>
<FontAwesomeIcon icon={faCheckCircle} /> {savingContact ? 'Enregistrement...' : 'Confirmer'}
</button>
</div>
</div>
</div>
)}
{showUnlinkModal && (
<div className="profile-modal-overlay" onClick={() => setShowUnlinkModal(false)}>
<div className="profile-modal" onClick={(e) => e.stopPropagation()}>
<button className="profile-modal-close" onClick={() => setShowUnlinkModal(false)}>
<FontAwesomeIcon icon={faTimes} />
</button>
<div className="profile-modal-icon profile-modal-icon--danger">
<FontAwesomeIcon icon={faUnlink} />
</div>
<h3 className="profile-modal-title">Délier Telegram ?</h3>
<p className="profile-modal-body">
Vous ne recevrez plus de notifications Telegram. La double authentification sera également désactivée.
</p>
<div className="profile-modal-actions">
<button className="profile-modal-btn profile-modal-btn--cancel" onClick={() => setShowUnlinkModal(false)}>
Annuler
</button>
<button className="profile-modal-btn profile-modal-btn--danger" onClick={confirmUnlinkTelegram}>
<FontAwesomeIcon icon={faUnlink} /> Délier
</button>
</div>
</div>
</div>
)}
{/* Modal succès */}
{showSuccessModal && (
<div className="profile-modal-overlay" onClick={() => setShowSuccessModal(false)}>
<div className="profile-modal" onClick={(e) => e.stopPropagation()}>
<button className="profile-modal-close" onClick={() => setShowSuccessModal(false)}>
<FontAwesomeIcon icon={faTimes} />
</button>
<div className="profile-modal-icon profile-modal-icon--success">
<FontAwesomeIcon icon={faCheckCircle} />
</div>
<h3 className="profile-modal-title">{successTitle}</h3>
<p className="profile-modal-body">{successMsg}</p>
<div className="profile-modal-actions">
<button className="profile-modal-btn profile-modal-btn--confirm profile-modal-btn--success" onClick={() => setShowSuccessModal(false)}>
Fermer
</button>
</div>
</div>
</div>
)}
{/* Modal erreur */}
{showErrorModal && (
<div className="profile-modal-overlay" onClick={() => setShowErrorModal(false)}>
<div className="profile-modal" onClick={(e) => e.stopPropagation()}>
<button className="profile-modal-close" onClick={() => setShowErrorModal(false)}>
<FontAwesomeIcon icon={faTimes} />
</button>
<div className="profile-modal-icon profile-modal-icon--danger">
<FontAwesomeIcon icon={faExclamationTriangle} />
</div>
<h3 className="profile-modal-title">Une erreur est survenue</h3>
<p className="profile-modal-body">{errorMsg}</p>
<div className="profile-modal-actions">
<button className="profile-modal-btn profile-modal-btn--danger" onClick={() => setShowErrorModal(false)}>
Fermer
</button>
</div>
</div>
</div>
)}
{showUnlinkSuccessModal && (
<div className="profile-modal-overlay" onClick={() => setShowUnlinkSuccessModal(false)}>
<div className="profile-modal" onClick={(e) => e.stopPropagation()}>
<button className="profile-modal-close" onClick={() => setShowUnlinkSuccessModal(false)}>
<FontAwesomeIcon icon={faTimes} />
</button>
<div className="profile-modal-icon profile-modal-icon--success">
<FontAwesomeIcon icon={faCheckCircle} />
</div>
<h3 className="profile-modal-title">Compte délié</h3>
<p className="profile-modal-body">
Votre compte Telegram a é délié avec succès. Vous ne recevrez plus de notifications via Telegram.
</p>
<div className="profile-modal-actions">
<button className="profile-modal-btn profile-modal-btn--confirm" onClick={() => setShowUnlinkSuccessModal(false)}>
<FontAwesomeIcon icon={faCheckCircle} /> Fermer
</button> </button>
</div> </div>
</div> </div>
+18 -34
View File
@@ -73,29 +73,18 @@ interface ToastMessage {
* Somme des prix individuels (pas de multiplication) * Somme des prix individuels (pas de multiplication)
*/ */
const getTotalAmount = (order: OrderWithTracking): number => { 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) { if (typeof order.total_prix === "number" && order.total_prix > 0) {
return order.total_prix; return order.total_prix;
} }
if (typeof order.total === "number" && order.total > 0) {
// 3. Calcul depuis items (comme dans Checkout: somme des prix) return order.total;
}
if (order.items && order.items.length > 0) { 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; const itemPrice = item.prix || item.price || 0;
return sum + itemPrice; // ✅ Somme simple (pas de × quantity) return sum + itemPrice;
}, 0); }, 0);
console.log(
`💰 [getTotalAmount] Commande ${order.id}: ${calculatedTotal.toFixed(2)}`,
);
return calculatedTotal;
} }
return 0; return 0;
}; };
@@ -336,20 +325,17 @@ function SuiviLivraison() {
}, []); // eslint-disable-line react-hooks/exhaustive-deps }, []); // eslint-disable-line react-hooks/exhaustive-deps
const loadOrders = async () => { const loadOrders = async () => {
// ✅ Vérifier l'auth avant de charger les commandes
if (!isUserAuthenticated()) { if (!isUserAuthenticated()) {
console.log("❌ [loadOrders] Non authentifié");
navigate("/login/client", { replace: true }); navigate("/login/client", { replace: true });
return; return;
} }
try { try {
setLoading(true);
const response = await getMyOrders(); const response = await getMyOrders();
if (response.success && response.commands) { if (response.success) {
const ordersWithTracking = await Promise.all( const ordersWithTracking = await Promise.all(
response.commands.map(async (order: OrderDetail) => { (response.commands || []).map(async (order: OrderDetail) => {
const normalizedOrder = { const normalizedOrder = {
...order, ...order,
total: getTotalAmount(order), total: getTotalAmount(order),
@@ -361,18 +347,12 @@ function SuiviLivraison() {
try { try {
tracking = await getOrderTracking(order.id); tracking = await getOrderTracking(order.id);
} catch { } catch {
console.warn(
`Tracking non disponible pour commande ${order.id}`,
);
tracking = undefined; tracking = undefined;
} }
try { try {
eta = await getOrderETA(order.id); eta = await getOrderETA(order.id);
} catch { } catch {
console.warn(
`ETA non disponible pour commande ${order.id}`,
);
eta = undefined; eta = undefined;
} }
@@ -386,9 +366,6 @@ function SuiviLivraison() {
setOrders(ordersWithTracking); setOrders(ordersWithTracking);
setError(""); setError("");
} else {
setError("Impossible de charger les commandes");
showToast("Impossible de charger les commandes", "error");
} }
} catch (err: unknown) { } catch (err: unknown) {
console.error("Erreur loadOrders:", err); console.error("Erreur loadOrders:", err);
@@ -945,14 +922,21 @@ function SuiviLivraison() {
/>{" "} />{" "}
Montant total Montant total
</h4> </h4>
{(order.referral_used ?? 0) > 0 && (
<p style={{ margin: "0 0 2px", fontSize: "0.85rem", color: "var(--text-muted)" }}>
Brut : {(order.total_prix ?? 0).toFixed(2)}
</p>
)}
<p className="total-amount"> <p className="total-amount">
<strong> <strong>
{getTotalAmount( {Math.max(0, getTotalAmount(order) - (order.referral_used ?? 0)).toFixed(2)}
order,
).toFixed(2)}{" "}
</strong> </strong>
</p> </p>
{(order.referral_used ?? 0) > 0 && (
<p style={{ margin: "4px 0 0", fontSize: "0.82rem", color: "#10b981", fontWeight: 500 }}>
dont {(order.referral_used!).toFixed(2)} parrainage déduit
</p>
)}
</div> </div>
<div className="detail-section"> <div className="detail-section">