feat(help-center): align category payloads
This commit is contained in:
@@ -765,7 +765,7 @@ func Bootstrap(env string) (*App, error) {
|
||||
LiveReport: v1.NewLiveReportHandler(analyticsService),
|
||||
DashboardApp: v1.NewDashboardAppHandler(dashboardAppService),
|
||||
Portal: v1.NewPortalHandler(portalService),
|
||||
Category: v1.NewCategoryHandler(categoryService),
|
||||
Category: v1.NewCategoryHandler(categoryService, portalService),
|
||||
Article: v1.NewArticleHandler(articleService),
|
||||
Folder: v1.NewFolderHandler(folderService),
|
||||
PortalMember: v1.NewPortalMemberHandler(portalMemberService),
|
||||
|
||||
@@ -5,163 +5,290 @@ import (
|
||||
"strconv"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/gochat/gochat/internal/model"
|
||||
"github.com/gochat/gochat/internal/service"
|
||||
applogger "github.com/gochat/gochat/pkg/logger"
|
||||
"github.com/gochat/gochat/pkg/pagination"
|
||||
"github.com/gochat/gochat/pkg/response"
|
||||
)
|
||||
|
||||
// CategoryHandler handles Category CRUD endpoints.
|
||||
type CategoryHandler struct {
|
||||
svc *service.CategoryService
|
||||
svc *service.CategoryService
|
||||
portalSvc *service.PortalService
|
||||
}
|
||||
|
||||
func NewCategoryHandler(svc *service.CategoryService) *CategoryHandler {
|
||||
return &CategoryHandler{svc: svc}
|
||||
func NewCategoryHandler(svc *service.CategoryService, portalSvc ...*service.PortalService) *CategoryHandler {
|
||||
h := &CategoryHandler{svc: svc}
|
||||
if len(portalSvc) > 0 {
|
||||
h.portalSvc = portalSvc[0]
|
||||
}
|
||||
return h
|
||||
}
|
||||
|
||||
// Create creates a new category.
|
||||
// POST /api/v1/accounts/:account_id/portals/:portal_id/categories
|
||||
func (h *CategoryHandler) Create(c *gin.Context) {
|
||||
portalID, err := strconv.ParseUint(c.Param("portal_id"), 10, 64)
|
||||
if err != nil {
|
||||
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid portal_id")
|
||||
return
|
||||
}
|
||||
|
||||
accountID, err := strconv.ParseUint(c.Param("account_id"), 10, 64)
|
||||
if err != nil {
|
||||
accountID := getAccountID(c)
|
||||
if accountID == 0 {
|
||||
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid account_id")
|
||||
return
|
||||
}
|
||||
|
||||
// Chatwoot: params.require(:category) → {"category": {...}}
|
||||
var wrapper struct {
|
||||
Category service.CreateCategoryRequest `json:"category"`
|
||||
portal, ok := h.resolvePortal(c, accountID)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if err := c.ShouldBindJSON(&wrapper); err != nil {
|
||||
|
||||
var req service.CreateCategoryRequest
|
||||
if err := bindChatwootPayload(c, "category", &req); err != nil {
|
||||
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrValidation, err.Error())
|
||||
return
|
||||
}
|
||||
req := wrapper.Category
|
||||
|
||||
category, err := h.svc.Create(c.Request.Context(), uint(portalID), uint(accountID), &req)
|
||||
category, err := h.svc.Create(c.Request.Context(), portal.ID, accountID, &req)
|
||||
if err != nil {
|
||||
applogger.L().Errorf("Create category: %v", err)
|
||||
response.AbortWithStatusError(c, http.StatusInternalServerError, response.ErrInternal, "failed to create category")
|
||||
return
|
||||
}
|
||||
|
||||
response.Created(c, category)
|
||||
c.JSON(http.StatusOK, gin.H{"payload": categoryPayload(category, c.Query("locale"))})
|
||||
}
|
||||
|
||||
// Get retrieves a category by ID.
|
||||
// GET /api/v1/accounts/:account_id/portals/:portal_id/categories/:category_id
|
||||
func (h *CategoryHandler) Get(c *gin.Context) {
|
||||
accountID := getAccountID(c)
|
||||
if accountID == 0 {
|
||||
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid account_id")
|
||||
return
|
||||
}
|
||||
portal, ok := h.resolvePortal(c, accountID)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
categoryID, err := strconv.ParseUint(c.Param("category_id"), 10, 64)
|
||||
if err != nil {
|
||||
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid category_id")
|
||||
return
|
||||
}
|
||||
|
||||
category, err := h.svc.GetByID(c.Request.Context(), uint(categoryID))
|
||||
category, err := h.svc.GetByPortalAndID(c.Request.Context(), portal.ID, uint(categoryID))
|
||||
if err != nil {
|
||||
applogger.L().Errorf("Get category: %v", err)
|
||||
response.AbortWithStatusError(c, http.StatusNotFound, response.ErrNotFound, "category not found")
|
||||
return
|
||||
}
|
||||
|
||||
response.OK(c, category)
|
||||
c.JSON(http.StatusOK, gin.H{"payload": categoryPayload(category, c.Query("locale"))})
|
||||
}
|
||||
|
||||
// Update modifies an existing category.
|
||||
// PUT /api/v1/accounts/:account_id/portals/:portal_id/categories/:category_id
|
||||
func (h *CategoryHandler) Update(c *gin.Context) {
|
||||
accountID := getAccountID(c)
|
||||
if accountID == 0 {
|
||||
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid account_id")
|
||||
return
|
||||
}
|
||||
portal, ok := h.resolvePortal(c, accountID)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
categoryID, err := strconv.ParseUint(c.Param("category_id"), 10, 64)
|
||||
if err != nil {
|
||||
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid category_id")
|
||||
return
|
||||
}
|
||||
|
||||
// Chatwoot: params.require(:category) → {"category": {...}}
|
||||
var wrapper struct {
|
||||
Category service.UpdateCategoryRequest `json:"category"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&wrapper); err != nil {
|
||||
var req service.UpdateCategoryRequest
|
||||
if err := bindChatwootPayload(c, "category", &req); err != nil {
|
||||
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrValidation, err.Error())
|
||||
return
|
||||
}
|
||||
req := wrapper.Category
|
||||
|
||||
category, err := h.svc.Update(c.Request.Context(), uint(categoryID), &req)
|
||||
category, err := h.svc.UpdateScoped(c.Request.Context(), portal.ID, uint(categoryID), &req)
|
||||
if err != nil {
|
||||
applogger.L().Errorf("Update category: %v", err)
|
||||
response.AbortWithStatusError(c, http.StatusUnprocessableEntity, response.ErrInternal, "failed to update category")
|
||||
return
|
||||
}
|
||||
|
||||
response.OK(c, category)
|
||||
c.JSON(http.StatusOK, gin.H{"payload": categoryPayload(category, c.Query("locale"))})
|
||||
}
|
||||
|
||||
// Delete removes a category.
|
||||
// DELETE /api/v1/accounts/:account_id/portals/:portal_id/categories/:category_id
|
||||
func (h *CategoryHandler) Delete(c *gin.Context) {
|
||||
accountID := getAccountID(c)
|
||||
if accountID == 0 {
|
||||
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid account_id")
|
||||
return
|
||||
}
|
||||
portal, ok := h.resolvePortal(c, accountID)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
categoryID, err := strconv.ParseUint(c.Param("category_id"), 10, 64)
|
||||
if err != nil {
|
||||
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid category_id")
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.svc.Delete(c.Request.Context(), uint(categoryID)); err != nil {
|
||||
if err := h.svc.DeleteScoped(c.Request.Context(), portal.ID, uint(categoryID)); err != nil {
|
||||
applogger.L().Errorf("Delete category: %v", err)
|
||||
response.AbortWithStatusError(c, http.StatusUnprocessableEntity, response.ErrInternal, "failed to delete category")
|
||||
return
|
||||
}
|
||||
|
||||
response.NoContent(c)
|
||||
c.Status(http.StatusOK)
|
||||
}
|
||||
|
||||
// List returns all categories for a portal.
|
||||
// GET /api/v1/accounts/:account_id/portals/:portal_id/categories
|
||||
func (h *CategoryHandler) List(c *gin.Context) {
|
||||
portalID, err := strconv.ParseUint(c.Param("portal_id"), 10, 64)
|
||||
if err != nil {
|
||||
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid portal_id")
|
||||
accountID := getAccountID(c)
|
||||
if accountID == 0 {
|
||||
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid account_id")
|
||||
return
|
||||
}
|
||||
portal, ok := h.resolvePortal(c, accountID)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
locale := c.Query("locale")
|
||||
|
||||
pg := pagination.Parse(c)
|
||||
categories, count, err := h.svc.ListByPortalID(c.Request.Context(), uint(portalID), locale, pg.Page, pg.PerPage)
|
||||
categories, _, err := h.svc.ListByPortalID(c.Request.Context(), portal.ID, locale, 0, 0)
|
||||
if err != nil {
|
||||
applogger.L().Errorf("List categories: %v", err)
|
||||
response.AbortWithStatusError(c, http.StatusInternalServerError, response.ErrInternal, "failed to list categories")
|
||||
return
|
||||
}
|
||||
|
||||
response.OKWithMeta(c, categories, pg.Page, pg.PerPage, count)
|
||||
payload := make([]gin.H, 0, len(categories))
|
||||
for i := range categories {
|
||||
payload = append(payload, categoryPayload(&categories[i], locale))
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"payload": payload, "meta": gin.H{"current_page": c.DefaultQuery("page", "1"), "categories_count": len(categories)}})
|
||||
}
|
||||
|
||||
// Reorder updates the display order of categories.
|
||||
// POST /api/v1/accounts/:account_id/portals/:portal_id/categories/reorder
|
||||
func (h *CategoryHandler) Reorder(c *gin.Context) {
|
||||
var req reorderRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
accountID := getAccountID(c)
|
||||
if accountID == 0 {
|
||||
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid account_id")
|
||||
return
|
||||
}
|
||||
portal, ok := h.resolvePortal(c, accountID)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
positions, err := bindCategoryReorder(c)
|
||||
if err != nil {
|
||||
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrValidation, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
positions := make(map[uint]int, len(req.Positions))
|
||||
for _, entry := range req.Positions {
|
||||
positions[entry.ID] = entry.Position
|
||||
}
|
||||
|
||||
if err := h.svc.Reorder(c.Request.Context(), positions); err != nil {
|
||||
if err := h.svc.ReorderScoped(c.Request.Context(), portal.ID, positions); err != nil {
|
||||
applogger.L().Errorf("Reorder categories: %v", err)
|
||||
response.AbortWithStatusError(c, http.StatusInternalServerError, response.ErrInternal, "failed to reorder categories")
|
||||
return
|
||||
}
|
||||
|
||||
response.NoContent(c)
|
||||
c.Status(http.StatusOK)
|
||||
}
|
||||
|
||||
func (h *CategoryHandler) resolvePortal(c *gin.Context, accountID uint) (*model.Portal, bool) {
|
||||
if h.portalSvc != nil {
|
||||
portal, err := h.portalSvc.ResolveByAccountAndRouteID(c.Request.Context(), accountID, c.Param("portal_id"))
|
||||
if err != nil {
|
||||
response.AbortWithStatusError(c, http.StatusNotFound, response.ErrNotFound, "portal not found")
|
||||
return nil, false
|
||||
}
|
||||
return portal, true
|
||||
}
|
||||
portalID, err := strconv.ParseUint(c.Param("portal_id"), 10, 64)
|
||||
if err != nil {
|
||||
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid portal_id")
|
||||
return nil, false
|
||||
}
|
||||
return &model.Portal{Base: model.Base{ID: uint(portalID)}, AccountID: accountID}, true
|
||||
}
|
||||
|
||||
func categoryPayload(category *model.Category, currentLocale string) gin.H {
|
||||
if category == nil {
|
||||
return gin.H{}
|
||||
}
|
||||
payload := gin.H{
|
||||
"id": category.ID,
|
||||
"name": category.Name,
|
||||
"slug": category.Slug,
|
||||
"locale": category.Locale,
|
||||
"description": category.Description,
|
||||
"position": category.Position,
|
||||
"account_id": category.AccountID,
|
||||
"icon": category.Icon,
|
||||
"related_categories": relatedCategoryPayloads(category.RelatedCategories),
|
||||
"meta": gin.H{"articles_count": categoryArticleCount(category.Articles, currentLocale)},
|
||||
}
|
||||
if category.Parent != nil && category.Parent.ID != 0 {
|
||||
payload["parent_category"] = associatedCategoryPayload(category.Parent)
|
||||
payload["root_category"] = associatedCategoryPayload(category.Parent)
|
||||
}
|
||||
return payload
|
||||
}
|
||||
|
||||
func relatedCategoryPayloads(related []model.RelatedCategory) []gin.H {
|
||||
payload := make([]gin.H, 0, len(related))
|
||||
for _, item := range related {
|
||||
if item.RelatedCategory.ID != 0 {
|
||||
payload = append(payload, associatedCategoryPayload(&item.RelatedCategory))
|
||||
}
|
||||
}
|
||||
return payload
|
||||
}
|
||||
|
||||
func associatedCategoryPayload(category *model.Category) gin.H {
|
||||
return gin.H{
|
||||
"id": category.ID,
|
||||
"name": category.Name,
|
||||
"slug": category.Slug,
|
||||
"locale": category.Locale,
|
||||
"description": category.Description,
|
||||
"position": category.Position,
|
||||
"account_id": category.AccountID,
|
||||
}
|
||||
}
|
||||
|
||||
func categoryArticleCount(articles []model.Article, locale string) int {
|
||||
count := 0
|
||||
for _, article := range articles {
|
||||
if locale == "" || article.Locale == locale {
|
||||
count++
|
||||
}
|
||||
}
|
||||
return count
|
||||
}
|
||||
|
||||
func bindCategoryReorder(c *gin.Context) (map[uint]int, error) {
|
||||
var req struct {
|
||||
PositionsHash map[string]int `json:"positions_hash"`
|
||||
Positions []positionEntry `json:"positions"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
positions := make(map[uint]int, len(req.PositionsHash)+len(req.Positions))
|
||||
for id, position := range req.PositionsHash {
|
||||
parsed, err := strconv.ParseUint(id, 10, 32)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
positions[uint(parsed)] = position
|
||||
}
|
||||
for _, entry := range req.Positions {
|
||||
positions[entry.ID] = entry.Position
|
||||
}
|
||||
return positions, nil
|
||||
}
|
||||
|
||||
@@ -48,8 +48,10 @@ func (s *CategoryHandlerTestSuite) SetupSuite() {
|
||||
&model.AccountUser{},
|
||||
&model.Portal{},
|
||||
&model.Category{},
|
||||
&model.Article{},
|
||||
&model.RelatedCategory{},
|
||||
&model.Folder{},
|
||||
&model.PortalMember{},
|
||||
))
|
||||
s.db = db
|
||||
|
||||
@@ -66,8 +68,9 @@ func (s *CategoryHandlerTestSuite) SetupSuite() {
|
||||
// Build real service chain
|
||||
catRepo := repository.NewCategoryRepo(db)
|
||||
relatedRepo := repository.NewRelatedCategoryRepo(db)
|
||||
portalRepo := repository.NewPortalRepo(db)
|
||||
s.svc = service.NewCategoryService(catRepo, relatedRepo)
|
||||
s.handler = NewCategoryHandler(s.svc)
|
||||
s.handler = NewCategoryHandler(s.svc, service.NewPortalService(portalRepo))
|
||||
|
||||
s.router = gin.New()
|
||||
s.router.Use(func(c *gin.Context) {
|
||||
@@ -81,6 +84,7 @@ func (s *CategoryHandlerTestSuite) SetupSuite() {
|
||||
{
|
||||
portalGroup.POST("", s.handler.Create)
|
||||
portalGroup.GET("/:category_id", s.handler.Get)
|
||||
portalGroup.PATCH("/:category_id", s.handler.Update)
|
||||
portalGroup.PUT("/:category_id", s.handler.Update)
|
||||
portalGroup.DELETE("/:category_id", s.handler.Delete)
|
||||
portalGroup.GET("", s.handler.List)
|
||||
@@ -103,11 +107,12 @@ func (s *CategoryHandlerTestSuite) TestCreate_Success() {
|
||||
bytes.NewBufferString(body))
|
||||
s.router.ServeHTTP(w, req)
|
||||
|
||||
s.Equal(http.StatusCreated, w.Code)
|
||||
s.Equal(http.StatusOK, w.Code)
|
||||
var resp map[string]interface{}
|
||||
s.NoError(json.Unmarshal(w.Body.Bytes(), &resp))
|
||||
data := resp["data"].(map[string]interface{})
|
||||
s.Equal("Test Category", data["name"])
|
||||
payload := resp["payload"].(map[string]interface{})
|
||||
s.Equal("Test Category", payload["name"])
|
||||
s.NotContains(resp, "data")
|
||||
}
|
||||
|
||||
func (s *CategoryHandlerTestSuite) TestCreate_InvalidAccountID() {
|
||||
@@ -127,7 +132,7 @@ func (s *CategoryHandlerTestSuite) TestCreate_InvalidPortalID() {
|
||||
"/api/v1/accounts/"+fmt.Sprintf("%d", s.accountID)+"/portals/abc/categories",
|
||||
bytes.NewBufferString(body))
|
||||
s.router.ServeHTTP(w, req)
|
||||
s.Equal(http.StatusBadRequest, w.Code)
|
||||
s.Equal(http.StatusNotFound, w.Code)
|
||||
}
|
||||
|
||||
func (s *CategoryHandlerTestSuite) TestCreate_InvalidJSON() {
|
||||
@@ -147,14 +152,30 @@ func (s *CategoryHandlerTestSuite) TestCreate_EmptyName() {
|
||||
"/api/v1/accounts/"+fmt.Sprintf("%d", s.accountID)+"/portals/"+fmt.Sprintf("%d", s.portalID)+"/categories",
|
||||
bytes.NewBufferString(body))
|
||||
s.router.ServeHTTP(w, req)
|
||||
// ShouldBindJSON binds successfully even with empty name → service creates → 201
|
||||
s.Equal(http.StatusCreated, w.Code)
|
||||
// Chatwoot validation is handled by the model; SQLite unit path accepts the empty name.
|
||||
s.Equal(http.StatusOK, w.Code)
|
||||
}
|
||||
|
||||
func (s *CategoryHandlerTestSuite) TestCreate_RawFrontendPayloadAndSlugPortal() {
|
||||
body := `{"name":"Raw Category","slug":"raw-cat","locale":"en","description":"raw"}`
|
||||
w := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodPost,
|
||||
"/api/v1/accounts/"+fmt.Sprintf("%d", s.accountID)+"/portals/test-portal/categories",
|
||||
bytes.NewBufferString(body))
|
||||
s.router.ServeHTTP(w, req)
|
||||
s.Equal(http.StatusOK, w.Code)
|
||||
var resp map[string]interface{}
|
||||
s.NoError(json.Unmarshal(w.Body.Bytes(), &resp))
|
||||
payload := resp["payload"].(map[string]interface{})
|
||||
s.Equal("Raw Category", payload["name"])
|
||||
s.Equal("raw-cat", payload["slug"])
|
||||
}
|
||||
|
||||
// --- Get Tests ---
|
||||
func (s *CategoryHandlerTestSuite) TestGet_Success() {
|
||||
cat := &model.Category{PortalID: s.portalID, Name: "GetCat", Slug: "get-cat"}
|
||||
cat := &model.Category{AccountID: s.accountID, PortalID: s.portalID, Name: "GetCat", Slug: "get-cat", Locale: "en"}
|
||||
s.Require().NoError(s.db.Create(cat).Error)
|
||||
s.Require().NoError(s.db.Create(&model.Article{AccountID: s.accountID, PortalID: s.portalID, CategoryID: &cat.ID, Title: "Article", Slug: "article", Status: "published", Locale: "en"}).Error)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet,
|
||||
@@ -162,6 +183,12 @@ func (s *CategoryHandlerTestSuite) TestGet_Success() {
|
||||
nil)
|
||||
s.router.ServeHTTP(w, req)
|
||||
s.Equal(http.StatusOK, w.Code)
|
||||
var resp map[string]interface{}
|
||||
s.NoError(json.Unmarshal(w.Body.Bytes(), &resp))
|
||||
payload := resp["payload"].(map[string]interface{})
|
||||
s.Equal("GetCat", payload["name"])
|
||||
meta := payload["meta"].(map[string]interface{})
|
||||
s.EqualValues(1, meta["articles_count"])
|
||||
}
|
||||
|
||||
func (s *CategoryHandlerTestSuite) TestGet_InvalidCategoryID() {
|
||||
@@ -187,13 +214,17 @@ func (s *CategoryHandlerTestSuite) TestUpdate_Success() {
|
||||
cat := &model.Category{PortalID: s.portalID, Name: "OldName", Slug: "old-slug"}
|
||||
s.Require().NoError(s.db.Create(cat).Error)
|
||||
|
||||
body := `{"category":{"name":"NewName","slug":"new-slug"}}`
|
||||
body := `{"name":"NewName","slug":"new-slug","description":""}`
|
||||
w := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodPut,
|
||||
"/api/v1/accounts/"+fmt.Sprintf("%d", s.accountID)+"/portals/"+fmt.Sprintf("%d", s.portalID)+"/categories/"+fmt.Sprintf("%d", cat.ID),
|
||||
req := httptest.NewRequest(http.MethodPatch,
|
||||
"/api/v1/accounts/"+fmt.Sprintf("%d", s.accountID)+"/portals/test-portal/categories/"+fmt.Sprintf("%d", cat.ID),
|
||||
bytes.NewBufferString(body))
|
||||
s.router.ServeHTTP(w, req)
|
||||
s.Equal(http.StatusOK, w.Code)
|
||||
var resp map[string]interface{}
|
||||
s.NoError(json.Unmarshal(w.Body.Bytes(), &resp))
|
||||
payload := resp["payload"].(map[string]interface{})
|
||||
s.Equal("new-slug", payload["slug"])
|
||||
}
|
||||
|
||||
func (s *CategoryHandlerTestSuite) TestUpdate_InvalidCategoryID() {
|
||||
@@ -227,7 +258,7 @@ func (s *CategoryHandlerTestSuite) TestDelete_Success() {
|
||||
"/api/v1/accounts/"+fmt.Sprintf("%d", s.accountID)+"/portals/"+fmt.Sprintf("%d", s.portalID)+"/categories/"+fmt.Sprintf("%d", cat.ID),
|
||||
nil)
|
||||
s.router.ServeHTTP(w, req)
|
||||
s.Equal(http.StatusNoContent, w.Code)
|
||||
s.Equal(http.StatusOK, w.Code)
|
||||
}
|
||||
|
||||
func (s *CategoryHandlerTestSuite) TestDelete_InvalidCategoryID() {
|
||||
@@ -253,17 +284,24 @@ func (s *CategoryHandlerTestSuite) TestDelete_NotFound() {
|
||||
|
||||
// --- List Tests ---
|
||||
func (s *CategoryHandlerTestSuite) TestList_Success() {
|
||||
cat1 := &model.Category{PortalID: s.portalID, Name: "Cat1", Slug: "cat1"}
|
||||
cat2 := &model.Category{PortalID: s.portalID, Name: "Cat2", Slug: "cat2"}
|
||||
cat1 := &model.Category{AccountID: s.accountID, PortalID: s.portalID, Name: "Cat1", Slug: "cat1", Locale: "en", Position: 2}
|
||||
cat2 := &model.Category{AccountID: s.accountID, PortalID: s.portalID, Name: "Cat2", Slug: "cat2", Locale: "fr", Position: 1}
|
||||
s.Require().NoError(s.db.Create(cat1).Error)
|
||||
s.Require().NoError(s.db.Create(cat2).Error)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet,
|
||||
"/api/v1/accounts/"+fmt.Sprintf("%d", s.accountID)+"/portals/"+fmt.Sprintf("%d", s.portalID)+"/categories",
|
||||
"/api/v1/accounts/"+fmt.Sprintf("%d", s.accountID)+"/portals/test-portal/categories?locale=en&page=2",
|
||||
nil)
|
||||
s.router.ServeHTTP(w, req)
|
||||
s.Equal(http.StatusOK, w.Code)
|
||||
var resp map[string]interface{}
|
||||
s.NoError(json.Unmarshal(w.Body.Bytes(), &resp))
|
||||
s.Len(resp["payload"], 1)
|
||||
meta := resp["meta"].(map[string]interface{})
|
||||
s.Equal("2", meta["current_page"])
|
||||
s.EqualValues(1, meta["categories_count"])
|
||||
s.NotContains(resp, "data")
|
||||
}
|
||||
|
||||
func (s *CategoryHandlerTestSuite) TestList_InvalidPortalID() {
|
||||
@@ -272,7 +310,7 @@ func (s *CategoryHandlerTestSuite) TestList_InvalidPortalID() {
|
||||
"/api/v1/accounts/"+fmt.Sprintf("%d", s.accountID)+"/portals/abc/categories",
|
||||
nil)
|
||||
s.router.ServeHTTP(w, req)
|
||||
s.Equal(http.StatusBadRequest, w.Code)
|
||||
s.Equal(http.StatusNotFound, w.Code)
|
||||
}
|
||||
|
||||
func (s *CategoryHandlerTestSuite) TestList_Empty() {
|
||||
@@ -291,14 +329,13 @@ func (s *CategoryHandlerTestSuite) TestReorder_Success() {
|
||||
s.Require().NoError(s.db.Create(cat1).Error)
|
||||
s.Require().NoError(s.db.Create(cat2).Error)
|
||||
|
||||
body := fmt.Sprintf(`{"positions":[{"id":%d,"position":2},{"id":%d,"position":1}]}`, cat1.ID, cat2.ID)
|
||||
body := fmt.Sprintf(`{"positions_hash":{"%d":2,"%d":1}}`, cat1.ID, cat2.ID)
|
||||
w := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodPost,
|
||||
"/api/v1/accounts/"+fmt.Sprintf("%d", s.accountID)+"/portals/"+fmt.Sprintf("%d", s.portalID)+"/categories/reorder",
|
||||
bytes.NewBufferString(body))
|
||||
s.router.ServeHTTP(w, req)
|
||||
// Reorder success → NoContent (204)
|
||||
s.Equal(http.StatusNoContent, w.Code)
|
||||
s.Equal(http.StatusOK, w.Code)
|
||||
}
|
||||
|
||||
func (s *CategoryHandlerTestSuite) TestReorder_InvalidJSON() {
|
||||
@@ -308,4 +345,4 @@ func (s *CategoryHandlerTestSuite) TestReorder_InvalidJSON() {
|
||||
bytes.NewBufferString("{invalid}"))
|
||||
s.router.ServeHTTP(w, req)
|
||||
s.Equal(http.StatusBadRequest, w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,7 +22,18 @@ func (r *CategoryRepo) Create(ctx context.Context, category *model.Category) err
|
||||
|
||||
func (r *CategoryRepo) GetByID(ctx context.Context, id uint) (*model.Category, error) {
|
||||
var category model.Category
|
||||
if err := r.db.WithContext(ctx).Preload("Parent").Preload("RelatedCategories").Preload("Folders").First(&category, id).Error; err != nil {
|
||||
if err := r.db.WithContext(ctx).Preload("Parent").Preload("RelatedCategories.RelatedCategory").Preload("Folders").Preload("Articles").First(&category, id).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &category, nil
|
||||
}
|
||||
|
||||
func (r *CategoryRepo) GetByPortalAndID(ctx context.Context, portalID, id uint) (*model.Category, error) {
|
||||
var category model.Category
|
||||
if err := r.db.WithContext(ctx).
|
||||
Preload("Parent").Preload("RelatedCategories.RelatedCategory").Preload("Folders").Preload("Articles").
|
||||
Where("portal_id = ?", portalID).
|
||||
First(&category, id).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &category, nil
|
||||
@@ -42,7 +53,11 @@ func (r *CategoryRepo) FindByPortalID(ctx context.Context, portalID uint, offset
|
||||
var count int64
|
||||
db := r.db.WithContext(ctx).Model(&model.Category{}).Where("portal_id = ?", portalID)
|
||||
db.Count(&count)
|
||||
if err := db.Offset(offset).Limit(limit).Order("position ASC").Find(&categories).Error; err != nil {
|
||||
query := db.Preload("Parent").Preload("RelatedCategories.RelatedCategory").Preload("Articles").Order("position ASC")
|
||||
if limit > 0 {
|
||||
query = query.Offset(offset).Limit(limit)
|
||||
}
|
||||
if err := query.Find(&categories).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
return categories, count, nil
|
||||
@@ -54,7 +69,11 @@ func (r *CategoryRepo) FindByPortalIDAndLocale(ctx context.Context, portalID uin
|
||||
var count int64
|
||||
db := r.db.WithContext(ctx).Model(&model.Category{}).Where("portal_id = ? AND locale = ?", portalID, locale)
|
||||
db.Count(&count)
|
||||
if err := db.Offset(offset).Limit(limit).Order("position ASC").Find(&categories).Error; err != nil {
|
||||
query := db.Preload("Parent").Preload("RelatedCategories.RelatedCategory").Preload("Articles").Order("position ASC")
|
||||
if limit > 0 {
|
||||
query = query.Offset(offset).Limit(limit)
|
||||
}
|
||||
if err := query.Find(&categories).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
return categories, count, nil
|
||||
@@ -83,6 +102,15 @@ func (r *CategoryRepo) UpdatePositions(ctx context.Context, positions map[uint]i
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *CategoryRepo) UpdatePositionsForPortal(ctx context.Context, portalID uint, positions map[uint]int) error {
|
||||
for id, pos := range positions {
|
||||
if err := r.db.WithContext(ctx).Model(&model.Category{}).Where("id = ? AND portal_id = ?", id, portalID).Update("position", pos).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// FindBySlugAndPortalIDAndLocale returns a category by slug, portal, and locale.
|
||||
func (r *CategoryRepo) FindBySlugAndPortalIDAndLocale(ctx context.Context, slug string, portalID uint, locale string) (*model.Category, error) {
|
||||
var category model.Category
|
||||
@@ -90,4 +118,4 @@ func (r *CategoryRepo) FindBySlugAndPortalIDAndLocale(ctx context.Context, slug
|
||||
return nil, err
|
||||
}
|
||||
return &category, nil
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,7 +23,7 @@ func newTestCategory(portalID uint, name string) *model.Category {
|
||||
// ========== Create ==========
|
||||
|
||||
func TestCategoryRepo_Create(t *testing.T) {
|
||||
db := setupTestDB(t, &model.Category{}, &model.Folder{}, &model.RelatedCategory{}, &model.Portal{}, &model.Account{})
|
||||
db := setupTestDB(t, &model.Category{}, &model.Folder{}, &model.RelatedCategory{}, &model.Portal{}, &model.Account{}, &model.Article{})
|
||||
repo := NewCategoryRepo(db)
|
||||
|
||||
cat := newTestCategory(1, "TestCategory")
|
||||
@@ -36,7 +36,7 @@ func TestCategoryRepo_Create(t *testing.T) {
|
||||
// ========== GetByID ==========
|
||||
|
||||
func TestCategoryRepo_GetByID(t *testing.T) {
|
||||
db := setupTestDB(t, &model.Category{}, &model.Folder{}, &model.RelatedCategory{}, &model.Portal{}, &model.Account{})
|
||||
db := setupTestDB(t, &model.Category{}, &model.Folder{}, &model.RelatedCategory{}, &model.Portal{}, &model.Account{}, &model.Article{})
|
||||
repo := NewCategoryRepo(db)
|
||||
|
||||
cat := newTestCategory(1, "FindByIDCat")
|
||||
@@ -49,7 +49,7 @@ func TestCategoryRepo_GetByID(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestCategoryRepo_GetByID_NotFound(t *testing.T) {
|
||||
db := setupTestDB(t, &model.Category{}, &model.Folder{}, &model.RelatedCategory{}, &model.Portal{}, &model.Account{})
|
||||
db := setupTestDB(t, &model.Category{}, &model.Folder{}, &model.RelatedCategory{}, &model.Portal{}, &model.Account{}, &model.Article{})
|
||||
repo := NewCategoryRepo(db)
|
||||
|
||||
found, err := repo.GetByID(context.Background(), 9999)
|
||||
@@ -60,7 +60,7 @@ func TestCategoryRepo_GetByID_NotFound(t *testing.T) {
|
||||
// ========== Update ==========
|
||||
|
||||
func TestCategoryRepo_Update(t *testing.T) {
|
||||
db := setupTestDB(t, &model.Category{}, &model.Folder{}, &model.RelatedCategory{}, &model.Portal{}, &model.Account{})
|
||||
db := setupTestDB(t, &model.Category{}, &model.Folder{}, &model.RelatedCategory{}, &model.Portal{}, &model.Account{}, &model.Article{})
|
||||
repo := NewCategoryRepo(db)
|
||||
|
||||
cat := newTestCategory(1, "BeforeUpdate")
|
||||
@@ -81,7 +81,7 @@ func TestCategoryRepo_Update(t *testing.T) {
|
||||
// ========== Delete ==========
|
||||
|
||||
func TestCategoryRepo_Delete(t *testing.T) {
|
||||
db := setupTestDB(t, &model.Category{}, &model.Folder{}, &model.RelatedCategory{}, &model.Portal{}, &model.Account{})
|
||||
db := setupTestDB(t, &model.Category{}, &model.Folder{}, &model.RelatedCategory{}, &model.Portal{}, &model.Account{}, &model.Article{})
|
||||
repo := NewCategoryRepo(db)
|
||||
|
||||
cat := newTestCategory(1, "DeleteCat")
|
||||
@@ -99,7 +99,7 @@ func TestCategoryRepo_Delete(t *testing.T) {
|
||||
// ========== FindByPortalID ==========
|
||||
|
||||
func TestCategoryRepo_FindByPortalID(t *testing.T) {
|
||||
db := setupTestDB(t, &model.Category{}, &model.Folder{}, &model.RelatedCategory{}, &model.Portal{}, &model.Account{})
|
||||
db := setupTestDB(t, &model.Category{}, &model.Folder{}, &model.RelatedCategory{}, &model.Portal{}, &model.Account{}, &model.Article{})
|
||||
repo := NewCategoryRepo(db)
|
||||
|
||||
portalID := uint(10)
|
||||
@@ -119,7 +119,7 @@ func TestCategoryRepo_FindByPortalID(t *testing.T) {
|
||||
// ========== FindByPortalIDAndParentID ==========
|
||||
|
||||
func TestCategoryRepo_FindByPortalIDAndParentID(t *testing.T) {
|
||||
db := setupTestDB(t, &model.Category{}, &model.Folder{}, &model.RelatedCategory{}, &model.Portal{}, &model.Account{})
|
||||
db := setupTestDB(t, &model.Category{}, &model.Folder{}, &model.RelatedCategory{}, &model.Portal{}, &model.Account{}, &model.Article{})
|
||||
repo := NewCategoryRepo(db)
|
||||
|
||||
portalID := uint(10)
|
||||
@@ -145,4 +145,4 @@ func TestCategoryRepo_FindByPortalIDAndParentID(t *testing.T) {
|
||||
for _, c := range categories {
|
||||
assert.Equal(t, parentID, *c.ParentID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1400,9 +1400,12 @@ func registerV1Routes(g *gin.RouterGroup, h *Handlers) {
|
||||
// Categories nested under portal
|
||||
categories := portals.Group("/:portal_id/categories")
|
||||
{
|
||||
categories.GET("", h.Category.List)
|
||||
categories.GET("/", h.Category.List)
|
||||
categories.POST("", h.Category.Create)
|
||||
categories.POST("/", h.Category.Create)
|
||||
categories.GET("/:category_id", h.Category.Get)
|
||||
categories.PATCH("/:category_id", h.Category.Update)
|
||||
categories.PUT("/:category_id", h.Category.Update)
|
||||
categories.DELETE("/:category_id", h.Category.Delete)
|
||||
categories.POST("/reorder", h.Category.Reorder)
|
||||
|
||||
@@ -12,8 +12,8 @@ import (
|
||||
|
||||
// CategoryService implements business logic for Category CRUD.
|
||||
type CategoryService struct {
|
||||
repo *repository.CategoryRepo
|
||||
relatedRepo *repository.RelatedCategoryRepo
|
||||
repo *repository.CategoryRepo
|
||||
relatedRepo *repository.RelatedCategoryRepo
|
||||
}
|
||||
|
||||
func NewCategoryService(repo *repository.CategoryRepo, relatedRepo *repository.RelatedCategoryRepo) *CategoryService {
|
||||
@@ -22,44 +22,47 @@ func NewCategoryService(repo *repository.CategoryRepo, relatedRepo *repository.R
|
||||
|
||||
// CreateCategoryRequest is the DTO for creating a category.
|
||||
type CreateCategoryRequest struct {
|
||||
Name string `json:"name" validate:"required,min=2"`
|
||||
Slug string `json:"slug" validate:"required"`
|
||||
Description string `json:"description"`
|
||||
Icon string `json:"icon"`
|
||||
Position int `json:"position"`
|
||||
Locale string `json:"locale"`
|
||||
ParentID *uint `json:"parent_id"`
|
||||
AssociatedCategoryID *uint `json:"associated_category_id"`
|
||||
RelatedCategoryIDs []uint `json:"related_category_ids"`
|
||||
CustomAttributes json.RawMessage `json:"custom_attributes"`
|
||||
Name string `json:"name" validate:"required,min=2"`
|
||||
Slug string `json:"slug" validate:"required"`
|
||||
Description string `json:"description"`
|
||||
Icon string `json:"icon"`
|
||||
Position int `json:"position"`
|
||||
Locale string `json:"locale"`
|
||||
ParentID *uint `json:"parent_id"`
|
||||
ParentCategoryID *uint `json:"parent_category_id"`
|
||||
AssociatedCategoryID *uint `json:"associated_category_id"`
|
||||
RelatedCategoryIDs []uint `json:"related_category_ids"`
|
||||
CustomAttributes json.RawMessage `json:"custom_attributes"`
|
||||
}
|
||||
|
||||
// UpdateCategoryRequest is the DTO for updating a category.
|
||||
type UpdateCategoryRequest struct {
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
Icon string `json:"icon"`
|
||||
Position *int `json:"position"`
|
||||
Locale string `json:"locale"`
|
||||
ParentID *uint `json:"parent_id"`
|
||||
AssociatedCategoryID *uint `json:"associated_category_id"`
|
||||
RelatedCategoryIDs []uint `json:"related_category_ids"`
|
||||
CustomAttributes json.RawMessage `json:"custom_attributes"`
|
||||
Name *string `json:"name"`
|
||||
Slug *string `json:"slug"`
|
||||
Description *string `json:"description"`
|
||||
Icon *string `json:"icon"`
|
||||
Position *int `json:"position"`
|
||||
Locale *string `json:"locale"`
|
||||
ParentID *uint `json:"parent_id"`
|
||||
ParentCategoryID *uint `json:"parent_category_id"`
|
||||
AssociatedCategoryID *uint `json:"associated_category_id"`
|
||||
RelatedCategoryIDs []uint `json:"related_category_ids"`
|
||||
CustomAttributes *json.RawMessage `json:"custom_attributes"`
|
||||
}
|
||||
|
||||
func (s *CategoryService) Create(ctx context.Context, portalID uint, accountID uint, req *CreateCategoryRequest) (*model.Category, error) {
|
||||
category := &model.Category{
|
||||
AccountID: accountID,
|
||||
PortalID: portalID,
|
||||
Name: req.Name,
|
||||
Slug: req.Slug,
|
||||
Description: req.Description,
|
||||
Icon: req.Icon,
|
||||
Position: req.Position,
|
||||
Locale: req.Locale,
|
||||
ParentID: req.ParentID,
|
||||
AccountID: accountID,
|
||||
PortalID: portalID,
|
||||
Name: req.Name,
|
||||
Slug: req.Slug,
|
||||
Description: req.Description,
|
||||
Icon: req.Icon,
|
||||
Position: req.Position,
|
||||
Locale: req.Locale,
|
||||
ParentID: firstCategoryParentID(req.ParentID, req.ParentCategoryID),
|
||||
AssociatedCategoryID: req.AssociatedCategoryID,
|
||||
CustomAttributes: req.CustomAttributes,
|
||||
CustomAttributes: req.CustomAttributes,
|
||||
}
|
||||
|
||||
if err := s.repo.Create(ctx, category); err != nil {
|
||||
@@ -73,7 +76,7 @@ func (s *CategoryService) Create(ctx context.Context, portalID uint, accountID u
|
||||
}
|
||||
}
|
||||
|
||||
return category, nil
|
||||
return s.repo.GetByPortalAndID(ctx, portalID, category.ID)
|
||||
}
|
||||
|
||||
func (s *CategoryService) GetByID(ctx context.Context, id uint) (*model.Category, error) {
|
||||
@@ -84,35 +87,46 @@ func (s *CategoryService) GetByID(ctx context.Context, id uint) (*model.Category
|
||||
return category, nil
|
||||
}
|
||||
|
||||
func (s *CategoryService) GetByPortalAndID(ctx context.Context, portalID, id uint) (*model.Category, error) {
|
||||
category, err := s.repo.GetByPortalAndID(ctx, portalID, id)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get category: %w", err)
|
||||
}
|
||||
return category, nil
|
||||
}
|
||||
|
||||
func (s *CategoryService) Update(ctx context.Context, id uint, req *UpdateCategoryRequest) (*model.Category, error) {
|
||||
category, err := s.repo.GetByID(ctx, id)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("find category: %w", err)
|
||||
}
|
||||
|
||||
if req.Name != "" {
|
||||
category.Name = req.Name
|
||||
if req.Name != nil {
|
||||
category.Name = *req.Name
|
||||
}
|
||||
if req.Description != "" {
|
||||
category.Description = req.Description
|
||||
if req.Slug != nil {
|
||||
category.Slug = *req.Slug
|
||||
}
|
||||
if req.Icon != "" {
|
||||
category.Icon = req.Icon
|
||||
if req.Description != nil {
|
||||
category.Description = *req.Description
|
||||
}
|
||||
if req.Icon != nil {
|
||||
category.Icon = *req.Icon
|
||||
}
|
||||
if req.Position != nil {
|
||||
category.Position = *req.Position
|
||||
}
|
||||
if req.Locale != "" {
|
||||
category.Locale = req.Locale
|
||||
if req.Locale != nil {
|
||||
category.Locale = *req.Locale
|
||||
}
|
||||
if req.ParentID != nil {
|
||||
category.ParentID = req.ParentID
|
||||
if parentID := firstCategoryParentID(req.ParentID, req.ParentCategoryID); parentID != nil {
|
||||
category.ParentID = parentID
|
||||
}
|
||||
if req.AssociatedCategoryID != nil {
|
||||
category.AssociatedCategoryID = req.AssociatedCategoryID
|
||||
}
|
||||
if req.CustomAttributes != nil {
|
||||
category.CustomAttributes = req.CustomAttributes
|
||||
category.CustomAttributes = *req.CustomAttributes
|
||||
}
|
||||
|
||||
if err := s.repo.Update(ctx, category); err != nil {
|
||||
@@ -126,7 +140,57 @@ func (s *CategoryService) Update(ctx context.Context, id uint, req *UpdateCatego
|
||||
}
|
||||
}
|
||||
|
||||
return category, nil
|
||||
return s.repo.GetByPortalAndID(ctx, category.PortalID, category.ID)
|
||||
}
|
||||
|
||||
func (s *CategoryService) UpdateScoped(ctx context.Context, portalID, id uint, req *UpdateCategoryRequest) (*model.Category, error) {
|
||||
category, err := s.repo.GetByPortalAndID(ctx, portalID, id)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("find category: %w", err)
|
||||
}
|
||||
return s.UpdateExisting(ctx, category, req)
|
||||
}
|
||||
|
||||
func (s *CategoryService) UpdateExisting(ctx context.Context, category *model.Category, req *UpdateCategoryRequest) (*model.Category, error) {
|
||||
if category == nil {
|
||||
return nil, fmt.Errorf("find category: record not found")
|
||||
}
|
||||
if req.Name != nil {
|
||||
category.Name = *req.Name
|
||||
}
|
||||
if req.Slug != nil {
|
||||
category.Slug = *req.Slug
|
||||
}
|
||||
if req.Description != nil {
|
||||
category.Description = *req.Description
|
||||
}
|
||||
if req.Icon != nil {
|
||||
category.Icon = *req.Icon
|
||||
}
|
||||
if req.Position != nil {
|
||||
category.Position = *req.Position
|
||||
}
|
||||
if req.Locale != nil {
|
||||
category.Locale = *req.Locale
|
||||
}
|
||||
if parentID := firstCategoryParentID(req.ParentID, req.ParentCategoryID); parentID != nil {
|
||||
category.ParentID = parentID
|
||||
}
|
||||
if req.AssociatedCategoryID != nil {
|
||||
category.AssociatedCategoryID = req.AssociatedCategoryID
|
||||
}
|
||||
if req.CustomAttributes != nil {
|
||||
category.CustomAttributes = *req.CustomAttributes
|
||||
}
|
||||
if err := s.repo.Update(ctx, category); err != nil {
|
||||
return nil, fmt.Errorf("update category: %w", err)
|
||||
}
|
||||
if req.RelatedCategoryIDs != nil {
|
||||
if err := s.relatedRepo.ReplaceAll(ctx, category.ID, req.RelatedCategoryIDs); err != nil {
|
||||
applogger.L().Warnf("Failed to update related categories for category %d: %v", category.ID, err)
|
||||
}
|
||||
}
|
||||
return s.repo.GetByPortalAndID(ctx, category.PortalID, category.ID)
|
||||
}
|
||||
|
||||
func (s *CategoryService) Delete(ctx context.Context, id uint) error {
|
||||
@@ -146,19 +210,49 @@ func (s *CategoryService) Delete(ctx context.Context, id uint) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *CategoryService) DeleteScoped(ctx context.Context, portalID, id uint) error {
|
||||
if _, err := s.repo.GetByPortalAndID(ctx, portalID, id); err != nil {
|
||||
return fmt.Errorf("find category for delete: %w", err)
|
||||
}
|
||||
if err := s.relatedRepo.DeleteByCategoryID(ctx, id); err != nil {
|
||||
applogger.L().Warnf("Failed to delete related categories for category %d: %v", id, err)
|
||||
}
|
||||
if err := s.repo.Delete(ctx, id); err != nil {
|
||||
return fmt.Errorf("delete category: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ListByPortalID returns categories for a portal with pagination.
|
||||
func (s *CategoryService) ListByPortalID(ctx context.Context, portalID uint, locale string, page, perPage int) ([]model.Category, int64, error) {
|
||||
offset := (page - 1) * perPage
|
||||
offset := 0
|
||||
if page > 0 && perPage > 0 {
|
||||
offset = (page - 1) * perPage
|
||||
}
|
||||
if locale != "" {
|
||||
return s.repo.FindByPortalIDAndLocale(ctx, portalID, locale, offset, perPage)
|
||||
}
|
||||
return s.repo.FindByPortalID(ctx, portalID, offset, perPage)
|
||||
}
|
||||
|
||||
func firstCategoryParentID(parentID, parentCategoryID *uint) *uint {
|
||||
if parentCategoryID != nil {
|
||||
return parentCategoryID
|
||||
}
|
||||
return parentID
|
||||
}
|
||||
|
||||
// Reorder batch-updates category positions.
|
||||
func (s *CategoryService) Reorder(ctx context.Context, positions map[uint]int) error {
|
||||
if err := s.repo.UpdatePositions(ctx, positions); err != nil {
|
||||
return fmt.Errorf("reorder categories: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func (s *CategoryService) ReorderScoped(ctx context.Context, portalID uint, positions map[uint]int) error {
|
||||
if err := s.repo.UpdatePositionsForPortal(ctx, portalID, positions); err != nil {
|
||||
return fmt.Errorf("reorder categories: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -95,12 +95,16 @@ func TestCategoryService_Update(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
|
||||
newPos := 5
|
||||
newName := "User Guides"
|
||||
newDescription := "Updated description"
|
||||
newIcon := "📖"
|
||||
newLocale := "zh"
|
||||
updateReq := &UpdateCategoryRequest{
|
||||
Name: "User Guides",
|
||||
Description: "Updated description",
|
||||
Icon: "📖",
|
||||
Name: &newName,
|
||||
Description: &newDescription,
|
||||
Icon: &newIcon,
|
||||
Position: &newPos,
|
||||
Locale: "zh",
|
||||
Locale: &newLocale,
|
||||
}
|
||||
|
||||
updated, err := svc.Update(context.Background(), created.ID, updateReq)
|
||||
@@ -116,8 +120,9 @@ func TestCategoryService_Update_NotFound(t *testing.T) {
|
||||
svc, _, _ := setupCategoryService(t)
|
||||
|
||||
newPos := 1
|
||||
newName := "Nonexistent"
|
||||
updateReq := &UpdateCategoryRequest{
|
||||
Name: "Nonexistent",
|
||||
Name: &newName,
|
||||
Position: &newPos,
|
||||
}
|
||||
|
||||
@@ -248,10 +253,10 @@ func TestCategoryService_ListByPortalID(t *testing.T) {
|
||||
names := []string{"First", "Second", "Third"}
|
||||
for i, name := range names {
|
||||
_, err := svc.Create(context.Background(), portalID, accountID, &CreateCategoryRequest{
|
||||
Name: name,
|
||||
Slug: name,
|
||||
Name: name,
|
||||
Slug: name,
|
||||
Position: i + 1,
|
||||
Locale: "en",
|
||||
Locale: "en",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
}
|
||||
@@ -307,4 +312,4 @@ func TestCategoryService_ListByPortalID_WithLocale(t *testing.T) {
|
||||
for _, cat := range categories {
|
||||
assert.Equal(t, "en", cat.Locale)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user