Files
spsays-bff/internal/repository/feedback_repo.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

164 lines
6.3 KiB
Go

package repository
import (
"context"
"fmt"
"github.com/prabhupadasays/spsays-bff/internal/model"
)
// FeedbackRepo wraps Postgres access for the feedback table.
type FeedbackRepo struct{ pg *Postgres }
func NewFeedbackRepo(pg *Postgres) *FeedbackRepo { return &FeedbackRepo{pg: pg} }
// Create inserts a single feedback row. userID is a pointer so the
// authed and anonymous paths share one method; contactEmail is also a
// pointer because authed submissions don't carry one (we read the
// submitter's email from the joined user row at notify time).
func (r *FeedbackRepo) Create(ctx context.Context, userID *string, category, message string, contactEmail *string) (*model.Feedback, error) {
f := &model.Feedback{}
err := r.pg.Pool.QueryRow(ctx, `
INSERT INTO feedback (user_id, category, message, contact_email)
VALUES ($1, $2, $3, $4)
RETURNING id, user_id, category, message, contact_email, created_at`,
userID, category, message, contactEmail,
).Scan(&f.ID, &f.UserID, &f.Category, &f.Message, &f.ContactEmail, &f.CreatedAt)
if err != nil {
return nil, fmt.Errorf("insert feedback: %w", err)
}
return f, nil
}
// FindByID returns one feedback row joined with the submitter's
// handle + name + email if a user_id is present. Used after Create to
// assemble the admin notification email with the proper author block,
// and after MarkResolved to assemble the author thank-you email.
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, 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.CategoryLabel, &f.Message, &f.ContactEmail, &f.CreatedAt,
&f.AcknowledgedAt, &f.ResolvedAt,
&f.AuthorHandle, &f.AuthorName, &f.AuthorEmail)
if err != nil {
return nil, fmt.Errorf("find feedback: %w", err)
}
return f, nil
}
// MarkAcknowledged stamps acknowledged_at = now() on a not-yet-
// acknowledged row. The `acknowledged_at IS NULL` guard makes this
// idempotent and one-way: first call returns true (newly acknowledged →
// fire the email), any later call — or an unknown id — returns false.
func (r *FeedbackRepo) MarkAcknowledged(ctx context.Context, id string) (acked bool, err error) {
tag, err := r.pg.Pool.Exec(ctx, `
UPDATE feedback
SET acknowledged_at = now()
WHERE id = $1 AND acknowledged_at IS NULL`, id,
)
if err != nil {
return false, fmt.Errorf("mark feedback acknowledged: %w", err)
}
return tag.RowsAffected() == 1, nil
}
// MarkResolved stamps resolved_at = now() on a still-open row. The
// `resolved_at IS NULL` guard makes this idempotent and one-way: the
// first call returns true (newly resolved), any subsequent call — or a
// call on an unknown id — returns false so the caller knows NOT to fire
// the thank-you email a second time.
func (r *FeedbackRepo) MarkResolved(ctx context.Context, id string) (resolved bool, err error) {
tag, err := r.pg.Pool.Exec(ctx, `
UPDATE feedback
SET resolved_at = now()
WHERE id = $1 AND resolved_at IS NULL`, id,
)
if err != nil {
return false, fmt.Errorf("mark feedback resolved: %w", err)
}
return tag.RowsAffected() == 1, nil
}
// ListByUser returns a single user's own feedback submissions, newest
// first, with status timestamps and any admin reply. Backs the "My
// feedback" page under /settings. No author joins — the caller IS the
// author, so handle/name/email aren't needed here. Bounded limit keeps
// the query cheap.
func (r *FeedbackRepo) ListByUser(ctx context.Context, userID string, limit int) ([]model.Feedback, error) {
if limit <= 0 || limit > 200 {
limit = 50
}
rows, err := r.pg.Pool.Query(ctx, `
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 {
return nil, fmt.Errorf("query user feedback: %w", err)
}
defer rows.Close()
out := []model.Feedback{}
for rows.Next() {
var f model.Feedback
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)
}
out = append(out, f)
}
return out, rows.Err()
}
// Recent returns the most-recent N feedback rows joined with users
// for display on /stats. NULL author columns mean anonymous (or the
// original submitter's account was since deleted).
//
// PRIVACY: /stats is a public page, so this query deliberately does NOT select
// contact_email — only a boolean saying whether one exists. Submitters' email
// addresses must never reach that template, and not fetching them makes that
// impossible rather than merely unlikely. Callers that legitimately need the
// address (the reply/thank-you emails) use FindByID instead.
func (r *FeedbackRepo) Recent(ctx context.Context, limit int) ([]model.Feedback, error) {
if limit <= 0 || limit > 200 {
limit = 25
}
rows, err := r.pg.Pool.Query(ctx, `
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,
)
if err != nil {
return nil, fmt.Errorf("query recent feedback: %w", err)
}
defer rows.Close()
out := []model.Feedback{}
for rows.Next() {
var f model.Feedback
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 {
return nil, fmt.Errorf("scan feedback: %w", err)
}
out = append(out, f)
}
return out, rows.Err()
}