package v1 import ( "net/http" "strconv" "strings" "github.com/gin-gonic/gin" "github.com/gochat/gochat/internal/model" "github.com/gochat/gochat/internal/service" applogger "github.com/gochat/gochat/pkg/logger" "github.com/gochat/gochat/pkg/response" ) // CategoryHandler handles Category CRUD endpoints. type CategoryHandler struct { svc *service.CategoryService portalSvc *service.PortalService } func NewCategoryHandler(svc *service.CategoryService, portalSvc ...*service.PortalService) *CategoryHandler { h := &CategoryHandler{svc: svc} if len(portalSvc) > 0 { h.portalSvc = portalSvc[0] } return h } // PublicList returns public help-center categories for a portal. // GET /hc/:slug/:locale/categories.json func (h *CategoryHandler) PublicList(c *gin.Context) { portal, ok := h.resolvePublicPortal(c) if !ok { return } categories, _, err := h.svc.ListByPortalID(c.Request.Context(), portal.ID, "", 0, 0) if err != nil { applogger.L().Errorf("Public list categories: %v", err) response.AbortWithStatusError(c, http.StatusInternalServerError, response.ErrInternal, "failed to list categories") return } payload := make([]gin.H, 0, len(categories)) for i := range categories { payload = append(payload, publicCategoryPayload(&categories[i])) } c.JSON(http.StatusOK, gin.H{"payload": payload}) } // PublicGet returns a public help-center category by slug and locale. // GET /hc/:slug/:locale/categories/:category_slug.json func (h *CategoryHandler) PublicGet(c *gin.Context) { portal, ok := h.resolvePublicPortal(c) if !ok { return } category, err := h.svc.GetByPortalSlugAndLocale(c.Request.Context(), portal.ID, publicCategorySlugParam(c), c.Param("locale")) if err != nil { response.AbortWithStatusError(c, http.StatusNotFound, response.ErrNotFound, "category not found") return } c.JSON(http.StatusOK, publicCategoryPayload(category)) } // Create creates a new category. // POST /api/v1/accounts/:account_id/portals/:portal_id/categories func (h *CategoryHandler) 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.CreateCategoryRequest if err := bindChatwootPayload(c, "category", &req); err != nil { response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrValidation, err.Error()) return } category, err := h.svc.Create(c.Request.Context(), portal.ID, accountID, &req) if err != nil { applogger.L().Errorf("Create category: %v", err) response.AbortWithStatusError(c, http.StatusInternalServerError, response.ErrInternal, "failed to create category") return } c.JSON(http.StatusOK, gin.H{"payload": categoryPayload(category, c.Query("locale"))}) } // Get retrieves a category by ID. // GET /api/v1/accounts/:account_id/portals/:portal_id/categories/:category_id func (h *CategoryHandler) Get(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 } categoryID, err := strconv.ParseUint(c.Param("category_id"), 10, 64) if err != nil { response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid category_id") return } category, err := h.svc.GetByPortalAndID(c.Request.Context(), portal.ID, uint(categoryID)) if err != nil { applogger.L().Errorf("Get category: %v", err) response.AbortWithStatusError(c, http.StatusNotFound, response.ErrNotFound, "category not found") return } c.JSON(http.StatusOK, gin.H{"payload": categoryPayload(category, c.Query("locale"))}) } // Update modifies an existing category. // PUT /api/v1/accounts/:account_id/portals/:portal_id/categories/:category_id func (h *CategoryHandler) Update(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 } categoryID, err := strconv.ParseUint(c.Param("category_id"), 10, 64) if err != nil { response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid category_id") return } var req service.UpdateCategoryRequest if err := bindChatwootPayload(c, "category", &req); err != nil { response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrValidation, err.Error()) return } category, err := h.svc.UpdateScoped(c.Request.Context(), portal.ID, uint(categoryID), &req) if err != nil { applogger.L().Errorf("Update category: %v", err) response.AbortWithStatusError(c, http.StatusUnprocessableEntity, response.ErrInternal, "failed to update category") return } c.JSON(http.StatusOK, gin.H{"payload": categoryPayload(category, c.Query("locale"))}) } // Delete removes a category. // DELETE /api/v1/accounts/:account_id/portals/:portal_id/categories/:category_id func (h *CategoryHandler) Delete(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 } categoryID, err := strconv.ParseUint(c.Param("category_id"), 10, 64) if err != nil { response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid category_id") return } if err := h.svc.DeleteScoped(c.Request.Context(), portal.ID, uint(categoryID)); err != nil { applogger.L().Errorf("Delete category: %v", err) response.AbortWithStatusError(c, http.StatusUnprocessableEntity, response.ErrInternal, "failed to delete category") return } c.Status(http.StatusOK) } // List returns all categories for a portal. // GET /api/v1/accounts/:account_id/portals/:portal_id/categories func (h *CategoryHandler) 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 } locale := c.Query("locale") categories, _, err := h.svc.ListByPortalID(c.Request.Context(), portal.ID, locale, 0, 0) if err != nil { applogger.L().Errorf("List categories: %v", err) response.AbortWithStatusError(c, http.StatusInternalServerError, response.ErrInternal, "failed to list categories") return } payload := make([]gin.H, 0, len(categories)) for i := range categories { payload = append(payload, categoryPayload(&categories[i], locale)) } c.JSON(http.StatusOK, gin.H{"payload": payload, "meta": gin.H{"current_page": c.DefaultQuery("page", "1"), "categories_count": len(categories)}}) } // Reorder updates the display order of categories. // POST /api/v1/accounts/:account_id/portals/:portal_id/categories/reorder func (h *CategoryHandler) Reorder(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 } positions, err := bindCategoryReorder(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 categories: %v", err) response.AbortWithStatusError(c, http.StatusInternalServerError, response.ErrInternal, "failed to reorder categories") return } c.Status(http.StatusOK) } func (h *CategoryHandler) 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 *CategoryHandler) 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 categoryPayload(category *model.Category, currentLocale string) gin.H { if category == nil { return gin.H{} } payload := gin.H{ "id": category.ID, "name": category.Name, "slug": category.Slug, "locale": category.Locale, "description": category.Description, "position": category.Position, "account_id": category.AccountID, "icon": category.Icon, "related_categories": relatedCategoryPayloads(category.RelatedCategories), "meta": gin.H{"articles_count": categoryArticleCount(category.Articles, currentLocale)}, } if category.Parent != nil && category.Parent.ID != 0 { payload["parent_category"] = associatedCategoryPayload(category.Parent) payload["root_category"] = associatedCategoryPayload(category.Parent) } return payload } func publicCategoryPayload(category *model.Category) gin.H { if category == nil { return gin.H{} } return gin.H{ "name": category.Name, "slug": category.Slug, "locale": category.Locale, "description": category.Description, "position": category.Position, "meta": gin.H{"articles_count": categoryPublishedArticleCount(category.Articles)}, } } func relatedCategoryPayloads(related []model.RelatedCategory) []gin.H { payload := make([]gin.H, 0, len(related)) for _, item := range related { if item.RelatedCategory.ID != 0 { payload = append(payload, associatedCategoryPayload(&item.RelatedCategory)) } } return payload } func associatedCategoryPayload(category *model.Category) gin.H { return gin.H{ "id": category.ID, "name": category.Name, "slug": category.Slug, "locale": category.Locale, "description": category.Description, "position": category.Position, "account_id": category.AccountID, } } func categoryArticleCount(articles []model.Article, locale string) int { count := 0 for _, article := range articles { if locale == "" || article.Locale == locale { count++ } } return count } func categoryPublishedArticleCount(articles []model.Article) int { count := 0 for _, article := range articles { if article.Status == string(model.ArticleStatusPublished) { count++ } } return count } func publicCategorySlugParam(c *gin.Context) string { if slug := c.Param("category_slug"); slug != "" { return strings.TrimSuffix(slug, ".json") } return strings.TrimSuffix(c.Param("category_slug.json"), ".json") } func bindCategoryReorder(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 }