Files
gochat/backend/internal/handler/api/v1/article_handler.go
T
rogee c72e359e48 Phase 3.1: Help Center semantic search with pgvector
- ArticleEmbeddingRepo (new): Upsert, GetByArticleID, DeleteByArticleID,
  SearchByEmbedding using pgvector cosine distance (vector_embedding column)
- ArticleEmbedding model: add VectorEmbedding pgvector.Vector field
  alongside existing JSONB Embedding (backward compatible)
- ArticleService: add SemanticSearch() — generates query embedding via LLM,
  searches articles by cosine similarity; add GenerateEmbedding() — creates
  and stores article embedding from title+description+content
- ArticleHandler: add SemanticSearch endpoint
  GET /portals/:portal_id/articles/semantic_search?query=...
- bootstrap.go: inject articleEmbeddingRepo + llmProvider into ArticleService
- router.go: register /articles/semantic_search route
- migration 000049: add vector(1536) column to article_embeddings table,
  create ivfflat index, migrate existing JSONB data to vector format

Verified: go build + go vet + go test all pass
2026-07-08 15:38:08 +08:00

1105 lines
34 KiB
Go

package v1
import (
"encoding/json"
"errors"
"net/http"
"regexp"
"strconv"
"strings"
"github.com/gin-gonic/gin"
"github.com/gochat/gochat/internal/model"
"github.com/gochat/gochat/internal/repository"
"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"
)
// ArticleHandler handles Article CRUD and Knowledge Base endpoints.
type ArticleHandler struct {
svc *service.ArticleService
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,
Query: query,
CategorySlug: c.Param("category_slug"),
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
}
for i := range articles {
articles[i].Portal = *portal
}
c.JSON(http.StatusOK, gin.H{
"payload": publicArticlePayloads(articles, portal.Slug),
"meta": gin.H{"articles_count": articlesCount, "current_page": page},
})
}
// 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},
})
}
// PublicShow returns a public help-center article by slug.
// GET /hc/:slug/articles/:article_slug
func (h *ArticleHandler) PublicShow(c *gin.Context) {
portal, article, ok := h.resolvePublicArticle(c)
if !ok {
return
}
article.Portal = *portal
c.JSON(http.StatusOK, publicArticlePayload(article, portal.Slug))
}
// PublicArticle dispatches Chatwoot article, markdown, and tracking-pixel suffix paths.
func (h *ArticleHandler) PublicArticle(c *gin.Context) {
slug := publicArticleSlugRaw(c)
switch {
case strings.HasSuffix(slug, ".md"):
h.PublicMarkdown(c)
case strings.HasSuffix(slug, ".png"):
h.PublicTrackingPixel(c)
default:
h.PublicShow(c)
}
}
// PublicMarkdown returns the raw markdown for a published public article.
// GET /hc/:slug/articles/:article_slug.md
func (h *ArticleHandler) PublicMarkdown(c *gin.Context) {
_, article, ok := h.resolvePublicArticle(c)
if !ok {
return
}
if article.Status != string(model.ArticleStatusPublished) {
response.AbortWithStatusError(c, http.StatusNotFound, response.ErrNotFound, "article not found")
return
}
c.Data(http.StatusOK, "text/markdown; charset=utf-8", []byte(article.Content))
}
// PublicTrackingPixel increments views for published articles and serves a 1x1 PNG.
// GET /hc/:slug/articles/:article_slug.png
func (h *ArticleHandler) PublicTrackingPixel(c *gin.Context) {
_, article, ok := h.resolvePublicArticle(c)
if !ok {
return
}
if article.Status == string(model.ArticleStatusPublished) {
if err := h.svc.IncrementViews(c.Request.Context(), article.ID); err != nil {
applogger.L().Warnf("Increment public article pixel views %d: %v", article.ID, err)
}
}
c.Header("Cache-Control", "private, max-age=86400")
c.Data(http.StatusOK, "image/png", publicArticleTrackingPixelPNG)
}
// NewArticleHandler creates a new ArticleHandler.
func NewArticleHandler(svc *service.ArticleService, portalSvc ...*service.PortalService) *ArticleHandler {
h := &ArticleHandler{svc: svc}
if len(portalSvc) > 0 {
h.portalSvc = portalSvc[0]
}
return h
}
// Create creates a new article.
// POST /portals/:portal_id/articles
func (h *ArticleHandler) Create(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
}
var req service.CreateArticleRequest
if err := bindChatwootPayload(c, "article", &req); err != nil {
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrValidation, err.Error())
return
}
article, err := h.svc.CreateWithAccount(c.Request.Context(), accountID, portal.ID, currentUserID(c), &req)
if err != nil {
applogger.L().Errorf("Create article: %v", err)
response.AbortWithStatusError(c, http.StatusInternalServerError, response.ErrInternal, "failed to create article")
return
}
c.JSON(http.StatusOK, gin.H{"payload": articlePayload(article)})
}
// Get retrieves an article by ID and increments its view count.
// GET /portals/:portal_id/articles/:id
func (h *ArticleHandler) Get(c *gin.Context) {
portal, ok := h.resolvePortalForRequest(c)
if !ok {
return
}
id, err := articleIDParam(c)
if err != nil {
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid article id")
return
}
article, err := h.svc.GetByPortalAndID(c.Request.Context(), portal.ID, id)
if err != nil {
applogger.L().Errorf("Get article: %v", err)
response.AbortWithStatusError(c, http.StatusNotFound, response.ErrNotFound, "article not found")
return
}
// Increment views on each read (non-blocking; log error only)
if err := h.svc.IncrementViews(c.Request.Context(), id); err != nil {
applogger.L().Warnf("IncrementViews article %d: %v", id, err)
} else {
article.Views++
}
c.JSON(http.StatusOK, gin.H{"payload": articlePayload(article)})
}
// Edit retrieves an article in edit context (full content without incrementing views).
// GET /portals/:portal_id/articles/:id/edit
func (h *ArticleHandler) Edit(c *gin.Context) {
portal, ok := h.resolvePortalForRequest(c)
if !ok {
return
}
id, err := articleIDParam(c)
if err != nil {
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid article id")
return
}
article, err := h.svc.GetByPortalAndID(c.Request.Context(), portal.ID, id)
if err != nil {
applogger.L().Errorf("Edit article: %v", err)
response.AbortWithStatusError(c, http.StatusNotFound, response.ErrNotFound, "article not found")
return
}
c.JSON(http.StatusOK, gin.H{"payload": articlePayload(article)})
}
// Update modifies an existing article.
// PUT /portals/:portal_id/articles/:id
func (h *ArticleHandler) Update(c *gin.Context) {
portal, ok := h.resolvePortalForRequest(c)
if !ok {
return
}
id, err := articleIDParam(c)
if err != nil {
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid article id")
return
}
var req service.UpdateArticleRequest
if err := bindChatwootPayload(c, "article", &req); err != nil {
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrValidation, err.Error())
return
}
article, err := h.svc.UpdateScoped(c.Request.Context(), portal.ID, id, &req)
if err != nil {
applogger.L().Errorf("Update article: %v", err)
response.AbortWithStatusError(c, http.StatusUnprocessableEntity, response.ErrInternal, "failed to update article")
return
}
c.JSON(http.StatusOK, gin.H{"payload": articlePayload(article)})
}
// Delete removes an article.
// DELETE /portals/:portal_id/articles/:id
func (h *ArticleHandler) Delete(c *gin.Context) {
portal, ok := h.resolvePortalForRequest(c)
if !ok {
return
}
id, err := articleIDParam(c)
if err != nil {
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid article id")
return
}
if err := h.svc.DeleteScoped(c.Request.Context(), portal.ID, id); err != nil {
applogger.L().Errorf("Delete article: %v", err)
response.AbortWithStatusError(c, http.StatusUnprocessableEntity, response.ErrInternal, "failed to delete article")
return
}
c.Status(http.StatusOK)
}
// List returns all articles for a portal (paginated).
// GET /portals/:portal_id/articles
func (h *ArticleHandler) List(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
}
p := pagination.Parse(c)
params, ok := h.articleSearchParams(c, portal.ID, p.Offset, p.PerPage)
if !ok {
return
}
articles, _, svcErr := h.svc.Search(c.Request.Context(), params)
if svcErr != nil {
applogger.L().Errorf("List articles: %v", svcErr)
response.AbortWithStatusError(c, http.StatusInternalServerError, response.ErrInternal, "failed to list articles")
return
}
meta, svcErr := h.svc.ListMeta(c.Request.Context(), params, currentUserID(c))
if svcErr != nil {
applogger.L().Errorf("Article list meta: %v", svcErr)
response.AbortWithStatusError(c, http.StatusInternalServerError, response.ErrInternal, "failed to list articles")
return
}
c.JSON(http.StatusOK, gin.H{"payload": articlePayloads(articles), "meta": articleListMetaPayload(meta, p.Page)})
}
// ListByCategory returns articles for a specific category (paginated).
// GET /portals/:portal_id/categories/:category_id/articles
func (h *ArticleHandler) ListByCategory(c *gin.Context) {
categoryID, err := strconv.ParseUint(c.Param("category_id"), 10, 64)
if err != nil {
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid category_id")
return
}
p := pagination.Parse(c)
articles, count, svcErr := h.svc.ListByCategoryID(c.Request.Context(), uint(categoryID), p.Page, p.PerPage)
if svcErr != nil {
applogger.L().Errorf("List articles by category: %v", svcErr)
response.AbortWithStatusError(c, http.StatusInternalServerError, response.ErrInternal, "failed to list articles")
return
}
response.OKWithMeta(c, articles, p.Page, p.PerPage, count)
}
// Search returns filtered articles based on query parameters.
// GET /portals/:portal_id/articles/search
func (h *ArticleHandler) Search(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
}
p := pagination.Parse(c)
params, ok := h.articleSearchParams(c, portal.ID, p.Offset, p.PerPage)
if !ok {
return
}
articles, count, svcErr := h.svc.Search(c.Request.Context(), params)
if svcErr != nil {
applogger.L().Errorf("Search articles: %v", svcErr)
response.AbortWithStatusError(c, http.StatusInternalServerError, response.ErrInternal, "failed to search articles")
return
}
meta, svcErr := h.svc.ListMeta(c.Request.Context(), params, currentUserID(c))
if svcErr != nil {
applogger.L().Errorf("Article search meta: %v", svcErr)
response.AbortWithStatusError(c, http.StatusInternalServerError, response.ErrInternal, "failed to search articles")
return
}
meta.ArticlesCount = count
c.JSON(http.StatusOK, gin.H{"payload": articlePayloads(articles), "meta": articleListMetaPayload(meta, p.Page)})
}
// SemanticSearch performs AI-powered semantic search on help center articles.
// Uses LLM embeddings + pgvector cosine similarity to find articles by meaning,
// not just keyword matching.
//
// GET /portals/:portal_id/articles/semantic_search?query=how+to+reset+password
func (h *ArticleHandler) SemanticSearch(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
}
query := strings.TrimSpace(c.Query("query"))
if query == "" {
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "query parameter is required")
return
}
limit := 10
if l := c.Query("limit"); l != "" {
if parsed, err := strconv.Atoi(l); err == nil && parsed > 0 && parsed <= 50 {
limit = parsed
}
}
articles, err := h.svc.SemanticSearch(c.Request.Context(), portal.ID, query, limit)
if err != nil {
applogger.L().Errorf("SemanticSearch articles: %v", err)
response.AbortWithStatusError(c, http.StatusInternalServerError, response.ErrInternal, "failed to perform semantic search")
return
}
c.JSON(http.StatusOK, gin.H{"payload": articlePayloads(articles)})
}
// StatusCounts returns article counts grouped by status.
// GET /portals/:portal_id/articles/status_counts
func (h *ArticleHandler) StatusCounts(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
}
counts, svcErr := h.svc.StatusCounts(c.Request.Context(), uint(portalID))
if svcErr != nil {
applogger.L().Errorf("StatusCounts: %v", svcErr)
response.AbortWithStatusError(c, http.StatusInternalServerError, response.ErrInternal, "failed to get status counts")
return
}
response.OK(c, counts)
}
// reorderRequest is the JSON payload for the Reorder endpoint.
type reorderRequest struct {
Positions []positionEntry `json:"positions"`
}
// positionEntry maps an article ID to its new position value.
type positionEntry struct {
ID uint `json:"id"`
Position int `json:"position"`
}
// Reorder batch-updates article positions.
// POST /portals/:portal_id/articles/reorder
func (h *ArticleHandler) Reorder(c *gin.Context) {
portal, ok := h.resolvePortalForRequest(c)
if !ok {
return
}
positions, err := bindArticleReorder(c)
if err != nil {
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrValidation, err.Error())
return
}
if err := h.svc.ReorderScoped(c.Request.Context(), portal.ID, positions); err != nil {
applogger.L().Errorf("Reorder articles: %v", err)
response.AbortWithStatusError(c, http.StatusInternalServerError, response.ErrInternal, "failed to reorder articles")
return
}
c.Status(http.StatusOK)
}
// bulkUpdateStatusRequest is the JSON payload for BulkUpdateStatus.
type bulkUpdateStatusRequest struct {
IDs []uint `json:"ids"`
Status string `json:"status"`
}
// BulkUpdateStatus updates the status of multiple articles at once.
// POST /portals/:portal_id/articles/bulk_update_status
func (h *ArticleHandler) BulkUpdateStatus(c *gin.Context) {
portal, ok := h.resolvePortalForRequest(c)
if !ok {
return
}
var req bulkUpdateStatusRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrValidation, err.Error())
return
}
if err := h.svc.BulkUpdateStatusScoped(c.Request.Context(), portal.ID, req.IDs, req.Status); err != nil {
applogger.L().Errorf("BulkUpdateStatus articles: %v", err)
renderArticleBulkError(c, err)
return
}
c.Status(http.StatusOK)
}
type bulkUpdateCategoryRequest struct {
IDs []uint `json:"ids"`
CategoryID uint `json:"category_id"`
}
func (h *ArticleHandler) BulkUpdateCategory(c *gin.Context) {
portal, ok := h.resolvePortalForRequest(c)
if !ok {
return
}
var req bulkUpdateCategoryRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrValidation, err.Error())
return
}
if err := h.svc.BulkUpdateCategoryScoped(c.Request.Context(), portal.ID, req.IDs, req.CategoryID); err != nil {
applogger.L().Errorf("BulkUpdateCategory articles: %v", err)
renderArticleBulkError(c, err)
return
}
c.Status(http.StatusOK)
}
// bulkDeleteRequest is the JSON payload for BulkDelete.
type bulkDeleteRequest struct {
IDs []uint `json:"ids"`
}
// BulkDelete deletes multiple articles at once.
// POST /portals/:portal_id/articles/bulk_delete
func (h *ArticleHandler) BulkDelete(c *gin.Context) {
portal, ok := h.resolvePortalForRequest(c)
if !ok {
return
}
var req bulkDeleteRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrValidation, err.Error())
return
}
if err := h.svc.BulkDeleteScoped(c.Request.Context(), portal.ID, req.IDs); err != nil {
applogger.L().Errorf("BulkDelete articles: %v", err)
renderArticleBulkError(c, err)
return
}
c.Status(http.StatusOK)
}
func (h *ArticleHandler) BulkTranslate(c *gin.Context) {
portal, ok := h.resolvePortalForRequest(c)
if !ok {
return
}
var req service.BulkTranslateRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrValidation, err.Error())
return
}
if err := h.svc.BulkTranslate(c.Request.Context(), portal.AccountID, portal, currentUserID(c), req); err != nil {
var conflict *service.ArticleTranslationConflictError
if errors.As(err, &conflict) {
c.JSON(http.StatusConflict, gin.H{"duplicate_articles": conflict.Duplicates})
return
}
renderArticleBulkError(c, err)
return
}
c.Status(http.StatusOK)
}
// BulkActions performs a bulk operation (publish/archive/delete) on multiple articles.
// POST /api/v1/accounts/:account_id/portals/:portal_id/articles/bulk_actions
func (h *ArticleHandler) BulkActions(c *gin.Context) {
var req service.BulkActionsRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrValidation, err.Error())
return
}
if len(req.IDs) == 0 {
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "ids must not be empty")
return
}
if err := h.svc.BulkActions(c.Request.Context(), &req); err != nil {
applogger.L().Errorf("BulkActions articles: %v", err)
response.AbortWithStatusError(c, http.StatusInternalServerError, response.ErrInternal, "failed to perform bulk action")
return
}
response.OK(c, gin.H{"action": req.Action, "ids": req.IDs})
}
func (h *ArticleHandler) resolvePortalForRequest(c *gin.Context) (*model.Portal, bool) {
accountID := getAccountID(c)
if accountID == 0 {
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid account_id")
return nil, false
}
return h.resolvePortal(c, accountID)
}
func (h *ArticleHandler) 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 (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 (h *ArticleHandler) resolvePublicArticle(c *gin.Context) (*model.Portal, *model.Article, bool) {
portal, ok := h.resolvePublicPortal(c)
if !ok {
return nil, nil, false
}
slug := publicArticleSlugParam(c)
article, err := h.svc.GetByPortalAndSlug(c.Request.Context(), portal.ID, slug)
if err != nil {
response.AbortWithStatusError(c, http.StatusNotFound, response.ErrNotFound, "article not found")
return nil, nil, false
}
return portal, article, true
}
func publicArticleSlugParam(c *gin.Context) string {
value := publicArticleSlugRaw(c)
return strings.TrimSuffix(strings.TrimSuffix(value, ".md"), ".png")
}
func publicArticleSlugRaw(c *gin.Context) string {
for _, key := range []string{"article_slug", "article_slug.md", "article_slug.png"} {
if value := c.Param(key); value != "" {
return value
}
}
return ""
}
var publicArticleTrackingPixelPNG = []byte{
0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a,
0x00, 0x00, 0x00, 0x0d, 0x49, 0x48, 0x44, 0x52,
0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01,
0x08, 0x06, 0x00, 0x00, 0x00, 0x1f, 0x15, 0xc4,
0x89, 0x00, 0x00, 0x00, 0x0a, 0x49, 0x44, 0x41,
0x54, 0x78, 0x9c, 0x63, 0x00, 0x01, 0x00, 0x00,
0x05, 0x00, 0x01, 0x0d, 0x0a, 0x2d, 0xb4, 0x00,
0x00, 0x00, 0x00, 0x49, 0x45, 0x4e, 0x44, 0xae,
0x42, 0x60, 0x82,
}
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 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,
Query: c.Query("query"),
CategorySlug: c.Query("category_slug"),
Locale: c.Query("locale"),
Status: c.Query("status"),
SortBy: c.Query("sort_by"),
Offset: offset,
Limit: limit,
}
if params.SortBy == "" {
params.SortBy = c.Query("sort")
}
if authorIDStr := c.Query("author_id"); authorIDStr != "" {
aid, parseErr := strconv.ParseUint(authorIDStr, 10, 64)
if parseErr != nil {
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid author_id")
return params, false
}
aidUint := uint(aid)
params.AuthorID = &aidUint
}
return params, true
}
func articleIDParam(c *gin.Context) (uint, error) {
if id, err := parseUintAnyParam(c, "article_id", "id"); err == nil && id != 0 {
return id, nil
} else if err != nil {
return 0, err
}
return 0, strconv.ErrSyntax
}
func bindArticleReorder(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
}
func articlePayloads(articles []model.Article) []gin.H {
payload := make([]gin.H, 0, len(articles))
for i := range articles {
payload = append(payload, articlePayload(&articles[i]))
}
return payload
}
func articlePayload(article *model.Article) gin.H {
if article == nil {
return gin.H{}
}
payload := gin.H{
"id": article.ID,
"slug": article.Slug,
"title": article.Title,
"content": article.Content,
"description": article.Description,
"status": article.Status,
"position": article.Position,
"account_id": article.AccountID,
"updated_at": article.UpdatedAt.Unix(),
"meta": articleMetaPayload(article.Meta),
"category": articleCategoryPayload(article),
"views": article.Views,
"associated_articles": associatedArticlePayloads(article.AssociatedArticles),
}
if article.Author != nil && article.Author.ID != 0 {
payload["author"] = serializeAgentUser(article.Author, article.AccountID, article.Author.Role, "", false, 0)
}
return payload
}
func articleCategoryPayload(article *model.Article) gin.H {
payload := gin.H{"id": article.CategoryID, "name": nil, "slug": nil, "locale": nil}
if article.Category != nil {
payload["name"] = article.Category.Name
payload["slug"] = article.Category.Slug
payload["locale"] = article.Category.Locale
}
return payload
}
func associatedArticlePayloads(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,
"views": article.Views,
}
if article.Portal.ID != 0 {
item["portal"] = portalPayload(&article.Portal, article.Locale, 0)
}
if article.Author != nil && article.Author.ID != 0 {
item["author"] = serializeAgentUser(article.Author, article.AccountID, article.Author.Role, "", false, 0)
}
payload = append(payload, item)
}
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 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 {
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{}
}
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 {
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,
"archived_articles_count": meta.ArchivedArticlesCount,
"articles_count": meta.ArticlesCount,
"current_page": currentPage,
"draft_articles_count": meta.DraftArticlesCount,
"mine_articles_count": meta.MineArticlesCount,
"published_count": meta.PublishedCount,
}
}
func articleMetaPayload(raw json.RawMessage) map[string]any {
if len(raw) == 0 {
return map[string]any{}
}
var obj map[string]any
if err := json.Unmarshal(raw, &obj); err != nil || obj == nil {
return map[string]any{}
}
return obj
}
func renderArticleBulkError(c *gin.Context, err error) {
message := "failed to update articles"
switch {
case errors.Is(err, service.ErrArticleBulkNoArticles):
message = "No articles found"
case errors.Is(err, service.ErrArticleBulkInvalidStatus):
message = "Invalid status"
case errors.Is(err, service.ErrArticleBulkCategoryNotFound):
message = "Category not found"
case errors.Is(err, service.ErrArticleBulkCaptainNotAvailable):
message = "Captain is not available"
case errors.Is(err, service.ErrArticleBulkLocaleNotAvailable):
message = "Locale is not available"
default:
message = err.Error()
}
c.JSON(http.StatusUnprocessableEntity, gin.H{"error": message})
}