Files
gochat/internal/handler/api/v1/article_handler.go
T

396 lines
13 KiB
Go

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})
}