Add Company Category Management Functionality and Update API Documentation

- Introduced a new company category management feature, including CRUD operations for categories in both admin and public API endpoints.
- Implemented category entity, service, repository, and handler layers following clean architecture principles.
- Added new API routes for searching, retrieving, updating, deleting, and toggling publish status of categories, enhancing the overall functionality of the system.
- Updated Swagger and YAML documentation to include detailed descriptions and examples for the new category endpoints, ensuring clarity for API consumers.
- Enhanced error handling for duplicate categories and validation, improving the robustness of the category management process.
- Updated existing routes to integrate category handling, ensuring a seamless user experience across the application.
This commit is contained in:
n.nakhostin
2025-09-08 12:31:49 +03:30
parent 617547def8
commit 991771ee19
10 changed files with 2156 additions and 7 deletions
+46
View File
@@ -0,0 +1,46 @@
package company_category
import (
"tm/pkg/mongo"
"go.mongodb.org/mongo-driver/bson/primitive"
)
type Category struct {
mongo.Model `bson:",inline"`
Name string `bson:"name" json:"name"`
Description *string `bson:"description" json:"description"`
Thumbnail *string `bson:"thumbnail" json:"thumbnail"`
Published bool `bson:"published" json:"published"`
PublishedAt *int64 `bson:"published_at" json:"published_at"`
}
// SetID sets the category ID (implements IDSetter interface)
func (c *Category) SetID(id string) {
c.ID, _ = primitive.ObjectIDFromHex(id)
}
// GetID returns the category ID (implements IDGetter interface)
func (c *Category) GetID() string {
return c.ID.Hex()
}
// SetCreatedAt sets the created timestamp (implements Timestamp interface)
func (c *Category) SetCreatedAt(timestamp int64) {
c.CreatedAt = timestamp
}
// SetUpdatedAt sets the updated timestamp (implements Timestamp interface)
func (c *Category) SetUpdatedAt(timestamp int64) {
c.UpdatedAt = timestamp
}
// GetCreatedAt returns the created timestamp (implements Timestamp interface)
func (c *Category) GetCreatedAt() int64 {
return c.CreatedAt
}
// GetUpdatedAt returns the updated timestamp (implements Timestamp interface)
func (c *Category) GetUpdatedAt() int64 {
return c.UpdatedAt
}
+52
View File
@@ -0,0 +1,52 @@
package company_category
import "tm/pkg/response"
// CategoryForm represents the form for creating/updating a category
type CategoryForm struct {
Name string `json:"name" valid:"required,length(2|100)" example:"Technology"`
Description *string `json:"description,omitempty" valid:"optional,length(10|500)" example:"Technology related companies"`
Thumbnail *string `json:"thumbnail,omitempty" valid:"optional,url" example:"https://example.com/thumbnail.jpg"`
}
// SearchForm represents the form for searching categories with filters
type SearchForm struct {
Search *string `query:"q" valid:"optional"`
Published *bool `query:"published" valid:"optional"`
Limit *int `query:"limit" valid:"optional,range(1|100)"`
Offset *int `query:"offset" valid:"optional,min(0)"`
SortBy *string `query:"sort_by" valid:"optional,in(name|created_at|updated_at|published_at)"`
SortOrder *string `query:"sort_order" valid:"optional,in(asc|desc)"`
}
// CategoryListResponse represents the response for listing categories
type CategoryListResponse struct {
Categories []*CategoryResponse `json:"categories"`
Meta *response.Meta `json:"-"`
}
// CategoryResponse represents the category data sent in API responses
type CategoryResponse struct {
ID string `json:"id"`
Name string `json:"name"`
Description *string `json:"description,omitempty"`
Thumbnail *string `json:"thumbnail,omitempty"`
Published bool `json:"published"`
PublishedAt *int64 `json:"published_at,omitempty"`
CreatedAt int64 `json:"created_at"`
UpdatedAt int64 `json:"updated_at"`
}
// ToResponse converts Category to CategoryResponse
func (c *Category) ToResponse() *CategoryResponse {
return &CategoryResponse{
ID: c.ID.Hex(),
Name: c.Name,
Description: c.Description,
Thumbnail: c.Thumbnail,
Published: c.Published,
PublishedAt: c.PublishedAt,
CreatedAt: c.CreatedAt,
UpdatedAt: c.UpdatedAt,
}
}
+213
View File
@@ -0,0 +1,213 @@
package company_category
import (
"tm/pkg/logger"
"tm/pkg/response"
"github.com/labstack/echo/v4"
)
// Handler handles HTTP requests for category operations
type Handler struct {
service Service
logger logger.Logger
}
// NewHandler creates a new category handler
func NewHandler(service Service, logger logger.Logger) *Handler {
return &Handler{
service: service,
logger: logger,
}
}
// Create creates a new category (Web Panel)
// @Summary Create a new category
// @Description Create a new category with name, description, and thumbnail
// @Tags Admin-Company-Categories
// @Accept json
// @Produce json
// @Param category body CategoryForm true "Category information"
// @Success 201 {object} response.APIResponse{data=CategoryResponse} "Category created successfully"
// @Failure 400 {object} response.APIResponse "Bad request - Invalid input data"
// @Failure 409 {object} response.APIResponse "Conflict - Category with this name already exists"
// @Failure 422 {object} response.APIResponse "Validation error - Invalid request data"
// @Failure 500 {object} response.APIResponse "Internal server error"
// @Security BearerAuth
// @Router /admin/v1/company-categories [post]
func (h *Handler) Create(c echo.Context) error {
form, err := response.Parse[CategoryForm](c)
if err != nil {
return response.ValidationError(c, "Invalid request data", err.Error())
}
category, err := h.service.Create(c.Request().Context(), form)
if err != nil {
if err.Error() == "category with this name already exists" {
return response.Conflict(c, err.Error())
}
return response.InternalServerError(c, "Failed to create category")
}
return response.Created(c, category, "Category created successfully")
}
// GetCategory retrieves a category by ID (Web Panel)
// @Summary Get category by ID
// @Description Retrieve detailed category information by category ID
// @Tags Admin-Company-Categories
// @Accept json
// @Produce json
// @Param id path string true "Category ID"
// @Success 200 {object} response.APIResponse{data=CategoryResponse} "Category retrieved successfully"
// @Failure 400 {object} response.APIResponse "Bad request - Invalid category ID"
// @Failure 404 {object} response.APIResponse "Not found - Category not found"
// @Failure 500 {object} response.APIResponse "Internal server error"
// @Security BearerAuth
// @Router /admin/v1/company-categories/{id} [get]
func (h *Handler) Get(c echo.Context) error {
id := c.Param("id")
category, err := h.service.GetByID(c.Request().Context(), id)
if err != nil {
if err.Error() == "category not found" {
return response.NotFound(c, "Category not found")
}
return response.InternalServerError(c, "Failed to get category")
}
return response.Success(c, category, "Category retrieved successfully")
}
// Update updates a category (Web Panel)
// @Summary Update category information
// @Description Update category information including name, description, and thumbnail
// @Tags Admin-Company-Categories
// @Accept json
// @Produce json
// @Param id path string true "Category ID"
// @Param category body CategoryForm true "Category update information"
// @Success 200 {object} response.APIResponse{data=CategoryResponse} "Category updated successfully"
// @Failure 400 {object} response.APIResponse "Bad request - Invalid input data"
// @Failure 404 {object} response.APIResponse "Not found - Category not found"
// @Failure 409 {object} response.APIResponse "Conflict - Category with this name already exists"
// @Failure 422 {object} response.APIResponse "Validation error - Invalid request data"
// @Failure 500 {object} response.APIResponse "Internal server error"
// @Security BearerAuth
// @Router /admin/v1/company-categories/{id} [put]
func (h *Handler) Update(c echo.Context) error {
id := c.Param("id")
form, err := response.Parse[CategoryForm](c)
if err != nil {
return response.ValidationError(c, "Invalid request data", err.Error())
}
category, err := h.service.Update(c.Request().Context(), id, form)
if err != nil {
if err.Error() == "category not found" {
return response.NotFound(c, "Category not found")
}
if err.Error() == "category with this name already exists" {
return response.Conflict(c, err.Error())
}
return response.InternalServerError(c, "Failed to update category")
}
return response.Success(c, category, "Category updated successfully")
}
// Delete deletes a category (Web Panel)
// @Summary Delete category
// @Description Delete a category permanently
// @Tags Admin-Company-Categories
// @Accept json
// @Produce json
// @Param id path string true "Category ID"
// @Success 200 {object} response.APIResponse "Category deleted successfully"
// @Failure 400 {object} response.APIResponse "Bad request - Invalid category ID"
// @Failure 404 {object} response.APIResponse "Not found - Category not found"
// @Failure 500 {object} response.APIResponse "Internal server error"
// @Security BearerAuth
// @Router /admin/v1/company-categories/{id} [delete]
func (h *Handler) Delete(c echo.Context) error {
id := c.Param("id")
err := h.service.Delete(c.Request().Context(), id)
if err != nil {
if err.Error() == "category not found" {
return response.NotFound(c, "Category not found")
}
return response.InternalServerError(c, "Failed to delete category")
}
return response.Success(c, map[string]interface{}{
"message": "Category deleted successfully",
}, "Category deleted successfully")
}
// Search searches categories with filters (Web Panel)
// @Summary Search categories
// @Description Search categories with filtering capabilities
// @Tags Admin-Company-Categories
// @Accept json
// @Produce json
// @Param q query string false "Search query"
// @Param published query boolean false "Published status filter"
// @Param limit query integer false "Limit" default(20)
// @Param offset query integer false "Offset" default(0)
// @Param sort_by query string false "Sort by field" Enums(name,created_at,updated_at,published_at)
// @Param sort_order query string false "Sort order" Enums(asc,desc)
// @Success 200 {object} response.APIResponse{data=CategoryListResponse} "Categories search completed"
// @Failure 400 {object} response.APIResponse "Bad request - Invalid query parameters"
// @Failure 422 {object} response.APIResponse "Validation error - Invalid query parameters"
// @Failure 500 {object} response.APIResponse "Internal server error"
// @Security BearerAuth
// @Router /admin/v1/company-categories [get]
func (h *Handler) Search(c echo.Context) error {
form, err := response.Parse[SearchForm](c)
if err != nil {
return response.ValidationError(c, "Invalid request data", err.Error())
}
result, err := h.service.SearchCategories(c.Request().Context(), form, response.NewPagination(c))
if err != nil {
return response.InternalServerError(c, "Failed to search categories")
}
return response.SuccessWithMeta(c, result, result.Meta, "Categories retrieved successfully")
}
// TogglePublish toggles category publish status (Web Panel)
// @Summary Toggle category publish status
// @Description Toggle category publish/unpublish status
// @Tags Admin-Company-Categories
// @Accept json
// @Produce json
// @Param id path string true "Category ID"
// @Success 200 {object} response.APIResponse "Category publish status updated successfully"
// @Failure 400 {object} response.APIResponse "Bad request - Invalid input data"
// @Failure 404 {object} response.APIResponse "Not found - Category not found"
// @Failure 422 {object} response.APIResponse "Validation error - Invalid request data"
// @Failure 500 {object} response.APIResponse "Internal server error"
// @Security BearerAuth
// @Router /admin/v1/company-categories/{id}/publish [patch]
func (h *Handler) TogglePublish(c echo.Context) error {
id := c.Param("id")
published, err := h.service.TogglePublish(c.Request().Context(), id)
if err != nil {
if err.Error() == "category not found" {
return response.NotFound(c, "Category not found")
}
return response.InternalServerError(c, "Failed to toggle category publish status")
}
return response.Success(
c,
map[string]interface{}{
"message": "Category publish status updated successfully",
"published": published,
},
"Category publish status updated successfully",
)
}
+255
View File
@@ -0,0 +1,255 @@
package company_category
import (
"context"
"errors"
"time"
"tm/pkg/logger"
orm "tm/pkg/mongo"
"tm/pkg/response"
"go.mongodb.org/mongo-driver/bson"
)
// Repository defines the interface for category data operations
type Repository interface {
Create(ctx context.Context, category *Category) error
Update(ctx context.Context, category *Category) error
GetByID(ctx context.Context, id string) (*Category, error)
GetByIDs(ctx context.Context, ids []string) ([]Category, error)
GetByName(ctx context.Context, name string) (*Category, error)
Search(ctx context.Context, form *SearchForm, pagination *response.Pagination) ([]Category, int64, error)
Delete(ctx context.Context, id string) error
TogglePublish(ctx context.Context, id string) (bool, error)
}
// categoryRepository implements the Repository interface using the MongoDB ORM
type categoryRepository struct {
ormRepo orm.Repository[Category]
logger logger.Logger
}
// NewCategoryRepository creates a new category repository
func NewCategoryRepository(mongoManager *orm.ConnectionManager, logger logger.Logger) Repository {
// Create indexes using the ORM's index management
indexes := []orm.Index{
*orm.NewIndex("name_idx", bson.D{{Key: "name", Value: 1}}),
*orm.NewIndex("published_idx", bson.D{{Key: "published", Value: 1}}),
}
// Create indexes
err := mongoManager.CreateIndexes("company_categories", indexes)
if err != nil {
logger.Warn("Failed to create category indexes", map[string]interface{}{
"error": err.Error(),
})
}
// Create ORM repository
ormRepo := orm.NewRepository[Category](mongoManager.GetCollection("company_categories"), logger)
return &categoryRepository{
ormRepo: ormRepo,
logger: logger,
}
}
// Create creates a new category
func (r *categoryRepository) Create(ctx context.Context, category *Category) error {
// Set created/updated timestamps using Unix timestamps
now := time.Now().Unix()
category.SetCreatedAt(now)
// Use ORM to create category
err := r.ormRepo.Create(ctx, category)
if err != nil {
r.logger.Error("Failed to create category", map[string]interface{}{
"error": err.Error(),
"name": category.Name,
})
return err
}
r.logger.Info("Category created successfully", map[string]interface{}{
"category_id": category.ID,
"name": category.Name,
"published": category.Published,
})
return nil
}
// GetByID retrieves a category by ID
func (r *categoryRepository) GetByID(ctx context.Context, id string) (*Category, error) {
category, err := r.ormRepo.FindByID(ctx, id)
if err != nil {
if errors.Is(err, orm.ErrDocumentNotFound) {
return nil, errors.New("category not found")
}
r.logger.Error("Failed to get category by ID", map[string]interface{}{
"error": err.Error(),
"category_id": id,
})
return nil, err
}
return category, nil
}
// GetByIDs retrieves categories by IDs
func (r *categoryRepository) GetByIDs(ctx context.Context, ids []string) ([]Category, error) {
filter := bson.M{"_id": bson.M{"$in": ids}}
paginationBuilder := orm.NewPaginationBuilder().
Build()
result, err := r.ormRepo.FindAll(ctx, filter, paginationBuilder)
if err != nil {
return nil, err
}
return result.Items, nil
}
// GetByName retrieves a category by name
func (r *categoryRepository) GetByName(ctx context.Context, name string) (*Category, error) {
category, err := r.ormRepo.FindOne(ctx, bson.M{"name": name})
if err != nil {
return nil, err
}
return category, nil
}
// Update updates a category
func (r *categoryRepository) Update(ctx context.Context, category *Category) error {
// Set updated timestamp using Unix timestamp
category.SetUpdatedAt(time.Now().Unix())
// Use ORM to update category
err := r.ormRepo.Update(ctx, category)
if err != nil {
r.logger.Error("Failed to update category", map[string]interface{}{
"error": err.Error(),
"category_id": category.ID,
})
return err
}
r.logger.Info("Category updated successfully", map[string]interface{}{
"category_id": category.ID,
"name": category.Name,
})
return nil
}
// Delete deletes a category
func (r *categoryRepository) Delete(ctx context.Context, id string) error {
// Get category first
_, err := r.GetByID(ctx, id)
if err != nil {
return err
}
// Delete from database
err = r.ormRepo.Delete(ctx, id)
if err != nil {
r.logger.Error("Failed to delete category", map[string]interface{}{
"error": err.Error(),
"category_id": id,
})
return err
}
r.logger.Info("Category deleted successfully", map[string]interface{}{
"category_id": id,
})
return nil
}
// Search retrieves categories with search and filters
func (r *categoryRepository) Search(ctx context.Context, form *SearchForm, pagination *response.Pagination) ([]Category, int64, error) {
// Build filter
filter := bson.M{}
if form.Search != nil && *form.Search != "" {
// Use regex search for case-insensitive partial matching
searchRegex := bson.M{
"$regex": *form.Search,
"$options": "i", // case-insensitive
}
filter["$or"] = []bson.M{
{"name": searchRegex},
{"description": searchRegex},
}
}
if form.Published != nil {
filter["published"] = *form.Published
}
// Build sort
sort := "created_at"
if pagination.SortBy != "" {
sort = pagination.SortBy
}
sortOrder := "desc"
if pagination.SortOrder != "" {
sortOrder = pagination.SortOrder
}
// Build pagination
paginationBuilder := orm.NewPaginationBuilder().
Limit(pagination.Limit).
Skip(pagination.Offset).
SortBy(sort, sortOrder).
Build()
// Use ORM to find categories
result, err := r.ormRepo.FindAll(ctx, filter, paginationBuilder)
if err != nil {
r.logger.Error("Failed to search categories", map[string]interface{}{
"error": err.Error(),
})
return nil, 0, err
}
return result.Items, result.TotalCount, nil
}
// TogglePublish toggles category publish status
func (r *categoryRepository) TogglePublish(ctx context.Context, id string) (bool, error) {
// Get category first
category, err := r.GetByID(ctx, id)
if err != nil {
return false, err
}
// Update publish status
category.Published = !category.Published
now := time.Now().Unix()
category.SetUpdatedAt(now)
// Set published_at timestamp if publishing
category.PublishedAt = &now
// Update in database
err = r.ormRepo.Update(ctx, category)
if err != nil {
r.logger.Error("Failed to toggle category publish status", map[string]interface{}{
"error": err.Error(),
"category_id": id,
"published": category.Published,
})
return false, err
}
r.logger.Info("Category publish status updated successfully", map[string]interface{}{
"category_id": id,
"published": category.Published,
})
return category.Published, nil
}
+224
View File
@@ -0,0 +1,224 @@
package company_category
import (
"context"
"errors"
"tm/pkg/logger"
"tm/pkg/response"
)
// Service defines business logic for category operations
type Service interface {
// Core category operations
Create(ctx context.Context, form *CategoryForm) (*CategoryResponse, error)
Update(ctx context.Context, id string, form *CategoryForm) (*CategoryResponse, error)
GetByID(ctx context.Context, id string) (*CategoryResponse, error)
GetByIDs(ctx context.Context, ids []string) ([]*CategoryResponse, error)
Delete(ctx context.Context, id string) error
// Category listing and search
SearchCategories(ctx context.Context, form *SearchForm, pagination *response.Pagination) (*CategoryListResponse, error)
// Category management
TogglePublish(ctx context.Context, id string) (bool, error)
}
// categoryService implements the Service interface
type categoryService struct {
repository Repository
logger logger.Logger
}
// NewCategoryService creates a new category service
func NewCategoryService(repository Repository, logger logger.Logger) Service {
return &categoryService{
repository: repository,
logger: logger,
}
}
// Create creates a new category
func (s *categoryService) Create(ctx context.Context, form *CategoryForm) (*CategoryResponse, error) {
// Check if category name already exists
existingCategory, _ := s.repository.GetByName(ctx, form.Name)
if existingCategory != nil {
return nil, errors.New("category with this name already exists")
}
// Create category
category := &Category{
Name: form.Name,
Description: form.Description,
Thumbnail: form.Thumbnail,
Published: false, // Default to unpublished
}
// Save to database
err := s.repository.Create(ctx, category)
if err != nil {
s.logger.Error("Failed to create category", map[string]interface{}{
"error": err.Error(),
"name": form.Name,
})
return nil, err
}
s.logger.Info("Category created successfully", map[string]interface{}{
"category_id": category.ID,
"name": category.Name,
"published": category.Published,
})
return category.ToResponse(), nil
}
// GetByIDs retrieves categories by IDs
func (s *categoryService) GetByIDs(ctx context.Context, ids []string) ([]*CategoryResponse, error) {
categories, err := s.repository.GetByIDs(ctx, ids)
if err != nil {
return nil, err
}
var categoryResponses []*CategoryResponse
for _, category := range categories {
categoryResponses = append(categoryResponses, category.ToResponse())
}
return categoryResponses, nil
}
// GetByID retrieves a category by ID
func (s *categoryService) GetByID(ctx context.Context, id string) (*CategoryResponse, error) {
category, err := s.repository.GetByID(ctx, id)
if err != nil {
s.logger.Error("Failed to get category by ID", map[string]interface{}{
"error": err.Error(),
"category_id": id,
})
return nil, err
}
s.logger.Info("Category retrieved successfully", map[string]interface{}{
"category_id": category.ID,
"name": category.Name,
})
return category.ToResponse(), nil
}
// Update updates a category
func (s *categoryService) Update(ctx context.Context, id string, form *CategoryForm) (*CategoryResponse, error) {
// Get existing category
category, err := s.repository.GetByID(ctx, id)
if err != nil {
s.logger.Error("Failed to get category for update", map[string]interface{}{
"error": err.Error(),
"category_id": id,
})
return nil, errors.New("category not found")
}
// Check if name already exists (if changing name)
if form.Name != "" && form.Name != category.Name {
existingCategory, _ := s.repository.GetByName(ctx, form.Name)
if existingCategory != nil {
return nil, errors.New("category with this name already exists")
}
category.Name = form.Name
}
// Update fields if provided
if form.Description != nil {
category.Description = form.Description
}
if form.Thumbnail != nil {
category.Thumbnail = form.Thumbnail
}
// Update in database
err = s.repository.Update(ctx, category)
if err != nil {
s.logger.Error("Failed to update category", map[string]interface{}{
"error": err.Error(),
"category_id": id,
})
return nil, errors.New("failed to update category")
}
s.logger.Info("Category updated successfully", map[string]interface{}{
"category_id": category.ID,
"name": category.Name,
})
return category.ToResponse(), nil
}
// Delete deletes a category
func (s *categoryService) Delete(ctx context.Context, id string) error {
// Delete category
err := s.repository.Delete(ctx, id)
if err != nil {
s.logger.Error("Failed to delete category", map[string]interface{}{
"error": err.Error(),
"category_id": id,
})
return errors.New("failed to delete category")
}
s.logger.Info("Category deleted successfully", map[string]interface{}{
"category_id": id,
})
return nil
}
// SearchCategories searches categories with filters
func (s *categoryService) SearchCategories(ctx context.Context, form *SearchForm, pagination *response.Pagination) (*CategoryListResponse, error) {
// Get categories using search
categories, total, err := s.repository.Search(ctx, form, pagination)
if err != nil {
s.logger.Error("Failed to search categories", map[string]interface{}{
"error": err.Error(),
})
return nil, errors.New("failed to search categories")
}
// Convert to responses
var categoryResponses []*CategoryResponse
for _, category := range categories {
categoryResponses = append(categoryResponses, category.ToResponse())
}
return &CategoryListResponse{
Categories: categoryResponses,
Meta: pagination.Response(total),
}, nil
}
// TogglePublish toggles category publish status
func (s *categoryService) TogglePublish(ctx context.Context, id string) (bool, error) {
// Get category to check if exists
_, err := s.repository.GetByID(ctx, id)
if err != nil {
return false, errors.New("category not found")
}
// Toggle publish status
published, err := s.repository.TogglePublish(ctx, id)
if err != nil {
s.logger.Error("Failed to toggle category publish status", map[string]interface{}{
"error": err.Error(),
"category_id": id,
})
return false, errors.New("failed to toggle category publish status")
}
s.logger.Info("Category publish status updated successfully", map[string]interface{}{
"category_id": id,
"published": published,
})
return published, nil
}