package service import ( "context" "errors" "fmt" "io" approvalHistoriesService "jaecoo-be/app/module/approval_histories/service" "jaecoo-be/app/module/products/mapper" "jaecoo-be/app/module/products/repository" "jaecoo-be/app/module/products/request" "jaecoo-be/app/module/products/response" usersRepository "jaecoo-be/app/module/users/repository" "jaecoo-be/config/config" minioStorage "jaecoo-be/config/config" "jaecoo-be/utils/paginator" utilSvc "jaecoo-be/utils/service" "math/rand" "mime" "mime/multipart" "path/filepath" "strconv" "strings" "time" "encoding/json" "github.com/gofiber/fiber/v2" "github.com/minio/minio-go/v7" "github.com/rs/zerolog" ) type productsService struct { Repo repository.ProductsRepository Log zerolog.Logger Cfg *config.Config MinioStorage *minioStorage.MinioStorage UsersRepo usersRepository.UsersRepository ApprovalHistoriesService approvalHistoriesService.ApprovalHistoriesService } type ProductsService interface { GetAll(req request.ProductsQueryRequest) (products []*response.ProductsResponse, paging paginator.Pagination, err error) GetOne(id uint) (product *response.ProductsResponse, err error) Create(c *fiber.Ctx, req request.ProductsCreateRequest) (product *response.ProductsResponse, err error) Update(c *fiber.Ctx, id uint, req request.ProductsUpdateRequest) (product *response.ProductsResponse, err error) Delete(id uint) (err error) Approve(id uint, authToken string) (product *response.ProductsResponse, err error) Reject(id uint, authToken string, message *string) (product *response.ProductsResponse, err error) Comment(id uint, authToken string, message *string) (product *response.ProductsResponse, err error) UploadFileToMinio(c *fiber.Ctx, fileKey string) (filePath *string, err error) Viewer(c *fiber.Ctx) (err error) } func NewProductsService(repo repository.ProductsRepository, log zerolog.Logger, cfg *config.Config, minioStorage *minioStorage.MinioStorage, usersRepo usersRepository.UsersRepository, approvalHistoriesService approvalHistoriesService.ApprovalHistoriesService) ProductsService { return &productsService{ Repo: repo, Log: log, Cfg: cfg, MinioStorage: minioStorage, UsersRepo: usersRepo, ApprovalHistoriesService: approvalHistoriesService, } } func (_i *productsService) GetAll(req request.ProductsQueryRequest) (products []*response.ProductsResponse, paging paginator.Pagination, err error) { productsEntity, paging, err := _i.Repo.GetAll(req) if err != nil { return } host := _i.Cfg.App.Domain for _, product := range productsEntity { products = append(products, mapper.ProductsResponseMapper(product, host)) } return } func (_i *productsService) GetOne(id uint) (product *response.ProductsResponse, err error) { productEntity, err := _i.Repo.FindOne(id) if err != nil { return } if productEntity == nil { err = errors.New("product not found") return } host := _i.Cfg.App.Domain product = mapper.ProductsResponseMapper(productEntity, host) return } func (_i *productsService) uploadColorFile( 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/colors/%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) 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 productEntity := req.ToEntity() // =============================== // 3️⃣ 🔥 HANDLE COLORS + IMAGE // =============================== 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"` 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, } // hubungkan file berdasarkan index if len(colorFiles) > i { fileHeader := colorFiles[i] path, err := _i.uploadColorFile(fileHeader) if err == nil && path != nil { color.ImagePath = path } } colorEntities = append(colorEntities, color) } // simpan ke kolom products.colors (JSON string) if len(colorEntities) > 0 { 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") } // 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 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") // 8️⃣ RESPONSE host := _i.Cfg.App.Domain product = mapper.ProductsResponseMapper(productEntity, host) return } func (_i *productsService) UploadFileToMinio(c *fiber.Ctx, fileKey string) (filePath *string, err error) { form, err := c.MultipartForm() if err != nil { return nil, err } files := form.File[fileKey] if len(files) == 0 { return nil, nil // No file uploaded, return nil without error } fileHeader := files[0] // Create minio connection minioClient, err := _i.MinioStorage.ConnectMinio() if err != nil { return nil, err } bucketName := _i.MinioStorage.Cfg.ObjectStorage.MinioStorage.BucketName // Open file src, err := fileHeader.Open() if err != nil { return nil, err } defer src.Close() // Process filename filename := filepath.Base(fileHeader.Filename) filename = strings.ReplaceAll(filename, " ", "") filenameWithoutExt := filepath.Clean(filename[:len(filename)-len(filepath.Ext(filename))]) extension := filepath.Ext(fileHeader.Filename)[1:] // Generate unique filename now := time.Now() rand.New(rand.NewSource(now.UnixNano())) randUniqueId := rand.Intn(1000000) newFilenameWithoutExt := filenameWithoutExt + "_" + strconv.Itoa(randUniqueId) newFilename := newFilenameWithoutExt + "." + extension // Create object name with path structure objectName := fmt.Sprintf("products/upload/%d/%d/%s", now.Year(), now.Month(), newFilename) _i.Log.Info().Str("timestamp", time.Now(). Format(time.RFC3339)).Str("Service:Resource", "Products:UploadFileToMinio"). Interface("Uploading file", objectName).Msg("") // Upload file to MinIO _, err = minioClient.PutObject(context.Background(), bucketName, objectName, src, fileHeader.Size, minio.PutObjectOptions{}) if err != nil { _i.Log.Error().Str("timestamp", time.Now(). Format(time.RFC3339)).Str("Service:Resource", "Products:UploadFileToMinio"). Interface("Error uploading file", err).Msg("") return nil, err } _i.Log.Info().Str("timestamp", time.Now(). Format(time.RFC3339)).Str("Service:Resource", "Products:UploadFileToMinio"). Interface("Successfully uploaded", objectName).Msg("") return &objectName, nil } func (_i *productsService) Update(c *fiber.Ctx, id uint, req request.ProductsUpdateRequest) (product *response.ProductsResponse, err error) { // Handle file upload if exists if filePath, uploadErr := _i.UploadFileToMinio(c, "file"); uploadErr == nil && filePath != nil { 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 } productEntity, err = _i.Repo.FindOne(id) if err != nil { return } host := _i.Cfg.App.Domain product = mapper.ProductsResponseMapper(productEntity, host) return } func (_i *productsService) Delete(id uint) (err error) { err = _i.Repo.Delete(id) return } func (_i *productsService) Viewer(c *fiber.Ctx) (err error) { filename := c.Params("filename") var objectName string var found bool // First, try to find by ThumbnailPath (for main product images) result, err := _i.Repo.FindByThumbnailPath(filename) if err == nil && result != nil && result.ThumbnailPath != nil && *result.ThumbnailPath != "" { // Check if the filename matches the thumbnail if strings.Contains(*result.ThumbnailPath, filename) { objectName = *result.ThumbnailPath found = true } } // If not found in ThumbnailPath, search in Colors JSON field if !found { // Create a query request with large limit to search all products queryReq := request.ProductsQueryRequest{ Pagination: &paginator.Pagination{ Page: 1, Limit: 1000, // Large limit to search all products }, } 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"` ImagePath *string `json:"image_path"` } if err := json.Unmarshal([]byte(*product.Colors), &rawColors); err == nil { for _, color := range rawColors { if color.ImagePath != nil && strings.Contains(*color.ImagePath, filename) { objectName = *color.ImagePath found = true break } } } if found { 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 } } } } } if !found || objectName == "" { return c.Status(fiber.StatusNotFound).JSON(fiber.Map{ "error": true, "msg": "Product file not found", }) } ctx := context.Background() bucketName := _i.MinioStorage.Cfg.ObjectStorage.MinioStorage.BucketName _i.Log.Info().Str("timestamp", time.Now(). Format(time.RFC3339)).Str("Service:Resource", "Products:Viewer"). Interface("data", objectName).Msg("") // Create minio connection minioClient, err := _i.MinioStorage.ConnectMinio() if err != nil { return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{ "error": true, "msg": err.Error(), }) } fileContent, err := minioClient.GetObject(ctx, bucketName, objectName, minio.GetObjectOptions{}) if err != nil { _i.Log.Error().Str("timestamp", time.Now(). Format(time.RFC3339)).Str("Service:Resource", "Products:Viewer"). Interface("Error getting file", err).Msg("") return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{ "error": true, "msg": "Failed to retrieve file", }) } defer fileContent.Close() // Determine Content-Type based on file extension contentType := mime.TypeByExtension("." + getFileExtension(objectName)) if contentType == "" { contentType = "application/octet-stream" // fallback if no MIME type matches } c.Set("Content-Type", contentType) if _, err := io.Copy(c.Response().BodyWriter(), fileContent); err != nil { return err } return } func getFileExtension(filename string) string { // split file name parts := strings.Split(filename, ".") // jika tidak ada ekstensi, kembalikan string kosong if len(parts) == 1 || (len(parts) == 2 && parts[0] == "") { return "" } // ambil ekstensi terakhir return parts[len(parts)-1] } func (_i *productsService) Approve(id uint, authToken string) (product *response.ProductsResponse, err error) { // Get user from token user := utilSvc.GetUserInfo(_i.Log, _i.UsersRepo, authToken) if user == nil { err = errors.New("unauthorized: user not found") return } // Check if user has admin role (roleId = 1) if user.UserRoleId != 1 { err = errors.New("unauthorized: only admin can approve") return } // Approve product (update status_id to 2) err = _i.Repo.Approve(id) if err != nil { return } // Save approval history userID := user.ID statusApprove := 2 err = _i.ApprovalHistoriesService.CreateHistory( "products", id, &statusApprove, // ✅ pointer "approve", &userID, nil, ) if err != nil { _i.Log.Error().Err(err).Msg("Failed to save approval history") } // Get updated product data productEntity, err := _i.Repo.FindOne(id) if err != nil { return } if productEntity == nil { err = errors.New("product not found") return } host := _i.Cfg.App.Domain product = mapper.ProductsResponseMapper(productEntity, host) return } func (_i *productsService) Reject(id uint, authToken string, message *string) (product *response.ProductsResponse, err error) { // Get user from token user := utilSvc.GetUserInfo(_i.Log, _i.UsersRepo, authToken) if user == nil { err = errors.New("unauthorized: user not found") return } // Check if user has admin role (roleId = 1) if user.UserRoleId != 1 { err = errors.New("unauthorized: only admin can reject") return } // Reject product (update status_id to 3) err = _i.Repo.Reject(id) if err != nil { return } // Save rejection history userID := user.ID statusReject := 3 err = _i.ApprovalHistoriesService.CreateHistory( "productss", id, &statusReject, // ✅ pointer "reject", &userID, message, ) if err != nil { _i.Log.Error().Err(err).Msg("Failed to save rejection history") } // Get updated product data productEntity, err := _i.Repo.FindOne(id) if err != nil { return } if productEntity == nil { err = errors.New("product not found") return } host := _i.Cfg.App.Domain product = mapper.ProductsResponseMapper(productEntity, host) return } func (_i *productsService) Comment( id uint, authToken string, message *string, ) (banner *response.ProductsResponse, err error) { user := utilSvc.GetUserInfo(_i.Log, _i.UsersRepo, authToken) if user == nil { err = errors.New("unauthorized") return } if user.UserRoleId != 1 { err = errors.New("only admin can comment") return } // SIMPAN COMMENT KE HISTORY (INTI FITURNYA) userID := user.ID err = _i.ApprovalHistoriesService.CreateHistory( "banners", id, nil, // status_id NULL "comment", &userID, message, ) if err != nil { return } // Ambil banner terbaru bannerEntity, err := _i.Repo.FindOne(id) if err != nil { return } if bannerEntity == nil { err = errors.New("banner not found") return } host := _i.Cfg.App.Domain banner = mapper.ProductsResponseMapper(bannerEntity, host) return }