feat(help-center): expose public portal search
This commit is contained in:
@@ -4,6 +4,7 @@ import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
@@ -75,6 +76,46 @@ func (h *ArticleHandler) PublicList(c *gin.Context) {
|
||||
})
|
||||
}
|
||||
|
||||
// PublicSearch returns public help-center search results for the portal search page.
|
||||
// GET /hc/:slug/:locale/search
|
||||
func (h *ArticleHandler) PublicSearch(c *gin.Context) {
|
||||
portal, ok := h.resolvePublicPortal(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
page, perPage, offset, ok := publicSearchPagination(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
query := strings.TrimSpace(c.Query("query"))
|
||||
if query == "" {
|
||||
c.JSON(http.StatusOK, gin.H{"payload": []gin.H{}, "meta": gin.H{"articles_count": 0, "current_page": page}})
|
||||
return
|
||||
}
|
||||
|
||||
params := repository.ArticleSearchParams{
|
||||
PortalID: portal.ID,
|
||||
Query: query,
|
||||
Locale: c.Param("locale"),
|
||||
Status: string(model.ArticleStatusPublished),
|
||||
SortBy: c.Query("sort"),
|
||||
Offset: offset,
|
||||
Limit: perPage,
|
||||
}
|
||||
articles, count, err := h.svc.Search(c.Request.Context(), params)
|
||||
if err != nil {
|
||||
applogger.L().Errorf("Public article search: %v", err)
|
||||
response.AbortWithStatusError(c, http.StatusInternalServerError, response.ErrInternal, "failed to search articles")
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"payload": publicSearchArticlePayloads(articles, portal.Slug, query),
|
||||
"meta": gin.H{"articles_count": count, "current_page": page},
|
||||
})
|
||||
}
|
||||
|
||||
// NewArticleHandler creates a new ArticleHandler.
|
||||
func NewArticleHandler(svc *service.ArticleService, portalSvc ...*service.PortalService) *ArticleHandler {
|
||||
h := &ArticleHandler{svc: svc}
|
||||
@@ -556,6 +597,23 @@ func publicArticlePagination(c *gin.Context) (int, int, int, bool) {
|
||||
return page, perPage, offset, true
|
||||
}
|
||||
|
||||
func publicSearchPagination(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 := 10
|
||||
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,
|
||||
@@ -723,6 +781,79 @@ func publicArticlePayload(article *model.Article, portalSlug string) gin.H {
|
||||
return payload
|
||||
}
|
||||
|
||||
func publicSearchArticlePayloads(articles []model.Article, portalSlug, query string) []gin.H {
|
||||
payload := make([]gin.H, 0, len(articles))
|
||||
for i := range articles {
|
||||
payload = append(payload, publicSearchArticlePayload(&articles[i], portalSlug, query))
|
||||
}
|
||||
return payload
|
||||
}
|
||||
|
||||
func publicSearchArticlePayload(article *model.Article, portalSlug, query string) gin.H {
|
||||
if article == nil {
|
||||
return gin.H{}
|
||||
}
|
||||
return gin.H{
|
||||
"id": article.ID,
|
||||
"category_id": article.CategoryID,
|
||||
"title": article.Title,
|
||||
"content": publicSearchArticleSnippet(article.Content, query),
|
||||
"link": "/hc/" + portalSlug + "/articles/" + article.Slug,
|
||||
}
|
||||
}
|
||||
|
||||
var publicSearchHTMLTagPattern = regexp.MustCompile(`<[^>]*>`)
|
||||
|
||||
func publicSearchArticleSnippet(content, query string) string {
|
||||
plain := publicSearchPlainText(content)
|
||||
if plain == "" {
|
||||
return ""
|
||||
}
|
||||
lowerPlain := strings.ToLower(plain)
|
||||
lowerQuery := strings.ToLower(strings.TrimSpace(query))
|
||||
if lowerQuery != "" {
|
||||
if idx := strings.Index(lowerPlain, lowerQuery); idx >= 0 {
|
||||
start := idx - 110
|
||||
if start < 0 {
|
||||
start = 0
|
||||
}
|
||||
end := idx + len(query) + 110
|
||||
if end > len(plain) {
|
||||
end = len(plain)
|
||||
}
|
||||
snippet := strings.TrimSpace(plain[start:end])
|
||||
if start > 0 {
|
||||
snippet = "..." + snippet
|
||||
}
|
||||
if end < len(plain) {
|
||||
snippet += "..."
|
||||
}
|
||||
return snippet
|
||||
}
|
||||
}
|
||||
return publicSearchTruncate(plain, 220)
|
||||
}
|
||||
|
||||
func publicSearchPlainText(content string) string {
|
||||
text := publicSearchHTMLTagPattern.ReplaceAllString(content, " ")
|
||||
replacements := []string{"#", "*", "_", "`", ">", "[", "]", "(", ")", "!"}
|
||||
for _, old := range replacements {
|
||||
text = strings.ReplaceAll(text, old, " ")
|
||||
}
|
||||
return strings.Join(strings.Fields(text), " ")
|
||||
}
|
||||
|
||||
func publicSearchTruncate(text string, limit int) string {
|
||||
if limit <= 0 || len(text) <= limit {
|
||||
return text
|
||||
}
|
||||
cut := text[:limit]
|
||||
if idx := strings.LastIndex(cut, " "); idx > 0 {
|
||||
cut = cut[:idx]
|
||||
}
|
||||
return strings.TrimSpace(cut) + "..."
|
||||
}
|
||||
|
||||
func publicAssociatedArticlePayloads(articles []model.Article) []gin.H {
|
||||
payload := make([]gin.H, 0, len(articles))
|
||||
for i := range articles {
|
||||
|
||||
@@ -180,6 +180,71 @@ func (s *ArticleHandlerTestSuite) TestSearch_BadRequest_InvalidPortalID() {
|
||||
assert.Equal(s.T(), http.StatusNotFound, w.Code)
|
||||
}
|
||||
|
||||
func (s *ArticleHandlerTestSuite) TestPublicSearch_ReturnsPublishedLocaleSearchArticlePayload() {
|
||||
portal := &model.Portal{AccountID: s.account.ID, Name: "Search Portal", Slug: "search-portal"}
|
||||
s.Require().NoError(s.db.Create(portal).Error)
|
||||
category := &model.Category{AccountID: s.account.ID, PortalID: portal.ID, Name: "Billing", Slug: "billing", Locale: "en"}
|
||||
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: "Billing setup", Slug: "billing-setup", Content: "# Billing\nUse the billing portal to update invoices and cards.", Status: "published", Locale: "en"}).Error)
|
||||
s.Require().NoError(s.db.Create(&model.Article{AccountID: s.account.ID, PortalID: portal.ID, CategoryID: &category.ID, Title: "Billing draft", Slug: "billing-draft", Content: "billing hidden", Status: "draft", Locale: "en"}).Error)
|
||||
s.Require().NoError(s.db.Create(&model.Article{AccountID: s.account.ID, PortalID: portal.ID, CategoryID: &category.ID, Title: "Facturation", Slug: "facturation", Content: "billing french", Status: "published", Locale: "fr"}).Error)
|
||||
|
||||
r := gin.New()
|
||||
r.GET("/hc/:slug/:locale/search", s.handler.PublicSearch)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
req, _ := http.NewRequest("GET", "/hc/search-portal/en/search?query=%20billing%20", nil)
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
assert.Equal(s.T(), http.StatusOK, w.Code)
|
||||
var resp map[string]any
|
||||
s.Require().NoError(json.Unmarshal(w.Body.Bytes(), &resp))
|
||||
payload := resp["payload"].([]any)
|
||||
s.Require().Len(payload, 1)
|
||||
item := payload[0].(map[string]any)
|
||||
assert.Equal(s.T(), "Billing setup", item["title"])
|
||||
assert.Equal(s.T(), "/hc/search-portal/articles/billing-setup", item["link"])
|
||||
assert.EqualValues(s.T(), category.ID, item["category_id"])
|
||||
assert.Contains(s.T(), item["content"], "billing portal")
|
||||
assert.NotContains(s.T(), item, "status")
|
||||
meta := resp["meta"].(map[string]any)
|
||||
assert.EqualValues(s.T(), 1, meta["articles_count"])
|
||||
assert.EqualValues(s.T(), 1, meta["current_page"])
|
||||
}
|
||||
|
||||
func (s *ArticleHandlerTestSuite) TestPublicSearch_EmptyQueryReturnsEmptyPayload() {
|
||||
portal := &model.Portal{AccountID: s.account.ID, Name: "Empty Search Portal", Slug: "empty-search-portal"}
|
||||
s.Require().NoError(s.db.Create(portal).Error)
|
||||
s.Require().NoError(s.db.Create(&model.Article{AccountID: s.account.ID, PortalID: portal.ID, Title: "Published Searchable", Slug: "published-searchable", Content: "searchable", Status: "published", Locale: "en"}).Error)
|
||||
|
||||
r := gin.New()
|
||||
r.GET("/hc/:slug/:locale/search", s.handler.PublicSearch)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
req, _ := http.NewRequest("GET", "/hc/empty-search-portal/en/search?query=%20", nil)
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
assert.Equal(s.T(), http.StatusOK, w.Code)
|
||||
var resp map[string]any
|
||||
s.Require().NoError(json.Unmarshal(w.Body.Bytes(), &resp))
|
||||
assert.Empty(s.T(), resp["payload"].([]any))
|
||||
assert.EqualValues(s.T(), 0, resp["meta"].(map[string]any)["articles_count"])
|
||||
}
|
||||
|
||||
func (s *ArticleHandlerTestSuite) TestPublicSearch_NotFoundForArchivedPortal() {
|
||||
portal := &model.Portal{AccountID: s.account.ID, Name: "Archived Search Portal", Slug: "archived-search-portal", Archived: true}
|
||||
s.Require().NoError(s.db.Create(portal).Error)
|
||||
|
||||
r := gin.New()
|
||||
r.GET("/hc/:slug/:locale/search", s.handler.PublicSearch)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
req, _ := http.NewRequest("GET", "/hc/archived-search-portal/en/search?query=billing", nil)
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
assert.Equal(s.T(), http.StatusNotFound, w.Code)
|
||||
}
|
||||
|
||||
func (s *ArticleHandlerTestSuite) TestStatusCounts_BadRequest_InvalidPortalID() {
|
||||
r := gin.New()
|
||||
r.GET("/api/v1/accounts/:account_id/portals/:portal_id/articles/status_counts", s.handler.StatusCounts)
|
||||
|
||||
Reference in New Issue
Block a user