Admin-managed feedback categories
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>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
fa0255e9cd
commit
d76505772a
@@ -0,0 +1,124 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user