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,143 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"github.com/prabhupadasays/spsays-bff/internal/model"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrFeedbackCategoryNotFound = errors.New("feedback category not found")
|
||||
ErrFeedbackCategoryInUse = errors.New("feedback is filed under this category; archive it instead")
|
||||
ErrFeedbackCategoryExists = errors.New("a category with that name already exists")
|
||||
)
|
||||
|
||||
// FeedbackCategoryRepo stores the /feedback form's categories.
|
||||
type FeedbackCategoryRepo struct{ pg *Postgres }
|
||||
|
||||
func NewFeedbackCategoryRepo(pg *Postgres) *FeedbackCategoryRepo {
|
||||
return &FeedbackCategoryRepo{pg: pg}
|
||||
}
|
||||
|
||||
// List returns categories in form order. includeArchived is true for the
|
||||
// admin (which also gets usage counts), false for the public form.
|
||||
func (r *FeedbackCategoryRepo) List(ctx context.Context, includeArchived bool) ([]model.FeedbackCategory, error) {
|
||||
rows, err := r.pg.Pool.Query(ctx, `
|
||||
SELECT c.key, c.label, c.description, c.sort_order, c.archived_at,
|
||||
CASE WHEN $1 THEN (SELECT count(*) FROM feedback f WHERE f.category = c.key) ELSE 0 END
|
||||
FROM feedback_categories c
|
||||
WHERE $1 OR c.archived_at IS NULL
|
||||
ORDER BY (c.archived_at IS NOT NULL), c.sort_order, c.label`, includeArchived)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list feedback categories: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
out := []model.FeedbackCategory{}
|
||||
for rows.Next() {
|
||||
var c model.FeedbackCategory
|
||||
if err := rows.Scan(&c.Key, &c.Label, &c.Description, &c.SortOrder, &c.ArchivedAt, &c.UsageCount); err != nil {
|
||||
return nil, fmt.Errorf("scan feedback category: %w", err)
|
||||
}
|
||||
out = append(out, c)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// IsActive reports whether new feedback may be filed under key.
|
||||
func (r *FeedbackCategoryRepo) IsActive(ctx context.Context, key string) (bool, error) {
|
||||
var ok bool
|
||||
err := r.pg.Pool.QueryRow(ctx, `
|
||||
SELECT EXISTS (SELECT 1 FROM feedback_categories WHERE key = $1 AND archived_at IS NULL)`,
|
||||
key).Scan(&ok)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("check feedback category: %w", err)
|
||||
}
|
||||
return ok, nil
|
||||
}
|
||||
|
||||
// KeyTaken reports whether a key is already used, archived or not.
|
||||
func (r *FeedbackCategoryRepo) KeyTaken(ctx context.Context, key string) (bool, error) {
|
||||
var ok bool
|
||||
err := r.pg.Pool.QueryRow(ctx,
|
||||
`SELECT EXISTS (SELECT 1 FROM feedback_categories WHERE key = $1)`, key).Scan(&ok)
|
||||
return ok, err
|
||||
}
|
||||
|
||||
// Create appends a category at the end of the form.
|
||||
func (r *FeedbackCategoryRepo) Create(ctx context.Context, key, label, description string) (*model.FeedbackCategory, error) {
|
||||
c := model.FeedbackCategory{Key: key, Label: label, Description: description}
|
||||
err := r.pg.Pool.QueryRow(ctx, `
|
||||
INSERT INTO feedback_categories (key, label, description, sort_order)
|
||||
VALUES ($1, $2, $3, COALESCE((SELECT max(sort_order) + 1 FROM feedback_categories WHERE archived_at IS NULL), 0))
|
||||
RETURNING sort_order`, key, label, description).Scan(&c.SortOrder)
|
||||
if isUniqueViolation(err, "feedback_categories_pkey") {
|
||||
return nil, ErrFeedbackCategoryExists
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create feedback category: %w", err)
|
||||
}
|
||||
return &c, nil
|
||||
}
|
||||
|
||||
// Update changes a category's wording and archived state. Its key — what
|
||||
// past feedback points at — is never changed.
|
||||
func (r *FeedbackCategoryRepo) Update(ctx context.Context, key, label, description string, archived bool) error {
|
||||
tag, err := r.pg.Pool.Exec(ctx, `
|
||||
UPDATE feedback_categories
|
||||
SET label = $2, description = $3,
|
||||
archived_at = CASE WHEN $4 THEN COALESCE(archived_at, now()) ELSE NULL END
|
||||
WHERE key = $1`, key, label, description, archived)
|
||||
if err != nil {
|
||||
return fmt.Errorf("update feedback category: %w", err)
|
||||
}
|
||||
if tag.RowsAffected() == 0 {
|
||||
return ErrFeedbackCategoryNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Reorder sets the form order to the given keys, first to last.
|
||||
func (r *FeedbackCategoryRepo) Reorder(ctx context.Context, keys []string) error {
|
||||
tx, err := r.pg.Pool.Begin(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("begin reorder: %w", err)
|
||||
}
|
||||
defer tx.Rollback(ctx) //nolint:errcheck
|
||||
for i, k := range keys {
|
||||
if _, err := tx.Exec(ctx,
|
||||
`UPDATE feedback_categories SET sort_order = $2 WHERE key = $1`, k, i); err != nil {
|
||||
return fmt.Errorf("reorder feedback category: %w", err)
|
||||
}
|
||||
}
|
||||
return tx.Commit(ctx)
|
||||
}
|
||||
|
||||
// Delete removes a category nothing has been filed under. One that's in use
|
||||
// can only be archived, so no submission is ever left without a label.
|
||||
func (r *FeedbackCategoryRepo) Delete(ctx context.Context, key string) error {
|
||||
var used bool
|
||||
err := r.pg.Pool.QueryRow(ctx,
|
||||
`SELECT EXISTS (SELECT 1 FROM feedback WHERE category = $1)`, key).Scan(&used)
|
||||
if err != nil {
|
||||
return fmt.Errorf("check category usage: %w", err)
|
||||
}
|
||||
if used {
|
||||
return ErrFeedbackCategoryInUse
|
||||
}
|
||||
tag, err := r.pg.Pool.Exec(ctx, `
|
||||
DELETE FROM feedback_categories c WHERE c.key = $1
|
||||
AND NOT EXISTS (SELECT 1 FROM feedback f WHERE f.category = c.key)`, key)
|
||||
if err != nil {
|
||||
return fmt.Errorf("delete feedback category: %w", err)
|
||||
}
|
||||
if tag.RowsAffected() == 0 {
|
||||
// Gone already, or feedback arrived between the check and the delete.
|
||||
if ok, _ := r.KeyTaken(ctx, key); ok {
|
||||
return ErrFeedbackCategoryInUse
|
||||
}
|
||||
return ErrFeedbackCategoryNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
Reference in New Issue
Block a user