1005 lines
26 KiB
Go
1005 lines
26 KiB
Go
package handlers
|
|
|
|
import (
|
|
"fmt"
|
|
"gestion/db"
|
|
"gestion/models"
|
|
"gestion/services"
|
|
"gestion/utils"
|
|
"io"
|
|
"log"
|
|
"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)
|
|
}
|
|
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)
|
|
}
|
|
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)
|
|
}
|
|
|
|
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
|
|
}
|