package service import ( "context" "errors" "fmt" "regexp" "strings" "github.com/prabhupadasays/spsays-bff/internal/model" "github.com/prabhupadasays/spsays-bff/internal/repository" ) const ( maxCategoryLabel = 60 maxCategoryDescription = 200 ) var ( ErrCategoryLabel = fmt.Errorf("a category needs a name (up to %d characters)", maxCategoryLabel) ErrCategoryDescription = fmt.Errorf("the description can be up to %d characters", maxCategoryDescription) ErrCategoryLastActive = errors.New("the feedback form needs at least one category") ) var categoryNonSlug = regexp.MustCompile(`[^a-z0-9]+`) // FeedbackCategoryService manages the /feedback form's categories. type FeedbackCategoryService struct { repo *repository.FeedbackCategoryRepo } func NewFeedbackCategoryService(repo *repository.FeedbackCategoryRepo) *FeedbackCategoryService { return &FeedbackCategoryService{repo: repo} } // Active is what the /feedback form offers, in order. func (s *FeedbackCategoryService) Active(ctx context.Context) ([]model.FeedbackCategory, error) { return s.repo.List(ctx, false) } // All is the admin's view: archived ones too, with usage counts. func (s *FeedbackCategoryService) All(ctx context.Context) ([]model.FeedbackCategory, error) { return s.repo.List(ctx, true) } func cleanCategory(label, description string) (string, string, error) { label = strings.TrimSpace(label) description = strings.TrimSpace(description) if label == "" || len([]rune(label)) > maxCategoryLabel { return "", "", ErrCategoryLabel } if len([]rune(description)) > maxCategoryDescription { return "", "", ErrCategoryDescription } return label, description, nil } // Create adds a category to the end of the form. Its key is derived from // the name ("Audio problem" → audio_problem) and fixed from then on, so it // can be renamed freely later without touching past feedback. func (s *FeedbackCategoryService) Create(ctx context.Context, label, description string) (*model.FeedbackCategory, error) { label, description, err := cleanCategory(label, description) if err != nil { return nil, err } base := strings.Trim(categoryNonSlug.ReplaceAllString(strings.ToLower(label), "_"), "_") if base == "" { base = "category" } if len(base) > 40 { base = strings.TrimRight(base[:40], "_") } key := base for i := 2; ; i++ { taken, err := s.repo.KeyTaken(ctx, key) if err != nil { return nil, err } if !taken { break } key = fmt.Sprintf("%s_%d", base, i) } return s.repo.Create(ctx, key, label, description) } // Update renames, rewords, archives or restores a category. The last active // category can't be archived — the form would have nothing to offer. func (s *FeedbackCategoryService) Update(ctx context.Context, key, label, description string, archived bool) error { label, description, err := cleanCategory(label, description) if err != nil { return err } if archived { if err := s.guardLastActive(ctx, key); err != nil { return err } } return s.repo.Update(ctx, key, label, description, archived) } // Delete removes a category nothing is filed under (otherwise: archive it). func (s *FeedbackCategoryService) Delete(ctx context.Context, key string) error { if err := s.guardLastActive(ctx, key); err != nil { return err } return s.repo.Delete(ctx, key) } // Reorder sets the form's order. func (s *FeedbackCategoryService) Reorder(ctx context.Context, keys []string) error { return s.repo.Reorder(ctx, keys) } func (s *FeedbackCategoryService) guardLastActive(ctx context.Context, key string) error { active, err := s.repo.List(ctx, false) if err != nil { return err } if len(active) == 1 && active[0].Key == key { return ErrCategoryLastActive } return nil }