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/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/index.database.go b/app/database/index.database.go index 32dfb87..003111a 100644 --- a/app/database/index.database.go +++ b/app/database/index.database.go @@ -105,6 +105,7 @@ func Models() []interface{} { entity.Clients{}, entity.HeroContents{}, entity.HeroContentImages{}, + entity.CmsContentSubmission{}, entity.ClientApprovalSettings{}, entity.CsrfTokenRecords{}, entity.CustomStaticPages{}, @@ -113,6 +114,7 @@ func Models() []interface{} { entity.ForgotPasswords{}, entity.Magazines{}, entity.MagazineFiles{}, + entity.MediaLibraryItem{}, entity.MasterMenus{}, entity.MasterModules{}, entity.MasterStatuses{}, 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 22c21ce..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 @@ -9,6 +9,7 @@ import ( "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" @@ -18,6 +19,7 @@ import ( type aboutUsContentImageService struct { Repo repository.AboutUsContentImageRepository MinioStorage *minioStorage.MinioStorage + MediaLib medialib.MediaLibraryService Log zerolog.Logger } @@ -32,11 +34,13 @@ type AboutUsContentImageService interface { func NewAboutUsContentImageService( repo repository.AboutUsContentImageRepository, minio *minioStorage.MinioStorage, + mediaLib medialib.MediaLibraryService, log zerolog.Logger, ) AboutUsContentImageService { return &aboutUsContentImageService{ Repo: repo, MinioStorage: minio, + MediaLib: mediaLib, Log: log, } } @@ -83,7 +87,9 @@ 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 } @@ -105,7 +111,11 @@ func (_i *aboutUsContentImageService) SaveRemoteURL(aboutUsContentID uint, media MediaURL: mediaURL, MediaType: mt, } - return _i.Repo.Create(data) + 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 { 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..924d9ee 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_level_id = ?", 2) + } + } + 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 7ca403d..51d9897 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 (level 2) for approver history + MyContentMode *string `json:"myContentMode"` } type ArticlesCreateRequest struct { @@ -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 b4e77d6..760de44 100644 --- a/app/module/articles/service/articles.service.go +++ b/app/module/articles/service/articles.service.go @@ -120,27 +120,49 @@ func NewArticlesService( } } +const myContentApproverMinLevel = uint(3) + // 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 user.UserLevelId != myContentApproverMinLevel { + return nil, paging, errors.New("myContentMode approver requires user level 3") + } + 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 } 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..c238682 --- /dev/null +++ b/app/module/cms_content_submissions/service/cms_content_submissions.service.go @@ -0,0 +1,597 @@ +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" + userLevelContributor = uint(2) + userLevelApprover = uint(3) +) + +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.UserLevelId != userLevelContributor { + return nil, errors.New("only contributor (user level 2) 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.UserLevelId == userLevelContributor { + 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 user.UserLevelId != userLevelApprover { + return errors.New("only approver (user level 3) 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 user.UserLevelId != userLevelApprover { + return errors.New("only approver (user level 3) 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 + } + 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") + } + return _i.OurService.Delete(uint(*p.ServiceID)) + } + 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/hero_content_images/service/hero_content_images.service.go b/app/module/hero_content_images/service/hero_content_images.service.go index 9ef884e..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 @@ -8,6 +8,7 @@ import ( "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" ) @@ -15,6 +16,7 @@ import ( type heroContentImagesService struct { Repo repository.HeroContentImagesRepository MinioStorage *minioStorage.MinioStorage + MediaLib medialib.MediaLibraryService Log zerolog.Logger } @@ -30,11 +32,13 @@ type HeroContentImagesService interface { func NewHeroContentImagesService( repo repository.HeroContentImagesRepository, minio *minioStorage.MinioStorage, + mediaLib medialib.MediaLibraryService, log zerolog.Logger, ) HeroContentImagesService { return &heroContentImagesService{ Repo: repo, MinioStorage: minio, + MediaLib: mediaLib, Log: log, } } @@ -71,7 +75,14 @@ func (s *heroContentImagesService) SaveWithFile(heroContentID uuid.UUID, file *m ImagePath: key, ImageURL: url, } - return s.Save(data) + 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 { @@ -89,10 +100,16 @@ func (s *heroContentImagesService) UpdateWithFile(id uuid.UUID, file *multipart. if err != nil { return err } - return s.Repo.Update(id, &entity.HeroContentImages{ + 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 { 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..58dc692 --- /dev/null +++ b/app/module/media_library/controller/media_library.controller.go @@ -0,0 +1,104 @@ +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) + } + if err := _i.svc.Upload(clientID, uid, c); err != nil { + return err + } + return utilRes.Resp(c, utilRes.Response{ + Success: true, + Messages: utilRes.Messages{"File uploaded and added to media library"}, + }) +} + +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..e607fe5 --- /dev/null +++ b/app/module/media_library/service/media_library.service.go @@ -0,0 +1,244 @@ +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) 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) 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 + return 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"), + }) +} + +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/service/our_product_content_images.service.go b/app/module/our_product_content_images/service/our_product_content_images.service.go index 666c4c0..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 @@ -7,6 +7,7 @@ import ( "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" @@ -15,6 +16,7 @@ import ( type ourProductContentImagesService struct { Repo repository.OurProductContentImagesRepository MinioStorage *minioStorage.MinioStorage + MediaLib medialib.MediaLibraryService Log zerolog.Logger } @@ -30,11 +32,13 @@ type OurProductContentImagesService interface { func NewOurProductContentImagesService( repo repository.OurProductContentImagesRepository, minio *minioStorage.MinioStorage, + mediaLib medialib.MediaLibraryService, log zerolog.Logger, ) OurProductContentImagesService { return &ourProductContentImagesService{ Repo: repo, MinioStorage: minio, + MediaLib: mediaLib, Log: log, } } @@ -71,7 +75,14 @@ func (s *ourProductContentImagesService) SaveWithFile(ourProductContentID uuid.U ImagePath: key, ImageURL: url, } - return s.Save(data) + 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 { @@ -89,10 +100,16 @@ func (s *ourProductContentImagesService) UpdateWithFile(id uuid.UUID, file *mult if err != nil { return err } - return s.Repo.Update(id, &entity.OurProductContentImage{ + 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 { 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 f5fcc73..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 @@ -6,6 +6,7 @@ import ( "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" @@ -14,6 +15,7 @@ import ( type ourServiceContentImagesService struct { Repo repository.OurServiceContentImagesRepository MinioStorage *minioStorage.MinioStorage + MediaLib medialib.MediaLibraryService Log zerolog.Logger } @@ -29,11 +31,13 @@ type OurServiceContentImagesService interface { func NewOurServiceContentImagesService( repo repository.OurServiceContentImagesRepository, minio *minioStorage.MinioStorage, + mediaLib medialib.MediaLibraryService, log zerolog.Logger, ) OurServiceContentImagesService { return &ourServiceContentImagesService{ Repo: repo, MinioStorage: minio, + MediaLib: mediaLib, Log: log, } } @@ -68,7 +72,14 @@ func (s *ourServiceContentImagesService) SaveWithFile(ourServiceContentID uint, ImagePath: key, ImageURL: url, } - return s.Save(data) + 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 { @@ -86,10 +97,16 @@ func (s *ourServiceContentImagesService) UpdateWithFile(id uint, file *multipart if err != nil { return err } - return s.Repo.Update(id, &entity.OurServiceContentImage{ + 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 { diff --git a/app/module/partner_contents/service/partner_contents.service.go b/app/module/partner_contents/service/partner_contents.service.go index 3e5e0af..8e52468 100644 --- a/app/module/partner_contents/service/partner_contents.service.go +++ b/app/module/partner_contents/service/partner_contents.service.go @@ -7,6 +7,7 @@ import ( "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" @@ -15,6 +16,7 @@ import ( type partnerContentService struct { Repo repository.PartnerContentRepository MinioStorage *minioStorage.MinioStorage + MediaLib medialib.MediaLibraryService Log zerolog.Logger } @@ -29,11 +31,13 @@ type PartnerContentService interface { func NewPartnerContentService( repo repository.PartnerContentRepository, minio *minioStorage.MinioStorage, + mediaLib medialib.MediaLibraryService, log zerolog.Logger, ) PartnerContentService { return &partnerContentService{ Repo: repo, MinioStorage: minio, + MediaLib: mediaLib, Log: log, } } @@ -75,7 +79,13 @@ func (s *partnerContentService) UploadLogo(id uuid.UUID, file *multipart.FileHea if err != nil { return err } - return s.Repo.UpdateImageFields(id, key, url) + 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 { 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 cb5a669..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 @@ -6,6 +6,7 @@ import ( "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" @@ -15,6 +16,7 @@ import ( type popupNewsContentImagesService struct { Repo repository.PopupNewsContentImagesRepository MinioStorage *minioStorage.MinioStorage + MediaLib medialib.MediaLibraryService Log zerolog.Logger } @@ -68,6 +70,9 @@ func (_i *popupNewsContentImagesService) SaveWithFile(popupNewsContentID uint, f 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 } diff --git a/app/router/api.go b/app/router/api.go index dd3e3ca..a1a13e0 100644 --- a/app/router/api.go +++ b/app/router/api.go @@ -20,6 +20,7 @@ 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" @@ -28,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" @@ -73,6 +75,7 @@ type Router struct { 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 @@ -81,6 +84,7 @@ type Router struct { 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 @@ -121,6 +125,7 @@ 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, @@ -129,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, @@ -168,6 +174,7 @@ func NewRouter( CitiesRouter: citiesRouter, ClientApprovalSettingsRouter: clientApprovalSettingsRouter, ClientsRouter: clientsRouter, + CmsContentSubmissionsRouter: cmsContentSubmissionsRouter, CmsMediaRouter: cmsMediaRouter, HeroContentsRouter: heroContentsRouter, HeroContentImagesRouter: heroContentImagesRouter, @@ -176,6 +183,7 @@ func NewRouter( FeedbacksRouter: feedbacksRouter, MagazineFilesRouter: magazineFilesRouter, MagazinesRouter: magazinesRouter, + MediaLibraryRouter: mediaLibraryRouter, MasterMenusRouter: masterMenuRouter, MasterModulesRouter: masterModuleRouter, OurProductContentsRouter: ourProductContentsRouter, @@ -225,6 +233,7 @@ 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() @@ -233,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/toml/config.toml b/config/toml/config.toml index a18d711..7d6e3a2 100644 --- a/config/toml/config.toml +++ b/config/toml/config.toml @@ -9,7 +9,7 @@ idle-timeout = 5 # As seconds print-routes = false prefork = false # false: CMS preview URLs use http://localhost + port above. true: use domain (e.g. https://qudo.id/api). -production = true +production = false body-limit = 1048576000 # "100 * 1024 * 1024" [db.postgres] diff --git a/main.go b/main.go index d97db86..a6279a5 100644 --- a/main.go +++ b/main.go @@ -23,6 +23,7 @@ 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" @@ -31,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" @@ -105,6 +107,7 @@ func main() { cities.NewCitiesModule, client_approval_settings.NewClientApprovalSettingsModule, clients.NewClientsModule, + cms_content_submissions.NewCmsContentSubmissionsModule, cms_media.NewCmsMediaModule, custom_static_pages.NewCustomStaticPagesModule, districts.NewDistrictsModule, @@ -113,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 index df877e2..055e243 100644 --- a/utils/storage/cms_upload.go +++ b/utils/storage/cms_upload.go @@ -31,13 +31,25 @@ var mediaExts = map[string]bool{ ".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 allowVideo { + 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)") } @@ -74,3 +86,15 @@ func UploadCMSObject(ms *appcfg.MinioStorage, folder string, file *multipart.Fil 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 5cf48d9..1c21a3d 100644 Binary files a/web-qudo-be.exe and b/web-qudo-be.exe differ