feat(help-center): expose public portal show

This commit is contained in:
2026-06-06 07:41:24 +08:00
parent c64b4c2f92
commit 5275e6e113
12 changed files with 175 additions and 13 deletions
+28 -1
View File
@@ -65,6 +65,9 @@ func (h *ArticleHandler) PublicList(c *gin.Context) {
response.AbortWithStatusError(c, http.StatusInternalServerError, response.ErrInternal, "failed to list articles")
return
}
for i := range articles {
articles[i].Portal = *portal
}
c.JSON(http.StatusOK, gin.H{
"payload": publicArticlePayloads(articles, portal.Slug),
@@ -747,14 +750,38 @@ func publicPortalPayload(portal *model.Portal) gin.H {
if portal == nil {
return gin.H{}
}
return gin.H{
categories := make([]gin.H, 0, len(portal.Categories))
for i := range portal.Categories {
categories = append(categories, publicCategoryPayload(&portal.Categories[i]))
}
payload := gin.H{
"custom_domain": portal.CustomDomain,
"header_text": portal.HeaderText,
"homepage_link": portal.HomepageLink,
"name": portal.Name,
"page_title": portal.PageTitle,
"slug": portal.Slug,
"categories": categories,
"meta": gin.H{
"articles_count": publicPortalPublishedArticleCount(portal.Articles),
"categories_count": len(portal.Categories),
"default_locale": portalDefaultLocale(portal),
},
}
if portal.LogoURL != "" {
payload["logo"] = gin.H{"file_url": portal.LogoURL, "portal_id": portal.ID, "account_id": portal.AccountID}
}
return payload
}
func publicPortalPublishedArticleCount(articles []model.Article) int {
count := 0
for _, article := range articles {
if article.Status == string(model.ArticleStatusPublished) {
count++
}
}
return count
}
func publicAuthorPayload(user *model.User) gin.H {
@@ -310,6 +310,8 @@ func (s *ArticleHandlerTestSuite) TestPublicList_WidgetPopularArticles() {
assert.Equal(s.T(), "hc/test-portal/articles/public-high-views", first["link"])
assert.Equal(s.T(), "guides", first["category"].(map[string]interface{})["slug"])
assert.Equal(s.T(), "Writer", first["author"].(map[string]interface{})["available_name"])
portal := first["portal"].(map[string]interface{})
assert.EqualValues(s.T(), 3, portal["meta"].(map[string]interface{})["articles_count"])
meta := resp["meta"].(map[string]interface{})
assert.EqualValues(s.T(), 2, meta["articles_count"])
}
+42
View File
@@ -23,6 +23,26 @@ func NewPortalHandler(svc *service.PortalService) *PortalHandler {
return &PortalHandler{svc: svc}
}
// PublicRedirectDefaultLocale redirects /hc/:slug to the portal default locale.
// GET /hc/:slug
func (h *PortalHandler) PublicRedirectDefaultLocale(c *gin.Context) {
portal, ok := h.resolvePublicPortal(c)
if !ok {
return
}
c.Redirect(http.StatusFound, "/hc/"+portal.Slug+"/"+portalDefaultLocale(portal))
}
// PublicGet returns the public help-center portal JSON payload.
// GET /hc/:slug/:locale
func (h *PortalHandler) PublicGet(c *gin.Context) {
portal, ok := h.resolvePublicPortal(c)
if !ok {
return
}
c.JSON(http.StatusOK, publicPortalPayload(portal))
}
// Create creates a new portal.
// POST /api/v1/accounts/:account_id/portals
func (h *PortalHandler) Create(c *gin.Context) {
@@ -240,6 +260,28 @@ func (h *PortalHandler) SSLStatus(c *gin.Context) {
c.JSON(http.StatusOK, portalSSLStatusPayload(portal))
}
func (h *PortalHandler) resolvePublicPortal(c *gin.Context) (*model.Portal, bool) {
slug := strings.TrimSuffix(c.Param("slug"), ".json")
portal, err := h.svc.ResolvePublicBySlug(c.Request.Context(), slug)
if err != nil {
response.AbortWithStatusError(c, http.StatusNotFound, response.ErrNotFound, "portal not found")
return nil, false
}
return portal, true
}
func portalDefaultLocale(portal *model.Portal) string {
if portal == nil {
return "en"
}
config := portalConfig(portal.PortalConfiguration)
defaultLocale := configString(config, "default_locale", portal.Locale)
if defaultLocale == "" {
return "en"
}
return strings.TrimSuffix(defaultLocale, ".json")
}
func portalPayload(portal *model.Portal, locale string, currentUserID uint) gin.H {
if portal == nil {
return gin.H{}
@@ -44,6 +44,8 @@ func (s *PortalHandlerTestSuite) SetupSuite() {
}
func (s *PortalHandlerTestSuite) SetupTest() {
s.db.Exec("DELETE FROM articles")
s.db.Exec("DELETE FROM categories")
s.db.Exec("DELETE FROM portals")
}
@@ -125,6 +127,70 @@ func (s *PortalHandlerTestSuite) TestGet_BySlugReturnsChatwootMeta() {
assert.Len(s.T(), locales, 2)
}
func (s *PortalHandlerTestSuite) TestPublicRedirectDefaultLocale() {
portal := &model.Portal{AccountID: s.account.ID, Name: "Public", Slug: "public", Locale: "en", PortalConfiguration: json.RawMessage(`{"default_locale":"fr"}`)}
s.Require().NoError(s.db.Create(portal).Error)
r := gin.New()
r.GET("/hc/:slug", s.handler.PublicRedirectDefaultLocale)
w := httptest.NewRecorder()
req, _ := http.NewRequest("GET", "/hc/public", nil)
r.ServeHTTP(w, req)
assert.Equal(s.T(), http.StatusFound, w.Code)
assert.Equal(s.T(), "/hc/public/fr", w.Header().Get("Location"))
}
func (s *PortalHandlerTestSuite) TestPublicGet_ReturnsChatwootHCPortalPayload() {
portal := &model.Portal{AccountID: s.account.ID, Name: "Help", Slug: "help", HeaderText: "How can we help?", HomepageLink: "https://example.com", PageTitle: "Help Center", LogoURL: "https://cdn.example/logo.png", Locale: "en", PortalConfiguration: json.RawMessage(`{"default_locale":"en"}`)}
s.Require().NoError(s.db.Create(portal).Error)
category := &model.Category{AccountID: s.account.ID, PortalID: portal.ID, Name: "Basics", Slug: "basics", Locale: "en", Description: "Start here", Position: 1}
s.Require().NoError(s.db.Create(category).Error)
s.Require().NoError(s.db.Create(&model.Article{AccountID: s.account.ID, PortalID: portal.ID, CategoryID: &category.ID, Title: "Published", Slug: "published", Status: "published", Locale: "en"}).Error)
s.Require().NoError(s.db.Create(&model.Article{AccountID: s.account.ID, PortalID: portal.ID, CategoryID: &category.ID, Title: "Draft", Slug: "draft", Status: "draft", Locale: "en"}).Error)
r := gin.New()
r.GET("/hc/:slug/:locale", s.handler.PublicGet)
w := httptest.NewRecorder()
req, _ := http.NewRequest("GET", "/hc/help/en.json", nil)
r.ServeHTTP(w, req)
assert.Equal(s.T(), http.StatusOK, w.Code)
var payload map[string]any
s.Require().NoError(json.Unmarshal(w.Body.Bytes(), &payload))
assert.Equal(s.T(), "help", payload["slug"])
assert.Equal(s.T(), "How can we help?", payload["header_text"])
assert.NotContains(s.T(), payload, "id")
assert.NotContains(s.T(), payload, "account_id")
categories := payload["categories"].([]any)
assert.Len(s.T(), categories, 1)
cat := categories[0].(map[string]any)
assert.Equal(s.T(), "basics", cat["slug"])
assert.EqualValues(s.T(), 1, cat["meta"].(map[string]any)["articles_count"])
meta := payload["meta"].(map[string]any)
assert.EqualValues(s.T(), 1, meta["articles_count"])
assert.EqualValues(s.T(), 1, meta["categories_count"])
assert.Equal(s.T(), "en", meta["default_locale"])
logo := payload["logo"].(map[string]any)
assert.Equal(s.T(), "https://cdn.example/logo.png", logo["file_url"])
}
func (s *PortalHandlerTestSuite) TestPublicGet_NotFoundForArchivedPortal() {
portal := &model.Portal{AccountID: s.account.ID, Name: "Archived", Slug: "archived", Archived: true}
s.Require().NoError(s.db.Create(portal).Error)
r := gin.New()
r.GET("/hc/:slug/:locale", s.handler.PublicGet)
w := httptest.NewRecorder()
req, _ := http.NewRequest("GET", "/hc/archived/en", nil)
r.ServeHTTP(w, req)
assert.Equal(s.T(), http.StatusNotFound, w.Code)
}
func (s *PortalHandlerTestSuite) TestUpdate_Success() {
portal := &model.Portal{AccountID: s.account.ID, Name: "update-portal", Slug: "update-slug"}
s.Require().NoError(s.db.Create(portal).Error)