feat(help-center): expose public article lists
This commit is contained in:
@@ -5,6 +5,7 @@ import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
@@ -22,6 +23,55 @@ type ArticleHandler struct {
|
||||
portalSvc *service.PortalService
|
||||
}
|
||||
|
||||
// PublicList returns published help-center articles for the public portal/widget views.
|
||||
// GET /hc/:slug/:locale/articles.json
|
||||
func (h *ArticleHandler) PublicList(c *gin.Context) {
|
||||
portal, ok := h.resolvePublicPortal(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
page, perPage, offset, ok := publicArticlePagination(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
locale := c.Param("locale")
|
||||
query := strings.TrimSpace(c.Query("query"))
|
||||
params := repository.ArticleSearchParams{
|
||||
PortalID: portal.ID,
|
||||
Query: query,
|
||||
CategorySlug: c.Param("category_slug"),
|
||||
Locale: locale,
|
||||
Status: string(model.ArticleStatusPublished),
|
||||
SortBy: c.Query("sort"),
|
||||
Offset: offset,
|
||||
Limit: perPage,
|
||||
}
|
||||
|
||||
countParams := repository.ArticleSearchParams{
|
||||
PortalID: portal.ID,
|
||||
Locale: locale,
|
||||
Status: string(model.ArticleStatusPublished),
|
||||
}
|
||||
articlesCount, err := h.svc.Count(c.Request.Context(), countParams)
|
||||
if err != nil {
|
||||
applogger.L().Errorf("Public article count: %v", err)
|
||||
response.AbortWithStatusError(c, http.StatusInternalServerError, response.ErrInternal, "failed to list articles")
|
||||
return
|
||||
}
|
||||
articles, _, err := h.svc.Search(c.Request.Context(), params)
|
||||
if err != nil {
|
||||
applogger.L().Errorf("Public article list: %v", err)
|
||||
response.AbortWithStatusError(c, http.StatusInternalServerError, response.ErrInternal, "failed to list articles")
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"payload": publicArticlePayloads(articles, portal.Slug),
|
||||
"meta": gin.H{"articles_count": articlesCount, "current_page": page},
|
||||
})
|
||||
}
|
||||
|
||||
// NewArticleHandler creates a new ArticleHandler.
|
||||
func NewArticleHandler(svc *service.ArticleService, portalSvc ...*service.PortalService) *ArticleHandler {
|
||||
h := &ArticleHandler{svc: svc}
|
||||
@@ -456,6 +506,53 @@ func (h *ArticleHandler) resolvePortal(c *gin.Context, accountID uint) (*model.P
|
||||
return &model.Portal{Base: model.Base{ID: uint(portalID)}, AccountID: accountID}, true
|
||||
}
|
||||
|
||||
func (h *ArticleHandler) 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 publicArticlePagination(c *gin.Context) (int, int, int, bool) {
|
||||
page := 1
|
||||
if raw := c.Query("page"); raw != "" {
|
||||
parsed, err := strconv.Atoi(raw)
|
||||
if err != nil {
|
||||
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid page")
|
||||
return 0, 0, 0, false
|
||||
}
|
||||
if parsed > 0 {
|
||||
page = parsed
|
||||
}
|
||||
}
|
||||
perPage := 0
|
||||
if raw := c.Query("per_page"); raw != "" {
|
||||
parsed, err := strconv.Atoi(raw)
|
||||
if err != nil {
|
||||
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid per_page")
|
||||
return 0, 0, 0, false
|
||||
}
|
||||
perPage = parsed
|
||||
if perPage < 1 {
|
||||
perPage = 25
|
||||
}
|
||||
if perPage > 100 {
|
||||
perPage = 100
|
||||
}
|
||||
}
|
||||
offset := 0
|
||||
if perPage > 0 {
|
||||
offset = (page - 1) * perPage
|
||||
}
|
||||
return page, perPage, offset, true
|
||||
}
|
||||
|
||||
func (h *ArticleHandler) articleSearchParams(c *gin.Context, portalID uint, offset, limit int) (repository.ArticleSearchParams, bool) {
|
||||
params := repository.ArticleSearchParams{
|
||||
PortalID: portalID,
|
||||
@@ -581,6 +678,96 @@ func associatedArticlePayloads(articles []model.Article) []gin.H {
|
||||
return payload
|
||||
}
|
||||
|
||||
func publicArticlePayloads(articles []model.Article, portalSlug string) []gin.H {
|
||||
payload := make([]gin.H, 0, len(articles))
|
||||
for i := range articles {
|
||||
payload = append(payload, publicArticlePayload(&articles[i], portalSlug))
|
||||
}
|
||||
return payload
|
||||
}
|
||||
|
||||
func publicArticlePayload(article *model.Article, portalSlug string) gin.H {
|
||||
if article == nil {
|
||||
return gin.H{}
|
||||
}
|
||||
if article.Portal.Slug != "" {
|
||||
portalSlug = article.Portal.Slug
|
||||
}
|
||||
payload := gin.H{
|
||||
"id": article.ID,
|
||||
"category_id": article.CategoryID,
|
||||
"title": article.Title,
|
||||
"content": article.Content,
|
||||
"description": article.Description,
|
||||
"status": article.Status,
|
||||
"position": article.Position,
|
||||
"account_id": article.AccountID,
|
||||
"last_updated_at": article.UpdatedAt,
|
||||
"slug": article.Slug,
|
||||
"views": article.Views,
|
||||
"associated_articles": publicAssociatedArticlePayloads(article.AssociatedArticles),
|
||||
"link": "hc/" + portalSlug + "/articles/" + article.Slug,
|
||||
}
|
||||
if article.Portal.ID != 0 {
|
||||
payload["portal"] = publicPortalPayload(&article.Portal)
|
||||
}
|
||||
if article.Category != nil && article.Category.ID != 0 {
|
||||
payload["category"] = gin.H{"id": article.Category.ID, "slug": article.Category.Slug, "locale": article.Category.Locale}
|
||||
}
|
||||
if article.Author != nil && article.Author.ID != 0 {
|
||||
payload["author"] = publicAuthorPayload(article.Author)
|
||||
}
|
||||
return payload
|
||||
}
|
||||
|
||||
func publicAssociatedArticlePayloads(articles []model.Article) []gin.H {
|
||||
payload := make([]gin.H, 0, len(articles))
|
||||
for i := range articles {
|
||||
article := &articles[i]
|
||||
item := gin.H{
|
||||
"id": article.ID,
|
||||
"category_id": article.CategoryID,
|
||||
"title": article.Title,
|
||||
"content": article.Content,
|
||||
"description": article.Description,
|
||||
"status": article.Status,
|
||||
"account_id": article.AccountID,
|
||||
"last_updated_at": article.UpdatedAt,
|
||||
"views": article.Views,
|
||||
}
|
||||
if article.Author != nil && article.Author.ID != 0 {
|
||||
item["author"] = publicAuthorPayload(article.Author)
|
||||
}
|
||||
payload = append(payload, item)
|
||||
}
|
||||
return payload
|
||||
}
|
||||
|
||||
func publicPortalPayload(portal *model.Portal) gin.H {
|
||||
if portal == nil {
|
||||
return gin.H{}
|
||||
}
|
||||
return gin.H{
|
||||
"custom_domain": portal.CustomDomain,
|
||||
"header_text": portal.HeaderText,
|
||||
"homepage_link": portal.HomepageLink,
|
||||
"name": portal.Name,
|
||||
"page_title": portal.PageTitle,
|
||||
"slug": portal.Slug,
|
||||
}
|
||||
}
|
||||
|
||||
func publicAuthorPayload(user *model.User) gin.H {
|
||||
if user == nil {
|
||||
return gin.H{}
|
||||
}
|
||||
availableName := user.DisplayName
|
||||
if availableName == "" {
|
||||
availableName = user.Name
|
||||
}
|
||||
return gin.H{"available_name": availableName, "name": user.Name, "thumbnail": user.AvatarURL}
|
||||
}
|
||||
|
||||
func articleListMetaPayload(meta *service.ArticleListMeta, currentPage int) gin.H {
|
||||
return gin.H{
|
||||
"all_articles_count": meta.AllArticlesCount,
|
||||
|
||||
@@ -277,6 +277,57 @@ func (s *ArticleHandlerTestSuite) TestList_Success() {
|
||||
assert.Contains(s.T(), meta, "all_articles_count")
|
||||
}
|
||||
|
||||
func (s *ArticleHandlerTestSuite) TestPublicList_WidgetPopularArticles() {
|
||||
r := gin.New()
|
||||
r.GET("/hc/:slug/:locale/articles.json", s.handler.PublicList)
|
||||
|
||||
author := &model.User{AccountID: s.account.ID, Name: "Article Author", DisplayName: "Writer", Email: "public-article-author@example.com", Password: "secret"}
|
||||
s.Require().NoError(s.db.Create(author).Error)
|
||||
category := &model.Category{AccountID: s.account.ID, PortalID: s.portal.ID, Name: "Guides", Slug: "guides", Locale: "en"}
|
||||
s.Require().NoError(s.db.Create(category).Error)
|
||||
low := &model.Article{AccountID: s.account.ID, PortalID: s.portal.ID, CategoryID: &category.ID, AuthorID: &author.ID, Title: "Low Views", Slug: "public-low-views", Status: "published", Locale: "en", Views: 3, Content: "low"}
|
||||
high := &model.Article{AccountID: s.account.ID, PortalID: s.portal.ID, CategoryID: &category.ID, AuthorID: &author.ID, Title: "High Views", Slug: "public-high-views", Status: "published", Locale: "en", Views: 9, Content: "high"}
|
||||
draft := &model.Article{AccountID: s.account.ID, PortalID: s.portal.ID, Title: "Draft", Slug: "public-draft", Status: "draft", Locale: "en", Views: 99, Content: "draft"}
|
||||
french := &model.Article{AccountID: s.account.ID, PortalID: s.portal.ID, Title: "French", Slug: "public-french", Status: "published", Locale: "fr", Views: 99, Content: "fr"}
|
||||
s.Require().NoError(s.db.Create(low).Error)
|
||||
s.Require().NoError(s.db.Create(high).Error)
|
||||
s.Require().NoError(s.db.Create(draft).Error)
|
||||
s.Require().NoError(s.db.Create(french).Error)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
req, _ := http.NewRequest("GET", "/hc/test-portal/en/articles.json?page=1&sort=views&status=1&per_page=2", nil)
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
assert.Equal(s.T(), http.StatusOK, w.Code)
|
||||
var resp map[string]interface{}
|
||||
s.Require().NoError(json.Unmarshal(w.Body.Bytes(), &resp))
|
||||
payload := resp["payload"].([]interface{})
|
||||
assert.Len(s.T(), payload, 2)
|
||||
first := payload[0].(map[string]interface{})
|
||||
second := payload[1].(map[string]interface{})
|
||||
assert.Equal(s.T(), "High Views", first["title"])
|
||||
assert.Equal(s.T(), "Low Views", second["title"])
|
||||
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"])
|
||||
meta := resp["meta"].(map[string]interface{})
|
||||
assert.EqualValues(s.T(), 2, meta["articles_count"])
|
||||
}
|
||||
|
||||
func (s *ArticleHandlerTestSuite) TestPublicList_ArchivedPortalNotFound() {
|
||||
r := gin.New()
|
||||
r.GET("/hc/:slug/:locale/articles.json", s.handler.PublicList)
|
||||
|
||||
portal := &model.Portal{AccountID: s.account.ID, Name: "Archived", Slug: "archived-public", Archived: true}
|
||||
s.Require().NoError(s.db.Create(portal).Error)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
req, _ := http.NewRequest("GET", "/hc/archived-public/en/articles.json", nil)
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
assert.Equal(s.T(), http.StatusNotFound, w.Code)
|
||||
}
|
||||
|
||||
func (s *ArticleHandlerTestSuite) TestCreate_RawFrontendPayloadAndSlugPortal() {
|
||||
r := gin.New()
|
||||
r.POST("/api/v1/accounts/:account_id/portals/:portal_id/articles", s.handler.Create)
|
||||
|
||||
Reference in New Issue
Block a user