Categories move from a hard-coded list to a feedback_categories table (migration 0062), seeded with the four existing keys, labels and descriptions so production feedback is untouched; any other key already in use gets an archived category. Keys are fixed, so renames relabel past feedback. Archive hides a category from the form; only unused categories can be deleted. Submissions are validated against active categories, and feedback responses carry category_label. New: GET /feedback/categories (public); /admin/feedback/categories CRUD + reorder behind the new feedback:categories permission (granted to Admin). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
125 lines
3.7 KiB
Go
125 lines
3.7 KiB
Go
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
|
|
}
|