chore: add ansible backend docker frontend-prep
This commit is contained in:
@@ -0,0 +1,491 @@
|
||||
// ============================================
|
||||
// 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,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// HELPER
|
||||
// ============================================
|
||||
|
||||
func getStatusCancelReason(status string) string {
|
||||
reasons := map[string]string{
|
||||
"livre": "La commande a déjà été livrée",
|
||||
"approved": "La livraison a été confirmée",
|
||||
"cancelled": "La commande est déjà annulée",
|
||||
"disabled": "La commande a été désactivée",
|
||||
}
|
||||
if reason, ok := reasons[status]; ok {
|
||||
return reason
|
||||
}
|
||||
return "Statut ne permettant pas l'annulation"
|
||||
}
|
||||
Reference in New Issue
Block a user