diff --git a/app/module/banners/banners.module.go b/app/module/banners/banners.module.go index 6c25bd8..f6f9a8b 100644 --- a/app/module/banners/banners.module.go +++ b/app/module/banners/banners.module.go @@ -46,6 +46,7 @@ func (_i *BannersRouter) RegisterBannersRoutes() { // define routes _i.App.Route("/banners", func(router fiber.Router) { router.Get("/", bannersController.All) + router.Get("/viewer/:filename", bannersController.Viewer) router.Get("/:id", bannersController.Show) router.Post("/", bannersController.Save) router.Put("/:id", bannersController.Update) diff --git a/app/module/banners/controller/banners.controller.go b/app/module/banners/controller/banners.controller.go index ea1515a..a97c985 100644 --- a/app/module/banners/controller/banners.controller.go +++ b/app/module/banners/controller/banners.controller.go @@ -22,6 +22,7 @@ type BannersController interface { Save(c *fiber.Ctx) error Update(c *fiber.Ctx) error Delete(c *fiber.Ctx) error + Viewer(c *fiber.Ctx) error } func NewBannersController(bannersService service.BannersService) BannersController { @@ -102,23 +103,61 @@ func (_i *bannersController) Show(c *fiber.Ctx) error { // Save Banner // @Summary Create Banner -// @Description API for creating Banner +// @Description API for creating Banner with file upload // @Tags Banners // @Security Bearer // @Param X-Client-Key header string true "Insert the X-Client-Key" -// @Param payload body request.BannersCreateRequest true "Required payload" +// @Param file formData file false "Upload file" +// @Param title formData string true "Banner title" +// @Param description formData string false "Banner description" +// @Param position formData string false "Banner position" +// @Param status formData string false "Banner status" // @Success 200 {object} response.Response // @Failure 400 {object} response.BadRequestError // @Failure 401 {object} response.UnauthorizedError // @Failure 500 {object} response.InternalServerError // @Router /banners [post] func (_i *bannersController) Save(c *fiber.Ctx) error { - req := new(request.BannersCreateRequest) - if err := utilVal.ParseAndValidate(c, req); err != nil { - return err + // Parse multipart form + form, err := c.MultipartForm() + if err != nil { + return utilRes.Resp(c, utilRes.Response{ + Success: false, + Messages: utilRes.Messages{"Failed to parse form data"}, + }) } - dataResult, err := _i.bannersService.Create(*req) + // Extract form values + req := request.BannersCreateRequest{ + Title: c.FormValue("title"), + } + + if description := c.FormValue("description"); description != "" { + req.Description = &description + } + + if position := c.FormValue("position"); position != "" { + req.Position = &position + } + + if status := c.FormValue("status"); status != "" { + req.Status = &status + } + + // Validate required fields + if req.Title == "" { + return utilRes.Resp(c, utilRes.Response{ + Success: false, + Messages: utilRes.Messages{"Title is required"}, + }) + } + + // Check if file is uploaded + if len(form.File["file"]) > 0 { + // File will be handled in service + } + + dataResult, err := _i.bannersService.Create(c, req) if err != nil { return err } @@ -194,3 +233,19 @@ func (_i *bannersController) Delete(c *fiber.Ctx) error { Messages: utilRes.Messages{"Banner successfully deleted"}, }) } + +// Viewer Banner +// @Summary Viewer Banner +// @Description API for viewing Banner file +// @Tags Banners +// @Security Bearer +// @Param X-Client-Key header string true "Insert the X-Client-Key" +// @Param filename path string true "Banner File Path" +// @Success 200 {file} file +// @Failure 400 {object} response.BadRequestError +// @Failure 401 {object} response.UnauthorizedError +// @Failure 500 {object} response.InternalServerError +// @Router /banners/viewer/{filename} [get] +func (_i *bannersController) Viewer(c *fiber.Ctx) error { + return _i.bannersService.Viewer(c) +} diff --git a/app/module/banners/repository/banners.repository.go b/app/module/banners/repository/banners.repository.go index 5293b94..0fc73fc 100644 --- a/app/module/banners/repository/banners.repository.go +++ b/app/module/banners/repository/banners.repository.go @@ -21,6 +21,7 @@ type BannersRepository interface { Create(banner *entity.Banners) (bannerReturn *entity.Banners, err error) Update(id uint, banner *entity.Banners) (err error) Delete(id uint) (err error) + FindByThumbnailPath(thumbnailPath string) (banner *entity.Banners, err error) } func NewBannersRepository(db *database.Database, log zerolog.Logger) BannersRepository { @@ -90,3 +91,9 @@ func (_i *bannersRepository) Delete(id uint) (err error) { err = _i.DB.DB.Model(&entity.Banners{}).Where("id = ?", id).Update("is_active", false).Error return } + +func (_i *bannersRepository) FindByThumbnailPath(thumbnailPath string) (banner *entity.Banners, err error) { + banner = &entity.Banners{} + err = _i.DB.DB.Where("thumbnail_path LIKE ? AND is_active = ?", "%"+thumbnailPath, true).First(banner).Error + return +} diff --git a/app/module/banners/service/banners.service.go b/app/module/banners/service/banners.service.go index 2f17a38..381f095 100644 --- a/app/module/banners/service/banners.service.go +++ b/app/module/banners/service/banners.service.go @@ -1,36 +1,52 @@ package service import ( + "context" "errors" + "fmt" + "io" "jaecoo-be/app/module/banners/mapper" "jaecoo-be/app/module/banners/repository" "jaecoo-be/app/module/banners/request" "jaecoo-be/app/module/banners/response" "jaecoo-be/config/config" + minioStorage "jaecoo-be/config/config" "jaecoo-be/utils/paginator" + "math/rand" + "mime" + "path/filepath" + "strconv" + "strings" + "time" + "github.com/gofiber/fiber/v2" + "github.com/minio/minio-go/v7" "github.com/rs/zerolog" ) type bannersService struct { - Repo repository.BannersRepository - Log zerolog.Logger - Cfg *config.Config + Repo repository.BannersRepository + Log zerolog.Logger + Cfg *config.Config + MinioStorage *minioStorage.MinioStorage } type BannersService interface { GetAll(req request.BannersQueryRequest) (banners []*response.BannersResponse, paging paginator.Pagination, err error) GetOne(id uint) (banner *response.BannersResponse, err error) - Create(req request.BannersCreateRequest) (banner *response.BannersResponse, err error) + Create(c *fiber.Ctx, req request.BannersCreateRequest) (banner *response.BannersResponse, err error) Update(id uint, req request.BannersUpdateRequest) (banner *response.BannersResponse, err error) Delete(id uint) (err error) + UploadFileToMinio(c *fiber.Ctx, fileKey string) (filePath *string, err error) + Viewer(c *fiber.Ctx) (err error) } -func NewBannersService(repo repository.BannersRepository, log zerolog.Logger, cfg *config.Config) BannersService { +func NewBannersService(repo repository.BannersRepository, log zerolog.Logger, cfg *config.Config, minioStorage *minioStorage.MinioStorage) BannersService { return &bannersService{ - Repo: repo, - Log: log, - Cfg: cfg, + Repo: repo, + Log: log, + Cfg: cfg, + MinioStorage: minioStorage, } } @@ -66,7 +82,12 @@ func (_i *bannersService) GetOne(id uint) (banner *response.BannersResponse, err return } -func (_i *bannersService) Create(req request.BannersCreateRequest) (banner *response.BannersResponse, err error) { +func (_i *bannersService) Create(c *fiber.Ctx, req request.BannersCreateRequest) (banner *response.BannersResponse, err error) { + // Handle file upload if exists + if filePath, uploadErr := _i.UploadFileToMinio(c, "file"); uploadErr == nil && filePath != nil { + req.ThumbnailPath = filePath + } + bannerEntity := req.ToEntity() isActive := true bannerEntity.IsActive = &isActive @@ -82,6 +103,71 @@ func (_i *bannersService) Create(req request.BannersCreateRequest) (banner *resp return } +func (_i *bannersService) 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("banners/upload/%d/%d/%s", now.Year(), now.Month(), newFilename) + + _i.Log.Info().Str("timestamp", time.Now(). + Format(time.RFC3339)).Str("Service:Resource", "Banners: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", "Banners:UploadFileToMinio"). + Interface("Error uploading file", err).Msg("") + return nil, err + } + + _i.Log.Info().Str("timestamp", time.Now(). + Format(time.RFC3339)).Str("Service:Resource", "Banners:UploadFileToMinio"). + Interface("Successfully uploaded", objectName).Msg("") + + return &objectName, nil +} + func (_i *bannersService) Update(id uint, req request.BannersUpdateRequest) (banner *response.BannersResponse, err error) { bannerEntity := req.ToEntity() @@ -105,3 +191,79 @@ func (_i *bannersService) Delete(id uint) (err error) { err = _i.Repo.Delete(id) return } + +func (_i *bannersService) Viewer(c *fiber.Ctx) (err error) { + filename := c.Params("filename") + + // Find banner by thumbnail path + result, err := _i.Repo.FindByThumbnailPath(filename) + if err != nil { + return c.Status(fiber.StatusNotFound).JSON(fiber.Map{ + "error": true, + "msg": "Banner file not found", + }) + } + + if result.ThumbnailPath == nil || *result.ThumbnailPath == "" { + return c.Status(fiber.StatusNotFound).JSON(fiber.Map{ + "error": true, + "msg": "Banner thumbnail path not found", + }) + } + + ctx := context.Background() + bucketName := _i.MinioStorage.Cfg.ObjectStorage.MinioStorage.BucketName + objectName := *result.ThumbnailPath + + _i.Log.Info().Str("timestamp", time.Now(). + Format(time.RFC3339)).Str("Service:Resource", "Banners: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", "Banners: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] +} diff --git a/app/module/gallery_files/controller/gallery_files.controller.go b/app/module/gallery_files/controller/gallery_files.controller.go index 55014c0..566f65b 100644 --- a/app/module/gallery_files/controller/gallery_files.controller.go +++ b/app/module/gallery_files/controller/gallery_files.controller.go @@ -22,6 +22,7 @@ type GalleryFilesController interface { Save(c *fiber.Ctx) error Update(c *fiber.Ctx) error Delete(c *fiber.Ctx) error + Viewer(c *fiber.Ctx) error } func NewGalleryFilesController(galleryFilesService service.GalleryFilesService) GalleryFilesController { @@ -101,23 +102,52 @@ func (_i *galleryFilesController) Show(c *fiber.Ctx) error { // Save GalleryFile // @Summary Create GalleryFile -// @Description API for creating GalleryFile +// @Description API for creating GalleryFile with file upload // @Tags GalleryFiles // @Security Bearer // @Param X-Client-Key header string true "Insert the X-Client-Key" -// @Param payload body request.GalleryFilesCreateRequest true "Required payload" +// @Param file formData file false "Upload file" +// @Param gallery_id formData int true "Gallery ID" +// @Param title formData string false "Gallery file title" // @Success 200 {object} response.Response // @Failure 400 {object} response.BadRequestError // @Failure 401 {object} response.UnauthorizedError // @Failure 500 {object} response.InternalServerError // @Router /gallery-files [post] func (_i *galleryFilesController) Save(c *fiber.Ctx) error { - req := new(request.GalleryFilesCreateRequest) - if err := utilVal.ParseAndValidate(c, req); err != nil { - return err + // Parse multipart form + form, err := c.MultipartForm() + if err != nil { + return utilRes.Resp(c, utilRes.Response{ + Success: false, + Messages: utilRes.Messages{"Failed to parse form data"}, + }) } - dataResult, err := _i.galleryFilesService.Create(*req) + // Extract form values + galleryIDStr := c.FormValue("gallery_id") + galleryID, err := strconv.ParseUint(galleryIDStr, 10, 0) + if err != nil { + return utilRes.Resp(c, utilRes.Response{ + Success: false, + Messages: utilRes.Messages{"Invalid gallery_id"}, + }) + } + + req := request.GalleryFilesCreateRequest{ + GalleryID: uint(galleryID), + } + + if title := c.FormValue("title"); title != "" { + req.Title = &title + } + + // Check if file is uploaded + if len(form.File["file"]) > 0 { + // File will be handled in service + } + + dataResult, err := _i.galleryFilesService.Create(c, req) if err != nil { return err } @@ -193,3 +223,19 @@ func (_i *galleryFilesController) Delete(c *fiber.Ctx) error { Messages: utilRes.Messages{"GalleryFile successfully deleted"}, }) } + +// Viewer GalleryFile +// @Summary Viewer GalleryFile +// @Description API for viewing GalleryFile file +// @Tags GalleryFiles +// @Security Bearer +// @Param X-Client-Key header string true "Insert the X-Client-Key" +// @Param filename path string true "Gallery File Path" +// @Success 200 {file} file +// @Failure 400 {object} response.BadRequestError +// @Failure 401 {object} response.UnauthorizedError +// @Failure 500 {object} response.InternalServerError +// @Router /gallery-files/viewer/{filename} [get] +func (_i *galleryFilesController) Viewer(c *fiber.Ctx) error { + return _i.galleryFilesService.Viewer(c) +} diff --git a/app/module/gallery_files/gallery_files.module.go b/app/module/gallery_files/gallery_files.module.go index 5efb8ad..df50df3 100644 --- a/app/module/gallery_files/gallery_files.module.go +++ b/app/module/gallery_files/gallery_files.module.go @@ -46,6 +46,7 @@ func (_i *GalleryFilesRouter) RegisterGalleryFilesRoutes() { // define routes _i.App.Route("/gallery-files", func(router fiber.Router) { router.Get("/", galleryFilesController.All) + router.Get("/viewer/:filename", galleryFilesController.Viewer) router.Get("/:id", galleryFilesController.Show) router.Post("/", galleryFilesController.Save) router.Put("/:id", galleryFilesController.Update) diff --git a/app/module/gallery_files/repository/gallery_files.repository.go b/app/module/gallery_files/repository/gallery_files.repository.go index 4292cda..287212d 100644 --- a/app/module/gallery_files/repository/gallery_files.repository.go +++ b/app/module/gallery_files/repository/gallery_files.repository.go @@ -21,6 +21,7 @@ type GalleryFilesRepository interface { Create(file *entity.GalleryFiles) (fileReturn *entity.GalleryFiles, err error) Update(id uint, file *entity.GalleryFiles) (err error) Delete(id uint) (err error) + FindByImagePath(imagePath string) (file *entity.GalleryFiles, err error) } func NewGalleryFilesRepository(db *database.Database, log zerolog.Logger) GalleryFilesRepository { @@ -87,3 +88,9 @@ func (_i *galleryFilesRepository) Delete(id uint) (err error) { return } +func (_i *galleryFilesRepository) FindByImagePath(imagePath string) (file *entity.GalleryFiles, err error) { + file = &entity.GalleryFiles{} + err = _i.DB.DB.Where("image_path LIKE ? AND is_active = ?", "%"+imagePath, true).First(file).Error + return +} + diff --git a/app/module/gallery_files/service/gallery_files.service.go b/app/module/gallery_files/service/gallery_files.service.go index f620596..b3dd8b1 100644 --- a/app/module/gallery_files/service/gallery_files.service.go +++ b/app/module/gallery_files/service/gallery_files.service.go @@ -1,36 +1,52 @@ package service import ( + "context" "errors" + "fmt" + "io" "jaecoo-be/app/module/gallery_files/mapper" "jaecoo-be/app/module/gallery_files/repository" "jaecoo-be/app/module/gallery_files/request" "jaecoo-be/app/module/gallery_files/response" "jaecoo-be/config/config" + minioStorage "jaecoo-be/config/config" "jaecoo-be/utils/paginator" + "math/rand" + "mime" + "path/filepath" + "strconv" + "strings" + "time" + "github.com/gofiber/fiber/v2" + "github.com/minio/minio-go/v7" "github.com/rs/zerolog" ) type galleryFilesService struct { - Repo repository.GalleryFilesRepository - Log zerolog.Logger - Cfg *config.Config + Repo repository.GalleryFilesRepository + Log zerolog.Logger + Cfg *config.Config + MinioStorage *minioStorage.MinioStorage } type GalleryFilesService interface { GetAll(req request.GalleryFilesQueryRequest) (files []*response.GalleryFilesResponse, paging paginator.Pagination, err error) GetOne(id uint) (file *response.GalleryFilesResponse, err error) - Create(req request.GalleryFilesCreateRequest) (file *response.GalleryFilesResponse, err error) + Create(c *fiber.Ctx, req request.GalleryFilesCreateRequest) (file *response.GalleryFilesResponse, err error) Update(id uint, req request.GalleryFilesUpdateRequest) (file *response.GalleryFilesResponse, err error) Delete(id uint) (err error) + UploadFileToMinio(c *fiber.Ctx, fileKey string) (filePath *string, err error) + Viewer(c *fiber.Ctx) (err error) } -func NewGalleryFilesService(repo repository.GalleryFilesRepository, log zerolog.Logger, cfg *config.Config) GalleryFilesService { +func NewGalleryFilesService(repo repository.GalleryFilesRepository, log zerolog.Logger, cfg *config.Config, minioStorage *minioStorage.MinioStorage) GalleryFilesService { return &galleryFilesService{ - Repo: repo, - Log: log, - Cfg: cfg, + Repo: repo, + Log: log, + Cfg: cfg, + MinioStorage: minioStorage, } } @@ -66,7 +82,12 @@ func (_i *galleryFilesService) GetOne(id uint) (file *response.GalleryFilesRespo return } -func (_i *galleryFilesService) Create(req request.GalleryFilesCreateRequest) (file *response.GalleryFilesResponse, err error) { +func (_i *galleryFilesService) Create(c *fiber.Ctx, req request.GalleryFilesCreateRequest) (file *response.GalleryFilesResponse, err error) { + // Handle file upload if exists + if filePath, uploadErr := _i.UploadFileToMinio(c, "file"); uploadErr == nil && filePath != nil { + req.ImagePath = filePath + } + fileEntity := req.ToEntity() isActive := true fileEntity.IsActive = &isActive @@ -82,6 +103,71 @@ func (_i *galleryFilesService) Create(req request.GalleryFilesCreateRequest) (fi return } +func (_i *galleryFilesService) 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("gallery-files/upload/%d/%d/%s", now.Year(), now.Month(), newFilename) + + _i.Log.Info().Str("timestamp", time.Now(). + Format(time.RFC3339)).Str("Service:Resource", "GalleryFiles: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", "GalleryFiles:UploadFileToMinio"). + Interface("Error uploading file", err).Msg("") + return nil, err + } + + _i.Log.Info().Str("timestamp", time.Now(). + Format(time.RFC3339)).Str("Service:Resource", "GalleryFiles:UploadFileToMinio"). + Interface("Successfully uploaded", objectName).Msg("") + + return &objectName, nil +} + func (_i *galleryFilesService) Update(id uint, req request.GalleryFilesUpdateRequest) (file *response.GalleryFilesResponse, err error) { fileEntity := req.ToEntity() @@ -105,3 +191,79 @@ func (_i *galleryFilesService) Delete(id uint) (err error) { err = _i.Repo.Delete(id) return } + +func (_i *galleryFilesService) Viewer(c *fiber.Ctx) (err error) { + filename := c.Params("filename") + + // Find gallery file by image path + result, err := _i.Repo.FindByImagePath(filename) + if err != nil { + return c.Status(fiber.StatusNotFound).JSON(fiber.Map{ + "error": true, + "msg": "Gallery file not found", + }) + } + + if result.ImagePath == nil || *result.ImagePath == "" { + return c.Status(fiber.StatusNotFound).JSON(fiber.Map{ + "error": true, + "msg": "Gallery image path not found", + }) + } + + ctx := context.Background() + bucketName := _i.MinioStorage.Cfg.ObjectStorage.MinioStorage.BucketName + objectName := *result.ImagePath + + _i.Log.Info().Str("timestamp", time.Now(). + Format(time.RFC3339)).Str("Service:Resource", "GalleryFiles: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", "GalleryFiles: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] +} diff --git a/app/module/product_specifications/controller/product_specifications.controller.go b/app/module/product_specifications/controller/product_specifications.controller.go index d065081..d57b022 100644 --- a/app/module/product_specifications/controller/product_specifications.controller.go +++ b/app/module/product_specifications/controller/product_specifications.controller.go @@ -22,6 +22,7 @@ type ProductSpecificationsController interface { Save(c *fiber.Ctx) error Update(c *fiber.Ctx) error Delete(c *fiber.Ctx) error + Viewer(c *fiber.Ctx) error } func NewProductSpecificationsController(productSpecificationsService service.ProductSpecificationsService) ProductSpecificationsController { @@ -101,23 +102,57 @@ func (_i *productSpecificationsController) Show(c *fiber.Ctx) error { // Save ProductSpecification // @Summary Create ProductSpecification -// @Description API for creating ProductSpecification +// @Description API for creating ProductSpecification with file upload // @Tags ProductSpecifications // @Security Bearer // @Param X-Client-Key header string true "Insert the X-Client-Key" -// @Param payload body request.ProductSpecificationsCreateRequest true "Required payload" +// @Param file formData file false "Upload file" +// @Param product_id formData int true "Product ID" +// @Param title formData string true "Product specification title" // @Success 200 {object} response.Response // @Failure 400 {object} response.BadRequestError // @Failure 401 {object} response.UnauthorizedError // @Failure 500 {object} response.InternalServerError // @Router /product-specifications [post] func (_i *productSpecificationsController) Save(c *fiber.Ctx) error { - req := new(request.ProductSpecificationsCreateRequest) - if err := utilVal.ParseAndValidate(c, req); err != nil { - return err + // Parse multipart form + form, err := c.MultipartForm() + if err != nil { + return utilRes.Resp(c, utilRes.Response{ + Success: false, + Messages: utilRes.Messages{"Failed to parse form data"}, + }) } - dataResult, err := _i.productSpecificationsService.Create(*req) + // Extract form values + productIDStr := c.FormValue("product_id") + productID, err := strconv.ParseUint(productIDStr, 10, 0) + if err != nil { + return utilRes.Resp(c, utilRes.Response{ + Success: false, + Messages: utilRes.Messages{"Invalid product_id"}, + }) + } + + req := request.ProductSpecificationsCreateRequest{ + ProductID: uint(productID), + Title: c.FormValue("title"), + } + + // Validate required fields + if req.Title == "" { + return utilRes.Resp(c, utilRes.Response{ + Success: false, + Messages: utilRes.Messages{"Title is required"}, + }) + } + + // Check if file is uploaded + if len(form.File["file"]) > 0 { + // File will be handled in service + } + + dataResult, err := _i.productSpecificationsService.Create(c, req) if err != nil { return err } @@ -194,3 +229,18 @@ func (_i *productSpecificationsController) Delete(c *fiber.Ctx) error { }) } +// Viewer ProductSpecification +// @Summary Viewer ProductSpecification +// @Description API for viewing ProductSpecification file +// @Tags ProductSpecifications +// @Security Bearer +// @Param X-Client-Key header string true "Insert the X-Client-Key" +// @Param filename path string true "Product Specification File Path" +// @Success 200 {file} file +// @Failure 400 {object} response.BadRequestError +// @Failure 401 {object} response.UnauthorizedError +// @Failure 500 {object} response.InternalServerError +// @Router /product-specifications/viewer/{filename} [get] +func (_i *productSpecificationsController) Viewer(c *fiber.Ctx) error { + return _i.productSpecificationsService.Viewer(c) +} diff --git a/app/module/product_specifications/product_specifications.module.go b/app/module/product_specifications/product_specifications.module.go index a1c77ec..9a71df1 100644 --- a/app/module/product_specifications/product_specifications.module.go +++ b/app/module/product_specifications/product_specifications.module.go @@ -46,6 +46,7 @@ func (_i *ProductSpecificationsRouter) RegisterProductSpecificationsRoutes() { // define routes _i.App.Route("/product-specifications", func(router fiber.Router) { router.Get("/", productSpecificationsController.All) + router.Get("/viewer/:filename", productSpecificationsController.Viewer) router.Get("/:id", productSpecificationsController.Show) router.Post("/", productSpecificationsController.Save) router.Put("/:id", productSpecificationsController.Update) diff --git a/app/module/product_specifications/repository/product_specifications.repository.go b/app/module/product_specifications/repository/product_specifications.repository.go index 995af74..bfa803b 100644 --- a/app/module/product_specifications/repository/product_specifications.repository.go +++ b/app/module/product_specifications/repository/product_specifications.repository.go @@ -21,6 +21,7 @@ type ProductSpecificationsRepository interface { Create(spec *entity.ProductSpecifications) (specReturn *entity.ProductSpecifications, err error) Update(id uint, spec *entity.ProductSpecifications) (err error) Delete(id uint) (err error) + FindByThumbnailPath(thumbnailPath string) (spec *entity.ProductSpecifications, err error) } func NewProductSpecificationsRepository(db *database.Database, log zerolog.Logger) ProductSpecificationsRepository { @@ -87,3 +88,9 @@ func (_i *productSpecificationsRepository) Delete(id uint) (err error) { return } +func (_i *productSpecificationsRepository) FindByThumbnailPath(thumbnailPath string) (spec *entity.ProductSpecifications, err error) { + spec = &entity.ProductSpecifications{} + err = _i.DB.DB.Where("thumbnail_path LIKE ? AND is_active = ?", "%"+thumbnailPath, true).First(spec).Error + return +} + diff --git a/app/module/product_specifications/service/product_specifications.service.go b/app/module/product_specifications/service/product_specifications.service.go index fb2bbde..8ab4a68 100644 --- a/app/module/product_specifications/service/product_specifications.service.go +++ b/app/module/product_specifications/service/product_specifications.service.go @@ -1,36 +1,52 @@ package service import ( + "context" "errors" + "fmt" + "io" "jaecoo-be/app/module/product_specifications/mapper" "jaecoo-be/app/module/product_specifications/repository" "jaecoo-be/app/module/product_specifications/request" "jaecoo-be/app/module/product_specifications/response" "jaecoo-be/config/config" + minioStorage "jaecoo-be/config/config" "jaecoo-be/utils/paginator" + "math/rand" + "mime" + "path/filepath" + "strconv" + "strings" + "time" + "github.com/gofiber/fiber/v2" + "github.com/minio/minio-go/v7" "github.com/rs/zerolog" ) type productSpecificationsService struct { - Repo repository.ProductSpecificationsRepository - Log zerolog.Logger - Cfg *config.Config + Repo repository.ProductSpecificationsRepository + Log zerolog.Logger + Cfg *config.Config + MinioStorage *minioStorage.MinioStorage } type ProductSpecificationsService interface { GetAll(req request.ProductSpecificationsQueryRequest) (specs []*response.ProductSpecificationsResponse, paging paginator.Pagination, err error) GetOne(id uint) (spec *response.ProductSpecificationsResponse, err error) - Create(req request.ProductSpecificationsCreateRequest) (spec *response.ProductSpecificationsResponse, err error) + Create(c *fiber.Ctx, req request.ProductSpecificationsCreateRequest) (spec *response.ProductSpecificationsResponse, err error) Update(id uint, req request.ProductSpecificationsUpdateRequest) (spec *response.ProductSpecificationsResponse, err error) Delete(id uint) (err error) + UploadFileToMinio(c *fiber.Ctx, fileKey string) (filePath *string, err error) + Viewer(c *fiber.Ctx) (err error) } -func NewProductSpecificationsService(repo repository.ProductSpecificationsRepository, log zerolog.Logger, cfg *config.Config) ProductSpecificationsService { +func NewProductSpecificationsService(repo repository.ProductSpecificationsRepository, log zerolog.Logger, cfg *config.Config, minioStorage *minioStorage.MinioStorage) ProductSpecificationsService { return &productSpecificationsService{ - Repo: repo, - Log: log, - Cfg: cfg, + Repo: repo, + Log: log, + Cfg: cfg, + MinioStorage: minioStorage, } } @@ -66,7 +82,12 @@ func (_i *productSpecificationsService) GetOne(id uint) (spec *response.ProductS return } -func (_i *productSpecificationsService) Create(req request.ProductSpecificationsCreateRequest) (spec *response.ProductSpecificationsResponse, err error) { +func (_i *productSpecificationsService) Create(c *fiber.Ctx, req request.ProductSpecificationsCreateRequest) (spec *response.ProductSpecificationsResponse, err error) { + // Handle file upload if exists + if filePath, uploadErr := _i.UploadFileToMinio(c, "file"); uploadErr == nil && filePath != nil { + req.ThumbnailPath = filePath + } + specEntity := req.ToEntity() isActive := true specEntity.IsActive = &isActive @@ -82,6 +103,71 @@ func (_i *productSpecificationsService) Create(req request.ProductSpecifications return } +func (_i *productSpecificationsService) 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("product-specifications/upload/%d/%d/%s", now.Year(), now.Month(), newFilename) + + _i.Log.Info().Str("timestamp", time.Now(). + Format(time.RFC3339)).Str("Service:Resource", "ProductSpecifications: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", "ProductSpecifications:UploadFileToMinio"). + Interface("Error uploading file", err).Msg("") + return nil, err + } + + _i.Log.Info().Str("timestamp", time.Now(). + Format(time.RFC3339)).Str("Service:Resource", "ProductSpecifications:UploadFileToMinio"). + Interface("Successfully uploaded", objectName).Msg("") + + return &objectName, nil +} + func (_i *productSpecificationsService) Update(id uint, req request.ProductSpecificationsUpdateRequest) (spec *response.ProductSpecificationsResponse, err error) { specEntity := req.ToEntity() @@ -105,3 +191,79 @@ func (_i *productSpecificationsService) Delete(id uint) (err error) { err = _i.Repo.Delete(id) return } + +func (_i *productSpecificationsService) Viewer(c *fiber.Ctx) (err error) { + filename := c.Params("filename") + + // Find product specification by thumbnail path + result, err := _i.Repo.FindByThumbnailPath(filename) + if err != nil { + return c.Status(fiber.StatusNotFound).JSON(fiber.Map{ + "error": true, + "msg": "Product specification file not found", + }) + } + + if result.ThumbnailPath == nil || *result.ThumbnailPath == "" { + return c.Status(fiber.StatusNotFound).JSON(fiber.Map{ + "error": true, + "msg": "Product specification thumbnail path not found", + }) + } + + ctx := context.Background() + bucketName := _i.MinioStorage.Cfg.ObjectStorage.MinioStorage.BucketName + objectName := *result.ThumbnailPath + + _i.Log.Info().Str("timestamp", time.Now(). + Format(time.RFC3339)).Str("Service:Resource", "ProductSpecifications: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", "ProductSpecifications: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] +} diff --git a/app/module/products/controller/products.controller.go b/app/module/products/controller/products.controller.go index 0a4cf77..f4dd61d 100644 --- a/app/module/products/controller/products.controller.go +++ b/app/module/products/controller/products.controller.go @@ -1,6 +1,7 @@ package controller import ( + "encoding/json" "jaecoo-be/app/module/products/request" "jaecoo-be/app/module/products/service" "jaecoo-be/utils/paginator" @@ -22,6 +23,7 @@ type ProductsController interface { Save(c *fiber.Ctx) error Update(c *fiber.Ctx) error Delete(c *fiber.Ctx) error + Viewer(c *fiber.Ctx) error } func NewProductsController(productsService service.ProductsService) ProductsController { @@ -101,23 +103,67 @@ func (_i *productsController) Show(c *fiber.Ctx) error { // Save Product // @Summary Create Product -// @Description API for creating Product +// @Description API for creating Product with file upload // @Tags Products // @Security Bearer // @Param X-Client-Key header string true "Insert the X-Client-Key" -// @Param payload body request.ProductsCreateRequest true "Required payload" +// @Param file formData file false "Upload file" +// @Param title formData string true "Product title" +// @Param variant formData string false "Product variant" +// @Param price formData number false "Product price" +// @Param colors formData string false "Product colors (JSON array)" // @Success 200 {object} response.Response // @Failure 400 {object} response.BadRequestError // @Failure 401 {object} response.UnauthorizedError // @Failure 500 {object} response.InternalServerError // @Router /products [post] func (_i *productsController) Save(c *fiber.Ctx) error { - req := new(request.ProductsCreateRequest) - if err := utilVal.ParseAndValidate(c, req); err != nil { - return err + // Parse multipart form + form, err := c.MultipartForm() + if err != nil { + return utilRes.Resp(c, utilRes.Response{ + Success: false, + Messages: utilRes.Messages{"Failed to parse form data"}, + }) } - dataResult, err := _i.productsService.Create(*req) + // Extract form values + req := request.ProductsCreateRequest{ + Title: c.FormValue("title"), + } + + if variant := c.FormValue("variant"); variant != "" { + req.Variant = &variant + } + + if priceStr := c.FormValue("price"); priceStr != "" { + if price, err := strconv.ParseFloat(priceStr, 64); err == nil { + req.Price = &price + } + } + + // Handle colors (JSON array string) + if colorsStr := c.FormValue("colors"); colorsStr != "" { + var colors []string + if err := json.Unmarshal([]byte(colorsStr), &colors); err == nil { + req.Colors = colors + } + } + + // Validate required fields + if req.Title == "" { + return utilRes.Resp(c, utilRes.Response{ + Success: false, + Messages: utilRes.Messages{"Title is required"}, + }) + } + + // Check if file is uploaded + if len(form.File["file"]) > 0 { + // File will be handled in service + } + + dataResult, err := _i.productsService.Create(c, req) if err != nil { return err } @@ -194,3 +240,19 @@ func (_i *productsController) Delete(c *fiber.Ctx) error { }) } +// Viewer Product +// @Summary Viewer Product +// @Description API for viewing Product file +// @Tags Products +// @Security Bearer +// @Param X-Client-Key header string true "Insert the X-Client-Key" +// @Param filename path string true "Product File Path" +// @Success 200 {file} file +// @Failure 400 {object} response.BadRequestError +// @Failure 401 {object} response.UnauthorizedError +// @Failure 500 {object} response.InternalServerError +// @Router /products/viewer/{filename} [get] +func (_i *productsController) Viewer(c *fiber.Ctx) error { + return _i.productsService.Viewer(c) +} + diff --git a/app/module/products/products.module.go b/app/module/products/products.module.go index 3365dff..4973eea 100644 --- a/app/module/products/products.module.go +++ b/app/module/products/products.module.go @@ -46,6 +46,7 @@ func (_i *ProductsRouter) RegisterProductsRoutes() { // define routes _i.App.Route("/products", func(router fiber.Router) { router.Get("/", productsController.All) + router.Get("/viewer/:filename", productsController.Viewer) router.Get("/:id", productsController.Show) router.Post("/", productsController.Save) router.Put("/:id", productsController.Update) diff --git a/app/module/products/repository/products.repository.go b/app/module/products/repository/products.repository.go index 4c72756..6d282e2 100644 --- a/app/module/products/repository/products.repository.go +++ b/app/module/products/repository/products.repository.go @@ -21,6 +21,7 @@ type ProductsRepository interface { Create(product *entity.Products) (productReturn *entity.Products, err error) Update(id uint, product *entity.Products) (err error) Delete(id uint) (err error) + FindByThumbnailPath(thumbnailPath string) (product *entity.Products, err error) } func NewProductsRepository(db *database.Database, log zerolog.Logger) ProductsRepository { @@ -87,3 +88,9 @@ func (_i *productsRepository) Delete(id uint) (err error) { return } +func (_i *productsRepository) FindByThumbnailPath(thumbnailPath string) (product *entity.Products, err error) { + product = &entity.Products{} + err = _i.DB.DB.Where("thumbnail_path LIKE ? AND is_active = ?", "%"+thumbnailPath, true).First(product).Error + return +} + diff --git a/app/module/products/service/products.service.go b/app/module/products/service/products.service.go index ee51bf9..6704a55 100644 --- a/app/module/products/service/products.service.go +++ b/app/module/products/service/products.service.go @@ -1,36 +1,52 @@ package service import ( + "context" "errors" + "fmt" + "io" "jaecoo-be/app/module/products/mapper" "jaecoo-be/app/module/products/repository" "jaecoo-be/app/module/products/request" "jaecoo-be/app/module/products/response" "jaecoo-be/config/config" + minioStorage "jaecoo-be/config/config" "jaecoo-be/utils/paginator" + "math/rand" + "mime" + "path/filepath" + "strconv" + "strings" + "time" + "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 + Repo repository.ProductsRepository + Log zerolog.Logger + Cfg *config.Config + MinioStorage *minioStorage.MinioStorage } type ProductsService interface { GetAll(req request.ProductsQueryRequest) (products []*response.ProductsResponse, paging paginator.Pagination, err error) GetOne(id uint) (product *response.ProductsResponse, err error) - Create(req request.ProductsCreateRequest) (product *response.ProductsResponse, err error) + Create(c *fiber.Ctx, req request.ProductsCreateRequest) (product *response.ProductsResponse, err error) Update(id uint, req request.ProductsUpdateRequest) (product *response.ProductsResponse, err error) Delete(id uint) (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) ProductsService { +func NewProductsService(repo repository.ProductsRepository, log zerolog.Logger, cfg *config.Config, minioStorage *minioStorage.MinioStorage) ProductsService { return &productsService{ - Repo: repo, - Log: log, - Cfg: cfg, + Repo: repo, + Log: log, + Cfg: cfg, + MinioStorage: minioStorage, } } @@ -66,7 +82,12 @@ func (_i *productsService) GetOne(id uint) (product *response.ProductsResponse, return } -func (_i *productsService) Create(req request.ProductsCreateRequest) (product *response.ProductsResponse, err error) { +func (_i *productsService) Create(c *fiber.Ctx, req request.ProductsCreateRequest) (product *response.ProductsResponse, err error) { + // Handle file upload if exists + if filePath, uploadErr := _i.UploadFileToMinio(c, "file"); uploadErr == nil && filePath != nil { + req.ThumbnailPath = filePath + } + productEntity := req.ToEntity() isActive := true productEntity.IsActive = &isActive @@ -82,6 +103,71 @@ func (_i *productsService) Create(req request.ProductsCreateRequest) (product *r 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(id uint, req request.ProductsUpdateRequest) (product *response.ProductsResponse, err error) { productEntity := req.ToEntity() @@ -105,3 +191,79 @@ 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") + + // Find product by thumbnail path + result, err := _i.Repo.FindByThumbnailPath(filename) + if err != nil { + return c.Status(fiber.StatusNotFound).JSON(fiber.Map{ + "error": true, + "msg": "Product file not found", + }) + } + + if result.ThumbnailPath == nil || *result.ThumbnailPath == "" { + return c.Status(fiber.StatusNotFound).JSON(fiber.Map{ + "error": true, + "msg": "Product thumbnail path not found", + }) + } + + ctx := context.Background() + bucketName := _i.MinioStorage.Cfg.ObjectStorage.MinioStorage.BucketName + objectName := *result.ThumbnailPath + + _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] +} diff --git a/app/module/promotions/controller/promotions.controller.go b/app/module/promotions/controller/promotions.controller.go index c2e7678..d96f517 100644 --- a/app/module/promotions/controller/promotions.controller.go +++ b/app/module/promotions/controller/promotions.controller.go @@ -22,6 +22,7 @@ type PromotionsController interface { Save(c *fiber.Ctx) error Update(c *fiber.Ctx) error Delete(c *fiber.Ctx) error + Viewer(c *fiber.Ctx) error } func NewPromotionsController(promotionsService service.PromotionsService) PromotionsController { @@ -100,23 +101,51 @@ func (_i *promotionsController) Show(c *fiber.Ctx) error { // Save Promotion // @Summary Create Promotion -// @Description API for creating Promotion +// @Description API for creating Promotion with file upload // @Tags Promotions // @Security Bearer // @Param X-Client-Key header string true "Insert the X-Client-Key" -// @Param payload body request.PromotionsCreateRequest true "Required payload" +// @Param file formData file false "Upload file" +// @Param title formData string true "Promotion title" +// @Param description formData string false "Promotion description" // @Success 200 {object} response.Response // @Failure 400 {object} response.BadRequestError // @Failure 401 {object} response.UnauthorizedError // @Failure 500 {object} response.InternalServerError // @Router /promotions [post] func (_i *promotionsController) Save(c *fiber.Ctx) error { - req := new(request.PromotionsCreateRequest) - if err := utilVal.ParseAndValidate(c, req); err != nil { - return err + // Parse multipart form + form, err := c.MultipartForm() + if err != nil { + return utilRes.Resp(c, utilRes.Response{ + Success: false, + Messages: utilRes.Messages{"Failed to parse form data"}, + }) } - dataResult, err := _i.promotionsService.Create(*req) + // Extract form values + req := request.PromotionsCreateRequest{ + Title: c.FormValue("title"), + } + + if description := c.FormValue("description"); description != "" { + req.Description = &description + } + + // Validate required fields + if req.Title == "" { + return utilRes.Resp(c, utilRes.Response{ + Success: false, + Messages: utilRes.Messages{"Title is required"}, + }) + } + + // Check if file is uploaded + if len(form.File["file"]) > 0 { + // File will be handled in service + } + + dataResult, err := _i.promotionsService.Create(c, req) if err != nil { return err } @@ -192,3 +221,19 @@ func (_i *promotionsController) Delete(c *fiber.Ctx) error { Messages: utilRes.Messages{"Promotion successfully deleted"}, }) } + +// Viewer Promotion +// @Summary Viewer Promotion +// @Description API for viewing Promotion file +// @Tags Promotions +// @Security Bearer +// @Param X-Client-Key header string true "Insert the X-Client-Key" +// @Param filename path string true "Promotion File Path" +// @Success 200 {file} file +// @Failure 400 {object} response.BadRequestError +// @Failure 401 {object} response.UnauthorizedError +// @Failure 500 {object} response.InternalServerError +// @Router /promotions/viewer/{filename} [get] +func (_i *promotionsController) Viewer(c *fiber.Ctx) error { + return _i.promotionsService.Viewer(c) +} diff --git a/app/module/promotions/promotions.module.go b/app/module/promotions/promotions.module.go index 9daba31..ab55a04 100644 --- a/app/module/promotions/promotions.module.go +++ b/app/module/promotions/promotions.module.go @@ -46,6 +46,7 @@ func (_i *PromotionsRouter) RegisterPromotionsRoutes() { // define routes _i.App.Route("/promotions", func(router fiber.Router) { router.Get("/", promotionsController.All) + router.Get("/viewer/:filename", promotionsController.Viewer) router.Get("/:id", promotionsController.Show) router.Post("/", promotionsController.Save) router.Put("/:id", promotionsController.Update) diff --git a/app/module/promotions/repository/promotions.repository.go b/app/module/promotions/repository/promotions.repository.go index a04ac4e..c8900d9 100644 --- a/app/module/promotions/repository/promotions.repository.go +++ b/app/module/promotions/repository/promotions.repository.go @@ -21,6 +21,7 @@ type PromotionsRepository interface { Create(promotion *entity.Promotions) (promotionReturn *entity.Promotions, err error) Update(id uint, promotion *entity.Promotions) (err error) Delete(id uint) (err error) + FindByThumbnailPath(thumbnailPath string) (promotion *entity.Promotions, err error) } func NewPromotionsRepository(db *database.Database, log zerolog.Logger) PromotionsRepository { @@ -83,3 +84,9 @@ func (_i *promotionsRepository) Delete(id uint) (err error) { return } +func (_i *promotionsRepository) FindByThumbnailPath(thumbnailPath string) (promotion *entity.Promotions, err error) { + promotion = &entity.Promotions{} + err = _i.DB.DB.Where("thumbnail_path LIKE ? AND is_active = ?", "%"+thumbnailPath, true).First(promotion).Error + return +} + diff --git a/app/module/promotions/service/promotions.service.go b/app/module/promotions/service/promotions.service.go index b528a5a..15aed23 100644 --- a/app/module/promotions/service/promotions.service.go +++ b/app/module/promotions/service/promotions.service.go @@ -1,36 +1,52 @@ package service import ( + "context" "errors" + "fmt" + "io" "jaecoo-be/app/module/promotions/mapper" "jaecoo-be/app/module/promotions/repository" "jaecoo-be/app/module/promotions/request" "jaecoo-be/app/module/promotions/response" "jaecoo-be/config/config" + minioStorage "jaecoo-be/config/config" "jaecoo-be/utils/paginator" + "math/rand" + "mime" + "path/filepath" + "strconv" + "strings" + "time" + "github.com/gofiber/fiber/v2" + "github.com/minio/minio-go/v7" "github.com/rs/zerolog" ) type promotionsService struct { - Repo repository.PromotionsRepository - Log zerolog.Logger - Cfg *config.Config + Repo repository.PromotionsRepository + Log zerolog.Logger + Cfg *config.Config + MinioStorage *minioStorage.MinioStorage } type PromotionsService interface { GetAll(req request.PromotionsQueryRequest) (promotions []*response.PromotionsResponse, paging paginator.Pagination, err error) GetOne(id uint) (promotion *response.PromotionsResponse, err error) - Create(req request.PromotionsCreateRequest) (promotion *response.PromotionsResponse, err error) + Create(c *fiber.Ctx, req request.PromotionsCreateRequest) (promotion *response.PromotionsResponse, err error) Update(id uint, req request.PromotionsUpdateRequest) (promotion *response.PromotionsResponse, err error) Delete(id uint) (err error) + UploadFileToMinio(c *fiber.Ctx, fileKey string) (filePath *string, err error) + Viewer(c *fiber.Ctx) (err error) } -func NewPromotionsService(repo repository.PromotionsRepository, log zerolog.Logger, cfg *config.Config) PromotionsService { +func NewPromotionsService(repo repository.PromotionsRepository, log zerolog.Logger, cfg *config.Config, minioStorage *minioStorage.MinioStorage) PromotionsService { return &promotionsService{ - Repo: repo, - Log: log, - Cfg: cfg, + Repo: repo, + Log: log, + Cfg: cfg, + MinioStorage: minioStorage, } } @@ -66,7 +82,12 @@ func (_i *promotionsService) GetOne(id uint) (promotion *response.PromotionsResp return } -func (_i *promotionsService) Create(req request.PromotionsCreateRequest) (promotion *response.PromotionsResponse, err error) { +func (_i *promotionsService) Create(c *fiber.Ctx, req request.PromotionsCreateRequest) (promotion *response.PromotionsResponse, err error) { + // Handle file upload if exists + if filePath, uploadErr := _i.UploadFileToMinio(c, "file"); uploadErr == nil && filePath != nil { + req.ThumbnailPath = filePath + } + promotionEntity := req.ToEntity() isActive := true promotionEntity.IsActive = &isActive @@ -82,6 +103,71 @@ func (_i *promotionsService) Create(req request.PromotionsCreateRequest) (promot return } +func (_i *promotionsService) 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("promotions/upload/%d/%d/%s", now.Year(), now.Month(), newFilename) + + _i.Log.Info().Str("timestamp", time.Now(). + Format(time.RFC3339)).Str("Service:Resource", "Promotions: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", "Promotions:UploadFileToMinio"). + Interface("Error uploading file", err).Msg("") + return nil, err + } + + _i.Log.Info().Str("timestamp", time.Now(). + Format(time.RFC3339)).Str("Service:Resource", "Promotions:UploadFileToMinio"). + Interface("Successfully uploaded", objectName).Msg("") + + return &objectName, nil +} + func (_i *promotionsService) Update(id uint, req request.PromotionsUpdateRequest) (promotion *response.PromotionsResponse, err error) { promotionEntity := req.ToEntity() @@ -105,3 +191,79 @@ func (_i *promotionsService) Delete(id uint) (err error) { err = _i.Repo.Delete(id) return } + +func (_i *promotionsService) Viewer(c *fiber.Ctx) (err error) { + filename := c.Params("filename") + + // Find promotion by thumbnail path + result, err := _i.Repo.FindByThumbnailPath(filename) + if err != nil { + return c.Status(fiber.StatusNotFound).JSON(fiber.Map{ + "error": true, + "msg": "Promotion file not found", + }) + } + + if result.ThumbnailPath == nil || *result.ThumbnailPath == "" { + return c.Status(fiber.StatusNotFound).JSON(fiber.Map{ + "error": true, + "msg": "Promotion thumbnail path not found", + }) + } + + ctx := context.Background() + bucketName := _i.MinioStorage.Cfg.ObjectStorage.MinioStorage.BucketName + objectName := *result.ThumbnailPath + + _i.Log.Info().Str("timestamp", time.Now(). + Format(time.RFC3339)).Str("Service:Resource", "Promotions: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", "Promotions: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] +} diff --git a/docs/swagger/docs.go b/docs/swagger/docs.go index 4546f46..5d87973 100644 --- a/docs/swagger/docs.go +++ b/docs/swagger/docs.go @@ -3331,7 +3331,7 @@ const docTemplate = `{ "Bearer": [] } ], - "description": "API for creating Banner", + "description": "API for creating Banner with file upload", "tags": [ "Banners" ], @@ -3345,13 +3345,35 @@ const docTemplate = `{ "required": true }, { - "description": "Required payload", - "name": "payload", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/request.BannersCreateRequest" - } + "type": "file", + "description": "Upload file", + "name": "file", + "in": "formData" + }, + { + "type": "string", + "description": "Banner title", + "name": "title", + "in": "formData", + "required": true + }, + { + "type": "string", + "description": "Banner description", + "name": "description", + "in": "formData" + }, + { + "type": "string", + "description": "Banner position", + "name": "position", + "in": "formData" + }, + { + "type": "string", + "description": "Banner status", + "name": "status", + "in": "formData" } ], "responses": { @@ -3382,6 +3404,62 @@ const docTemplate = `{ } } }, + "/banners/viewer/{filename}": { + "get": { + "security": [ + { + "Bearer": [] + } + ], + "description": "API for viewing Banner file", + "tags": [ + "Banners" + ], + "summary": "Viewer Banner", + "parameters": [ + { + "type": "string", + "description": "Insert the X-Client-Key", + "name": "X-Client-Key", + "in": "header", + "required": true + }, + { + "type": "string", + "description": "Banner File Path", + "name": "filename", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "file" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/response.BadRequestError" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/response.UnauthorizedError" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/response.InternalServerError" + } + } + } + } + }, "/banners/{id}": { "get": { "security": [ @@ -5279,7 +5357,7 @@ const docTemplate = `{ "Bearer": [] } ], - "description": "API for creating GalleryFile", + "description": "API for creating GalleryFile with file upload", "tags": [ "GalleryFiles" ], @@ -5293,13 +5371,23 @@ const docTemplate = `{ "required": true }, { - "description": "Required payload", - "name": "payload", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/request.GalleryFilesCreateRequest" - } + "type": "file", + "description": "Upload file", + "name": "file", + "in": "formData" + }, + { + "type": "integer", + "description": "Gallery ID", + "name": "gallery_id", + "in": "formData", + "required": true + }, + { + "type": "string", + "description": "Gallery file title", + "name": "title", + "in": "formData" } ], "responses": { @@ -5330,6 +5418,62 @@ const docTemplate = `{ } } }, + "/gallery-files/viewer/{filename}": { + "get": { + "security": [ + { + "Bearer": [] + } + ], + "description": "API for viewing GalleryFile file", + "tags": [ + "GalleryFiles" + ], + "summary": "Viewer GalleryFile", + "parameters": [ + { + "type": "string", + "description": "Insert the X-Client-Key", + "name": "X-Client-Key", + "in": "header", + "required": true + }, + { + "type": "string", + "description": "Gallery File Path", + "name": "filename", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "file" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/response.BadRequestError" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/response.UnauthorizedError" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/response.InternalServerError" + } + } + } + } + }, "/gallery-files/{id}": { "get": { "security": [ @@ -5607,7 +5751,7 @@ const docTemplate = `{ "Bearer": [] } ], - "description": "API for creating ProductSpecification", + "description": "API for creating ProductSpecification with file upload", "tags": [ "ProductSpecifications" ], @@ -5621,13 +5765,24 @@ const docTemplate = `{ "required": true }, { - "description": "Required payload", - "name": "payload", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/request.ProductSpecificationsCreateRequest" - } + "type": "file", + "description": "Upload file", + "name": "file", + "in": "formData" + }, + { + "type": "integer", + "description": "Product ID", + "name": "product_id", + "in": "formData", + "required": true + }, + { + "type": "string", + "description": "Product specification title", + "name": "title", + "in": "formData", + "required": true } ], "responses": { @@ -5658,6 +5813,62 @@ const docTemplate = `{ } } }, + "/product-specifications/viewer/{filename}": { + "get": { + "security": [ + { + "Bearer": [] + } + ], + "description": "API for viewing ProductSpecification file", + "tags": [ + "ProductSpecifications" + ], + "summary": "Viewer ProductSpecification", + "parameters": [ + { + "type": "string", + "description": "Insert the X-Client-Key", + "name": "X-Client-Key", + "in": "header", + "required": true + }, + { + "type": "string", + "description": "Product Specification File Path", + "name": "filename", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "file" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/response.BadRequestError" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/response.UnauthorizedError" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/response.InternalServerError" + } + } + } + } + }, "/product-specifications/{id}": { "get": { "security": [ @@ -5935,7 +6146,7 @@ const docTemplate = `{ "Bearer": [] } ], - "description": "API for creating Product", + "description": "API for creating Product with file upload", "tags": [ "Products" ], @@ -5949,13 +6160,35 @@ const docTemplate = `{ "required": true }, { - "description": "Required payload", - "name": "payload", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/request.ProductsCreateRequest" - } + "type": "file", + "description": "Upload file", + "name": "file", + "in": "formData" + }, + { + "type": "string", + "description": "Product title", + "name": "title", + "in": "formData", + "required": true + }, + { + "type": "string", + "description": "Product variant", + "name": "variant", + "in": "formData" + }, + { + "type": "number", + "description": "Product price", + "name": "price", + "in": "formData" + }, + { + "type": "string", + "description": "Product colors (JSON array)", + "name": "colors", + "in": "formData" } ], "responses": { @@ -5986,6 +6219,62 @@ const docTemplate = `{ } } }, + "/products/viewer/{filename}": { + "get": { + "security": [ + { + "Bearer": [] + } + ], + "description": "API for viewing Product file", + "tags": [ + "Products" + ], + "summary": "Viewer Product", + "parameters": [ + { + "type": "string", + "description": "Insert the X-Client-Key", + "name": "X-Client-Key", + "in": "header", + "required": true + }, + { + "type": "string", + "description": "Product File Path", + "name": "filename", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "file" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/response.BadRequestError" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/response.UnauthorizedError" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/response.InternalServerError" + } + } + } + } + }, "/products/{id}": { "get": { "security": [ @@ -6258,7 +6547,7 @@ const docTemplate = `{ "Bearer": [] } ], - "description": "API for creating Promotion", + "description": "API for creating Promotion with file upload", "tags": [ "Promotions" ], @@ -6272,13 +6561,23 @@ const docTemplate = `{ "required": true }, { - "description": "Required payload", - "name": "payload", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/request.PromotionsCreateRequest" - } + "type": "file", + "description": "Upload file", + "name": "file", + "in": "formData" + }, + { + "type": "string", + "description": "Promotion title", + "name": "title", + "in": "formData", + "required": true + }, + { + "type": "string", + "description": "Promotion description", + "name": "description", + "in": "formData" } ], "responses": { @@ -6309,6 +6608,62 @@ const docTemplate = `{ } } }, + "/promotions/viewer/{filename}": { + "get": { + "security": [ + { + "Bearer": [] + } + ], + "description": "API for viewing Promotion file", + "tags": [ + "Promotions" + ], + "summary": "Viewer Promotion", + "parameters": [ + { + "type": "string", + "description": "Insert the X-Client-Key", + "name": "X-Client-Key", + "in": "header", + "required": true + }, + { + "type": "string", + "description": "Promotion File Path", + "name": "filename", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "file" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/response.BadRequestError" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/response.UnauthorizedError" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/response.InternalServerError" + } + } + } + } + }, "/promotions/{id}": { "get": { "security": [ @@ -9820,29 +10175,6 @@ const docTemplate = `{ } } }, - "request.BannersCreateRequest": { - "type": "object", - "required": [ - "title" - ], - "properties": { - "description": { - "type": "string" - }, - "position": { - "type": "string" - }, - "status": { - "type": "string" - }, - "thumbnail_path": { - "type": "string" - }, - "title": { - "type": "string" - } - } - }, "request.BannersUpdateRequest": { "type": "object", "properties": { @@ -10027,23 +10359,6 @@ const docTemplate = `{ } } }, - "request.GalleryFilesCreateRequest": { - "type": "object", - "required": [ - "gallery_id" - ], - "properties": { - "gallery_id": { - "type": "integer" - }, - "image_path": { - "type": "string" - }, - "title": { - "type": "string" - } - } - }, "request.GalleryFilesUpdateRequest": { "type": "object", "properties": { @@ -10061,24 +10376,6 @@ const docTemplate = `{ } } }, - "request.ProductSpecificationsCreateRequest": { - "type": "object", - "required": [ - "product_id", - "title" - ], - "properties": { - "product_id": { - "type": "integer" - }, - "thumbnail_path": { - "type": "string" - }, - "title": { - "type": "string" - } - } - }, "request.ProductSpecificationsUpdateRequest": { "type": "object", "properties": { @@ -10096,32 +10393,6 @@ const docTemplate = `{ } } }, - "request.ProductsCreateRequest": { - "type": "object", - "required": [ - "title" - ], - "properties": { - "colors": { - "type": "array", - "items": { - "type": "string" - } - }, - "price": { - "type": "number" - }, - "thumbnail_path": { - "type": "string" - }, - "title": { - "type": "string" - }, - "variant": { - "type": "string" - } - } - }, "request.ProductsUpdateRequest": { "type": "object", "properties": { @@ -10148,23 +10419,6 @@ const docTemplate = `{ } } }, - "request.PromotionsCreateRequest": { - "type": "object", - "required": [ - "title" - ], - "properties": { - "description": { - "type": "string" - }, - "thumbnail_path": { - "type": "string" - }, - "title": { - "type": "string" - } - } - }, "request.PromotionsUpdateRequest": { "type": "object", "properties": { diff --git a/docs/swagger/swagger.json b/docs/swagger/swagger.json index 8d9935f..ae23bd9 100644 --- a/docs/swagger/swagger.json +++ b/docs/swagger/swagger.json @@ -3320,7 +3320,7 @@ "Bearer": [] } ], - "description": "API for creating Banner", + "description": "API for creating Banner with file upload", "tags": [ "Banners" ], @@ -3334,13 +3334,35 @@ "required": true }, { - "description": "Required payload", - "name": "payload", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/request.BannersCreateRequest" - } + "type": "file", + "description": "Upload file", + "name": "file", + "in": "formData" + }, + { + "type": "string", + "description": "Banner title", + "name": "title", + "in": "formData", + "required": true + }, + { + "type": "string", + "description": "Banner description", + "name": "description", + "in": "formData" + }, + { + "type": "string", + "description": "Banner position", + "name": "position", + "in": "formData" + }, + { + "type": "string", + "description": "Banner status", + "name": "status", + "in": "formData" } ], "responses": { @@ -3371,6 +3393,62 @@ } } }, + "/banners/viewer/{filename}": { + "get": { + "security": [ + { + "Bearer": [] + } + ], + "description": "API for viewing Banner file", + "tags": [ + "Banners" + ], + "summary": "Viewer Banner", + "parameters": [ + { + "type": "string", + "description": "Insert the X-Client-Key", + "name": "X-Client-Key", + "in": "header", + "required": true + }, + { + "type": "string", + "description": "Banner File Path", + "name": "filename", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "file" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/response.BadRequestError" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/response.UnauthorizedError" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/response.InternalServerError" + } + } + } + } + }, "/banners/{id}": { "get": { "security": [ @@ -5268,7 +5346,7 @@ "Bearer": [] } ], - "description": "API for creating GalleryFile", + "description": "API for creating GalleryFile with file upload", "tags": [ "GalleryFiles" ], @@ -5282,13 +5360,23 @@ "required": true }, { - "description": "Required payload", - "name": "payload", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/request.GalleryFilesCreateRequest" - } + "type": "file", + "description": "Upload file", + "name": "file", + "in": "formData" + }, + { + "type": "integer", + "description": "Gallery ID", + "name": "gallery_id", + "in": "formData", + "required": true + }, + { + "type": "string", + "description": "Gallery file title", + "name": "title", + "in": "formData" } ], "responses": { @@ -5319,6 +5407,62 @@ } } }, + "/gallery-files/viewer/{filename}": { + "get": { + "security": [ + { + "Bearer": [] + } + ], + "description": "API for viewing GalleryFile file", + "tags": [ + "GalleryFiles" + ], + "summary": "Viewer GalleryFile", + "parameters": [ + { + "type": "string", + "description": "Insert the X-Client-Key", + "name": "X-Client-Key", + "in": "header", + "required": true + }, + { + "type": "string", + "description": "Gallery File Path", + "name": "filename", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "file" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/response.BadRequestError" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/response.UnauthorizedError" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/response.InternalServerError" + } + } + } + } + }, "/gallery-files/{id}": { "get": { "security": [ @@ -5596,7 +5740,7 @@ "Bearer": [] } ], - "description": "API for creating ProductSpecification", + "description": "API for creating ProductSpecification with file upload", "tags": [ "ProductSpecifications" ], @@ -5610,13 +5754,24 @@ "required": true }, { - "description": "Required payload", - "name": "payload", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/request.ProductSpecificationsCreateRequest" - } + "type": "file", + "description": "Upload file", + "name": "file", + "in": "formData" + }, + { + "type": "integer", + "description": "Product ID", + "name": "product_id", + "in": "formData", + "required": true + }, + { + "type": "string", + "description": "Product specification title", + "name": "title", + "in": "formData", + "required": true } ], "responses": { @@ -5647,6 +5802,62 @@ } } }, + "/product-specifications/viewer/{filename}": { + "get": { + "security": [ + { + "Bearer": [] + } + ], + "description": "API for viewing ProductSpecification file", + "tags": [ + "ProductSpecifications" + ], + "summary": "Viewer ProductSpecification", + "parameters": [ + { + "type": "string", + "description": "Insert the X-Client-Key", + "name": "X-Client-Key", + "in": "header", + "required": true + }, + { + "type": "string", + "description": "Product Specification File Path", + "name": "filename", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "file" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/response.BadRequestError" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/response.UnauthorizedError" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/response.InternalServerError" + } + } + } + } + }, "/product-specifications/{id}": { "get": { "security": [ @@ -5924,7 +6135,7 @@ "Bearer": [] } ], - "description": "API for creating Product", + "description": "API for creating Product with file upload", "tags": [ "Products" ], @@ -5938,13 +6149,35 @@ "required": true }, { - "description": "Required payload", - "name": "payload", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/request.ProductsCreateRequest" - } + "type": "file", + "description": "Upload file", + "name": "file", + "in": "formData" + }, + { + "type": "string", + "description": "Product title", + "name": "title", + "in": "formData", + "required": true + }, + { + "type": "string", + "description": "Product variant", + "name": "variant", + "in": "formData" + }, + { + "type": "number", + "description": "Product price", + "name": "price", + "in": "formData" + }, + { + "type": "string", + "description": "Product colors (JSON array)", + "name": "colors", + "in": "formData" } ], "responses": { @@ -5975,6 +6208,62 @@ } } }, + "/products/viewer/{filename}": { + "get": { + "security": [ + { + "Bearer": [] + } + ], + "description": "API for viewing Product file", + "tags": [ + "Products" + ], + "summary": "Viewer Product", + "parameters": [ + { + "type": "string", + "description": "Insert the X-Client-Key", + "name": "X-Client-Key", + "in": "header", + "required": true + }, + { + "type": "string", + "description": "Product File Path", + "name": "filename", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "file" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/response.BadRequestError" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/response.UnauthorizedError" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/response.InternalServerError" + } + } + } + } + }, "/products/{id}": { "get": { "security": [ @@ -6247,7 +6536,7 @@ "Bearer": [] } ], - "description": "API for creating Promotion", + "description": "API for creating Promotion with file upload", "tags": [ "Promotions" ], @@ -6261,13 +6550,23 @@ "required": true }, { - "description": "Required payload", - "name": "payload", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/request.PromotionsCreateRequest" - } + "type": "file", + "description": "Upload file", + "name": "file", + "in": "formData" + }, + { + "type": "string", + "description": "Promotion title", + "name": "title", + "in": "formData", + "required": true + }, + { + "type": "string", + "description": "Promotion description", + "name": "description", + "in": "formData" } ], "responses": { @@ -6298,6 +6597,62 @@ } } }, + "/promotions/viewer/{filename}": { + "get": { + "security": [ + { + "Bearer": [] + } + ], + "description": "API for viewing Promotion file", + "tags": [ + "Promotions" + ], + "summary": "Viewer Promotion", + "parameters": [ + { + "type": "string", + "description": "Insert the X-Client-Key", + "name": "X-Client-Key", + "in": "header", + "required": true + }, + { + "type": "string", + "description": "Promotion File Path", + "name": "filename", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "file" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/response.BadRequestError" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/response.UnauthorizedError" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/response.InternalServerError" + } + } + } + } + }, "/promotions/{id}": { "get": { "security": [ @@ -9809,29 +10164,6 @@ } } }, - "request.BannersCreateRequest": { - "type": "object", - "required": [ - "title" - ], - "properties": { - "description": { - "type": "string" - }, - "position": { - "type": "string" - }, - "status": { - "type": "string" - }, - "thumbnail_path": { - "type": "string" - }, - "title": { - "type": "string" - } - } - }, "request.BannersUpdateRequest": { "type": "object", "properties": { @@ -10016,23 +10348,6 @@ } } }, - "request.GalleryFilesCreateRequest": { - "type": "object", - "required": [ - "gallery_id" - ], - "properties": { - "gallery_id": { - "type": "integer" - }, - "image_path": { - "type": "string" - }, - "title": { - "type": "string" - } - } - }, "request.GalleryFilesUpdateRequest": { "type": "object", "properties": { @@ -10050,24 +10365,6 @@ } } }, - "request.ProductSpecificationsCreateRequest": { - "type": "object", - "required": [ - "product_id", - "title" - ], - "properties": { - "product_id": { - "type": "integer" - }, - "thumbnail_path": { - "type": "string" - }, - "title": { - "type": "string" - } - } - }, "request.ProductSpecificationsUpdateRequest": { "type": "object", "properties": { @@ -10085,32 +10382,6 @@ } } }, - "request.ProductsCreateRequest": { - "type": "object", - "required": [ - "title" - ], - "properties": { - "colors": { - "type": "array", - "items": { - "type": "string" - } - }, - "price": { - "type": "number" - }, - "thumbnail_path": { - "type": "string" - }, - "title": { - "type": "string" - }, - "variant": { - "type": "string" - } - } - }, "request.ProductsUpdateRequest": { "type": "object", "properties": { @@ -10137,23 +10408,6 @@ } } }, - "request.PromotionsCreateRequest": { - "type": "object", - "required": [ - "title" - ], - "properties": { - "description": { - "type": "string" - }, - "thumbnail_path": { - "type": "string" - }, - "title": { - "type": "string" - } - } - }, "request.PromotionsUpdateRequest": { "type": "object", "properties": { diff --git a/docs/swagger/swagger.yaml b/docs/swagger/swagger.yaml index c7a3606..20ef1df 100644 --- a/docs/swagger/swagger.yaml +++ b/docs/swagger/swagger.yaml @@ -281,21 +281,6 @@ definitions: - title - typeId type: object - request.BannersCreateRequest: - properties: - description: - type: string - position: - type: string - status: - type: string - thumbnail_path: - type: string - title: - type: string - required: - - title - type: object request.BannersUpdateRequest: properties: description: @@ -420,17 +405,6 @@ definitions: title: type: string type: object - request.GalleryFilesCreateRequest: - properties: - gallery_id: - type: integer - image_path: - type: string - title: - type: string - required: - - gallery_id - type: object request.GalleryFilesUpdateRequest: properties: gallery_id: @@ -442,18 +416,6 @@ definitions: title: type: string type: object - request.ProductSpecificationsCreateRequest: - properties: - product_id: - type: integer - thumbnail_path: - type: string - title: - type: string - required: - - product_id - - title - type: object request.ProductSpecificationsUpdateRequest: properties: is_active: @@ -465,23 +427,6 @@ definitions: title: type: string type: object - request.ProductsCreateRequest: - properties: - colors: - items: - type: string - type: array - price: - type: number - thumbnail_path: - type: string - title: - type: string - variant: - type: string - required: - - title - type: object request.ProductsUpdateRequest: properties: colors: @@ -499,17 +444,6 @@ definitions: variant: type: string type: object - request.PromotionsCreateRequest: - properties: - description: - type: string - thumbnail_path: - type: string - title: - type: string - required: - - title - type: object request.PromotionsUpdateRequest: properties: description: @@ -3028,19 +2962,34 @@ paths: tags: - Banners post: - description: API for creating Banner + description: API for creating Banner with file upload parameters: - description: Insert the X-Client-Key in: header name: X-Client-Key required: true type: string - - description: Required payload - in: body - name: payload + - description: Upload file + in: formData + name: file + type: file + - description: Banner title + in: formData + name: title required: true - schema: - $ref: '#/definitions/request.BannersCreateRequest' + type: string + - description: Banner description + in: formData + name: description + type: string + - description: Banner position + in: formData + name: position + type: string + - description: Banner status + in: formData + name: status + type: string responses: "200": description: OK @@ -3175,6 +3124,42 @@ paths: summary: Update Banner tags: - Banners + /banners/viewer/{filename}: + get: + description: API for viewing Banner file + parameters: + - description: Insert the X-Client-Key + in: header + name: X-Client-Key + required: true + type: string + - description: Banner File Path + in: path + name: filename + required: true + type: string + responses: + "200": + description: OK + schema: + type: file + "400": + description: Bad Request + schema: + $ref: '#/definitions/response.BadRequestError' + "401": + description: Unauthorized + schema: + $ref: '#/definitions/response.UnauthorizedError' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/response.InternalServerError' + security: + - Bearer: [] + summary: Viewer Banner + tags: + - Banners /cities: get: description: API for getting all Cities @@ -4267,19 +4252,26 @@ paths: tags: - GalleryFiles post: - description: API for creating GalleryFile + description: API for creating GalleryFile with file upload parameters: - description: Insert the X-Client-Key in: header name: X-Client-Key required: true type: string - - description: Required payload - in: body - name: payload + - description: Upload file + in: formData + name: file + type: file + - description: Gallery ID + in: formData + name: gallery_id required: true - schema: - $ref: '#/definitions/request.GalleryFilesCreateRequest' + type: integer + - description: Gallery file title + in: formData + name: title + type: string responses: "200": description: OK @@ -4414,6 +4406,42 @@ paths: summary: Update GalleryFile tags: - GalleryFiles + /gallery-files/viewer/{filename}: + get: + description: API for viewing GalleryFile file + parameters: + - description: Insert the X-Client-Key + in: header + name: X-Client-Key + required: true + type: string + - description: Gallery File Path + in: path + name: filename + required: true + type: string + responses: + "200": + description: OK + schema: + type: file + "400": + description: Bad Request + schema: + $ref: '#/definitions/response.BadRequestError' + "401": + description: Unauthorized + schema: + $ref: '#/definitions/response.UnauthorizedError' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/response.InternalServerError' + security: + - Bearer: [] + summary: Viewer GalleryFile + tags: + - GalleryFiles /product-specifications: get: description: API for getting all ProductSpecifications @@ -4476,19 +4504,27 @@ paths: tags: - ProductSpecifications post: - description: API for creating ProductSpecification + description: API for creating ProductSpecification with file upload parameters: - description: Insert the X-Client-Key in: header name: X-Client-Key required: true type: string - - description: Required payload - in: body - name: payload + - description: Upload file + in: formData + name: file + type: file + - description: Product ID + in: formData + name: product_id required: true - schema: - $ref: '#/definitions/request.ProductSpecificationsCreateRequest' + type: integer + - description: Product specification title + in: formData + name: title + required: true + type: string responses: "200": description: OK @@ -4623,6 +4659,42 @@ paths: summary: Update ProductSpecification tags: - ProductSpecifications + /product-specifications/viewer/{filename}: + get: + description: API for viewing ProductSpecification file + parameters: + - description: Insert the X-Client-Key + in: header + name: X-Client-Key + required: true + type: string + - description: Product Specification File Path + in: path + name: filename + required: true + type: string + responses: + "200": + description: OK + schema: + type: file + "400": + description: Bad Request + schema: + $ref: '#/definitions/response.BadRequestError' + "401": + description: Unauthorized + schema: + $ref: '#/definitions/response.UnauthorizedError' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/response.InternalServerError' + security: + - Bearer: [] + summary: Viewer ProductSpecification + tags: + - ProductSpecifications /products: get: description: API for getting all Products @@ -4685,19 +4757,34 @@ paths: tags: - Products post: - description: API for creating Product + description: API for creating Product with file upload parameters: - description: Insert the X-Client-Key in: header name: X-Client-Key required: true type: string - - description: Required payload - in: body - name: payload + - description: Upload file + in: formData + name: file + type: file + - description: Product title + in: formData + name: title required: true - schema: - $ref: '#/definitions/request.ProductsCreateRequest' + type: string + - description: Product variant + in: formData + name: variant + type: string + - description: Product price + in: formData + name: price + type: number + - description: Product colors (JSON array) + in: formData + name: colors + type: string responses: "200": description: OK @@ -4832,6 +4919,42 @@ paths: summary: Update Product tags: - Products + /products/viewer/{filename}: + get: + description: API for viewing Product file + parameters: + - description: Insert the X-Client-Key + in: header + name: X-Client-Key + required: true + type: string + - description: Product File Path + in: path + name: filename + required: true + type: string + responses: + "200": + description: OK + schema: + type: file + "400": + description: Bad Request + schema: + $ref: '#/definitions/response.BadRequestError' + "401": + description: Unauthorized + schema: + $ref: '#/definitions/response.UnauthorizedError' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/response.InternalServerError' + security: + - Bearer: [] + summary: Viewer Product + tags: + - Products /promotions: get: description: API for getting all Promotions @@ -4891,19 +5014,26 @@ paths: tags: - Promotions post: - description: API for creating Promotion + description: API for creating Promotion with file upload parameters: - description: Insert the X-Client-Key in: header name: X-Client-Key required: true type: string - - description: Required payload - in: body - name: payload + - description: Upload file + in: formData + name: file + type: file + - description: Promotion title + in: formData + name: title required: true - schema: - $ref: '#/definitions/request.PromotionsCreateRequest' + type: string + - description: Promotion description + in: formData + name: description + type: string responses: "200": description: OK @@ -5038,6 +5168,42 @@ paths: summary: Update Promotion tags: - Promotions + /promotions/viewer/{filename}: + get: + description: API for viewing Promotion file + parameters: + - description: Insert the X-Client-Key + in: header + name: X-Client-Key + required: true + type: string + - description: Promotion File Path + in: path + name: filename + required: true + type: string + responses: + "200": + description: OK + schema: + type: file + "400": + description: Bad Request + schema: + $ref: '#/definitions/response.BadRequestError' + "401": + description: Unauthorized + schema: + $ref: '#/definitions/response.UnauthorizedError' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/response.InternalServerError' + security: + - Bearer: [] + summary: Viewer Promotion + tags: + - Promotions /provinces: get: description: API for getting all Provinces