Files
spsays-bff/internal/service/feedback_category_service.go
T
Krispire AdminandClaude Opus 5.5 d76505772a 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>
2026-10-18 13:47:09 +05:30

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
}