diff --git a/internal/handler/feedback_category.go b/internal/handler/feedback_category.go new file mode 100644 index 0000000..6acf0b2 --- /dev/null +++ b/internal/handler/feedback_category.go @@ -0,0 +1,114 @@ +package handler + +import ( + "encoding/json" + "errors" + "net/http" + + "github.com/go-chi/chi/v5" + "github.com/prabhupadasays/spsays-bff/internal/repository" + "github.com/prabhupadasays/spsays-bff/internal/service" + "github.com/prabhupadasays/spsays-bff/pkg/response" +) + +// FeedbackCategoryHandler serves the /feedback form's categories: the +// public list, and their management in the admin panel. +type FeedbackCategoryHandler struct { + svc *service.FeedbackCategoryService +} + +func NewFeedbackCategoryHandler(svc *service.FeedbackCategoryService) *FeedbackCategoryHandler { + return &FeedbackCategoryHandler{svc: svc} +} + +func writeCategoryError(w http.ResponseWriter, err error) { + switch { + case errors.Is(err, repository.ErrFeedbackCategoryNotFound): + response.Error(w, http.StatusNotFound, err.Error()) + case errors.Is(err, repository.ErrFeedbackCategoryInUse), + errors.Is(err, repository.ErrFeedbackCategoryExists), + errors.Is(err, service.ErrCategoryLastActive): + response.Error(w, http.StatusConflict, err.Error()) + case errors.Is(err, service.ErrCategoryLabel), errors.Is(err, service.ErrCategoryDescription): + response.Error(w, http.StatusBadRequest, err.Error()) + default: + response.Error(w, http.StatusInternalServerError, "feedback category request failed") + } +} + +// Public — GET /feedback/categories: the form's options, in order. +func (h *FeedbackCategoryHandler) Public(w http.ResponseWriter, r *http.Request) { + list, err := h.svc.Active(r.Context()) + if err != nil { + writeCategoryError(w, err) + return + } + response.JSON(w, http.StatusOK, list) +} + +// List — GET /admin/feedback/categories: all, with usage counts. +func (h *FeedbackCategoryHandler) List(w http.ResponseWriter, r *http.Request) { + list, err := h.svc.All(r.Context()) + if err != nil { + writeCategoryError(w, err) + return + } + response.JSON(w, http.StatusOK, list) +} + +type categoryReq struct { + Label string `json:"label"` + Description string `json:"description"` + Archived bool `json:"archived"` +} + +func (h *FeedbackCategoryHandler) Create(w http.ResponseWriter, r *http.Request) { + var req categoryReq + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + response.Error(w, http.StatusBadRequest, "invalid request body") + return + } + c, err := h.svc.Create(r.Context(), req.Label, req.Description) + if err != nil { + writeCategoryError(w, err) + return + } + response.JSON(w, http.StatusCreated, c) +} + +func (h *FeedbackCategoryHandler) Update(w http.ResponseWriter, r *http.Request) { + var req categoryReq + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + response.Error(w, http.StatusBadRequest, "invalid request body") + return + } + if err := h.svc.Update(r.Context(), chi.URLParam(r, "key"), req.Label, req.Description, req.Archived); err != nil { + writeCategoryError(w, err) + return + } + w.WriteHeader(http.StatusNoContent) +} + +func (h *FeedbackCategoryHandler) Delete(w http.ResponseWriter, r *http.Request) { + if err := h.svc.Delete(r.Context(), chi.URLParam(r, "key")); err != nil { + writeCategoryError(w, err) + return + } + w.WriteHeader(http.StatusNoContent) +} + +// Reorder — PUT /admin/feedback/categories/order {"keys": [...]}. +func (h *FeedbackCategoryHandler) Reorder(w http.ResponseWriter, r *http.Request) { + var req struct { + Keys []string `json:"keys"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil || len(req.Keys) == 0 { + response.Error(w, http.StatusBadRequest, "keys are required") + return + } + if err := h.svc.Reorder(r.Context(), req.Keys); err != nil { + writeCategoryError(w, err) + return + } + w.WriteHeader(http.StatusNoContent) +} diff --git a/internal/model/admin.go b/internal/model/admin.go index 7b1596e..ca33d5f 100644 --- a/internal/model/admin.go +++ b/internal/model/admin.go @@ -89,6 +89,7 @@ type AdminCollection struct { type AdminFeedback struct { ID string `json:"id"` Category string `json:"category"` + CategoryLabel string `json:"category_label"` Message string `json:"message"` ContactEmail *string `json:"contact_email"` CreatedAt time.Time `json:"created_at"` diff --git a/internal/model/feedback.go b/internal/model/feedback.go index 5ff7093..25e8470 100644 --- a/internal/model/feedback.go +++ b/internal/model/feedback.go @@ -18,7 +18,10 @@ type Feedback struct { ID string `json:"id"` UserID *string `json:"user_id,omitempty"` Category string `json:"category"` - Message string `json:"message"` + // CategoryLabel is the category's current display name (falls back to + // the key if the category was ever removed). + CategoryLabel string `json:"category_label"` + Message string `json:"message"` ContactEmail *string `json:"contact_email,omitempty"` CreatedAt time.Time `json:"created_at"` // AcknowledgedAt is set when an admin presses "Acknowledge" on the @@ -50,3 +53,15 @@ type Feedback struct { AuthorName *string `json:"author_name,omitempty"` AuthorEmail *string `json:"-"` // server-side only; never serialised } + +// FeedbackCategory is one option on the /feedback form, managed from the +// admin panel. Key is what feedback.category stores and never changes. +type FeedbackCategory struct { + Key string `json:"key"` + Label string `json:"label"` + Description string `json:"description"` + SortOrder int `json:"sort_order"` + ArchivedAt *time.Time `json:"archived_at,omitempty"` + // UsageCount is admin-only: how much feedback is filed under it. + UsageCount int `json:"usage_count,omitempty"` +} diff --git a/internal/rbac/rbac.go b/internal/rbac/rbac.go index bb879a9..9e854fd 100644 --- a/internal/rbac/rbac.go +++ b/internal/rbac/rbac.go @@ -42,6 +42,7 @@ var Catalog = []Permission{ {"feedback:read", "Feedback", "View feedback"}, {"feedback:reply", "Feedback", "Reply to feedback"}, {"feedback:resolve", "Feedback", "Acknowledge and resolve feedback"}, + {"feedback:categories", "Feedback", "Manage feedback categories"}, {"vani:read", "Vāṇī of the Day", "View the schedule"}, {"vani:write", "Vāṇī of the Day", "Pin and clear days"}, diff --git a/internal/repository/admin_repo.go b/internal/repository/admin_repo.go index 258d6d1..9b0ceaa 100644 --- a/internal/repository/admin_repo.go +++ b/internal/repository/admin_repo.go @@ -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) diff --git a/internal/repository/feedback_category_repo.go b/internal/repository/feedback_category_repo.go new file mode 100644 index 0000000..04a5a92 --- /dev/null +++ b/internal/repository/feedback_category_repo.go @@ -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 +} diff --git a/internal/repository/feedback_repo.go b/internal/repository/feedback_repo.go index 48cb81c..621f550 100644 --- a/internal/repository/feedback_repo.go +++ b/internal/repository/feedback_repo.go @@ -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 { diff --git a/internal/server/server.go b/internal/server/server.go index 8ce1f85..190946c 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -76,6 +76,7 @@ type Server struct { pushH *handler.PushHandler reminderH *handler.ReminderHandler feedbackH *handler.FeedbackHandler + feedbackCatH *handler.FeedbackCategoryHandler statsH *handler.StatsHandler adminH *handler.AdminHandler broadcastH *handler.BroadcastHandler @@ -343,8 +344,11 @@ func (s *Server) initAuth(cfg *config.Config, logger *zap.Logger) { // the existing mailer instance. feedbackRepo := repository.NewFeedbackRepo(s.pg) statsRepo := repository.NewStatsRepo(s.pg) - feedbackSvc := service.NewFeedbackService(feedbackRepo, mail, cfg.FeedbackAdminEmails, logger) + // Categories are admin-managed; submissions are checked against them. + feedbackCatRepo := repository.NewFeedbackCategoryRepo(s.pg) + feedbackSvc := service.NewFeedbackService(feedbackRepo, feedbackCatRepo, mail, cfg.FeedbackAdminEmails, logger) s.feedbackH = handler.NewFeedbackHandler(feedbackSvc) + s.feedbackCatH = handler.NewFeedbackCategoryHandler(service.NewFeedbackCategoryService(feedbackCatRepo)) s.statsH = handler.NewStatsHandler(statsRepo, feedbackRepo) // Admin panel — read-mostly listing + notify/reply/resolve actions. @@ -1112,6 +1116,8 @@ func (s *Server) setupRouter() { r.HandleFunc("/feedback/mine", s.authUnavailable) return } + // The form's categories — public, like the form itself. + r.Get("/feedback/categories", s.feedbackCatH.Public) // Submit — OptionalAuth (anonymous callers allowed). r.Group(func(r chi.Router) { r.Use(middleware.OptionalAuth(s.authIssuer, s.userRepo)) @@ -1248,6 +1254,11 @@ func (s *Server) setupRouter() { can("collections:read").Get("/collections", s.adminH.ListCollections) can("feedback:read").Get("/feedback", s.adminH.ListFeedback) + can("feedback:read").Get("/feedback/categories", s.feedbackCatH.List) + can("feedback:categories").Post("/feedback/categories", s.feedbackCatH.Create) + can("feedback:categories").Put("/feedback/categories/order", s.feedbackCatH.Reorder) + can("feedback:categories").Put("/feedback/categories/{key}", s.feedbackCatH.Update) + can("feedback:categories").Delete("/feedback/categories/{key}", s.feedbackCatH.Delete) can("feedback:reply").Post("/feedback/{id}/reply", s.adminH.ReplyFeedback) can("feedback:resolve").Post("/feedback/{id}/acknowledge", s.adminH.AcknowledgeFeedback) can("feedback:resolve").Post("/feedback/{id}/resolve", s.adminH.ResolveFeedback) diff --git a/internal/service/feedback_category_service.go b/internal/service/feedback_category_service.go new file mode 100644 index 0000000..bd986a6 --- /dev/null +++ b/internal/service/feedback_category_service.go @@ -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 +} diff --git a/internal/service/feedback_category_service_test.go b/internal/service/feedback_category_service_test.go new file mode 100644 index 0000000..e9515c7 --- /dev/null +++ b/internal/service/feedback_category_service_test.go @@ -0,0 +1,106 @@ +package service + +import ( + "context" + "errors" + "testing" + + "github.com/prabhupadasays/spsays-bff/internal/repository" + "go.uber.org/zap" +) + +// Categories are managed from the admin: a new one is offered on the form +// and accepts feedback; renaming relabels past feedback; archiving stops new +// submissions but keeps the label; in-use categories can't be deleted. +func TestFeedbackCategories(t *testing.T) { + _, _, pg := testChatService(t) + ctx := context.Background() + repo := repository.NewFeedbackCategoryRepo(pg) + cats := NewFeedbackCategoryService(repo) + fb := NewFeedbackService(repository.NewFeedbackRepo(pg), repo, nil, nil, zap.NewNop()) + user := chatUser(t, pg, "Feedback") + t.Cleanup(func() { + bg := context.Background() + _, _ = pg.Pool.Exec(bg, `DELETE FROM feedback WHERE user_id = $1`, user) + _, _ = pg.Pool.Exec(bg, `DELETE FROM feedback_categories WHERE key LIKE 'zz_qa_audio%'`) + }) + + // The seeded categories production already uses are all on the form. + active, err := cats.Active(ctx) + if err != nil { + t.Fatal(err) + } + seen := map[string]bool{} + for _, c := range active { + seen[c.Key] = true + } + for _, k := range []string{"general", "bug", "feature", "other"} { + if !seen[k] { + t.Errorf("seeded category %q missing from the form", k) + } + } + + // Keys come from the name, and never collide. + c1, err := cats.Create(ctx, "ZZ QA Audio problem", "Playback trouble") + if err != nil { + t.Fatal(err) + } + c2, err := cats.Create(ctx, "ZZ QA audio problem!", "") + if err != nil { + t.Fatal(err) + } + if c1.Key != "zz_qa_audio_problem" || c2.Key != "zz_qa_audio_problem_2" { + t.Errorf("keys = %q, %q", c1.Key, c2.Key) + } + if _, err := cats.Create(ctx, " ", ""); !errors.Is(err, ErrCategoryLabel) { + t.Errorf("blank name: %v, want ErrCategoryLabel", err) + } + + // New categories take feedback; unknown ones don't. + if _, err := fb.Submit(ctx, &user, c1.Key, "the audio stutters", nil); err != nil { + t.Fatalf("submit in a new category: %v", err) + } + if _, err := fb.Submit(ctx, &user, "no_such_category", "x", nil); !errors.Is(err, ErrFeedbackCategory) { + t.Errorf("unknown category: %v, want ErrFeedbackCategory", err) + } + + // Renaming relabels what's already been filed. + if err := cats.Update(ctx, c1.Key, "ZZ QA Audio & playback", "Playback trouble", false); err != nil { + t.Fatal(err) + } + mine, _ := fb.ListForUser(ctx, user, 10) + if len(mine) != 1 || mine[0].CategoryLabel != "ZZ QA Audio & playback" || mine[0].Category != c1.Key { + t.Errorf("past feedback after rename = %+v", mine) + } + + // Archived: off the form, refuses new feedback, keeps its label. + if err := cats.Update(ctx, c1.Key, "ZZ QA Audio & playback", "", true); err != nil { + t.Fatal(err) + } + if _, err := fb.Submit(ctx, &user, c1.Key, "again", nil); !errors.Is(err, ErrFeedbackCategory) { + t.Errorf("submit to an archived category: %v, want ErrFeedbackCategory", err) + } + active, _ = cats.Active(ctx) + for _, c := range active { + if c.Key == c1.Key { + t.Error("archived category still on the form") + } + } + if mine, _ := fb.ListForUser(ctx, user, 10); mine[0].CategoryLabel != "ZZ QA Audio & playback" { + t.Error("archiving lost the label on past feedback") + } + all, _ := cats.All(ctx) + for _, c := range all { + if c.Key == c1.Key && (c.ArchivedAt == nil || c.UsageCount != 1) { + t.Errorf("admin view of archived category = %+v", c) + } + } + + // In use → can't be deleted; unused → can. + if err := cats.Delete(ctx, c1.Key); !errors.Is(err, repository.ErrFeedbackCategoryInUse) { + t.Errorf("deleting an in-use category: %v, want ErrFeedbackCategoryInUse", err) + } + if err := cats.Delete(ctx, c2.Key); err != nil { + t.Errorf("deleting an unused category: %v", err) + } +} diff --git a/internal/service/feedback_service.go b/internal/service/feedback_service.go index c6b4cda..a13bd36 100644 --- a/internal/service/feedback_service.go +++ b/internal/service/feedback_service.go @@ -21,24 +21,23 @@ import ( ) type FeedbackService struct { - repo *repository.FeedbackRepo - mail mailer.Mailer - admins []string - logger *zap.Logger + repo *repository.FeedbackRepo + // categories is the admin-managed list a submission must belong to. + categories *repository.FeedbackCategoryRepo + mail mailer.Mailer + admins []string + logger *zap.Logger } -func NewFeedbackService(repo *repository.FeedbackRepo, mail mailer.Mailer, admins []string, logger *zap.Logger) *FeedbackService { - return &FeedbackService{repo: repo, mail: mail, admins: admins, logger: logger} +func NewFeedbackService(repo *repository.FeedbackRepo, categories *repository.FeedbackCategoryRepo, mail mailer.Mailer, admins []string, logger *zap.Logger) *FeedbackService { + return &FeedbackService{repo: repo, categories: categories, mail: mail, admins: admins, logger: logger} } // Allowed categories — kept lowercase in storage so dashboards group // cleanly. The frontend dropdown sends one of these exact strings. -var feedbackCategories = map[string]bool{ - "bug": true, - "feature": true, - "general": true, - "other": true, -} +// ErrFeedbackCategory: the submission names a category that doesn't exist +// or has been archived in the admin panel. +var ErrFeedbackCategory = errors.New("invalid category") // Submit persists a feedback row and fires the admin email. // @@ -49,8 +48,10 @@ var feedbackCategories = map[string]bool{ // trustworthy (the form can't lie about who's writing). func (s *FeedbackService) Submit(ctx context.Context, userID *string, category, message string, contactEmail *string) (*model.Feedback, error) { category = strings.ToLower(strings.TrimSpace(category)) - if !feedbackCategories[category] { - return nil, errors.New("invalid category") + if ok, err := s.categories.IsActive(ctx, category); err != nil { + return nil, err + } else if !ok { + return nil, ErrFeedbackCategory } message = strings.TrimSpace(message) if message == "" { @@ -76,16 +77,15 @@ func (s *FeedbackService) Submit(ctx context.Context, userID *string, category, return nil, err } - // Hydrate the joined author fields for the email template. Failing - // the lookup isn't worth failing the request — we fall back to - // "anonymous" rather than 500ing on the user. - if userID != nil { - if full, err := s.repo.FindByID(ctx, f.ID); err == nil { - f = full - } else { - s.logger.Warn("feedback hydrate failed; sending email without author info", - zap.String("feedback_id", f.ID), zap.Error(err)) - } + // Hydrate the joined author fields and category label for the email + // template. Failing the lookup isn't worth failing the request — we fall + // back to "anonymous" and the category key rather than 500ing. + if full, err := s.repo.FindByID(ctx, f.ID); err == nil { + f = full + } else { + s.logger.Warn("feedback hydrate failed; sending email without author info", + zap.String("feedback_id", f.ID), zap.Error(err)) + f.CategoryLabel = f.Category } s.notifyAdmins(f) @@ -222,7 +222,7 @@ func (s *FeedbackService) notifyAdmins(f *model.Feedback) { s.logger.Warn("render feedback email failed", zap.Error(err)) return } - subject := fmt.Sprintf("[Feedback · %s] %s", f.Category, summarize(f.Message, 60)) + subject := fmt.Sprintf("[Feedback · %s] %s", categoryLabel(f), summarize(f.Message, 60)) for _, to := range s.admins { if err := s.mail.Send(to, subject, body); err != nil { s.logger.Warn("feedback email send failed", @@ -263,7 +263,7 @@ func renderFeedbackEmail(f *model.Feedback) (string, error) { } var buf bytes.Buffer err := feedbackEmailTpl.Execute(&buf, map[string]any{ - "Category": f.Category, + "Category": categoryLabel(f), "Message": f.Message, "From": from, "CreatedAt": f.CreatedAt.Format("2006-01-02 15:04:05 MST"), @@ -360,3 +360,11 @@ func renderFeedbackResolvedEmail(f *model.Feedback) (string, error) { } return buf.String(), nil } + +// categoryLabel is the category's display name, or its key if unknown. +func categoryLabel(f *model.Feedback) string { + if f.CategoryLabel != "" { + return f.CategoryLabel + } + return f.Category +} diff --git a/migrations/0062_feedback_categories.down.sql b/migrations/0062_feedback_categories.down.sql new file mode 100644 index 0000000..8b02dcc --- /dev/null +++ b/migrations/0062_feedback_categories.down.sql @@ -0,0 +1,2 @@ +UPDATE admin_roles SET permissions = array_remove(permissions, 'feedback:categories'); +DROP TABLE IF EXISTS feedback_categories; diff --git a/migrations/0062_feedback_categories.up.sql b/migrations/0062_feedback_categories.up.sql new file mode 100644 index 0000000..7630afb --- /dev/null +++ b/migrations/0062_feedback_categories.up.sql @@ -0,0 +1,41 @@ +-- Feedback categories, managed from the admin panel instead of hard-coded in +-- the app and the BFF. +-- +-- `key` is what feedback.category stores, and never changes once created — +-- so renaming a category in the admin relabels every past submission too. +-- Archiving hides a category from the /feedback form while existing feedback +-- keeps its label. There is deliberately no foreign key from feedback: the +-- column predates this table, and the admin only hard-deletes categories +-- nothing uses. +CREATE TABLE IF NOT EXISTS feedback_categories ( + key TEXT PRIMARY KEY, + label TEXT NOT NULL, + description TEXT NOT NULL DEFAULT '', + sort_order INTEGER NOT NULL DEFAULT 0, + archived_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +-- The four categories the app offered until now, with the exact labels and +-- descriptions its form showed, in the form's order. Production feedback +-- already uses these keys, so they must stay byte-for-byte the same. +INSERT INTO feedback_categories (key, label, description, sort_order) VALUES + ('general', 'General feedback', 'Thoughts, impressions, suggestions — anything else.', 0), + ('bug', 'Bug report', 'Something is broken or behaving unexpectedly.', 1), + ('feature', 'Feature request', 'An idea for something new on the site.', 2), + ('other', 'Other', 'Doesn''t fit the above? Use this.', 3) +ON CONFLICT (key) DO NOTHING; + +-- Safety net: any other key already present in feedback (from an older +-- client) gets a category too — archived, so it isn't offered on the form, +-- but its submissions still carry a readable label. +INSERT INTO feedback_categories (key, label, sort_order, archived_at) +SELECT DISTINCT f.category, initcap(replace(f.category, '_', ' ')), 100, now() +FROM feedback f +WHERE NOT EXISTS (SELECT 1 FROM feedback_categories c WHERE c.key = f.category) +ON CONFLICT (key) DO NOTHING; + +-- The Admin role may manage them; Superadmin already has '*'. +UPDATE admin_roles +SET permissions = array_append(permissions, 'feedback:categories') +WHERE key = 'admin' AND NOT ('feedback:categories' = ANY(permissions));