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 — // aggregate counts plus the most-recent feedback rows. Mounted at /stats // (outside /api/v1, since it returns HTML not JSON) and intentionally // 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. 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 (h *StatsHandler) Render(w http.ResponseWriter, r *http.Request) { snap, err := h.stats.Snapshot(r.Context()) if err != nil { http.Error(w, "stats query failed: "+err.Error(), http.StatusInternalServerError) return } recent, err := h.feedback.Recent(r.Context(), 25) if err != nil { http.Error(w, "recent feedback query failed: "+err.Error(), http.StatusInternalServerError) return } w.Header().Set("Content-Type", "text/html; charset=utf-8") // Caching off — this is an ops snapshot; staleness defeats the point. w.Header().Set("Cache-Control", "no-store") if err := statsTpl.Execute(w, map[string]any{ "Stats": snap, "Feedback": recent, "GeneratedAt": time.Now().Format("2006-01-02 15:04:05 MST"), }); err != nil { // Headers already flushed — best we can do is bail mid-stream. return } } // 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)) const statsHTML = ` Stats · Prabhupāda Says

System snapshot

Generated {{.GeneratedAt}}

Users & activity

Users
{{.Stats.Users}}+{{.Stats.UsersLast7Days}} / 7d
Posts
{{.Stats.Posts}}+{{.Stats.PostsLast7Days}} / 7d
Collections
{{.Stats.Collections}}{{.Stats.PublicCollections}} public
Bookmarks
{{.Stats.Bookmarks}}
Reshares
{{.Stats.Reshares}}
Feedback
{{.Stats.Feedback}}+{{.Stats.FeedbackLast7Days}} / 7d

Feedback by category

{{if .Stats.FeedbackByCategory}} {{range .Stats.FeedbackByCategory}}
{{.Category}}{{.Count}}
{{end}} {{else}}

No feedback yet.

{{end}}

Recent feedback

{{if .Feedback}} {{range .Feedback}}
{{.Category}} {{fmtTime .CreatedAt}}
{{.Message}}
{{if .AuthorHandle}} @{{.AuthorHandle}}{{if .AuthorName}} · {{.AuthorName}}{{end}} {{else if .ContactEmail}} anonymous · {{.ContactEmail}} {{else}} anonymous {{end}}
{{if .ResolvedAt}} {{if .AcknowledgedAt}}✓ Acknowledged {{fmtTime .AcknowledgedAt}}{{end}} ✓ Resolved {{fmtTime .ResolvedAt}} {{else}} {{if .AcknowledgedAt}} ✓ Acknowledged {{fmtTime .AcknowledgedAt}} {{else}}
{{end}}
{{end}}
{{end}} {{else}}

No feedback yet.

{{end}} `