From 53b16cc0bc8f44973ae1c71b69aaa5d18dcbc5b2 Mon Sep 17 00:00:00 2001 From: hanif salafi Date: Wed, 28 Jan 2026 08:39:11 +0700 Subject: [PATCH] feat: fixing update spesification product --- app/database/entity/products.entity.go | 24 +- .../controller/products.controller.go | 4 +- app/module/products/mapper/products.mapper.go | 72 ++-- .../products/request/products.request.go | 14 +- .../products/response/products.response.go | 7 + .../products/service/products.service.go | 373 +++++++++++++++++- 6 files changed, 447 insertions(+), 47 deletions(-) diff --git a/app/database/entity/products.entity.go b/app/database/entity/products.entity.go index 0698156..5f1e76e 100644 --- a/app/database/entity/products.entity.go +++ b/app/database/entity/products.entity.go @@ -5,16 +5,16 @@ import ( ) type Products struct { - ID uint `json:"id" gorm:"primaryKey;type:int4;autoIncrement"` - Title string `json:"title" gorm:"type:varchar"` - Variant *string `json:"variant" gorm:"type:varchar"` - Price *string `json:"price" gorm:"type:varchar"` - ThumbnailPath *string `json:"thumbnail_path" gorm:"type:varchar"` - Colors *string `json:"colors" gorm:"type:text"` // JSON array stored as text - Status *string `json:"status" gorm:"type:varchar"` - StatusId *int `json:"status_id" gorm:"type:int4;default:1"` - IsActive *bool `json:"is_active" gorm:"type:bool;default:true"` - CreatedAt time.Time `json:"created_at" gorm:"default:now()"` - UpdatedAt time.Time `json:"updated_at" gorm:"default:now()"` + ID uint `json:"id" gorm:"primaryKey;type:int4;autoIncrement"` + Title string `json:"title" gorm:"type:varchar"` + Variant *string `json:"variant" gorm:"type:varchar"` + Price *string `json:"price" gorm:"type:varchar"` + ThumbnailPath *string `json:"thumbnail_path" gorm:"type:varchar"` + Colors *string `json:"colors" gorm:"type:text"` // JSON array stored as text + Specifications *string `json:"specifications" gorm:"type:text"` // JSON array stored as text + Status *string `json:"status" gorm:"type:varchar"` + StatusId *int `json:"status_id" gorm:"type:int4;default:1"` + IsActive *bool `json:"is_active" gorm:"type:bool;default:true"` + CreatedAt time.Time `json:"created_at" gorm:"default:now()"` + UpdatedAt time.Time `json:"updated_at" gorm:"default:now()"` } - diff --git a/app/module/products/controller/products.controller.go b/app/module/products/controller/products.controller.go index 6c17dd6..05d7086 100644 --- a/app/module/products/controller/products.controller.go +++ b/app/module/products/controller/products.controller.go @@ -229,9 +229,9 @@ func (_i *productsController) Update(c *fiber.Ctx) error { req.Price = &price } - // Handle colors (JSON array string) + // Handle colors (JSON array of objects with name and image_path) if colorsStr := c.FormValue("colors"); colorsStr != "" { - var colors []string + var colors []request.ProductColorRequest if err := json.Unmarshal([]byte(colorsStr), &colors); err == nil { req.Colors = colors } diff --git a/app/module/products/mapper/products.mapper.go b/app/module/products/mapper/products.mapper.go index eed5080..f22974f 100644 --- a/app/module/products/mapper/products.mapper.go +++ b/app/module/products/mapper/products.mapper.go @@ -14,32 +14,57 @@ func ProductsResponseMapper(product *entity.Products, host string) *res.Products var colors []res.ProductColorResponse -if product.Colors != nil && *product.Colors != "" { - var rawColors []struct { - Name string `json:"name"` - ImagePath *string `json:"image_path"` - } - - _ = json.Unmarshal([]byte(*product.Colors), &rawColors) - - for _, c := range rawColors { - var imageUrl *string - - if c.ImagePath != nil { - filename := filepath.Base(*c.ImagePath) - url := host + "/products/viewer/" + filename - imageUrl = &url - + if product.Colors != nil && *product.Colors != "" { + var rawColors []struct { + Name string `json:"name"` + ImagePath *string `json:"image_path"` } - colors = append(colors, res.ProductColorResponse{ - Name: c.Name, - ImagePath: c.ImagePath, - ImageUrl: imageUrl, - }) - } -} + _ = json.Unmarshal([]byte(*product.Colors), &rawColors) + for _, c := range rawColors { + var imageUrl *string + + if c.ImagePath != nil { + filename := filepath.Base(*c.ImagePath) + url := host + "/products/viewer/" + filename + imageUrl = &url + } + + colors = append(colors, res.ProductColorResponse{ + Name: c.Name, + ImagePath: c.ImagePath, + ImageUrl: imageUrl, + }) + } + } + + var specifications []res.ProductSpecificationResponse + + if product.Specifications != nil && *product.Specifications != "" { + var rawSpecs []struct { + Title string `json:"title"` + ImagePaths []string `json:"image_paths"` + } + + _ = json.Unmarshal([]byte(*product.Specifications), &rawSpecs) + + for _, s := range rawSpecs { + var imageUrls []string + + for _, imagePath := range s.ImagePaths { + filename := filepath.Base(imagePath) + url := host + "/products/viewer/" + filename + imageUrls = append(imageUrls, url) + } + + specifications = append(specifications, res.ProductSpecificationResponse{ + Title: s.Title, + ImagePaths: s.ImagePaths, + ImageUrls: imageUrls, + }) + } + } response := &res.ProductsResponse{ ID: product.ID, @@ -48,6 +73,7 @@ if product.Colors != nil && *product.Colors != "" { Price: product.Price, ThumbnailPath: product.ThumbnailPath, Colors: colors, + Specifications: specifications, Status: product.Status, StatusId: product.StatusId, IsActive: product.IsActive, diff --git a/app/module/products/request/products.request.go b/app/module/products/request/products.request.go index 4fd452d..03faab1 100644 --- a/app/module/products/request/products.request.go +++ b/app/module/products/request/products.request.go @@ -95,13 +95,13 @@ func (req ProductsCreateRequest) ToEntity() *entity.Products { type ProductsUpdateRequest struct { - Title *string `json:"title"` - Variant *string `json:"variant"` - Price *string `json:"price"` - ThumbnailPath *string `json:"thumbnail_path"` - Colors []string `json:"colors"` - Status *string `json:"status"` - IsActive *bool `json:"is_active"` + Title *string `json:"title"` + Variant *string `json:"variant"` + Price *string `json:"price"` + ThumbnailPath *string `json:"thumbnail_path"` + Colors []ProductColorRequest `json:"colors"` + Status *string `json:"status"` + IsActive *bool `json:"is_active"` } func (req ProductsUpdateRequest) ToEntity() *entity.Products { diff --git a/app/module/products/response/products.response.go b/app/module/products/response/products.response.go index 3d016d1..1787c77 100644 --- a/app/module/products/response/products.response.go +++ b/app/module/products/response/products.response.go @@ -10,6 +10,12 @@ type ProductColorResponse struct { ImageUrl *string `json:"image_url"` } +type ProductSpecificationResponse struct { + Title string `json:"title"` + ImagePaths []string `json:"image_paths"` + ImageUrls []string `json:"image_urls"` +} + type ProductsResponse struct { ID uint `json:"id"` Title string `json:"title"` @@ -18,6 +24,7 @@ type ProductsResponse struct { ThumbnailPath *string `json:"thumbnail_path"` ThumbnailUrl *string `json:"thumbnail_url"` Colors []ProductColorResponse `json:"colors"` + Specifications []ProductSpecificationResponse `json:"specifications"` Status *string `json:"status"` StatusId *int `json:"status_id"` IsActive *bool `json:"is_active"` diff --git a/app/module/products/service/products.service.go b/app/module/products/service/products.service.go index 701d944..485ec60 100644 --- a/app/module/products/service/products.service.go +++ b/app/module/products/service/products.service.go @@ -144,10 +144,65 @@ func (_i *productsService) uploadColorFile( return &objectName, nil } +func (_i *productsService) uploadSpecFile( + fileHeader *multipart.FileHeader, +) (*string, error) { + + minioClient, err := _i.MinioStorage.ConnectMinio() + if err != nil { + return nil, err + } + + src, err := fileHeader.Open() + if err != nil { + return nil, err + } + defer src.Close() + + now := time.Now() + ext := filepath.Ext(fileHeader.Filename) + name := strings.TrimSuffix(fileHeader.Filename, ext) + name = strings.ReplaceAll(name, " ", "") + + filename := fmt.Sprintf( + "%s_%d%s", + name, + rand.Intn(999999), + ext, + ) + + objectName := fmt.Sprintf( + "products/specifications/%d/%d/%s", + now.Year(), + now.Month(), + filename, + ) + + _, err = minioClient.PutObject( + context.Background(), + _i.MinioStorage.Cfg.ObjectStorage.MinioStorage.BucketName, + objectName, + src, + fileHeader.Size, + minio.PutObjectOptions{}, + ) + if err != nil { + return nil, err + } + + return &objectName, nil +} + func (_i *productsService) Create(c *fiber.Ctx, req request.ProductsCreateRequest) (product *response.ProductsResponse, err error) { + _i.Log.Info(). + Str("title", req.Title). + Interface("colors", req.Colors). + Msg("🚀 Starting Create Product") + // Handle file upload if exists if filePath, uploadErr := _i.UploadFileToMinio(c, "file"); uploadErr == nil && filePath != nil { req.ThumbnailPath = filePath + _i.Log.Info().Str("thumbnailPath", *filePath).Msg("✅ Uploaded thumbnail") } // 🔥 CONVERT REQUEST KE ENTITY @@ -158,6 +213,12 @@ func (_i *productsService) Create(c *fiber.Ctx, req request.ProductsCreateReques // =============================== form, _ := c.MultipartForm() colorFiles := form.File["color_images"] + colorsStr := c.FormValue("colors") + + _i.Log.Info(). + Str("colorsStr", colorsStr). + Int("colorFilesCount", len(colorFiles)). + Msg("🎨 Processing colors") var colorEntities []struct { Name string `json:"name"` @@ -194,19 +255,119 @@ func (_i *productsService) Create(c *fiber.Ctx, req request.ProductsCreateReques bytes, _ := json.Marshal(colorEntities) str := string(bytes) productEntity.Colors = &str + _i.Log.Info(). + Int("colorsCount", len(colorEntities)). + Str("json", str). + Msg("💾 Saved colors to entity") } - // 4️⃣ DEFAULT ACTIVE + // 6️⃣ HANDLE SPECIFICATIONS (as JSON array like colors) + form, _ = c.MultipartForm() + specFiles := form.File["specification_images"] + specificationsStr := c.FormValue("specifications") + + _i.Log.Info(). + Str("specificationsStr", specificationsStr). + Int("specFilesCount", len(specFiles)). + Msg("📦 Processing specifications in Create") + + var specEntities []struct { + Title string `json:"title"` + ImagePaths []string `json:"image_paths"` + } + + if specificationsStr != "" { + var specifications []struct { + Title string `json:"title"` + ImageCount int `json:"imageCount"` + } + if err := json.Unmarshal([]byte(specificationsStr), &specifications); err != nil { + _i.Log.Error().Err(err).Str("specificationsStr", specificationsStr).Msg("❌ Failed to unmarshal specifications") + } else { + _i.Log.Info().Int("specsCount", len(specifications)).Msg("✅ Parsed specifications JSON") + fileIndex := 0 + for specIdx, spec := range specifications { + if spec.Title == "" { + _i.Log.Warn().Int("specIndex", specIdx).Msg("⚠️ Skipping spec with empty title") + continue + } + + specEntity := struct { + Title string `json:"title"` + ImagePaths []string `json:"image_paths"` + }{ + Title: spec.Title, + ImagePaths: []string{}, + } + + _i.Log.Info(). + Int("specIndex", specIdx). + Str("title", spec.Title). + Int("imageCount", spec.ImageCount). + Int("fileIndex", fileIndex). + Msg("📝 Processing spec") + + // Upload files for this specification + imageCount := spec.ImageCount + if imageCount > 0 && fileIndex < len(specFiles) { + for i := 0; i < imageCount && fileIndex < len(specFiles); i++ { + fileHeader := specFiles[fileIndex] + _i.Log.Info(). + Int("fileIndex", fileIndex). + Str("filename", fileHeader.Filename). + Msg("📤 Uploading spec image") + + path, uploadErr := _i.uploadSpecFile(fileHeader) + if uploadErr != nil { + _i.Log.Error().Err(uploadErr).Int("fileIndex", fileIndex).Msg("❌ Failed to upload spec file") + } else if path != nil { + specEntity.ImagePaths = append(specEntity.ImagePaths, *path) + _i.Log.Info().Str("path", *path).Msg("✅ Uploaded spec image") + } + fileIndex++ + } + } + + specEntities = append(specEntities, specEntity) + _i.Log.Info(). + Str("title", specEntity.Title). + Int("imagesCount", len(specEntity.ImagePaths)). + Msg("✅ Added spec entity") + } + } + } else { + _i.Log.Warn().Msg("⚠️ No specifications string in form") + } + + // Save specifications as JSON string + if len(specEntities) > 0 { + bytes, _ := json.Marshal(specEntities) + str := string(bytes) + productEntity.Specifications = &str + _i.Log.Info(). + Int("specsCount", len(specEntities)). + Str("json", str). + Msg("💾 Saved specifications to entity") + } else { + _i.Log.Warn().Msg("⚠️ No spec entities to save") + } + + // 7️⃣ DEFAULT ACTIVE & SAVE TO DB isActive := true productEntity.IsActive = &isActive - // 5️⃣ SIMPAN KE DB productEntity, err = _i.Repo.Create(productEntity) if err != nil { + _i.Log.Error().Err(err).Msg("❌ Failed to create product in DB") return } + _i.Log.Info(). + Uint("productId", productEntity.ID). + Interface("colors", productEntity.Colors). + Interface("specifications", productEntity.Specifications). + Msg("✅ Product created in DB with all data") - // 6️⃣ RESPONSE + // 8️⃣ RESPONSE host := _i.Cfg.App.Domain product = mapper.ProductsResponseMapper(productEntity, host) return @@ -283,8 +444,187 @@ func (_i *productsService) Update(c *fiber.Ctx, id uint, req request.ProductsUpd req.ThumbnailPath = filePath } + // Get existing product to preserve existing colors if no new color images uploaded + existingProduct, err := _i.Repo.FindOne(id) + if err != nil { + return + } + productEntity := req.ToEntity() + // Handle color images if uploaded (similar to Create) + form, _ := c.MultipartForm() + colorFiles := form.File["color_images"] + + // If color images are uploaded, process them + if len(colorFiles) > 0 { + var colorEntities []struct { + Name string `json:"name"` + ImagePath *string `json:"image_path"` + } + + // Use colors from request if available, otherwise preserve existing + var existingColors []struct { + Name string `json:"name"` + ImagePath *string `json:"image_path"` + } + if existingProduct.Colors != nil && *existingProduct.Colors != "" { + _ = json.Unmarshal([]byte(*existingProduct.Colors), &existingColors) + } + + // Process color files and merge with request colors + for i, cReq := range req.Colors { + if cReq.Name == "" { + continue + } + + color := struct { + Name string `json:"name"` + ImagePath *string `json:"image_path"` + }{ + Name: cReq.Name, + } + + // If there's a new file uploaded for this color index, use it + if len(colorFiles) > i { + fileHeader := colorFiles[i] + path, err := _i.uploadColorFile(fileHeader) + if err == nil && path != nil { + color.ImagePath = path + } else if i < len(existingColors) { + // Keep existing image path if upload failed + color.ImagePath = existingColors[i].ImagePath + } + } else if i < len(existingColors) { + // No new file, preserve existing image path + color.ImagePath = existingColors[i].ImagePath + } + + colorEntities = append(colorEntities, color) + } + + // Update colors JSON if we have colors + if len(colorEntities) > 0 { + bytes, _ := json.Marshal(colorEntities) + str := string(bytes) + productEntity.Colors = &str + } + } else if len(req.Colors) > 0 { + // No new color images uploaded, but colors data is provided + // Preserve existing image paths and update names + var existingColors []struct { + Name string `json:"name"` + ImagePath *string `json:"image_path"` + } + if existingProduct.Colors != nil && *existingProduct.Colors != "" { + _ = json.Unmarshal([]byte(*existingProduct.Colors), &existingColors) + } + + var colorEntities []struct { + Name string `json:"name"` + ImagePath *string `json:"image_path"` + } + + for i, cReq := range req.Colors { + if cReq.Name == "" { + continue + } + + color := struct { + Name string `json:"name"` + ImagePath *string `json:"image_path"` + }{ + Name: cReq.Name, + } + + // Preserve existing image path if available + if i < len(existingColors) { + color.ImagePath = existingColors[i].ImagePath + } + + colorEntities = append(colorEntities, color) + } + + if len(colorEntities) > 0 { + bytes, _ := json.Marshal(colorEntities) + str := string(bytes) + productEntity.Colors = &str + } + } else if existingProduct.Colors != nil { + // No colors in request, preserve existing colors + productEntity.Colors = existingProduct.Colors + } + + // Handle specifications update (as JSON array like colors) + specFiles := form.File["specification_images"] + specificationsStr := c.FormValue("specifications") + + var specEntities []struct { + Title string `json:"title"` + ImagePaths []string `json:"image_paths"` + } + + if specificationsStr != "" { + var specifications []struct { + Title string `json:"title"` + ImageCount int `json:"imageCount"` + } + if err := json.Unmarshal([]byte(specificationsStr), &specifications); err == nil { + // Get existing specifications to preserve existing images if no new files uploaded + var existingSpecs []struct { + Title string `json:"title"` + ImagePaths []string `json:"image_paths"` + } + if existingProduct.Specifications != nil && *existingProduct.Specifications != "" { + _ = json.Unmarshal([]byte(*existingProduct.Specifications), &existingSpecs) + } + + fileIndex := 0 + for i, spec := range specifications { + if spec.Title == "" { + continue + } + + specEntity := struct { + Title string `json:"title"` + ImagePaths []string `json:"image_paths"` + }{ + Title: spec.Title, + ImagePaths: []string{}, + } + + // If there are new files uploaded for this spec index, use them + imageCount := spec.ImageCount + if imageCount > 0 && fileIndex < len(specFiles) { + // Upload new files + for j := 0; j < imageCount && fileIndex < len(specFiles); j++ { + fileHeader := specFiles[fileIndex] + path, uploadErr := _i.uploadSpecFile(fileHeader) + if uploadErr == nil && path != nil { + specEntity.ImagePaths = append(specEntity.ImagePaths, *path) + } + fileIndex++ + } + } else if i < len(existingSpecs) { + // No new files, preserve existing image paths + specEntity.ImagePaths = existingSpecs[i].ImagePaths + } + + specEntities = append(specEntities, specEntity) + } + } + } else if existingProduct.Specifications != nil { + // No specifications in request, preserve existing + productEntity.Specifications = existingProduct.Specifications + } + + // Save specifications as JSON string + if len(specEntities) > 0 { + bytes, _ := json.Marshal(specEntities) + str := string(bytes) + productEntity.Specifications = &str + } + err = _i.Repo.Update(id, productEntity) if err != nil { return @@ -334,6 +674,7 @@ func (_i *productsService) Viewer(c *fiber.Ctx) (err error) { allProducts, _, err := _i.Repo.GetAll(queryReq) if err == nil { for _, product := range allProducts { + // Search in Colors if product.Colors != nil && *product.Colors != "" { var rawColors []struct { Name string `json:"name"` @@ -353,6 +694,32 @@ func (_i *productsService) Viewer(c *fiber.Ctx) (err error) { break } } + + // Search in Specifications + if !found && product.Specifications != nil && *product.Specifications != "" { + var rawSpecs []struct { + Title string `json:"title"` + ImagePaths []string `json:"image_paths"` + } + + if err := json.Unmarshal([]byte(*product.Specifications), &rawSpecs); err == nil { + for _, spec := range rawSpecs { + for _, imagePath := range spec.ImagePaths { + if strings.Contains(imagePath, filename) { + objectName = imagePath + found = true + break + } + } + if found { + break + } + } + } + if found { + break + } + } } } }