feat: auth user feedbacks
This commit is contained in:
@@ -68,3 +68,21 @@ func (h *FeedbackHandler) Submit(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
response.JSON(w, http.StatusCreated, f)
|
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)
|
||||||
|
}
|
||||||
|
|||||||
@@ -31,6 +31,13 @@ type Feedback struct {
|
|||||||
// resolve is a no-op.
|
// resolve is a no-op.
|
||||||
ResolvedAt *time.Time `json:"resolved_at,omitempty"`
|
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"`
|
AuthorHandle *string `json:"author_handle,omitempty"`
|
||||||
AuthorName *string `json:"author_name,omitempty"`
|
AuthorName *string `json:"author_name,omitempty"`
|
||||||
AuthorEmail *string `json:"-"` // server-side only; never serialised
|
AuthorEmail *string `json:"-"` // server-side only; never serialised
|
||||||
|
|||||||
@@ -85,6 +85,39 @@ func (r *FeedbackRepo) MarkResolved(ctx context.Context, id string) (resolved bo
|
|||||||
return tag.RowsAffected() == 1, nil
|
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
|
// Recent returns the most-recent N feedback rows joined with users
|
||||||
// for display on /stats. NULL author columns mean anonymous (or the
|
// for display on /stats. NULL author columns mean anonymous (or the
|
||||||
// original submitter's account was since deleted).
|
// original submitter's account was since deleted).
|
||||||
|
|||||||
@@ -953,10 +953,20 @@ func (s *Server) setupRouter() {
|
|||||||
r.Group(func(r chi.Router) {
|
r.Group(func(r chi.Router) {
|
||||||
if s.feedbackH == nil {
|
if s.feedbackH == nil {
|
||||||
r.HandleFunc("/feedback", s.authUnavailable)
|
r.HandleFunc("/feedback", s.authUnavailable)
|
||||||
|
r.HandleFunc("/feedback/mine", s.authUnavailable)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
r.Use(middleware.OptionalAuth(s.authIssuer, s.userRepo))
|
// Submit — OptionalAuth (anonymous callers allowed).
|
||||||
r.Post("/feedback", s.feedbackH.Submit)
|
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.
|
// Notifications — the signed-in user's in-app feed + unread badge.
|
||||||
|
|||||||
@@ -92,6 +92,12 @@ func (s *FeedbackService) Submit(ctx context.Context, userID *string, category,
|
|||||||
return f, nil
|
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
|
// ErrAlreadyResolved is returned by Resolve when the row was already
|
||||||
// closed (or doesn't exist). The handler maps it to a benign redirect
|
// 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.
|
// rather than an error — the end state the admin wanted is already true.
|
||||||
|
|||||||
Reference in New Issue
Block a user