diff --git a/internal/handler/admin.go b/internal/handler/admin.go index a0c3284..27d1220 100644 --- a/internal/handler/admin.go +++ b/internal/handler/admin.go @@ -169,6 +169,27 @@ func (h *AdminHandler) ReplyFeedback(w http.ResponseWriter, r *http.Request) { response.JSON(w, http.StatusOK, map[string]any{"replied": true, "emailed": emailed}) } +// AcknowledgeFeedback — POST /admin/feedback/{id}/acknowledge +func (h *AdminHandler) AcknowledgeFeedback(w http.ResponseWriter, r *http.Request) { + err := h.svc.AcknowledgeFeedback(r.Context(), chi.URLParam(r, "id")) + // Already-acknowledged is a no-op success from the client's perspective. + if err != nil && !errors.Is(err, service.ErrAlreadyAcknowledged) { + response.Error(w, http.StatusInternalServerError, "could not acknowledge feedback") + return + } + response.JSON(w, http.StatusOK, map[string]any{"acknowledged": true}) +} + +// ResolveFeedback — POST /admin/feedback/{id}/resolve +func (h *AdminHandler) ResolveFeedback(w http.ResponseWriter, r *http.Request) { + err := h.svc.ResolveFeedback(r.Context(), chi.URLParam(r, "id")) + if err != nil && !errors.Is(err, service.ErrAlreadyResolved) { + response.Error(w, http.StatusInternalServerError, "could not resolve feedback") + return + } + response.JSON(w, http.StatusOK, map[string]any{"resolved": true}) +} + // ResolveReport — POST /admin/reports/{type}/{id}/resolve func (h *AdminHandler) ResolveReport(w http.ResponseWriter, r *http.Request) { reportType := chi.URLParam(r, "type") diff --git a/internal/handler/stats.go b/internal/handler/stats.go index 9d3e2b7..6e3fd80 100644 --- a/internal/handler/stats.go +++ b/internal/handler/stats.go @@ -1,14 +1,11 @@ package handler import ( - "errors" "html/template" "net/http" "time" - "github.com/go-chi/chi/v5" "github.com/prabhupadasays/spsays-bff/internal/repository" - "github.com/prabhupadasays/spsays-bff/internal/service" ) // StatsHandler renders a single-page HTML snapshot of the system — @@ -17,17 +14,15 @@ import ( // public per current product call. If that ever needs to change, wrap // the route in middleware in server.go. // -// It also handles the "Mark resolved" form POST per feedback row, which -// stamps resolved_at (one-way) and fires the author thank-you email via -// the feedback service. +// It is READ-ONLY: feedback acknowledge/resolve actions live in the +// authenticated admin panel, never on this public page. type StatsHandler struct { stats *repository.StatsRepo feedback *repository.FeedbackRepo - svc *service.FeedbackService } -func NewStatsHandler(stats *repository.StatsRepo, feedback *repository.FeedbackRepo, svc *service.FeedbackService) *StatsHandler { - return &StatsHandler{stats: stats, feedback: feedback, svc: svc} +func NewStatsHandler(stats *repository.StatsRepo, feedback *repository.FeedbackRepo) *StatsHandler { + return &StatsHandler{stats: stats, feedback: feedback} } func (h *StatsHandler) Render(w http.ResponseWriter, r *http.Request) { @@ -55,33 +50,6 @@ func (h *StatsHandler) Render(w http.ResponseWriter, r *http.Request) { } } -// AcknowledgeFeedback handles the per-row "Acknowledge" form POST. Marks -// the row acknowledged (one-way) and emails the author that we've seen -// it and started work, then 303-redirects back to /stats. Already- -// acknowledged or unknown ids redirect silently. -func (h *StatsHandler) AcknowledgeFeedback(w http.ResponseWriter, r *http.Request) { - id := chi.URLParam(r, "id") - if err := h.svc.Acknowledge(r.Context(), id); err != nil && !errors.Is(err, service.ErrAlreadyAcknowledged) { - http.Error(w, "acknowledge failed: "+err.Error(), http.StatusInternalServerError) - return - } - http.Redirect(w, r, "/stats", http.StatusSeeOther) -} - -// ResolveFeedback handles the per-row "Mark resolved" form POST. It marks -// the row resolved (one-way) and fires the author thank-you email, then -// 303-redirects back to /stats so a refresh doesn't re-submit. Already- -// resolved or unknown ids redirect silently — the desired end state is -// already true. -func (h *StatsHandler) ResolveFeedback(w http.ResponseWriter, r *http.Request) { - id := chi.URLParam(r, "id") - if err := h.svc.Resolve(r.Context(), id); err != nil && !errors.Is(err, service.ErrAlreadyResolved) { - http.Error(w, "resolve failed: "+err.Error(), http.StatusInternalServerError) - return - } - http.Redirect(w, r, "/stats", http.StatusSeeOther) -} - var statsTpl = template.Must(template.New("stats").Funcs(template.FuncMap{ "fmtTime": func(t time.Time) string { return t.Format("2006-01-02 15:04") }, }).Parse(statsHTML)) @@ -115,6 +83,7 @@ h2 { font-size: 1.05rem; margin-top: 2rem; margin-bottom: 0.5rem; border-bottom: .fb .foot { display: flex; justify-content: flex-end; align-items: center; gap: 0.6rem; margin-top: 0.6rem } .resolved-badge { display: inline-flex; align-items: center; gap: 0.3rem; font-size: 0.78rem; font-weight: 600; color: #1e7a4a } .acked-badge { display: inline-flex; align-items: center; gap: 0.3rem; font-size: 0.78rem; font-weight: 600; color: #9a6b2f } +.pending-badge { display: inline-flex; align-items: center; font-size: 0.78rem; font-weight: 600; color: #999 } .resolve-btn { font: inherit; font-size: 0.8rem; font-weight: 600; color: #fff; background: #1e7a4a; border: 0; border-radius: 6px; padding: 0.4rem 0.85rem; cursor: pointer } .resolve-btn:hover { background: #196b40 } .ack-btn { font: inherit; font-size: 0.8rem; font-weight: 600; color: #9a6b2f; background: #fff; border: 1px solid #d6b27a; border-radius: 6px; padding: 0.4rem 0.85rem; cursor: pointer } @@ -168,17 +137,10 @@ code { background: #f4f4f4; padding: 0.05em 0.3em; border-radius: 3px; font-size {{if .ResolvedAt}} {{if .AcknowledgedAt}}✓ Acknowledged {{fmtTime .AcknowledgedAt}}{{end}} ✓ Resolved {{fmtTime .ResolvedAt}} + {{else if .AcknowledgedAt}} + ✓ Acknowledged {{fmtTime .AcknowledgedAt}} {{else}} - {{if .AcknowledgedAt}} - ✓ Acknowledged {{fmtTime .AcknowledgedAt}} - {{else}} -
- -
- {{end}} -
- -
+ Pending {{end}} diff --git a/internal/model/admin.go b/internal/model/admin.go index ff2597c..e40e5fd 100644 --- a/internal/model/admin.go +++ b/internal/model/admin.go @@ -17,6 +17,7 @@ type AdminUser struct { Plan string `json:"plan"` EmailVerifiedAt *time.Time `json:"email_verified_at"` BannedAt *time.Time `json:"banned_at"` + PostCount int `json:"post_count"` CreatedAt time.Time `json:"created_at"` } @@ -54,10 +55,11 @@ type AdminFeedback struct { ID string `json:"id"` Category string `json:"category"` Message string `json:"message"` - ContactEmail *string `json:"contact_email"` - CreatedAt time.Time `json:"created_at"` - ResolvedAt *time.Time `json:"resolved_at"` - RepliedAt *time.Time `json:"replied_at"` + ContactEmail *string `json:"contact_email"` + CreatedAt time.Time `json:"created_at"` + AcknowledgedAt *time.Time `json:"acknowledged_at"` + ResolvedAt *time.Time `json:"resolved_at"` + RepliedAt *time.Time `json:"replied_at"` ReplyMessage *string `json:"reply_message"` AuthorHandle *string `json:"author_handle"` AuthorName *string `json:"author_name"` diff --git a/internal/repository/admin_repo.go b/internal/repository/admin_repo.go index 4add8de..9d2b49b 100644 --- a/internal/repository/admin_repo.go +++ b/internal/repository/admin_repo.go @@ -33,12 +33,15 @@ func clampLimit(limit int) int { func (r *AdminRepo) ListUsers(ctx context.Context, q string, limit, offset int) ([]model.AdminUser, int, error) { limit = clampLimit(limit) rows, err := r.pg.Pool.Query(ctx, ` - SELECT id, email, handle, name, avatar_url, role, plan, email_verified_at, banned_at, created_at, + SELECT u.id, u.email, u.handle, u.name, u.avatar_url, u.role, u.plan, + u.email_verified_at, u.banned_at, + (SELECT COUNT(*) FROM posts p WHERE p.user_id = u.id AND p.deleted_at IS NULL) AS post_count, + u.created_at, COUNT(*) OVER() AS total - FROM users - WHERE ($1 = '' OR name ILIKE '%'||$1||'%' OR handle ILIKE '%'||$1||'%' - OR COALESCE(email, '') ILIKE '%'||$1||'%') - ORDER BY created_at DESC + FROM users u + WHERE ($1 = '' OR u.name ILIKE '%'||$1||'%' OR u.handle ILIKE '%'||$1||'%' + OR COALESCE(u.email, '') ILIKE '%'||$1||'%') + ORDER BY u.created_at DESC LIMIT $2 OFFSET $3`, q, limit, offset) if err != nil { return nil, 0, fmt.Errorf("admin list users: %w", err) @@ -50,7 +53,7 @@ func (r *AdminRepo) ListUsers(ctx context.Context, q string, limit, offset int) for rows.Next() { var u model.AdminUser if err := rows.Scan(&u.ID, &u.Email, &u.Handle, &u.Name, &u.AvatarURL, - &u.Role, &u.Plan, &u.EmailVerifiedAt, &u.BannedAt, &u.CreatedAt, &total); err != nil { + &u.Role, &u.Plan, &u.EmailVerifiedAt, &u.BannedAt, &u.PostCount, &u.CreatedAt, &total); err != nil { return nil, 0, fmt.Errorf("scan admin user: %w", err) } out = append(out, u) @@ -146,7 +149,7 @@ func (r *AdminRepo) ListFeedback(ctx context.Context, status string, limit, offs } rows, err := r.pg.Pool.Query(ctx, ` SELECT f.id, f.category, f.message, f.contact_email, f.created_at, - f.resolved_at, f.replied_at, f.reply_message, + 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 @@ -166,7 +169,7 @@ func (r *AdminRepo) ListFeedback(ctx context.Context, status string, limit, offs for rows.Next() { var f model.AdminFeedback if err := rows.Scan(&f.ID, &f.Category, &f.Message, &f.ContactEmail, &f.CreatedAt, - &f.ResolvedAt, &f.RepliedAt, &f.ReplyMessage, + &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/server/server.go b/internal/server/server.go index 1f1a02f..0ae9a3a 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -284,13 +284,13 @@ func (s *Server) initAuth(cfg *config.Config, logger *zap.Logger) { statsRepo := repository.NewStatsRepo(s.pg) feedbackSvc := service.NewFeedbackService(feedbackRepo, mail, cfg.FeedbackAdminEmails, logger) s.feedbackH = handler.NewFeedbackHandler(feedbackSvc) - s.statsH = handler.NewStatsHandler(statsRepo, feedbackRepo, feedbackSvc) + s.statsH = handler.NewStatsHandler(statsRepo, feedbackRepo) // Admin panel — read-mostly listing + notify/reply/resolve actions. // Reuses the notification service (delivery) and mailer (feedback reply). // Gated by RequireAuth + RequireAdmin in the router. adminRepo := repository.NewAdminRepo(s.pg) - adminSvc := service.NewAdminService(adminRepo, notifSvc, mail, logger, cfg.FrontendURL) + adminSvc := service.NewAdminService(adminRepo, notifSvc, feedbackSvc, mail, logger, cfg.FrontendURL) s.adminH = handler.NewAdminHandler(adminSvc) } @@ -441,12 +441,10 @@ func (s *Server) setupRouter() { // Currently unauthenticated by product decision; gate here if that // ever needs to change. if s.statsH != nil { + // Read-only public snapshot. The feedback acknowledge/resolve actions + // moved to the admin panel (POST /api/v1/admin/feedback/{id}/...) — they + // must not be exposed on this unauthenticated page. r.Get("/stats", s.statsH.Render) - // One-way per-row feedback actions — POST so a refresh of /stats - // can't replay them. Both live at the same public access level as - // /stats itself (gate them together if that ever changes). - r.Post("/stats/feedback/{id}/acknowledge", s.statsH.AcknowledgeFeedback) - r.Post("/stats/feedback/{id}/resolve", s.statsH.ResolveFeedback) } // API v1 @@ -1002,6 +1000,8 @@ func (s *Server) setupRouter() { r.Get("/collections", s.adminH.ListCollections) r.Get("/feedback", s.adminH.ListFeedback) r.Post("/feedback/{id}/reply", s.adminH.ReplyFeedback) + r.Post("/feedback/{id}/acknowledge", s.adminH.AcknowledgeFeedback) + r.Post("/feedback/{id}/resolve", s.adminH.ResolveFeedback) r.Get("/reports", s.adminH.ListReports) r.Post("/reports/{type}/{id}/resolve", s.adminH.ResolveReport) }) diff --git a/internal/service/admin_service.go b/internal/service/admin_service.go index cc1183c..ed8b344 100644 --- a/internal/service/admin_service.go +++ b/internal/service/admin_service.go @@ -23,13 +23,28 @@ var ErrAdminUserNotFound = errors.New("user not found") type AdminService struct { repo *repository.AdminRepo notif *NotificationService + feedback *FeedbackService mail mailer.Mailer logger *zap.Logger frontendURL string } -func NewAdminService(repo *repository.AdminRepo, notif *NotificationService, mail mailer.Mailer, logger *zap.Logger, frontendURL string) *AdminService { - return &AdminService{repo: repo, notif: notif, mail: mail, logger: logger, frontendURL: frontendURL} +func NewAdminService(repo *repository.AdminRepo, notif *NotificationService, feedback *FeedbackService, mail mailer.Mailer, logger *zap.Logger, frontendURL string) *AdminService { + return &AdminService{repo: repo, notif: notif, feedback: feedback, mail: mail, logger: logger, frontendURL: frontendURL} +} + +// AcknowledgeFeedback marks a feedback row acknowledged and emails the author +// that we've seen it. Reuses FeedbackService so the email is identical to the +// (now removed) public /stats action. Returns ErrAlreadyAcknowledged when the +// row was already acknowledged. +func (s *AdminService) AcknowledgeFeedback(ctx context.Context, id string) error { + return s.feedback.Acknowledge(ctx, id) +} + +// ResolveFeedback marks a feedback row resolved and emails the author a +// thank-you. Returns ErrAlreadyResolved when the row was already resolved. +func (s *AdminService) ResolveFeedback(ctx context.Context, id string) error { + return s.feedback.Resolve(ctx, id) } func (s *AdminService) ListUsers(ctx context.Context, q string, limit, offset int) ([]model.AdminUser, int, error) {