feat(help-center): align category payloads

This commit is contained in:
2026-06-06 03:56:16 +08:00
parent f3ac4a7ee3
commit c38ad40a85
10 changed files with 447 additions and 149 deletions
+174 -47
View File
@@ -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)
}
}