package v1 import ( "net/http" "strconv" "github.com/gin-gonic/gin" "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 } // NewArticleHandler creates a new ArticleHandler. func NewArticleHandler(svc *service.ArticleService) *ArticleHandler { return &ArticleHandler{svc: svc} } // Create creates a new article. // POST /portals/:portal_id/articles func (h *ArticleHandler) Create(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 } accountID, err := parseUintParam(c, "account_id") if err != nil { response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid account id") return } // Chatwoot: params.require(:article) → {"article": {...}} var wrapper struct { Article service.CreateArticleRequest `json:"article"` } if err := c.ShouldBindJSON(&wrapper); err != nil { response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrValidation, err.Error()) return } req := wrapper.Article // Extract authorID from auth context header (X-User-ID). // When auth middleware is wired, this will come from c.Get("user_id"). authorIDStr := c.GetHeader("X-User-ID") var authorID uint authorID64, parseErr := strconv.ParseUint(authorIDStr, 10, 64) if parseErr == nil && authorID64 != 0 { authorID = uint(authorID64) } article, err := h.svc.CreateWithAccount(c.Request.Context(), accountID, uint(portalID), authorID, &req) if err != nil { applogger.L().Errorf("Create article: %v", err) response.AbortWithStatusError(c, http.StatusInternalServerError, response.ErrInternal, "failed to create article") return } response.Created(c, 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) { id, err := strconv.ParseUint(c.Param("id"), 10, 64) if err != nil { response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid article id") return } article, err := h.svc.GetByID(c.Request.Context(), uint(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(), uint(id)); err != nil { applogger.L().Warnf("IncrementViews article %d: %v", id, err) } response.OK(c, 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) { id, err := strconv.ParseUint(c.Param("id"), 10, 64) if err != nil { response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid article id") return } article, err := h.svc.GetByID(c.Request.Context(), uint(id)) if err != nil { applogger.L().Errorf("Edit article: %v", err) response.AbortWithStatusError(c, http.StatusNotFound, response.ErrNotFound, "article not found") return } response.OK(c, article) } // Update modifies an existing article. // PUT /portals/:portal_id/articles/:id func (h *ArticleHandler) Update(c *gin.Context) { id, err := strconv.ParseUint(c.Param("id"), 10, 64) if err != nil { response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid article id") return } // Chatwoot: params.require(:article) → {"article": {...}} var wrapper struct { Article service.UpdateArticleRequest `json:"article"` } if err := c.ShouldBindJSON(&wrapper); err != nil { response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrValidation, err.Error()) return } req := wrapper.Article article, err := h.svc.Update(c.Request.Context(), uint(id), &req) if err != nil { applogger.L().Errorf("Update article: %v", err) response.AbortWithStatusError(c, http.StatusInternalServerError, response.ErrInternal, "failed to update article") return } response.OK(c, article) } // Delete removes an article. // DELETE /portals/:portal_id/articles/:id func (h *ArticleHandler) Delete(c *gin.Context) { id, err := strconv.ParseUint(c.Param("id"), 10, 64) if err != nil { response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid article id") return } if err := h.svc.Delete(c.Request.Context(), uint(id)); err != nil { applogger.L().Errorf("Delete article: %v", err) response.AbortWithStatusError(c, http.StatusInternalServerError, response.ErrInternal, "failed to delete article") return } response.NoContent(c) } // List returns all articles for a portal (paginated). // GET /portals/:portal_id/articles func (h *ArticleHandler) List(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 } p := pagination.Parse(c) // If status query param is present, delegate to ListByStatus status := c.Query("status") if status != "" { articles, count, svcErr := h.svc.ListByStatus(c.Request.Context(), uint(portalID), status, p.Page, p.PerPage) if svcErr != nil { applogger.L().Errorf("List articles by status: %v", svcErr) response.AbortWithStatusError(c, http.StatusInternalServerError, response.ErrInternal, "failed to list articles") return } response.OKWithMeta(c, articles, p.Page, p.PerPage, count) return } articles, count, svcErr := h.svc.ListByPortalID(c.Request.Context(), uint(portalID), p.Page, p.PerPage) if svcErr != nil { applogger.L().Errorf("List articles: %v", svcErr) response.AbortWithStatusError(c, http.StatusInternalServerError, response.ErrInternal, "failed to list articles") return } response.OKWithMeta(c, articles, p.Page, p.PerPage, count) } // 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) { portalID, err := strconv.ParseUint(c.Param("portal_id"), 10, 64) if err != nil { response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid portal_id") return } p := pagination.Parse(c) params := repository.ArticleSearchParams{ PortalID: uint(portalID), Query: c.Query("query"), CategorySlug: c.Query("category_slug"), Locale: c.Query("locale"), Status: c.Query("status"), SortBy: c.Query("sort_by"), Offset: p.Offset, Limit: p.PerPage, } // Parse optional author_id authorIDStr := c.Query("author_id") if authorIDStr != "" { aid, parseErr := strconv.ParseUint(authorIDStr, 10, 64) if parseErr != nil { response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid author_id") return } aidUint := uint(aid) params.AuthorID = &aidUint } 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 } response.OKWithMeta(c, articles, p.Page, p.PerPage, count) } // 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) { var req reorderRequest if err := c.ShouldBindJSON(&req); err != nil { response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrValidation, err.Error()) return } // Convert slice to map[uint]int as expected by service positions := make(map[uint]int, len(req.Positions)) for _, entry := range req.Positions { positions[entry.ID] = entry.Position } if err := h.svc.Reorder(c.Request.Context(), positions); err != nil { applogger.L().Errorf("Reorder articles: %v", err) response.AbortWithStatusError(c, http.StatusInternalServerError, response.ErrInternal, "failed to reorder articles") return } response.OK(c, gin.H{"reordered": true}) } // 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) { var req bulkUpdateStatusRequest 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 req.Status == "" { response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "status must not be empty") return } if err := h.svc.BulkUpdateStatus(c.Request.Context(), req.IDs, req.Status); err != nil { applogger.L().Errorf("BulkUpdateStatus articles: %v", err) response.AbortWithStatusError(c, http.StatusInternalServerError, response.ErrInternal, "failed to bulk update status") return } response.OK(c, gin.H{"updated": true}) } // 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) { var req bulkDeleteRequest 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.BulkDelete(c.Request.Context(), req.IDs); err != nil { applogger.L().Errorf("BulkDelete articles: %v", err) response.AbortWithStatusError(c, http.StatusInternalServerError, response.ErrInternal, "failed to bulk delete articles") return } response.OK(c, gin.H{"deleted": true}) } // 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}) }