396 lines
13 KiB
Go
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})
|
|
}
|