chore: add ansible backend docker frontend-prep

This commit is contained in:
2026-01-21 13:05:13 +01:00
parent 5a280b6b01
commit 943fe4de7d
14930 changed files with 2341433 additions and 0 deletions
+211
View File
@@ -0,0 +1,211 @@
package handlers
import (
"gestion/db"
"net/http"
"strconv"
"github.com/gin-gonic/gin"
)
func AlertPolice(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
}
usernameStr := username.(string)
alert, err := database.CreateAlert(usernameStr)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(200, gin.H{
"success": true,
"message": "Police alert created",
"alert_id": alert.ID,
"user": alert.Username,
})
}
func DeleteAlert(c *gin.Context) {
database := c.MustGet("database").(*db.Database)
alertIDStr := c.Param("id")
alertID, err := strconv.Atoi(alertIDStr)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "ID d'alerte invalide"})
return
}
err = database.DeleteAlertPolicy(alertID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(200, gin.H{
"success": true,
"message": "Alert deleted",
})
}
func GetAlert(c *gin.Context) {
database := c.MustGet("database").(*db.Database)
alertIDStr := c.Param("id")
alertID, err := strconv.Atoi(alertIDStr)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "ID d'alerte invalide"})
return
}
alert, err := database.GetAlertPolicy(alertID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(200, gin.H{
"success": true,
"alert": alert,
})
}
// EndAlert permet au livreur de mettre fin à son alerte active
func EndAlert(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
}
alertIDStr := c.Param("id")
alertID, err := strconv.Atoi(alertIDStr)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "ID d'alerte invalide"})
return
}
usernameStr := username.(string)
// Vérifier que l'alerte appartient bien à ce livreur
alert, err := database.GetAlertPolicy(alertID)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Alerte non trouvée"})
return
}
if alert.Username != usernameStr {
c.JSON(http.StatusForbidden, gin.H{"error": "Cette alerte ne vous appartient pas"})
return
}
// Mettre fin à l'alerte en changeant le statut à false
err = database.EndAlert(alertID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Impossible de mettre fin à l'alerte", "details": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "Alerte terminée avec succès",
"alert_id": alertID,
"user": usernameStr,
})
}
// GetMyAlerts permet au livreur de voir ses alertes
func GetMyAlerts(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
}
usernameStr := username.(string)
alerts, err := database.GetAlertsByUsername(usernameStr)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Impossible de récupérer les alertes", "details": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"alerts": alerts,
"count": len(alerts),
"user": usernameStr,
})
}
func GetAllAlerts(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 réservé aux administrateurs"})
return
}
alerts, err := database.GetAllAlerts()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Impossible de récupérer les alertes", "details": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"alerts": alerts,
"count": len(alerts),
})
}
// GetActiveAlerts permet aux admins de voir toutes les alertes actives
func GetActiveAlerts(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 réservé aux administrateurs"})
return
}
alerts, err := database.GetActiveAlerts()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Impossible de récupérer les alertes", "details": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"alerts": alerts,
"count": len(alerts),
})
}
+741
View File
@@ -0,0 +1,741 @@
// ============================================
// handlers/auth_handlers_REFACTORED.go
// ============================================
// Version simplifiée sans les middlewares
// Les middlewares sont maintenant dans middleware/middleware_CORRIGES.go
package handlers
import (
"crypto/rand"
"encoding/hex"
"gestion/db"
"gestion/models"
"log"
"net/http"
"os"
"regexp"
"strconv"
"strings"
"time"
"github.com/gin-gonic/gin"
"github.com/golang-jwt/jwt/v5"
"github.com/google/uuid"
"golang.org/x/crypto/bcrypt"
)
// ============================================
// TYPES JWT CLAIMS (Duplicés dans middleware aussi)
// ============================================
type ClientClaims struct {
ClientID int `json:"client_id"`
Username string `json:"username"`
Role string `json:"role"`
SessionID string `json:"session_id"`
jwt.RegisteredClaims
}
type AdminClaims struct {
UserID int `json:"user_id"`
Username string `json:"username"`
Role string `json:"role"`
SessionID string `json:"session_id"`
jwt.RegisteredClaims
}
// ============================================
// STRUCTURES REQUÊTE / RÉPONSE
// ============================================
type LoginRequest struct {
Username string `json:"username" binding:"required"`
Password string `json:"password" binding:"required"`
}
type RegisterClientRequest struct {
Username string `json:"username" binding:"required,min=3,max=50"`
Password string `json:"password" binding:"required,min=8"`
Nom string `json:"nom" binding:"required,min=2,max=100"`
Prenom string `json:"prenom" binding:"required,min=2,max=100"`
Telephone string `json:"telephone" binding:"required"`
}
type RegisterAdminRequest struct {
Username string `json:"username" binding:"required,min=3,max=50"`
Password string `json:"password" binding:"required,min=8"`
Role string `json:"role" binding:"required,oneof=admin cabine livreur"`
}
type LoginResponse struct {
AccessToken string `json:"access_token"`
TokenType string `json:"token_type"`
ExpiresIn int `json:"expires_in"`
User interface{} `json:"user"`
}
type ProfileResponse struct {
Username string `json:"username"`
Role string `json:"role"`
}
// ============================================
// VARIABLES & CONSTANTS
// ============================================
var (
clientTokenDuration = 5 * time.Hour
adminTokenDuration = 2 * time.Hour
userJWTSecret = []byte(os.Getenv("USER_JWT_SECRET")) // ✅ Pour clients
adminJWTSecret = []byte(os.Getenv("ADMIN_JWT_SECRET")) // ✅ Pour admin/cabine/livreur
)
// ============================================
// UTILITAIRES
// ============================================
func generateSessionID() string {
bytes := make([]byte, 16)
rand.Read(bytes)
return hex.EncodeToString(bytes)
}
// validatePhoneNumber valide le format du numéro de téléphone
func validatePhoneNumber(phone string) bool {
// Supprimer espaces/tirets
clean := regexp.MustCompile(`[\s\-\(\)]`).ReplaceAllString(phone, "")
// Format international ou national
validFormat := regexp.MustCompile(`^(\+33|0)[1-9]\d{8}$`)
return validFormat.MatchString(clean)
}
func normalizePhoneNumber(phone string) string {
clean := regexp.MustCompile(`[\s\-\(\)]`).ReplaceAllString(phone, "")
// Convertir 06... en +336...
if strings.HasPrefix(clean, "0") {
return "+33" + clean[1:]
}
return clean
}
// ============================================
// GENERATION TOKENS
// ============================================
func generateClientToken(client *models.Client) (string, error) {
sessionID := generateSessionID()
claims := ClientClaims{
ClientID: client.ID,
Username: client.Username,
Role: "client",
SessionID: sessionID,
RegisteredClaims: jwt.RegisteredClaims{
ExpiresAt: jwt.NewNumericDate(time.Now().Add(clientTokenDuration)),
IssuedAt: jwt.NewNumericDate(time.Now()),
NotBefore: jwt.NewNumericDate(time.Now()),
Issuer: "api-client",
Subject: strconv.Itoa(client.ID),
},
}
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
tokenString, err := token.SignedString(userJWTSecret) // ✅ UTILISER userJWTSecret (CLIENT)
if err != nil {
return "", err
}
return tokenString, nil
}
func generateAdminToken(user *models.User) (string, error) {
sessionID := generateSessionID()
claims := AdminClaims{
UserID: user.ID,
Username: user.Username,
Role: user.Role, // ← "admin" ou "cabine" ou "livreur"
SessionID: sessionID,
RegisteredClaims: jwt.RegisteredClaims{
ExpiresAt: jwt.NewNumericDate(time.Now().Add(adminTokenDuration)),
IssuedAt: jwt.NewNumericDate(time.Now()),
NotBefore: jwt.NewNumericDate(time.Now()),
Issuer: "api-admin", // Même issuer pour tous les admins
Subject: strconv.Itoa(user.ID),
},
}
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
tokenString, err := token.SignedString(adminJWTSecret) // ✅ UTILISER adminJWTSecret (ADMIN/CABINE/LIVREUR)
if err != nil {
return "", err
}
return tokenString, nil
}
// ============================================
// HANDLERS AUTHENTIFICATION CLIENT
// ============================================
// RegisterClient crée un nouveau compte client
// POST /api/v1/auth/register
func RegisterClient(c *gin.Context) {
var req RegisterClientRequest
if err := c.ShouldBindJSON(&req); err != nil {
log.Printf("❌ [REGISTER_CLIENT] Erreur binding: %v", err)
c.JSON(http.StatusBadRequest, gin.H{
"error": "Données invalides",
"details": err.Error(),
})
return
}
// Validation téléphone
if !validatePhoneNumber(req.Telephone) {
log.Printf("❌ [REGISTER_CLIENT] Téléphone invalide: %s", req.Telephone)
c.JSON(http.StatusBadRequest, gin.H{
"error": "Numéro de téléphone invalide",
})
return
}
normalizedPhone := normalizePhoneNumber(req.Telephone)
database := c.MustGet("database").(*db.Database)
// Vérifier username unique
if existingClient, _ := database.GetClientByUsername(req.Username); existingClient != nil {
log.Printf("❌ [REGISTER_CLIENT] Username déjà utilisé: %s", req.Username)
c.JSON(http.StatusConflict, gin.H{"error": "Nom d'utilisateur déjà utilisé"})
return
}
// Vérifier téléphone unique
if existingClient, _ := database.GetClientByTelephone(normalizedPhone); existingClient != nil {
log.Printf("❌ [REGISTER_CLIENT] Téléphone déjà utilisé: %s", normalizedPhone)
c.JSON(http.StatusConflict, gin.H{"error": "Ce numéro de téléphone est déjà utilisé"})
return
}
// Hasher le mot de passe
hashed, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost)
if err != nil {
log.Printf("❌ [REGISTER_CLIENT] Erreur bcrypt: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur traitement mot de passe"})
return
}
// Créer le client
client := &models.Client{
Username: req.Username,
Password: string(hashed),
Nom: strings.TrimSpace(req.Nom),
Prenom: strings.TrimSpace(req.Prenom),
Telephone: normalizedPhone,
CreatedAt: time.Now(),
}
if err := database.CreateClient(client); err != nil {
log.Printf("❌ [REGISTER_CLIENT] Erreur création: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur création client"})
return
}
// Générer le token
token, err := generateClientToken(client)
if err != nil {
log.Printf("❌ [REGISTER_CLIENT] Erreur génération token: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur génération token"})
return
}
// Sauvegarder le token
expiresAt := time.Now().Add(clientTokenDuration)
if err := database.SaveToken(client.ID, "client", token, expiresAt); err != nil {
log.Printf("❌ [REGISTER_CLIENT] Erreur SaveToken: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur enregistrement token"})
return
}
// Créer la session Redis
sessionID := uuid.New().String()
if err := database.CreateClientSession(client.ID, client.Username, sessionID); err != nil {
log.Printf("⚠️ [REGISTER_CLIENT] Erreur session Redis: %v", err)
}
client.Password = ""
log.Printf("✅ [REGISTER_CLIENT] Client créé: %s (ID=%d)", client.Username, client.ID)
c.JSON(http.StatusCreated, 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,
},
})
}
// LoginClient authentifie un client
// POST /api/v1/auth/login
func LoginClient(c *gin.Context) {
var req LoginRequest
if err := c.ShouldBindJSON(&req); err != nil {
log.Printf("❌ [LOGIN_CLIENT] Erreur binding: %v", err)
c.JSON(http.StatusBadRequest, gin.H{"error": "Données invalides"})
return
}
database := c.MustGet("database").(*db.Database)
client, err := database.GetClientByUsername(req.Username)
if err != nil {
log.Printf("❌ [LOGIN_CLIENT] Client non trouvé: %s", req.Username)
c.JSON(http.StatusUnauthorized, gin.H{"error": "Identifiants invalides"})
return
}
if err := bcrypt.CompareHashAndPassword([]byte(client.Password), []byte(req.Password)); err != nil {
log.Printf("❌ [LOGIN_CLIENT] Mot de passe invalide: %s", req.Username)
c.JSON(http.StatusUnauthorized, gin.H{"error": "Identifiants invalides"})
return
}
// ✅ LIGNE 302 CORRIGÉE - Gérer l'erreur !
token, err := generateClientToken(client)
if err != nil {
log.Printf("❌ [LOGIN_CLIENT] Erreur génération token: %v", err)
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 {
log.Printf("❌ [LOGIN_CLIENT] Erreur SaveToken: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur enregistrement token"})
return
}
// Créer la session Redis
sessionID := uuid.New().String()
if err := database.CreateClientSession(client.ID, client.Username, sessionID); err != nil {
log.Printf("⚠️ [LOGIN_CLIENT] Erreur session Redis: %v", err)
}
log.Printf("✅ [LOGIN_CLIENT] Client authentifié: %s", req.Username)
c.JSON(http.StatusOK, 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,
},
})
}
// LogoutClient déconnecte un client
// POST /api/v1/auth/logout
func LogoutClient(c *gin.Context) {
database := c.MustGet("database").(*db.Database)
// Invalider la session Redis
clientID, hasClientID := c.Get("client_id")
if hasClientID && clientID != nil {
if err := database.InvalidateSession(clientID.(int)); err != nil {
log.Printf("⚠️ [LOGOUT_CLIENT] Erreur invalidation session: %v", err)
}
}
// Révoquer le JWT token
authHeader := c.GetHeader("Authorization")
if authHeader != "" && strings.HasPrefix(authHeader, "Bearer ") {
tokenStr := strings.TrimPrefix(authHeader, "Bearer ")
database.RevokeToken(tokenStr)
}
log.Printf("✅ [LOGOUT_CLIENT] Client déconnecté")
c.JSON(http.StatusOK, gin.H{"message": "Déconnexion réussie"})
}
// ============================================
// HANDLERS AUTHENTIFICATION ADMIN
// ============================================
// RegisterAdmin crée un nouvel utilisateur admin/cabine/livreur
// POST /api/v1/auth/admin/register
func RegisterAdmin(c *gin.Context) {
var req 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)
// Vérifier que l'user n'existe pas
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
}
log.Printf("✅ [REGISTER_ADMIN] User créé: %s (role=%s, ID=%d)", user.Username, user.Role, user.ID)
// Générer le token
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
}
log.Printf("✅ [REGISTER_ADMIN] Token généré: %s...", token[:50])
// ============================================
// ✅ CRITICAL FIX: ENREGISTRER LE TOKEN EN DB
// ============================================
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
}
log.Printf("✅ [REGISTER_ADMIN] Token enregistré en DB pour user ID: %d", user.ID)
user.Password = ""
c.JSON(http.StatusCreated, LoginResponse{
AccessToken: token,
TokenType: "Bearer",
ExpiresIn: int(adminTokenDuration.Seconds()),
User: user,
})
}
// LoginAdmin authentifie un admin/cabine/livreur
// POST /api/v1/auth/admin/login
// ✅ AMÉLIORÉ: Meilleure gestion d'erreurs
func LoginAdmin(c *gin.Context) {
var req LoginRequest
if err := c.ShouldBindJSON(&req); err != nil {
log.Printf("❌ [LOGIN_ADMIN] Erreur binding: %v", err)
c.JSON(http.StatusBadRequest, gin.H{"error": "Données invalides"})
return
}
database := c.MustGet("database").(*db.Database)
user, err := database.GetUserByUsername(req.Username)
if err != nil || user == nil {
log.Printf("❌ [LOGIN_ADMIN] User non trouvé: %s", req.Username)
c.JSON(http.StatusUnauthorized, gin.H{"error": "Identifiants invalides"})
return
}
// Vérifier que c'est un admin/cabine/livreur
if user.Role != "admin" && user.Role != "cabine" && user.Role != "livreur" {
log.Printf("❌ [LOGIN_ADMIN] Rôle invalide: %s", user.Role)
c.JSON(http.StatusUnauthorized, gin.H{"error": "Accès non autorisé"})
return
}
// Vérifier le mot de passe
if user.Password == "" {
log.Printf("❌ [LOGIN_ADMIN] Mot de passe vide en base")
c.JSON(http.StatusUnauthorized, gin.H{"error": "Identifiants invalides"})
return
}
if err := bcrypt.CompareHashAndPassword([]byte(user.Password), []byte(req.Password)); err != nil {
log.Printf("❌ [LOGIN_ADMIN] Mot de passe invalide: %s", req.Username)
c.JSON(http.StatusUnauthorized, gin.H{"error": "Identifiants invalides"})
return
}
// Générer le token
token, _ := generateAdminToken(user)
expiresAt := time.Now().Add(adminTokenDuration)
if err := database.SaveToken(user.ID, user.Role, token, expiresAt); err != nil {
log.Printf("❌ [LOGIN_ADMIN] Erreur SaveToken: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur enregistrement token"})
return
}
log.Printf("✅ [LOGIN_ADMIN] User authentifié: %s (role=%s)", user.Username, user.Role)
c.JSON(http.StatusOK, LoginResponse{
AccessToken: token,
TokenType: "Bearer",
ExpiresIn: int(adminTokenDuration.Seconds()),
User: gin.H{
"id": user.ID,
"username": user.Username,
"role": user.Role,
},
})
}
// LogoutAdmin déconnecte un admin/cabine/livreur
// POST /api/v1/auth/admin/logout
func LogoutAdmin(c *gin.Context) {
database := c.MustGet("database").(*db.Database)
// Révoquer le JWT token
authHeader := c.GetHeader("Authorization")
if authHeader != "" && strings.HasPrefix(authHeader, "Bearer ") {
tokenStr := strings.TrimPrefix(authHeader, "Bearer ")
database.RevokeToken(tokenStr)
}
log.Printf("✅ [LOGOUT_ADMIN] User déconnecté")
c.JSON(http.StatusOK, gin.H{"message": "Déconnexion réussie"})
}
// ============================================
// HELPERS
// ============================================
// GetCurrentClient récupère le client actuel
// GET /api/v1/profile/client
func GetCurrentClient(c *gin.Context) {
clientID := c.GetInt("client_id")
database := c.MustGet("database").(*db.Database)
client, err := database.GetClientByID(clientID)
if err != nil {
log.Printf("❌ [GET_CURRENT_CLIENT] Client non trouvé: ID=%d", clientID)
c.JSON(http.StatusNotFound, gin.H{"error": "Client non trouvé"})
return
}
client.Password = ""
log.Printf("✅ [GET_CURRENT_CLIENT] Client récupéré: %s", client.Username)
c.JSON(http.StatusOK, gin.H{
"client": gin.H{
"id": client.ID,
"username": client.Username,
"nom": client.Nom,
"prenom": client.Prenom,
"telephone": client.Telephone,
"command": client.Command,
"point": client.Point,
"amende": client.Amende,
},
})
}
// GetCurrentAdmin récupère l'admin/user actuel
// GET /api/v1/profile/admin
func GetCurrentAdmin(c *gin.Context) {
userID := c.GetInt("user_id")
database := c.MustGet("database").(*db.Database)
user, err := database.GetUserByID(userID)
if err != nil {
log.Printf("❌ [GET_CURRENT_ADMIN] User non trouvé: ID=%d", userID)
c.JSON(http.StatusNotFound, gin.H{"error": "Utilisateur non trouvé"})
return
}
user.Password = ""
log.Printf("✅ [GET_CURRENT_ADMIN] User récupéré: %s", user.Username)
c.JSON(http.StatusOK, gin.H{
"user": ProfileResponse{
Username: user.Username,
Role: user.Role,
},
})
}
// HealthCheck vérifie la santé de l'API
// GET /api/v1/health
func HealthCheck(c *gin.Context) {
database := c.MustGet("database").(*db.Database)
if err := database.DB.Ping(); err != nil {
log.Printf("⚠️ [HEALTH] Database down: %v", err)
c.JSON(http.StatusServiceUnavailable, gin.H{
"status": "unhealthy",
"database": "disconnected",
"timestamp": time.Now().Unix(),
})
return
}
log.Printf("✅ [HEALTH] API healthy")
c.JSON(http.StatusOK, gin.H{
"status": "healthy",
"database": "connected",
"timestamp": time.Now().Unix(),
"version": "2.0.0",
})
}
// GetAllUsers récupère tous les utilisateurs (Admin only)
func GetAllUsers(c *gin.Context) {
database := c.MustGet("database").(*db.Database)
users, err := database.GetAllUsers()
if err != nil {
log.Printf("❌ [GET_ALL_USERS] Erreur: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur récupération"})
return
}
var sanitized []gin.H
for _, u := range users {
sanitized = append(sanitized, gin.H{
"id": u.ID,
"username": u.Username,
"role": u.Role,
})
}
log.Printf("✅ [GET_ALL_USERS] %d users récupérés", len(sanitized))
c.JSON(http.StatusOK, gin.H{
"users": sanitized,
"count": len(sanitized),
})
}
func GetAllDeliveryMen(c *gin.Context) {
database := c.MustGet("database").(*db.Database)
users, err := database.GetAllDeliveryMen()
if err != nil {
log.Printf("❌ [GET_ALL_USERS] Erreur: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur récupération"})
return
}
var sanitized []gin.H
for _, u := range users {
sanitized = append(sanitized, gin.H{
"id": u.ID,
"username": u.Username,
"role": u.Role,
})
}
log.Printf("✅ [GET_ALL_USERS] %d users récupérés", len(sanitized))
c.JSON(http.StatusOK, gin.H{
"users": sanitized,
"count": len(sanitized),
})
}
// GetAllClients récupère tous les clients
// GET /api/v1/admin/clients
func GetAllClients(c *gin.Context) {
database := c.MustGet("database").(*db.Database)
clients, err := database.GetAllClients()
if err != nil {
log.Printf("❌ [GET_ALL_CLIENTS] Erreur: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur récupération"})
return
}
var sanitized []gin.H
for _, cl := range clients {
sanitized = append(sanitized, gin.H{
"id": cl.ID,
"username": cl.Username,
"nom": cl.Nom,
"prenom": cl.Prenom,
"telephone": cl.Telephone,
"command": cl.Command,
"point": cl.Point,
"points_zipette": cl.PointZipette,
"amende": cl.Amende,
"cancellations_count": cl.CancellationsCount,
"last_penalty_reason": cl.LastPenaltyReason,
})
}
log.Printf("✅ [GET_ALL_CLIENTS] %d clients récupérés", len(sanitized))
c.JSON(http.StatusOK, gin.H{
"clients": sanitized,
"count": len(sanitized),
})
}
func DeleteUser(c *gin.Context) {
database := c.MustGet("database").(*db.Database)
idStr := c.Param("id")
id, err := strconv.Atoi(idStr)
if err != nil {
log.Printf("❌ [DELETE_USER] ID invalide: %v", err)
c.JSON(http.StatusBadRequest, gin.H{"error": "ID invalide"})
return
}
err = database.DeleteUser(id)
if err != nil {
log.Printf("❌ [DELETE_USER] Erreur: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur suppression"})
return
}
log.Printf("✅ [DELETE_USER] Utilisateur %d supprimé", id)
c.JSON(http.StatusOK, gin.H{"message": "Utilisateur supprimé"})
}
// DeleteClient
func DeleteClient(c *gin.Context) {
database := c.MustGet("database").(*db.Database)
idStr := c.Param("id")
id, err := strconv.Atoi(idStr)
if err != nil {
log.Printf("❌ [DELETE_CLIENT] ID invalide: %v", err)
c.JSON(http.StatusBadRequest, gin.H{"error": "ID invalide"})
return
}
err = database.DeleteClient(id)
if err != nil {
log.Printf("❌ [DELETE_CLIENT] Erreur: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur suppression"})
return
}
log.Printf("✅ [DELETE_CLIENT] Client %d supprimé", id)
c.JSON(http.StatusOK, gin.H{"message": "Client supprimé"})
}
+703
View File
@@ -0,0 +1,703 @@
// ============================================
// handlers/cabine_handlers.go - COMPLET
// INCLUT: SetCommandDestinationCoordinates
// ============================================
package handlers
import (
"encoding/json"
"fmt"
"gestion/db"
"log"
"net/http"
"strconv"
"time"
"github.com/gin-gonic/gin"
)
// ============================================
// 0️⃣ FONCTION ADMIN: SET DESTINATION COORDINATES
// ============================================
// SetCommandDestinationCoordinates stocke les coordonnées destination en Redis
// POST /api/v2/admin/protected/orders/:id/set-destination
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
}
// Vérifier que la commande existe
command, err := database.GetCommandByID(commandID)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Commande non trouvée"})
return
}
log.Printf("Command: %v", command)
// Stocker en Redis avec format JSON
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",
"details": err.Error(),
})
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,
"point": client.Point,
"amende": client.Amende,
"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,
"points": client.Point,
"amende": client.Amende,
},
"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"}
isAllowed := false
for _, s := range allowedStatuses {
if status == s {
isAllowed = true
break
}
}
if !isAllowed {
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",
"details": err.Error(),
})
return
}
cabineUsername, _ := c.Get("username")
message := fmt.Sprintf("Adresse modifiée par cabine: %s", req.DeliveryAddress)
if req.Reason != "" {
message += fmt.Sprintf(" (Raison: %s)", req.Reason)
}
database.AddCommandLog(commandID, "address_updated", message, cabineUsername.(string))
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "Adresse de livraison mise à jour",
"command_id": commandID,
"delivery_address": req.DeliveryAddress,
})
}
// ============================================
// 3. LIVREUR POSITION
// ============================================
func GetLivreurPosition(c *gin.Context) {
database := c.MustGet("database").(*db.Database)
livreurUsername := c.Param("username")
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
}
if livreurUsername == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "Username livreur requis"})
return
}
position, err := database.GetLivreurPosition(livreurUsername)
if err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"livreur": livreurUsername,
"message": "Position non disponible (GPS désactivé ou livraison terminée)",
"details": err.Error(),
})
return
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"livreur": livreurUsername,
"position": position,
})
}
// ============================================
// 4. DELIVERY TRACKING CLIENT (SANS GPS)
// ============================================
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)
}
// ============================================
// 6. DELIVERY ISSUES
// ============================================
func GetDeliveryIssues(c *gin.Context) {
database := c.MustGet("database").(*db.Database)
status := c.Query("status")
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
}
issues, err := database.GetDeliveryIssues(status)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{
"error": "Erreur récupération problèmes",
"details": err.Error(),
})
return
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"issues": issues,
"count": len(issues),
})
}
func CreateDeliveryIssue(c *gin.Context) {
database := c.MustGet("database").(*db.Database)
var req struct {
CommandID int `json:"command_id" binding:"required"`
IssueType string `json:"issue_type" binding:"required"`
Description string `json:"description" binding:"required"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Données invalides"})
return
}
cabineUsername, _ := c.Get("username")
issue, err := database.CreateDeliveryIssue(
req.CommandID,
req.IssueType,
req.Description,
cabineUsername.(string),
)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{
"error": "Erreur création problème",
"details": err.Error(),
})
return
}
c.JSON(http.StatusCreated, gin.H{
"success": true,
"message": "Problème enregistré",
"issue": issue,
})
}
func UpdateDeliveryIssue(c *gin.Context) {
database := c.MustGet("database").(*db.Database)
issueID, err := strconv.Atoi(c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "ID invalide"})
return
}
var req struct {
Status string `json:"status"`
Resolution string `json:"resolution"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Données invalides"})
return
}
cabineUsername, _ := c.Get("username")
err = database.UpdateDeliveryIssue(issueID, req.Status, req.Resolution, cabineUsername.(string))
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{
"error": "Erreur mise à jour",
"details": err.Error(),
})
return
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "Problème mis à jour",
})
}
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,
"support",
fmt.Sprintf("Support cabine: %s", req.Message),
cabineUsername.(string),
)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{
"error": "Erreur ajout support",
"details": err.Error(),
})
return
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "Support ajouté",
})
}
func GetCommandLogs(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
}
logs, err := database.GetCommandLogs(commandID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{
"error": "Erreur récupération logs",
"details": err.Error(),
})
return
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"logs": 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",
"details": err.Error(),
"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
}
validStatuses := []string{"assigned", "in_transit", "en_route", "en_cours", "support", "pending", "priority"}
isValidStatus := false
for _, vs := range validStatuses {
if status == vs {
isValidStatus = true
break
}
}
if !isValidStatus && status != "livre" {
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
}
if status == "livre" {
c.JSON(http.StatusBadRequest, gin.H{
"error": "Cette commande a déjà été validée",
"current_status": status,
})
return
}
err = database.UpdateCommandStatus(commandID, "livre")
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{
"error": "Erreur lors de la validation forcée",
"details": err.Error(),
})
return
}
clientUsername, _ := command["username"].(string)
livreurAssign, _ := command["livreur_assign"].(string)
if err := database.IncrementClientCommandCount(clientUsername); err != nil {
log.Printf("⚠️ Erreur compteur commandes: %v", err)
}
if err := database.AddClientPoints(clientUsername, 10); err != nil {
log.Printf("⚠️ Erreur ajout points: %v", err)
}
if livreurAssign != "" {
log.Printf("📦 Commande %d validée de force par admin - Optimisation queue de %s...", commandID, 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 != "",
})
}
+491
View File
@@ -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"
}
+264
View File
@@ -0,0 +1,264 @@
// ============================================
// handlers/client_tracking.go - NOUVEAU FICHIER
// SUIVI COMMANDE POUR CLIENTS
// ============================================
package handlers
import (
"gestion/db"
"log"
"net/http"
"strconv"
"github.com/gin-gonic/gin"
)
// GetCommandStatus - Statut temps réel d'une commande
// GET /api/v1/commands/:id/status
func GetCommandStatus(c *gin.Context) {
database := c.MustGet("database").(*db.Database)
username, exists := c.Get("username")
if !exists {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Authentification requise"})
return
}
usernameStr := username.(string)
commandID, err := strconv.Atoi(c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "ID invalide"})
return
}
log.Printf("📊 [STATUS] Client %s demande statut cmd %d", usernameStr, commandID)
// 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 PROPRIÉTÉ
cmdUsername, _ := command["username"].(string)
if cmdUsername != usernameStr {
log.Printf("❌ [STATUS] Accès refusé - cmd de %s demandée par %s", cmdUsername, usernameStr)
c.JSON(http.StatusForbidden, gin.H{
"error": "Cette commande ne vous appartient pas",
})
return
}
// Récupérer ETA
etaData, _ := database.GetCommandETA(commandID)
// Récupérer infos livreur (si assigné)
livreurInfo := gin.H{
"assigned": false,
}
if livreurAssign, ok := command["livreur_assign"].(string); ok && livreurAssign != "" {
queueInfo, _ := database.GetDeliverymanQueueInfo(livreurAssign)
livreurInfo = gin.H{
"assigned": true,
"livreur_name": livreurAssign,
"queue_position": queueInfo["queue_size"],
}
}
// Mapper le statut en message lisible
statusMessage := getStatusMessage(command["status"].(string))
c.JSON(http.StatusOK, gin.H{
"success": true,
"command_id": commandID,
"status": command["status"],
"status_message": statusMessage,
"adresse": command["adresse"],
"total_prix": command["total_prix"],
"created_at": command["created_at"],
"livreur": livreurInfo,
"eta": etaData,
})
}
// GetMyCommandsWithTracking - Liste des commandes avec suivi
// GET /api/v1/my-commands
func GetMyCommandsWithTracking(c *gin.Context) {
database := c.MustGet("database").(*db.Database)
username, exists := c.Get("username")
if !exists {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Authentification requise"})
return
}
usernameStr := username.(string)
status := c.Query("status")
log.Printf("📋 [MY_CMDS] Client %s demande ses commandes (status=%s)", usernameStr, status)
commands, err := database.GetAllCommands(status, usernameStr)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{
"error": "Erreur récupération",
"details": err.Error(),
})
return
}
// Enrichir avec tracking
enrichedCommands := make([]gin.H, len(commands))
for i, cmd := range commands {
commandID, _ := cmd["id"].(int)
// ETA
etaData, _ := database.GetCommandETA(commandID)
// Infos livreur
livreurInfo := gin.H{
"assigned": false,
}
if livreurAssign, ok := cmd["livreur_assign"].(string); ok && livreurAssign != "" {
queueInfo, _ := database.GetDeliverymanQueueInfo(livreurAssign)
livreurInfo = gin.H{
"assigned": true,
"livreur_name": livreurAssign,
"queue_position": queueInfo["queue_size"],
}
}
enrichedCommands[i] = gin.H{
"id": cmd["id"],
"status": cmd["status"],
"status_message": getStatusMessage(cmd["status"].(string)),
"adresse": cmd["adresse"],
"total_prix": cmd["total_prix"],
"created_at": cmd["created_at"],
"livreur": livreurInfo,
"eta": etaData,
}
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"commands": enrichedCommands,
"count": len(enrichedCommands),
})
}
// GetCommandTracking - Suivi détaillé d'une commande
// GET /api/v1/commands/:id/tracking
func GetCommandTracking(c *gin.Context) {
database := c.MustGet("database").(*db.Database)
username := c.GetString("username")
commandID, err := strconv.Atoi(c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "ID invalide"})
return
}
// Récupérer commande
command, err := database.GetCommandByID(commandID)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Commande non trouvée"})
return
}
// Vérifier propriété
cmdUsername, _ := command["username"].(string)
if cmdUsername != username {
c.JSON(http.StatusForbidden, gin.H{
"error": "Cette commande ne vous appartient pas",
})
return
}
// Récupérer logs
logs, _ := database.GetCommandLogs(commandID)
// ETA
etaData, _ := database.GetCommandETA(commandID)
// Timeline (basé sur les logs)
timeline := buildTimeline(logs)
c.JSON(http.StatusOK, gin.H{
"success": true,
"command_id": commandID,
"status": command["status"],
"status_message": getStatusMessage(command["status"].(string)),
"eta": etaData,
"timeline": timeline,
"logs": logs,
})
}
// ============================================
// HELPERS
// ============================================
// getStatusMessage retourne un message lisible pour le client
func getStatusMessage(status string) string {
messages := map[string]string{
"pending": "⏳ En attente d'assignation",
"assigned": "✅ Livreur assigné",
"support": "👨‍💼 En préparation",
"en_route": "🚗 En cours de livraison",
"arrived": "📍 Livreur arrivé",
"livre": "📦 Livré - En attente de confirmation",
"delivered": "✅ Livré",
"approved": "🎉 Livraison confirmée",
"failed": "❌ Échec de livraison",
"cancelled": "🚫 Annulée",
"disabled": "⚠️ Désactivée",
}
if msg, ok := messages[status]; ok {
return msg
}
return "📋 " + status
}
// buildTimeline construit une timeline depuis les logs
func buildTimeline(logs []map[string]interface{}) []gin.H {
timeline := make([]gin.H, 0)
for _, logEntry := range logs {
status, _ := logEntry["status"].(string)
message, _ := logEntry["message"].(string)
createdAt, _ := logEntry["created_at"]
timeline = append(timeline, gin.H{
"status": status,
"message": message,
"icon": getStatusIcon(status),
"created_at": createdAt,
})
}
return timeline
}
// getStatusIcon retourne une icône pour la timeline
func getStatusIcon(status string) string {
icons := map[string]string{
"created": "🛒",
"assigned": "👤",
"support": "📦",
"en_route": "🚗",
"arrived": "📍",
"livre": "✅",
"approved": "🎉",
"failed": "❌",
"cancelled": "🚫",
}
if icon, ok := icons[status]; ok {
return icon
}
return "📋"
}
File diff suppressed because it is too large Load Diff
+442
View File
@@ -0,0 +1,442 @@
// ============================================
// handlers/delivery_handlers.go
// 🔧 VERSION MODIFIÉE avec ETA automatique
// ============================================
package handlers
import (
"encoding/json"
"fmt"
"gestion/db"
"log"
"math"
"net/http"
"strconv"
"github.com/gin-gonic/gin"
)
// ============================================
// 🔧 GetMyDeliveries
// ============================================
func GetMyDeliveries(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)
status := c.Query("status")
commands, err := database.GetDeliveryPersonCommands(usernameStr, status)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{
"error": "Erreur récupération",
"details": err.Error(),
})
return
}
// ✨ FILTRAGE (SANS TÉLÉPHONE)
filteredCommands := make([]gin.H, len(commands))
for i, cmd := range commands {
commandID, _ := cmd["id"].(int)
items, _ := database.GetCommandItems(commandID)
// Client info SANS téléphone
clientUsername, _ := cmd["username"].(string)
client, _ := database.GetClientByUsername(clientUsername)
clientInfo := gin.H{"nom": "Client", "prenom": ""}
if client != nil {
clientInfo = gin.H{
"nom": client.Nom,
"prenom": client.Prenom,
}
}
itemsSummary := make([]gin.H, len(items))
for j, item := range items {
itemsSummary[j] = gin.H{
"produit": item["produit"],
"quantite": item["quantite"],
"prix": item["prix"],
}
}
etaData, _ := database.GetCommandETA(commandID)
filteredCommands[i] = gin.H{
"id": cmd["id"],
"status": cmd["status"],
"adresse": cmd["adresse"],
"total_prix": cmd["total_prix"],
"created_at": cmd["created_at"],
"client_info": clientInfo,
"items": itemsSummary,
"items_count": len(items),
"eta": etaData,
}
}
log.Printf("✅ [MY_DELIVERIES] %d livraisons (données filtrées)", len(filteredCommands))
c.JSON(http.StatusOK, gin.H{
"success": true,
"deliveries": filteredCommands,
"count": len(filteredCommands),
})
}
// ============================================
// GetDeliveryDetails
// ============================================
func GetDeliveryDetails(c *gin.Context) {
database := c.MustGet("database").(*db.Database)
username := c.GetString("username")
if c.GetString("role") != "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 invalide"})
return
}
command, err := database.GetCommandByID(commandID)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Commande non trouvée"})
return
}
// ✅ VÉRIFIER PROPRIÉTÉ
livreurAssign, _ := command["livreur_assign"].(string)
if livreurAssign != username {
log.Printf("❌ Accès refusé - cmd assignée à %s", livreurAssign)
c.JSON(http.StatusForbidden, gin.H{
"error": "Cette livraison ne vous est pas assignée",
})
return
}
items, _ := database.GetCommandItems(commandID)
clientUsername, _ := command["username"].(string)
client, _ := database.GetClientByUsername(clientUsername)
clientInfo := gin.H{"nom": "Client", "prenom": ""}
if client != nil {
clientInfo = gin.H{
"nom": client.Nom,
"prenom": client.Prenom,
}
}
itemsSummary := make([]gin.H, len(items))
for i, item := range items {
itemsSummary[i] = gin.H{
"produit": item["produit"],
"quantite": item["quantite"],
"prix": item["prix"],
}
}
etaData, _ := database.GetCommandETA(commandID)
c.JSON(http.StatusOK, gin.H{
"success": true,
"delivery": gin.H{
"id": command["id"],
"status": command["status"],
"adresse": command["adresse"],
"total_prix": command["total_prix"],
"created_at": command["created_at"],
"client_info": clientInfo,
"items": itemsSummary,
"items_count": len(items),
"eta": etaData,
},
})
}
// ============================================
// 🔧 UpdateDeliveryStatus - VERSION MODIFIÉE
// ✅ CALCUL AUTOMATIQUE ETA lors du passage en "en_route"
// ============================================
func UpdateDeliveryStatus(c *gin.Context) {
database := c.MustGet("database").(*db.Database)
username, exists := c.Get("username")
if !exists || c.GetString("role") != "livreur" {
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 invalide"})
return
}
var req struct {
Status string `json:"status" binding:"required"`
Notes string `json:"notes"`
Latitude float64 `json:"latitude"`
Longitude float64 `json:"longitude"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{
"error": "Données invalides",
"details": err.Error(),
})
return
}
log.Printf("📝 [UPD_STATUS] %s update cmd %d: %s", usernameStr, commandID, req.Status)
command, err := database.GetCommandByID(commandID)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Commande non trouvée"})
return
}
// ✅ VÉRIFIER PROPRIÉTÉ
livreurAssign, _ := command["livreur_assign"].(string)
if livreurAssign != usernameStr {
log.Printf("❌ Accès refusé - assigné à %s", livreurAssign)
c.JSON(http.StatusForbidden, gin.H{
"error": "Cette commande ne vous est pas assignée",
})
return
}
// ✅ STATUTS VALIDES POUR LIVREUR (correspondant à la DB)
validStatuses := []string{
"support", // Prise en charge
"assigned", // Assigné (si auto-assignation)
"en_route", // En route vers le client
"arrived", // Arrivé à destination
"livre", // Livré (en attente confirmation client)
"failed", // Échec de livraison
"cancelled", // Annulée
}
isValid := false
for _, s := range validStatuses {
if req.Status == s {
isValid = true
break
}
}
if !isValid {
c.JSON(http.StatusBadRequest, gin.H{
"error": "Statut invalide",
"valid_statuses": validStatuses,
"received": req.Status,
})
return
}
// ✅ VALIDATION GPS pour livraison finale (livre ou failed)
if (req.Status == "livre" || req.Status == "failed") &&
req.Latitude != 0 && req.Longitude != 0 {
destLat, _ := command["dest_latitude"].(float64)
destLon, _ := command["dest_longitude"].(float64)
if destLat != 0 && destLon != 0 {
distance := calculateDistance(req.Latitude, req.Longitude, destLat, destLon)
log.Printf("📍 [GPS] Distance: %.2f m", distance)
if distance > 100 {
c.JSON(http.StatusBadRequest, gin.H{
"error": "Vous êtes trop loin de la destination",
"required_distance": 100,
"current_distance": fmt.Sprintf("%.2f", distance),
"unit": "meters",
})
return
}
log.Printf("✅ [GPS] Validation OK")
} else {
log.Printf("⚠️ [GPS] Coordonnées de destination non disponibles, validation ignorée")
}
}
// Mettre à jour le statut
if err := database.UpdateCommandStatus(commandID, req.Status); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{
"error": "Erreur mise à jour",
"details": err.Error(),
})
return
}
// ✅ SI PASSAGE EN "EN_ROUTE" → CALCULER ET DÉFINIR L'ETA
var etaMinutes int
var etaMessage string
if req.Status == "en_route" {
log.Printf("🚗 [STATUS_LIVREUR] Passage en 'en_route' - Calcul ETA...")
// Récupérer les coordonnées destination
var destLat, destLon float64
// 1. Essayer le cache Redis
destCacheKey := fmt.Sprintf("command:destination:%d", commandID)
destData, err := db.Redis.Get(db.RedisCtx, destCacheKey).Result()
if err == 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
log.Printf("📍 [STATUS_LIVREUR] Coords depuis cache Redis: (%.6f, %.6f)", destLat, destLon)
}
}
// 2. Fallback: récupérer depuis la DB
if destLat == 0 || destLon == 0 {
if dLat, ok := command["dest_latitude"].(float64); ok && dLat != 0 {
destLat = dLat
}
if dLon, ok := command["dest_longitude"].(float64); ok && dLon != 0 {
destLon = dLon
}
if destLat != 0 && destLon != 0 {
log.Printf("📍 [STATUS_LIVREUR] Coords depuis DB: (%.6f, %.6f)", destLat, destLon)
}
}
// 3. Calculer l'ETA depuis la position du livreur
if destLat != 0 && destLon != 0 {
etaMinutes = database.CalculateETAForDeliveryman(usernameStr, destLat, destLon)
// Définir l'ETA dans Redis
if err := database.SetCommandETA(commandID, etaMinutes); err != nil {
log.Printf("⚠️ [STATUS_LIVREUR] Erreur définition ETA: %v", err)
} else {
log.Printf("✅ [STATUS_LIVREUR] ETA défini: %d minutes", etaMinutes)
etaMessage = fmt.Sprintf("Arrivée prévue dans %d minutes", etaMinutes)
}
} else {
log.Printf("⚠️ [STATUS_LIVREUR] Coordonnées destination manquantes - ETA par défaut")
etaMinutes = 30 // Fallback
database.SetCommandETA(commandID, etaMinutes)
etaMessage = "Arrivée prévue dans 30 minutes (estimation par défaut)"
}
// Mettre à jour le statut du livreur en "delivering"
database.SetDeliveryPersonStatus(usernameStr, "delivering", commandID)
log.Printf("🚗 [STATUS_LIVREUR] Statut livreur mis à jour: delivering")
}
// Log
message := req.Notes
if message == "" {
message = getDeliveryStatusMessage(req.Status)
}
if etaMessage != "" {
message += fmt.Sprintf(" - %s", etaMessage)
}
database.AddCommandLog(commandID, req.Status, message, usernameStr)
// ✅ GESTION SPÉCIALE SELON LE STATUT
switch req.Status {
case "livre":
// Livraison terminée - Optimiser la queue
log.Printf("📦 Livraison marquée 'livre' - Optimisation queue...")
database.CompleteDeliveryAndProcessNext(usernameStr, commandID)
case "failed":
// Échec de livraison - Optimiser la queue
log.Printf("❌ Livraison échouée - Optimisation queue...")
database.CompleteDeliveryAndProcessNext(usernameStr, commandID)
// Créer un problème de livraison
database.CreateDeliveryIssue(
commandID,
"delivery_failed",
fmt.Sprintf("Échec de livraison: %s", req.Notes),
usernameStr,
)
case "arrived":
log.Printf("📍 Livreur arrivé à destination - Commande %d", commandID)
}
response := gin.H{
"success": true,
"message": "Statut mis à jour",
"command_id": commandID,
"status": req.Status,
}
if req.Status == "en_route" && etaMinutes > 0 {
response["eta_minutes"] = etaMinutes
response["eta_message"] = etaMessage
}
c.JSON(http.StatusOK, response)
}
// ============================================
// HELPERS
// ============================================
func calculateDistance(lat1, lon1, lat2, lon2 float64) float64 {
const earthRadiusKm = 6371
const metersPerKm = 1000
lat1Rad := degreesToRadians(lat1)
lon1Rad := degreesToRadians(lon1)
lat2Rad := degreesToRadians(lat2)
lon2Rad := degreesToRadians(lon2)
dLat := lat2Rad - lat1Rad
dLon := lon2Rad - lon1Rad
a := math.Sin(dLat/2)*math.Sin(dLat/2) +
math.Cos(lat1Rad)*math.Cos(lat2Rad)*
math.Sin(dLon/2)*math.Sin(dLon/2)
c := 2 * math.Atan2(math.Sqrt(a), math.Sqrt(1-a))
return earthRadiusKm * c * metersPerKm
}
func degreesToRadians(degrees float64) float64 {
return degrees * math.Pi / 180
}
func getDeliveryStatusMessage(status string) string {
messages := map[string]string{
"support": "Prise en charge de la livraison",
"assigned": "Commande assignée",
"en_route": "En route vers le client",
"arrived": "Arrivé à destination",
"livre": "Livraison effectuée",
"failed": "Échec de livraison",
"cancelled": "Livraison annulée",
}
if msg, ok := messages[status]; ok {
return msg
}
return fmt.Sprintf("Statut changé: %s", status)
}
+602
View File
@@ -0,0 +1,602 @@
// ============================================
// handlers/delivery_admin_handlers.go
// HANDLERS ADMIN POUR LA GESTION DES LIVREURS
// ============================================
package handlers
import (
"fmt"
"gestion/db"
"log"
"net/http"
"strconv"
"time"
"github.com/gin-gonic/gin"
)
// ============================================
// 📊 GET DELIVERY PERSON DETAILS
// ============================================
// GetDeliveryPersonDetails récupère les détails complets d'un livreur
// GET /api/v2/admin/protected/delivery-persons/:username
func GetDeliveryPersonDetails(c *gin.Context) {
database := c.MustGet("database").(*db.Database)
// ✅ SÉCURITÉ: Admin seulement
userRole := c.GetString("role")
if userRole != "admin" && userRole != "cabine" && userRole != "livreur" {
log.Printf("❌ [GET_DELIVERY_DETAILS] Accès refusé - role=%s", userRole)
c.JSON(http.StatusForbidden, gin.H{"error": "Accès refusé"})
return
}
username := c.Param("username")
if username == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "Username requis"})
return
}
log.Printf("👤 [GET_DELIVERY_DETAILS] Récupération détails pour: %s", username)
// ============================================
// ÉTAPE 1: Récupérer les infos de base du livreur
// ============================================
livreur, err := database.GetUserByUsername(username)
if err != nil {
log.Printf("❌ [GET_DELIVERY_DETAILS] Livreur non trouvé: %v", err)
c.JSON(http.StatusNotFound, gin.H{
"error": "Livreur non trouvé",
"details": err.Error(),
})
return
}
// Vérifier que c'est bien un livreur
if livreur.Role != "livreur" {
log.Printf("❌ [GET_DELIVERY_DETAILS] Utilisateur n'est pas un livreur: role=%s", livreur.Role)
c.JSON(http.StatusBadRequest, gin.H{
"error": "Cet utilisateur n'est pas un livreur",
"role": livreur.Role,
})
return
}
// ============================================
// ÉTAPE 2: Récupérer le statut et la position GPS
// ============================================
status, err := database.GetDeliveryPersonStatus(username)
if err != nil {
log.Printf("⚠️ [GET_DELIVERY_DETAILS] Impossible de récupérer le statut: %v", err)
status = "offline" // Statut par défaut
}
// Utiliser la fonction GPS existante
lat, lon, err := database.GetDeliveryPersonLocation(username)
var locationInfo map[string]interface{}
if err == nil {
locationInfo = map[string]interface{}{
"latitude": lat,
"longitude": lon,
}
}
// ============================================
// ÉTAPE 3: Récupérer les stats de livraison
// ============================================
queueSize, _ := database.GetDeliverymanQueueSize(username)
currentCommand, _ := database.GetCurrentCommand(username)
// Compter les livraisons
totalDeliveries, _ := database.CountDeliveriesByStatus(username, "")
completedDeliveries, _ := database.CountDeliveriesByStatus(username, "approved")
pendingDeliveries, _ := database.CountDeliveriesByStatus(username, "assigned,en_route,livre")
log.Printf("✅ [GET_DELIVERY_DETAILS] Détails récupérés pour %s", username)
// ============================================
// RÉPONSE
// ============================================
c.JSON(http.StatusOK, gin.H{
"success": true,
"deliveryman": gin.H{
"id": livreur.ID,
"username": livreur.Username,
"role": livreur.Role,
"status": status,
"current_command": currentCommand,
"queue_size": queueSize,
"total_deliveries": totalDeliveries,
"completed_deliveries": completedDeliveries,
"pending_deliveries": pendingDeliveries,
"location": locationInfo,
},
})
}
// ============================================
// 🔄 UPDATE DELIVERY PERSON STATUS
// ============================================
// UpdateDeliveryPersonStatusAdmin modifie le statut d'un livreur (Admin)
// PUT /api/v2/admin/protected/delivery-persons/:username/status
func UpdateDeliveryPersonStatusAdmin(c *gin.Context) {
database := c.MustGet("database").(*db.Database)
// ✅ SÉCURITÉ: Admin seulement
userRole := c.GetString("role")
if userRole != "admin" {
log.Printf("❌ [UPDATE_DELIVERY_STATUS] Accès refusé - role=%s", userRole)
c.JSON(http.StatusForbidden, gin.H{"error": "Accès refusé"})
return
}
username := c.Param("username")
if username == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "Username requis"})
return
}
var req struct {
Status string `json:"status" binding:"required"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{
"error": "Statut requis",
"details": err.Error(),
})
return
}
// Valider le statut
validStatuses := []string{"available", "busy", "offline"}
isValid := false
for _, vs := range validStatuses {
if req.Status == vs {
isValid = true
break
}
}
if !isValid {
c.JSON(http.StatusBadRequest, gin.H{
"error": "Statut invalide",
"valid_statuses": validStatuses,
"received": req.Status,
})
return
}
log.Printf("📝 [UPDATE_DELIVERY_STATUS] Modification: %s → %s", username, req.Status)
// ============================================
// Vérifier que le livreur existe
// ============================================
livreur, err := database.GetUserByUsername(username)
if err != nil {
log.Printf("❌ [UPDATE_DELIVERY_STATUS] Livreur non trouvé")
c.JSON(http.StatusNotFound, gin.H{"error": "Livreur non trouvé"})
return
}
if livreur.Role != "livreur" {
c.JSON(http.StatusBadRequest, gin.H{
"error": "Cet utilisateur n'est pas un livreur",
})
return
}
// ============================================
// Mettre à jour le statut
// ============================================
err = database.UpdateDeliveryPersonStatus(username, req.Status)
if err != nil {
log.Printf("❌ [UPDATE_DELIVERY_STATUS] Erreur mise à jour: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{
"error": "Erreur mise à jour statut",
"details": err.Error(),
})
return
}
adminUsername, _ := c.Get("username")
log.Printf("✅ [UPDATE_DELIVERY_STATUS] Statut modifié par admin %s: %s → %s",
adminUsername, username, req.Status)
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "Statut du livreur mis à jour",
"username": username,
"new_status": req.Status,
"updated_by": adminUsername,
})
}
// ============================================
// 📊 GET DELIVERY PERSON STATS
// ============================================
// GetDeliveryPersonStats récupère les statistiques d'un livreur
// GET /api/v2/admin/protected/delivery-persons/:username/stats
func GetDeliveryPersonStats(c *gin.Context) {
database := c.MustGet("database").(*db.Database)
// ✅ SÉCURITÉ: Admin seulement
userRole := c.GetString("role")
if userRole != "admin" {
log.Printf("❌ [GET_DELIVERY_STATS] Accès refusé - role=%s", userRole)
c.JSON(http.StatusForbidden, gin.H{"error": "Accès refusé"})
return
}
username := c.Param("username")
if username == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "Username requis"})
return
}
log.Printf("📊 [GET_DELIVERY_STATS] Calcul stats pour: %s", username)
// ============================================
// Vérifier que le livreur existe
// ============================================
livreur, err := database.GetUserByUsername(username)
if err != nil {
log.Printf("❌ [GET_DELIVERY_STATS] Livreur non trouvé")
c.JSON(http.StatusNotFound, gin.H{"error": "Livreur non trouvé"})
return
}
if livreur.Role != "livreur" {
c.JSON(http.StatusBadRequest, gin.H{
"error": "Cet utilisateur n'est pas un livreur",
})
return
}
// ============================================
// Récupérer les statistiques
// ============================================
totalDeliveries, _ := database.CountDeliveriesByStatus(username, "")
completedDeliveries, _ := database.CountDeliveriesByStatus(username, "approved")
cancelledDeliveries, _ := database.CountDeliveriesByStatus(username, "cancelled")
pendingDeliveries, _ := database.CountDeliveriesByStatus(username, "pending,assigned")
inProgressDeliveries, _ := database.CountDeliveriesByStatus(username, "en_route,livre")
// Récupérer statut et queue
status, _ := database.GetDeliveryPersonStatus(username)
queueSize, _ := database.GetDeliverymanQueueSize(username)
// Calculer le taux de succès
successRate := 0.0
if totalDeliveries > 0 {
successRate = (float64(completedDeliveries) / float64(totalDeliveries)) * 100
}
// Récupérer la dernière livraison
lastDeliveryDate := ""
lastDelivery, err := database.GetLastDeliveryDate(username)
if err == nil && lastDelivery != nil {
lastDeliveryDate = lastDelivery.Format("2006-01-02 15:04:05")
}
log.Printf("✅ [GET_DELIVERY_STATS] Stats calculées: total=%d, completed=%d",
totalDeliveries, completedDeliveries)
// ============================================
// RÉPONSE
// ============================================
c.JSON(http.StatusOK, gin.H{
"success": true,
"stats": gin.H{
"username": username,
"total_deliveries": totalDeliveries,
"completed_deliveries": completedDeliveries,
"cancelled_deliveries": cancelledDeliveries,
"pending_deliveries": pendingDeliveries,
"in_progress_deliveries": inProgressDeliveries,
"success_rate": successRate,
"current_queue_size": queueSize,
"last_delivery_date": lastDeliveryDate,
"status": status,
},
})
}
// ============================================
// 📜 GET DELIVERY PERSON HISTORY
// ============================================
// GetDeliveryPersonHistory récupère l'historique des livraisons d'un livreur
// GET /api/v2/admin/protected/delivery-persons/:username/history?limit=20&offset=0
func GetDeliveryPersonHistory(c *gin.Context) {
database := c.MustGet("database").(*db.Database)
// ✅ SÉCURITÉ: Admin seulement
userRole := c.GetString("role")
if userRole != "admin" {
log.Printf("❌ [GET_DELIVERY_HISTORY] Accès refusé - role=%s", userRole)
c.JSON(http.StatusForbidden, gin.H{"error": "Accès refusé"})
return
}
username := c.Param("username")
if username == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "Username requis"})
return
}
// Paramètres de pagination
limit := 20
offset := 0
if limitStr := c.Query("limit"); limitStr != "" {
if l, err := strconv.Atoi(limitStr); err == nil && l > 0 && l <= 100 {
limit = l
}
}
if offsetStr := c.Query("offset"); offsetStr != "" {
if o, err := strconv.Atoi(offsetStr); err == nil && o >= 0 {
offset = o
}
}
log.Printf("📜 [GET_DELIVERY_HISTORY] Récupération historique: %s (limit=%d, offset=%d)",
username, limit, offset)
// ============================================
// Vérifier que le livreur existe
// ============================================
livreur, err := database.GetUserByUsername(username)
if err != nil {
log.Printf("❌ [GET_DELIVERY_HISTORY] Livreur non trouvé")
c.JSON(http.StatusNotFound, gin.H{"error": "Livreur non trouvé"})
return
}
if livreur.Role != "livreur" {
c.JSON(http.StatusBadRequest, gin.H{
"error": "Cet utilisateur n'est pas un livreur",
})
return
}
// ============================================
// Récupérer l'historique
// ============================================
history, err := database.GetDeliveryPersonHistory(username, limit, offset)
if err != nil {
log.Printf("❌ [GET_DELIVERY_HISTORY] Erreur récupération: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{
"error": "Erreur récupération historique",
"details": err.Error(),
})
return
}
// Compter le total
total, _ := database.CountDeliveriesByStatus(username, "")
log.Printf("✅ [GET_DELIVERY_HISTORY] Historique récupéré: %d livraisons (total=%d)",
len(history), total)
c.JSON(http.StatusOK, gin.H{
"success": true,
"history": history,
"count": len(history),
"total": total,
"limit": limit,
"offset": offset,
})
}
// ============================================
// 📍 UPDATE DELIVERY PERSON LOCATION
// ============================================
// UpdateDeliveryPersonLocationAdmin modifie la position GPS d'un livreur (Admin)
// PUT /api/v2/admin/protected/delivery-persons/:username/location
func UpdateDeliveryPersonLocationAdmin(c *gin.Context) {
database := c.MustGet("database").(*db.Database)
// ✅ SÉCURITÉ: Admin seulement
userRole := c.GetString("role")
if userRole != "admin" && userRole != "cabine" {
log.Printf("❌ [UPDATE_DELIVERY_LOCATION] Accès refusé - role=%s", userRole)
c.JSON(http.StatusForbidden, gin.H{"error": "Accès refusé"})
return
}
username := c.Param("username")
if username == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "Username requis"})
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": "Coordonnées GPS requises",
"details": err.Error(),
})
return
}
// Valider les coordonnées
if req.Latitude < -90 || req.Latitude > 90 {
c.JSON(http.StatusBadRequest, gin.H{
"error": "Latitude invalide (doit être entre -90 et 90)",
})
return
}
if req.Longitude < -180 || req.Longitude > 180 {
c.JSON(http.StatusBadRequest, gin.H{
"error": "Longitude invalide (doit être entre -180 et 180)",
})
return
}
log.Printf("📍 [UPDATE_DELIVERY_LOCATION] Modification: %s → (%.6f, %.6f)",
username, req.Latitude, req.Longitude)
// ============================================
// Vérifier que le livreur existe
// ============================================
livreur, err := database.GetUserByUsername(username)
if err != nil {
log.Printf("❌ [UPDATE_DELIVERY_LOCATION] Livreur non trouvé")
c.JSON(http.StatusNotFound, gin.H{"error": "Livreur non trouvé"})
return
}
if livreur.Role != "livreur" {
c.JSON(http.StatusBadRequest, gin.H{
"error": "Cet utilisateur n'est pas un livreur",
})
return
}
// ============================================
// Mettre à jour la position (utilise la fonction existante)
// ============================================
err = database.UpdateDeliveryPersonLocation(username, req.Latitude, req.Longitude)
if err != nil {
log.Printf("❌ [UPDATE_DELIVERY_LOCATION] Erreur mise à jour: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{
"error": "Erreur mise à jour position",
"details": err.Error(),
})
return
}
adminUsername, _ := c.Get("username")
log.Printf("✅ [UPDATE_DELIVERY_LOCATION] Position modifiée par admin %s: %s → (%.6f, %.6f)",
adminUsername, username, req.Latitude, req.Longitude)
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "Position GPS mise à jour",
"location": gin.H{
"username": username,
"latitude": req.Latitude,
"longitude": req.Longitude,
"updated_at": time.Now().Unix(),
},
"updated_by": adminUsername,
})
}
// ============================================
// 🗑️ REMOVE COMMAND FROM QUEUE
// ============================================
// RemoveCommandFromQueue retire une commande de la queue d'un livreur
// DELETE /api/v2/admin/protected/delivery-persons/:username/queue/:command_id
func RemoveCommandFromQueue(c *gin.Context) {
database := c.MustGet("database").(*db.Database)
// ✅ SÉCURITÉ: Admin seulement
userRole := c.GetString("role")
if userRole != "admin" {
log.Printf("❌ [REMOVE_FROM_QUEUE] Accès refusé - role=%s", userRole)
c.JSON(http.StatusForbidden, gin.H{"error": "Accès refusé"})
return
}
username := c.Param("username")
commandIDStr := c.Param("command_id")
if username == "" || commandIDStr == "" {
c.JSON(http.StatusBadRequest, gin.H{
"error": "Username et command_id requis",
})
return
}
commandID, err := strconv.Atoi(commandIDStr)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{
"error": "ID de commande invalide",
})
return
}
log.Printf("🗑️ [REMOVE_FROM_QUEUE] Suppression: cmd %d de la queue de %s", commandID, username)
// ============================================
// Vérifier que le livreur existe
// ============================================
livreur, err := database.GetUserByUsername(username)
if err != nil {
log.Printf("❌ [REMOVE_FROM_QUEUE] Livreur non trouvé")
c.JSON(http.StatusNotFound, gin.H{"error": "Livreur non trouvé"})
return
}
if livreur.Role != "livreur" {
c.JSON(http.StatusBadRequest, gin.H{
"error": "Cet utilisateur n'est pas un livreur",
})
return
}
// ============================================
// Vérifier que la commande existe
// ============================================
command, err := database.GetCommandByID(commandID)
if err != nil {
log.Printf("❌ [REMOVE_FROM_QUEUE] Commande non trouvée")
c.JSON(http.StatusNotFound, gin.H{"error": "Commande non trouvée"})
return
}
// ============================================
// Retirer de la queue
// ============================================
err = database.RemoveCommandFromDeliverymanQueue(username, commandID)
if err != nil {
log.Printf("❌ [REMOVE_FROM_QUEUE] Erreur suppression: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{
"error": "Erreur suppression de la queue",
"details": err.Error(),
})
return
}
// Optionnel: Réassigner la commande en "pending"
currentStatus, _ := command["status"].(string)
if currentStatus == "assigned" || currentStatus == "en_route" {
err = database.UpdateCommandStatus(commandID, "pending")
if err != nil {
log.Printf("⚠️ [REMOVE_FROM_QUEUE] Impossible de réinitialiser le statut: %v", err)
} else {
// Retirer l'assignation du livreur
database.UpdateCommandLivreur(commandID, "")
log.Printf("✅ [REMOVE_FROM_QUEUE] Commande réinitialisée en 'pending'")
}
}
adminUsername, _ := c.Get("username")
database.AddCommandLog(commandID, "queue_removed",
fmt.Sprintf("Commande retirée de la queue du livreur %s par admin %s", username, adminUsername),
adminUsername.(string))
log.Printf("✅ [REMOVE_FROM_QUEUE] Commande %d retirée de la queue de %s", commandID, username)
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "Commande retirée de la queue du livreur",
"command_id": commandID,
"username": username,
"removed_by": adminUsername,
})
}
+275
View File
@@ -0,0 +1,275 @@
// ============================================
// handlers/eta_handler_corrected.go
// CORRECTION: ETA visible UNIQUEMENT après en_route
// ============================================
package handlers
import (
"encoding/json"
"fmt"
"gestion/db"
"gestion/services"
"log"
"net/http"
"strconv"
"time"
"github.com/gin-gonic/gin"
)
// ============================================
// GET /api/v1/orders/:id/eta
// ✅ CORRECTION: ETA visible UNIQUEMENT si status >= en_route
// ============================================
func GetOrderETA(c *gin.Context) {
database := c.MustGet("database").(*db.Database)
geoService := c.MustGet("geoService").(*services.GeoService)
// 1️⃣ AUTHENTIFICATION
username, exists := c.Get("username")
if !exists {
log.Printf("❌ [ETA] Non authentifié")
c.JSON(http.StatusUnauthorized, gin.H{
"success": false,
"error": "Utilisateur non authentifié",
})
return
}
// 2️⃣ RÉCUPÉRER L'ID DE LA COMMANDE
commandID, err := strconv.Atoi(c.Param("id"))
if err != nil {
log.Printf("❌ [ETA] ID invalide: %v", err)
c.JSON(http.StatusBadRequest, gin.H{
"success": false,
"error": "ID de commande invalide",
})
return
}
log.Printf("📊 [ETA] START - commandID=%d, username=%s", commandID, username.(string))
// 3️⃣ RÉCUPÉRER LA COMMANDE
command, err := database.GetCommandByID(commandID)
if err != nil {
log.Printf("❌ [ETA] Commande %d non trouvée", commandID)
c.JSON(http.StatusNotFound, gin.H{
"success": false,
"error": "Commande non trouvée",
})
return
}
// 4️⃣ VÉRIFIER LES DROITS D'ACCÈS
cmdUsername, _ := command["username"].(string)
userRole := c.GetString("role")
if userRole != "admin" {
if userRole == "client" && cmdUsername != username.(string) {
log.Printf("❌ [ETA] Accès refusé - commande appartient à %s", cmdUsername)
c.JSON(http.StatusForbidden, gin.H{
"success": false,
"error": "Vous n'avez pas accès à cette commande",
})
return
}
if userRole == "livreur" {
livreurAssign, _ := command["livreur_assign"].(string)
if livreurAssign != username.(string) {
log.Printf("❌ [ETA] Accès refusé - livreur non assigné")
c.JSON(http.StatusForbidden, gin.H{
"success": false,
"error": "Vous n'avez pas accès à cette commande",
})
return
}
}
}
// 5️⃣ VÉRIFIER LE STATUT DE LA COMMANDE
cmdStatus, _ := command["status"].(string)
// ✅ CORRECTION: Vérifier si commande terminée
if cmdStatus == "livre" || cmdStatus == "delivered" || cmdStatus == "approved" {
log.Printf("️ [ETA] Commande déjà %s - pas d'ETA applicable", cmdStatus)
c.JSON(http.StatusOK, gin.H{
"success": true,
"command_id": commandID,
"status": cmdStatus,
"message": "La commande a déjà été livrée",
"eta_available": false,
"estimated_arrival": "Livraison complétée",
})
return
}
// ✅ CORRECTION CRITIQUE: Vérifier si le livreur a démarré
if cmdStatus != "en_route" && cmdStatus != "arrived" {
log.Printf("⏳ [ETA] Commande en statut '%s' - ETA pas encore disponible", cmdStatus)
livreurAssign, _ := command["livreur_assign"].(string)
var livreurInfo string
if livreurAssign != "" {
livreurInfo = fmt.Sprintf("Livreur %s assigné", livreurAssign)
} else {
livreurInfo = "En attente d'assignation"
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"command_id": commandID,
"status": cmdStatus,
"message": "Le livreur n'a pas encore démarré la livraison",
"eta_available": false,
"info": livreurInfo,
})
return
}
// 6️⃣ VÉRIFIER LE CACHE REDIS POUR ETA
etaKey := fmt.Sprintf("command:eta:%d", commandID)
etaData, err := db.Redis.HGetAll(db.RedisCtx, etaKey).Result()
if err == nil && len(etaData) > 0 {
// ETA existe, vérifier s'il est récent
if updatedAtStr, ok := etaData["updated_at"]; ok {
var updatedAt int64
fmt.Sscanf(updatedAtStr, "%d", &updatedAt)
timeSinceUpdate := time.Since(time.Unix(updatedAt, 0))
if timeSinceUpdate < 2*time.Minute {
// Cache valide
var etaMinutes int64
if etaStr, ok := etaData["eta_minutes"]; ok {
fmt.Sscanf(etaStr, "%d", &etaMinutes)
}
var arrivalTime int64
if arrivalStr, ok := etaData["arrival_time"]; ok {
fmt.Sscanf(arrivalStr, "%d", &arrivalTime)
}
log.Printf("✅ [ETA] Cache hit - ETA: %d min", etaMinutes)
c.JSON(http.StatusOK, gin.H{
"success": true,
"command_id": commandID,
"status": cmdStatus,
"eta_minutes": etaMinutes,
"estimated_arrival": time.Unix(arrivalTime, 0).Format("15:04"),
"eta_available": true,
"with_traffic": true,
"livreur_assign": command["livreur_assign"],
"delivery_address": command["adresse"],
})
return
}
}
}
// 7️⃣ Pas de cache valide - Recalculer l'ETA
log.Printf("🔄 [ETA] Cache miss ou expiré - Recalcul de l'ETA...")
// Récupérer coordonnées destination
var destLat, destLon float64
destCacheKey := fmt.Sprintf("command:destination:%d", commandID)
destData, err := db.Redis.Get(db.RedisCtx, destCacheKey).Result()
if err == 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
log.Printf("📍 Destination depuis cache Redis: (%.6f, %.6f)", destLat, destLon)
}
}
if destLat == 0 || destLon == 0 {
if dLat, okLat := command["dest_latitude"].(float64); okLat && dLat != 0 {
destLat = dLat
}
if dLon, okLon := command["dest_longitude"].(float64); okLon && dLon != 0 {
destLon = dLon
}
}
if destLat == 0 || destLon == 0 {
log.Printf("❌ [ETA] Coordonnées destination manquantes")
c.JSON(http.StatusBadRequest, gin.H{
"success": false,
"error": "Coordonnées de destination manquantes",
})
return
}
// Récupérer position du livreur
livreurAssign, _ := command["livreur_assign"].(string)
if livreurAssign == "" {
log.Printf("⚠️ [ETA] Aucun livreur assigné")
c.JSON(http.StatusBadRequest, gin.H{
"success": false,
"error": "Aucun livreur assigné à cette commande",
})
return
}
livreurLocation, err := geoService.GetDeliveryPersonLocation(livreurAssign)
if err != nil {
log.Printf("❌ [ETA] Position livreur introuvable: %s", livreurAssign)
c.JSON(http.StatusNotFound, gin.H{
"success": false,
"error": "Position du livreur non disponible",
})
return
}
toCoords := services.Coordinates{Latitude: destLat, Longitude: destLon}
// Calculer ETA avec TomTom
log.Printf("🛣️ [ETA] Calcul TomTom: (%.6f, %.6f) -> (%.6f, %.6f)",
livreurLocation.Latitude, livreurLocation.Longitude, toCoords.Latitude, toCoords.Longitude)
etaMinutes, distanceKm, err := services.GetETAWithTraffic(*livreurLocation, toCoords)
if err != nil {
log.Printf("⚠️ [ETA] TomTom failed, fallback local: %v", err)
distanceKm = services.CalculateDistance(*livreurLocation, toCoords)
etaMinutes = services.CalculateETA(distanceKm)
}
// Sauvegarder en cache
now := time.Now()
arrivalTime := now.Add(time.Duration(etaMinutes) * time.Minute)
etaCache := map[string]interface{}{
"command_id": commandID,
"eta_minutes": etaMinutes,
"updated_at": now.Unix(),
"arrival_time": arrivalTime.Unix(),
"distance_km": distanceKm,
"with_traffic": err == nil,
}
db.Redis.HSet(db.RedisCtx, etaKey, etaCache)
db.Redis.Expire(db.RedisCtx, etaKey, 4*time.Hour)
log.Printf("✅ [ETA] SUCCESS - ETA: %d min, arrivée: %s", etaMinutes, arrivalTime.Format("15:04"))
c.JSON(http.StatusOK, gin.H{
"success": true,
"command_id": commandID,
"status": cmdStatus,
"eta_minutes": etaMinutes,
"distance_km": fmt.Sprintf("%.2f", distanceKm),
"estimated_arrival": arrivalTime.Format("15:04"),
"eta_available": true,
"with_traffic": err == nil,
"livreur_assign": livreurAssign,
"delivery_address": command["adresse"],
})
}
+900
View File
@@ -0,0 +1,900 @@
// ============================================
// handlers/geo_handlers.go - VERSION CORRIGÉE COMPLÈTE
// ============================================
package handlers
import (
"encoding/json"
"fmt"
"gestion/db"
"gestion/services"
"log"
"net/http"
"strconv"
"time"
"github.com/gin-gonic/gin"
)
// ============================================
// GÉOCODAGE D'ADRESSES
// ============================================
// GeocodeAddress convertit une adresse en coordonnées GPS
// POST /api/v1/geocode
// Body: {"address": "1600 Amphitheatre Parkway, Mountain View, CA"}
func GeocodeAddress(c *gin.Context) {
geoService := c.MustGet("geoService").(*services.GeoService)
var req struct {
Address string `json:"address" binding:"required"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{
"error": "Adresse requise",
"details": err.Error(),
})
return
}
location, err := geoService.GeocodeAddress(req.Address)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{
"error": "Impossible de géocoder cette adresse",
"details": err.Error(),
})
return
}
log.Printf("📍 Adresse géocodée: %s -> (%.6f, %.6f)",
req.Address, location.Latitude, location.Longitude)
c.JSON(http.StatusOK, gin.H{
"success": true,
"latitude": location.Latitude,
"longitude": location.Longitude,
"display_name": location.DisplayName,
})
}
// ============================================
// RECHERCHE DU LIVREUR LE PLUS PROCHE
// ============================================
// FindNearestDeliveryPerson trouve le livreur le plus proche d'une adresse
func FindNearestDeliveryPerson(c *gin.Context) {
database := c.MustGet("database").(*db.Database)
geoService := c.MustGet("geoService").(*services.GeoService)
userRole := c.GetString("role")
if userRole != "admin" {
c.JSON(http.StatusForbidden, gin.H{"error": "Accès refusé"})
return
}
var req struct {
Address string `json:"address"`
Latitude float64 `json:"latitude"`
Longitude float64 `json:"longitude"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{
"error": "Données invalides",
"details": err.Error(),
})
return
}
var targetCoords services.Coordinates
// Si adresse fournie, la géocoder
if req.Address != "" {
location, err := geoService.GeocodeAddress(req.Address)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{
"error": "Impossible de géocoder l'adresse",
"details": err.Error(),
})
return
}
targetCoords.Latitude = location.Latitude
targetCoords.Longitude = location.Longitude
} else if req.Latitude != 0 && req.Longitude != 0 {
// Sinon utiliser les coordonnées fournies
targetCoords.Latitude = req.Latitude
targetCoords.Longitude = req.Longitude
} else {
c.JSON(http.StatusBadRequest, gin.H{
"error": "Fournir soit une adresse, soit des coordonnées GPS",
})
return
}
// Valider les coordonnées
if err := services.ValidateCoordinates(targetCoords); err != nil {
c.JSON(http.StatusBadRequest, gin.H{
"error": "Coordonnées invalides",
"details": err.Error(),
})
return
}
// Récupérer les livreurs disponibles
availableLivreurs, err := database.GetAvailableDeliveryPersonsRedis()
if err != nil || len(availableLivreurs) == 0 {
c.JSON(http.StatusNotFound, gin.H{
"error": "Aucun livreur disponible",
})
return
}
// Extraire les usernames
usernames := make([]string, len(availableLivreurs))
for i, livreur := range availableLivreurs {
usernames[i] = livreur.Username
}
// Trouver le plus proche (calcul rapide avec Haversine)
nearest, err := geoService.FindNearestDeliveryPersonFast(targetCoords, usernames)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{
"error": "Aucun livreur avec position GPS valide",
"details": err.Error(),
})
return
}
// Recalculer l'ETA du plus proche avec TomTom pour plus de précision
etaWithTraffic, distanceReal, err := services.GetETAWithTraffic(nearest.Location, targetCoords)
if err == nil {
nearest.EstimatedTime = etaWithTraffic
nearest.Distance = distanceReal
log.Printf("✅ Livreur le plus proche: %s (%.2f km, ~%d min avec trafic)",
nearest.Username, nearest.Distance, nearest.EstimatedTime)
} else {
log.Printf("✅ Livreur le plus proche: %s (%.2f km, ~%d min sans trafic)",
nearest.Username, nearest.Distance, nearest.EstimatedTime)
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"target": gin.H{
"latitude": targetCoords.Latitude,
"longitude": targetCoords.Longitude,
},
"nearest_delivery_person": gin.H{
"username": nearest.Username,
"latitude": nearest.Location.Latitude,
"longitude": nearest.Location.Longitude,
"distance_km": nearest.Distance,
"eta_minutes": nearest.EstimatedTime,
"traffic_aware": err == nil,
},
})
}
// ============================================
// LISTE TOUS LES LIVREURS TRIÉS PAR DISTANCE
// ============================================
// GetAllDeliveryDistances retourne tous les livreurs triés par distance
// POST /api/v2/admin/protected/delivery/distances
// Body: {"address": "123 Main St"} ou {"latitude": 48.8566, "longitude": 2.3522}
func GetAllDeliveryDistances(c *gin.Context) {
database := c.MustGet("database").(*db.Database)
geoService := c.MustGet("geoService").(*services.GeoService)
userRole := c.GetString("role")
if userRole != "admin" {
c.JSON(http.StatusForbidden, gin.H{"error": "Accès refusé"})
return
}
var req struct {
Address string `json:"address"`
Latitude float64 `json:"latitude"`
Longitude float64 `json:"longitude"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{
"error": "Données invalides",
"details": err.Error(),
})
return
}
var targetCoords services.Coordinates
if req.Address != "" {
location, err := geoService.GeocodeAddress(req.Address)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{
"error": "Impossible de géocoder l'adresse",
"details": err.Error(),
})
return
}
targetCoords.Latitude = location.Latitude
targetCoords.Longitude = location.Longitude
} else if req.Latitude != 0 && req.Longitude != 0 {
targetCoords.Latitude = req.Latitude
targetCoords.Longitude = req.Longitude
} else {
c.JSON(http.StatusBadRequest, gin.H{
"error": "Fournir soit une adresse, soit des coordonnées GPS",
})
return
}
if err := services.ValidateCoordinates(targetCoords); err != nil {
c.JSON(http.StatusBadRequest, gin.H{
"error": "Coordonnées invalides",
"details": err.Error(),
})
return
}
availableLivreurs, err := database.GetAvailableDeliveryPersonsRedis()
if err != nil || len(availableLivreurs) == 0 {
c.JSON(http.StatusNotFound, gin.H{
"error": "Aucun livreur disponible",
})
return
}
usernames := make([]string, len(availableLivreurs))
for i, livreur := range availableLivreurs {
usernames[i] = livreur.Username
}
distances, err := geoService.GetAllDeliveryDistances(targetCoords, usernames)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{
"error": "Erreur calcul des distances",
"details": err.Error(),
})
return
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"target": gin.H{
"latitude": targetCoords.Latitude,
"longitude": targetCoords.Longitude,
},
"delivery_persons": distances,
"count": len(distances),
})
}
// ============================================
// AUTO-ASSIGNATION INTELLIGENTE AVEC QUEUE MULTI-COMMANDES
// ============================================
func AutoAssignNearestDeliveryPerson(c *gin.Context) {
database := c.MustGet("database").(*db.Database)
geoService := c.MustGet("geoService").(*services.GeoService)
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
}
status, ok := command["status"].(string)
if !ok || (status != "pending" && status != "priority") {
c.JSON(http.StatusBadRequest, gin.H{
"error": "La commande doit être en statut 'pending' ou 'priority'",
"current_status": status,
})
return
}
// Récupérer l'adresse de livraison de la commande
address, ok := command["adresse"].(string)
if !ok || address == "" || address == "Adresse non spécifiée" {
c.JSON(http.StatusBadRequest, gin.H{
"error": "Cette commande n'a pas d'adresse de livraison valide",
"command_id": commandID,
"message": "L'adresse de livraison doit être définie lors de la création de la commande",
})
return
}
log.Printf("📍 Adresse de livraison: %s", address)
// Géocoder l'adresse
location, err := geoService.GeocodeAddress(address)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{
"error": "Impossible de géocoder l'adresse de livraison",
"address": address,
"details": err.Error(),
})
return
}
log.Printf("📍 Adresse géocodée: %s -> (%.6f, %.6f)", address, location.Latitude, location.Longitude)
// ============================================
// 🔹 SAUVEGARDER LES COORDONNÉES DANS LE CACHE REDIS
// ============================================
destCacheKey := fmt.Sprintf("command:destination:%d", commandID)
coordsJSON, _ := json.Marshal(map[string]float64{
"lat": location.Latitude,
"lon": location.Longitude,
})
if err := db.Redis.Set(db.RedisCtx, destCacheKey, coordsJSON, 4*time.Hour).Err(); err != nil {
log.Printf("⚠️ Impossible de sauvegarder coordonnées destination: %v", err)
} else {
log.Printf("📍 Coordonnées destination sauvegardées pour commande %d: (%.6f, %.6f)",
commandID, location.Latitude, location.Longitude)
}
targetCoords := services.Coordinates{
Latitude: location.Latitude,
Longitude: location.Longitude,
}
// Compter le nombre de livreurs actifs
activeCount, _ := database.CountActiveDeliverymen()
if activeCount == 0 {
c.JSON(http.StatusNotFound, gin.H{
"error": "Aucun livreur actif (tous sont offline)",
})
return
}
log.Printf("🚗 %d livreur(s) actif(s)", activeCount)
// Récupérer les livreurs actifs avec capacité disponible
activeLivreurs, err := database.GetAllActiveDeliveryPersons()
// Si aucun livreur avec capacité disponible
if err != nil || len(activeLivreurs) == 0 {
// Cas 1: Un seul livreur actif -> pas de limite
if activeCount == 1 {
singleDeliveryman, err := database.GetSingleActiveDeliveryman()
if err != nil {
c.JSON(http.StatusNotFound, gin.H{
"error": "Impossible de trouver le livreur actif",
})
return
}
// Calculer ETA avec TomTom
travelTime, distance, err := calculateTravelTimeWithTomTom(geoService, singleDeliveryman, location.Latitude, location.Longitude)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{
"error": "Erreur calcul ETA",
"details": err.Error(),
})
return
}
// ✅ Passer les coordonnées à la fonction d'assignation
err = database.AssignCommandToDeliverymanQueueWithCoords(commandID, singleDeliveryman, travelTime, location.Latitude, location.Longitude, address)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{
"error": "Erreur lors de l'assignation",
"details": err.Error(),
})
return
}
database.SetDeliveryPersonStatus(singleDeliveryman, "busy", commandID)
queueInfo, _ := database.GetDeliverymanQueueInfo(singleDeliveryman)
etaData, _ := database.GetCommandETA(commandID)
log.Printf("✅ Commande %d assignée au seul livreur actif %s (%.2f km)", commandID, singleDeliveryman, distance)
// ✅ CORRECTION: Utiliser etaData directement sans accès aux clés
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "Commande assignée au seul livreur actif (sans limite)",
"command_id": commandID,
"assigned_to": gin.H{
"username": singleDeliveryman,
"travel_time": travelTime,
"distance_km": distance,
"queue_position": queueInfo["queue_size"],
"single_driver": true,
"traffic_aware": true,
},
"eta": etaData, // ✅ Directement l'objet complet
"delivery_address": address,
"coordinates": gin.H{
"latitude": location.Latitude,
"longitude": location.Longitude,
},
"queue_info": queueInfo,
})
return
}
// Cas 2: Plusieurs livreurs mais tous à capacité max -> Distribution forcée
allAtCapacity, numActive, _ := database.AreAllDeliverymenAtCapacity()
if allAtCapacity && numActive > 1 {
log.Printf("⚠️ Tous les %d livreurs sont à capacité max - Distribution forcée", numActive)
// Trouver le livreur le moins chargé (même s'il dépasse 10)
leastLoaded, currentSize, err := database.GetLeastLoadedDeliverymanForced()
if err != nil {
c.JSON(http.StatusNotFound, gin.H{
"error": "Impossible de trouver un livreur pour la distribution forcée",
})
return
}
// Calculer le temps de trajet avec TomTom
travelTime, distance, err := calculateTravelTimeWithTomTom(geoService, leastLoaded, location.Latitude, location.Longitude)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{
"error": "Erreur calcul ETA",
"details": err.Error(),
})
return
}
// ✅ Assigner de force avec coordonnées
err = database.ForceAssignCommandToDeliverymanWithCoords(commandID, leastLoaded, travelTime, location.Latitude, location.Longitude, address)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{
"error": "Erreur lors de l'assignation forcée",
"details": err.Error(),
})
return
}
database.SetDeliveryPersonStatus(leastLoaded, "busy", commandID)
queueInfo, _ := database.GetDeliverymanQueueInfo(leastLoaded)
etaData, _ := database.GetCommandETA(commandID)
log.Printf("✅ FORCE: Commande %d assignée à %s (capacité dépassée: %d, %.2f km)", commandID, leastLoaded, currentSize+1, distance)
// ✅ CORRECTION: Utiliser etaData directement
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "Commande assignée par distribution forcée (capacité max dépassée)",
"command_id": commandID,
"forced": true,
"assigned_to": gin.H{
"username": leastLoaded,
"travel_time": travelTime,
"distance_km": distance,
"queue_position": currentSize + 1,
"over_capacity": true,
"traffic_aware": true,
},
"eta": etaData, // ✅ Directement l'objet complet
"delivery_address": address,
"coordinates": gin.H{
"latitude": location.Latitude,
"longitude": location.Longitude,
},
"queue_info": queueInfo,
})
return
}
// Cas 3: Erreur générique
c.JSON(http.StatusNotFound, gin.H{
"error": "Aucun livreur actif avec capacité disponible",
"active_count": activeCount,
"max_per_deliveryman": db.MAX_COMMANDS_PER_DELIVERYMAN,
})
return
}
// Cas normal: Au moins un livreur avec capacité disponible
usernames := make([]string, len(activeLivreurs))
for i, livreur := range activeLivreurs {
usernames[i] = livreur.Username
}
// Trouver le livreur le plus proche (calcul rapide)
nearest, err := geoService.FindNearestDeliveryPersonFast(targetCoords, usernames)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{
"error": "Aucun livreur avec position GPS valide",
"details": err.Error(),
})
return
}
// Recalculer l'ETA avec TomTom pour plus de précision
travelTime, distance, err := services.GetETAWithTraffic(nearest.Location, targetCoords)
if err != nil {
// Fallback sur le calcul initial
travelTime = nearest.EstimatedTime
distance = nearest.Distance
log.Printf("⚠️ TomTom indisponible, utilisation du calcul Haversine")
}
log.Printf("🎯 Livreur le plus proche: %s (%.2f km, ~%d min)", nearest.Username, distance, travelTime)
// ✅ Assigner à la queue du livreur avec coordonnées
err = database.AssignCommandToDeliverymanQueueWithCoords(commandID, nearest.Username, travelTime, location.Latitude, location.Longitude, address)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{
"error": "Erreur lors de l'assignation",
"details": err.Error(),
})
return
}
database.SetDeliveryPersonStatus(nearest.Username, "busy", commandID)
queueInfo, _ := database.GetDeliverymanQueueInfo(nearest.Username)
etaData, _ := database.GetCommandETA(commandID)
log.Printf("✅ Commande %d assignée à la queue de %s", commandID, nearest.Username)
// ✅ CORRECTION: Utiliser etaData directement
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "Commande assignée à la queue du livreur",
"command_id": commandID,
"assigned_to": gin.H{
"username": nearest.Username,
"distance_km": distance,
"travel_time": travelTime,
"queue_position": queueInfo["queue_size"],
"single_driver": activeCount == 1,
"traffic_aware": err == nil,
},
"eta": etaData, // ✅ Directement l'objet complet
"delivery_address": address,
"coordinates": gin.H{
"latitude": location.Latitude,
"longitude": location.Longitude,
},
"queue_info": queueInfo,
})
}
// ============================================
// ASSIGNATION EN MASSE (TOUTES LES COMMANDES PENDING)
// ============================================
// AutoAssignAllPendingCommands assigne toutes les commandes en attente
// POST /api/v2/admin/protected/commands/auto-assign-all
func AutoAssignAllPendingCommands(c *gin.Context) {
database := c.MustGet("database").(*db.Database)
geoService := c.MustGet("geoService").(*services.GeoService)
userRole := c.GetString("role")
if userRole != "admin" {
c.JSON(http.StatusForbidden, gin.H{"error": "Accès refusé"})
return
}
// Récupérer toutes les commandes pending
commands, err := database.GetAllCommands("pending", "")
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{
"error": "Erreur récupération des commandes",
"details": err.Error(),
})
return
}
if len(commands) == 0 {
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "Aucune commande en attente",
"assigned": 0,
})
return
}
log.Printf("📋 %d commandes en attente à assigner", len(commands))
var assigned []gin.H
var failed []gin.H
for _, cmd := range commands {
commandID, ok := cmd["id"].(int)
if !ok {
// Essayer avec float64
if idFloat, ok := cmd["id"].(float64); ok {
commandID = int(idFloat)
} else {
continue
}
}
// Récupérer l'adresse
address, ok := cmd["adresse"].(string)
if !ok || address == "" || address == "Adresse non spécifiée" {
failed = append(failed, gin.H{
"command_id": commandID,
"error": "Adresse de livraison manquante",
})
continue
}
// Géocoder l'adresse
location, err := geoService.GeocodeAddress(address)
if err != nil {
failed = append(failed, gin.H{
"command_id": commandID,
"error": fmt.Sprintf("Impossible de géocoder: %s", address),
})
continue
}
targetCoords := services.Coordinates{
Latitude: location.Latitude,
Longitude: location.Longitude,
}
// Récupérer les livreurs actifs
activeLivreurs, err := database.GetAllActiveDeliveryPersons()
if err != nil || len(activeLivreurs) == 0 {
failed = append(failed, gin.H{
"command_id": commandID,
"error": "Aucun livreur disponible",
})
continue
}
usernames := make([]string, len(activeLivreurs))
for i, livreur := range activeLivreurs {
usernames[i] = livreur.Username
}
// Trouver le livreur le plus proche (version rapide pour assignation masse)
nearest, err := geoService.FindNearestDeliveryPersonFast(targetCoords, usernames)
if err != nil {
failed = append(failed, gin.H{
"command_id": commandID,
"error": "Aucun livreur avec position GPS",
})
continue
}
// Pour l'assignation en masse, on utilise le calcul rapide
travelTime := nearest.EstimatedTime
distance := nearest.Distance
// Assigner à la queue
err = database.AssignCommandToDeliverymanQueue(commandID, nearest.Username, travelTime)
if err != nil {
failed = append(failed, gin.H{
"command_id": commandID,
"error": err.Error(),
})
continue
}
// Mettre à jour le statut du livreur
database.SetDeliveryPersonStatus(nearest.Username, "busy", commandID)
// Récupérer l'ETA - ✅ CORRECTION: Gérer les types correctement
etaData, _ := database.GetCommandETA(commandID)
var totalETA, waitTime interface{}
totalETA = "N/A"
waitTime = "N/A"
if etaData != nil {
if val, exists := etaData["total_eta_minutes"]; exists {
totalETA = val
}
if val, exists := etaData["wait_time_minutes"]; exists {
waitTime = val
}
}
assigned = append(assigned, gin.H{
"command_id": commandID,
"assigned_to": nearest.Username,
"distance_km": distance,
"total_eta_minutes": totalETA,
"wait_time_minutes": waitTime,
"travel_time": travelTime,
})
log.Printf("✅ Commande %d -> %s (ETA: %v min)", commandID, nearest.Username, totalETA)
}
// Récupérer l'overview des queues
queuesOverview, _ := database.GetAllQueuesOverview()
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": fmt.Sprintf("%d commandes assignées, %d échecs", len(assigned), len(failed)),
"total_pending": len(commands),
"assigned_count": len(assigned),
"failed_count": len(failed),
"assigned": assigned,
"failed": failed,
"queues_overview": queuesOverview,
"note": "ETAs calculés avec Haversine pour rapidité, précision TomTom disponible individuellement",
})
}
// ============================================
// RÉCUPÉRER L'ÉTAT DES QUEUES DES LIVREURS
// ============================================
// GetAllDeliveryQueues retourne l'état de toutes les queues des livreurs
// GET /api/v2/admin/protected/delivery/queues
func GetAllDeliveryQueues(c *gin.Context) {
database := c.MustGet("database").(*db.Database)
userRole := c.GetString("role")
if userRole != "admin" {
c.JSON(http.StatusForbidden, gin.H{"error": "Accès refusé"})
return
}
overview, err := database.GetAllQueuesOverview()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{
"error": "Erreur récupération des queues",
"details": err.Error(),
})
return
}
// Récupérer les détails de chaque livreur
var deliverymenDetails []gin.H
keys, _ := db.Redis.Keys(db.RedisCtx, "delivery:status:*").Result()
for _, key := range keys {
username := key[len("delivery:status:"):]
queueInfo, _ := database.GetDeliverymanQueueInfo(username)
// Récupérer le statut
statusData, _ := db.Redis.Get(db.RedisCtx, key).Result()
var status map[string]interface{}
if statusData != "" {
json.Unmarshal([]byte(statusData), &status)
}
deliverymenDetails = append(deliverymenDetails, gin.H{
"username": username,
"status": status["status"],
"queue_info": queueInfo,
})
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"overview": overview,
"deliverymen_detail": deliverymenDetails,
})
}
// ============================================
// RÉCUPÉRER LA QUEUE D'UN LIVREUR SPÉCIFIQUE
// ============================================
// GetDeliverymanQueue retourne la queue d'un livreur spécifique
// GET /api/v2/admin/protected/delivery/:username/queue
func GetDeliverymanQueue(c *gin.Context) {
database := c.MustGet("database").(*db.Database)
userRole := c.GetString("role")
if userRole != "admin" {
c.JSON(http.StatusForbidden, gin.H{"error": "Accès refusé"})
return
}
username := c.Param("username")
if username == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "Username requis"})
return
}
queueInfo, err := database.GetDeliverymanQueueInfo(username)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{
"error": "Erreur récupération de la queue",
"details": err.Error(),
})
return
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"queue_info": queueInfo,
})
}
// ============================================
// VALIDATION D'ADRESSE
// ============================================
// ValidateAddress vérifie si une adresse peut être géocodée
// POST /api/v1/validate-address
// Body: {"address": "123 Main St, Paris"}
func ValidateAddress(c *gin.Context) {
geoService := c.MustGet("geoService").(*services.GeoService)
var req struct {
Address string `json:"address" binding:"required"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{
"error": "Adresse requise",
"details": err.Error(),
})
return
}
isValid := geoService.IsValidAddress(req.Address)
if !isValid {
c.JSON(http.StatusOK, gin.H{
"valid": false,
"message": "Adresse introuvable ou invalide",
})
return
}
// Récupérer les détails
location, _ := geoService.GeocodeAddress(req.Address)
c.JSON(http.StatusOK, gin.H{
"valid": true,
"message": "Adresse valide",
"latitude": location.Latitude,
"longitude": location.Longitude,
"display_name": location.DisplayName,
})
}
// ============================================
// HELPER FUNCTION - CALCUL ETA AVEC TOMTOM
// ============================================
// calculateTravelTimeWithTomTom calcule l'ETA avec TomTom ou fallback local
func calculateTravelTimeWithTomTom(geoService *services.GeoService, deliverymanUsername string, targetLat, targetLon float64) (int, float64, error) {
// Récupérer position du livreur
deliverymanLoc, err := geoService.GetDeliveryPersonLocation(deliverymanUsername)
if err != nil {
return 0, 0, fmt.Errorf("position du livreur introuvable: %w", err)
}
targetCoords := services.Coordinates{
Latitude: targetLat,
Longitude: targetLon,
}
// Calculer ETA avec TomTom (avec fallback automatique intégré)
travelTime, distance, err := services.GetETAWithTraffic(*deliverymanLoc, targetCoords)
if err != nil {
// Fallback sur calcul local
distance = services.CalculateDistance(*deliverymanLoc, targetCoords)
travelTime = services.CalculateETA(distance)
log.Printf("⚠️ TomTom indisponible pour %s, fallback: %.2f km -> %d min",
deliverymanUsername, distance, travelTime)
} else {
log.Printf("🛣️ TomTom utilisé pour %s: %.2f km -> %d min (trafic réel)",
deliverymanUsername, distance, travelTime)
}
return travelTime, distance, nil
}
+129
View File
@@ -0,0 +1,129 @@
// ============================================
// handlers/gps_handlers.go
// Gestion des liens GPS et visualisation
// ============================================
package handlers
import (
"gestion/db"
"log"
"net/http"
"strconv"
"github.com/gin-gonic/gin"
)
// GetDeliveryPersonMapLinks génère les liens de cartes pour visualiser la position d'un livreur
// GET /api/v2/admin/protected/delivery-persons/:username/map-links
func GetDeliveryPersonMapLinks(c *gin.Context) {
database := c.MustGet("database").(*db.Database)
// Vérification du rôle admin
userRole := c.GetString("role")
if userRole != "admin" && userRole != "cabine" {
c.JSON(http.StatusForbidden, gin.H{"error": "Accès refusé"})
return
}
username := c.Param("username")
if username == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "Username requis"})
return
}
log.Printf("🗺️ [MAP_LINKS] Demande pour livreur: %s", username)
// Récupérer la position GPS du livreur
lat, lon, err := database.GetDeliveryPersonLocation(username)
if err != nil {
log.Printf("❌ [MAP_LINKS] Erreur position: %v", err)
c.JSON(http.StatusNotFound, gin.H{
"success": false,
"error": "Position GPS non disponible pour ce livreur",
"details": err.Error(),
"message": "Le livreur n'a pas encore partagé sa position ou est hors ligne",
})
return
}
// Validation des coordonnées
if lat == 0 && lon == 0 {
log.Printf("⚠️ [MAP_LINKS] Coordonnées invalides (0,0) pour %s", username)
c.JSON(http.StatusNotFound, gin.H{
"success": false,
"error": "Coordonnées GPS invalides (0,0)",
"message": "Le livreur doit mettre à jour sa position",
})
return
}
// Générer les liens de cartes
mapLinks := database.GenerateMapLinks(lat, lon, username)
log.Printf("✅ [MAP_LINKS] Liens générés pour %s: (%.6f, %.6f)", username, lat, lon)
c.JSON(http.StatusOK, gin.H{
"success": true,
"deliveryman": gin.H{
"username": username,
},
"location": gin.H{
"latitude": lat,
"longitude": lon,
"valid": true,
},
"map_links": mapLinks,
})
}
// 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",
"details": err.Error(),
})
return
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"command_id": commandID,
"deliveryman": livreurAssign,
"navigation_links": links,
})
}
+229
View File
@@ -0,0 +1,229 @@
// ============================================
// handlers/history_handlers.go
// ============================================
// Gestion de l'historique des commandes terminées
package handlers
import (
"fmt"
"gestion/db"
"log"
"net/http"
"github.com/gin-gonic/gin"
)
// GetMyCompletedOrders récupère l'historique des commandes terminées du client
// GET /api/v1/my-commands/history
// ✅ Authentification requise (ClientMiddleware)
// ✅ Retourne uniquement les commandes avec status = "approved"
func GetMyCompletedOrders(c *gin.Context) {
database := c.MustGet("database").(*db.Database)
// ✅ SÉCURITÉ: Récupérer depuis JWT validé
username, exists := c.Get("username")
if !exists {
log.Printf("❌ [HISTORY] Utilisateur non authentifié")
c.JSON(http.StatusUnauthorized, gin.H{
"error": "Authentification requise",
})
return
}
usernameStr := username.(string)
log.Printf("📚 [HISTORY] Récupération historique pour: %s", usernameStr)
// ✅ Récupérer les commandes terminées (approved)
commands, err := database.GetCompletedCommandsByUsername(usernameStr)
if err != nil {
log.Printf("❌ [HISTORY] Erreur: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{
"error": "Erreur lors de la récupération de l'historique",
"details": err.Error(),
})
return
}
log.Printf("✅ [HISTORY] %d commandes terminées trouvées", len(commands))
// ✅ Récupérer les infos client pour statistiques
client, err := database.GetClientByUsername(usernameStr)
response := gin.H{
"success": true,
"commands": commands,
"count": len(commands),
}
if err == nil && client != nil {
response["client_stats"] = gin.H{
"username": client.Username,
"total_commands": client.Command,
"points": client.Point,
"penalties": client.Amende,
}
}
c.JSON(http.StatusOK, response)
}
// GetMyCompletedOrdersWithItems récupère l'historique avec les détails des items
// GET /api/v1/my-commands/history/detailed
// ✅ Authentification requise (ClientMiddleware)
// ✅ Retourne les commandes approved avec tous les items
func GetMyCompletedOrdersWithItems(c *gin.Context) {
database := c.MustGet("database").(*db.Database)
// ✅ SÉCURITÉ: Récupérer depuis JWT validé
username, exists := c.Get("username")
if !exists {
log.Printf("❌ [HISTORY_DETAILED] Utilisateur non authentifié")
c.JSON(http.StatusUnauthorized, gin.H{
"error": "Authentification requise",
})
return
}
usernameStr := username.(string)
log.Printf("📚 [HISTORY_DETAILED] Récupération historique détaillé pour: %s", usernameStr)
// ✅ Récupérer les commandes terminées
commands, err := database.GetCompletedCommandsByUsername(usernameStr)
if err != nil {
log.Printf("❌ [HISTORY_DETAILED] Erreur: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{
"error": "Erreur lors de la récupération de l'historique",
"details": err.Error(),
})
return
}
// ✅ Enrichir chaque commande avec ses items
var enrichedCommands []map[string]interface{}
for _, command := range commands {
commandID, ok := command["id"].(int)
if !ok {
continue
}
// Récupérer les items de cette commande
items, err := database.GetCommandItems(commandID)
if err != nil {
log.Printf("⚠️ [HISTORY_DETAILED] Erreur items pour cmd %d: %v", commandID, err)
items = []map[string]interface{}{}
}
// Ajouter les items à la commande
enrichedCommand := make(map[string]interface{})
for k, v := range command {
enrichedCommand[k] = v
}
enrichedCommand["items"] = items
enrichedCommand["items_count"] = len(items)
enrichedCommands = append(enrichedCommands, enrichedCommand)
}
log.Printf("✅ [HISTORY_DETAILED] %d commandes enrichies", len(enrichedCommands))
// ✅ Récupérer les infos client
client, err := database.GetClientByUsername(usernameStr)
response := gin.H{
"success": true,
"commands": enrichedCommands,
"count": len(enrichedCommands),
}
if err == nil && client != nil {
response["client_stats"] = gin.H{
"username": client.Username,
"nom": client.Nom,
"prenom": client.Prenom,
"telephone": client.Telephone,
"total_commands": client.Command,
"points": client.Point,
"penalties": client.Amende,
}
}
c.JSON(http.StatusOK, response)
}
// GetOrderHistory récupère l'historique d'une commande spécifique avec logs
// GET /api/v1/commands/:id/history
// ✅ Authentification requise
// ✅ Vérifie que la commande appartient au client
func GetOrderHistory(c *gin.Context) {
database := c.MustGet("database").(*db.Database)
// ✅ SÉCURITÉ: Récupérer depuis JWT validé
username, exists := c.Get("username")
if !exists {
log.Printf("❌ [ORDER_HISTORY] Utilisateur non authentifié")
c.JSON(http.StatusUnauthorized, gin.H{
"error": "Authentification requise",
})
return
}
usernameStr := username.(string)
// Récupérer l'ID de la commande
var commandID int
if _, err := fmt.Sscanf(c.Param("id"), "%d", &commandID); err != nil {
c.JSON(http.StatusBadRequest, gin.H{
"error": "ID de commande invalide",
})
return
}
log.Printf("📜 [ORDER_HISTORY] Récupération historique cmd %d pour %s", commandID, usernameStr)
// ✅ Vérifier que la commande existe
command, err := database.GetCommandByID(commandID)
if err != nil {
log.Printf("❌ [ORDER_HISTORY] Commande non trouvée")
c.JSON(http.StatusNotFound, gin.H{
"error": "Commande non trouvée",
})
return
}
// ✅ Vérifier que la commande appartient au client
cmdUsername, ok := command["username"].(string)
if !ok || cmdUsername != usernameStr {
log.Printf("❌ [ORDER_HISTORY] Accès refusé - cmd appartient à %s, pas à %s", cmdUsername, usernameStr)
c.JSON(http.StatusForbidden, gin.H{
"error": "Cette commande ne vous appartient pas",
})
return
}
// ✅ Récupérer les logs de la commande
logs, err := database.GetCommandLogs(commandID)
if err != nil {
log.Printf("⚠️ [ORDER_HISTORY] Erreur logs: %v", err)
logs = []map[string]interface{}{}
}
// ✅ Récupérer les items
items, err := database.GetCommandItems(commandID)
if err != nil {
log.Printf("⚠️ [ORDER_HISTORY] Erreur items: %v", err)
items = []map[string]interface{}{}
}
log.Printf("✅ [ORDER_HISTORY] Cmd %d: %d logs, %d items", commandID, len(logs), len(items))
c.JSON(http.StatusOK, gin.H{
"success": true,
"command": command,
"logs": logs,
"logs_count": len(logs),
"items": items,
"items_count": len(items),
})
}
+456
View File
@@ -0,0 +1,456 @@
// ============================================
// handlers/basket_handlers_CORRIGES.go
// ============================================
package handlers
import (
"gestion/db"
"gestion/services"
"log"
"net/http"
"github.com/gin-gonic/gin"
)
type BasketsRequest struct {
Username string `json:"username"`
NameProduct string `json:"name_product"`
Category string `json:"category"`
Quantity float64 `json:"quantity"`
}
// ============================================
// ✅ SÉCURISÉ: AddProductsBasket
// ============================================
// POST /api/v1/panier/add
func AddProductsBasket(c *gin.Context) {
db := c.MustGet("database").(*db.Database)
// Liaison JSON
var req BasketsRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Requête invalide", "details": err.Error()})
return
}
// Récupérer le username depuis JWT ou contexte
username, ok := c.Get("username")
if !ok {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Authentification requise"})
return
}
req.Username = username.(string)
// Validation des champs
if req.NameProduct == "" || req.Category == "" || req.Quantity <= 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "Champs invalides"})
return
}
// Vérifier le stock
stock, err := db.GetProductStock(req.NameProduct, req.Category)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Produit non trouvé"})
return
}
if stock < req.Quantity {
c.JSON(http.StatusBadRequest, gin.H{
"error": "Stock insuffisant",
"available": stock,
})
return
}
// Ajouter au panier (ou mettre à jour si déjà présent)
panier, err := db.AddProductInBasket(req.Username, req.NameProduct, req.Quantity, req.Category)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Impossible d'ajouter le produit au panier"})
return
}
// Décrémenter le stock
if err := db.DecrementProductStock(req.NameProduct, req.Category, req.Quantity); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Impossible de réserver le stock"})
return
}
c.JSON(http.StatusCreated, gin.H{
"success": true,
"message": "Produit ajouté au panier avec succès",
"panier": panier,
})
}
// ============================================
// ============================================
// GET /api/v1/panier/:username
// Récupère le panier du client authentifié
func GetAllBaskets(c *gin.Context) {
database := c.MustGet("database").(*db.Database)
username := c.Param("username")
log.Printf("📦 [GET_PANIER] Requête pour: %s", username)
if username == "" {
log.Printf("❌ [GET_PANIER] Username manquant")
c.JSON(http.StatusBadRequest, gin.H{"error": "Le paramètre 'username' est requis"})
return
}
// ✅ SÉCURITÉ 1: Récupérer username depuis le contexte (du JWT validé)
authUsername, hasAuth := c.Get("username")
if !hasAuth {
log.Printf("❌ [GET_PANIER] Username manquant dans JWT")
c.JSON(http.StatusUnauthorized, gin.H{"error": "Authentification requise"})
return
}
authUsernameStr := authUsername.(string)
// ✅ SÉCURITÉ 2: Vérifier que c'est bien l'utilisateur de la session
if username != authUsernameStr {
log.Printf("❌ [GET_PANIER] ⚠️ TENTATIVE D'ACCÈS AU PANIER NON AUTORISÉE!")
log.Printf(" Username du JWT: %s", authUsernameStr)
log.Printf(" Username demandé: %s", username)
c.JSON(http.StatusForbidden, gin.H{
"error": "Vous ne pouvez accéder qu'à votre panier",
})
return
}
// ✅ SÉCURITÉ 3: Forcer l'utilisation du username du JWT
username = authUsernameStr
// ✅ SÉCURITÉ 4: Vérifier que c'est un CLIENT
_, err := database.GetClientByUsername(username)
if err != nil {
log.Printf("❌ [GET_PANIER] Client inexistant: %s", username)
c.JSON(http.StatusNotFound, gin.H{"error": "Utilisateur inexistant"})
return
}
baskets, err := database.GetAllProductsInBasket(username)
if err != nil {
log.Printf("❌ [GET_PANIER] Erreur récupération: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{
"error": "Erreur lors de la récupération du panier",
"details": err.Error(),
})
return
}
var totalAmount float64
for _, item := range baskets {
totalAmount += item.Price * item.Quantity
}
log.Printf("✅ [GET_PANIER] Panier %s: %d articles, total=%.2f€", username, len(baskets), totalAmount)
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "Panier récupéré avec succès",
"panier": baskets,
"count": len(baskets),
"total_amount": totalAmount,
})
}
// ============================================
// ✅ SÉCURISÉ: DeleteProductFromBasket
// ============================================
// DELETE /api/v1/panier/remove
// Supprime un produit du panier
func DeleteProductFromBasket(c *gin.Context) {
database := c.MustGet("database").(*db.Database)
var req struct {
ID int `json:"id" binding:"required"`
}
if err := c.ShouldBindJSON(&req); err != nil {
log.Printf("❌ [DEL_PANIER] Erreur JSON: %v", err)
c.JSON(http.StatusBadRequest, gin.H{
"error": "Données requises manquantes",
"details": err.Error(),
})
return
}
// ✅ SÉCURITÉ 1: Récupérer username depuis le contexte (du JWT validé)
authUsername, hasAuth := c.Get("username")
if !hasAuth {
log.Printf("❌ [DEL_PANIER] Username manquant dans JWT")
c.JSON(http.StatusUnauthorized, gin.H{"error": "Authentification requise"})
return
}
authUsernameStr := authUsername.(string)
log.Printf("🗑️ [DEL_PANIER] Suppression article: id=%d, client=%s", req.ID, authUsernameStr)
// ✅ SÉCURITÉ 2: Vérifier que l'article appartient à ce client
var itemUsername string
err := database.QueryRow(
"SELECT username FROM baskets WHERE id = $1",
req.ID,
).Scan(&itemUsername)
if err != nil {
log.Printf("❌ [DEL_PANIER] Article non trouvé: id=%d", req.ID)
c.JSON(http.StatusNotFound, gin.H{"error": "Article non trouvé"})
return
}
if itemUsername != authUsernameStr {
log.Printf("❌ [DEL_PANIER] ⚠️ TENTATIVE DE SUPPRESSION NON AUTORISÉE!")
log.Printf(" Client JWT: %s", authUsernameStr)
log.Printf(" Propriétaire article: %s", itemUsername)
c.JSON(http.StatusForbidden, gin.H{
"error": "Vous ne pouvez supprimer que vos articles",
})
return
}
// Supprimer l'article
err = database.DeleteProductFromBasket(req.ID)
if err != nil {
log.Printf("❌ [DEL_PANIER] Erreur suppression: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{
"error": "Erreur lors de la suppression",
"details": err.Error(),
})
return
}
log.Printf("✅ [DEL_PANIER] Article %d supprimé", req.ID)
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "Produit supprimé du panier avec succès",
"item_id": req.ID,
"stock_released": true,
})
}
// ============================================
// ✅ SÉCURISÉ: ClearBasket
// ============================================
// DELETE /api/v1/panier/clear
// Vide le panier du client
func ClearBasket(c *gin.Context) {
database := c.MustGet("database").(*db.Database)
// ✅ SÉCURITÉ 1: Récupérer username depuis le contexte (du JWT validé)
authUsername, hasAuth := c.Get("username")
if !hasAuth {
log.Printf("❌ [CLEAR_PANIER] Username manquant dans JWT")
c.JSON(http.StatusUnauthorized, gin.H{"error": "Authentification requise"})
return
}
authUsernameStr := authUsername.(string)
log.Printf("🧹 [CLEAR_PANIER] Vider panier de: %s", authUsernameStr)
baskets, err := database.GetAllProductsInBasket(authUsernameStr)
if err != nil {
log.Printf("❌ [CLEAR_PANIER] Erreur récupération: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{
"error": "Erreur lors du vidage du panier",
})
return
}
err = database.ClearBasket(authUsernameStr)
if err != nil {
log.Printf("❌ [CLEAR_PANIER] Erreur vidage: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{
"error": "Erreur lors du vidage du panier",
"details": err.Error(),
})
return
}
log.Printf("✅ [CLEAR_PANIER] Panier %s vidé: %d articles supprimés", authUsernameStr, len(baskets))
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "Panier vidé avec succès",
"stock_released": len(baskets),
})
}
// ============================================
// ✅ SÉCURISÉ: ValidateBasket - CHECKOUT FINAL
// ============================================
// POST /api/v1/checkout
// Crée la commande depuis le panier et le vide
// ⚠️ SEUL ENDPOINT DE CRÉATION DE COMMANDE (CreateCommandFromBasket supprimé)
func ValidateBasket(c *gin.Context) {
database := c.MustGet("database").(*db.Database)
geoService := c.MustGet("geoService").(*services.GeoService)
username, exists := c.Get("username")
if !exists {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Utilisateur non authentifié"})
return
}
usernameStr := username.(string)
var req struct {
DeliveryAddress string `json:"delivery_address" binding:"required"`
}
if err := c.ShouldBindJSON(&req); err != nil || req.DeliveryAddress == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "Adresse de livraison requise"})
return
}
log.Printf("🛒 [CHECKOUT] Début checkout pour: %s", usernameStr)
// ============================================
// 1️⃣ Vérifier que le panier n'est pas vide
// ============================================
items, err := database.GetBasketItems(usernameStr)
if err != nil {
log.Printf("❌ [CHECKOUT] Erreur récupération panier: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Impossible de récupérer le panier", "details": err.Error()})
return
}
if len(items) == 0 {
log.Printf("❌ [CHECKOUT] Panier vide")
c.JSON(http.StatusBadRequest, gin.H{"error": "Panier vide"})
return
}
log.Printf("🛒 [CHECKOUT] Panier: %d articles", len(items))
// ============================================
// 2️⃣ Créer la commande (qui décrémente automatiquement le stock)
// ============================================
command, err := database.CreateCommandWithAddress(usernameStr, req.DeliveryAddress)
if err != nil {
log.Printf("❌ [CHECKOUT] Erreur création commande: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur création commande", "details": err.Error()})
return
}
commandID := command.ID
log.Printf("✅ [CHECKOUT] Commande %d créée", commandID)
// ============================================
// 3️⃣ Vider le panier
// ============================================
err = database.ClearBasket(usernameStr)
if err != nil {
log.Printf("❌ [CHECKOUT] Erreur vidage panier: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Impossible de vider le panier", "details": err.Error()})
return
}
log.Printf("🧹 [CHECKOUT] Panier vidé")
// ============================================
// 4️⃣ Auto-assignation livreur (optionnel)
// ============================================
var assigned bool
var assignInfo gin.H
location, err := geoService.GeocodeAddress(req.DeliveryAddress)
if err == nil {
log.Printf("📍 [CHECKOUT] Adresse géocodée: %.6f,%.6f", location.Latitude, location.Longitude)
activeLivreurs, err := database.GetAllActiveDeliveryPersons()
if err == nil && len(activeLivreurs) > 0 {
log.Printf("🚚 [CHECKOUT] %d livreurs actifs disponibles", len(activeLivreurs))
usernames := make([]string, len(activeLivreurs))
for i, livreur := range activeLivreurs {
usernames[i] = livreur.Username
}
nearest, err := geoService.FindNearestDeliveryPersonFast(services.Coordinates{
Latitude: location.Latitude,
Longitude: location.Longitude,
}, usernames)
if err == nil {
log.Printf("👤 [CHECKOUT] Livreur le plus proche: %s (%.2f km)", nearest.Username, nearest.Distance)
// ✅ CORRECTION: Utiliser CalculateETAWithTomTom au lieu de GetETAWithTraffic
travelTime, distance, err := services.CalculateETAWithTomTom(
nearest.Location,
services.Coordinates{
Latitude: location.Latitude,
Longitude: location.Longitude,
},
)
if err != nil {
// Fallback sur ETA simple
travelTime = nearest.EstimatedTime
distance = nearest.Distance
log.Printf("⚠️ [CHECKOUT] Fallback ETA: %d min", travelTime)
}
log.Printf("⏱️ [CHECKOUT] ETA calculé: %d min, distance: %.2f km", travelTime, distance)
// Assigner la commande au livreur
err = database.AssignCommandToDeliverymanQueueWithCoords(
commandID,
nearest.Username,
travelTime,
location.Latitude,
location.Longitude,
req.DeliveryAddress,
)
if err != nil {
log.Printf("⚠️ [CHECKOUT] Erreur assignation: %v", err)
} else {
// Mettre à jour le statut du livreur
err = database.SetDeliveryPersonStatus(nearest.Username, "busy", commandID)
if err != nil {
log.Printf("⚠️ [CHECKOUT] Erreur mise à jour statut livreur: %v", err)
}
assigned = true
assignInfo = gin.H{
"username": nearest.Username,
"distance_km": distance,
"travel_time": travelTime,
}
log.Printf("✅ [CHECKOUT] Commande assignée à %s", nearest.Username)
}
} else {
log.Printf("⚠️ [CHECKOUT] Aucun livreur trouvé: %v", err)
}
} else {
log.Printf("⚠️ [CHECKOUT] Aucun livreur actif disponible")
}
} else {
log.Printf("⚠️ [CHECKOUT] Erreur géocodage: %v", err)
}
// ============================================
// 5️⃣ Réponse
// ============================================
resp := gin.H{
"success": true,
"command_id": commandID,
"delivery_address": req.DeliveryAddress,
"status": "pending",
}
if assigned {
resp["message"] = "Commande créée et livreur assigné automatiquement"
resp["auto_assigned"] = true
resp["assigned_to"] = assignInfo
resp["status"] = "assigned"
log.Printf("✅ [CHECKOUT] Réponse 200 - Commande %d assignée", commandID)
} else {
resp["message"] = "Commande créée - En attente d'assignation"
resp["auto_assigned"] = false
log.Printf("✅ [CHECKOUT] Réponse 200 - Commande %d en attente", commandID)
}
c.JSON(http.StatusOK, resp)
}
+959
View File
@@ -0,0 +1,959 @@
package handlers
import (
"fmt"
"gestion/db"
"gestion/models"
"gestion/utils"
"log"
"mime/multipart"
"net/http"
"os"
"path/filepath"
"strconv"
"strings"
"time"
"github.com/gin-gonic/gin"
)
// ============================================
// CONFIGURATION & LIMITES
// ============================================
const (
MaxFileSize = 10 * 1024 * 1024 // 10MB par fichier
MaxTotalUploadSize = 50 * 1024 * 1024 // 50MB total
MaxFilesPerProduct = 10 // Max 10 fichiers
MaxNameLength = 200
MaxDescLength = 2000
MaxProductsPerUser = 100 // Limite pour éviter spam
)
// ✅ MIME types autorisés (vérification réelle du contenu)
var allowedMimeTypes = map[string]bool{
"image/jpeg": true,
"image/png": true,
"image/gif": true,
"image/webp": true,
"video/mp4": true,
"video/webm": 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 {
if len(name) == 0 {
return fmt.Errorf("nom requis")
}
if len(name) > MaxNameLength {
return fmt.Errorf("nom trop long (max %d caractères)", MaxNameLength)
}
// Sanitize
if strings.Contains(name, "..") || strings.Contains(name, "/") {
return fmt.Errorf("nom invalide")
}
return nil
}
func validateProductDescription(desc string) error {
if len(desc) == 0 {
return fmt.Errorf("description requise")
}
if len(desc) > MaxDescLength {
return fmt.Errorf("description trop longue (max %d caractères)", MaxDescLength)
}
return nil
}
func validateStock(stock float64) error {
if stock < 0 {
return fmt.Errorf("stock ne peut pas être négatif")
}
if stock > 1000000 {
return fmt.Errorf("stock trop élevé (max 1000000)")
}
return nil
}
func validatePrice(quantity int, price float64) error {
if quantity <= 0 {
return fmt.Errorf("quantité doit être > 0")
}
if quantity > 10000 {
return fmt.Errorf("quantité trop élevée (max 10000)")
}
if price <= 0 {
return fmt.Errorf("prix doit être > 0")
}
if price > 100000 {
return fmt.Errorf("prix trop élevé (max 100000)")
}
return nil
}
func validateCategory(category string) error {
// Nettoyage
category = strings.ToLower(strings.TrimSpace(category))
category = strings.Map(func(r rune) rune {
if r < 32 || r == 127 {
return -1
}
return r
}, category)
validCategories := []string{"weed&hash", "zipette&co", "gros&semi"}
for _, v := range validCategories {
if category == v {
return nil
}
}
return fmt.Errorf("catégorie invalide")
}
// ✅ VÉRIFICATION DU TYPE MIME RÉEL (pas juste l'extension)
func validateFileMimeType(fileHeader *multipart.FileHeader) (string, error) {
file, err := fileHeader.Open()
if err != nil {
return "", err
}
defer file.Close()
// Lire les premiers 512 bytes pour détecter le type MIME
buffer := make([]byte, 512)
_, err = file.Read(buffer)
if err != nil {
return "", err
}
mimeType := http.DetectContentType(buffer)
if !allowedMimeTypes[mimeType] {
return "", fmt.Errorf("type de fichier non autorisé: %s", mimeType)
}
return mimeType, nil
}
// ✅ PROTECTION CONTRE PATH TRAVERSAL
func sanitizeFilePath(path string) (string, error) {
// Nettoyer le chemin
cleaned := filepath.Clean(path)
// Vérifier qu'il ne contient pas de ".."
if strings.Contains(cleaned, "..") {
return "", fmt.Errorf("path traversal détecté")
}
// Vérifier qu'il commence par "uploads/"
if !strings.HasPrefix(cleaned, "uploads/") && !strings.HasPrefix(cleaned, "uploads\\") {
return "", fmt.Errorf("chemin invalide")
}
return cleaned, nil
}
// ============================================
// CREATE PRODUCT - VERSION SÉCURISÉE
// ============================================
func CreateProduct(c *gin.Context) {
database := c.MustGet("database").(*db.Database)
// ✅ VÉRIFIER LE RÔLE (déjà fait par middleware, double-check)
role := c.GetString("role")
if role != "admin" && role != "cabine" {
c.JSON(http.StatusForbidden, gin.H{"error": "Accès refusé"})
return
}
username, _ := safeGetUsername(c)
// ✅ PARSER AVEC LIMITE DE TAILLE
if err := c.Request.ParseMultipartForm(MaxTotalUploadSize); err != nil {
log.Printf("❌ [CreateProduct] Formulaire trop grand: %v", err)
c.JSON(http.StatusBadRequest, gin.H{"error": "Fichiers trop volumineux"})
return
}
// ✅ RÉCUPÉRER ET VALIDER LES DONNÉES
name := strings.TrimSpace(c.PostForm("name"))
category := strings.TrimSpace(c.PostForm("category"))
description := strings.TrimSpace(c.PostForm("description"))
stockStr := c.PostForm("stock")
// ✅ VALIDATION STRICTE
if err := validateProductName(name); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if err := validateProductDescription(description); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// ✅ NETTOYER ET VALIDER LA CATÉGORIE
category = strings.ToLower(strings.TrimSpace(category))
category = strings.Map(func(r rune) rune {
if r < 32 || r == 127 {
return -1
}
return r
}, category)
if err := validateCategory(category); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// ✅ VALIDER LE STOCK
stock, err := strconv.ParseFloat(stockStr, 64)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Stock invalide"})
return
}
if err := validateStock(stock); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// ✅ RÉCUPÉRER ET VALIDER LES PRIX
prices := []models.ProductPrice{}
priceIndex := 0
for priceIndex < 100 { // Limite anti-spam
quantityKey := fmt.Sprintf("prices[%d][quantity]", priceIndex)
priceKey := fmt.Sprintf("prices[%d][price]", priceIndex)
quantityStr := c.PostForm(quantityKey)
priceStr := c.PostForm(priceKey)
if quantityStr == "" || priceStr == "" {
break
}
quantity, err := strconv.Atoi(quantityStr)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Quantité invalide"})
return
}
price, err := strconv.ParseFloat(priceStr, 64)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Prix invalide"})
return
}
if err := validatePrice(quantity, price); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
prices = append(prices, models.ProductPrice{
Quantity: quantity,
Price: price,
})
priceIndex++
}
if len(prices) == 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "Au moins un prix requis"})
return
}
log.Printf("✅ [CreateProduct] %s crée produit: %s", username, name)
// ✅ CRÉER LE PRODUIT
product := models.Product{
Name: name,
Category: category,
Description: description,
Stock: stock,
Prices: prices,
}
err = database.CreateProduct(&product)
if err != nil {
log.Printf("❌ [CreateProduct] Erreur DB: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur création produit"})
return
}
log.Printf("✅ [CreateProduct] Produit créé: ID=%d", product.ID)
// ✅ TRAITER LES FICHIERS MÉDIAS AVEC SÉCURITÉ
if c.Request.MultipartForm == nil || c.Request.MultipartForm.File == nil {
c.JSON(http.StatusCreated, gin.H{
"success": true,
"product": product,
})
return
}
files, exists := c.Request.MultipartForm.File["media"]
if !exists || len(files) == 0 {
c.JSON(http.StatusCreated, gin.H{
"success": true,
"product": product,
})
return
}
// ✅ LIMITER LE NOMBRE DE FICHIERS
if len(files) > MaxFilesPerProduct {
database.DeleteProduct(product.ID)
c.JSON(http.StatusBadRequest, gin.H{
"error": fmt.Sprintf("Maximum %d fichiers autorisés", MaxFilesPerProduct),
})
return
}
log.Printf("📁 [CreateProduct] %d fichiers à traiter", len(files))
cleanProductName := cleanFileName(product.Name)
uploadedMedia := []models.Media{}
savedFiles := []string{}
var totalSize int64 = 0
for i, fileHeader := range files {
// ✅ VÉRIFIER LA TAILLE INDIVIDUELLE
if fileHeader.Size > MaxFileSize {
rollbackFiles(savedFiles)
database.DeleteProduct(product.ID)
c.JSON(http.StatusBadRequest, gin.H{
"error": fmt.Sprintf("Fichier %s trop volumineux (max %dMB)", fileHeader.Filename, MaxFileSize/(1024*1024)),
})
return
}
totalSize += fileHeader.Size
// ✅ VÉRIFIER LA TAILLE TOTALE
if totalSize > MaxTotalUploadSize {
rollbackFiles(savedFiles)
database.DeleteProduct(product.ID)
c.JSON(http.StatusBadRequest, gin.H{
"error": fmt.Sprintf("Taille totale dépassée (max %dMB)", MaxTotalUploadSize/(1024*1024)),
})
return
}
log.Printf("📄 [%d/%d] Traitement: %s", i+1, len(files), fileHeader.Filename)
// ✅ VÉRIFIER LE TYPE MIME RÉEL (pas juste l'extension)
mimeType, err := validateFileMimeType(fileHeader)
if err != nil {
log.Printf("❌ [CreateProduct] Type MIME invalide: %v", err)
rollbackFiles(savedFiles)
database.DeleteProduct(product.ID)
c.JSON(http.StatusBadRequest, gin.H{"error": "Type de fichier non autorisé"})
return
}
// ✅ DÉTERMINER LE TYPE DE MÉDIA
var mediaType string
if strings.HasPrefix(mimeType, "image/") {
mediaType = "image"
} else if strings.HasPrefix(mimeType, "video/") {
mediaType = "video"
} else {
rollbackFiles(savedFiles)
database.DeleteProduct(product.ID)
c.JSON(http.StatusBadRequest, gin.H{"error": "Type de média non supporté"})
return
}
// ✅ GÉNÉRER UN NOM UNIQUE ET SÉCURISÉ
uniqueFileName := utils.GenerateUniqueFileName(cleanProductName, fileHeader.Filename)
// ✅ CRÉER LE DOSSIER DE MANIÈRE SÉCURISÉE
destFolder := filepath.Join("uploads", mediaType+"s")
if err := os.MkdirAll(destFolder, 0755); err != nil {
log.Printf("❌ [CreateProduct] Erreur création dossier: %v", err)
rollbackFiles(savedFiles)
database.DeleteProduct(product.ID)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur système"})
return
}
filePath := filepath.Join(destFolder, uniqueFileName)
// ✅ VALIDER LE CHEMIN (protection path traversal)
safeFilePath, err := sanitizeFilePath(filePath)
if err != nil {
log.Printf("❌ [CreateProduct] Path traversal détecté: %v", err)
rollbackFiles(savedFiles)
database.DeleteProduct(product.ID)
c.JSON(http.StatusBadRequest, gin.H{"error": "Chemin invalide"})
return
}
// ✅ SAUVEGARDER LE FICHIER
if err := c.SaveUploadedFile(fileHeader, safeFilePath); err != nil {
log.Printf("❌ [CreateProduct] Erreur sauvegarde: %v", err)
rollbackFiles(savedFiles)
database.DeleteProduct(product.ID)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur sauvegarde fichier"})
return
}
savedFiles = append(savedFiles, safeFilePath)
// ✅ CRÉER L'ENTRÉE MÉDIA
mediaURL := "/" + filepath.ToSlash(safeFilePath)
media := models.Media{
ProductID: product.ID,
Type: mediaType,
URL: mediaURL,
}
if err := database.CreateMedia(&media); err != nil {
log.Printf("❌ [CreateProduct] Erreur DB média: %v", err)
rollbackFiles(savedFiles)
database.DeleteProduct(product.ID)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur création média"})
return
}
uploadedMedia = append(uploadedMedia, media)
log.Printf("✅ [CreateProduct] Média %d créé", media.ID)
}
product.Media = uploadedMedia
log.Printf("🎉 [CreateProduct] SUCCÈS: %d médias enregistrés", len(uploadedMedia))
c.JSON(http.StatusCreated, gin.H{
"success": true,
"product": product,
})
}
// ============================================
// GET ENDPOINTS - SÉCURISÉS (lecture publique OK)
// ============================================
func GetAllProducts(c *gin.Context) {
database := c.MustGet("database").(*db.Database)
products, err := database.GetAllProducts()
if err != nil {
log.Printf("❌ [GetAllProducts] Erreur: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{
"success": false,
"error": "Erreur récupération produits",
})
return
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"data": products,
"count": len(products),
})
}
func GetProductsByCategory(c *gin.Context) {
database := c.MustGet("database").(*db.Database)
category := strings.ToLower(strings.TrimSpace(c.Param("category")))
// ✅ VALIDATION
if err := validateCategory(category); err != nil {
c.JSON(http.StatusBadRequest, gin.H{
"success": false,
"error": err.Error(),
})
return
}
products, err := database.GetProductsByCategory(category)
if err != nil {
log.Printf("❌ [GetProductsByCategory] Erreur: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{
"success": false,
"error": "Erreur récupération produits",
})
return
}
// ✅ Charger les médias
for i := range products {
media, _ := database.GetMediaByProductID(products[i].ID)
products[i].Media = media
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"data": products,
"count": len(products),
})
}
func GetProductByID(c *gin.Context) {
database := c.MustGet("database").(*db.Database)
id, err := strconv.Atoi(c.Param("id"))
if err != nil || id <= 0 {
c.JSON(http.StatusBadRequest, gin.H{
"success": false,
"error": "ID invalide",
})
return
}
product, err := database.GetProductByID(id)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{
"success": false,
"error": "Produit non trouvé",
})
return
}
// ✅ Charger les médias
media, _ := database.GetMediaByProductID(product.ID)
product.Media = media
c.JSON(http.StatusOK, gin.H{
"success": true,
"data": product,
})
}
// ============================================
// UPDATE PRODUCT - VERSION SÉCURISÉE
// ============================================
func UpdateProduct(c *gin.Context) {
database := c.MustGet("database").(*db.Database)
// ✅ VÉRIFIER LE RÔLE
role := c.GetString("role")
if role != "admin" && role != "cabine" {
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 updateData struct {
Name string `json:"name"`
Category string `json:"category"`
Description string `json:"description"`
Stock float64 `json:"stock"`
Prices []models.ProductPrice `json:"prices"`
}
if err := c.ShouldBindJSON(&updateData); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Données invalides"})
return
}
// ✅ VALIDATION COMPLÈTE
if err := validateProductName(updateData.Name); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if err := validateProductDescription(updateData.Description); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if err := validateCategory(updateData.Category); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if err := validateStock(updateData.Stock); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if len(updateData.Prices) == 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "Au moins un prix requis"})
return
}
for _, price := range updateData.Prices {
if err := validatePrice(price.Quantity, price.Price); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
}
log.Printf("🔄 [UpdateProduct] %s met à jour produit #%d", username, id)
// ✅ UPDATE PRODUIT
updateQuery := `
UPDATE products
SET name = $1, category = $2, description = $3, stock = $4, updated_at = $5
WHERE id = $6
`
_, err = database.Exec(updateQuery,
updateData.Name,
updateData.Category,
updateData.Description,
updateData.Stock,
time.Now(),
id,
)
if err != nil {
log.Printf("❌ [UpdateProduct] Erreur UPDATE: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur mise à jour"})
return
}
// ✅ UPDATE PRIX
database.Exec(`DELETE FROM product_prices WHERE product_id = $1`, id)
for _, price := range updateData.Prices {
_, err := database.Exec(`
INSERT INTO product_prices (product_id, quantity, price)
VALUES ($1, $2, $3)
`, id, price.Quantity, price.Price)
if err != nil {
log.Printf("❌ [UpdateProduct] Erreur prix: %v", err)
}
}
// ✅ RÉCUPÉRER LE PRODUIT MIS À JOUR
updatedProduct, _ := database.GetProductByID(id)
media, _ := database.GetMediaByProductID(id)
updatedProduct.Media = media
log.Printf("✅ [UpdateProduct] Produit #%d mis à jour", id)
c.JSON(http.StatusOK, gin.H{
"success": true,
"product": updatedProduct,
})
}
// ============================================
// DELETE MEDIA - VERSION SÉCURISÉE
// ============================================
func DeleteMedia(c *gin.Context) {
database := c.MustGet("database").(*db.Database)
// ✅ VÉRIFIER LE RÔLE
role := c.GetString("role")
if role != "admin" && role != "cabine" {
c.JSON(http.StatusForbidden, gin.H{"error": "Accès refusé"})
return
}
mediaID, err := strconv.Atoi(c.Param("media_id"))
if err != nil || mediaID <= 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "ID invalide"})
return
}
media, err := database.GetMediaByID(mediaID)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Média non trouvé"})
return
}
// ✅ SÉCURISER LE CHEMIN AVANT SUPPRESSION
filePath := strings.TrimPrefix(media.URL, "/")
safeFilePath, err := sanitizeFilePath(filePath)
if err != nil {
log.Printf("❌ [DeleteMedia] Path invalide: %v", err)
c.JSON(http.StatusBadRequest, gin.H{"error": "Chemin invalide"})
return
}
// ✅ SUPPRIMER LE FICHIER PHYSIQUE
if err := os.Remove(safeFilePath); err != nil && !os.IsNotExist(err) {
log.Printf("⚠️ [DeleteMedia] Erreur suppression fichier: %v", err)
}
// ✅ SUPPRIMER DE LA DB
err = database.DeleteMedia(mediaID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur suppression"})
return
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "Média supprimé",
})
}
// handlers/product_handlers_SECURED.go
func UploadMedia(c *gin.Context) {
database := c.MustGet("database").(*db.Database)
// ✅ VÉRIFIER LE RÔLE
username, err := safeGetUsername(c)
if err != nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Authentification requise"})
return
}
role := c.GetString("role")
if role != "admin" && role != "cabine" {
c.JSON(http.StatusForbidden, gin.H{"error": "Accès refusé"})
return
}
// ✅ RÉCUPÉRER ET VALIDER L'ID PRODUIT
productID, err := strconv.Atoi(c.Param("id"))
if err != nil || productID <= 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "ID produit invalide"})
return
}
// ✅ VÉRIFIER QUE LE PRODUIT EXISTE
productName, err := database.GetProductNameByID(productID)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Produit non trouvé"})
return
}
log.Printf("📤 [UploadMedia] %s upload média pour produit #%d (%s)", username, productID, productName)
// ✅ RÉCUPÉRER LE TYPE ET LE FICHIER
fileType := c.PostForm("type")
if fileType != "image" && fileType != "video" {
c.JSON(http.StatusBadRequest, gin.H{"error": "Type invalide (image ou video requis)"})
return
}
file, err := c.FormFile("file")
if err != nil {
log.Printf("❌ [UploadMedia] Erreur récupération fichier: %v", err)
c.JSON(http.StatusBadRequest, gin.H{"error": "Fichier manquant"})
return
}
// ✅ VÉRIFIER LA TAILLE
const MaxFileSize = 10 * 1024 * 1024 // 10MB
if file.Size > MaxFileSize {
c.JSON(http.StatusBadRequest, gin.H{
"error": fmt.Sprintf("Fichier trop volumineux (max %dMB)", MaxFileSize/(1024*1024)),
})
return
}
// ✅ VÉRIFIER LE TYPE MIME RÉEL
fileHeader, err := file.Open()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur lecture fichier"})
return
}
defer fileHeader.Close()
buffer := make([]byte, 512)
_, err = fileHeader.Read(buffer)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur lecture fichier"})
return
}
mimeType := http.DetectContentType(buffer)
log.Printf("📋 [UploadMedia] Type MIME détecté: %s", mimeType)
// Vérifier que le MIME correspond au type déclaré
if fileType == "image" && !strings.HasPrefix(mimeType, "image/") {
c.JSON(http.StatusBadRequest, gin.H{"error": "Le fichier n'est pas une image valide"})
return
}
if fileType == "video" && !strings.HasPrefix(mimeType, "video/") {
c.JSON(http.StatusBadRequest, gin.H{"error": "Le fichier n'est pas une vidéo valide"})
return
}
// ✅ GÉNÉRER UN NOM UNIQUE
cleanProductName := cleanFileName(productName)
uniqueFileName := utils.GenerateUniqueFileName(cleanProductName, file.Filename)
// ✅ CRÉER LE DOSSIER
destFolder := filepath.Join("uploads", fileType+"s")
if err := os.MkdirAll(destFolder, 0755); err != nil {
log.Printf("❌ [UploadMedia] Erreur création dossier: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur création dossier"})
return
}
// ✅ SAUVEGARDER LE FICHIER
filePath := filepath.Join(destFolder, uniqueFileName)
if err := c.SaveUploadedFile(file, filePath); err != nil {
log.Printf("❌ [UploadMedia] Erreur sauvegarde: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur sauvegarde fichier"})
return
}
log.Printf("✅ [UploadMedia] Fichier sauvegardé: %s", filePath)
// ✅ CRÉER L'ENTRÉE EN BASE
mediaURL := "/" + filepath.ToSlash(filePath)
media := models.Media{
ProductID: productID,
Type: fileType,
URL: mediaURL,
}
err = database.CreateMedia(&media)
if err != nil {
// Rollback: supprimer le fichier
os.Remove(filePath)
log.Printf("❌ [UploadMedia] Erreur DB: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur création média"})
return
}
log.Printf("✅ [UploadMedia] Média #%d créé pour produit #%d", media.ID, productID)
c.JSON(http.StatusCreated, gin.H{
"success": true,
"message": "Média uploadé avec succès",
"media": media,
"uploaded_by": gin.H{
"username": username,
"role": role,
},
})
}
// ============================================
// DELETE PRODUCT - VERSION SÉCURISÉE
// ============================================
func DeleteProduct(c *gin.Context) {
database := c.MustGet("database").(*db.Database)
// ✅ VÉRIFIER LE RÔLE
role := c.GetString("role")
if role != "admin" && role != "cabine" {
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
}
log.Printf("🗑️ [DeleteProduct] %s supprime produit #%d", username, id)
// ✅ RÉCUPÉRER LES MÉDIAS AVANT SUPPRESSION
mediaList, err := database.GetMediaByProductID(id)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur récupération médias"})
return
}
// ✅ SUPPRIMER LES FICHIERS AVEC SÉCURITÉ
for _, media := range mediaList {
filePath := strings.TrimPrefix(media.URL, "/")
safeFilePath, err := sanitizeFilePath(filePath)
if err != nil {
log.Printf("⚠️ [DeleteProduct] Path invalide: %v", err)
continue
}
if err := os.Remove(safeFilePath); err != nil && !os.IsNotExist(err) {
log.Printf("⚠️ [DeleteProduct] Erreur suppression: %v", err)
}
}
// ✅ SUPPRIMER LES MÉDIAS DE LA DB
database.DeleteMediaByProductID(id)
// ✅ SUPPRIMER LE PRODUIT
err = database.DeleteProduct(id)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur suppression produit"})
return
}
log.Printf("✅ [DeleteProduct] Produit #%d supprimé", id)
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "Produit supprimé",
})
}
// ============================================
// HELPERS
// ============================================
func rollbackFiles(files []string) {
for _, file := range files {
safeFilePath, err := sanitizeFilePath(file)
if err != nil {
continue
}
os.Remove(safeFilePath)
}
}
func cleanFileName(name string) string {
replacements := map[string]string{
"/": "-", "\\": "-", ":": "-",
"*": "-", "?": "-", "\"": "-",
"<": "-", ">": "-", "|": "-",
" ": "_",
}
result := name
for old, new := range replacements {
result = strings.ReplaceAll(result, old, new)
}
// Limiter la longueur
if len(result) > 50 {
result = result[:50]
}
return result
}
File diff suppressed because it is too large Load Diff
+624
View File
@@ -0,0 +1,624 @@
// ============================================
// handlers/traffic_handlers.go - COMPLET
// ============================================
package handlers
import (
"encoding/json"
"fmt"
"gestion/db"
"gestion/models"
"gestion/services"
"io"
"log"
"math"
"net/http"
"os"
"strconv"
"time"
"github.com/gin-gonic/gin"
)
// ============================================
// INCIDENTS TRAFFIC TOMTOM
// ============================================
// GetIncidentsAroundDeliveryPerson récupère les incidents autour d'un livreur
// GET /api/v2/admin/traffic/delivery/:username/incidents
func GetIncidentsAroundDeliveryPerson(c *gin.Context) {
database := c.MustGet("database").(*db.Database)
userRole := c.GetString("role")
if userRole != "admin" && userRole != "livreur" {
c.JSON(http.StatusForbidden, gin.H{"error": "Accès refusé"})
return
}
username := c.Param("username")
if username == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "Username requis"})
return
}
// Récupérer position du livreur
lat, lon, err := database.GetDeliveryPersonLocation(username)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{
"error": "Position livreur non trouvée",
"details": err.Error(),
})
return
}
// Rayon de recherche par défaut: 5 km
radius := 5000 // mètres
// Récupérer incidents TomTom
incidents, err := fetchIncidents(lat, lon, radius)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{
"error": "Erreur récupération incidents",
"details": err.Error(),
})
return
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"username": username,
"location": gin.H{"latitude": lat, "longitude": lon},
"radius_km": radius / 1000,
"incidents": incidents,
"count": len(incidents),
})
}
// GetIncidentsForAllDeliveries récupère incidents + routes pour tous livreurs actifs
// GET /api/v2/admin/traffic/incidents/all
func GetIncidentsForAllDeliveries(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
}
// Récupérer tous les livreurs disponibles depuis Redis
livreurs, err := database.GetAvailableDeliveryPersonsRedis()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{
"error": "Erreur récupération livreurs",
"details": err.Error(),
})
return
}
results := []gin.H{}
for _, livreur := range livreurs {
username := livreur.Username
status := livreur.Status
// Sauter les livreurs offline
if status == "offline" {
continue
}
// Position livreur
lat, lon, err := database.GetDeliveryPersonLocation(username)
if err != nil {
log.Printf("⚠️ Position non trouvée pour %s", username)
continue
}
// Vérifier s'il a une commande en cours
commandID := livreur.CurrentCommand
if commandID == 0 {
// Pas de livraison en cours
results = append(results, gin.H{
"username": username,
"status": status,
"location": gin.H{"latitude": lat, "longitude": lon},
"has_delivery": false,
"incidents": []gin.H{},
"route": nil,
})
continue
}
// Récupérer la commande
command, err := database.GetCommandByID(commandID)
if err != nil {
log.Printf("⚠️ Commande %d non trouvée", commandID)
continue
}
// Coordonnées destination
var destLat, destLon float64
if dLat, ok := getFloatFromMap(command, "dest_latitude"); ok && dLat != 0 {
destLat = dLat
}
if dLon, ok := getFloatFromMap(command, "dest_longitude"); ok && dLon != 0 {
destLon = dLon
}
// Si pas de coordonnées, géocoder
if destLat == 0 || destLon == 0 {
address, ok := command["adresse"].(string)
if !ok || address == "" || address == "Adresse non spécifiée" {
continue
}
geoService := c.MustGet("geoService").(*services.GeoService)
location, err := geoService.GeocodeAddress(address)
if err != nil {
log.Printf("⚠️ Géocodage échoué pour %s", address)
continue
}
destLat = location.Latitude
destLon = location.Longitude
}
// Récupérer incidents sur le trajet
incidents, _ := fetchIncidentsOnRoute(lat, lon, destLat, destLon)
// Convertir incidents en gin.H pour JSON
incidentsJSON := make([]gin.H, len(incidents))
for i, inc := range incidents {
incidentsJSON[i] = gin.H{
"type": inc.Type,
"icon": inc.Icon,
"description": inc.Description,
}
}
// Calculer route avec trafic
routeSummary, err := fetchRouteSummary(lat, lon, destLat, destLon)
if err != nil {
log.Printf("⚠️ Erreur calcul route pour %s", username)
routeSummary = models.RouteSummary{}
}
results = append(results, gin.H{
"username": username,
"status": status,
"location": gin.H{"latitude": lat, "longitude": lon},
"destination": gin.H{"latitude": destLat, "longitude": destLon},
"has_delivery": true,
"command_id": commandID,
"incidents": incidentsJSON,
"route": routeSummary,
})
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"deliveries": results,
"count": len(results),
})
}
// ============================================
// MISE À JOUR ETA AVEC TRAFIC
// ============================================
func UpdateETAWithRealTraffic(c *gin.Context) {
database := c.MustGet("database").(*db.Database)
userRole := c.GetString("role")
if userRole != "admin" && userRole != "livreur" {
c.JSON(http.StatusForbidden, gin.H{"error": "Accès refusé"})
return
}
// Récupérer l'ID de la commande
commandID, err := strconv.Atoi(c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "ID 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",
"command_id": commandID,
})
return
}
// Vérifier qu'un livreur est assigné
livreurAssign, ok := command["livreur_assign"].(string)
if !ok || livreurAssign == "" {
c.JSON(http.StatusBadRequest, gin.H{
"error": "Aucun livreur assigné",
"command_id": commandID,
})
return
}
// Position actuelle du livreur
lat, lon, err := database.GetDeliveryPersonLocation(livreurAssign)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{
"error": "Position livreur introuvable",
"livreur": livreurAssign,
"details": err.Error(),
})
return
}
var destLat, destLon float64
// 🔹 1. Tenter de récupérer depuis le cache Redis (clé spécifique pour destination)
destCacheKey := fmt.Sprintf("command:destination:%d", commandID)
destData, err := db.Redis.Get(db.RedisCtx, destCacheKey).Result()
if err == 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
log.Printf("📍 Destination trouvée dans cache Redis pour commande %d", commandID)
}
}
// 🔹 2. Fallback: récupérer depuis la DB
if destLat == 0 || destLon == 0 {
if dLat, okLat := getFloatFromMap(command, "dest_latitude"); okLat && dLat != 0 {
destLat = dLat
}
if dLon, okLon := getFloatFromMap(command, "dest_longitude"); okLon && dLon != 0 {
destLon = dLon
}
}
// 🔹 3. Si toujours pas de coordonnées, géocoder l'adresse
if destLat == 0 || destLon == 0 {
address, ok := command["adresse"].(string)
if !ok || address == "" || address == "Adresse non spécifiée" {
c.JSON(http.StatusBadRequest, gin.H{
"error": "Adresse de destination manquante ou invalide",
"command_id": commandID,
})
return
}
geoService := c.MustGet("geoService").(*services.GeoService)
location, err := geoService.GeocodeAddress(address)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{
"error": "Impossible de géocoder l'adresse",
"address": address,
"command_id": commandID,
"details": err.Error(),
})
return
}
destLat = location.Latitude
destLon = location.Longitude
log.Printf("📍 Adresse géocodée pour commande %d: %s -> (%.6f, %.6f)",
commandID, address, destLat, destLon)
}
// 🔹 4. Sauvegarder les coordonnées destination dans le cache Redis
coordsJSON, _ := json.Marshal(map[string]float64{
"lat": destLat,
"lon": destLon,
})
if err := db.Redis.Set(db.RedisCtx, destCacheKey, coordsJSON, 4*time.Hour).Err(); err != nil {
log.Printf("⚠️ Impossible de sauvegarder destination dans Redis: %v", err)
}
// 🔹 5. Calculer le temps réel avec TomTom Routing API
routeSummary, err := fetchRouteSummary(lat, lon, destLat, destLon)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{
"error": "Impossible de calculer l'itinéraire",
"command_id": commandID,
"from": gin.H{"lat": lat, "lon": lon},
"to": gin.H{"lat": destLat, "lon": destLon},
"details": err.Error(),
})
return
}
// 🔹 6. Mettre à jour l'ETA dans Redis
err = database.SetCommandETA(commandID, routeSummary.TravelTimeInMinutes)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{
"error": "Erreur mise à jour ETA",
"command_id": commandID,
"details": err.Error(),
})
return
}
log.Printf("✅ ETA mis à jour pour commande %d: %d min (trafic réel inclus)",
commandID, routeSummary.TravelTimeInMinutes)
c.JSON(http.StatusOK, gin.H{
"success": true,
"command_id": commandID,
"eta_minutes": routeSummary.TravelTimeInMinutes,
"distance_km": routeSummary.LengthInKm,
"with_traffic": true,
"route_summary": routeSummary,
})
}
// ============================================
// FONCTIONS HELPERS - TOMTOM API
// ============================================
// fetchIncidents récupère les incidents de trafic autour d'une position
func fetchIncidents(lat, lon float64, radius int) ([]models.Incident, error) {
apiKey := os.Getenv("TOMTOM_API_KEY")
if apiKey == "" {
return nil, fmt.Errorf("TOMTOM_API_KEY non configurée")
}
// API TomTom Traffic Incidents
url := fmt.Sprintf(
"https://api.tomtom.com/traffic/services/5/incidentDetails?key=%s&bbox=%f,%f,%f,%f&fields={incidents{type,geometry{type,coordinates},properties{iconCategory,magnitudeOfDelay,events{description,code,iconCategory}}}}",
apiKey,
lon-0.05, lat-0.05, // Southwest corner
lon+0.05, lat+0.05, // Northeast corner
)
client := &http.Client{Timeout: 10 * time.Second}
resp, err := client.Get(url)
if err != nil {
return nil, fmt.Errorf("erreur requête incidents: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
return nil, fmt.Errorf("API incidents error %d: %s", resp.StatusCode, string(body))
}
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("erreur lecture réponse: %w", err)
}
var incidentResponse models.IncidentResponse
err = json.Unmarshal(body, &incidentResponse)
if err != nil {
return nil, fmt.Errorf("erreur parsing incidents: %w", err)
}
// Convertir en []models.Incident
incidents := make([]models.Incident, len(incidentResponse.Incidents))
for i, inc := range incidentResponse.Incidents {
incidents[i] = models.Incident{
Type: inc.Type,
Icon: inc.Icon,
Description: inc.Description,
}
}
return incidents, nil
}
// fetchIncidentsOnRoute récupère les incidents sur un trajet
func fetchIncidentsOnRoute(startLat, startLon, destLat, destLon float64) ([]models.Incident, error) {
// Calculer la bounding box du trajet
minLat := min(startLat, destLat) - 0.02
maxLat := max(startLat, destLat) + 0.02
minLon := min(startLon, destLon) - 0.02
maxLon := max(startLon, destLon) + 0.02
apiKey := os.Getenv("TOMTOM_API_KEY")
if apiKey == "" {
return []models.Incident{}, nil
}
url := fmt.Sprintf(
"https://api.tomtom.com/traffic/services/5/incidentDetails?key=%s&bbox=%f,%f,%f,%f&fields={incidents{type,geometry{type,coordinates},properties{iconCategory,magnitudeOfDelay,events{description,code,iconCategory}}}}",
apiKey,
minLon, minLat,
maxLon, maxLat,
)
client := &http.Client{Timeout: 10 * time.Second}
resp, err := client.Get(url)
if err != nil {
return []models.Incident{}, nil
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return []models.Incident{}, nil
}
body, _ := io.ReadAll(resp.Body)
var incidentResponse models.IncidentResponse
if err := json.Unmarshal(body, &incidentResponse); err != nil {
return []models.Incident{}, nil
}
// Convertir en []models.Incident
incidents := make([]models.Incident, len(incidentResponse.Incidents))
for i, inc := range incidentResponse.Incidents {
incidents[i] = models.Incident{
Type: inc.Type,
Icon: inc.Icon,
Description: inc.Description,
}
}
return incidents, nil
}
// récupère le temps de trajet réel via l'API TomTom Routing
// fetchRouteSummary récupère le temps de trajet réel via l'API TomTom Routing
func fetchRouteSummary(startLat, startLon, destLat, destLon float64) (models.RouteSummary, error) {
apiKey := os.Getenv("TOMTOM_API_KEY")
if apiKey == "" {
return models.RouteSummary{}, fmt.Errorf("TOMTOM_API_KEY non configurée")
}
// Validation des coordonnées
if startLat < -90 || startLat > 90 || destLat < -90 || destLat > 90 {
return models.RouteSummary{}, fmt.Errorf("latitude invalide: start=%.6f, dest=%.6f", startLat, destLat)
}
if startLon < -180 || startLon > 180 || destLon < -180 || destLon > 180 {
return models.RouteSummary{}, fmt.Errorf("longitude invalide: start=%.6f, dest=%.6f", startLon, destLon)
}
// API TomTom Routing: Calculate Route
url := fmt.Sprintf(
"https://api.tomtom.com/routing/1/calculateRoute/%f,%f:%f,%f/json?key=%s&traffic=true&travelMode=car",
startLat, startLon, destLat, destLon, apiKey,
)
log.Printf("🛣️ Appel TomTom: (%.6f,%.6f) -> (%.6f,%.6f)", startLat, startLon, destLat, destLon)
// Timeout réduit à 8 secondes
client := &http.Client{Timeout: 8 * time.Second}
resp, err := client.Get(url)
if err != nil {
// Fallback: estimation basée sur distance Haversine
distance := haversineDistance(startLat, startLon, destLat, destLon)
estimatedMinutes := int(distance/25*60) + 3 // ~25 km/h en ville + 3 min marge
if estimatedMinutes < 5 {
estimatedMinutes = 5
}
log.Printf("⚠️ TomTom timeout/erreur, fallback: %.2f km -> %d min estimé", distance, estimatedMinutes)
return models.RouteSummary{
TravelTimeInMinutes: estimatedMinutes,
LengthInKm: distance,
}, nil // Pas d'erreur, on retourne l'estimation
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
io.ReadAll(resp.Body) // Lire et ignorer le body pour fermer proprement
// Fallback en cas d'erreur API
distance := haversineDistance(startLat, startLon, destLat, destLon)
estimatedMinutes := int(distance/25*60) + 3
if estimatedMinutes < 5 {
estimatedMinutes = 5
}
log.Printf("⚠️ TomTom API error %d, fallback: %.2f km -> %d min", resp.StatusCode, distance, estimatedMinutes)
return models.RouteSummary{
TravelTimeInMinutes: estimatedMinutes,
LengthInKm: distance,
}, nil
}
body, err := io.ReadAll(resp.Body)
if err != nil {
return models.RouteSummary{}, fmt.Errorf("erreur lecture réponse: %w", err)
}
var routeResponse models.RouteResponse
err = json.Unmarshal(body, &routeResponse)
if err != nil {
return models.RouteSummary{}, fmt.Errorf("erreur parsing routing: %w", err)
}
if len(routeResponse.Routes) == 0 {
return models.RouteSummary{}, fmt.Errorf("aucun itinéraire trouvé")
}
summary := routeResponse.Routes[0].Summary
summary.TravelTimeInMinutes = (summary.TravelTimeInSeconds + 59) / 60
summary.LengthInKm = float64(summary.LengthInMeters) / 1000.0
log.Printf("🛣️ Route calculée: %.2f km, %d min (trafic inclus)", summary.LengthInKm, summary.TravelTimeInMinutes)
return summary, nil
}
// haversineDistance calcule la distance en km entre deux points GPS
func haversineDistance(lat1, lon1, lat2, lon2 float64) float64 {
const R = 6371.0 // Rayon Terre en km
const toRad = math.Pi / 180.0
dLat := (lat2 - lat1) * toRad
dLon := (lon2 - lon1) * toRad
a := math.Sin(dLat/2)*math.Sin(dLat/2) +
math.Cos(lat1*toRad)*math.Cos(lat2*toRad)*
math.Sin(dLon/2)*math.Sin(dLon/2)
c := 2 * math.Atan2(math.Sqrt(a), math.Sqrt(1-a))
return R * c
}
// getFloatFromMap récupère un float64 depuis une map avec différents types
func getFloatFromMap(m map[string]interface{}, key string) (float64, bool) {
value, exists := m[key]
if !exists || value == nil {
return 0, false
}
switch v := value.(type) {
case float64:
return v, true
case float32:
return float64(v), true
case int:
return float64(v), true
case int64:
return float64(v), true
case int32:
return float64(v), true
case json.Number:
f, err := v.Float64()
if err != nil {
return 0, false
}
return f, true
case string:
f, err := strconv.ParseFloat(v, 64)
if err != nil {
return 0, false
}
return f, true
case []byte:
s := string(v)
f, err := strconv.ParseFloat(s, 64)
if err != nil {
return 0, false
}
return f, true
default:
return 0, false
}
}
// min retourne le minimum entre deux float64
func min(a, b float64) float64 {
if a < b {
return a
}
return b
}
// max retourne le maximum entre deux float64
func max(a, b float64) float64 {
if a > b {
return a
}
return b
}
+446
View File
@@ -0,0 +1,446 @@
// ============================================
// handlers/profile_handlers.go - VERSION CORRIGÉE
// ============================================
// Gestion des modifications de profils
package handlers
import (
"log"
"net/http"
"strconv"
"strings"
"github.com/gin-gonic/gin"
"golang.org/x/crypto/bcrypt"
"gestion/db"
"gestion/models"
)
// ============================================
// MODIFICATION PROFIL CLIENT (PAR LE CLIENT)
// ============================================
// UpdateMyProfile permet à un client de modifier son propre profil
// PUT /api/v1/profile/update
func UpdateMyProfile(c *gin.Context) {
clientID := c.GetInt("client_id")
database := c.MustGet("database").(*db.Database)
var req models.UpdateClientProfileRequest
if err := c.ShouldBindJSON(&req); err != nil {
log.Printf("❌ [UPDATE_MY_PROFILE] Erreur binding: %v", err)
c.JSON(http.StatusBadRequest, gin.H{
"error": "Données invalides",
"details": err.Error(),
})
return
}
// Récupérer le client actuel
client, err := database.GetClientByID(clientID)
if err != nil {
log.Printf("❌ [UPDATE_MY_PROFILE] Client non trouvé: ID=%d", clientID)
c.JSON(http.StatusNotFound, gin.H{"error": "Client non trouvé"})
return
}
// Vérifier si des modifications sont demandées
hasChanges := false
// Mise à jour du username
if req.Username != "" && req.Username != client.Username {
// Vérifier que le nouveau username n'existe pas
if existingClient, _ := database.GetClientByUsername(req.Username); existingClient != nil {
log.Printf("❌ [UPDATE_MY_PROFILE] Username déjà utilisé: %s", req.Username)
c.JSON(http.StatusConflict, gin.H{"error": "Nom d'utilisateur déjà utilisé"})
return
}
client.Username = req.Username
hasChanges = true
}
// Mise à jour du mot de passe
if req.Password != "" {
if len(req.Password) < 8 {
c.JSON(http.StatusBadRequest, gin.H{"error": "Le mot de passe doit contenir au moins 8 caractères"})
return
}
hashed, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost)
if err != nil {
log.Printf("❌ [UPDATE_MY_PROFILE] Erreur bcrypt: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur traitement mot de passe"})
return
}
client.Password = string(hashed)
hasChanges = true
}
// Mise à jour du nom
if req.Nom != "" && req.Nom != client.Nom {
if len(strings.TrimSpace(req.Nom)) < 2 {
c.JSON(http.StatusBadRequest, gin.H{"error": "Le nom doit contenir au moins 2 caractères"})
return
}
client.Nom = strings.TrimSpace(req.Nom)
hasChanges = true
}
// Mise à jour du prénom
if req.Prenom != "" && req.Prenom != client.Prenom {
if len(strings.TrimSpace(req.Prenom)) < 2 {
c.JSON(http.StatusBadRequest, gin.H{"error": "Le prénom doit contenir au moins 2 caractères"})
return
}
client.Prenom = strings.TrimSpace(req.Prenom)
hasChanges = true
}
// Mise à jour du téléphone
if req.Telephone != "" && req.Telephone != client.Telephone {
if !validatePhoneNumber(req.Telephone) {
log.Printf("❌ [UPDATE_MY_PROFILE] Téléphone invalide: %s", req.Telephone)
c.JSON(http.StatusBadRequest, gin.H{"error": "Numéro de téléphone invalide"})
return
}
normalizedPhone := normalizePhoneNumber(req.Telephone)
// Vérifier que le téléphone n'est pas déjà utilisé
if existingClient, _ := database.GetClientByTelephone(normalizedPhone); existingClient != nil && existingClient.ID != clientID {
log.Printf("❌ [UPDATE_MY_PROFILE] Téléphone déjà utilisé: %s", normalizedPhone)
c.JSON(http.StatusConflict, gin.H{"error": "Ce numéro de téléphone est déjà utilisé"})
return
}
client.Telephone = normalizedPhone
hasChanges = true
}
if !hasChanges {
c.JSON(http.StatusOK, gin.H{
"message": "Aucune modification détectée",
"client": sanitizeClient(client),
})
return
}
// Sauvegarder les modifications
if err := database.UpdateClient(client); err != nil {
log.Printf("❌ [UPDATE_MY_PROFILE] Erreur mise à jour: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur lors de la mise à jour"})
return
}
log.Printf("✅ [UPDATE_MY_PROFILE] Profil mis à jour: %s (ID=%d)", client.Username, client.ID)
c.JSON(http.StatusOK, gin.H{
"message": "Profil mis à jour avec succès",
"client": sanitizeClient(client),
})
}
// ============================================
// MODIFICATION PROFIL CLIENT (PAR ADMIN)
// ============================================
// UpdateClientByAdmin permet à un admin de modifier n'importe quel profil client
// PUT /api/v2/admin/protected/clients/:id
func UpdateClientByAdmin(c *gin.Context) {
database := c.MustGet("database").(*db.Database)
role, exists := c.Get("user_role")
if !exists || role != "admin" {
c.JSON(http.StatusForbidden, gin.H{
"error": "Accès réservé aux administrateurs",
})
return
}
// Récupérer l'ID du client à modifier
clientIDStr := c.Param("id")
clientID, err := strconv.Atoi(clientIDStr)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "ID client invalide"})
return
}
var req models.AdminUpdateClientRequest
if err := c.ShouldBindJSON(&req); err != nil {
log.Printf("❌ [UPDATE_CLIENT_ADMIN] Erreur binding: %v", err)
c.JSON(http.StatusBadRequest, gin.H{
"error": "Données invalides",
"details": err.Error(),
})
return
}
// ✅ LOG DEBUG - Voir ce qui est reçu
log.Printf("📝 [UPDATE_CLIENT_ADMIN] Requête reçue: %+v", req)
// Récupérer le client actuel
client, err := database.GetClientByID(clientID)
if err != nil {
log.Printf("❌ [UPDATE_CLIENT_ADMIN] Client non trouvé: ID=%d", clientID)
c.JSON(http.StatusNotFound, gin.H{"error": "Client non trouvé"})
return
}
// ✅ LOG DEBUG - État initial
log.Printf("📊 [UPDATE_CLIENT_ADMIN] État initial - Command: %d, Point: %d, PointZipette: %d, Amende: %.2f",
client.Command, client.Point, client.PointZipette, client.Amende)
// Vérifier si des modifications sont demandées
hasChanges := false
// Mise à jour du username
if req.Username != "" && req.Username != client.Username {
// Vérifier que le nouveau username n'existe pas
if existingClient, _ := database.GetClientByUsername(req.Username); existingClient != nil {
log.Printf("❌ [UPDATE_CLIENT_ADMIN] Username déjà utilisé: %s", req.Username)
c.JSON(http.StatusConflict, gin.H{"error": "Nom d'utilisateur déjà utilisé"})
return
}
client.Username = req.Username
hasChanges = true
log.Printf("✏️ [UPDATE_CLIENT_ADMIN] Username modifié: %s", req.Username)
}
// Mise à jour du mot de passe
if req.Password != "" {
if len(req.Password) < 8 {
c.JSON(http.StatusBadRequest, gin.H{"error": "Le mot de passe doit contenir au moins 8 caractères"})
return
}
hashed, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost)
if err != nil {
log.Printf("❌ [UPDATE_CLIENT_ADMIN] Erreur bcrypt: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur traitement mot de passe"})
return
}
client.Password = string(hashed)
hasChanges = true
log.Printf("✏️ [UPDATE_CLIENT_ADMIN] Mot de passe modifié")
}
// Mise à jour du nom
if req.Nom != "" && req.Nom != client.Nom {
client.Nom = strings.TrimSpace(req.Nom)
hasChanges = true
log.Printf("✏️ [UPDATE_CLIENT_ADMIN] Nom modifié: %s", req.Nom)
}
// Mise à jour du prénom
if req.Prenom != "" && req.Prenom != client.Prenom {
client.Prenom = strings.TrimSpace(req.Prenom)
hasChanges = true
log.Printf("✏️ [UPDATE_CLIENT_ADMIN] Prénom modifié: %s", req.Prenom)
}
// Mise à jour du téléphone
if req.Telephone != "" && req.Telephone != client.Telephone {
if !validatePhoneNumber(req.Telephone) {
log.Printf("❌ [UPDATE_CLIENT_ADMIN] Téléphone invalide: %s", req.Telephone)
c.JSON(http.StatusBadRequest, gin.H{"error": "Numéro de téléphone invalide"})
return
}
normalizedPhone := normalizePhoneNumber(req.Telephone)
// Vérifier que le téléphone n'est pas déjà utilisé
if existingClient, _ := database.GetClientByTelephone(normalizedPhone); existingClient != nil && existingClient.ID != clientID {
log.Printf("❌ [UPDATE_CLIENT_ADMIN] Téléphone déjà utilisé: %s", normalizedPhone)
c.JSON(http.StatusConflict, gin.H{"error": "Ce numéro de téléphone est déjà utilisé"})
return
}
client.Telephone = normalizedPhone
hasChanges = true
log.Printf("✏️ [UPDATE_CLIENT_ADMIN] Téléphone modifié: %s", normalizedPhone)
}
// ✅ CORRECTION: Mise à jour du compteur de commandes (Admin uniquement)
// Vérifier explicitement si le champ est présent (même si valeur = 0)
if req.Command != nil && *req.Command != client.Command {
client.Command = *req.Command
hasChanges = true
log.Printf("✏️ [UPDATE_CLIENT_ADMIN] Commandes modifiées: %d → %d", client.Command, *req.Command)
}
// ✅ CORRECTION: Mise à jour des points weed/hash (Admin uniquement)
if req.Point != nil && *req.Point != client.Point {
client.Point = *req.Point
hasChanges = true
log.Printf("✏️ [UPDATE_CLIENT_ADMIN] Points Weed modifiés: %d → %d", client.Point, *req.Point)
}
// ✅ CORRECTION CRITIQUE: Mise à jour des points zipette (Admin uniquement)
if req.PointsZipette != nil && *req.PointsZipette != client.PointZipette {
client.PointZipette = *req.PointsZipette
hasChanges = true
log.Printf("✏️ [UPDATE_CLIENT_ADMIN] Points Zipette modifiés: %d → %d", client.PointZipette, *req.PointsZipette)
}
// ✅ CORRECTION: Mise à jour des amendes (Admin uniquement)
if req.Amende != nil && *req.Amende != client.Amende {
client.Amende = *req.Amende
hasChanges = true
log.Printf("✏️ [UPDATE_CLIENT_ADMIN] Amendes modifiées: %.2f → %.2f", client.Amende, *req.Amende)
}
if !hasChanges {
log.Printf("️ [UPDATE_CLIENT_ADMIN] Aucune modification détectée pour client ID=%d", clientID)
c.JSON(http.StatusOK, gin.H{
"message": "Aucune modification détectée",
"client": sanitizeClient(client),
})
return
}
// ✅ LOG DEBUG - État avant sauvegarde
log.Printf("📊 [UPDATE_CLIENT_ADMIN] État avant save - Command: %d, Point: %d, PointZipette: %d, Amende: %.2f",
client.Command, client.Point, client.PointZipette, client.Amende)
// Sauvegarder les modifications
if err := database.UpdateClient(client); err != nil {
log.Printf("❌ [UPDATE_CLIENT_ADMIN] Erreur mise à jour: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur lors de la mise à jour"})
return
}
log.Printf("✅ [UPDATE_CLIENT_ADMIN] Client mis à jour par admin: %s (ID=%d)", client.Username, client.ID)
c.JSON(http.StatusOK, gin.H{
"message": "Client mis à jour avec succès",
"client": sanitizeClient(client),
})
}
// ============================================
// MODIFICATION PROFIL USER (PAR ADMIN)
// ============================================
// UpdateUserByAdmin permet à un admin de modifier n'importe quel profil user
// PUT /api/v2/admin/protected/users/:id
func UpdateUserByAdmin(c *gin.Context) {
database := c.MustGet("database").(*db.Database)
// Récupérer l'ID de l'utilisateur à modifier
userIDStr := c.Param("id")
userID, err := strconv.Atoi(userIDStr)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "ID utilisateur invalide"})
return
}
var req models.UpdateUserProfileRequest
if err := c.ShouldBindJSON(&req); err != nil {
log.Printf("❌ [UPDATE_USER_ADMIN] Erreur binding: %v", err)
c.JSON(http.StatusBadRequest, gin.H{
"error": "Données invalides",
"details": err.Error(),
})
return
}
// Récupérer l'utilisateur actuel
user, err := database.GetUserByID(userID)
if err != nil {
log.Printf("❌ [UPDATE_USER_ADMIN] User non trouvé: ID=%d", userID)
c.JSON(http.StatusNotFound, gin.H{"error": "Utilisateur non trouvé"})
return
}
// Vérifier si des modifications sont demandées
hasChanges := false
// Mise à jour du username
if req.Username != "" && req.Username != user.Username {
// Vérifier que le nouveau username n'existe pas
if existingUser, _ := database.GetUserByUsername(req.Username); existingUser != nil {
log.Printf("❌ [UPDATE_USER_ADMIN] Username déjà utilisé: %s", req.Username)
c.JSON(http.StatusConflict, gin.H{"error": "Nom d'utilisateur déjà utilisé"})
return
}
user.Username = req.Username
hasChanges = true
}
// Mise à jour du mot de passe
if req.Password != "" {
if len(req.Password) < 8 {
c.JSON(http.StatusBadRequest, gin.H{"error": "Le mot de passe doit contenir au moins 8 caractères"})
return
}
hashed, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost)
if err != nil {
log.Printf("❌ [UPDATE_USER_ADMIN] Erreur bcrypt: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur traitement mot de passe"})
return
}
user.Password = string(hashed)
hasChanges = true
}
// Mise à jour du rôle
if req.Role != "" && req.Role != user.Role {
// Valider le rôle
validRoles := map[string]bool{
"admin": true,
"cabine": true,
"livreur": true,
}
if !validRoles[req.Role] {
c.JSON(http.StatusBadRequest, gin.H{
"error": "Rôle invalide. Valeurs acceptées: admin, cabine, livreur",
})
return
}
user.Role = req.Role
hasChanges = true
}
if !hasChanges {
c.JSON(http.StatusOK, gin.H{
"message": "Aucune modification détectée",
"user": sanitizeUser(user),
})
return
}
// Sauvegarder les modifications
if err := database.UpdateUser(user); err != nil {
log.Printf("❌ [UPDATE_USER_ADMIN] Erreur mise à jour: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur lors de la mise à jour"})
return
}
log.Printf("✅ [UPDATE_USER_ADMIN] User mis à jour par admin: %s (ID=%d, Role=%s)", user.Username, user.ID, user.Role)
c.JSON(http.StatusOK, gin.H{
"message": "Utilisateur mis à jour avec succès",
"user": sanitizeUser(user),
})
}
// ============================================
// UTILITAIRES
// ============================================
func sanitizeClient(client *models.Client) gin.H {
return gin.H{
"id": client.ID,
"username": client.Username,
"nom": client.Nom,
"prenom": client.Prenom,
"telephone": client.Telephone,
"command": client.Command,
"point": client.Point,
"points_zipette": client.PointZipette, // ✅ AJOUTÉ
"amende": client.Amende,
}
}
func sanitizeUser(user *models.User) gin.H {
return gin.H{
"id": user.ID,
"username": user.Username,
"role": user.Role,
}
}
@@ -0,0 +1,409 @@
// ============================================
// handlers/delivery_validation_handler.go - CLEAN VERSION
// VALIDATION LIVRAISON AVEC VÉRIFICATION PROXIMITÉ GPS
//
// ⚠️ IMPORTANT: Ce fichier contient UNIQUEMENT les fonctions livreur
// Les fonctions ADMIN sont dans cabine_handlers.go
// ============================================
package handlers
import (
"encoding/json"
"fmt"
"gestion/db"
"gestion/services"
"log"
"net/http"
"strconv"
"github.com/gin-gonic/gin"
)
// ============================================
// CONSTANTES DE CONFIGURATION
// ============================================
const (
// Distance maximale en mètres pour valider une livraison
MAX_DELIVERY_VALIDATION_DISTANCE_METERS = 100 // 100 mètres
// Distance maximale en kilomètres
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",
"details": err.Error(),
})
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: VALIDATION GPS (CRITIQUE)
destLat, _ := command["dest_latitude"].(float64)
destLon, _ := command["dest_longitude"].(float64)
distance := calculateDistance(req.Latitude, req.Longitude, destLat, destLon)
log.Printf("📍 [VALIDATE_LIVREUR] Distance: %.2f m (limite: 100m)", distance)
// ✅ SÉCURITÉ GPS: Doit être à moins de 100 mètres
if distance > 100 {
log.Printf("❌ [VALIDATE_LIVREUR] Trop loin! Distance: %.2f m", distance)
c.JSON(http.StatusBadRequest, gin.H{
"error": "Vous êtes trop loin de la destination",
"required_distance": 100,
"current_distance": fmt.Sprintf("%.2f", distance),
"unit": "meters",
"destination_coords": gin.H{
"latitude": destLat,
"longitude": destLon,
},
"your_coords": gin.H{
"latitude": req.Latitude,
"longitude": req.Longitude,
},
})
return
}
log.Printf("✅ [VALIDATE_LIVREUR] GPS VALIDÉ - Distance: %.2f m < 100m", distance)
// ✅ É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",
"details": err.Error(),
})
return
}
// ✅ ÉTAPE 6: Ajouter un log
database.AddCommandLog(commandID, "livre",
fmt.Sprintf("Livraison confirmée par livreur - Distance: %.2f m", distance),
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",
"distance": fmt.Sprintf("%.2f", distance),
"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)
// ============================================
// StartDelivery permet au livreur de démarrer une livraison (passage en in_transit)
// POST /api/v1/deliveries/:id/start
func StartDelivery(c *gin.Context) {
database := c.MustGet("database").(*db.Database)
username, exists := c.Get("username")
if !exists || c.GetString("role") != "livreur" {
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 invalide"})
return
}
var req struct {
Latitude float64 `json:"latitude"`
Longitude float64 `json:"longitude"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{
"error": "Coordonnées GPS requises",
"details": err.Error(),
})
return
}
log.Printf("🚗 [START] %s démarre livraison cmd %d", usernameStr, commandID)
command, err := database.GetCommandByID(commandID)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Commande non trouvée"})
return
}
// Vérifier propriété
livreurAssign, _ := command["livreur_assign"].(string)
if livreurAssign != usernameStr {
c.JSON(http.StatusForbidden, gin.H{
"error": "Cette commande ne vous est pas assignée",
})
return
}
// Vérifier le statut actuel
currentStatus, _ := command["status"].(string)
if currentStatus != "support" && currentStatus != "assigned" {
c.JSON(http.StatusBadRequest, gin.H{
"error": "Impossible de démarrer cette livraison",
"current_status": currentStatus,
"message": "La commande doit être en statut 'support' ou 'assigned'",
})
return
}
// Mettre à jour le statut en "en_route"
if err := database.UpdateCommandStatus(commandID, "en_route"); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{
"error": "Erreur mise à jour statut",
"details": err.Error(),
})
return
}
// Mettre à jour la position du livreur
database.UpdateLivreurPosition(usernameStr, req.Latitude, req.Longitude, "busy")
// Mettre à jour le statut du livreur
database.SetDeliveryPersonStatus(usernameStr, "busy", commandID)
// Ajouter un log
database.AddCommandLog(commandID, "en_route",
fmt.Sprintf("Livraison démarrée par %s", usernameStr),
usernameStr)
log.Printf("✅ [START] Livraison %d démarrée", commandID)
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "Livraison démarrée",
"command_id": commandID,
"status": "en_route",
})
}
// ============================================
// HELPERS
// ============================================
func maxInt(a, b int) int {
if a > b {
return a
}
return b
}