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:
Krispire Admin
2026-10-18 13:47:09 +05:30
co-authored by Claude Opus 5.5
parent fa0255e9cd
commit d76505772a
13 changed files with 610 additions and 40 deletions
+3 -2
View File
@@ -248,12 +248,13 @@ func (r *AdminRepo) ListFeedback(ctx context.Context, status string, limit, offs
status = "all"
}
rows, err := r.pg.Pool.Query(ctx, `
SELECT f.id, f.category, f.message, f.contact_email, f.created_at,
SELECT f.id, f.category, COALESCE(fc.label, f.category), f.message, f.contact_email, f.created_at,
f.acknowledged_at, f.resolved_at, f.replied_at, f.reply_message,
u.handle, u.name, u.email,
COUNT(*) OVER() AS total
FROM feedback f
LEFT JOIN users u ON u.id = f.user_id
LEFT JOIN feedback_categories fc ON fc.key = f.category
WHERE ($1 = 'all'
OR ($1 = 'open' AND f.resolved_at IS NULL)
OR ($1 = 'resolved' AND f.resolved_at IS NOT NULL))
@@ -268,7 +269,7 @@ func (r *AdminRepo) ListFeedback(ctx context.Context, status string, limit, offs
total := 0
for rows.Next() {
var f model.AdminFeedback
if err := rows.Scan(&f.ID, &f.Category, &f.Message, &f.ContactEmail, &f.CreatedAt,
if err := rows.Scan(&f.ID, &f.Category, &f.CategoryLabel, &f.Message, &f.ContactEmail, &f.CreatedAt,
&f.AcknowledgedAt, &f.ResolvedAt, &f.RepliedAt, &f.ReplyMessage,
&f.AuthorHandle, &f.AuthorName, &f.AuthorEmail, &total); err != nil {
return nil, 0, fmt.Errorf("scan admin feedback: %w", err)
@@ -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
}
+13 -10
View File
@@ -37,13 +37,14 @@ func (r *FeedbackRepo) Create(ctx context.Context, userID *string, category, mes
func (r *FeedbackRepo) FindByID(ctx context.Context, id string) (*model.Feedback, error) {
f := &model.Feedback{}
err := r.pg.Pool.QueryRow(ctx, `
SELECT f.id, f.user_id, f.category, f.message, f.contact_email, f.created_at,
SELECT f.id, f.user_id, f.category, COALESCE(fc.label, f.category), f.message, f.contact_email, f.created_at,
f.acknowledged_at, f.resolved_at,
u.handle, u.name, u.email
FROM feedback f
LEFT JOIN users u ON u.id = f.user_id
LEFT JOIN feedback_categories fc ON fc.key = f.category
WHERE f.id = $1`, id,
).Scan(&f.ID, &f.UserID, &f.Category, &f.Message, &f.ContactEmail, &f.CreatedAt,
).Scan(&f.ID, &f.UserID, &f.Category, &f.CategoryLabel, &f.Message, &f.ContactEmail, &f.CreatedAt,
&f.AcknowledgedAt, &f.ResolvedAt,
&f.AuthorHandle, &f.AuthorName, &f.AuthorEmail)
if err != nil {
@@ -95,11 +96,12 @@ func (r *FeedbackRepo) ListByUser(ctx context.Context, userID string, limit int)
limit = 50
}
rows, err := r.pg.Pool.Query(ctx, `
SELECT id, user_id, category, message, contact_email, created_at,
acknowledged_at, resolved_at, replied_at, reply_message
FROM feedback
WHERE user_id = $1
ORDER BY created_at DESC
SELECT f.id, f.user_id, f.category, COALESCE(fc.label, f.category), f.message, f.contact_email, f.created_at,
f.acknowledged_at, f.resolved_at, f.replied_at, f.reply_message
FROM feedback f
LEFT JOIN feedback_categories fc ON fc.key = f.category
WHERE f.user_id = $1
ORDER BY f.created_at DESC
LIMIT $2`, userID, limit,
)
if err != nil {
@@ -109,7 +111,7 @@ func (r *FeedbackRepo) ListByUser(ctx context.Context, userID string, limit int)
out := []model.Feedback{}
for rows.Next() {
var f model.Feedback
if err := rows.Scan(&f.ID, &f.UserID, &f.Category, &f.Message, &f.ContactEmail, &f.CreatedAt,
if err := rows.Scan(&f.ID, &f.UserID, &f.Category, &f.CategoryLabel, &f.Message, &f.ContactEmail, &f.CreatedAt,
&f.AcknowledgedAt, &f.ResolvedAt, &f.RepliedAt, &f.ReplyMessage); err != nil {
return nil, fmt.Errorf("scan user feedback: %w", err)
}
@@ -132,12 +134,13 @@ func (r *FeedbackRepo) Recent(ctx context.Context, limit int) ([]model.Feedback,
limit = 25
}
rows, err := r.pg.Pool.Query(ctx, `
SELECT f.id, f.user_id, f.category, f.message, f.created_at,
SELECT f.id, f.user_id, f.category, COALESCE(fc.label, f.category), f.message, f.created_at,
(f.contact_email IS NOT NULL AND f.contact_email <> '') AS has_contact_email,
f.acknowledged_at, f.resolved_at,
u.handle, u.name
FROM feedback f
LEFT JOIN users u ON u.id = f.user_id
LEFT JOIN feedback_categories fc ON fc.key = f.category
ORDER BY f.created_at DESC
LIMIT $1`, limit,
)
@@ -148,7 +151,7 @@ func (r *FeedbackRepo) Recent(ctx context.Context, limit int) ([]model.Feedback,
out := []model.Feedback{}
for rows.Next() {
var f model.Feedback
if err := rows.Scan(&f.ID, &f.UserID, &f.Category, &f.Message, &f.CreatedAt,
if err := rows.Scan(&f.ID, &f.UserID, &f.Category, &f.CategoryLabel, &f.Message, &f.CreatedAt,
&f.HasContactEmail,
&f.AcknowledgedAt, &f.ResolvedAt,
&f.AuthorHandle, &f.AuthorName); err != nil {