package service import ( "context" "encoding/json" "fmt" "github.com/gochat/gochat/internal/model" "github.com/gochat/gochat/internal/repository" applogger "github.com/gochat/gochat/pkg/logger" ) // CategoryService implements business logic for Category CRUD. type CategoryService struct { repo *repository.CategoryRepo relatedRepo *repository.RelatedCategoryRepo } func NewCategoryService(repo *repository.CategoryRepo, relatedRepo *repository.RelatedCategoryRepo) *CategoryService { return &CategoryService{repo: repo, relatedRepo: relatedRepo} } // CreateCategoryRequest is the DTO for creating a category. type CreateCategoryRequest struct { Name string `json:"name" validate:"required,min=2"` Slug string `json:"slug" validate:"required"` Description string `json:"description"` Icon string `json:"icon"` Position int `json:"position"` Locale string `json:"locale"` ParentID *uint `json:"parent_id"` ParentCategoryID *uint `json:"parent_category_id"` AssociatedCategoryID *uint `json:"associated_category_id"` RelatedCategoryIDs []uint `json:"related_category_ids"` CustomAttributes json.RawMessage `json:"custom_attributes"` } // UpdateCategoryRequest is the DTO for updating a category. type UpdateCategoryRequest struct { Name *string `json:"name"` Slug *string `json:"slug"` Description *string `json:"description"` Icon *string `json:"icon"` Position *int `json:"position"` Locale *string `json:"locale"` ParentID *uint `json:"parent_id"` ParentCategoryID *uint `json:"parent_category_id"` AssociatedCategoryID *uint `json:"associated_category_id"` RelatedCategoryIDs []uint `json:"related_category_ids"` CustomAttributes *json.RawMessage `json:"custom_attributes"` } func (s *CategoryService) Create(ctx context.Context, portalID uint, accountID uint, req *CreateCategoryRequest) (*model.Category, error) { category := &model.Category{ AccountID: accountID, PortalID: portalID, Name: req.Name, Slug: req.Slug, Description: req.Description, Icon: req.Icon, Position: req.Position, Locale: req.Locale, ParentID: firstCategoryParentID(req.ParentID, req.ParentCategoryID), AssociatedCategoryID: req.AssociatedCategoryID, CustomAttributes: req.CustomAttributes, } if err := s.repo.Create(ctx, category); err != nil { return nil, fmt.Errorf("create category: %w", err) } // Handle related categories if len(req.RelatedCategoryIDs) > 0 { if err := s.relatedRepo.ReplaceAll(ctx, category.ID, req.RelatedCategoryIDs); err != nil { applogger.L().Warnf("Failed to set related categories for category %d: %v", category.ID, err) } } return s.repo.GetByPortalAndID(ctx, portalID, category.ID) } func (s *CategoryService) GetByID(ctx context.Context, id uint) (*model.Category, error) { category, err := s.repo.GetByID(ctx, id) if err != nil { return nil, fmt.Errorf("get category: %w", err) } return category, nil } func (s *CategoryService) GetByPortalAndID(ctx context.Context, portalID, id uint) (*model.Category, error) { category, err := s.repo.GetByPortalAndID(ctx, portalID, id) if err != nil { return nil, fmt.Errorf("get category: %w", err) } return category, nil } func (s *CategoryService) GetByPortalSlugAndLocale(ctx context.Context, portalID uint, slug string, locale string) (*model.Category, error) { category, err := s.repo.FindBySlugAndPortalIDAndLocale(ctx, slug, portalID, locale) if err != nil { return nil, fmt.Errorf("get category: %w", err) } return category, nil } func (s *CategoryService) Update(ctx context.Context, id uint, req *UpdateCategoryRequest) (*model.Category, error) { category, err := s.repo.GetByID(ctx, id) if err != nil { return nil, fmt.Errorf("find category: %w", err) } if req.Name != nil { category.Name = *req.Name } if req.Slug != nil { category.Slug = *req.Slug } if req.Description != nil { category.Description = *req.Description } if req.Icon != nil { category.Icon = *req.Icon } if req.Position != nil { category.Position = *req.Position } if req.Locale != nil { category.Locale = *req.Locale } if parentID := firstCategoryParentID(req.ParentID, req.ParentCategoryID); parentID != nil { category.ParentID = parentID } if req.AssociatedCategoryID != nil { category.AssociatedCategoryID = req.AssociatedCategoryID } if req.CustomAttributes != nil { category.CustomAttributes = *req.CustomAttributes } if err := s.repo.Update(ctx, category); err != nil { return nil, fmt.Errorf("update category: %w", err) } // Handle related categories — replace all if provided if req.RelatedCategoryIDs != nil { if err := s.relatedRepo.ReplaceAll(ctx, category.ID, req.RelatedCategoryIDs); err != nil { applogger.L().Warnf("Failed to update related categories for category %d: %v", category.ID, err) } } return s.repo.GetByPortalAndID(ctx, category.PortalID, category.ID) } func (s *CategoryService) UpdateScoped(ctx context.Context, portalID, id uint, req *UpdateCategoryRequest) (*model.Category, error) { category, err := s.repo.GetByPortalAndID(ctx, portalID, id) if err != nil { return nil, fmt.Errorf("find category: %w", err) } return s.UpdateExisting(ctx, category, req) } func (s *CategoryService) UpdateExisting(ctx context.Context, category *model.Category, req *UpdateCategoryRequest) (*model.Category, error) { if category == nil { return nil, fmt.Errorf("find category: record not found") } if req.Name != nil { category.Name = *req.Name } if req.Slug != nil { category.Slug = *req.Slug } if req.Description != nil { category.Description = *req.Description } if req.Icon != nil { category.Icon = *req.Icon } if req.Position != nil { category.Position = *req.Position } if req.Locale != nil { category.Locale = *req.Locale } if parentID := firstCategoryParentID(req.ParentID, req.ParentCategoryID); parentID != nil { category.ParentID = parentID } if req.AssociatedCategoryID != nil { category.AssociatedCategoryID = req.AssociatedCategoryID } if req.CustomAttributes != nil { category.CustomAttributes = *req.CustomAttributes } if err := s.repo.Update(ctx, category); err != nil { return nil, fmt.Errorf("update category: %w", err) } if req.RelatedCategoryIDs != nil { if err := s.relatedRepo.ReplaceAll(ctx, category.ID, req.RelatedCategoryIDs); err != nil { applogger.L().Warnf("Failed to update related categories for category %d: %v", category.ID, err) } } return s.repo.GetByPortalAndID(ctx, category.PortalID, category.ID) } func (s *CategoryService) Delete(ctx context.Context, id uint) error { // Check existence first — GORM Delete() returns nil even for non-existent IDs if _, err := s.repo.GetByID(ctx, id); err != nil { return fmt.Errorf("find category for delete: %w", err) } // Clean up related categories first if err := s.relatedRepo.DeleteByCategoryID(ctx, id); err != nil { applogger.L().Warnf("Failed to delete related categories for category %d: %v", id, err) } if err := s.repo.Delete(ctx, id); err != nil { return fmt.Errorf("delete category: %w", err) } return nil } func (s *CategoryService) DeleteScoped(ctx context.Context, portalID, id uint) error { if _, err := s.repo.GetByPortalAndID(ctx, portalID, id); err != nil { return fmt.Errorf("find category for delete: %w", err) } if err := s.relatedRepo.DeleteByCategoryID(ctx, id); err != nil { applogger.L().Warnf("Failed to delete related categories for category %d: %v", id, err) } if err := s.repo.Delete(ctx, id); err != nil { return fmt.Errorf("delete category: %w", err) } return nil } // ListByPortalID returns categories for a portal with pagination. func (s *CategoryService) ListByPortalID(ctx context.Context, portalID uint, locale string, page, perPage int) ([]model.Category, int64, error) { offset := 0 if page > 0 && perPage > 0 { offset = (page - 1) * perPage } if locale != "" { return s.repo.FindByPortalIDAndLocale(ctx, portalID, locale, offset, perPage) } return s.repo.FindByPortalID(ctx, portalID, offset, perPage) } func firstCategoryParentID(parentID, parentCategoryID *uint) *uint { if parentCategoryID != nil { return parentCategoryID } return parentID } // Reorder batch-updates category positions. func (s *CategoryService) Reorder(ctx context.Context, positions map[uint]int) error { if err := s.repo.UpdatePositions(ctx, positions); err != nil { return fmt.Errorf("reorder categories: %w", err) } return nil } func (s *CategoryService) ReorderScoped(ctx context.Context, portalID uint, positions map[uint]int) error { if err := s.repo.UpdatePositionsForPortal(ctx, portalID, positions); err != nil { return fmt.Errorf("reorder categories: %w", err) } return nil }