Files
Novault-backend/internal/service/category_service.go
2026-01-25 21:59:00 +08:00

314 lines
9.8 KiB
Go

package service
import (
"errors"
"fmt"
"accounting-app/internal/models"
"accounting-app/internal/repository"
)
// Category service errors
var (
ErrCategoryNotFound = errors.New("category not found")
ErrCategoryInUse = errors.New("category is in use and cannot be deleted")
ErrCategoryHasChildren = errors.New("category has children and cannot be deleted")
ErrInvalidParentCategory = errors.New("invalid parent category")
ErrParentTypeMismatch = errors.New("parent category type must match child category type")
ErrCircularReference = errors.New("circular reference detected in category hierarchy")
ErrParentIsChild = errors.New("cannot set a child category as parent")
)
// CategoryInput represents the input data for creating or updating a category
type CategoryInput struct {
UserID uint `json:"user_id"`
Name string `json:"name" binding:"required"`
Icon string `json:"icon"`
Type models.CategoryType `json:"type" binding:"required"`
ParentID *uint `json:"parent_id,omitempty"`
SortOrder int `json:"sort_order"`
}
// CategoryService handles business logic for categories
type CategoryService struct {
repo *repository.CategoryRepository
}
// NewCategoryService creates a new CategoryService instance
func NewCategoryService(repo *repository.CategoryRepository) *CategoryService {
return &CategoryService{
repo: repo,
}
}
// CreateCategory creates a new category with business logic validation
func (s *CategoryService) CreateCategory(input CategoryInput) (*models.Category, error) {
// Validate parent category if provided
if input.ParentID != nil {
parent, err := s.repo.GetByID(input.UserID, *input.ParentID)
if err != nil {
if errors.Is(err, repository.ErrCategoryNotFound) {
return nil, ErrInvalidParentCategory
}
return nil, fmt.Errorf("failed to validate parent category: %w", err)
}
// userID check handled by repo
// Ensure parent category type matches the new category type
if parent.Type != input.Type {
return nil, ErrParentTypeMismatch
}
// Ensure parent is not already a child (only allow 2 levels)
if parent.ParentID != nil {
return nil, ErrParentIsChild
}
}
// Create the category model
category := &models.Category{
UserID: input.UserID,
Name: input.Name,
Icon: input.Icon,
Type: input.Type,
ParentID: input.ParentID,
SortOrder: input.SortOrder,
}
// Save to database
if err := s.repo.Create(category); err != nil {
return nil, fmt.Errorf("failed to create category: %w", err)
}
return category, nil
}
// GetCategory retrieves a category by ID and verifies ownership
func (s *CategoryService) GetCategory(userID, id uint) (*models.Category, error) {
category, err := s.repo.GetByID(userID, id)
if err != nil {
if errors.Is(err, repository.ErrCategoryNotFound) {
return nil, ErrCategoryNotFound
}
return nil, fmt.Errorf("failed to get category: %w", err)
}
// userID check handled by repo
return category, nil
}
// GetCategoryWithChildren retrieves a category with its children and verifies ownership
func (s *CategoryService) GetCategoryWithChildren(userID, id uint) (*models.Category, error) {
category, err := s.repo.GetWithChildren(userID, id)
if err != nil {
if errors.Is(err, repository.ErrCategoryNotFound) {
return nil, ErrCategoryNotFound
}
return nil, fmt.Errorf("failed to get category with children: %w", err)
}
// userID check handled by repo
return category, nil
}
// GetAllCategories retrieves all categories for a user
func (s *CategoryService) GetAllCategories(userID uint) ([]models.Category, error) {
categories, err := s.repo.GetAll(userID)
if err != nil {
return nil, fmt.Errorf("failed to get categories: %w", err)
}
return categories, nil
}
// GetCategoriesByType retrieves all categories of a specific type for a user
func (s *CategoryService) GetCategoriesByType(userID uint, categoryType models.CategoryType) ([]models.Category, error) {
categories, err := s.repo.GetByType(userID, categoryType)
if err != nil {
return nil, fmt.Errorf("failed to get categories by type: %w", err)
}
return categories, nil
}
// GetCategoryTree retrieves all categories in a hierarchical tree structure for a user
// Returns only root categories with their children preloaded
func (s *CategoryService) GetCategoryTree(userID uint) ([]models.Category, error) {
categories, err := s.repo.GetAllWithChildren(userID)
if err != nil {
return nil, fmt.Errorf("failed to get category tree: %w", err)
}
return categories, nil
}
// GetCategoryTreeByType retrieves categories of a specific type in a hierarchical tree structure for a user
func (s *CategoryService) GetCategoryTreeByType(userID uint, categoryType models.CategoryType) ([]models.Category, error) {
// Get root categories of the specified type
rootCategories, err := s.repo.GetRootCategoriesByType(userID, categoryType)
if err != nil {
return nil, fmt.Errorf("failed to get root categories by type: %w", err)
}
// Load children for each root category
for i := range rootCategories {
children, err := s.repo.GetChildren(userID, rootCategories[i].ID)
if err != nil {
return nil, fmt.Errorf("failed to get children for category %d: %w", rootCategories[i].ID, err)
}
rootCategories[i].Children = children
}
return rootCategories, nil
}
// GetRootCategories retrieves all root categories (categories without parent) for a user
func (s *CategoryService) GetRootCategories(userID uint) ([]models.Category, error) {
categories, err := s.repo.GetRootCategories(userID)
if err != nil {
return nil, fmt.Errorf("failed to get root categories: %w", err)
}
return categories, nil
}
// GetChildCategories retrieves all child categories of a given parent
func (s *CategoryService) GetChildCategories(userID, parentID uint) ([]models.Category, error) {
// Verify parent exists
_, err := s.repo.GetByID(userID, parentID)
if err != nil {
if errors.Is(err, repository.ErrCategoryNotFound) {
return nil, ErrCategoryNotFound
}
return nil, fmt.Errorf("failed to verify parent category: %w", err)
}
// userID check handled by repo
children, err := s.repo.GetChildren(userID, parentID)
if err != nil {
return nil, fmt.Errorf("failed to get child categories: %w", err)
}
return children, nil
}
// UpdateCategory updates an existing category after verifying ownership
func (s *CategoryService) UpdateCategory(userID, id uint, input CategoryInput) (*models.Category, error) {
// Get existing category
category, err := s.repo.GetByID(userID, id)
if err != nil {
if errors.Is(err, repository.ErrCategoryNotFound) {
return nil, ErrCategoryNotFound
}
return nil, fmt.Errorf("failed to get category: %w", err)
}
// userID check handled by repo
// Validate parent category if provided
if input.ParentID != nil {
// Cannot set self as parent
if *input.ParentID == id {
return nil, ErrCircularReference
}
parent, err := s.repo.GetByID(userID, *input.ParentID)
if err != nil {
if errors.Is(err, repository.ErrCategoryNotFound) {
return nil, ErrInvalidParentCategory
}
return nil, fmt.Errorf("failed to validate parent category: %w", err)
}
// userID check handled by repo
// Ensure parent category type matches
if parent.Type != input.Type {
return nil, ErrParentTypeMismatch
}
// Ensure parent is not already a child (only allow 2 levels)
if parent.ParentID != nil {
return nil, ErrParentIsChild
}
// Check if the new parent is a child of the current category (circular reference)
if parent.ParentID != nil && *parent.ParentID == id {
return nil, ErrCircularReference
}
}
// If this category has children and we're trying to set a parent, reject
// (would create more than 2 levels)
if input.ParentID != nil {
children, err := s.repo.GetChildren(userID, id)
if err != nil {
return nil, fmt.Errorf("failed to check children: %w", err)
}
if len(children) > 0 {
return nil, ErrParentIsChild
}
}
// Update fields
category.Name = input.Name
category.Icon = input.Icon
category.Type = input.Type
category.ParentID = input.ParentID
category.SortOrder = input.SortOrder
// Save to database
if err := s.repo.Update(category); err != nil {
return nil, fmt.Errorf("failed to update category: %w", err)
}
return category, nil
}
// DeleteCategory deletes a category by ID after verifying ownership
func (s *CategoryService) DeleteCategory(userID, id uint) error {
_, err := s.repo.GetByID(userID, id)
if err != nil {
if errors.Is(err, repository.ErrCategoryNotFound) {
return ErrCategoryNotFound
}
return fmt.Errorf("failed to check category existence: %w", err)
}
// userID check handled by repo
err = s.repo.Delete(userID, id)
if err != nil {
if errors.Is(err, repository.ErrCategoryNotFound) {
return ErrCategoryNotFound
}
if errors.Is(err, repository.ErrCategoryInUse) {
return ErrCategoryInUse
}
if errors.Is(err, repository.ErrCategoryHasChildren) {
return ErrCategoryHasChildren
}
return fmt.Errorf("failed to delete category: %w", err)
}
return nil
}
// CategoryExists checks if a category exists by ID
func (s *CategoryService) CategoryExists(userID uint, id uint) (bool, error) {
exists, err := s.repo.ExistsByID(userID, id)
if err != nil {
return false, fmt.Errorf("failed to check category existence: %w", err)
}
return exists, nil
}
// GetCategoryPath returns the full path of a category (parent -> child)
func (s *CategoryService) GetCategoryPath(userID, id uint) ([]models.Category, error) {
category, err := s.repo.GetWithParent(userID, id)
if err != nil {
if errors.Is(err, repository.ErrCategoryNotFound) {
return nil, ErrCategoryNotFound
}
return nil, fmt.Errorf("failed to get category: %w", err)
}
// userID check handled by repo
path := []models.Category{}
if category.Parent != nil {
path = append(path, *category.Parent)
}
path = append(path, *category)
return path, nil
}