// ============================================ // handlers/cancel_command_handler.go // ANNULATION DE COMMANDES AVEC SANCTIONS ÉVOLUTIVES // VERSION SÉCURISÉE - FIX ETA CHECK // ============================================ package handlers import ( "fmt" "gestion/db" "log" "net/http" "strconv" "strings" "time" "github.com/gin-gonic/gin" ) // ============================================ // RATE LIMITING // ============================================ var ( cancelRateLimitMap = make(map[string][]time.Time) cancelMaxRequests = 5 // Max 5 annulations cancelTimeWindow = time.Hour // Par heure ) func checkCancelRateLimit(key string) bool { now := time.Now() if timestamps, exists := cancelRateLimitMap[key]; exists { var validTimestamps []time.Time for _, ts := range timestamps { if now.Sub(ts) < cancelTimeWindow { validTimestamps = append(validTimestamps, ts) } } cancelRateLimitMap[key] = validTimestamps if len(validTimestamps) >= cancelMaxRequests { return false } } cancelRateLimitMap[key] = append(cancelRateLimitMap[key], now) return true } // ============================================ // HELPERS DE SÉCURITÉ // ============================================ func validateReason(reason string) string { // Limiter la longueur if len(reason) > 500 { reason = reason[:500] } // Sanitizer reason = strings.Map(func(r rune) rune { if r < 32 || r == 127 { return -1 } return r }, reason) if strings.TrimSpace(reason) == "" { return "Annulation par le client" } return reason } // ============================================ // 1️⃣ ANNULATION PAR LE CLIENT - VERSION SÉCURISÉE // ============================================ func CancelCommandByClient(c *gin.Context) { database := c.MustGet("database").(*db.Database) username, err := safeGetUsername(c) if err != nil || c.GetString("role") != "client" { log.Printf("❌ [CANCEL_CLIENT] Accès refusé") c.JSON(http.StatusForbidden, gin.H{"error": "Accès réservé aux clients"}) return } rateLimitKey := fmt.Sprintf("cancel:%s", username) if !checkCancelRateLimit(rateLimitKey) { log.Printf("⚠️ [CANCEL_CLIENT] Rate limit dépassé pour %s", username) c.JSON(http.StatusTooManyRequests, gin.H{ "error": "Trop d'annulations récentes", "message": "Veuillez attendre avant de réessayer", }) return } commandID, err := strconv.Atoi(c.Param("id")) if err != nil || commandID <= 0 { c.JSON(http.StatusBadRequest, gin.H{"error": "ID de commande invalide"}) return } var req struct { Reason string `json:"reason"` Force bool `json:"force"` } if err := c.ShouldBindJSON(&req); err != nil { req.Reason = "Annulation par le client" req.Force = false } req.Reason = validateReason(req.Reason) log.Printf("🚫 [CANCEL_CLIENT] Client %s annule cmd %d (force=%v)", username, commandID, req.Force) // ============================================ // UTILISER LA FONCTION ATOMIQUE // ============================================ penalty, pointsLost, err := database.CancelCommandAtomic(commandID, username, req.Reason, req.Force) if err != nil { log.Printf("❌ [CANCEL_CLIENT] Erreur: %v", err) // ✅ GESTION SPÉCIALE POUR "confirmation requise" if err.Error() == "confirmation requise" { // ✅ RÉCUPÉRER LES INFORMATIONS DE LA COMMANDE command, errCmd := database.GetCommandByID(commandID) if errCmd != nil { c.JSON(http.StatusNotFound, gin.H{"error": "Commande non trouvée"}) return } livreurAssign, _ := command["livreur_assign"].(string) currentStatus, _ := command["status"].(string) // ✅ VÉRIFIER SI ETA EXISTE (VERSION CORRIGÉE) hasETA := false if livreurAssign != "" { // ✅ FIX: Utiliser la nouvelle fonction qui vérifie VRAIMENT l'ETA hasETA = database.CheckCommandETAExistsAndValid(commandID) } // ✅ CALCULER LA PÉNALITÉ QUI SERA APPLIQUÉE nextPenalty, _ := database.CalculateCancellationPenalty(username) cancelCount, _ := database.GetClientCancellationsCount(username) // ✅ RÉCUPÉRER LES POINTS ACTUELS client, _ := database.GetClientByUsername(username) currentPointsWeed := 0 currentPointsZipette := 0 if client != nil { currentPointsWeed = client.Point currentPointsZipette = client.PointZipette } totalPoints := currentPointsWeed + currentPointsZipette // ✅ CONSTRUIRE LA RÉPONSE EN FONCTION DE hasETA response := gin.H{ "success": false, "warning": true, "command_info": gin.H{ "command_id": commandID, "status": currentStatus, "livreur": livreurAssign, }, } if hasETA { // ⚠️ CAS 1: LIVREUR EN ROUTE (ETA définie) = PÉNALITÉ TOTALE log.Printf("⚠️ [CANCEL_CLIENT] Annulation tardive avec ETA - Status: %s, Livreur: %s", currentStatus, livreurAssign) response["message"] = "⚠️ Un livreur est en route vers votre adresse (ETA définie)" response["details"] = gin.H{ "livreur": livreurAssign, "status": currentStatus, "has_eta": true, } response["penalty_warning"] = gin.H{ "will_apply": true, "penalty_amount": nextPenalty, "current_violations": cancelCount, "current_points_weed": currentPointsWeed, "current_points_zipette": currentPointsZipette, "total_points": totalPoints, "points_will_reset": true, "message": fmt.Sprintf( "⚠️ ATTENTION: Une amende de %d points sera appliquée ET tous vos points (%d weed/hash + %d zipette = %d total) seront remis à zéro!", nextPenalty, currentPointsWeed, currentPointsZipette, totalPoints, ), "scale": gin.H{ "1st_cancel": "20 points + remise à zéro TOTALE", "2nd_cancel": "50 points + remise à zéro TOTALE", "3rd_cancel": "100 points + remise à zéro TOTALE", "4th+_cancel": "150 points + remise à zéro TOTALE", "your_next": fmt.Sprintf("%d points + remise à zéro de tous vos %d points", nextPenalty, totalPoints), }, } } else { // ℹ️ CAS 2: LIVREUR ASSIGNÉ MAIS PAS EN ROUTE (PAS D'ETA) = PAS DE PÉNALITÉ log.Printf("ℹ️ [CANCEL_CLIENT] Livreur assigné mais pas d'ETA - Annulation sans pénalité") response["message"] = "ℹ️ Un livreur est assigné mais n'est pas encore en route" response["details"] = gin.H{ "livreur": livreurAssign, "status": currentStatus, "has_eta": false, } response["penalty_warning"] = gin.H{ "will_apply": false, "message": "Aucune pénalité ne sera appliquée car le livreur n'est pas encore en route", "points_safe": true, } } // ✅ AJOUTER LES INSTRUCTIONS D'ACTION response["action_required"] = "Pour confirmer l'annulation, renvoyez la même requête avec 'force': true" response["example"] = gin.H{ "reason": req.Reason, "force": true, } // ✅ AJOUTER POSITION DANS LA QUEUE (si disponible) if livreurAssign != "" { position, posErr := database.GetCommandPositionInQueue(livreurAssign, commandID) if posErr == nil && position > 0 { response["details"].(gin.H)["position_in_queue"] = position queueInfo, _ := database.GetDeliverymanQueueInfo(livreurAssign) if queueInfo != nil { response["details"].(gin.H)["queue_info"] = queueInfo } } } log.Printf("⚠️ [CANCEL_CLIENT] Confirmation requise pour cmd %d - hasETA=%v, penalty=%d", commandID, hasETA, nextPenalty) c.JSON(http.StatusConflict, response) return } // ✅ AUTRES ERREURS switch err.Error() { case "commande non trouvée": c.JSON(http.StatusNotFound, gin.H{"error": "Commande non trouvée"}) case "commande ne vous appartient pas": c.JSON(http.StatusForbidden, gin.H{"error": "Cette commande ne vous appartient pas"}) case "impossible d'annuler": c.JSON(http.StatusBadRequest, gin.H{"error": "Cette commande ne peut plus être annulée"}) default: c.JSON(http.StatusBadRequest, gin.H{"error": "Impossible d'annuler la commande"}) } return } // ============================================ // SUCCÈS // ============================================ log.Printf("✅ [CANCEL_CLIENT] Commande %d annulée", commandID) response := gin.H{ "success": true, "message": "Commande annulée avec succès", "command_id": commandID, "new_status": "cancelled", } if penalty > 0 { response["penalty"] = gin.H{ "penalty_points": penalty, "points_weed_lost": pointsLost["weed"], "points_zipette_lost": pointsLost["zipette"], "total_points_lost": pointsLost["weed"] + pointsLost["zipette"], "warning": "Une pénalité a été appliquée et vos points ont été remis à zéro", } } else { response["info"] = "Aucune pénalité appliquée" } c.JSON(http.StatusOK, response) } // ============================================ // HISTORIQUE DES ANNULATIONS - VERSION SÉCURISÉE // ============================================ func GetMyCancellationHistory(c *gin.Context) { database := c.MustGet("database").(*db.Database) username, err := safeGetUsername(c) if err != nil || c.GetString("role") != "client" { c.JSON(http.StatusForbidden, gin.H{"error": "Accès réservé aux clients"}) return } log.Printf("📊 [CANCEL_HISTORY] Client %s - Consultation historique", username) history, err := database.GetClientCancellationHistory(username) if err != nil { log.Printf("❌ [CANCEL_HISTORY] Erreur: %v", err) c.JSON(http.StatusInternalServerError, gin.H{ "error": "Erreur récupération historique", }) return } var totalPenalty int penaltyQuery := `SELECT COALESCE(amende, 0) FROM clients WHERE username = $1` database.QueryRow(penaltyQuery).Scan(&totalPenalty) c.JSON(http.StatusOK, gin.H{ "success": true, "data": gin.H{ "username": username, "history": history, "total_penalties": totalPenalty, }, }) } // ============================================ // LISTE DES COMMANDES ANNULÉES - VERSION SÉCURISÉE // ============================================ func GetAllCancelledOrders(c *gin.Context) { database := c.MustGet("database").(*db.Database) username, err := safeGetUsername(c) if err != nil { c.JSON(http.StatusUnauthorized, gin.H{"error": "Authentification requise"}) return } userRole := c.GetString("role") if userRole != "admin" && userRole != "cabine" { log.Printf("❌ [GET_ALL_CANCELLED] Accès refusé - role=%s", userRole) c.JSON(http.StatusForbidden, gin.H{"error": "Accès réservé aux administrateurs"}) return } // ✅ VALIDATION des paramètres filterUsername := c.Query("username") if len(filterUsername) > 100 { c.JSON(http.StatusBadRequest, gin.H{"error": "Username trop long"}) return } limitStr := c.DefaultQuery("limit", "50") limit, err := strconv.Atoi(limitStr) if err != nil || limit < 1 { limit = 50 } if limit > 500 { limit = 500 } log.Printf("📋 [GET_ALL_CANCELLED] %s (%s) récupère %d commandes", username, userRole, limit) cancelledOrders, err := database.GetCancelledCommands(filterUsername, limit) if err != nil { log.Printf("❌ [GET_ALL_CANCELLED] Erreur: %v", err) c.JSON(http.StatusInternalServerError, gin.H{ "error": "Erreur lors de la récupération", }) return } // ✅ ENRICHIR les données (sans exposer d'infos sensibles inutiles) var enrichedOrders []map[string]interface{} for _, order := range cancelledOrders { orderID, _ := order["id"].(int) items, _ := database.GetCommandItems(orderID) logs, _ := database.GetCommandLogs(orderID) var cancellationLog map[string]interface{} for _, logEntry := range logs { status, _ := logEntry["status"].(string) if status == "cancelled" { cancellationLog = logEntry break } } enrichedOrder := map[string]interface{}{ "id": order["id"], "username": order["username"], "total_prix": order["total_prix"], "created_at": order["created_at"], "updated_at": order["updated_at"], "items_count": len(items), } if cancellationLog != nil { enrichedOrder["cancellation"] = gin.H{ "cancelled_at": cancellationLog["created_at"], "cancelled_by": cancellationLog["author"], } } enrichedOrders = append(enrichedOrders, enrichedOrder) } log.Printf("✅ [GET_ALL_CANCELLED] %d commandes récupérées", len(enrichedOrders)) c.JSON(http.StatusOK, gin.H{ "success": true, "data": gin.H{ "cancelled_orders": enrichedOrders, "count": len(enrichedOrders), }, }) } // ============================================ // SUPPRESSION PAR CABINE - VERSION SÉCURISÉE // ============================================ func DeleteCommandByCabine(c *gin.Context) { database := c.MustGet("database").(*db.Database) username, err := safeGetUsername(c) if err != nil { c.JSON(http.StatusUnauthorized, gin.H{"error": "Authentification requise"}) return } userRole := c.GetString("role") if userRole != "cabine" && userRole != "admin" { log.Printf("❌ [DELETE_COMMAND] Accès refusé - role=%s", userRole) c.JSON(http.StatusForbidden, gin.H{"error": "Accès réservé aux cabines"}) return } commandID, err := strconv.Atoi(c.Param("id")) if err != nil || commandID <= 0 { c.JSON(http.StatusBadRequest, gin.H{"error": "ID de commande invalide"}) return } log.Printf("🗑️ [DELETE_COMMAND] %s (%s) supprime cmd %d", username, userRole, commandID) // ✅ UTILISER LA FONCTION ATOMIQUE err = database.DeleteCommandAtomic(commandID, username, userRole) if err != nil { log.Printf("❌ [DELETE_COMMAND] Erreur: %v", err) // ❌ Ne pas exposer les détails de l'erreur if err.Error() == "commande non trouvée" { c.JSON(http.StatusNotFound, gin.H{"error": "Commande non trouvée"}) } else { c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur lors de la suppression"}) } return } log.Printf("✅ [DELETE_COMMAND] Commande %d supprimée", commandID) c.JSON(http.StatusOK, gin.H{ "success": true, "message": "Commande supprimée définitivement", "deleted_by": gin.H{ "username": username, "role": userRole, }, }) }