feat(help-center): expose public category lists
This commit is contained in:
@@ -3,6 +3,7 @@ package v1
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/gochat/gochat/internal/model"
|
||||
@@ -25,6 +26,41 @@ func NewCategoryHandler(svc *service.CategoryService, portalSvc ...*service.Port
|
||||
return h
|
||||
}
|
||||
|
||||
// PublicList returns public help-center categories for a portal.
|
||||
// GET /hc/:slug/:locale/categories.json
|
||||
func (h *CategoryHandler) PublicList(c *gin.Context) {
|
||||
portal, ok := h.resolvePublicPortal(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
categories, _, err := h.svc.ListByPortalID(c.Request.Context(), portal.ID, "", 0, 0)
|
||||
if err != nil {
|
||||
applogger.L().Errorf("Public list categories: %v", err)
|
||||
response.AbortWithStatusError(c, http.StatusInternalServerError, response.ErrInternal, "failed to list categories")
|
||||
return
|
||||
}
|
||||
payload := make([]gin.H, 0, len(categories))
|
||||
for i := range categories {
|
||||
payload = append(payload, publicCategoryPayload(&categories[i]))
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"payload": payload})
|
||||
}
|
||||
|
||||
// PublicGet returns a public help-center category by slug and locale.
|
||||
// GET /hc/:slug/:locale/categories/:category_slug.json
|
||||
func (h *CategoryHandler) PublicGet(c *gin.Context) {
|
||||
portal, ok := h.resolvePublicPortal(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
category, err := h.svc.GetByPortalSlugAndLocale(c.Request.Context(), portal.ID, publicCategorySlugParam(c), c.Param("locale"))
|
||||
if err != nil {
|
||||
response.AbortWithStatusError(c, http.StatusNotFound, response.ErrNotFound, "category not found")
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, publicCategoryPayload(category))
|
||||
}
|
||||
|
||||
// Create creates a new category.
|
||||
// POST /api/v1/accounts/:account_id/portals/:portal_id/categories
|
||||
func (h *CategoryHandler) Create(c *gin.Context) {
|
||||
@@ -216,6 +252,19 @@ func (h *CategoryHandler) resolvePortal(c *gin.Context, accountID uint) (*model.
|
||||
return &model.Portal{Base: model.Base{ID: uint(portalID)}, AccountID: accountID}, true
|
||||
}
|
||||
|
||||
func (h *CategoryHandler) resolvePublicPortal(c *gin.Context) (*model.Portal, bool) {
|
||||
if h.portalSvc == nil {
|
||||
response.AbortWithStatusError(c, http.StatusNotFound, response.ErrNotFound, "portal not found")
|
||||
return nil, false
|
||||
}
|
||||
portal, err := h.portalSvc.ResolvePublicBySlug(c.Request.Context(), c.Param("slug"))
|
||||
if err != nil {
|
||||
response.AbortWithStatusError(c, http.StatusNotFound, response.ErrNotFound, "portal not found")
|
||||
return nil, false
|
||||
}
|
||||
return portal, true
|
||||
}
|
||||
|
||||
func categoryPayload(category *model.Category, currentLocale string) gin.H {
|
||||
if category == nil {
|
||||
return gin.H{}
|
||||
@@ -239,6 +288,20 @@ func categoryPayload(category *model.Category, currentLocale string) gin.H {
|
||||
return payload
|
||||
}
|
||||
|
||||
func publicCategoryPayload(category *model.Category) gin.H {
|
||||
if category == nil {
|
||||
return gin.H{}
|
||||
}
|
||||
return gin.H{
|
||||
"name": category.Name,
|
||||
"slug": category.Slug,
|
||||
"locale": category.Locale,
|
||||
"description": category.Description,
|
||||
"position": category.Position,
|
||||
"meta": gin.H{"articles_count": categoryPublishedArticleCount(category.Articles)},
|
||||
}
|
||||
}
|
||||
|
||||
func relatedCategoryPayloads(related []model.RelatedCategory) []gin.H {
|
||||
payload := make([]gin.H, 0, len(related))
|
||||
for _, item := range related {
|
||||
@@ -271,6 +334,23 @@ func categoryArticleCount(articles []model.Article, locale string) int {
|
||||
return count
|
||||
}
|
||||
|
||||
func categoryPublishedArticleCount(articles []model.Article) int {
|
||||
count := 0
|
||||
for _, article := range articles {
|
||||
if article.Status == string(model.ArticleStatusPublished) {
|
||||
count++
|
||||
}
|
||||
}
|
||||
return count
|
||||
}
|
||||
|
||||
func publicCategorySlugParam(c *gin.Context) string {
|
||||
if slug := c.Param("category_slug"); slug != "" {
|
||||
return strings.TrimSuffix(slug, ".json")
|
||||
}
|
||||
return strings.TrimSuffix(c.Param("category_slug.json"), ".json")
|
||||
}
|
||||
|
||||
func bindCategoryReorder(c *gin.Context) (map[uint]int, error) {
|
||||
var req struct {
|
||||
PositionsHash map[string]int `json:"positions_hash"`
|
||||
|
||||
@@ -91,9 +91,15 @@ func (s *CategoryHandlerTestSuite) SetupSuite() {
|
||||
portalGroup.POST("/reorder", s.handler.Reorder)
|
||||
}
|
||||
}
|
||||
publicGroup := s.router.Group("/hc")
|
||||
{
|
||||
publicGroup.GET("/:slug/:locale/categories.json", s.handler.PublicList)
|
||||
publicGroup.GET("/:slug/:locale/categories/:category_slug.json", s.handler.PublicGet)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *CategoryHandlerTestSuite) SetupTest() {
|
||||
s.db.Exec("DELETE FROM articles")
|
||||
s.db.Exec("DELETE FROM categories")
|
||||
s.db.Exec("DELETE FROM related_categories")
|
||||
}
|
||||
@@ -322,6 +328,61 @@ func (s *CategoryHandlerTestSuite) TestList_Empty() {
|
||||
s.Equal(http.StatusOK, w.Code)
|
||||
}
|
||||
|
||||
func (s *CategoryHandlerTestSuite) TestPublicList_ReturnsChatwootCategoryPayloads() {
|
||||
cat1 := &model.Category{AccountID: s.accountID, PortalID: s.portalID, Name: "Second", Slug: "second", Locale: "en", Description: "second", Position: 2}
|
||||
cat2 := &model.Category{AccountID: s.accountID, PortalID: s.portalID, Name: "First", Slug: "first", Locale: "fr", Description: "first", Position: 1}
|
||||
s.Require().NoError(s.db.Create(cat1).Error)
|
||||
s.Require().NoError(s.db.Create(cat2).Error)
|
||||
s.Require().NoError(s.db.Create(&model.Article{AccountID: s.accountID, PortalID: s.portalID, CategoryID: &cat2.ID, Title: "Published", Slug: "published-cat", Status: "published", Locale: "fr"}).Error)
|
||||
s.Require().NoError(s.db.Create(&model.Article{AccountID: s.accountID, PortalID: s.portalID, CategoryID: &cat2.ID, Title: "Draft", Slug: "draft-cat", Status: "draft", Locale: "fr"}).Error)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, "/hc/test-portal/en/categories.json", 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"].([]interface{})
|
||||
s.Len(payload, 2)
|
||||
first := payload[0].(map[string]interface{})
|
||||
s.Equal("First", first["name"])
|
||||
s.Equal("first", first["slug"])
|
||||
s.Equal("fr", first["locale"])
|
||||
s.NotContains(first, "id")
|
||||
s.EqualValues(1, first["meta"].(map[string]interface{})["articles_count"])
|
||||
}
|
||||
|
||||
func (s *CategoryHandlerTestSuite) TestPublicGet_FiltersBySlugAndLocale() {
|
||||
catEN := &model.Category{AccountID: s.accountID, PortalID: s.portalID, Name: "English", Slug: "shared", Locale: "en", Position: 1}
|
||||
catFR := &model.Category{AccountID: s.accountID, PortalID: s.portalID, Name: "French", Slug: "shared", Locale: "fr", Position: 2}
|
||||
s.Require().NoError(s.db.Create(catEN).Error)
|
||||
s.Require().NoError(s.db.Create(catFR).Error)
|
||||
s.Require().NoError(s.db.Create(&model.Article{AccountID: s.accountID, PortalID: s.portalID, CategoryID: &catFR.ID, Title: "French Article", Slug: "french-article", Status: "published", Locale: "fr"}).Error)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, "/hc/test-portal/fr/categories/shared.json", 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.Equal("French", resp["name"])
|
||||
s.Equal("fr", resp["locale"])
|
||||
s.EqualValues(1, resp["meta"].(map[string]interface{})["articles_count"])
|
||||
}
|
||||
|
||||
func (s *CategoryHandlerTestSuite) TestPublicGet_NotFoundForWrongLocale() {
|
||||
cat := &model.Category{AccountID: s.accountID, PortalID: s.portalID, Name: "English", Slug: "english-only", Locale: "en"}
|
||||
s.Require().NoError(s.db.Create(cat).Error)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, "/hc/test-portal/fr/categories/english-only.json", nil)
|
||||
s.router.ServeHTTP(w, req)
|
||||
|
||||
s.Equal(http.StatusNotFound, w.Code)
|
||||
}
|
||||
|
||||
// --- Reorder Tests ---
|
||||
func (s *CategoryHandlerTestSuite) TestReorder_Success() {
|
||||
cat1 := &model.Category{PortalID: s.portalID, Name: "Cat1", Slug: "cat1"}
|
||||
|
||||
Reference in New Issue
Block a user