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: normalizeArticleStatusFilter(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 normalizeArticleStatusFilter(status string) string { switch strings.TrimSpace(status) { case "0": return string(model.ArticleStatusDraft) case "1": return string(model.ArticleStatusPublished) case "2": return string(model.ArticleStatusArchived) default: return status } } 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}) }