diff --git a/app/database/entity/about_us_content_images.entity.go b/app/database/entity/about_us_content_images.entity.go index f5f26bf..973e07c 100644 --- a/app/database/entity/about_us_content_images.entity.go +++ b/app/database/entity/about_us_content_images.entity.go @@ -11,8 +11,7 @@ type AboutUsContentImage struct { CreatedAt time.Time `json:"created_at" gorm:"default:now()"` UpdatedAt time.Time `json:"updated_at" gorm:"default:now()"` - // relation (optional tapi bagus) - AboutUsContent AboutUsContent `json:"about_us_content" gorm:"foreignKey:AboutUsContentID"` + AboutUsContent AboutUsContent `json:"-" gorm:"foreignKey:AboutUsContentID"` } func (AboutUsContentImage) TableName() string { diff --git a/app/database/entity/about_us_contents.entity.go b/app/database/entity/about_us_contents.entity.go index 1f4804a..4cdda88 100644 --- a/app/database/entity/about_us_contents.entity.go +++ b/app/database/entity/about_us_contents.entity.go @@ -12,6 +12,8 @@ type AboutUsContent struct { IsActive *bool `json:"is_active" gorm:"type:bool;default:true"` CreatedAt time.Time `json:"created_at" gorm:"default:now()"` UpdatedAt time.Time `json:"updated_at" gorm:"default:now()"` + + Images []AboutUsContentImage `json:"images,omitempty" gorm:"foreignKey:AboutUsContentID"` } func (AboutUsContent) TableName() string { diff --git a/app/database/entity/cms_content_submissions.entity.go b/app/database/entity/cms_content_submissions.entity.go new file mode 100644 index 0000000..76d9d8f --- /dev/null +++ b/app/database/entity/cms_content_submissions.entity.go @@ -0,0 +1,26 @@ +package entity + +import ( + "time" + + "github.com/google/uuid" +) + +// CmsContentSubmission stores pending Content Website changes until an approver applies them. +type CmsContentSubmission struct { + ID uuid.UUID `json:"id" gorm:"type:uuid;primaryKey"` + ClientID uuid.UUID `json:"client_id" gorm:"type:uuid;index"` + Domain string `json:"domain" gorm:"size:32;index"` + Title string `json:"title" gorm:"size:512"` + Status string `json:"status" gorm:"size:24;index"` // pending | approved | rejected + Payload string `json:"payload" gorm:"type:text"` // JSON + SubmittedByID uint `json:"submitted_by_id" gorm:"index"` + ReviewedByID *uint `json:"reviewed_by_id"` + ReviewNote string `json:"review_note" gorm:"size:512"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +func (CmsContentSubmission) TableName() string { + return "cms_content_submissions" +} diff --git a/app/database/entity/hero_contents.entity.go b/app/database/entity/hero_contents.entity.go index 149bdf3..63367f8 100644 --- a/app/database/entity/hero_contents.entity.go +++ b/app/database/entity/hero_contents.entity.go @@ -7,7 +7,7 @@ import ( ) type HeroContents struct { - ID uuid.UUID `json:"id" gorm:"primaryKey;type:uuid;default:uuid_generate_v4()"` + ID uuid.UUID `json:"id" gorm:"primaryKey;type:uuid"` PrimaryTitle string `json:"primary_title" gorm:"type:varchar(255)"` SecondaryTitle string `json:"secondary_title" gorm:"type:varchar(255)"` Description string `json:"description" gorm:"type:text"` diff --git a/app/database/entity/hero_contents_images.entity.go b/app/database/entity/hero_contents_images.entity.go index 6e75a26..492803b 100644 --- a/app/database/entity/hero_contents_images.entity.go +++ b/app/database/entity/hero_contents_images.entity.go @@ -7,7 +7,7 @@ import ( ) type HeroContentImages struct { - ID uuid.UUID `json:"id" gorm:"primaryKey;type:uuid;default:uuid_generate_v4()"` + ID uuid.UUID `json:"id" gorm:"primaryKey;type:uuid"` HeroContentID uuid.UUID `json:"hero_content_id" gorm:"type:uuid;not null"` ImagePath string `json:"image_path" gorm:"type:text"` ImageURL string `json:"image_url" gorm:"type:text"` diff --git a/app/database/entity/media_library_items.entity.go b/app/database/entity/media_library_items.entity.go new file mode 100644 index 0000000..caa8eb4 --- /dev/null +++ b/app/database/entity/media_library_items.entity.go @@ -0,0 +1,26 @@ +package entity + +import ( + "time" + + "github.com/google/uuid" +) + +// MediaLibraryItem stores metadata for a single logical media asset (one public URL). +// The file may also be referenced from article_files, CMS image tables, etc. +type MediaLibraryItem struct { + ID uint `json:"id" gorm:"primaryKey;type:int4;autoIncrement"` + PublicURL string `json:"public_url" gorm:"type:varchar(2048);not null;uniqueIndex:ux_media_library_public_url"` + ObjectKey *string `json:"object_key" gorm:"type:varchar(1024)"` + OriginalFilename *string `json:"original_filename" gorm:"type:varchar(512)"` + FileCategory string `json:"file_category" gorm:"type:varchar(32);not null;default:other"` // image, video, audio, document, other + SizeBytes *int64 `json:"size_bytes" gorm:"type:int8"` + SourceType string `json:"source_type" gorm:"type:varchar(64);not null"` // article_file, cms, upload + SourceLabel *string `json:"source_label" gorm:"type:varchar(255)"` + ArticleFileID *uint `json:"article_file_id" gorm:"type:int4"` + CreatedByID int `json:"created_by_id" gorm:"type:int4;default:0"` + ClientID *uuid.UUID `json:"client_id" gorm:"type:UUID"` + IsActive bool `json:"is_active" gorm:"type:bool;default:true"` + CreatedAt time.Time `json:"created_at" gorm:"default:now()"` + UpdatedAt time.Time `json:"updated_at" gorm:"default:now()"` +} diff --git a/app/database/entity/our_product_content_images.entity.go b/app/database/entity/our_product_content_images.entity.go index 3868801..6a1e4ea 100644 --- a/app/database/entity/our_product_content_images.entity.go +++ b/app/database/entity/our_product_content_images.entity.go @@ -5,7 +5,7 @@ import ( ) type OurProductContentImage struct { - ID uuid.UUID `json:"id" gorm:"primaryKey;type:uuid;default:uuid_generate_v4()"` + ID uuid.UUID `json:"id" gorm:"primaryKey;type:uuid"` OurProductContentID uuid.UUID `json:"our_product_content_id" gorm:"type:uuid"` ImagePath string `json:"image_path" gorm:"type:varchar(255)"` ImageURL string `json:"image_url" gorm:"type:text"` diff --git a/app/database/entity/our_product_contents.entity.go b/app/database/entity/our_product_contents.entity.go index 1215a0d..320ff06 100644 --- a/app/database/entity/our_product_contents.entity.go +++ b/app/database/entity/our_product_contents.entity.go @@ -7,17 +7,18 @@ import ( ) type OurProductContent struct { - ID uuid.UUID `json:"id" gorm:"primaryKey;type:uuid;default:uuid_generate_v4()"` + ID uuid.UUID `json:"id" gorm:"primaryKey;type:uuid"` PrimaryTitle string `json:"primary_title" gorm:"type:varchar(255)"` SecondaryTitle string `json:"secondary_title" gorm:"type:varchar(255)"` Description string `json:"description" gorm:"type:text"` + LinkURL string `json:"link_url" gorm:"type:text"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` - IsActive *bool `json:"is_active" gorm:"default:true"` + IsActive *bool `json:"is_active" gorm:"default:true"` Images []OurProductContentImage `json:"images" gorm:"foreignKey:OurProductContentID"` } func (OurProductContent) TableName() string { return "our_product_contents" -} \ No newline at end of file +} diff --git a/app/database/entity/our_service_contents.entity.go b/app/database/entity/our_service_contents.entity.go index 8a01645..13a1a71 100644 --- a/app/database/entity/our_service_contents.entity.go +++ b/app/database/entity/our_service_contents.entity.go @@ -5,16 +5,17 @@ import ( ) type OurServiceContent struct { - ID uint `json:"id" gorm:"primaryKey;autoIncrement"` - PrimaryTitle string `json:"primary_title" gorm:"type:varchar(255)"` - SecondaryTitle string `json:"secondary_title" gorm:"type:varchar(255)"` - Description string `json:"description" gorm:"type:text"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` - IsActive *bool `json:"is_active" gorm:"default:true"` - Images []OurServiceContentImage `json:"images" gorm:"foreignKey:OurServiceContentID"` + ID uint `json:"id" gorm:"primaryKey;autoIncrement"` + PrimaryTitle string `json:"primary_title" gorm:"type:varchar(255)"` + SecondaryTitle string `json:"secondary_title" gorm:"type:varchar(255)"` + Description string `json:"description" gorm:"type:text"` + LinkURL string `json:"link_url" gorm:"type:text"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + IsActive *bool `json:"is_active" gorm:"default:true"` + Images []OurServiceContentImage `json:"images" gorm:"foreignKey:OurServiceContentID"` } func (OurServiceContent) TableName() string { return "our_service_contents" -} \ No newline at end of file +} diff --git a/app/database/entity/partner_contents.entity.go b/app/database/entity/partner_contents.entity.go index b80e01c..050041f 100644 --- a/app/database/entity/partner_contents.entity.go +++ b/app/database/entity/partner_contents.entity.go @@ -7,7 +7,7 @@ import ( ) type PartnerContent struct { - ID uuid.UUID `json:"id" gorm:"primaryKey;type:uuid;default:uuid_generate_v4()"` + ID uuid.UUID `json:"id" gorm:"primaryKey;type:uuid"` PrimaryTitle string `json:"primary_title" gorm:"type:varchar(255)"` ImagePath string `json:"image_path" gorm:"type:varchar(255)"` ImageURL string `json:"image_url" gorm:"type:text"` diff --git a/app/database/index.database.go b/app/database/index.database.go index 833372a..003111a 100644 --- a/app/database/index.database.go +++ b/app/database/index.database.go @@ -103,16 +103,18 @@ func Models() []interface{} { entity.Bookmarks{}, entity.Cities{}, entity.Clients{}, + entity.HeroContents{}, + entity.HeroContentImages{}, + entity.CmsContentSubmission{}, entity.ClientApprovalSettings{}, entity.CsrfTokenRecords{}, entity.CustomStaticPages{}, entity.Districts{}, entity.Feedbacks{}, - entity.HeroContents{}, - entity.HeroContentImages{}, entity.ForgotPasswords{}, entity.Magazines{}, entity.MagazineFiles{}, + entity.MediaLibraryItem{}, entity.MasterMenus{}, entity.MasterModules{}, entity.MasterStatuses{}, @@ -121,7 +123,11 @@ func Models() []interface{} { entity.OneTimePasswords{}, entity.OurProductContent{}, entity.OurProductContentImage{}, + entity.OurServiceContent{}, + entity.OurServiceContentImage{}, entity.PartnerContent{}, + entity.PopupNewsContents{}, + entity.PopupNewsContentImages{}, entity.Subscription{}, entity.Schedules{}, entity.UserLevels{}, diff --git a/app/module/about_us_content_images/about_us_content_images.module.go b/app/module/about_us_content_images/about_us_content_images.module.go index 68e0903..f85dd19 100644 --- a/app/module/about_us_content_images/about_us_content_images.module.go +++ b/app/module/about_us_content_images/about_us_content_images.module.go @@ -46,6 +46,7 @@ func (_i *AboutUsContentImageRouter) RegisterAboutUsContentImageRoutes() { _i.App.Route("/about-us-content-images", func(router fiber.Router) { router.Get("/", aboutUsContentImageController.All) + router.Post("/url", aboutUsContentImageController.SaveRemote) router.Get("/:id", aboutUsContentImageController.Show) // upload image (pakai form-data) diff --git a/app/module/about_us_content_images/controller/about_us_content_images.controller.go b/app/module/about_us_content_images/controller/about_us_content_images.controller.go index af5dfba..f46d9c2 100644 --- a/app/module/about_us_content_images/controller/about_us_content_images.controller.go +++ b/app/module/about_us_content_images/controller/about_us_content_images.controller.go @@ -3,12 +3,14 @@ package controller import ( "strconv" + "web-qudo-be/app/module/about_us_content_images/request" "web-qudo-be/app/module/about_us_content_images/service" "github.com/gofiber/fiber/v2" "github.com/rs/zerolog" utilRes "web-qudo-be/utils/response" + utilVal "web-qudo-be/utils/validator" ) type aboutUsContentImageController struct { @@ -20,6 +22,7 @@ type AboutUsContentImageController interface { All(c *fiber.Ctx) error Show(c *fiber.Ctx) error Save(c *fiber.Ctx) error + SaveRemote(c *fiber.Ctx) error Delete(c *fiber.Ctx) error } @@ -95,6 +98,24 @@ func (_i *aboutUsContentImageController) Save(c *fiber.Ctx) error { }) } +// SaveRemote JSON: public URL for image or video (e.g. CDN .mp4) +func (_i *aboutUsContentImageController) SaveRemote(c *fiber.Ctx) error { + req := new(request.AboutUsContentImageRemoteRequest) + if err := utilVal.ParseAndValidate(c, req); err != nil { + return err + } + result, err := _i.service.SaveRemoteURL(req.AboutUsContentID, req.MediaURL, req.MediaType) + if err != nil { + _i.Log.Error().Err(err).Msg("failed save remote about us media") + return err + } + return utilRes.Resp(c, utilRes.Response{ + Success: true, + Messages: utilRes.Messages{"About us media URL saved"}, + Data: result, + }) +} + // DELETE func (_i *aboutUsContentImageController) Delete(c *fiber.Ctx) error { id, err := strconv.Atoi(c.Params("id")) diff --git a/app/module/about_us_content_images/repository/about_us_content_images.repository.go b/app/module/about_us_content_images/repository/about_us_content_images.repository.go index 5570e85..ddf10eb 100644 --- a/app/module/about_us_content_images/repository/about_us_content_images.repository.go +++ b/app/module/about_us_content_images/repository/about_us_content_images.repository.go @@ -35,9 +35,7 @@ func NewAboutUsContentImageRepository(db *database.Database, log zerolog.Logger) // GET ALL func (_i *aboutUsContentImageRepository) GetAll() (images []*entity.AboutUsContentImage, err error) { - err = _i.DB.DB. - Where("is_active = ?", true). - Find(&images).Error + err = _i.DB.DB.Find(&images).Error return } @@ -52,7 +50,7 @@ func (_i *aboutUsContentImageRepository) FindOne(id uint) (image *entity.AboutUs // GET BY ABOUT US CONTENT ID func (_i *aboutUsContentImageRepository) FindByContentID(contentID uint) (images []*entity.AboutUsContentImage, err error) { err = _i.DB.DB. - Where("about_us_content_id = ? AND is_active = ?", contentID, true). + Where("about_us_content_id = ?", contentID). Find(&images).Error return } diff --git a/app/module/about_us_content_images/request/about_us_content_images.request.go b/app/module/about_us_content_images/request/about_us_content_images.request.go new file mode 100644 index 0000000..c065ffa --- /dev/null +++ b/app/module/about_us_content_images/request/about_us_content_images.request.go @@ -0,0 +1,7 @@ +package request + +type AboutUsContentImageRemoteRequest struct { + AboutUsContentID uint `json:"about_us_content_id" validate:"required"` + MediaURL string `json:"media_url" validate:"required"` + MediaType string `json:"media_type"` +} diff --git a/app/module/about_us_content_images/service/about_us_content_images.service.go b/app/module/about_us_content_images/service/about_us_content_images.service.go index cea0775..1d12a6d 100644 --- a/app/module/about_us_content_images/service/about_us_content_images.service.go +++ b/app/module/about_us_content_images/service/about_us_content_images.service.go @@ -2,36 +2,46 @@ package service import ( "fmt" + "mime" "mime/multipart" "path/filepath" - "time" + "strings" + "web-qudo-be/app/database/entity" "web-qudo-be/app/module/about_us_content_images/repository" + medialib "web-qudo-be/app/module/media_library/service" + minioStorage "web-qudo-be/config/config" + "web-qudo-be/utils/storage" "github.com/rs/zerolog" - - fileUtil "web-qudo-be/utils/file" ) type aboutUsContentImageService struct { - Repo repository.AboutUsContentImageRepository - Log zerolog.Logger + Repo repository.AboutUsContentImageRepository + MinioStorage *minioStorage.MinioStorage + MediaLib medialib.MediaLibraryService + Log zerolog.Logger } type AboutUsContentImageService interface { All() (images []*entity.AboutUsContentImage, err error) Show(id uint) (image *entity.AboutUsContentImage, err error) Save(aboutUsContentId uint, file *multipart.FileHeader) (image *entity.AboutUsContentImage, err error) + SaveRemoteURL(aboutUsContentID uint, mediaURL, mediaType string) (image *entity.AboutUsContentImage, err error) Delete(id uint) error } func NewAboutUsContentImageService( repo repository.AboutUsContentImageRepository, + minio *minioStorage.MinioStorage, + mediaLib medialib.MediaLibraryService, log zerolog.Logger, ) AboutUsContentImageService { return &aboutUsContentImageService{ - Repo: repo, - Log: log, + Repo: repo, + MinioStorage: minio, + MediaLib: mediaLib, + Log: log, } } @@ -48,31 +58,28 @@ func (_i *aboutUsContentImageService) Save(aboutUsContentId uint, file *multipar _i.Log.Info(). Uint("aboutUsContentId", aboutUsContentId). Str("filename", file.Filename). - Msg("upload image") + Msg("upload about us media") - // validasi file - ext := filepath.Ext(file.Filename) - if ext != ".jpg" && ext != ".jpeg" && ext != ".png" { - return nil, fmt.Errorf("invalid file type") - } - - // generate filename - filename := fmt.Sprintf("about_us_%d_%d%s", aboutUsContentId, time.Now().Unix(), ext) - - filePath := fmt.Sprintf("./uploads/%s", filename) - - // save file - if err := fileUtil.SaveFile(file, filePath); err != nil { - _i.Log.Error().Err(err).Msg("failed save file") + key, url, err := storage.UploadCMSObject(_i.MinioStorage, "about-us", file, true) + if err != nil { return nil, err } - // save ke DB + ext := strings.ToLower(filepath.Ext(file.Filename)) + mt := mime.TypeByExtension(ext) + if mt == "" { + if ext == ".mp4" || ext == ".webm" { + mt = "video/" + strings.TrimPrefix(ext, ".") + } else { + mt = "application/octet-stream" + } + } + data := &entity.AboutUsContentImage{ AboutUsContentID: aboutUsContentId, - MediaPath: filePath, - MediaType: ext, - MediaURL: "/uploads/" + filename, + MediaPath: key, + MediaType: mt, + MediaURL: url, } result, err := _i.Repo.Create(data) @@ -80,10 +87,37 @@ func (_i *aboutUsContentImageService) Save(aboutUsContentId uint, file *multipar _i.Log.Error().Err(err).Msg("failed save to DB") return nil, err } - + if _i.MediaLib != nil { + _ = _i.MediaLib.RegisterCMSAsset(url, key, "about_us", file) + } return result, nil } +func (_i *aboutUsContentImageService) SaveRemoteURL(aboutUsContentID uint, mediaURL, mediaType string) (image *entity.AboutUsContentImage, err error) { + if strings.TrimSpace(mediaURL) == "" { + return nil, fmt.Errorf("media_url is required") + } + mt := mediaType + if mt == "" { + lower := strings.ToLower(mediaURL) + if strings.HasSuffix(lower, ".mp4") || strings.Contains(lower, "video") { + mt = "video/mp4" + } else { + mt = "image/url" + } + } + data := &entity.AboutUsContentImage{ + AboutUsContentID: aboutUsContentID, + MediaURL: mediaURL, + MediaType: mt, + } + img, err := _i.Repo.Create(data) + if err == nil && _i.MediaLib != nil { + _ = _i.MediaLib.RegisterCMSAsset(mediaURL, "", "about_us_remote", nil) + } + return img, err +} + func (_i *aboutUsContentImageService) Delete(id uint) error { return _i.Repo.Delete(id) } diff --git a/app/module/about_us_contents/controller/about_us_contents.controller.go b/app/module/about_us_contents/controller/about_us_contents.controller.go index 000e715..36d9cbd 100644 --- a/app/module/about_us_contents/controller/about_us_contents.controller.go +++ b/app/module/about_us_contents/controller/about_us_contents.controller.go @@ -3,6 +3,7 @@ package controller import ( "strconv" + "web-qudo-be/app/module/about_us_contents/request" "web-qudo-be/app/module/about_us_contents/service" "github.com/gofiber/fiber/v2" @@ -71,13 +72,13 @@ func (_i *aboutUsContentController) Show(c *fiber.Ctx) error { // CREATE func (_i *aboutUsContentController) Save(c *fiber.Ctx) error { - req := new(map[string]interface{}) + req := new(request.AboutUsContentCreateRequest) if err := utilVal.ParseAndValidate(c, req); err != nil { return err } - result, err := _i.service.Save(*req) + result, err := _i.service.Save(req.ToEntity()) if err != nil { _i.Log.Error().Err(err).Msg("failed create about us content") return err @@ -97,13 +98,13 @@ func (_i *aboutUsContentController) Update(c *fiber.Ctx) error { return err } - req := new(map[string]interface{}) + req := new(request.AboutUsContentUpdateRequest) if err := utilVal.ParseAndValidate(c, req); err != nil { return err } - err = _i.service.Update(uint(id), *req) + err = _i.service.Update(uint(id), req.ToEntity()) if err != nil { _i.Log.Error().Err(err).Msg("failed update about us content") return err diff --git a/app/module/about_us_contents/repository/about_use_contents.repository.go b/app/module/about_us_contents/repository/about_use_contents.repository.go index a2abcfc..bbc7ac0 100644 --- a/app/module/about_us_contents/repository/about_use_contents.repository.go +++ b/app/module/about_us_contents/repository/about_use_contents.repository.go @@ -34,7 +34,9 @@ func (r *aboutUsContentRepository) GetAll() ([]*entity.AboutUsContent, error) { var results []*entity.AboutUsContent err := r.DB.DB. - Where("is_active = ?", true). + Preload("Images"). + Where("is_active IS NULL OR is_active = ?", true). + Order("id ASC"). Find(&results).Error if err != nil { @@ -69,12 +71,24 @@ func (r *aboutUsContentRepository) Create(data *entity.AboutUsContent) (*entity. return data, nil } -// UPDATE +// Update uses a column map so we never SET primary key / created_at to zero values (breaks FK children). func (r *aboutUsContentRepository) Update(id uint, data *entity.AboutUsContent) error { + updates := map[string]interface{}{ + "primary_title": data.PrimaryTitle, + "secondary_title": data.SecondaryTitle, + "description": data.Description, + "primary_cta": data.PrimaryCta, + "secondary_cta_text": data.SecondaryCtaText, + "updated_at": data.UpdatedAt, + } + if data.IsActive != nil { + updates["is_active"] = data.IsActive + } + err := r.DB.DB. Model(&entity.AboutUsContent{}). Where("id = ?", id). - Updates(data).Error + Updates(updates).Error if err != nil { r.Log.Error().Err(err).Msg("failed update about us content") diff --git a/app/module/about_us_contents/service/about_use_contents.service.go b/app/module/about_us_contents/service/about_use_contents.service.go index 75096e9..4bc0bcc 100644 --- a/app/module/about_us_contents/service/about_use_contents.service.go +++ b/app/module/about_us_contents/service/about_use_contents.service.go @@ -1,6 +1,8 @@ package service import ( + "time" + "github.com/rs/zerolog" "web-qudo-be/app/database/entity" @@ -15,8 +17,8 @@ type aboutUsContentService struct { type AboutUsContentService interface { All() ([]*entity.AboutUsContent, error) Show(id uint) (*entity.AboutUsContent, error) - Save(data map[string]interface{}) (*entity.AboutUsContent, error) - Update(id uint, data map[string]interface{}) error + Save(data *entity.AboutUsContent) (*entity.AboutUsContent, error) + Update(id uint, data *entity.AboutUsContent) error Delete(id uint) error } @@ -53,26 +55,8 @@ func (s *aboutUsContentService) Show(id uint) (*entity.AboutUsContent, error) { } // CREATE -func (s *aboutUsContentService) Save(data map[string]interface{}) (*entity.AboutUsContent, error) { - entityData := &entity.AboutUsContent{} - - if v, ok := data["primary_title"].(string); ok { - entityData.PrimaryTitle = v - } - if v, ok := data["secondary_title"].(string); ok { - entityData.SecondaryTitle = v - } - if v, ok := data["description"].(string); ok { - entityData.Description = v - } - if v, ok := data["primary_cta"].(string); ok { - entityData.PrimaryCta = v - } - if v, ok := data["secondary_cta_text"].(string); ok { - entityData.SecondaryCtaText = v - } - - result, err := s.Repo.Create(entityData) +func (s *aboutUsContentService) Save(data *entity.AboutUsContent) (*entity.AboutUsContent, error) { + result, err := s.Repo.Create(data) if err != nil { s.Log.Error().Err(err).Msg("failed create about us content") return nil, err @@ -82,26 +66,8 @@ func (s *aboutUsContentService) Save(data map[string]interface{}) (*entity.About } // UPDATE -func (s *aboutUsContentService) Update(id uint, data map[string]interface{}) error { - entityData := &entity.AboutUsContent{} - - if v, ok := data["primary_title"].(string); ok { - entityData.PrimaryTitle = v - } - if v, ok := data["secondary_title"].(string); ok { - entityData.SecondaryTitle = v - } - if v, ok := data["description"].(string); ok { - entityData.Description = v - } - if v, ok := data["primary_cta"].(string); ok { - entityData.PrimaryCta = v - } - if v, ok := data["secondary_cta_text"].(string); ok { - entityData.SecondaryCtaText = v - } - - err := s.Repo.Update(id, entityData) +func (s *aboutUsContentService) Update(id uint, data *entity.AboutUsContent) error { + err := s.Repo.Update(id, data) if err != nil { s.Log.Error().Err(err).Msg("failed update about us content") return err @@ -119,6 +85,7 @@ func (s *aboutUsContentService) Delete(id uint) error { isActive := false result.IsActive = &isActive + result.UpdatedAt = time.Now() return s.Repo.Update(id, result) } \ No newline at end of file diff --git a/app/module/article_files/controller/article_files.controller.go b/app/module/article_files/controller/article_files.controller.go index f1faa8a..119d334 100644 --- a/app/module/article_files/controller/article_files.controller.go +++ b/app/module/article_files/controller/article_files.controller.go @@ -134,7 +134,11 @@ func (_i *articleFilesController) Save(c *fiber.Ctx) error { return err } - err = _i.articleFilesService.Save(clientId, c, uint(id)) + uid := 0 + if u := middleware.GetUser(c); u != nil { + uid = int(u.ID) + } + err = _i.articleFilesService.Save(clientId, c, uint(id), uid) if err != nil { return err } diff --git a/app/module/article_files/service/article_files.service.go b/app/module/article_files/service/article_files.service.go index 9710e6f..803f7ff 100644 --- a/app/module/article_files/service/article_files.service.go +++ b/app/module/article_files/service/article_files.service.go @@ -18,6 +18,7 @@ import ( "web-qudo-be/app/module/article_files/repository" "web-qudo-be/app/module/article_files/request" "web-qudo-be/app/module/article_files/response" + medialib "web-qudo-be/app/module/media_library/service" config "web-qudo-be/config/config" "web-qudo-be/utils/paginator" @@ -33,13 +34,14 @@ type articleFilesService struct { Log zerolog.Logger Cfg *config.Config MinioStorage *config.MinioStorage + MediaLib medialib.MediaLibraryService } // ArticleFilesService define interface of IArticleFilesService type ArticleFilesService interface { All(clientId *uuid.UUID, req request.ArticleFilesQueryRequest) (articleFiles []*response.ArticleFilesResponse, paging paginator.Pagination, err error) Show(clientId *uuid.UUID, id uint) (articleFiles *response.ArticleFilesResponse, err error) - Save(clientId *uuid.UUID, c *fiber.Ctx, id uint) error + Save(clientId *uuid.UUID, c *fiber.Ctx, articleID uint, createdByUserID int) error SaveAsync(clientId *uuid.UUID, c *fiber.Ctx, id uint) error Update(clientId *uuid.UUID, id uint, req request.ArticleFilesUpdateRequest) (err error) GetUploadStatus(c *fiber.Ctx) (progress int, err error) @@ -48,13 +50,14 @@ type ArticleFilesService interface { } // NewArticleFilesService init ArticleFilesService -func NewArticleFilesService(repo repository.ArticleFilesRepository, log zerolog.Logger, cfg *config.Config, minioStorage *config.MinioStorage) ArticleFilesService { +func NewArticleFilesService(repo repository.ArticleFilesRepository, log zerolog.Logger, cfg *config.Config, minioStorage *config.MinioStorage, mediaLib medialib.MediaLibraryService) ArticleFilesService { return &articleFilesService{ Repo: repo, Log: log, Cfg: cfg, MinioStorage: minioStorage, + MediaLib: mediaLib, } } @@ -195,7 +198,7 @@ func (_i *articleFilesService) SaveAsync(clientId *uuid.UUID, c *fiber.Ctx, id u return } -func (_i *articleFilesService) Save(clientId *uuid.UUID, c *fiber.Ctx, id uint) (err error) { +func (_i *articleFilesService) Save(clientId *uuid.UUID, c *fiber.Ctx, articleID uint, createdByUserID int) (err error) { bucketName := _i.MinioStorage.Cfg.ObjectStorage.MinioStorage.BucketName form, err := c.MultipartForm() @@ -250,14 +253,19 @@ func (_i *articleFilesService) Save(clientId *uuid.UUID, c *fiber.Ctx, id uint) fileSize := strconv.FormatInt(fileHeader.Size, 10) req := request.ArticleFilesCreateRequest{ - ArticleId: id, + ArticleId: articleID, FilePath: &objectName, FileName: &newFilename, FileAlt: &filenameAlt, Size: &fileSize, } - err = _i.Repo.Create(clientId, req.ToEntity()) + ent := req.ToEntity() + ent.CreatedById = createdByUserID + if ent.StatusId == 0 { + ent.StatusId = 1 + } + err = _i.Repo.Create(clientId, ent) if err != nil { return err } @@ -267,6 +275,29 @@ func (_i *articleFilesService) Save(clientId *uuid.UUID, c *fiber.Ctx, id uint) if err != nil { return err } + + if _i.MediaLib != nil && ent.FileName != nil { + pub := medialib.ArticleFilePublicURL(_i.Cfg, *ent.FileName) + var sizePtr *int64 + if ent.Size != nil { + if n, perr := strconv.ParseInt(*ent.Size, 10, 64); perr == nil { + sizePtr = &n + } + } + lbl := fmt.Sprintf("article:%d", articleID) + _ = _i.MediaLib.UpsertRegister(medialib.RegisterInput{ + ClientID: clientId, + UserID: createdByUserID, + PublicURL: pub, + ObjectKey: ent.FilePath, + OriginalFilename: ent.FileName, + FileCategory: medialib.CategoryFromFilename(*ent.FileName), + SizeBytes: sizePtr, + SourceType: "article_file", + SourceLabel: &lbl, + ArticleFileID: &ent.ID, + }) + } } } diff --git a/app/module/articles/controller/articles.controller.go b/app/module/articles/controller/articles.controller.go index 7d5e906..e5f156b 100644 --- a/app/module/articles/controller/articles.controller.go +++ b/app/module/articles/controller/articles.controller.go @@ -88,6 +88,8 @@ func (_i *articlesController) All(c *fiber.Ctx) error { Source: c.Query("source"), StartDate: c.Query("startDate"), EndDate: c.Query("endDate"), + CreatedById: c.Query("createdById"), + MyContentMode: c.Query("myContentMode"), } req := reqContext.ToParamRequest() req.Pagination = paginate diff --git a/app/module/articles/repository/articles.repository.go b/app/module/articles/repository/articles.repository.go index 831427b..3d5bc16 100644 --- a/app/module/articles/repository/articles.repository.go +++ b/app/module/articles/repository/articles.repository.go @@ -111,6 +111,15 @@ func (_i *articlesRepository) GetAll(clientId *uuid.UUID, userLevelId *uint, req query = query.Where("articles.client_id = ?", clientId) } + if req.MyContentMode != nil { + mode := strings.ToLower(strings.TrimSpace(*req.MyContentMode)) + if mode == "approver" { + query = query.Where("articles.is_draft = ?", false) + query = query.Joins("JOIN users acu ON acu.id = articles.created_by_id"). + Where("acu.user_role_id = ?", 3) + } + } + if req.Title != nil && *req.Title != "" { title := strings.ToLower(*req.Title) query = query.Where("LOWER(articles.title) LIKE ?", "%"+strings.ToLower(title)+"%") diff --git a/app/module/articles/request/articles.request.go b/app/module/articles/request/articles.request.go index 6cc1e55..0bdb994 100644 --- a/app/module/articles/request/articles.request.go +++ b/app/module/articles/request/articles.request.go @@ -3,6 +3,7 @@ package request import ( "errors" "strconv" + "strings" "time" "web-qudo-be/app/database/entity" "web-qudo-be/utils/paginator" @@ -29,6 +30,8 @@ type ArticlesQueryRequest struct { StartDate *time.Time `json:"startDate"` EndDate *time.Time `json:"endDate"` Pagination *paginator.Pagination `json:"pagination"` + // myContentMode: "own" = current user's articles (any level); "approver" = non-draft from contributors (user_role_id 3) for approver history + MyContentMode *string `json:"myContentMode"` } type ArticlesCreateRequest struct { @@ -36,7 +39,7 @@ type ArticlesCreateRequest struct { Slug string `json:"slug" validate:"required"` Description string `json:"description" validate:"required"` HtmlDescription string `json:"htmlDescription" validate:"required"` - CategoryIds string `json:"categoryIds" validate:"required"` + CategoryIds string `json:"categoryIds" validate:"omitempty"` TypeId int `json:"typeId" validate:"required"` Tags string `json:"tags" validate:"required"` AiArticleId *int `json:"aiArticleId"` @@ -71,7 +74,7 @@ type ArticlesUpdateRequest struct { Slug string `json:"slug" validate:"required"` Description string `json:"description" validate:"required"` HtmlDescription string `json:"htmlDescription" validate:"required"` - CategoryIds string `json:"categoryIds" validate:"required"` + CategoryIds string `json:"categoryIds" validate:"omitempty"` TypeId int `json:"typeId" validate:"required"` Tags string `json:"tags" validate:"required"` CreatedAt *string `json:"createdAt"` @@ -137,6 +140,7 @@ type ArticlesQueryRequestContext struct { CustomCreatorName string `json:"customCreatorName"` StartDate string `json:"startDate"` EndDate string `json:"endDate"` + MyContentMode string `json:"myContentMode"` } func (req ArticlesQueryRequestContext) ToParamRequest() ArticlesQueryRequest { @@ -213,6 +217,9 @@ func (req ArticlesQueryRequestContext) ToParamRequest() ArticlesQueryRequest { request.EndDate = &endDate } } + if m := strings.TrimSpace(req.MyContentMode); m != "" { + request.MyContentMode = &m + } return request } diff --git a/app/module/articles/service/articles.service.go b/app/module/articles/service/articles.service.go index 82f532f..ec52f3a 100644 --- a/app/module/articles/service/articles.service.go +++ b/app/module/articles/service/articles.service.go @@ -120,27 +120,57 @@ func NewArticlesService( } } +const ( + userRoleAdmin = uint(1) + userRoleApprover = uint(2) + userRoleContributor = uint(3) +) + +func canUseMyContentApproverMode(roleID uint) bool { + return roleID == userRoleApprover || roleID == userRoleAdmin +} + // All implement interface of ArticlesService func (_i *articlesService) All(clientId *uuid.UUID, authToken string, req request.ArticlesQueryRequest) (articless []*response.ArticlesResponse, paging paginator.Pagination, err error) { - // Extract userLevelId from authToken var userLevelId *uint + reqScoped := req + if authToken != "" { user := utilSvc.GetUserInfo(_i.Log, _i.UsersRepo, authToken) if user != nil { - userLevelId = &user.UserLevelId - _i.Log.Info().Interface("userLevelId", userLevelId).Msg("Extracted userLevelId from auth token") + if req.MyContentMode != nil { + mode := strings.ToLower(strings.TrimSpace(*req.MyContentMode)) + switch mode { + case "own": + cb := int(user.ID) + reqScoped.CreatedById = &cb + userLevelId = nil + _i.Log.Info().Uint("userId", user.ID).Msg("myContentMode=own: list own articles without level visibility filter") + case "approver": + if !canUseMyContentApproverMode(user.UserRoleId) { + return nil, paging, errors.New("myContentMode approver requires approver or admin role") + } + userLevelId = nil + _i.Log.Info().Msg("myContentMode=approver: list contributor non-draft articles") + default: + userLevelId = &user.UserLevelId + } + } else { + userLevelId = &user.UserLevelId + } + _i.Log.Info().Interface("userLevelId", userLevelId).Msg("Articles.All visibility") } } - if req.Category != nil { - findCategory, err := _i.ArticleCategoriesRepo.FindOneBySlug(clientId, *req.Category) + if reqScoped.Category != nil { + findCategory, err := _i.ArticleCategoriesRepo.FindOneBySlug(clientId, *reqScoped.Category) if err != nil { return nil, paging, err } - req.CategoryId = &findCategory.ID + reqScoped.CategoryId = &findCategory.ID } - results, paging, err := _i.Repo.GetAll(clientId, userLevelId, req) + results, paging, err := _i.Repo.GetAll(clientId, userLevelId, reqScoped) if err != nil { return } @@ -336,14 +366,23 @@ func (_i *articlesService) Save(clientId *uuid.UUID, req request.ArticlesCreateR } var categoryIds []string - if req.CategoryIds != "" { - categoryIds = strings.Split(req.CategoryIds, ",") + if strings.TrimSpace(req.CategoryIds) != "" { + for _, part := range strings.Split(req.CategoryIds, ",") { + part = strings.TrimSpace(part) + if part == "" { + continue + } + categoryIds = append(categoryIds, part) + } } _i.Log.Info().Interface("categoryIds", categoryIds).Msg("") for _, categoryId := range categoryIds { - categoryIdInt, _ := strconv.Atoi(categoryId) + categoryIdInt, parseErr := strconv.Atoi(categoryId) + if parseErr != nil || categoryIdInt <= 0 { + continue + } _i.Log.Info().Interface("categoryIdUint", uint(categoryIdInt)).Msg("") diff --git a/app/module/cms_content_submissions/cms_content_submissions.module.go b/app/module/cms_content_submissions/cms_content_submissions.module.go new file mode 100644 index 0000000..7ec8d76 --- /dev/null +++ b/app/module/cms_content_submissions/cms_content_submissions.module.go @@ -0,0 +1,48 @@ +package cms_content_submissions + +import ( + "web-qudo-be/app/middleware" + "web-qudo-be/app/module/cms_content_submissions/controller" + "web-qudo-be/app/module/cms_content_submissions/repository" + "web-qudo-be/app/module/cms_content_submissions/service" + usersRepo "web-qudo-be/app/module/users/repository" + + "github.com/gofiber/fiber/v2" + "go.uber.org/fx" +) + +type CmsContentSubmissionsRouter struct { + App fiber.Router + Controller *controller.Controller + UsersRepo usersRepo.UsersRepository +} + +var NewCmsContentSubmissionsModule = fx.Options( + fx.Provide(repository.NewCmsContentSubmissionsRepository), + fx.Provide(service.NewCmsContentSubmissionsService), + fx.Provide(controller.NewController), + fx.Provide(NewCmsContentSubmissionsRouter), +) + +func NewCmsContentSubmissionsRouter( + fiber *fiber.App, + ctrl *controller.Controller, + usersRepo usersRepo.UsersRepository, +) *CmsContentSubmissionsRouter { + return &CmsContentSubmissionsRouter{ + App: fiber, + Controller: ctrl, + UsersRepo: usersRepo, + } +} + +func (_i *CmsContentSubmissionsRouter) RegisterCmsContentSubmissionsRoutes() { + h := _i.Controller.CmsContentSubmissions + _i.App.Route("/cms-content-submissions", func(router fiber.Router) { + router.Use(middleware.UserMiddleware(_i.UsersRepo)) + router.Post("/", h.Submit) + router.Get("/", h.List) + router.Post("/:id/approve", h.Approve) + router.Post("/:id/reject", h.Reject) + }) +} diff --git a/app/module/cms_content_submissions/controller/cms_content_submissions.controller.go b/app/module/cms_content_submissions/controller/cms_content_submissions.controller.go new file mode 100644 index 0000000..6af8819 --- /dev/null +++ b/app/module/cms_content_submissions/controller/cms_content_submissions.controller.go @@ -0,0 +1,114 @@ +package controller + +import ( + "web-qudo-be/app/middleware" + "web-qudo-be/app/module/cms_content_submissions/request" + "web-qudo-be/app/module/cms_content_submissions/service" + "web-qudo-be/utils/paginator" + + "github.com/gofiber/fiber/v2" + "github.com/google/uuid" + + utilRes "web-qudo-be/utils/response" + utilVal "web-qudo-be/utils/validator" +) + +type CmsContentSubmissionsController interface { + Submit(c *fiber.Ctx) error + List(c *fiber.Ctx) error + Approve(c *fiber.Ctx) error + Reject(c *fiber.Ctx) error +} + +type cmsContentSubmissionsController struct { + svc service.CmsContentSubmissionsService +} + +func NewCmsContentSubmissionsController(svc service.CmsContentSubmissionsService) CmsContentSubmissionsController { + return &cmsContentSubmissionsController{svc: svc} +} + +func (_i *cmsContentSubmissionsController) Submit(c *fiber.Ctx) error { + user := middleware.GetUser(c) + clientID := middleware.GetClientID(c) + if user == nil || clientID == nil { + return fiber.NewError(fiber.StatusUnauthorized, "Unauthorized") + } + req := new(request.SubmitCmsContentSubmissionRequest) + if err := utilVal.ParseAndValidate(c, req); err != nil { + return err + } + row, err := _i.svc.Submit(clientID, user, req) + if err != nil { + return fiber.NewError(fiber.StatusBadRequest, err.Error()) + } + return utilRes.Resp(c, utilRes.Response{ + Success: true, + Messages: utilRes.Messages{"CMS submission saved"}, + Data: row, + }) +} + +func (_i *cmsContentSubmissionsController) List(c *fiber.Ctx) error { + user := middleware.GetUser(c) + clientID := middleware.GetClientID(c) + if user == nil || clientID == nil { + return fiber.NewError(fiber.StatusUnauthorized, "Unauthorized") + } + p, err := paginator.Paginate(c) + if err != nil { + return err + } + status := c.Query("status") + mineOnly := c.Query("mine") == "1" || c.Query("mine") == "true" + rows, paging, err := _i.svc.List(clientID, user, status, mineOnly, p) + if err != nil { + return fiber.NewError(fiber.StatusBadRequest, err.Error()) + } + return utilRes.Resp(c, utilRes.Response{ + Success: true, + Messages: utilRes.Messages{"CMS submissions loaded"}, + Data: rows, + Meta: paging, + }) +} + +func (_i *cmsContentSubmissionsController) Approve(c *fiber.Ctx) error { + user := middleware.GetUser(c) + clientID := middleware.GetClientID(c) + if user == nil || clientID == nil { + return fiber.NewError(fiber.StatusUnauthorized, "Unauthorized") + } + id, err := uuid.Parse(c.Params("id")) + if err != nil { + return err + } + if err := _i.svc.Approve(clientID, user, id); err != nil { + return fiber.NewError(fiber.StatusBadRequest, err.Error()) + } + return utilRes.Resp(c, utilRes.Response{ + Success: true, + Messages: utilRes.Messages{"CMS submission approved and applied"}, + }) +} + +func (_i *cmsContentSubmissionsController) Reject(c *fiber.Ctx) error { + user := middleware.GetUser(c) + clientID := middleware.GetClientID(c) + if user == nil || clientID == nil { + return fiber.NewError(fiber.StatusUnauthorized, "Unauthorized") + } + id, err := uuid.Parse(c.Params("id")) + if err != nil { + return err + } + req := new(request.RejectCmsContentSubmissionRequest) + _ = c.BodyParser(req) + if err := _i.svc.Reject(clientID, user, id, req.Note); err != nil { + return fiber.NewError(fiber.StatusBadRequest, err.Error()) + } + return utilRes.Resp(c, utilRes.Response{ + Success: true, + Messages: utilRes.Messages{"CMS submission rejected"}, + }) +} diff --git a/app/module/cms_content_submissions/controller/controller.go b/app/module/cms_content_submissions/controller/controller.go new file mode 100644 index 0000000..3419963 --- /dev/null +++ b/app/module/cms_content_submissions/controller/controller.go @@ -0,0 +1,21 @@ +package controller + +import ( + "web-qudo-be/app/module/cms_content_submissions/service" + + "github.com/rs/zerolog" +) + +type Controller struct { + CmsContentSubmissions CmsContentSubmissionsController +} + +func NewController( + svc service.CmsContentSubmissionsService, + log zerolog.Logger, +) *Controller { + _ = log + return &Controller{ + CmsContentSubmissions: NewCmsContentSubmissionsController(svc), + } +} diff --git a/app/module/cms_content_submissions/repository/cms_content_submissions.repository.go b/app/module/cms_content_submissions/repository/cms_content_submissions.repository.go new file mode 100644 index 0000000..d29a8f4 --- /dev/null +++ b/app/module/cms_content_submissions/repository/cms_content_submissions.repository.go @@ -0,0 +1,73 @@ +package repository + +import ( + "strings" + "web-qudo-be/app/database" + "web-qudo-be/app/database/entity" + "web-qudo-be/utils/paginator" + + "github.com/google/uuid" + "github.com/rs/zerolog" +) + +type CmsContentSubmissionsRepository interface { + Create(row *entity.CmsContentSubmission) error + FindByID(clientID uuid.UUID, id uuid.UUID) (*entity.CmsContentSubmission, error) + List(clientID uuid.UUID, status string, submittedByID *uint, p *paginator.Pagination) ([]entity.CmsContentSubmission, *paginator.Pagination, error) + Update(row *entity.CmsContentSubmission) error +} + +type cmsContentSubmissionsRepository struct { + DB *database.Database + Log zerolog.Logger +} + +func NewCmsContentSubmissionsRepository(db *database.Database, log zerolog.Logger) CmsContentSubmissionsRepository { + return &cmsContentSubmissionsRepository{DB: db, Log: log} +} + +func (_i *cmsContentSubmissionsRepository) Create(row *entity.CmsContentSubmission) error { + return _i.DB.DB.Create(row).Error +} + +func (_i *cmsContentSubmissionsRepository) FindByID(clientID uuid.UUID, id uuid.UUID) (*entity.CmsContentSubmission, error) { + var row entity.CmsContentSubmission + err := _i.DB.DB.Where("client_id = ? AND id = ?", clientID, id).First(&row).Error + if err != nil { + return nil, err + } + return &row, nil +} + +func (_i *cmsContentSubmissionsRepository) List(clientID uuid.UUID, status string, submittedByID *uint, p *paginator.Pagination) ([]entity.CmsContentSubmission, *paginator.Pagination, error) { + var rows []entity.CmsContentSubmission + var count int64 + + q := _i.DB.DB.Model(&entity.CmsContentSubmission{}).Where("client_id = ?", clientID) + st := strings.TrimSpace(strings.ToLower(status)) + if st != "" && st != "all" { + q = q.Where("status = ?", strings.TrimSpace(status)) + } + if submittedByID != nil { + q = q.Where("submitted_by_id = ?", *submittedByID) + } + if err := q.Count(&count).Error; err != nil { + return nil, p, err + } + p.Count = count + p = paginator.Paging(p) + order := "created_at DESC" + if p.SortBy != "" { + dir := "DESC" + if p.Sort == "asc" { + dir = "ASC" + } + order = p.SortBy + " " + dir + } + err := q.Order(order).Offset(p.Offset).Limit(p.Limit).Find(&rows).Error + return rows, p, err +} + +func (_i *cmsContentSubmissionsRepository) Update(row *entity.CmsContentSubmission) error { + return _i.DB.DB.Save(row).Error +} diff --git a/app/module/cms_content_submissions/request/cms_content_submissions.request.go b/app/module/cms_content_submissions/request/cms_content_submissions.request.go new file mode 100644 index 0000000..1939b9f --- /dev/null +++ b/app/module/cms_content_submissions/request/cms_content_submissions.request.go @@ -0,0 +1,13 @@ +package request + +import "encoding/json" + +type SubmitCmsContentSubmissionRequest struct { + Domain string `json:"domain" validate:"required"` + Title string `json:"title" validate:"required"` + Payload json.RawMessage `json:"payload" validate:"required"` +} + +type RejectCmsContentSubmissionRequest struct { + Note string `json:"note"` +} diff --git a/app/module/cms_content_submissions/response/cms_content_submissions.response.go b/app/module/cms_content_submissions/response/cms_content_submissions.response.go new file mode 100644 index 0000000..7064475 --- /dev/null +++ b/app/module/cms_content_submissions/response/cms_content_submissions.response.go @@ -0,0 +1,19 @@ +package response + +import ( + "time" + + "github.com/google/uuid" +) + +type CmsContentSubmissionListItem struct { + ID uuid.UUID `json:"id"` + Domain string `json:"domain"` + Title string `json:"title"` + Status string `json:"status"` + Payload string `json:"payload"` + SubmittedByID uint `json:"submitted_by_id"` + SubmitterName string `json:"submitter_name"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} diff --git a/app/module/cms_content_submissions/service/cms_content_submissions.service.go b/app/module/cms_content_submissions/service/cms_content_submissions.service.go new file mode 100644 index 0000000..3dfe009 --- /dev/null +++ b/app/module/cms_content_submissions/service/cms_content_submissions.service.go @@ -0,0 +1,623 @@ +package service + +import ( + "encoding/json" + "errors" + "strconv" + "strings" + "time" + + "web-qudo-be/app/database" + "web-qudo-be/app/database/entity" + "web-qudo-be/app/database/entity/users" + aboutUsImageSvc "web-qudo-be/app/module/about_us_content_images/service" + aboutUsSvc "web-qudo-be/app/module/about_us_contents/service" + "web-qudo-be/app/module/cms_content_submissions/repository" + "web-qudo-be/app/module/cms_content_submissions/request" + "web-qudo-be/app/module/cms_content_submissions/response" + heroImageSvc "web-qudo-be/app/module/hero_content_images/service" + heroSvc "web-qudo-be/app/module/hero_contents/service" + ourProductImageSvc "web-qudo-be/app/module/our_product_content_images/service" + ourProductSvc "web-qudo-be/app/module/our_product_contents/service" + ourServiceImageSvc "web-qudo-be/app/module/our_service_content_images/service" + ourServiceSvc "web-qudo-be/app/module/our_service_contents/service" + partnerSvc "web-qudo-be/app/module/partner_contents/service" + popupImageReq "web-qudo-be/app/module/popup_news_content_images/request" + popupImageSvc "web-qudo-be/app/module/popup_news_content_images/service" + popupNewsReq "web-qudo-be/app/module/popup_news_contents/request" + popupSvc "web-qudo-be/app/module/popup_news_contents/service" + "web-qudo-be/utils/paginator" + + "github.com/google/uuid" + "github.com/rs/zerolog" +) + +const ( + cmsSubmissionPending = "pending" + cmsSubmissionApproved = "approved" + cmsSubmissionRejected = "rejected" + userRoleAdmin = uint(1) + userRoleApprover = uint(2) + userRoleContributor = uint(3) +) + +func canApproveCmsSubmissions(roleID uint) bool { + return roleID == userRoleApprover || roleID == userRoleAdmin +} + +type CmsContentSubmissionsService interface { + Submit(clientID *uuid.UUID, user *users.Users, req *request.SubmitCmsContentSubmissionRequest) (*entity.CmsContentSubmission, error) + List(clientID *uuid.UUID, user *users.Users, status string, mineOnly bool, p *paginator.Pagination) ([]response.CmsContentSubmissionListItem, *paginator.Pagination, error) + Approve(clientID *uuid.UUID, user *users.Users, id uuid.UUID) error + Reject(clientID *uuid.UUID, user *users.Users, id uuid.UUID, note string) error +} + +type cmsContentSubmissionsService struct { + Repo repository.CmsContentSubmissionsRepository + DB *database.Database + Hero heroSvc.HeroContentsService + HeroImg heroImageSvc.HeroContentImagesService + About aboutUsSvc.AboutUsContentService + AboutImg aboutUsImageSvc.AboutUsContentImageService + OurProduct ourProductSvc.OurProductContentService + OurProductImg ourProductImageSvc.OurProductContentImagesService + OurService ourServiceSvc.OurServiceContentService + OurServiceImg ourServiceImageSvc.OurServiceContentImagesService + Partner partnerSvc.PartnerContentService + Popup popupSvc.PopupNewsContentsService + PopupImg popupImageSvc.PopupNewsContentImagesService + Log zerolog.Logger +} + +func NewCmsContentSubmissionsService( + repo repository.CmsContentSubmissionsRepository, + db *database.Database, + hero heroSvc.HeroContentsService, + heroImg heroImageSvc.HeroContentImagesService, + about aboutUsSvc.AboutUsContentService, + aboutImg aboutUsImageSvc.AboutUsContentImageService, + ourProduct ourProductSvc.OurProductContentService, + ourProductImg ourProductImageSvc.OurProductContentImagesService, + ourService ourServiceSvc.OurServiceContentService, + ourServiceImg ourServiceImageSvc.OurServiceContentImagesService, + partner partnerSvc.PartnerContentService, + popup popupSvc.PopupNewsContentsService, + popupImg popupImageSvc.PopupNewsContentImagesService, + log zerolog.Logger, +) CmsContentSubmissionsService { + return &cmsContentSubmissionsService{ + Repo: repo, + DB: db, + Hero: hero, + HeroImg: heroImg, + About: about, + AboutImg: aboutImg, + OurProduct: ourProduct, + OurProductImg: ourProductImg, + OurService: ourService, + OurServiceImg: ourServiceImg, + Partner: partner, + Popup: popup, + PopupImg: popupImg, + Log: log, + } +} + +func (_i *cmsContentSubmissionsService) Submit(clientID *uuid.UUID, user *users.Users, req *request.SubmitCmsContentSubmissionRequest) (*entity.CmsContentSubmission, error) { + if clientID == nil || user == nil { + return nil, errors.New("unauthorized") + } + if user.UserRoleId != userRoleContributor { + return nil, errors.New("only contributor role can submit CMS drafts") + } + domain := strings.TrimSpace(strings.ToLower(req.Domain)) + if domain == "" { + return nil, errors.New("domain is required") + } + title := strings.TrimSpace(req.Title) + if title == "" { + return nil, errors.New("title is required") + } + if len(req.Payload) == 0 || string(req.Payload) == "null" { + return nil, errors.New("payload is required") + } + row := &entity.CmsContentSubmission{ + ID: uuid.New(), + ClientID: *clientID, + Domain: domain, + Title: title, + Status: cmsSubmissionPending, + Payload: string(req.Payload), + SubmittedByID: user.ID, + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + } + if err := _i.Repo.Create(row); err != nil { + return nil, err + } + return row, nil +} + +func (_i *cmsContentSubmissionsService) List(clientID *uuid.UUID, user *users.Users, status string, mineOnly bool, p *paginator.Pagination) ([]response.CmsContentSubmissionListItem, *paginator.Pagination, error) { + if clientID == nil || user == nil { + return nil, p, errors.New("unauthorized") + } + st := strings.TrimSpace(strings.ToLower(status)) + var submittedBy *uint + if mineOnly { + submittedBy = &user.ID + } else if user.UserRoleId == userRoleContributor { + submittedBy = &user.ID + } + statusArg := status + if st == "" { + statusArg = "all" + } + rows, paging, err := _i.Repo.List(*clientID, statusArg, submittedBy, p) + if err != nil { + return nil, paging, err + } + out := make([]response.CmsContentSubmissionListItem, 0, len(rows)) + for _, row := range rows { + name := "" + var u users.Users + if err := _i.DB.DB.Select("fullname").Where("id = ?", row.SubmittedByID).First(&u).Error; err == nil { + name = u.Fullname + } + out = append(out, response.CmsContentSubmissionListItem{ + ID: row.ID, + Domain: row.Domain, + Title: row.Title, + Status: row.Status, + Payload: row.Payload, + SubmittedByID: row.SubmittedByID, + SubmitterName: name, + CreatedAt: row.CreatedAt, + UpdatedAt: row.UpdatedAt, + }) + } + return out, paging, nil +} + +func (_i *cmsContentSubmissionsService) Approve(clientID *uuid.UUID, user *users.Users, id uuid.UUID) error { + if clientID == nil || user == nil { + return errors.New("unauthorized") + } + if !canApproveCmsSubmissions(user.UserRoleId) { + return errors.New("only approver or admin role can approve CMS submissions") + } + row, err := _i.Repo.FindByID(*clientID, id) + if err != nil { + return err + } + if row.Status != cmsSubmissionPending { + return errors.New("submission is not pending") + } + if err := _i.applyDomainPayload(row.Domain, row.Payload); err != nil { + return err + } + now := time.Now() + row.Status = cmsSubmissionApproved + row.ReviewedByID = &user.ID + row.ReviewNote = "" + row.UpdatedAt = now + return _i.Repo.Update(row) +} + +func (_i *cmsContentSubmissionsService) Reject(clientID *uuid.UUID, user *users.Users, id uuid.UUID, note string) error { + if clientID == nil || user == nil { + return errors.New("unauthorized") + } + if !canApproveCmsSubmissions(user.UserRoleId) { + return errors.New("only approver or admin role can reject CMS submissions") + } + row, err := _i.Repo.FindByID(*clientID, id) + if err != nil { + return err + } + if row.Status != cmsSubmissionPending { + return errors.New("submission is not pending") + } + now := time.Now() + row.Status = cmsSubmissionRejected + row.ReviewedByID = &user.ID + row.ReviewNote = strings.TrimSpace(note) + row.UpdatedAt = now + return _i.Repo.Update(row) +} + +func (_i *cmsContentSubmissionsService) applyDomainPayload(domain string, payloadJSON string) error { + switch strings.ToLower(strings.TrimSpace(domain)) { + case "hero": + return _i.mergeHero([]byte(payloadJSON)) + case "about": + return _i.mergeAbout([]byte(payloadJSON)) + case "product": + return _i.mergeProduct([]byte(payloadJSON)) + case "service": + return _i.mergeService([]byte(payloadJSON)) + case "partner": + return _i.mergePartner([]byte(payloadJSON)) + case "popup": + return _i.mergePopup([]byte(payloadJSON)) + default: + return errors.New("unknown domain") + } +} + +type heroPayload struct { + Action string `json:"action"` + HeroID string `json:"hero_id"` + HeroImageID string `json:"hero_image_id"` + PrimaryTitle string `json:"primary_title"` + SecondaryTitle string `json:"secondary_title"` + Description string `json:"description"` + PrimaryCta string `json:"primary_cta"` + SecondaryCtaText string `json:"secondary_cta_text"` + ImageURL string `json:"image_url"` +} + +func (_i *cmsContentSubmissionsService) mergeHero(raw []byte) error { + var p heroPayload + if err := json.Unmarshal(raw, &p); err != nil { + return err + } + if strings.EqualFold(p.Action, "delete") { + return errors.New("hero delete is not supported") + } + ent := &entity.HeroContents{ + PrimaryTitle: p.PrimaryTitle, + SecondaryTitle: p.SecondaryTitle, + Description: p.Description, + PrimaryCta: p.PrimaryCta, + SecondaryCtaText: p.SecondaryCtaText, + } + var heroUUID uuid.UUID + if strings.TrimSpace(p.HeroID) == "" { + saved, err := _i.Hero.Save(ent) + if err != nil { + return err + } + heroUUID = saved.ID + } else { + id, err := uuid.Parse(p.HeroID) + if err != nil { + return err + } + heroUUID = id + if err := _i.Hero.Update(heroUUID, ent); err != nil { + return err + } + } + imgURL := strings.TrimSpace(p.ImageURL) + if imgURL == "" { + return nil + } + if strings.TrimSpace(p.HeroImageID) != "" { + imgID, err := uuid.Parse(p.HeroImageID) + if err != nil { + return err + } + return _i.HeroImg.Update(imgID, &entity.HeroContentImages{ + ID: imgID, + HeroContentID: heroUUID, + ImageURL: imgURL, + }) + } + _, err := _i.HeroImg.Save(&entity.HeroContentImages{ + HeroContentID: heroUUID, + ImageURL: imgURL, + }) + return err +} + +type aboutPayload struct { + Action string `json:"action"` + AboutID *int `json:"about_id"` + AboutMediaImageID *int `json:"about_media_image_id"` + PrimaryTitle string `json:"primary_title"` + SecondaryTitle string `json:"secondary_title"` + Description string `json:"description"` + PrimaryCta string `json:"primary_cta"` + SecondaryCtaText string `json:"secondary_cta_text"` + MediaURL string `json:"media_url"` +} + +func (_i *cmsContentSubmissionsService) mergeAbout(raw []byte) error { + var p aboutPayload + if err := json.Unmarshal(raw, &p); err != nil { + return err + } + if strings.EqualFold(p.Action, "delete") { + return errors.New("about delete is not supported") + } + ent := &entity.AboutUsContent{ + PrimaryTitle: p.PrimaryTitle, + SecondaryTitle: p.SecondaryTitle, + Description: p.Description, + PrimaryCta: p.PrimaryCta, + SecondaryCtaText: p.SecondaryCtaText, + } + var aboutID uint + if p.AboutID == nil || *p.AboutID == 0 { + saved, err := _i.About.Save(ent) + if err != nil { + return err + } + aboutID = saved.ID + } else { + aboutID = uint(*p.AboutID) + if err := _i.About.Update(aboutID, ent); err != nil { + return err + } + } + mediaURL := strings.TrimSpace(p.MediaURL) + if mediaURL == "" { + return nil + } + if p.AboutMediaImageID != nil && *p.AboutMediaImageID > 0 { + _ = _i.AboutImg.Delete(uint(*p.AboutMediaImageID)) + } + _, err := _i.AboutImg.SaveRemoteURL(aboutID, mediaURL, "") + return err +} + +type productPayload struct { + Action string `json:"action"` + ProductID string `json:"product_id"` + ProductImageID string `json:"product_image_id"` + PrimaryTitle string `json:"primary_title"` + SecondaryTitle string `json:"secondary_title"` + Description string `json:"description"` + LinkURL string `json:"link_url"` + ImageURL string `json:"image_url"` +} + +func (_i *cmsContentSubmissionsService) mergeProduct(raw []byte) error { + var p productPayload + if err := json.Unmarshal(raw, &p); err != nil { + return err + } + if strings.EqualFold(p.Action, "delete") { + if strings.TrimSpace(p.ProductID) == "" { + return errors.New("product_id required for delete") + } + id, err := uuid.Parse(p.ProductID) + if err != nil { + return err + } + // Remove images first (FK from our_product_content_images → our_product_contents). + imgs, err := _i.OurProductImg.FindByContentID(id) + if err != nil { + return err + } + for i := range imgs { + if err := _i.OurProductImg.Delete(imgs[i].ID); err != nil { + return err + } + } + return _i.OurProduct.Delete(id) + } + ent := &entity.OurProductContent{ + PrimaryTitle: p.PrimaryTitle, + SecondaryTitle: p.SecondaryTitle, + Description: p.Description, + LinkURL: p.LinkURL, + } + var pid uuid.UUID + if strings.TrimSpace(p.ProductID) == "" { + saved, err := _i.OurProduct.Save(ent) + if err != nil { + return err + } + pid = saved.ID + } else { + id, err := uuid.Parse(p.ProductID) + if err != nil { + return err + } + pid = id + if err := _i.OurProduct.Update(pid, ent); err != nil { + return err + } + } + imgURL := strings.TrimSpace(p.ImageURL) + if imgURL == "" { + return nil + } + if strings.TrimSpace(p.ProductImageID) != "" { + imgID, err := uuid.Parse(p.ProductImageID) + if err != nil { + return err + } + return _i.OurProductImg.Update(imgID, &entity.OurProductContentImage{ + ID: imgID, + OurProductContentID: pid, + ImageURL: imgURL, + }) + } + _, err := _i.OurProductImg.Save(&entity.OurProductContentImage{ + OurProductContentID: pid, + ImageURL: imgURL, + }) + return err +} + +type servicePayload struct { + Action string `json:"action"` + ServiceID *int `json:"service_id"` + ServiceImageID string `json:"service_image_id"` + PrimaryTitle string `json:"primary_title"` + SecondaryTitle string `json:"secondary_title"` + Description string `json:"description"` + LinkURL string `json:"link_url"` + ImageURL string `json:"image_url"` +} + +func (_i *cmsContentSubmissionsService) mergeService(raw []byte) error { + var p servicePayload + if err := json.Unmarshal(raw, &p); err != nil { + return err + } + if strings.EqualFold(p.Action, "delete") { + if p.ServiceID == nil || *p.ServiceID == 0 { + return errors.New("service_id required for delete") + } + sid := uint(*p.ServiceID) + // Remove images first (FK from our_service_content_images → our_service_contents). + simgs, err := _i.OurServiceImg.FindByContentID(sid) + if err != nil { + return err + } + for i := range simgs { + if err := _i.OurServiceImg.Delete(simgs[i].ID); err != nil { + return err + } + } + return _i.OurService.Delete(sid) + } + ent := &entity.OurServiceContent{ + PrimaryTitle: p.PrimaryTitle, + SecondaryTitle: p.SecondaryTitle, + Description: p.Description, + LinkURL: p.LinkURL, + } + var sid uint + if p.ServiceID == nil || *p.ServiceID == 0 { + saved, err := _i.OurService.Save(ent) + if err != nil { + return err + } + sid = saved.ID + } else { + sid = uint(*p.ServiceID) + if err := _i.OurService.Update(sid, ent); err != nil { + return err + } + } + imgURL := strings.TrimSpace(p.ImageURL) + if imgURL == "" { + return nil + } + return _i.mergeServiceImage(sid, strings.TrimSpace(p.ServiceImageID), imgURL) +} + +func (_i *cmsContentSubmissionsService) mergeServiceImage(sid uint, serviceImageID string, imgURL string) error { + if serviceImageID != "" { + n, err := strconv.ParseUint(serviceImageID, 10, 64) + if err != nil { + return err + } + imgID := uint(n) + return _i.OurServiceImg.Update(imgID, &entity.OurServiceContentImage{ + ID: imgID, + OurServiceContentID: sid, + ImageURL: imgURL, + }) + } + _, err := _i.OurServiceImg.Save(&entity.OurServiceContentImage{ + OurServiceContentID: sid, + ImageURL: imgURL, + }) + return err +} + +type partnerPayload struct { + Action string `json:"action"` + PartnerID string `json:"partner_id"` + PrimaryTitle string `json:"primary_title"` + ImagePath string `json:"image_path"` + ImageURL string `json:"image_url"` +} + +func (_i *cmsContentSubmissionsService) mergePartner(raw []byte) error { + var p partnerPayload + if err := json.Unmarshal(raw, &p); err != nil { + return err + } + if strings.EqualFold(p.Action, "delete") { + if strings.TrimSpace(p.PartnerID) == "" { + return errors.New("partner_id required for delete") + } + id, err := uuid.Parse(p.PartnerID) + if err != nil { + return err + } + return _i.Partner.Delete(id) + } + ent := &entity.PartnerContent{ + PrimaryTitle: strings.TrimSpace(p.PrimaryTitle), + ImagePath: p.ImagePath, + ImageURL: strings.TrimSpace(p.ImageURL), + } + if strings.TrimSpace(p.PartnerID) == "" { + _, err := _i.Partner.Save(ent) + return err + } + id, err := uuid.Parse(p.PartnerID) + if err != nil { + return err + } + return _i.Partner.Update(id, ent) +} + +type popupPayload struct { + Action string `json:"action"` + PopupID *uint `json:"popup_id"` + PrimaryTitle string `json:"primary_title"` + SecondaryTitle string `json:"secondary_title"` + Description string `json:"description"` + PrimaryCta string `json:"primary_cta"` + SecondaryCtaText string `json:"secondary_cta_text"` + MediaURL string `json:"media_url"` +} + +func (_i *cmsContentSubmissionsService) mergePopup(raw []byte) error { + var p popupPayload + if err := json.Unmarshal(raw, &p); err != nil { + return err + } + if strings.EqualFold(p.Action, "delete") { + if p.PopupID == nil || *p.PopupID == 0 { + return errors.New("popup_id required for delete") + } + return _i.Popup.Delete(*p.PopupID) + } + if p.PopupID == nil || *p.PopupID == 0 { + res, err := _i.Popup.Save(popupNewsReq.PopupNewsContentsCreateRequest{ + PrimaryTitle: p.PrimaryTitle, + SecondaryTitle: p.SecondaryTitle, + Description: p.Description, + PrimaryCTA: p.PrimaryCta, + SecondaryCTAText: p.SecondaryCtaText, + }) + if err != nil { + return err + } + return _i.attachPopupImage(res.ID, p.MediaURL) + } + pid := *p.PopupID + if err := _i.Popup.Update(pid, popupNewsReq.PopupNewsContentsUpdateRequest{ + ID: pid, + PrimaryTitle: p.PrimaryTitle, + SecondaryTitle: p.SecondaryTitle, + Description: p.Description, + PrimaryCTA: p.PrimaryCta, + SecondaryCTAText: p.SecondaryCtaText, + }); err != nil { + return err + } + return _i.attachPopupImage(pid, p.MediaURL) +} + +func (_i *cmsContentSubmissionsService) attachPopupImage(popupID uint, mediaURL string) error { + mediaURL = strings.TrimSpace(mediaURL) + if mediaURL == "" { + return nil + } + return _i.PopupImg.Save(popupImageReq.PopupNewsContentImagesCreateRequest{ + PopupNewsContentID: popupID, + MediaPath: "", + MediaURL: mediaURL, + }) +} diff --git a/app/module/cms_media/cms_media.module.go b/app/module/cms_media/cms_media.module.go new file mode 100644 index 0000000..4a0d404 --- /dev/null +++ b/app/module/cms_media/cms_media.module.go @@ -0,0 +1,30 @@ +package cms_media + +import ( + "github.com/gofiber/fiber/v2" + "go.uber.org/fx" + + "web-qudo-be/app/module/cms_media/controller" + "web-qudo-be/app/module/cms_media/service" +) + +type CmsMediaRouter struct { + App *fiber.App + Ctrl *controller.CmsMediaController +} + +var NewCmsMediaModule = fx.Options( + fx.Provide(service.NewCmsMediaService), + fx.Provide(controller.NewCmsMediaController), + fx.Provide(NewCmsMediaRouter), +) + +func NewCmsMediaRouter(app *fiber.App, ctrl *controller.CmsMediaController) *CmsMediaRouter { + return &CmsMediaRouter{App: app, Ctrl: ctrl} +} + +func (r *CmsMediaRouter) RegisterCmsMediaRoutes() { + r.App.Route("/cms-media", func(router fiber.Router) { + router.Get("/viewer/*", r.Ctrl.Viewer) + }) +} diff --git a/app/module/cms_media/controller/cms_media.controller.go b/app/module/cms_media/controller/cms_media.controller.go new file mode 100644 index 0000000..124ca31 --- /dev/null +++ b/app/module/cms_media/controller/cms_media.controller.go @@ -0,0 +1,23 @@ +package controller + +import ( + "github.com/gofiber/fiber/v2" + "github.com/rs/zerolog" + + "web-qudo-be/app/module/cms_media/service" +) + +type CmsMediaController struct { + svc *service.CmsMediaService + Log zerolog.Logger +} + +func NewCmsMediaController(svc *service.CmsMediaService, log zerolog.Logger) *CmsMediaController { + return &CmsMediaController{svc: svc, Log: log} +} + +// Viewer streams CMS media from MinIO via API URL (for img/video src). +// @Router /cms-media/viewer/{path} [get] +func (ctrl *CmsMediaController) Viewer(c *fiber.Ctx) error { + return ctrl.svc.Viewer(c) +} diff --git a/app/module/cms_media/service/cms_media.service.go b/app/module/cms_media/service/cms_media.service.go new file mode 100644 index 0000000..d1a3f74 --- /dev/null +++ b/app/module/cms_media/service/cms_media.service.go @@ -0,0 +1,75 @@ +package service + +import ( + "context" + "io" + "mime" + "path/filepath" + "strings" + + "github.com/gofiber/fiber/v2" + "github.com/minio/minio-go/v7" + "github.com/rs/zerolog" + + minioStorage "web-qudo-be/config/config" +) + +type CmsMediaService struct { + Minio *minioStorage.MinioStorage + Log zerolog.Logger +} + +func NewCmsMediaService(minio *minioStorage.MinioStorage, log zerolog.Logger) *CmsMediaService { + return &CmsMediaService{Minio: minio, Log: log} +} + +// Viewer streams a CMS object from MinIO (same idea as article-files viewer). +func (s *CmsMediaService) Viewer(c *fiber.Ctx) error { + objectKey := strings.TrimSpace(c.Params("*")) + objectKey = strings.TrimPrefix(objectKey, "/") + if objectKey == "" { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{ + "success": false, + "messages": []string{"object key required"}, + }) + } + if !strings.HasPrefix(objectKey, "cms/") || strings.Contains(objectKey, "..") { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{ + "success": false, + "messages": []string{"invalid object key"}, + }) + } + + ctx := context.Background() + bucket := s.Minio.Cfg.ObjectStorage.MinioStorage.BucketName + + client, err := s.Minio.ConnectMinio() + if err != nil { + s.Log.Error().Err(err).Msg("cms media viewer: minio connect") + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{ + "success": false, + "messages": []string{"storage unavailable"}, + }) + } + + obj, err := client.GetObject(ctx, bucket, objectKey, minio.GetObjectOptions{}) + if err != nil { + s.Log.Error().Err(err).Str("key", objectKey).Msg("cms media viewer: get object") + return c.Status(fiber.StatusNotFound).SendString("not found") + } + defer obj.Close() + + ext := strings.ToLower(filepath.Ext(objectKey)) + contentType := mime.TypeByExtension(ext) + if contentType == "" { + contentType = "application/octet-stream" + } + c.Set("Content-Type", contentType) + c.Set("Cache-Control", "public, max-age=86400") + + if _, err := io.Copy(c.Response().BodyWriter(), obj); err != nil { + s.Log.Error().Err(err).Msg("cms media viewer: stream") + return err + } + return nil +} diff --git a/app/module/hero_content_images/controller/hero_content_images.controller.go b/app/module/hero_content_images/controller/hero_content_images.controller.go index 1bf78eb..19ede48 100644 --- a/app/module/hero_content_images/controller/hero_content_images.controller.go +++ b/app/module/hero_content_images/controller/hero_content_images.controller.go @@ -1,6 +1,8 @@ package controller import ( + "strings" + "github.com/gofiber/fiber/v2" "github.com/google/uuid" "github.com/rs/zerolog" @@ -82,6 +84,21 @@ func (_i *heroContentImagesController) Update(c *fiber.Ctx) error { return err } + if strings.HasPrefix(c.Get("Content-Type"), "multipart/form-data") { + file, err := c.FormFile("file") + if err != nil { + return err + } + if err := _i.service.UpdateWithFile(id, file); err != nil { + _i.Log.Error().Err(err).Msg("failed update hero content image (upload)") + return err + } + return utilRes.Resp(c, utilRes.Response{ + Success: true, + Messages: utilRes.Messages{"Hero content image updated"}, + }) + } + req := new(request.HeroContentImageUpdateRequest) if err := utilVal.ParseAndValidate(c, req); err != nil { return err diff --git a/app/module/hero_content_images/repository/hero_content_images.repository.go b/app/module/hero_content_images/repository/hero_content_images.repository.go index 47859b4..14ad3b3 100644 --- a/app/module/hero_content_images/repository/hero_content_images.repository.go +++ b/app/module/hero_content_images/repository/hero_content_images.repository.go @@ -1,6 +1,7 @@ package repository import ( + "time" "web-qudo-be/app/database" "web-qudo-be/app/database/entity" @@ -58,7 +59,11 @@ func (r *heroContentImagesRepository) Update(id uuid.UUID, data *entity.HeroCont err := r.DB.DB. Model(&entity.HeroContentImages{}). Where("id = ?", id). - Updates(data).Error + Updates(map[string]interface{}{ + "image_path": data.ImagePath, + "image_url": data.ImageURL, + "updated_at": time.Now(), + }).Error if err != nil { r.Log.Error().Err(err).Msg("failed update hero content image") @@ -76,4 +81,4 @@ func (r *heroContentImagesRepository) Delete(id uuid.UUID) error { } return nil -} \ No newline at end of file +} diff --git a/app/module/hero_content_images/service/hero_content_images.service.go b/app/module/hero_content_images/service/hero_content_images.service.go index 066198e..246ffeb 100644 --- a/app/module/hero_content_images/service/hero_content_images.service.go +++ b/app/module/hero_content_images/service/hero_content_images.service.go @@ -1,32 +1,45 @@ package service import ( + "mime/multipart" + "github.com/google/uuid" "github.com/rs/zerolog" "web-qudo-be/app/database/entity" "web-qudo-be/app/module/hero_content_images/repository" + medialib "web-qudo-be/app/module/media_library/service" + minioStorage "web-qudo-be/config/config" + "web-qudo-be/utils/storage" ) type heroContentImagesService struct { - Repo repository.HeroContentImagesRepository - Log zerolog.Logger + Repo repository.HeroContentImagesRepository + MinioStorage *minioStorage.MinioStorage + MediaLib medialib.MediaLibraryService + Log zerolog.Logger } type HeroContentImagesService interface { FindByHeroID(heroID uuid.UUID) (*entity.HeroContentImages, error) Save(data *entity.HeroContentImages) (*entity.HeroContentImages, error) + SaveWithFile(heroContentID uuid.UUID, file *multipart.FileHeader) (*entity.HeroContentImages, error) Update(id uuid.UUID, data *entity.HeroContentImages) error + UpdateWithFile(id uuid.UUID, file *multipart.FileHeader) error Delete(id uuid.UUID) error } func NewHeroContentImagesService( repo repository.HeroContentImagesRepository, + minio *minioStorage.MinioStorage, + mediaLib medialib.MediaLibraryService, log zerolog.Logger, ) HeroContentImagesService { return &heroContentImagesService{ - Repo: repo, - Log: log, + Repo: repo, + MinioStorage: minio, + MediaLib: mediaLib, + Log: log, } } @@ -52,6 +65,26 @@ func (s *heroContentImagesService) Save(data *entity.HeroContentImages) (*entity return result, nil } +func (s *heroContentImagesService) SaveWithFile(heroContentID uuid.UUID, file *multipart.FileHeader) (*entity.HeroContentImages, error) { + key, url, err := storage.UploadCMSObject(s.MinioStorage, "hero", file, false) + if err != nil { + return nil, err + } + data := &entity.HeroContentImages{ + HeroContentID: heroContentID, + ImagePath: key, + ImageURL: url, + } + out, err := s.Save(data) + if err != nil { + return nil, err + } + if s.MediaLib != nil { + _ = s.MediaLib.RegisterCMSAsset(url, key, "hero_content", file) + } + return out, nil +} + func (s *heroContentImagesService) Update(id uuid.UUID, data *entity.HeroContentImages) error { err := s.Repo.Update(id, data) if err != nil { @@ -62,6 +95,23 @@ func (s *heroContentImagesService) Update(id uuid.UUID, data *entity.HeroContent return nil } +func (s *heroContentImagesService) UpdateWithFile(id uuid.UUID, file *multipart.FileHeader) error { + key, url, err := storage.UploadCMSObject(s.MinioStorage, "hero", file, false) + if err != nil { + return err + } + if err := s.Repo.Update(id, &entity.HeroContentImages{ + ImagePath: key, + ImageURL: url, + }); err != nil { + return err + } + if s.MediaLib != nil { + _ = s.MediaLib.RegisterCMSAsset(url, key, "hero_content", file) + } + return nil +} + func (s *heroContentImagesService) Delete(id uuid.UUID) error { err := s.Repo.Delete(id) if err != nil { @@ -70,4 +120,4 @@ func (s *heroContentImagesService) Delete(id uuid.UUID) error { } return nil -} \ No newline at end of file +} diff --git a/app/module/hero_contents/repository/hero_contents.repository.go b/app/module/hero_contents/repository/hero_contents.repository.go index 5f02682..71ae0eb 100644 --- a/app/module/hero_contents/repository/hero_contents.repository.go +++ b/app/module/hero_contents/repository/hero_contents.repository.go @@ -1,11 +1,14 @@ package repository import ( + "errors" + "time" "web-qudo-be/app/database" "web-qudo-be/app/database/entity" "github.com/google/uuid" "github.com/rs/zerolog" + "gorm.io/gorm" ) type heroContentsRepository struct { @@ -35,6 +38,9 @@ func (r *heroContentsRepository) Get() (*entity.HeroContents, error) { First(&data).Error if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, nil + } r.Log.Error().Err(err).Msg("failed get hero content") return nil, err } @@ -55,10 +61,23 @@ func (r *heroContentsRepository) Create(data *entity.HeroContents) (*entity.Hero } func (r *heroContentsRepository) Update(id uuid.UUID, data *entity.HeroContents) error { + // map (not struct) so empty strings are persisted; GORM Updates(struct) skips zero values. + updates := map[string]interface{}{ + "primary_title": data.PrimaryTitle, + "secondary_title": data.SecondaryTitle, + "description": data.Description, + "primary_cta": data.PrimaryCta, + "secondary_cta_text": data.SecondaryCtaText, + } + if data.IsActive != nil { + updates["is_active"] = data.IsActive + } + updates["updated_at"] = time.Now() + err := r.DB.DB. Model(&entity.HeroContents{}). Where("id = ?", id). - Updates(data).Error + Updates(updates).Error if err != nil { r.Log.Error().Err(err).Msg("failed update hero content") @@ -76,4 +95,4 @@ func (r *heroContentsRepository) Delete(id uuid.UUID) error { } return nil -} \ No newline at end of file +} diff --git a/app/module/hero_contents/request/hero_contents.request.go b/app/module/hero_contents/request/hero_contents.request.go index 61ee453..b323e8f 100644 --- a/app/module/hero_contents/request/hero_contents.request.go +++ b/app/module/hero_contents/request/hero_contents.request.go @@ -21,7 +21,7 @@ func (r *HeroContentsCreateRequest) ToEntity() *entity.HeroContents { } type HeroContentsUpdateRequest struct { - PrimaryTitle string `json:"primary_title"` + PrimaryTitle string `json:"primary_title" validate:"required"` SecondaryTitle string `json:"secondary_title"` Description string `json:"description"` PrimaryCTA string `json:"primary_cta"` diff --git a/app/module/media_library/controller/media_library.controller.go b/app/module/media_library/controller/media_library.controller.go new file mode 100644 index 0000000..268ca94 --- /dev/null +++ b/app/module/media_library/controller/media_library.controller.go @@ -0,0 +1,106 @@ +package controller + +import ( + "strconv" + "strings" + + "github.com/gofiber/fiber/v2" + + "web-qudo-be/app/middleware" + "web-qudo-be/app/module/media_library/request" + "web-qudo-be/app/module/media_library/service" + "web-qudo-be/utils/paginator" + utilRes "web-qudo-be/utils/response" + utilVal "web-qudo-be/utils/validator" +) + +type MediaLibraryController struct { + svc service.MediaLibraryService +} + +func NewMediaLibraryController(svc service.MediaLibraryService) *MediaLibraryController { + return &MediaLibraryController{svc: svc} +} + +func (_i *MediaLibraryController) All(c *fiber.Ctx) error { + clientID := middleware.GetClientID(c) + user := middleware.GetUser(c) + uid := 0 + if user != nil { + uid = int(user.ID) + } + _ = uid + + paginate, err := paginator.Paginate(c) + if err != nil { + return err + } + q := strings.TrimSpace(c.Query("q")) + var st *string + if v := strings.TrimSpace(c.Query("source_type")); v != "" { + st = &v + } + data, paging, err := _i.svc.All(clientID, q, st, paginate) + if err != nil { + return err + } + return utilRes.Resp(c, utilRes.Response{ + Success: true, + Messages: utilRes.Messages{"Media library list"}, + Data: data, + Meta: paging, + }) +} + +func (_i *MediaLibraryController) Register(c *fiber.Ctx) error { + clientID := middleware.GetClientID(c) + user := middleware.GetUser(c) + uid := 0 + if user != nil { + uid = int(user.ID) + } + req := new(request.MediaLibraryRegisterRequest) + if err := utilVal.ParseAndValidate(c, req); err != nil { + return err + } + if err := _i.svc.RegisterFromRequest(clientID, uid, req); err != nil { + return err + } + return utilRes.Resp(c, utilRes.Response{ + Success: true, + Messages: utilRes.Messages{"Registered to media library"}, + }) +} + +func (_i *MediaLibraryController) Upload(c *fiber.Ctx) error { + clientID := middleware.GetClientID(c) + user := middleware.GetUser(c) + uid := 0 + if user != nil { + uid = int(user.ID) + } + publicURL, err := _i.svc.Upload(clientID, uid, c) + if err != nil { + return err + } + return utilRes.Resp(c, utilRes.Response{ + Success: true, + Messages: utilRes.Messages{"File uploaded and added to media library"}, + Data: map[string]string{"public_url": publicURL}, + }) +} + +func (_i *MediaLibraryController) Delete(c *fiber.Ctx) error { + clientID := middleware.GetClientID(c) + id, err := strconv.ParseUint(c.Params("id"), 10, 64) + if err != nil { + return err + } + if err := _i.svc.Delete(clientID, uint(id)); err != nil { + return err + } + return utilRes.Resp(c, utilRes.Response{ + Success: true, + Messages: utilRes.Messages{"Media library entry removed"}, + }) +} diff --git a/app/module/media_library/media_library.module.go b/app/module/media_library/media_library.module.go new file mode 100644 index 0000000..410e94f --- /dev/null +++ b/app/module/media_library/media_library.module.go @@ -0,0 +1,36 @@ +package media_library + +import ( + "github.com/gofiber/fiber/v2" + "go.uber.org/fx" + + "web-qudo-be/app/module/media_library/controller" + "web-qudo-be/app/module/media_library/repository" + "web-qudo-be/app/module/media_library/service" +) + +type MediaLibraryRouter struct { + App *fiber.App + Ctrl *controller.MediaLibraryController +} + +var NewMediaLibraryModule = fx.Options( + fx.Provide(repository.NewMediaLibraryRepository), + fx.Provide(service.NewMediaLibraryService), + fx.Provide(controller.NewMediaLibraryController), + fx.Provide(NewMediaLibraryRouter), +) + +func NewMediaLibraryRouter(app *fiber.App, ctrl *controller.MediaLibraryController) *MediaLibraryRouter { + return &MediaLibraryRouter{App: app, Ctrl: ctrl} +} + +func (r *MediaLibraryRouter) RegisterMediaLibraryRoutes() { + c := r.Ctrl + r.App.Route("/media-library", func(router fiber.Router) { + router.Get("/", c.All) + router.Post("/register", c.Register) + router.Post("/upload", c.Upload) + router.Delete("/:id", c.Delete) + }) +} diff --git a/app/module/media_library/repository/media_library.repository.go b/app/module/media_library/repository/media_library.repository.go new file mode 100644 index 0000000..5819b6a --- /dev/null +++ b/app/module/media_library/repository/media_library.repository.go @@ -0,0 +1,88 @@ +package repository + +import ( + "strings" + "web-qudo-be/app/database" + "web-qudo-be/app/database/entity" + "web-qudo-be/utils/paginator" + + "github.com/google/uuid" + "github.com/rs/zerolog" + "gorm.io/gorm" +) + +type MediaLibraryRepository interface { + FindByPublicURLAny(publicURL string) (*entity.MediaLibraryItem, error) + Create(item *entity.MediaLibraryItem) error + Update(id uint, fields map[string]interface{}) error + GetAll(clientID *uuid.UUID, q string, sourceType *string, p *paginator.Pagination) ([]*entity.MediaLibraryItem, *paginator.Pagination, error) + SoftDelete(clientID *uuid.UUID, id uint) error +} + +type mediaLibraryRepository struct { + DB *database.Database + Log zerolog.Logger +} + +func NewMediaLibraryRepository(db *database.Database, log zerolog.Logger) MediaLibraryRepository { + return &mediaLibraryRepository{DB: db, Log: log} +} + +func (_i *mediaLibraryRepository) FindByPublicURLAny(publicURL string) (*entity.MediaLibraryItem, error) { + var row entity.MediaLibraryItem + err := _i.DB.DB.Where("public_url = ?", publicURL).First(&row).Error + if err != nil { + return nil, err + } + return &row, nil +} + +func (_i *mediaLibraryRepository) Create(item *entity.MediaLibraryItem) error { + return _i.DB.DB.Create(item).Error +} + +func (_i *mediaLibraryRepository) Update(id uint, fields map[string]interface{}) error { + return _i.DB.DB.Model(&entity.MediaLibraryItem{}).Where("id = ?", id).Updates(fields).Error +} + +func (_i *mediaLibraryRepository) GetAll(clientID *uuid.UUID, q string, sourceType *string, p *paginator.Pagination) ([]*entity.MediaLibraryItem, *paginator.Pagination, error) { + var rows []*entity.MediaLibraryItem + var count int64 + + query := _i.DB.DB.Model(&entity.MediaLibraryItem{}).Where("is_active = ?", true) + if clientID != nil { + query = query.Where("client_id = ?", clientID) + } + if sourceType != nil && strings.TrimSpace(*sourceType) != "" { + query = query.Where("source_type = ?", strings.TrimSpace(*sourceType)) + } + if strings.TrimSpace(q) != "" { + like := "%" + strings.ToLower(strings.TrimSpace(q)) + "%" + query = query.Where( + "LOWER(COALESCE(original_filename,'')) LIKE ? OR LOWER(public_url) LIKE ? OR LOWER(COALESCE(source_label,'')) LIKE ?", + like, like, like, + ) + } + if err := query.Count(&count).Error; err != nil { + return nil, p, err + } + p.Count = count + p = paginator.Paging(p) + err := query.Order("created_at DESC").Offset(p.Offset).Limit(p.Limit).Find(&rows).Error + return rows, p, err +} + +func (_i *mediaLibraryRepository) SoftDelete(clientID *uuid.UUID, id uint) error { + q := _i.DB.DB.Model(&entity.MediaLibraryItem{}).Where("id = ?", id) + if clientID != nil { + q = q.Where("client_id = ?", clientID) + } + res := q.Update("is_active", false) + if res.Error != nil { + return res.Error + } + if res.RowsAffected == 0 { + return gorm.ErrRecordNotFound + } + return nil +} diff --git a/app/module/media_library/request/media_library.request.go b/app/module/media_library/request/media_library.request.go new file mode 100644 index 0000000..bb95592 --- /dev/null +++ b/app/module/media_library/request/media_library.request.go @@ -0,0 +1,22 @@ +package request + +import ( + "web-qudo-be/utils/paginator" +) + +type MediaLibraryRegisterRequest struct { + PublicURL string `json:"public_url" validate:"required"` + ObjectKey *string `json:"object_key"` + OriginalFilename *string `json:"original_filename"` + FileCategory *string `json:"file_category"` + SizeBytes *int64 `json:"size_bytes"` + SourceType string `json:"source_type" validate:"required"` + SourceLabel *string `json:"source_label"` + ArticleFileID *uint `json:"article_file_id"` +} + +type MediaLibraryQueryRequest struct { + Q string `json:"q"` + SourceType *string `json:"source_type"` + Pagination *paginator.Pagination `json:"pagination"` +} diff --git a/app/module/media_library/response/media_library.response.go b/app/module/media_library/response/media_library.response.go new file mode 100644 index 0000000..f92b0e3 --- /dev/null +++ b/app/module/media_library/response/media_library.response.go @@ -0,0 +1,23 @@ +package response + +import ( + "time" + + "github.com/google/uuid" +) + +type MediaLibraryItemResponse struct { + ID uint `json:"id"` + PublicURL string `json:"public_url"` + ObjectKey *string `json:"object_key"` + OriginalFilename *string `json:"original_filename"` + FileCategory string `json:"file_category"` + SizeBytes *int64 `json:"size_bytes"` + SourceType string `json:"source_type"` + SourceLabel *string `json:"source_label"` + ArticleFileID *uint `json:"article_file_id"` + CreatedByID int `json:"created_by_id"` + ClientID *uuid.UUID `json:"client_id"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} diff --git a/app/module/media_library/service/media_library.service.go b/app/module/media_library/service/media_library.service.go new file mode 100644 index 0000000..1ea0cec --- /dev/null +++ b/app/module/media_library/service/media_library.service.go @@ -0,0 +1,245 @@ +package service + +import ( + "errors" + "mime/multipart" + "path/filepath" + "strings" + "time" + + "github.com/gofiber/fiber/v2" + "github.com/google/uuid" + "github.com/rs/zerolog" + "gorm.io/gorm" + + "web-qudo-be/app/database/entity" + "web-qudo-be/app/module/media_library/repository" + "web-qudo-be/app/module/media_library/request" + "web-qudo-be/app/module/media_library/response" + appcfg "web-qudo-be/config/config" + "web-qudo-be/utils/paginator" + "web-qudo-be/utils/storage" +) + +// RegisterInput is used from article/CMS upload hooks (same physical file, extra catalog row). +type RegisterInput struct { + ClientID *uuid.UUID + UserID int + PublicURL string + ObjectKey *string + OriginalFilename *string + FileCategory string + SizeBytes *int64 + SourceType string + SourceLabel *string + ArticleFileID *uint +} + +type MediaLibraryService interface { + UpsertRegister(in RegisterInput) error + RegisterFromRequest(clientID *uuid.UUID, userID int, req *request.MediaLibraryRegisterRequest) error + RegisterCMSAsset(publicURL, objectKey, sourceLabel string, file *multipart.FileHeader) error + All(clientID *uuid.UUID, q string, sourceType *string, p *paginator.Pagination) ([]*response.MediaLibraryItemResponse, *paginator.Pagination, error) + Upload(clientID *uuid.UUID, userID int, c *fiber.Ctx) (publicURL string, err error) + Delete(clientID *uuid.UUID, id uint) error +} + +type mediaLibraryService struct { + Repo repository.MediaLibraryRepository + Cfg *appcfg.Config + MinioStorage *appcfg.MinioStorage + Log zerolog.Logger +} + +func NewMediaLibraryService( + repo repository.MediaLibraryRepository, + cfg *appcfg.Config, + minio *appcfg.MinioStorage, + log zerolog.Logger, +) MediaLibraryService { + return &mediaLibraryService{Repo: repo, Cfg: cfg, MinioStorage: minio, Log: log} +} + +func ArticleFilePublicURL(cfg *appcfg.Config, fileName string) string { + base := strings.TrimSuffix(cfg.App.Domain, "/") + return base + "/article-files/viewer/" + strings.TrimPrefix(fileName, "/") +} + +func CategoryFromFilename(name string) string { + ext := strings.ToLower(filepath.Ext(name)) + switch ext { + case ".jpg", ".jpeg", ".png", ".gif", ".webp", ".svg": + return "image" + case ".mp4", ".webm", ".mov": + return "video" + case ".mp3", ".wav", ".ogg", ".m4a": + return "audio" + case ".pdf", ".doc", ".docx", ".txt", ".csv": + return "document" + default: + return "other" + } +} + +func (s *mediaLibraryService) UpsertRegister(in RegisterInput) error { + url := strings.TrimSpace(in.PublicURL) + if url == "" { + return nil + } + existing, err := s.Repo.FindByPublicURLAny(url) + if err == nil { + if !existing.IsActive { + cat := strings.TrimSpace(in.FileCategory) + if cat == "" { + cat = "other" + } + return s.Repo.Update(existing.ID, map[string]interface{}{ + "is_active": true, + "object_key": in.ObjectKey, + "original_filename": in.OriginalFilename, + "file_category": cat, + "size_bytes": in.SizeBytes, + "source_type": in.SourceType, + "source_label": in.SourceLabel, + "article_file_id": in.ArticleFileID, + "updated_at": time.Now(), + }) + } + return nil + } + if !errors.Is(err, gorm.ErrRecordNotFound) { + return err + } + cat := strings.TrimSpace(in.FileCategory) + if cat == "" { + cat = "other" + } + item := &entity.MediaLibraryItem{ + PublicURL: url, + ObjectKey: in.ObjectKey, + OriginalFilename: in.OriginalFilename, + FileCategory: cat, + SizeBytes: in.SizeBytes, + SourceType: in.SourceType, + SourceLabel: in.SourceLabel, + ArticleFileID: in.ArticleFileID, + CreatedByID: in.UserID, + ClientID: in.ClientID, + IsActive: true, + } + return s.Repo.Create(item) +} + +func (s *mediaLibraryService) RegisterCMSAsset(publicURL, objectKey, sourceLabel string, file *multipart.FileHeader) error { + if strings.TrimSpace(publicURL) == "" { + return nil + } + var keyPtr *string + if strings.TrimSpace(objectKey) != "" { + k := objectKey + keyPtr = &k + } + lbl := sourceLabel + var namePtr *string + var sz *int64 + cat := "other" + if file != nil { + b := filepath.Base(file.Filename) + namePtr = &b + ss := file.Size + sz = &ss + cat = CategoryFromFilename(b) + } + return s.UpsertRegister(RegisterInput{ + PublicURL: publicURL, + ObjectKey: keyPtr, + OriginalFilename: namePtr, + FileCategory: cat, + SizeBytes: sz, + SourceType: "cms", + SourceLabel: &lbl, + }) +} + +func (s *mediaLibraryService) RegisterFromRequest(clientID *uuid.UUID, userID int, req *request.MediaLibraryRegisterRequest) error { + cat := CategoryFromFilename(req.PublicURL) + if req.OriginalFilename != nil { + cat = CategoryFromFilename(*req.OriginalFilename) + } + if req.FileCategory != nil && strings.TrimSpace(*req.FileCategory) != "" { + cat = strings.TrimSpace(*req.FileCategory) + } + return s.UpsertRegister(RegisterInput{ + ClientID: clientID, + UserID: userID, + PublicURL: strings.TrimSpace(req.PublicURL), + ObjectKey: req.ObjectKey, + OriginalFilename: req.OriginalFilename, + FileCategory: cat, + SizeBytes: req.SizeBytes, + SourceType: req.SourceType, + SourceLabel: req.SourceLabel, + ArticleFileID: req.ArticleFileID, + }) +} + +func toResponse(e *entity.MediaLibraryItem) *response.MediaLibraryItemResponse { + return &response.MediaLibraryItemResponse{ + ID: e.ID, + PublicURL: e.PublicURL, + ObjectKey: e.ObjectKey, + OriginalFilename: e.OriginalFilename, + FileCategory: e.FileCategory, + SizeBytes: e.SizeBytes, + SourceType: e.SourceType, + SourceLabel: e.SourceLabel, + ArticleFileID: e.ArticleFileID, + CreatedByID: e.CreatedByID, + ClientID: e.ClientID, + CreatedAt: e.CreatedAt, + UpdatedAt: e.UpdatedAt, + } +} + +func (s *mediaLibraryService) All(clientID *uuid.UUID, q string, sourceType *string, p *paginator.Pagination) ([]*response.MediaLibraryItemResponse, *paginator.Pagination, error) { + rows, paging, err := s.Repo.GetAll(clientID, q, sourceType, p) + if err != nil { + return nil, paging, err + } + out := make([]*response.MediaLibraryItemResponse, 0, len(rows)) + for _, r := range rows { + out = append(out, toResponse(r)) + } + return out, paging, nil +} + +func (s *mediaLibraryService) Upload(clientID *uuid.UUID, userID int, c *fiber.Ctx) (string, error) { + file, err := c.FormFile("file") + if err != nil { + return "", err + } + key, previewURL, err := storage.UploadMediaLibraryObject(s.MinioStorage, file) + if err != nil { + return "", err + } + name := filepath.Base(file.Filename) + sz := file.Size + err = s.UpsertRegister(RegisterInput{ + ClientID: clientID, + UserID: userID, + PublicURL: previewURL, + ObjectKey: &key, + OriginalFilename: &name, + FileCategory: CategoryFromFilename(name), + SizeBytes: &sz, + SourceType: "upload", + SourceLabel: strPtr("media_library_direct"), + }) + return previewURL, err +} + +func strPtr(s string) *string { return &s } + +func (s *mediaLibraryService) Delete(clientID *uuid.UUID, id uint) error { + return s.Repo.SoftDelete(clientID, id) +} diff --git a/app/module/our_product_content_images/controller/our_product_content_images.controller.go b/app/module/our_product_content_images/controller/our_product_content_images.controller.go index c20d068..2230bd9 100644 --- a/app/module/our_product_content_images/controller/our_product_content_images.controller.go +++ b/app/module/our_product_content_images/controller/our_product_content_images.controller.go @@ -1,6 +1,8 @@ package controller import ( + "strings" + "github.com/gofiber/fiber/v2" "github.com/google/uuid" "github.com/rs/zerolog" @@ -32,7 +34,7 @@ func NewOurProductContentImagesController(service service.OurProductContentImage } func (_i *ourProductContentImagesController) FindByOurProductContentID(c *fiber.Ctx) error { - contentIDStr := c.Params("our_product_content_id") + contentIDStr := c.Params("content_id") contentID, err := uuid.Parse(contentIDStr) if err != nil { @@ -53,6 +55,28 @@ func (_i *ourProductContentImagesController) FindByOurProductContentID(c *fiber. } func (_i *ourProductContentImagesController) Save(c *fiber.Ctx) error { + if strings.HasPrefix(c.Get("Content-Type"), "multipart/form-data") { + cidStr := c.FormValue("our_product_content_id") + cid, err := uuid.Parse(cidStr) + if err != nil { + return err + } + file, err := c.FormFile("file") + if err != nil { + return err + } + result, err := _i.service.SaveWithFile(cid, file) + if err != nil { + _i.Log.Error().Err(err).Msg("failed create our product content image (upload)") + return err + } + return utilRes.Resp(c, utilRes.Response{ + Success: true, + Messages: utilRes.Messages{"Our product content image created"}, + Data: result, + }) + } + req := new(request.OurProductContentImageCreateRequest) if err := utilVal.ParseAndValidate(c, req); err != nil { @@ -82,6 +106,21 @@ func (_i *ourProductContentImagesController) Update(c *fiber.Ctx) error { return err } + if strings.HasPrefix(c.Get("Content-Type"), "multipart/form-data") { + file, err := c.FormFile("file") + if err != nil { + return err + } + if err := _i.service.UpdateWithFile(id, file); err != nil { + _i.Log.Error().Err(err).Msg("failed update our product content image (upload)") + return err + } + return utilRes.Resp(c, utilRes.Response{ + Success: true, + Messages: utilRes.Messages{"Our product content image updated"}, + }) + } + req := new(request.OurProductContentImageUpdateRequest) if err := utilVal.ParseAndValidate(c, req); err != nil { return err diff --git a/app/module/our_product_content_images/repository/our_product_content_images.repository.go b/app/module/our_product_content_images/repository/our_product_content_images.repository.go index 1f7372f..1deaf35 100644 --- a/app/module/our_product_content_images/repository/our_product_content_images.repository.go +++ b/app/module/our_product_content_images/repository/our_product_content_images.repository.go @@ -55,10 +55,18 @@ func (r *ourProductContentImagesRepository) Create(data *entity.OurProductConten } func (r *ourProductContentImagesRepository) Update(id uuid.UUID, data *entity.OurProductContentImage) error { + updates := map[string]interface{}{ + "image_path": data.ImagePath, + "image_url": data.ImageURL, + } + if data.IsThumbnail != nil { + updates["is_thumbnail"] = data.IsThumbnail + } + err := r.DB.DB. Model(&entity.OurProductContentImage{}). Where("id = ?", id). - Updates(data).Error + Updates(updates).Error if err != nil { r.Log.Error().Err(err).Msg("failed update our product content image") @@ -76,4 +84,4 @@ func (r *ourProductContentImagesRepository) Delete(id uuid.UUID) error { } return nil -} \ No newline at end of file +} diff --git a/app/module/our_product_content_images/service/our_product_content_images.service.go b/app/module/our_product_content_images/service/our_product_content_images.service.go index ab92635..da13b46 100644 --- a/app/module/our_product_content_images/service/our_product_content_images.service.go +++ b/app/module/our_product_content_images/service/our_product_content_images.service.go @@ -1,32 +1,45 @@ package service import ( + "mime/multipart" + "github.com/google/uuid" "github.com/rs/zerolog" "web-qudo-be/app/database/entity" + medialib "web-qudo-be/app/module/media_library/service" "web-qudo-be/app/module/our_product_content_images/repository" + minioStorage "web-qudo-be/config/config" + "web-qudo-be/utils/storage" ) type ourProductContentImagesService struct { - Repo repository.OurProductContentImagesRepository - Log zerolog.Logger + Repo repository.OurProductContentImagesRepository + MinioStorage *minioStorage.MinioStorage + MediaLib medialib.MediaLibraryService + Log zerolog.Logger } type OurProductContentImagesService interface { FindByContentID(contentID uuid.UUID) ([]entity.OurProductContentImage, error) Save(data *entity.OurProductContentImage) (*entity.OurProductContentImage, error) + SaveWithFile(ourProductContentID uuid.UUID, file *multipart.FileHeader) (*entity.OurProductContentImage, error) Update(id uuid.UUID, data *entity.OurProductContentImage) error + UpdateWithFile(id uuid.UUID, file *multipart.FileHeader) error Delete(id uuid.UUID) error } func NewOurProductContentImagesService( repo repository.OurProductContentImagesRepository, + minio *minioStorage.MinioStorage, + mediaLib medialib.MediaLibraryService, log zerolog.Logger, ) OurProductContentImagesService { return &ourProductContentImagesService{ - Repo: repo, - Log: log, + Repo: repo, + MinioStorage: minio, + MediaLib: mediaLib, + Log: log, } } @@ -52,6 +65,26 @@ func (s *ourProductContentImagesService) Save(data *entity.OurProductContentImag return result, nil } +func (s *ourProductContentImagesService) SaveWithFile(ourProductContentID uuid.UUID, file *multipart.FileHeader) (*entity.OurProductContentImage, error) { + key, url, err := storage.UploadCMSObject(s.MinioStorage, "our-products", file, false) + if err != nil { + return nil, err + } + data := &entity.OurProductContentImage{ + OurProductContentID: ourProductContentID, + ImagePath: key, + ImageURL: url, + } + out, err := s.Save(data) + if err != nil { + return nil, err + } + if s.MediaLib != nil { + _ = s.MediaLib.RegisterCMSAsset(url, key, "our_product", file) + } + return out, nil +} + func (s *ourProductContentImagesService) Update(id uuid.UUID, data *entity.OurProductContentImage) error { err := s.Repo.Update(id, data) if err != nil { @@ -62,6 +95,23 @@ func (s *ourProductContentImagesService) Update(id uuid.UUID, data *entity.OurPr return nil } +func (s *ourProductContentImagesService) UpdateWithFile(id uuid.UUID, file *multipart.FileHeader) error { + key, url, err := storage.UploadCMSObject(s.MinioStorage, "our-products", file, false) + if err != nil { + return err + } + if err := s.Repo.Update(id, &entity.OurProductContentImage{ + ImagePath: key, + ImageURL: url, + }); err != nil { + return err + } + if s.MediaLib != nil { + _ = s.MediaLib.RegisterCMSAsset(url, key, "our_product", file) + } + return nil +} + func (s *ourProductContentImagesService) Delete(id uuid.UUID) error { err := s.Repo.Delete(id) if err != nil { @@ -70,4 +120,4 @@ func (s *ourProductContentImagesService) Delete(id uuid.UUID) error { } return nil -} \ No newline at end of file +} diff --git a/app/module/our_product_contents/repository/our_product_contents.repository.go b/app/module/our_product_contents/repository/our_product_contents.repository.go index 0a4e77a..a171ff0 100644 --- a/app/module/our_product_contents/repository/our_product_contents.repository.go +++ b/app/module/our_product_contents/repository/our_product_contents.repository.go @@ -1,11 +1,14 @@ package repository import ( + "errors" + "time" "web-qudo-be/app/database" "web-qudo-be/app/database/entity" "github.com/google/uuid" "github.com/rs/zerolog" + "gorm.io/gorm" ) type ourProductContentRepository struct { @@ -15,6 +18,7 @@ type ourProductContentRepository struct { type OurProductContentRepository interface { Get() (*entity.OurProductContent, error) + GetAll() ([]entity.OurProductContent, error) Create(data *entity.OurProductContent) (*entity.OurProductContent, error) Update(id uuid.UUID, data *entity.OurProductContent) error Delete(id uuid.UUID) error @@ -35,6 +39,9 @@ func (r *ourProductContentRepository) Get() (*entity.OurProductContent, error) { First(&data).Error if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, nil + } r.Log.Error().Err(err).Msg("failed get our product content") return nil, err } @@ -42,6 +49,19 @@ func (r *ourProductContentRepository) Get() (*entity.OurProductContent, error) { return &data, nil } +func (r *ourProductContentRepository) GetAll() ([]entity.OurProductContent, error) { + var rows []entity.OurProductContent + err := r.DB.DB. + Preload("Images"). + Order("created_at ASC"). + Find(&rows).Error + if err != nil { + r.Log.Error().Err(err).Msg("failed list our product contents") + return nil, err + } + return rows, nil +} + func (r *ourProductContentRepository) Create(data *entity.OurProductContent) (*entity.OurProductContent, error) { data.ID = uuid.New() @@ -55,10 +75,21 @@ func (r *ourProductContentRepository) Create(data *entity.OurProductContent) (*e } func (r *ourProductContentRepository) Update(id uuid.UUID, data *entity.OurProductContent) error { + updates := map[string]interface{}{ + "primary_title": data.PrimaryTitle, + "secondary_title": data.SecondaryTitle, + "description": data.Description, + "link_url": data.LinkURL, + "updated_at": time.Now(), + } + if data.IsActive != nil { + updates["is_active"] = data.IsActive + } + err := r.DB.DB. Model(&entity.OurProductContent{}). Where("id = ?", id). - Updates(data).Error + Updates(updates).Error if err != nil { r.Log.Error().Err(err).Msg("failed update our product content") @@ -68,12 +99,17 @@ func (r *ourProductContentRepository) Update(id uuid.UUID, data *entity.OurProdu return nil } +// Delete removes child images first so FK constraints do not block the parent delete. func (r *ourProductContentRepository) Delete(id uuid.UUID) error { - err := r.DB.DB.Delete(&entity.OurProductContent{}, id).Error - if err != nil { - r.Log.Error().Err(err).Msg("failed delete our product content") - return err - } - - return nil -} \ No newline at end of file + return r.DB.DB.Transaction(func(tx *gorm.DB) error { + if err := tx.Exec(`DELETE FROM our_product_content_images WHERE our_product_content_id = ?`, id).Error; err != nil { + r.Log.Error().Err(err).Msg("failed delete our product content images") + return err + } + if err := tx.Exec(`DELETE FROM our_product_contents WHERE id = ?`, id).Error; err != nil { + r.Log.Error().Err(err).Msg("failed delete our product content") + return err + } + return nil + }) +} diff --git a/app/module/our_product_contents/request/our_product_contents.request.go b/app/module/our_product_contents/request/our_product_contents.request.go index 78664e1..8a7cd91 100644 --- a/app/module/our_product_contents/request/our_product_contents.request.go +++ b/app/module/our_product_contents/request/our_product_contents.request.go @@ -6,6 +6,7 @@ type OurProductContentCreateRequest struct { PrimaryTitle string `json:"primary_title" form:"primary_title" validate:"required"` SecondaryTitle string `json:"secondary_title" form:"secondary_title"` Description string `json:"description" form:"description"` + LinkURL string `json:"link_url" form:"link_url"` } func (r *OurProductContentCreateRequest) ToEntity() *entity.OurProductContent { @@ -13,6 +14,7 @@ func (r *OurProductContentCreateRequest) ToEntity() *entity.OurProductContent { PrimaryTitle: r.PrimaryTitle, SecondaryTitle: r.SecondaryTitle, Description: r.Description, + LinkURL: r.LinkURL, } } @@ -20,6 +22,7 @@ type OurProductContentUpdateRequest struct { PrimaryTitle string `json:"primary_title"` SecondaryTitle string `json:"secondary_title"` Description string `json:"description"` + LinkURL string `json:"link_url"` } func (r *OurProductContentUpdateRequest) ToEntity() *entity.OurProductContent { @@ -27,5 +30,6 @@ func (r *OurProductContentUpdateRequest) ToEntity() *entity.OurProductContent { PrimaryTitle: r.PrimaryTitle, SecondaryTitle: r.SecondaryTitle, Description: r.Description, + LinkURL: r.LinkURL, } -} \ No newline at end of file +} diff --git a/app/module/our_product_contents/service/our_product_contents.service.go b/app/module/our_product_contents/service/our_product_contents.service.go index 6f2168d..993c2c4 100644 --- a/app/module/our_product_contents/service/our_product_contents.service.go +++ b/app/module/our_product_contents/service/our_product_contents.service.go @@ -16,7 +16,7 @@ type ourProductContentService struct { } type OurProductContentService interface { - Show() (*entity.OurProductContent, error) + Show() ([]entity.OurProductContent, error) Save(data *entity.OurProductContent) (*entity.OurProductContent, error) Update(id uuid.UUID, data *entity.OurProductContent) error Delete(id uuid.UUID) error @@ -34,14 +34,13 @@ func NewOurProductContentService( } } -func (s *ourProductContentService) Show() (*entity.OurProductContent, error) { - data, err := s.Repo.Get() +func (s *ourProductContentService) Show() ([]entity.OurProductContent, error) { + rows, err := s.Repo.GetAll() if err != nil { - s.Log.Error().Err(err).Msg("failed get our product content") + s.Log.Error().Err(err).Msg("failed list our product contents") return nil, err } - - return data, nil + return rows, nil } func (s *ourProductContentService) Save(data *entity.OurProductContent) (*entity.OurProductContent, error) { @@ -67,13 +66,5 @@ func (s *ourProductContentService) Update(id uuid.UUID, data *entity.OurProductC } func (s *ourProductContentService) Delete(id uuid.UUID) error { - result, err := s.Repo.Get() - if err != nil { - return err - } - - isActive := false - result.IsActive = &isActive - - return s.Repo.Update(id, result) + return s.Repo.Delete(id) } \ No newline at end of file diff --git a/app/module/our_service_content_images/controller/our_service_content_images.controller.go b/app/module/our_service_content_images/controller/our_service_content_images.controller.go index 54e66d5..5ba9bf4 100644 --- a/app/module/our_service_content_images/controller/our_service_content_images.controller.go +++ b/app/module/our_service_content_images/controller/our_service_content_images.controller.go @@ -2,6 +2,7 @@ package controller import ( "strconv" + "strings" "github.com/gofiber/fiber/v2" "github.com/rs/zerolog" @@ -56,6 +57,29 @@ func (_i *ourServiceContentImagesController) FindByOurServiceContentID(c *fiber. } func (_i *ourServiceContentImagesController) Save(c *fiber.Ctx) error { + if strings.HasPrefix(c.Get("Content-Type"), "multipart/form-data") { + cidStr := c.FormValue("our_service_content_id") + cidInt, err := strconv.Atoi(cidStr) + if err != nil { + return err + } + cid := uint(cidInt) + file, err := c.FormFile("file") + if err != nil { + return err + } + result, err := _i.service.SaveWithFile(cid, file) + if err != nil { + _i.Log.Error().Err(err).Msg("failed create our service content image (upload)") + return err + } + return utilRes.Resp(c, utilRes.Response{ + Success: true, + Messages: utilRes.Messages{"Our service content image created"}, + Data: result, + }) + } + req := new(request.OurServiceContentImageCreateRequest) if err := utilVal.ParseAndValidate(c, req); err != nil { @@ -87,6 +111,21 @@ func (_i *ourServiceContentImagesController) Update(c *fiber.Ctx) error { id := uint(idInt) + if strings.HasPrefix(c.Get("Content-Type"), "multipart/form-data") { + file, err := c.FormFile("file") + if err != nil { + return err + } + if err := _i.service.UpdateWithFile(id, file); err != nil { + _i.Log.Error().Err(err).Msg("failed update our service content image (upload)") + return err + } + return utilRes.Resp(c, utilRes.Response{ + Success: true, + Messages: utilRes.Messages{"Our service content image updated"}, + }) + } + req := new(request.OurServiceContentImageUpdateRequest) if err := utilVal.ParseAndValidate(c, req); err != nil { return err diff --git a/app/module/our_service_content_images/repository/our_service_content_images.repository.go b/app/module/our_service_content_images/repository/our_service_content_images.repository.go index a3cf6e6..0dca1bc 100644 --- a/app/module/our_service_content_images/repository/our_service_content_images.repository.go +++ b/app/module/our_service_content_images/repository/our_service_content_images.repository.go @@ -52,10 +52,18 @@ func (r *ourServiceContentImagesRepository) Create(data *entity.OurServiceConten } func (r *ourServiceContentImagesRepository) Update(id uint, data *entity.OurServiceContentImage) error { + updates := map[string]interface{}{ + "image_path": data.ImagePath, + "image_url": data.ImageURL, + } + if data.IsThumbnail != nil { + updates["is_thumbnail"] = data.IsThumbnail + } + err := r.DB.DB. Model(&entity.OurServiceContentImage{}). Where("id = ?", id). - Updates(data).Error + Updates(updates).Error if err != nil { r.Log.Error().Err(err).Msg("failed update our service content images") @@ -73,4 +81,4 @@ func (r *ourServiceContentImagesRepository) Delete(id uint) error { } return nil -} \ No newline at end of file +} diff --git a/app/module/our_service_content_images/service/our_service_content_images.service.go b/app/module/our_service_content_images/service/our_service_content_images.service.go index 87e60b1..c5588ee 100644 --- a/app/module/our_service_content_images/service/our_service_content_images.service.go +++ b/app/module/our_service_content_images/service/our_service_content_images.service.go @@ -1,31 +1,44 @@ package service import ( + "mime/multipart" + "github.com/rs/zerolog" "web-qudo-be/app/database/entity" + medialib "web-qudo-be/app/module/media_library/service" "web-qudo-be/app/module/our_service_content_images/repository" + minioStorage "web-qudo-be/config/config" + "web-qudo-be/utils/storage" ) type ourServiceContentImagesService struct { - Repo repository.OurServiceContentImagesRepository - Log zerolog.Logger + Repo repository.OurServiceContentImagesRepository + MinioStorage *minioStorage.MinioStorage + MediaLib medialib.MediaLibraryService + Log zerolog.Logger } type OurServiceContentImagesService interface { FindByContentID(contentID uint) ([]entity.OurServiceContentImage, error) Save(data *entity.OurServiceContentImage) (*entity.OurServiceContentImage, error) + SaveWithFile(ourServiceContentID uint, file *multipart.FileHeader) (*entity.OurServiceContentImage, error) Update(id uint, data *entity.OurServiceContentImage) error + UpdateWithFile(id uint, file *multipart.FileHeader) error Delete(id uint) error } func NewOurServiceContentImagesService( repo repository.OurServiceContentImagesRepository, + minio *minioStorage.MinioStorage, + mediaLib medialib.MediaLibraryService, log zerolog.Logger, ) OurServiceContentImagesService { return &ourServiceContentImagesService{ - Repo: repo, - Log: log, + Repo: repo, + MinioStorage: minio, + MediaLib: mediaLib, + Log: log, } } @@ -49,6 +62,26 @@ func (s *ourServiceContentImagesService) Save(data *entity.OurServiceContentImag return result, nil } +func (s *ourServiceContentImagesService) SaveWithFile(ourServiceContentID uint, file *multipart.FileHeader) (*entity.OurServiceContentImage, error) { + key, url, err := storage.UploadCMSObject(s.MinioStorage, "our-services", file, false) + if err != nil { + return nil, err + } + data := &entity.OurServiceContentImage{ + OurServiceContentID: ourServiceContentID, + ImagePath: key, + ImageURL: url, + } + out, err := s.Save(data) + if err != nil { + return nil, err + } + if s.MediaLib != nil { + _ = s.MediaLib.RegisterCMSAsset(url, key, "our_service", file) + } + return out, nil +} + func (s *ourServiceContentImagesService) Update(id uint, data *entity.OurServiceContentImage) error { err := s.Repo.Update(id, data) if err != nil { @@ -59,6 +92,23 @@ func (s *ourServiceContentImagesService) Update(id uint, data *entity.OurService return nil } +func (s *ourServiceContentImagesService) UpdateWithFile(id uint, file *multipart.FileHeader) error { + key, url, err := storage.UploadCMSObject(s.MinioStorage, "our-services", file, false) + if err != nil { + return err + } + if err := s.Repo.Update(id, &entity.OurServiceContentImage{ + ImagePath: key, + ImageURL: url, + }); err != nil { + return err + } + if s.MediaLib != nil { + _ = s.MediaLib.RegisterCMSAsset(url, key, "our_service", file) + } + return nil +} + func (s *ourServiceContentImagesService) Delete(id uint) error { err := s.Repo.Delete(id) if err != nil { @@ -67,4 +117,4 @@ func (s *ourServiceContentImagesService) Delete(id uint) error { } return nil -} \ No newline at end of file +} diff --git a/app/module/our_service_contents/repository/our_service_contents.repository.go b/app/module/our_service_contents/repository/our_service_contents.repository.go index 05977d1..fd5337a 100644 --- a/app/module/our_service_contents/repository/our_service_contents.repository.go +++ b/app/module/our_service_contents/repository/our_service_contents.repository.go @@ -1,10 +1,13 @@ package repository import ( + "errors" + "time" "web-qudo-be/app/database" "web-qudo-be/app/database/entity" "github.com/rs/zerolog" + "gorm.io/gorm" ) type ourServiceContentRepository struct { @@ -14,6 +17,7 @@ type ourServiceContentRepository struct { type OurServiceContentRepository interface { Get() (*entity.OurServiceContent, error) + GetAll() ([]entity.OurServiceContent, error) Create(data *entity.OurServiceContent) (*entity.OurServiceContent, error) Update(id uint, data *entity.OurServiceContent) error Delete(id uint) error @@ -34,6 +38,9 @@ func (r *ourServiceContentRepository) Get() (*entity.OurServiceContent, error) { First(&data).Error if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, nil + } r.Log.Error().Err(err).Msg("failed get our service content") return nil, err } @@ -41,6 +48,19 @@ func (r *ourServiceContentRepository) Get() (*entity.OurServiceContent, error) { return &data, nil } +func (r *ourServiceContentRepository) GetAll() ([]entity.OurServiceContent, error) { + var rows []entity.OurServiceContent + err := r.DB.DB. + Preload("Images"). + Order("created_at ASC"). + Find(&rows).Error + if err != nil { + r.Log.Error().Err(err).Msg("failed list our service contents") + return nil, err + } + return rows, nil +} + func (r *ourServiceContentRepository) Create(data *entity.OurServiceContent) (*entity.OurServiceContent, error) { err := r.DB.DB.Create(data).Error if err != nil { @@ -52,10 +72,21 @@ func (r *ourServiceContentRepository) Create(data *entity.OurServiceContent) (*e } func (r *ourServiceContentRepository) Update(id uint, data *entity.OurServiceContent) error { + updates := map[string]interface{}{ + "primary_title": data.PrimaryTitle, + "secondary_title": data.SecondaryTitle, + "description": data.Description, + "link_url": data.LinkURL, + "updated_at": time.Now(), + } + if data.IsActive != nil { + updates["is_active"] = data.IsActive + } + err := r.DB.DB. Model(&entity.OurServiceContent{}). Where("id = ?", id). - Updates(data).Error + Updates(updates).Error if err != nil { r.Log.Error().Err(err).Msg("failed update our service content") @@ -65,12 +96,17 @@ func (r *ourServiceContentRepository) Update(id uint, data *entity.OurServiceCon return nil } +// Delete removes child images first so FK constraints do not block the parent delete. func (r *ourServiceContentRepository) Delete(id uint) error { - err := r.DB.DB.Delete(&entity.OurServiceContent{}, id).Error - if err != nil { - r.Log.Error().Err(err).Msg("failed delete our service content") - return err - } - - return nil -} \ No newline at end of file + return r.DB.DB.Transaction(func(tx *gorm.DB) error { + if err := tx.Exec(`DELETE FROM our_service_content_images WHERE our_service_content_id = ?`, id).Error; err != nil { + r.Log.Error().Err(err).Msg("failed delete our service content images") + return err + } + if err := tx.Exec(`DELETE FROM our_service_contents WHERE id = ?`, id).Error; err != nil { + r.Log.Error().Err(err).Msg("failed delete our service content") + return err + } + return nil + }) +} diff --git a/app/module/our_service_contents/request/our_service_contents.request.go b/app/module/our_service_contents/request/our_service_contents.request.go index 9734133..2df2d10 100644 --- a/app/module/our_service_contents/request/our_service_contents.request.go +++ b/app/module/our_service_contents/request/our_service_contents.request.go @@ -6,6 +6,7 @@ type OurServiceContentCreateRequest struct { PrimaryTitle string `json:"primary_title" form:"primary_title" validate:"required"` SecondaryTitle string `json:"secondary_title" form:"secondary_title"` Description string `json:"description" form:"description"` + LinkURL string `json:"link_url" form:"link_url"` } func (r *OurServiceContentCreateRequest) ToEntity() *entity.OurServiceContent { @@ -13,6 +14,7 @@ func (r *OurServiceContentCreateRequest) ToEntity() *entity.OurServiceContent { PrimaryTitle: r.PrimaryTitle, SecondaryTitle: r.SecondaryTitle, Description: r.Description, + LinkURL: r.LinkURL, } } @@ -20,6 +22,7 @@ type OurServiceContentUpdateRequest struct { PrimaryTitle string `json:"primary_title"` SecondaryTitle string `json:"secondary_title"` Description string `json:"description"` + LinkURL string `json:"link_url"` } func (r *OurServiceContentUpdateRequest) ToEntity() *entity.OurServiceContent { @@ -27,5 +30,6 @@ func (r *OurServiceContentUpdateRequest) ToEntity() *entity.OurServiceContent { PrimaryTitle: r.PrimaryTitle, SecondaryTitle: r.SecondaryTitle, Description: r.Description, + LinkURL: r.LinkURL, } -} \ No newline at end of file +} diff --git a/app/module/our_service_contents/service/our_service_contents.service.go b/app/module/our_service_contents/service/our_service_contents.service.go index e242bba..2631fab 100644 --- a/app/module/our_service_contents/service/our_service_contents.service.go +++ b/app/module/our_service_contents/service/our_service_contents.service.go @@ -15,7 +15,7 @@ type ourServiceContentService struct { } type OurServiceContentService interface { - Show() (*entity.OurServiceContent, error) + Show() ([]entity.OurServiceContent, error) Save(data *entity.OurServiceContent) (*entity.OurServiceContent, error) Update(id uint, data *entity.OurServiceContent) error Delete(id uint) error @@ -33,14 +33,13 @@ func NewOurServiceContentService( } } -func (s *ourServiceContentService) Show() (*entity.OurServiceContent, error) { - data, err := s.Repo.Get() +func (s *ourServiceContentService) Show() ([]entity.OurServiceContent, error) { + rows, err := s.Repo.GetAll() if err != nil { - s.Log.Error().Err(err).Msg("failed get our service content") + s.Log.Error().Err(err).Msg("failed list our service contents") return nil, err } - - return data, nil + return rows, nil } func (s *ourServiceContentService) Save(data *entity.OurServiceContent) (*entity.OurServiceContent, error) { @@ -75,13 +74,5 @@ func (s *ourServiceContentService) Update(id uint, data *entity.OurServiceConten } func (s *ourServiceContentService) Delete(id uint) error { - result, err := s.Repo.Get() - if err != nil { - return err - } - - isActive := false - result.IsActive = &isActive - - return s.Repo.Update(id, result) + return s.Repo.Delete(id) } \ No newline at end of file diff --git a/app/module/partner_contents/controller/partner_contents.controller.go b/app/module/partner_contents/controller/partner_contents.controller.go index d3fe0d2..8cf6b40 100644 --- a/app/module/partner_contents/controller/partner_contents.controller.go +++ b/app/module/partner_contents/controller/partner_contents.controller.go @@ -21,6 +21,7 @@ type PartnerContentController interface { Show(c *fiber.Ctx) error Save(c *fiber.Ctx) error Update(c *fiber.Ctx) error + UploadLogo(c *fiber.Ctx) error Delete(c *fiber.Ctx) error } @@ -94,6 +95,26 @@ func (_i *partnerContentController) Update(c *fiber.Ctx) error { }) } +func (_i *partnerContentController) UploadLogo(c *fiber.Ctx) error { + idStr := c.Params("id") + id, err := uuid.Parse(idStr) + if err != nil { + return err + } + file, err := c.FormFile("file") + if err != nil { + return err + } + if err := _i.service.UploadLogo(id, file); err != nil { + _i.Log.Error().Err(err).Msg("failed upload partner logo") + return err + } + return utilRes.Resp(c, utilRes.Response{ + Success: true, + Messages: utilRes.Messages{"Partner logo uploaded"}, + }) +} + func (_i *partnerContentController) Delete(c *fiber.Ctx) error { idStr := c.Params("id") diff --git a/app/module/partner_contents/partner_contents.module.go b/app/module/partner_contents/partner_contents.module.go index 0312c7a..a404d21 100644 --- a/app/module/partner_contents/partner_contents.module.go +++ b/app/module/partner_contents/partner_contents.module.go @@ -42,6 +42,7 @@ func (_i *PartnerContentsRouter) RegisterPartnerContentsRoutes() { _i.App.Route("/partner-contents", func(router fiber.Router) { router.Get("/", partnerController.Show) router.Post("/", partnerController.Save) + router.Post("/:id/logo", partnerController.UploadLogo) router.Put("/:id", partnerController.Update) router.Delete("/:id", partnerController.Delete) }) diff --git a/app/module/partner_contents/repository/partner_contents.repository.go b/app/module/partner_contents/repository/partner_contents.repository.go index 0828bc2..b9fab33 100644 --- a/app/module/partner_contents/repository/partner_contents.repository.go +++ b/app/module/partner_contents/repository/partner_contents.repository.go @@ -1,6 +1,7 @@ package repository import ( + "time" "web-qudo-be/app/database" "web-qudo-be/app/database/entity" @@ -17,6 +18,7 @@ type PartnerContentRepository interface { Get() ([]entity.PartnerContent, error) Create(data *entity.PartnerContent) (*entity.PartnerContent, error) Update(id uuid.UUID, data *entity.PartnerContent) error + UpdateImageFields(id uuid.UUID, imagePath, imageURL string) error Delete(id uuid.UUID) error FindByID(id uuid.UUID) (*entity.PartnerContent, error) // opsional (buat soft delete) } @@ -58,7 +60,12 @@ func (r *partnerContentRepository) Update(id uuid.UUID, data *entity.PartnerCont err := r.DB.DB. Model(&entity.PartnerContent{}). Where("id = ?", id). - Updates(data).Error + Updates(map[string]interface{}{ + "primary_title": data.PrimaryTitle, + "image_path": data.ImagePath, + "image_url": data.ImageURL, + "updated_at": time.Now(), + }).Error if err != nil { r.Log.Error().Err(err).Msg("failed update partner content") @@ -68,6 +75,21 @@ func (r *partnerContentRepository) Update(id uuid.UUID, data *entity.PartnerCont return nil } +func (r *partnerContentRepository) UpdateImageFields(id uuid.UUID, imagePath, imageURL string) error { + err := r.DB.DB. + Model(&entity.PartnerContent{}). + Where("id = ?", id). + Updates(map[string]interface{}{ + "image_path": imagePath, + "image_url": imageURL, + }).Error + if err != nil { + r.Log.Error().Err(err).Msg("failed update partner logo") + return err + } + return nil +} + func (r *partnerContentRepository) Delete(id uuid.UUID) error { err := r.DB.DB.Delete(&entity.PartnerContent{}, id).Error if err != nil { @@ -90,4 +112,4 @@ func (r *partnerContentRepository) FindByID(id uuid.UUID) (*entity.PartnerConten } return &data, nil -} \ No newline at end of file +} diff --git a/app/module/partner_contents/service/partner_contents.service.go b/app/module/partner_contents/service/partner_contents.service.go index 5535592..8e52468 100644 --- a/app/module/partner_contents/service/partner_contents.service.go +++ b/app/module/partner_contents/service/partner_contents.service.go @@ -1,32 +1,44 @@ package service import ( + "mime/multipart" + "github.com/google/uuid" "github.com/rs/zerolog" "web-qudo-be/app/database/entity" + medialib "web-qudo-be/app/module/media_library/service" "web-qudo-be/app/module/partner_contents/repository" + minioStorage "web-qudo-be/config/config" + "web-qudo-be/utils/storage" ) type partnerContentService struct { - Repo repository.PartnerContentRepository - Log zerolog.Logger + Repo repository.PartnerContentRepository + MinioStorage *minioStorage.MinioStorage + MediaLib medialib.MediaLibraryService + Log zerolog.Logger } type PartnerContentService interface { Show() ([]entity.PartnerContent, error) Save(data *entity.PartnerContent) (*entity.PartnerContent, error) Update(id uuid.UUID, data *entity.PartnerContent) error + UploadLogo(id uuid.UUID, file *multipart.FileHeader) error Delete(id uuid.UUID) error } func NewPartnerContentService( repo repository.PartnerContentRepository, + minio *minioStorage.MinioStorage, + mediaLib medialib.MediaLibraryService, log zerolog.Logger, ) PartnerContentService { return &partnerContentService{ - Repo: repo, - Log: log, + Repo: repo, + MinioStorage: minio, + MediaLib: mediaLib, + Log: log, } } @@ -62,6 +74,20 @@ func (s *partnerContentService) Update(id uuid.UUID, data *entity.PartnerContent return nil } +func (s *partnerContentService) UploadLogo(id uuid.UUID, file *multipart.FileHeader) error { + key, url, err := storage.UploadCMSObject(s.MinioStorage, "partners", file, false) + if err != nil { + return err + } + if err := s.Repo.UpdateImageFields(id, key, url); err != nil { + return err + } + if s.MediaLib != nil { + _ = s.MediaLib.RegisterCMSAsset(url, key, "partner_logo", file) + } + return nil +} + func (s *partnerContentService) Delete(id uuid.UUID) error { err := s.Repo.Delete(id) if err != nil { diff --git a/app/module/popup_news_content_images/controller/popup_news_content_images.controller.go b/app/module/popup_news_content_images/controller/popup_news_content_images.controller.go index c848450..136e415 100644 --- a/app/module/popup_news_content_images/controller/popup_news_content_images.controller.go +++ b/app/module/popup_news_content_images/controller/popup_news_content_images.controller.go @@ -2,6 +2,7 @@ package controller import ( "strconv" + "strings" "github.com/gofiber/fiber/v2" @@ -39,6 +40,32 @@ func NewPopupNewsContentImagesController(s service.PopupNewsContentImagesService // @Failure 500 {object} response.Response // @Router /popup-news-content-images [post] func (_i *popupNewsContentImagesController) Save(c *fiber.Ctx) error { + if strings.HasPrefix(c.Get("Content-Type"), "multipart/form-data") { + cidStr := c.FormValue("popup_news_content_id") + cid64, err := strconv.ParseUint(cidStr, 10, 32) + if err != nil { + return err + } + file, err := c.FormFile("file") + if err != nil { + return err + } + var isThumb *bool + if v := strings.TrimSpace(c.FormValue("is_thumbnail")); v != "" { + b := v == "true" || v == "1" || v == "on" + isThumb = &b + } + result, err := _i.service.SaveWithFile(uint(cid64), file, isThumb) + if err != nil { + return err + } + return utilRes.Resp(c, utilRes.Response{ + Success: true, + Messages: utilRes.Messages{"Popup news content image successfully uploaded"}, + Data: result, + }) + } + req := new(request.PopupNewsContentImagesCreateRequest) if err := utilVal.ParseAndValidate(c, req); err != nil { return err @@ -50,6 +77,7 @@ func (_i *popupNewsContentImagesController) Save(c *fiber.Ctx) error { } return utilRes.Resp(c, utilRes.Response{ + Success: true, Messages: utilRes.Messages{"Popup news content image successfully uploaded"}, }) } diff --git a/app/module/popup_news_content_images/service/popup_news_content_images.go b/app/module/popup_news_content_images/service/popup_news_content_images.go index 2629a5a..3e59c25 100644 --- a/app/module/popup_news_content_images/service/popup_news_content_images.go +++ b/app/module/popup_news_content_images/service/popup_news_content_images.go @@ -1,40 +1,81 @@ package service import ( + "mime/multipart" + "github.com/rs/zerolog" + "web-qudo-be/app/database/entity" + medialib "web-qudo-be/app/module/media_library/service" "web-qudo-be/app/module/popup_news_content_images/repository" "web-qudo-be/app/module/popup_news_content_images/request" + minioStorage "web-qudo-be/config/config" + "web-qudo-be/utils/storage" ) -// service struct type popupNewsContentImagesService struct { - Repo repository.PopupNewsContentImagesRepository - Log zerolog.Logger + Repo repository.PopupNewsContentImagesRepository + MinioStorage *minioStorage.MinioStorage + MediaLib medialib.MediaLibraryService + Log zerolog.Logger } -// interface type PopupNewsContentImagesService interface { Save(req request.PopupNewsContentImagesCreateRequest) error + SaveWithFile(popupNewsContentID uint, file *multipart.FileHeader, isThumbnail *bool) (*entity.PopupNewsContentImages, error) Delete(id uint) error } -// constructor -func NewPopupNewsContentImagesService(repo repository.PopupNewsContentImagesRepository, log zerolog.Logger) PopupNewsContentImagesService { +func NewPopupNewsContentImagesService( + repo repository.PopupNewsContentImagesRepository, + minio *minioStorage.MinioStorage, + log zerolog.Logger, +) PopupNewsContentImagesService { return &popupNewsContentImagesService{ - Repo: repo, - Log: log, + Repo: repo, + MinioStorage: minio, + Log: log, } } -// Save func (_i *popupNewsContentImagesService) Save(req request.PopupNewsContentImagesCreateRequest) error { - _i.Log.Info().Interface("data", req).Msg("upload popup news content image") + _i.Log.Info().Interface("data", req).Msg("create popup news content image (json)") return _i.Repo.Create(req.ToEntity()) } -// Delete +func (_i *popupNewsContentImagesService) SaveWithFile(popupNewsContentID uint, file *multipart.FileHeader, isThumbnail *bool) (*entity.PopupNewsContentImages, error) { + _i.Log.Info(). + Uint("popup_news_content_id", popupNewsContentID). + Str("filename", file.Filename). + Msg("upload popup news content image") + + key, url, err := storage.UploadCMSObject(_i.MinioStorage, "popup-news", file, false) + if err != nil { + return nil, err + } + + if isThumbnail != nil && *isThumbnail { + if err := _i.Repo.ResetThumbnail(popupNewsContentID); err != nil { + return nil, err + } + } + + row := &entity.PopupNewsContentImages{ + PopupNewsContentID: popupNewsContentID, + MediaPath: key, + MediaURL: url, + IsThumbnail: isThumbnail, + } + if err := _i.Repo.Create(row); err != nil { + return nil, err + } + if _i.MediaLib != nil { + _ = _i.MediaLib.RegisterCMSAsset(url, key, "popup_news", file) + } + return row, nil +} + func (_i *popupNewsContentImagesService) Delete(id uint) error { return _i.Repo.Delete(id) -} \ No newline at end of file +} diff --git a/app/module/popup_news_contents/controller/popup_news_contents.controller.go b/app/module/popup_news_contents/controller/popup_news_contents.controller.go index fd5bc68..41a5dd4 100644 --- a/app/module/popup_news_contents/controller/popup_news_contents.controller.go +++ b/app/module/popup_news_contents/controller/popup_news_contents.controller.go @@ -110,13 +110,14 @@ func (_i *popupNewsContentsController) Save(c *fiber.Ctx) error { return err } - err := _i.service.Save(*req) + data, err := _i.service.Save(*req) if err != nil { return err } return utilRes.Resp(c, utilRes.Response{ Messages: utilRes.Messages{"Popup news content successfully created"}, + Data: data, }) } diff --git a/app/module/popup_news_contents/repository/popup_news_contents.repository.go b/app/module/popup_news_contents/repository/popup_news_contents.repository.go index eb7078d..fec46c5 100644 --- a/app/module/popup_news_contents/repository/popup_news_contents.repository.go +++ b/app/module/popup_news_contents/repository/popup_news_contents.repository.go @@ -5,6 +5,8 @@ import ( "web-qudo-be/app/database/entity" "web-qudo-be/app/module/popup_news_contents/request" "web-qudo-be/utils/paginator" + + "gorm.io/gorm" ) type popupNewsContentsRepository struct { @@ -70,11 +72,22 @@ func (_i *popupNewsContentsRepository) Create(data *entity.PopupNewsContents) er // Update func (_i *popupNewsContentsRepository) Update(id uint, data *entity.PopupNewsContents) error { return _i.DB.DB.Model(&entity.PopupNewsContents{}). - Where(&entity.PopupNewsContents{ID: id}). - Updates(data).Error + Where("id = ?", id). + Updates(map[string]interface{}{ + "primary_title": data.PrimaryTitle, + "secondary_title": data.SecondaryTitle, + "description": data.Description, + "primary_cta": data.PrimaryCTA, + "secondary_cta_text": data.SecondaryCTAText, + }).Error } -// Delete +// Delete removes child images first so FK constraints do not block the parent delete. func (_i *popupNewsContentsRepository) Delete(id uint) error { - return _i.DB.DB.Delete(&entity.PopupNewsContents{}, id).Error -} \ No newline at end of file + return _i.DB.DB.Transaction(func(tx *gorm.DB) error { + if err := tx.Where("popup_news_content_id = ?", id).Delete(&entity.PopupNewsContentImages{}).Error; err != nil { + return err + } + return tx.Delete(&entity.PopupNewsContents{}, id).Error + }) +} diff --git a/app/module/popup_news_contents/service/popup_news_contents.service.go b/app/module/popup_news_contents/service/popup_news_contents.service.go index b8636f3..330e943 100644 --- a/app/module/popup_news_contents/service/popup_news_contents.service.go +++ b/app/module/popup_news_contents/service/popup_news_contents.service.go @@ -20,7 +20,7 @@ type popupNewsContentsService struct { type PopupNewsContentsService interface { All(req request.PopupNewsContentsQueryRequest) (data []*response.PopupNewsContentsResponse, paging paginator.Pagination, err error) Show(id uint) (*response.PopupNewsContentsResponse, error) - Save(req request.PopupNewsContentsCreateRequest) error + Save(req request.PopupNewsContentsCreateRequest) (*response.PopupNewsContentsResponse, error) Update(id uint, req request.PopupNewsContentsUpdateRequest) error Delete(id uint) error } @@ -58,10 +58,14 @@ func (_i *popupNewsContentsService) Show(id uint) (*response.PopupNewsContentsRe } // Save -func (_i *popupNewsContentsService) Save(req request.PopupNewsContentsCreateRequest) error { +func (_i *popupNewsContentsService) Save(req request.PopupNewsContentsCreateRequest) (*response.PopupNewsContentsResponse, error) { _i.Log.Info().Interface("data", req).Msg("create popup news content") - return _i.Repo.Create(req.ToEntity()) + ent := req.ToEntity() + if err := _i.Repo.Create(ent); err != nil { + return nil, err + } + return mapper.PopupNewsContentsResponseMapper(ent), nil } // Update diff --git a/app/router/api.go b/app/router/api.go index 1c38c98..a1a13e0 100644 --- a/app/router/api.go +++ b/app/router/api.go @@ -20,6 +20,8 @@ import ( "web-qudo-be/app/module/cities" "web-qudo-be/app/module/client_approval_settings" "web-qudo-be/app/module/clients" + "web-qudo-be/app/module/cms_content_submissions" + "web-qudo-be/app/module/cms_media" "web-qudo-be/app/module/custom_static_pages" "web-qudo-be/app/module/districts" "web-qudo-be/app/module/feedbacks" @@ -27,6 +29,7 @@ import ( hero_content "web-qudo-be/app/module/hero_contents" "web-qudo-be/app/module/magazine_files" "web-qudo-be/app/module/magazines" + "web-qudo-be/app/module/media_library" "web-qudo-be/app/module/master_menus" "web-qudo-be/app/module/master_modules" "web-qudo-be/app/module/our_product_content_images" @@ -72,13 +75,16 @@ type Router struct { CitiesRouter *cities.CitiesRouter ClientApprovalSettingsRouter *client_approval_settings.ClientApprovalSettingsRouter ClientsRouter *clients.ClientsRouter - HeroContentsRouter *hero_content.HeroContentsRouter + CmsContentSubmissionsRouter *cms_content_submissions.CmsContentSubmissionsRouter + CmsMediaRouter *cms_media.CmsMediaRouter + HeroContentsRouter *hero_content.HeroContentsRouter HeroContentImagesRouter *hero_content_image.HeroContentImagesRouter CustomStaticPagesRouter *custom_static_pages.CustomStaticPagesRouter DistrictsRouter *districts.DistrictsRouter FeedbacksRouter *feedbacks.FeedbacksRouter MagazineFilesRouter *magazine_files.MagazineFilesRouter MagazinesRouter *magazines.MagazinesRouter + MediaLibraryRouter *media_library.MediaLibraryRouter MasterMenusRouter *master_menus.MasterMenusRouter MasterModulesRouter *master_modules.MasterModulesRouter OurProductContentsRouter *our_product_contents.OurProductContentsRouter @@ -119,6 +125,8 @@ func NewRouter( citiesRouter *cities.CitiesRouter, clientApprovalSettingsRouter *client_approval_settings.ClientApprovalSettingsRouter, clientsRouter *clients.ClientsRouter, + cmsContentSubmissionsRouter *cms_content_submissions.CmsContentSubmissionsRouter, + cmsMediaRouter *cms_media.CmsMediaRouter, heroContentsRouter *hero_content.HeroContentsRouter, heroContentImagesRouter *hero_content_image.HeroContentImagesRouter, customStaticPagesRouter *custom_static_pages.CustomStaticPagesRouter, @@ -126,6 +134,7 @@ func NewRouter( feedbacksRouter *feedbacks.FeedbacksRouter, magazineFilesRouter *magazine_files.MagazineFilesRouter, magazinesRouter *magazines.MagazinesRouter, + mediaLibraryRouter *media_library.MediaLibraryRouter, masterMenuRouter *master_menus.MasterMenusRouter, masterModuleRouter *master_modules.MasterModulesRouter, ourProductContentsRouter *our_product_contents.OurProductContentsRouter, @@ -165,6 +174,8 @@ func NewRouter( CitiesRouter: citiesRouter, ClientApprovalSettingsRouter: clientApprovalSettingsRouter, ClientsRouter: clientsRouter, + CmsContentSubmissionsRouter: cmsContentSubmissionsRouter, + CmsMediaRouter: cmsMediaRouter, HeroContentsRouter: heroContentsRouter, HeroContentImagesRouter: heroContentImagesRouter, CustomStaticPagesRouter: customStaticPagesRouter, @@ -172,6 +183,7 @@ func NewRouter( FeedbacksRouter: feedbacksRouter, MagazineFilesRouter: magazineFilesRouter, MagazinesRouter: magazinesRouter, + MediaLibraryRouter: mediaLibraryRouter, MasterMenusRouter: masterMenuRouter, MasterModulesRouter: masterModuleRouter, OurProductContentsRouter: ourProductContentsRouter, @@ -221,6 +233,8 @@ func (r *Router) Register() { r.CitiesRouter.RegisterCitiesRoutes() r.ClientApprovalSettingsRouter.RegisterClientApprovalSettingsRoutes() r.ClientsRouter.RegisterClientsRoutes() + r.CmsContentSubmissionsRouter.RegisterCmsContentSubmissionsRoutes() + r.CmsMediaRouter.RegisterCmsMediaRoutes() r.HeroContentsRouter.RegisterHeroContentsRoutes() r.HeroContentImagesRouter.RegisterHeroContentImagesRoutes() r.CustomStaticPagesRouter.RegisterCustomStaticPagesRoutes() @@ -228,6 +242,7 @@ func (r *Router) Register() { r.FeedbacksRouter.RegisterFeedbacksRoutes() r.MagazinesRouter.RegisterMagazinesRoutes() r.MagazineFilesRouter.RegisterMagazineFilesRoutes() + r.MediaLibraryRouter.RegisterMediaLibraryRoutes() r.MasterMenusRouter.RegisterMasterMenusRoutes() r.MasterModulesRouter.RegisterMasterModulesRoutes() r.OurProductContentsRouter.RegisterOurProductContentsRoutes() diff --git a/config/config/index.config.go b/config/config/index.config.go index 5f2d2e4..4d50bc8 100644 --- a/config/config/index.config.go +++ b/config/config/index.config.go @@ -128,6 +128,23 @@ type Config struct { Smtp smtp } +// APIPublicBaseURL is the base URL embedded in links returned to clients (e.g. CMS image preview). +// If app.production is true, it uses app.domain (trimmed). Otherwise it uses http://localhost plus app.port +// so local runs match opening the API in the browser without rewriting https://qudo.id/api. +func (c *Config) APIPublicBaseURL() string { + if c.App.Production { + return strings.TrimSuffix(c.App.Domain, "/") + } + port := strings.TrimSpace(c.App.Port) + if port == "" { + return "http://localhost" + } + if !strings.HasPrefix(port, ":") { + port = ":" + port + } + return "http://localhost" + port +} + // NewConfig : initialize config func NewConfig() *Config { config, err := ParseConfig("config") diff --git a/config/config/minio.config.go b/config/config/minio.config.go index 211db47..f17dec8 100644 --- a/config/config/minio.config.go +++ b/config/config/minio.config.go @@ -2,6 +2,7 @@ package config import ( "context" + "fmt" "log" "github.com/minio/minio-go/v7" @@ -69,3 +70,13 @@ func (_minio *MinioStorage) ConnectMinio() (*minio.Client, error) { log.Printf("[MinIO] Successfully connected to MinIO and bucket '%s' is ready", bucketName) return minioClient, nil } + +// PublicObjectURL builds a path-style URL for public reads (bucket policy must allow GET). +func (m *MinioStorage) PublicObjectURL(objectKey string) string { + o := m.Cfg.ObjectStorage.MinioStorage + scheme := "http" + if o.UseSSL { + scheme = "https" + } + return fmt.Sprintf("%s://%s/%s/%s", scheme, o.Endpoint, o.BucketName, objectKey) +} diff --git a/config/toml/config.toml b/config/toml/config.toml index 73a2e73..a18d711 100644 --- a/config/toml/config.toml +++ b/config/toml/config.toml @@ -8,11 +8,12 @@ external-port = ":8812" idle-timeout = 5 # As seconds print-routes = false prefork = false -production = false +# false: CMS preview URLs use http://localhost + port above. true: use domain (e.g. https://qudo.id/api). +production = true body-limit = 1048576000 # "100 * 1024 * 1024" [db.postgres] -dsn = "postgresql://medols_user:MedolsDB@2025@38.47.185.79:5432/medols_db" # ://:@:/ +dsn = "postgresql://qudo_user:QudoDB@2026@38.47.185.79:5432/qudo_db" # ://:@:/ log-mode = "ERROR" migrate = true seed = false @@ -28,7 +29,7 @@ endpoint = "is3.cloudhost.id" access-key-id = "YRP1RM617986USRU6NN8" secret-access-key = "vfbwQDYb1m7nfzo4LVEz90BIyOWfBMZ6bfGQbqDO" use-ssl = true -bucket-name = "mikulnews" +bucket-name = "qudo" location = "us-east-1" [middleware.compress] diff --git a/docs/docs.go b/docs/docs.go index 36fe5be..1c6cdc4 100644 --- a/docs/docs.go +++ b/docs/docs.go @@ -6869,6 +6869,12 @@ const docTemplate = `{ "name": "isPublish", "in": "query" }, + { + "type": "string", + "description": "myContentMode: \"own\" = current user's articles (any level); \"approver\" = non-draft from contributors (user_role_id 3) for approver history", + "name": "myContentMode", + "in": "query" + }, { "type": "string", "name": "source", @@ -9908,6 +9914,11 @@ const docTemplate = `{ } } }, + "/cms-media/viewer/{path}": { + "get": { + "responses": {} + } + }, "/custom-static-pages": { "get": { "security": [ @@ -17124,7 +17135,6 @@ const docTemplate = `{ "request.ArticlesCreateRequest": { "type": "object", "required": [ - "categoryIds", "description", "htmlDescription", "slug", @@ -17183,7 +17193,6 @@ const docTemplate = `{ "request.ArticlesUpdateRequest": { "type": "object", "required": [ - "categoryIds", "description", "htmlDescription", "slug", diff --git a/docs/swagger.json b/docs/swagger.json index 3e415bd..8fd973a 100644 --- a/docs/swagger.json +++ b/docs/swagger.json @@ -6858,6 +6858,12 @@ "name": "isPublish", "in": "query" }, + { + "type": "string", + "description": "myContentMode: \"own\" = current user's articles (any level); \"approver\" = non-draft from contributors (user_role_id 3) for approver history", + "name": "myContentMode", + "in": "query" + }, { "type": "string", "name": "source", @@ -9897,6 +9903,11 @@ } } }, + "/cms-media/viewer/{path}": { + "get": { + "responses": {} + } + }, "/custom-static-pages": { "get": { "security": [ @@ -17113,7 +17124,6 @@ "request.ArticlesCreateRequest": { "type": "object", "required": [ - "categoryIds", "description", "htmlDescription", "slug", @@ -17172,7 +17182,6 @@ "request.ArticlesUpdateRequest": { "type": "object", "required": [ - "categoryIds", "description", "htmlDescription", "slug", diff --git a/docs/swagger.yaml b/docs/swagger.yaml index 961af6b..ccae715 100644 --- a/docs/swagger.yaml +++ b/docs/swagger.yaml @@ -488,7 +488,6 @@ definitions: typeId: type: integer required: - - categoryIds - description - htmlDescription - slug @@ -529,7 +528,6 @@ definitions: typeId: type: integer required: - - categoryIds - description - htmlDescription - slug @@ -5840,6 +5838,11 @@ paths: - in: query name: isPublish type: boolean + - description: 'myContentMode: "own" = current user''s articles (any level); + "approver" = non-draft from contributors (user_role_id 3) for approver history' + in: query + name: myContentMode + type: string - in: query name: source type: string @@ -7793,6 +7796,9 @@ paths: summary: update Clients tags: - Clients + /cms-media/viewer/{path}: + get: + responses: {} /custom-static-pages: get: description: API for getting all CustomStaticPages diff --git a/main.go b/main.go index 68c3207..a6279a5 100644 --- a/main.go +++ b/main.go @@ -23,6 +23,8 @@ import ( "web-qudo-be/app/module/cities" "web-qudo-be/app/module/client_approval_settings" "web-qudo-be/app/module/clients" + "web-qudo-be/app/module/cms_content_submissions" + "web-qudo-be/app/module/cms_media" "web-qudo-be/app/module/custom_static_pages" "web-qudo-be/app/module/districts" "web-qudo-be/app/module/feedbacks" @@ -30,6 +32,7 @@ import ( hero_content "web-qudo-be/app/module/hero_contents" "web-qudo-be/app/module/magazine_files" "web-qudo-be/app/module/magazines" + "web-qudo-be/app/module/media_library" "web-qudo-be/app/module/master_menus" "web-qudo-be/app/module/master_modules" "web-qudo-be/app/module/our_product_content_images" @@ -104,6 +107,8 @@ func main() { cities.NewCitiesModule, client_approval_settings.NewClientApprovalSettingsModule, clients.NewClientsModule, + cms_content_submissions.NewCmsContentSubmissionsModule, + cms_media.NewCmsMediaModule, custom_static_pages.NewCustomStaticPagesModule, districts.NewDistrictsModule, feedbacks.NewFeedbacksModule, @@ -111,6 +116,7 @@ func main() { hero_content_image.NewHeroContentImagesModule, magazines.NewMagazinesModule, magazine_files.NewMagazineFilesModule, + media_library.NewMediaLibraryModule, master_menus.NewMasterMenusModule, master_modules.NewMasterModulesModule, our_product_contents.NewOurProductContentsModule, diff --git a/utils/storage/cms_upload.go b/utils/storage/cms_upload.go new file mode 100644 index 0000000..055e243 --- /dev/null +++ b/utils/storage/cms_upload.go @@ -0,0 +1,100 @@ +package storage + +import ( + "context" + "fmt" + "mime" + "mime/multipart" + "path/filepath" + "strings" + "time" + + "github.com/google/uuid" + "github.com/minio/minio-go/v7" + + appcfg "web-qudo-be/config/config" +) + +// CMSPreviewURL is the absolute URL served by this API (GET /cms-media/viewer/...) for DB image_url / media_url fields. +func CMSPreviewURL(cfg *appcfg.Config, objectKey string) string { + base := cfg.APIPublicBaseURL() + key := strings.TrimPrefix(strings.TrimSpace(objectKey), "/") + return base + "/cms-media/viewer/" + key +} + +var imageExts = map[string]bool{ + ".jpg": true, ".jpeg": true, ".png": true, ".gif": true, ".webp": true, +} + +var mediaExts = map[string]bool{ + ".jpg": true, ".jpeg": true, ".png": true, ".gif": true, ".webp": true, + ".mp4": true, ".webm": true, +} + +// mediaLibraryExts = images + video + audio + common documents (admin Media Library upload). +var mediaLibraryExts = map[string]bool{ + ".jpg": true, ".jpeg": true, ".png": true, ".gif": true, ".webp": true, ".svg": true, + ".mp4": true, ".webm": true, ".mov": true, + ".mp3": true, ".wav": true, ".ogg": true, ".m4a": true, + ".pdf": true, ".doc": true, ".docx": true, ".txt": true, ".csv": true, +} + +// UploadCMSObject stores a file in MinIO under cms/{folder}/YYYY/MM/{uuid}{ext} and returns object key + preview URL (API viewer, not direct MinIO). +func UploadCMSObject(ms *appcfg.MinioStorage, folder string, file *multipart.FileHeader, allowVideo bool) (objectKey string, previewURL string, err error) { + if file == nil { + return "", "", fmt.Errorf("file is required") + } + ext := strings.ToLower(filepath.Ext(file.Filename)) + if folder == "media-library" { + if !mediaLibraryExts[ext] { + return "", "", fmt.Errorf("unsupported file type for media library") + } + } else if allowVideo { + if !mediaExts[ext] { + return "", "", fmt.Errorf("unsupported file type (allowed: images, mp4, webm)") + } + } else if !imageExts[ext] { + return "", "", fmt.Errorf("unsupported image type") + } + + client, err := ms.ConnectMinio() + if err != nil { + return "", "", err + } + + src, err := file.Open() + if err != nil { + return "", "", err + } + defer src.Close() + + bucket := ms.Cfg.ObjectStorage.MinioStorage.BucketName + now := time.Now() + objectKey = fmt.Sprintf("cms/%s/%d/%02d/%s%s", folder, now.Year(), int(now.Month()), uuid.New().String(), ext) + + contentType := mime.TypeByExtension(ext) + if contentType == "" { + contentType = "application/octet-stream" + } + + _, err = client.PutObject(context.Background(), bucket, objectKey, src, file.Size, minio.PutObjectOptions{ + ContentType: contentType, + }) + if err != nil { + return "", "", err + } + + return objectKey, CMSPreviewURL(ms.Cfg, objectKey), nil +} + +// UploadMediaLibraryObject stores under cms/media-library/... with a broader MIME allowlist. +func UploadMediaLibraryObject(ms *appcfg.MinioStorage, file *multipart.FileHeader) (objectKey string, previewURL string, err error) { + if file == nil { + return "", "", fmt.Errorf("file is required") + } + ext := strings.ToLower(filepath.Ext(file.Filename)) + if !mediaLibraryExts[ext] { + return "", "", fmt.Errorf("unsupported file type for media library") + } + return UploadCMSObject(ms, "media-library", file, false) +} diff --git a/web-qudo-be.exe b/web-qudo-be.exe index 162f894..1c21a3d 100644 Binary files a/web-qudo-be.exe and b/web-qudo-be.exe differ