Files
projet_gestion_commande/backend/gestion/handlers/product.go
T
Xor290 82f9a2fae9
Backend - Build & Lint / build (push) Canceled after 11m16s
Frontend Admin - EAS Build / build (push) Canceled after 0s
Frontend Client - EAS Build / build (push) Canceled after 0s
Frontend Web - Build & Lint / build (push) Canceled after 0s
chore: build
2026-09-08 17:44:28 +02:00

1038 lines
27 KiB
Go

package handlers
import (
"fmt"
"gestion/db"
"gestion/models"
"gestion/services"
"gestion/utils"
"io"
"log"
"math"
"mime/multipart"
"net/http"
"strconv"
"strings"
"github.com/gabriel-vasile/mimetype"
"github.com/gin-gonic/gin"
)
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
)
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,
}
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 float64, 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 validateUnit(unit string) error {
validUnits := map[string]bool{
"u": true, // unité
"kg": true, // kilogramme
"g": true, // gramme
"bag": true, // sac
"l": true, // litre
"cl": true, // centilitre
"pcs": true, // pièces
}
if !validUnits[unit] {
return fmt.Errorf("unité invalide : valeurs acceptées : u, kg, g, bag, l, cl, pcs")
}
return nil
}
func validateCategory(database *db.Database, category string) error {
if category == "" {
return fmt.Errorf("catégorie requise")
}
exists, err := database.CategoryExists(category)
if err != nil {
return fmt.Errorf("erreur vérification catégorie")
}
if !exists {
return fmt.Errorf("catégorie invalide")
}
return nil
}
func validateFileMimeType(fileHeader *multipart.FileHeader) (string, error) {
file, err := fileHeader.Open()
if err != nil {
return "", err
}
defer file.Close()
mtype, err := mimetype.DetectReader(file)
if err != nil {
return "", err
}
mimeType := mtype.String()
if idx := strings.Index(mimeType, ";"); idx != -1 {
mimeType = strings.TrimSpace(mimeType[:idx])
}
if !allowedMimeTypes[mimeType] {
return "", fmt.Errorf("type de fichier non autorisé: %s", mimeType)
}
return mimeType, nil
}
func CreateProduct(c *gin.Context) {
database := c.MustGet("database").(*db.Database)
role := c.GetString("role")
if role != "admin" && role != "cabine" {
c.JSON(http.StatusForbidden, gin.H{"error": "Accès refusé"})
return
}
username, _ := safeGetUsername(c)
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
}
name := strings.TrimSpace(c.PostForm("name"))
category := strings.TrimSpace(c.PostForm("category"))
description := strings.TrimSpace(c.PostForm("description"))
stockStr := c.PostForm("stock")
unit := strings.ToLower(strings.TrimSpace(c.PostForm("unit")))
if unit == "" {
unit = "u"
}
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
}
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(database, category); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if err := validateUnit(unit); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
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
}
prices := []models.ProductPrice{}
priceIndex := 0
for priceIndex < 100 {
quantityKey := fmt.Sprintf("prices[%d][quantity]", priceIndex)
priceKey := fmt.Sprintf("prices[%d][price]", priceIndex)
activePriceKey := fmt.Sprintf("prices[%d][active_price]", priceIndex)
quantityStr := c.PostForm(quantityKey)
priceStr := c.PostForm(priceKey)
if quantityStr == "" || priceStr == "" {
break
}
quantity, err := strconv.ParseFloat(quantityStr, 64)
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
}
activePriceStr := c.PostForm(activePriceKey)
activePrice := activePriceStr != "false"
prices = append(prices, models.ProductPrice{
Quantity: quantity,
Price: price,
ActivePrice: activePrice,
})
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)
comingSoon := c.PostForm("coming_soon") == "true"
// ✅ CRÉER LE PRODUIT
product := models.Product{
Name: name,
Category: category,
Description: description,
Stock: stock,
Unit: unit,
ComingSoon: comingSoon,
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
}
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 := []models.Media{}
storage := c.MustGet("storage").(services.Storage)
var totalSize int64 = 0
for i, fileHeader := range files {
if fileHeader.Size > MaxFileSize {
rollbackFiles(storage, 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
if totalSize > MaxTotalUploadSize {
rollbackFiles(storage, 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)
mimeType, err := validateFileMimeType(fileHeader)
if err != nil {
log.Printf("❌ [CreateProduct] Type MIME invalide: %v", err)
rollbackFiles(storage, savedFiles)
database.DeleteProduct(product.ID)
c.JSON(http.StatusBadRequest, gin.H{"error": "Type de fichier non autorisé"})
return
}
var mediaType string
if strings.HasPrefix(mimeType, "image/") {
mediaType = "image"
} else if strings.HasPrefix(mimeType, "video/") {
mediaType = "video"
} else {
rollbackFiles(storage, savedFiles)
database.DeleteProduct(product.ID)
c.JSON(http.StatusBadRequest, gin.H{"error": "Type de média non supporté"})
return
}
uniqueFileName := utils.GenerateUniqueFileName(cleanProductName, fileHeader.Filename)
mediaURL, mediaKey, err := storage.Upload(fileHeader, mediaType+"s", uniqueFileName)
if err != nil {
log.Printf("❌ [CreateProduct] Erreur sauvegarde: %v", err)
rollbackFiles(storage, savedFiles)
database.DeleteProduct(product.ID)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur sauvegarde fichier"})
return
}
savedFiles = append(savedFiles, models.Media{URL: mediaURL, Key: mediaKey})
media := models.Media{
ProductID: product.ID,
Type: mediaType,
URL: mediaURL,
Key: mediaKey,
}
if err := database.CreateMedia(&media); err != nil {
log.Printf("❌ [CreateProduct] Erreur DB média: %v", err)
rollbackFiles(storage, 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,
})
}
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
}
role := c.GetString("role")
if role != "admin" && role != "cabine" {
products = filterActivePrices(products)
}
products = applyPromotions(products, database)
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")))
if err := validateCategory(database, 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
}
for i := range products {
media, _ := database.GetMediaByProductID(products[i].ID)
products[i].Media = media
}
roleCtx := c.GetString("role")
if roleCtx != "admin" && roleCtx != "cabine" {
products = filterActivePrices(products)
}
products = applyPromotions(products, database)
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
}
media, _ := database.GetMediaByProductID(product.ID)
product.Media = media
role := c.GetString("role")
if role != "admin" && role != "cabine" {
filterActivepricesSingle(&product)
}
applyPromotionsSingle(&product, database)
c.JSON(http.StatusOK, gin.H{
"success": true,
"data": product,
})
}
func UpdateProduct(c *gin.Context) {
database := c.MustGet("database").(*db.Database)
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
}
_, 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"`
Unit string `json:"unit"`
Prices []models.ProductPrice `json:"prices"`
Stock *float64 `json:"stock"`
ComingSoon *bool `json:"coming_soon"`
}
if err := c.ShouldBindJSON(&updateData); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Données invalides"})
return
}
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(database, updateData.Category); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if updateData.Unit == "" {
updateData.Unit = "u"
}
if err := validateUnit(updateData.Unit); 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
}
}
if updateData.Stock != nil {
if err := validateStock(*updateData.Stock); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
}
log.Printf("🔄 [UpdateProduct] %s met à jour produit #%d", username, id)
comingSoon := false
if updateData.ComingSoon != nil {
comingSoon = *updateData.ComingSoon
}
if err := database.UpdateProduct(id, updateData.Name, updateData.Category, updateData.Description, updateData.Unit, comingSoon, updateData.Prices); err != nil {
log.Printf("❌ [UpdateProduct] Erreur UPDATE: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur mise à jour"})
return
}
if updateData.Stock != nil {
if err := database.SetProductStock(id, *updateData.Stock); err != nil {
log.Printf("⚠️ [UpdateProduct] Erreur mise à jour stock: %v", err)
}
}
// ✅ RÉCUPÉRER LE PRODUIT MIS À JOUR
updatedProduct, _ := database.GetProductByID(id)
media, _ := database.GetMediaByProductID(id)
updatedProduct.Media = media
log.Printf("✅ [UpdateProduct] Produit #%d mis à jour", id)
c.JSON(http.StatusOK, gin.H{
"success": true,
"product": updatedProduct,
})
}
func UpdateStock(c *gin.Context) {
database := c.MustGet("database").(*db.Database)
role := c.GetString("role")
if role != "admin" {
c.JSON(http.StatusForbidden, gin.H{"error": "Accès refusé"})
return
}
username, _ := safeGetUsername(c)
id, err := strconv.Atoi(c.Param("id"))
if err != nil || id <= 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "ID invalide"})
return
}
// ✅ VÉRIFIER QUE LE PRODUIT EXISTE
_, err = database.GetProductByID(id)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Produit non trouvé"})
return
}
var req struct {
Stock float64 `json:"stock"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Données invalides"})
return
}
if err := validateStock(req.Stock); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
reserved, err := database.GetReservedQuantityInBaskets(id)
if err != nil {
utils.ServerErr(c, "Erreur lecture réservations", err)
return
}
if req.Stock+reserved < reserved {
c.JSON(http.StatusBadRequest, gin.H{"error": "Stock invalide"})
return
}
log.Printf("🔄 [UpdateStock] %s met à jour le stock #%d (réservé en paniers: %.3f)", username, id, reserved)
if err := database.SetProductStock(id, req.Stock); err != nil {
log.Printf("❌ [UpdateProduct] Erreur UPDATE: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur mise à jour"})
return
}
updatedProduct, _ := database.GetProductByID(id)
media, _ := database.GetMediaByProductID(id)
updatedProduct.Media = media
log.Printf("✅ [UpdateStock] le stock #%d est mis à jour", id)
c.JSON(http.StatusOK, gin.H{
"success": true,
"product": updatedProduct,
"reserved_in_baskets": reserved,
})
}
func DeleteMedia(c *gin.Context) {
database := c.MustGet("database").(*db.Database)
s3Service := c.MustGet("s3Service").(*services.S3Service)
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
}
if err := database.DeleteMedia(mediaID); err != nil {
log.Printf("❌ [DeleteMedia] Erreur suppression DB: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur suppression"})
return
}
if media.Key != "" {
if err := s3Service.DeleteFile(media.Key); err != nil {
log.Printf("⚠️ [DeleteMedia] Fichier non supprimé sur RustFS (clé: %s): %v", media.Key, err)
} else {
log.Printf("✅ [DeleteMedia] Fichier supprimé sur RustFS: %s", media.Key)
}
} else {
localStorage := services.NewLocalStorage("uploads")
if err := localStorage.Delete(media.URL, ""); err != nil {
log.Printf("⚠️ [DeleteMedia] Fichier local non supprimé (%s): %v", media.URL, err)
} else {
log.Printf("✅ [DeleteMedia] Fichier local supprimé: %s", media.URL)
}
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "Média supprimé",
})
}
func UploadMedia(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
}
role := c.GetString("role")
if role != "admin" {
c.JSON(http.StatusForbidden, gin.H{"error": "Accès refusé"})
return
}
productID, err := strconv.Atoi(c.Param("id"))
if err != nil || productID <= 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "ID produit invalide"})
return
}
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)
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
}
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
}
detectedMime, err := validateFileMimeType(file)
if err != nil {
log.Printf("❌ [UploadMedia] Type MIME invalide: %v", err)
c.JSON(http.StatusBadRequest, gin.H{"error": "Type de fichier non autorisé"})
return
}
log.Printf("📋 [UploadMedia] Type MIME détecté: %s", detectedMime)
if fileType == "image" && !strings.HasPrefix(detectedMime, "image/") {
c.JSON(http.StatusBadRequest, gin.H{"error": "Le fichier n'est pas une image valide"})
return
}
if fileType == "video" && !strings.HasPrefix(detectedMime, "video/") {
c.JSON(http.StatusBadRequest, gin.H{"error": "Le fichier n'est pas une vidéo valide"})
return
}
cleanProductName := cleanFileName(productName)
uniqueFileName := utils.GenerateUniqueFileName(cleanProductName, file.Filename)
storage := c.MustGet("storage").(services.Storage)
folder := fileType + "s"
mediaURL, mediaKey, err := storage.Upload(file, folder, uniqueFileName)
if err != nil {
log.Printf("❌ [UploadMedia] Erreur upload: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur upload fichier"})
return
}
log.Printf("✅ [UploadMedia] Fichier uploadé: %s", mediaURL)
media := models.Media{
ProductID: productID,
Type: fileType,
URL: mediaURL,
Key: mediaKey,
}
err = database.CreateMedia(&media)
if err != nil {
if delErr := storage.Delete(mediaURL, mediaKey); delErr != nil {
log.Printf("⚠️ [UploadMedia] Échec rollback (%s): %v", mediaURL, delErr)
}
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,
},
})
}
func ServeMedia(c *gin.Context) {
s3Service := c.MustGet("s3Service").(*services.S3Service)
key := strings.TrimPrefix(c.Param("key"), "/")
if key == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "Clé manquante"})
return
}
body, contentType, err := s3Service.GetFile(c.Request.Context(), key)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Média non trouvé"})
return
}
defer body.Close()
c.Header("Content-Type", contentType)
c.Header("Cache-Control", "public, max-age=31536000, immutable")
c.Status(http.StatusOK)
io.Copy(c.Writer, body)
}
func ActivePrice(c *gin.Context) {
database := c.MustGet("database").(*db.Database)
role := c.GetString("role")
if role != "admin" {
c.JSON(http.StatusForbidden, gin.H{"error": "Accès refusé"})
return
}
id, err := strconv.Atoi(c.Param("id"))
if err != nil || id <= 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "ID invalide"})
return
}
if err := database.AddActivePrice(id); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"message": "Prix activé avec succès"})
}
func DesActivePrice(c *gin.Context) {
database := c.MustGet("database").(*db.Database)
role := c.GetString("role")
if role != "admin" {
c.JSON(http.StatusForbidden, gin.H{"error": "Accès refusé"})
return
}
id, err := strconv.Atoi(c.Param("id"))
if err != nil || id <= 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "ID invalide"})
return
}
if err := database.DeActivePrice(id); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"message": "Prix désactivé avec succès"})
}
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)
mediaList, err := database.GetMediaByProductID(id)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur récupération médias"})
return
}
s3Service := c.MustGet("s3Service").(*services.S3Service)
localStorage := services.NewLocalStorage("uploads")
for _, media := range mediaList {
if media.Key != "" {
if err := s3Service.DeleteFile(media.Key); err != nil {
log.Printf("⚠️ [DeleteProduct] Fichier non supprimé sur RustFS (clé: %s): %v", media.Key, err)
}
} else {
if err := localStorage.Delete(media.URL, ""); err != nil {
log.Printf("⚠️ [DeleteProduct] Erreur suppression locale: %v", err)
}
}
}
database.DeleteMediaByProductID(id)
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é",
})
}
func rollbackFiles(storage services.Storage, files []models.Media) {
for _, f := range files {
if err := storage.Delete(f.URL, f.Key); err != nil {
log.Printf("⚠️ [rollbackFiles] Erreur suppression %s: %v", f.URL, err)
}
}
}
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
}
func filterActivePrices(products []models.Product) []models.Product {
for i := range products {
activePrices := []models.ProductPrice{}
for _, p := range products[i].Prices {
if p.ActivePrice {
activePrices = append(activePrices, p)
}
}
products[i].Prices = activePrices
}
return products
}
func filterActivepricesSingle(product *models.Product) {
activePrices := []models.ProductPrice{}
for _, p := range product.Prices {
if p.ActivePrice {
activePrices = append(activePrices, p)
}
}
product.Prices = activePrices
}
// applyPromotions annote chaque palier de prix éligible avec le prix promo
// (PromoPrice/PromoPercent) si une promotion couvre ce produit/quantité —
// affichage seulement, le prix catalogue (Price) n'est jamais modifié ici ;
// le prix réellement facturé est recalculé indépendamment dans AddToBasket.
func applyPromotions(products []models.Product, database *db.Database) []models.Product {
settings, err := database.GetSettings()
if err != nil || !settings.PromotionsEnabled {
return products
}
for i := range products {
for j := range products[i].Prices {
pr := &products[i].Prices[j]
discount, ok := db.ResolvePromotionDiscount(&settings, products[i].ID, products[i].Category, pr.Quantity)
if !ok {
continue
}
promoPrice := math.Round(pr.Price*(1-discount/100)*100) / 100
pr.PromoPrice = &promoPrice
pr.PromoPercent = discount
}
}
return products
}
func applyPromotionsSingle(product *models.Product, database *db.Database) {
products := applyPromotions([]models.Product{*product}, database)
*product = products[0]
}