diff --git a/internal/handler/feedback.go b/internal/handler/feedback.go index 8d203fa..d6d723f 100644 --- a/internal/handler/feedback.go +++ b/internal/handler/feedback.go @@ -68,3 +68,21 @@ func (h *FeedbackHandler) Submit(w http.ResponseWriter, r *http.Request) { } response.JSON(w, http.StatusCreated, f) } + +// Mine returns the authenticated caller's own feedback submissions, +// newest first — each with its status (acknowledged / resolved) and any +// admin reply. Registered under RequireAuth, so UserID is always set; +// the guard is belt-and-suspenders. +func (h *FeedbackHandler) Mine(w http.ResponseWriter, r *http.Request) { + uid := middleware.UserID(r.Context()) + if uid == "" { + response.Error(w, http.StatusUnauthorized, "authentication required") + return + } + items, err := h.svc.ListForUser(r.Context(), uid, 50) + if err != nil { + response.Error(w, http.StatusInternalServerError, "could not load your feedback") + return + } + response.JSON(w, http.StatusOK, items) +} diff --git a/internal/model/feedback.go b/internal/model/feedback.go index b07d1b2..64f03d1 100644 --- a/internal/model/feedback.go +++ b/internal/model/feedback.go @@ -31,6 +31,13 @@ type Feedback struct { // resolve is a no-op. ResolvedAt *time.Time `json:"resolved_at,omitempty"` + // ReplyMessage is the admin's written reply to the submitter, sent + // (and stored) from the admin panel. NULL until an admin replies. + // RepliedAt stamps when it was sent. Surfaced to the submitter on the + // "My feedback" page as the team's response. + ReplyMessage *string `json:"reply_message,omitempty"` + RepliedAt *time.Time `json:"replied_at,omitempty"` + AuthorHandle *string `json:"author_handle,omitempty"` AuthorName *string `json:"author_name,omitempty"` AuthorEmail *string `json:"-"` // server-side only; never serialised diff --git a/internal/repository/feedback_repo.go b/internal/repository/feedback_repo.go index 6150ce2..4d58102 100644 --- a/internal/repository/feedback_repo.go +++ b/internal/repository/feedback_repo.go @@ -85,6 +85,39 @@ func (r *FeedbackRepo) MarkResolved(ctx context.Context, id string) (resolved bo 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 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 + 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.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). diff --git a/internal/server/server.go b/internal/server/server.go index f5d081c..af02b03 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -953,10 +953,20 @@ func (s *Server) setupRouter() { r.Group(func(r chi.Router) { if s.feedbackH == nil { r.HandleFunc("/feedback", s.authUnavailable) + r.HandleFunc("/feedback/mine", s.authUnavailable) return } - r.Use(middleware.OptionalAuth(s.authIssuer, s.userRepo)) - r.Post("/feedback", s.feedbackH.Submit) + // Submit — OptionalAuth (anonymous callers allowed). + r.Group(func(r chi.Router) { + r.Use(middleware.OptionalAuth(s.authIssuer, s.userRepo)) + r.Post("/feedback", s.feedbackH.Submit) + }) + // My submissions — RequireAuth. Lists the caller's own rows + // with status + admin reply for the /settings/feedbacks page. + r.Group(func(r chi.Router) { + r.Use(middleware.RequireAuth(s.authIssuer, s.userRepo)) + r.Get("/feedback/mine", s.feedbackH.Mine) + }) }) // Notifications — the signed-in user's in-app feed + unread badge. diff --git a/internal/service/feedback_service.go b/internal/service/feedback_service.go index b5199bf..c6b4cda 100644 --- a/internal/service/feedback_service.go +++ b/internal/service/feedback_service.go @@ -92,6 +92,12 @@ func (s *FeedbackService) Submit(ctx context.Context, userID *string, category, return f, nil } +// ListForUser returns the signed-in user's own submissions (newest +// first) with status and any admin reply, for the "My feedback" page. +func (s *FeedbackService) ListForUser(ctx context.Context, userID string, limit int) ([]model.Feedback, error) { + return s.repo.ListByUser(ctx, userID, limit) +} + // ErrAlreadyResolved is returned by Resolve when the row was already // closed (or doesn't exist). The handler maps it to a benign redirect // rather than an error — the end state the admin wanted is already true.