feat: add stats page
This commit is contained in:
@@ -0,0 +1,70 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
|
||||
"github.com/prabhupadasays/spsays-bff/internal/middleware"
|
||||
"github.com/prabhupadasays/spsays-bff/internal/service"
|
||||
"github.com/prabhupadasays/spsays-bff/pkg/response"
|
||||
"github.com/prabhupadasays/spsays-bff/pkg/validator"
|
||||
)
|
||||
|
||||
// FeedbackHandler exposes POST /api/v1/feedback. The router wraps it
|
||||
// with OptionalAuth so a session cookie populates user_id when one is
|
||||
// present, and anonymous callers are still accepted (they have to
|
||||
// supply contact_email instead).
|
||||
type FeedbackHandler struct{ svc *service.FeedbackService }
|
||||
|
||||
func NewFeedbackHandler(svc *service.FeedbackService) *FeedbackHandler {
|
||||
return &FeedbackHandler{svc: svc}
|
||||
}
|
||||
|
||||
type submitFeedbackReq struct {
|
||||
Category string `json:"category"`
|
||||
Message string `json:"message"`
|
||||
ContactEmail *string `json:"contact_email,omitempty"`
|
||||
}
|
||||
|
||||
func (h *FeedbackHandler) Submit(w http.ResponseWriter, r *http.Request) {
|
||||
var req submitFeedbackReq
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
response.Error(w, http.StatusBadRequest, "invalid JSON body")
|
||||
return
|
||||
}
|
||||
|
||||
v := validator.New()
|
||||
validator.Required(v, "category", req.Category)
|
||||
validator.Required(v, "message", req.Message)
|
||||
validator.MaxLength(v, "message", req.Message, 5000)
|
||||
if !v.Valid() {
|
||||
response.Error(w, http.StatusBadRequest, v.Error())
|
||||
return
|
||||
}
|
||||
|
||||
// OptionalAuth seeds user_id when a valid session cookie was
|
||||
// present; "" means anonymous.
|
||||
uid := middleware.UserID(r.Context())
|
||||
var userIDPtr *string
|
||||
if uid != "" {
|
||||
userIDPtr = &uid
|
||||
}
|
||||
|
||||
// Soft-validate the contact email for the anonymous path. Authed
|
||||
// callers' contact_email is ignored downstream, so skip the check.
|
||||
if userIDPtr == nil && req.ContactEmail != nil && *req.ContactEmail != "" {
|
||||
ev := validator.New()
|
||||
validator.Email(ev, "contact_email", *req.ContactEmail)
|
||||
if !ev.Valid() {
|
||||
response.Error(w, http.StatusBadRequest, ev.Error())
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
f, err := h.svc.Submit(r.Context(), userIDPtr, req.Category, req.Message, req.ContactEmail)
|
||||
if err != nil {
|
||||
response.Error(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
response.JSON(w, http.StatusCreated, f)
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"html/template"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/prabhupadasays/spsays-bff/internal/repository"
|
||||
)
|
||||
|
||||
// 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.
|
||||
type StatsHandler struct {
|
||||
stats *repository.StatsRepo
|
||||
feedback *repository.FeedbackRepo
|
||||
}
|
||||
|
||||
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) {
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
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 = `<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Stats · Prabhupāda Says</title>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<style>
|
||||
* { box-sizing: border-box }
|
||||
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif; max-width: 920px; margin: 2.5rem auto; padding: 0 1.5rem; color: #1a1a1a; line-height: 1.5 }
|
||||
h1 { font-size: 1.5rem; margin-bottom: 0.25rem }
|
||||
h2 { font-size: 1.05rem; margin-top: 2rem; margin-bottom: 0.5rem; border-bottom: 1px solid #e5e5e5; padding-bottom: 0.25rem }
|
||||
.meta { color: #888; font-size: 0.8rem; margin-bottom: 1.5rem }
|
||||
.grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); gap: 0.75rem }
|
||||
.card { background: #fafafa; border: 1px solid #ececec; border-radius: 8px; padding: 0.9rem 1rem }
|
||||
.card .num { font-size: 1.4rem; font-weight: 600; line-height: 1.1 }
|
||||
.card .num .delta { font-size: 0.75rem; font-weight: 500; color: #1e7a4a; margin-left: 0.4rem }
|
||||
.card .label { color: #666; font-size: 0.78rem; text-transform: uppercase; letter-spacing: 0.05em; margin-bottom: 0.35rem }
|
||||
.row { display: flex; justify-content: space-between; gap: 0.5rem; padding: 0.4rem 0; border-bottom: 1px solid #f0f0f0; font-size: 0.92rem }
|
||||
.row:last-child { border-bottom: 0 }
|
||||
.row .cat { font-weight: 600; text-transform: capitalize }
|
||||
.fb { background: #fafafa; border: 1px solid #ececec; border-radius: 8px; padding: 0.9rem 1rem; margin-bottom: 0.6rem }
|
||||
.fb .head { display: flex; justify-content: space-between; gap: 1rem; font-size: 0.82rem; color: #666; margin-bottom: 0.4rem; flex-wrap: wrap }
|
||||
.fb .cat-pill { font-weight: 700; text-transform: capitalize; color: #1a1a1a }
|
||||
.fb .msg { white-space: pre-wrap; font-size: 0.95rem }
|
||||
.fb .author { font-size: 0.78rem; color: #888; margin-top: 0.5rem }
|
||||
code { background: #f4f4f4; padding: 0.05em 0.3em; border-radius: 3px; font-size: 0.85em }
|
||||
.empty { color: #888; font-style: italic }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>System snapshot</h1>
|
||||
<p class="meta">Generated {{.GeneratedAt}}</p>
|
||||
|
||||
<h2>Users & activity</h2>
|
||||
<div class="grid">
|
||||
<div class="card"><div class="label">Users</div><div class="num">{{.Stats.Users}}<span class="delta">+{{.Stats.UsersLast7Days}} / 7d</span></div></div>
|
||||
<div class="card"><div class="label">Posts</div><div class="num">{{.Stats.Posts}}<span class="delta">+{{.Stats.PostsLast7Days}} / 7d</span></div></div>
|
||||
<div class="card"><div class="label">Collections</div><div class="num">{{.Stats.Collections}}<span class="delta">{{.Stats.PublicCollections}} public</span></div></div>
|
||||
<div class="card"><div class="label">Bookmarks</div><div class="num">{{.Stats.Bookmarks}}</div></div>
|
||||
<div class="card"><div class="label">Reshares</div><div class="num">{{.Stats.Reshares}}</div></div>
|
||||
<div class="card"><div class="label">Feedback</div><div class="num">{{.Stats.Feedback}}<span class="delta">+{{.Stats.FeedbackLast7Days}} / 7d</span></div></div>
|
||||
</div>
|
||||
|
||||
<h2>Feedback by category</h2>
|
||||
{{if .Stats.FeedbackByCategory}}
|
||||
{{range .Stats.FeedbackByCategory}}
|
||||
<div class="row"><span class="cat">{{.Category}}</span><span>{{.Count}}</span></div>
|
||||
{{end}}
|
||||
{{else}}
|
||||
<p class="empty">No feedback yet.</p>
|
||||
{{end}}
|
||||
|
||||
<h2>Recent feedback</h2>
|
||||
{{if .Feedback}}
|
||||
{{range .Feedback}}
|
||||
<div class="fb">
|
||||
<div class="head">
|
||||
<span class="cat-pill">{{.Category}}</span>
|
||||
<span>{{fmtTime .CreatedAt}}</span>
|
||||
</div>
|
||||
<div class="msg">{{.Message}}</div>
|
||||
<div class="author">
|
||||
{{if .AuthorHandle}}
|
||||
@{{.AuthorHandle}}{{if .AuthorName}} · {{.AuthorName}}{{end}}
|
||||
{{else if .ContactEmail}}
|
||||
anonymous · <code>{{.ContactEmail}}</code>
|
||||
{{else}}
|
||||
anonymous
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
{{else}}
|
||||
<p class="empty">No feedback yet.</p>
|
||||
{{end}}
|
||||
</body>
|
||||
</html>`
|
||||
Reference in New Issue
Block a user