feat: add stats page

This commit is contained in:
Krispire Admin
2026-05-31 14:54:15 +05:30
parent d3ad443fde
commit 72f4e6527c
14 changed files with 750 additions and 36 deletions
+6 -1
View File
@@ -25,13 +25,18 @@ R2_BUCKET=
# Public base URL where bucket objects are served (custom domain or pub-XXX.r2.dev) # Public base URL where bucket objects are served (custom domain or pub-XXX.r2.dev)
R2_PUBLIC_URL= R2_PUBLIC_URL=
# SMTP (password-reset + email-verification) # SMTP (password-reset + email-verification + /feedback admin notifications)
SMTP_HOST= SMTP_HOST=
SMTP_PORT=587 SMTP_PORT=587
SMTP_USERNAME= SMTP_USERNAME=
SMTP_PASSWORD= SMTP_PASSWORD=
SMTP_FROM="Prabhupāda Says <noreply@prabhupadasays.org>" SMTP_FROM="Prabhupāda Says <noreply@prabhupadasays.org>"
# Comma-separated recipients for /feedback admin notifications. Empty
# disables the email fan-out (feedback rows still persist and show on
# the /stats page either way).
FEEDBACK_ADMIN_EMAILS=
# Push Notifications # Push Notifications
FCM_CREDENTIALS_FILE= FCM_CREDENTIALS_FILE=
APNS_KEY_FILE= APNS_KEY_FILE=
+27 -1
View File
@@ -4,6 +4,7 @@ import (
"fmt" "fmt"
"os" "os"
"strconv" "strconv"
"strings"
"time" "time"
"github.com/joho/godotenv" "github.com/joho/godotenv"
@@ -36,13 +37,19 @@ type Config struct {
R2Bucket string R2Bucket string
R2PublicURL string // Public base URL for objects, e.g. https://cdn.example.com or the R2 public bucket URL R2PublicURL string // Public base URL for objects, e.g. https://cdn.example.com or the R2 public bucket URL
// SMTP — used for password-reset and email-verification mails. // SMTP — used for password-reset, email-verification, and
// /feedback admin-notification mails.
SMTPHost string SMTPHost string
SMTPPort int SMTPPort int
SMTPUsername string SMTPUsername string
SMTPPassword string SMTPPassword string
SMTPFrom string // "Prabhupāda Says <noreply@prabhupadasays.org>" SMTPFrom string // "Prabhupāda Says <noreply@prabhupadasays.org>"
// Comma-separated FEEDBACK_ADMIN_EMAILS — recipients for the
// /feedback notification fan-out. Empty disables the email send
// (the DB row is still written and visible via /stats).
FeedbackAdminEmails []string
// Push Notifications (existing — unchanged). // Push Notifications (existing — unchanged).
FCMCredentialsFile string FCMCredentialsFile string
APNsKeyFile string APNsKeyFile string
@@ -95,6 +102,8 @@ func Load() (*Config, error) {
SMTPPassword: getEnv("SMTP_PASSWORD", ""), SMTPPassword: getEnv("SMTP_PASSWORD", ""),
SMTPFrom: getEnv("SMTP_FROM", "noreply@localhost"), SMTPFrom: getEnv("SMTP_FROM", "noreply@localhost"),
FeedbackAdminEmails: parseList(getEnv("FEEDBACK_ADMIN_EMAILS", "")),
FCMCredentialsFile: getEnv("FCM_CREDENTIALS_FILE", ""), FCMCredentialsFile: getEnv("FCM_CREDENTIALS_FILE", ""),
APNsKeyFile: getEnv("APNS_KEY_FILE", ""), APNsKeyFile: getEnv("APNS_KEY_FILE", ""),
APNsKeyID: getEnv("APNS_KEY_ID", ""), APNsKeyID: getEnv("APNS_KEY_ID", ""),
@@ -147,3 +156,20 @@ func getEnvBool(key string, fallback bool) bool {
} }
return fallback return fallback
} }
// parseList splits a comma-separated env value, trimming whitespace
// and discarding empty entries. Returns nil for empty input so
// callers can range over it safely without a length check.
func parseList(s string) []string {
if s == "" {
return nil
}
parts := strings.Split(s, ",")
out := make([]string, 0, len(parts))
for _, p := range parts {
if p = strings.TrimSpace(p); p != "" {
out = append(out, p)
}
}
return out
}
+70
View File
@@ -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)
}
+130
View File
@@ -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 &amp; 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>`
+28
View File
@@ -0,0 +1,28 @@
package model
import "time"
// Feedback is a user-submitted message — bug report, feature request,
// or general comment — captured by the /feedback page. Surfaced to
// admins both via the /stats page and via an email fan-out on insert.
//
// UserID is a pointer so the model can carry anonymous submissions
// (logged-out visitors) and orphaned rows (the submitter has since
// deleted their account — the FK is set null). ContactEmail is the
// optional reply-to filled in by anonymous submitters.
//
// AuthorHandle / AuthorName / AuthorEmail are populated by joins in
// the repo's lookup helpers (used by the admin email template and the
// /stats page). They're nil for anonymous rows.
type Feedback struct {
ID string `json:"id"`
UserID *string `json:"user_id,omitempty"`
Category string `json:"category"`
Message string `json:"message"`
ContactEmail *string `json:"contact_email,omitempty"`
CreatedAt time.Time `json:"created_at"`
AuthorHandle *string `json:"author_handle,omitempty"`
AuthorName *string `json:"author_name,omitempty"`
AuthorEmail *string `json:"-"` // server-side only; never serialised
}
+8
View File
@@ -25,6 +25,14 @@ type Post struct {
// always false for anonymous viewers (no viewer = no reshare state). // always false for anonymous viewers (no viewer = no reshare state).
// Drives the post-card "Reshare ↔ Send" swap on the frontend. // Drives the post-card "Reshare ↔ Send" swap on the frontend.
ViewerHasReshared bool `json:"viewer_has_reshared"` ViewerHasReshared bool `json:"viewer_has_reshared"`
// ResharedAt is the timestamp at which the feed owner reshared this
// post. Non-nil only for rows that appear in a profile feed because
// the profile owner reshared them (i.e. the row comes from the
// post_reshares union branch of ListByUser / ListByHandle).
// Authored-by-feed-owner rows leave this nil. Drives the
// "↻ Reshared" badge in the post card.
ResharedAt *time.Time `json:"reshared_at,omitempty"`
} }
// PostImage represents one attached image on a post. // PostImage represents one attached image on a post.
+81
View File
@@ -0,0 +1,81 @@
package repository
import (
"context"
"fmt"
"github.com/prabhupadasays/spsays-bff/internal/model"
)
// FeedbackRepo wraps Postgres access for the feedback table.
type FeedbackRepo struct{ pg *Postgres }
func NewFeedbackRepo(pg *Postgres) *FeedbackRepo { return &FeedbackRepo{pg: pg} }
// Create inserts a single feedback row. userID is a pointer so the
// authed and anonymous paths share one method; contactEmail is also a
// pointer because authed submissions don't carry one (we read the
// submitter's email from the joined user row at notify time).
func (r *FeedbackRepo) Create(ctx context.Context, userID *string, category, message string, contactEmail *string) (*model.Feedback, error) {
f := &model.Feedback{}
err := r.pg.Pool.QueryRow(ctx, `
INSERT INTO feedback (user_id, category, message, contact_email)
VALUES ($1, $2, $3, $4)
RETURNING id, user_id, category, message, contact_email, created_at`,
userID, category, message, contactEmail,
).Scan(&f.ID, &f.UserID, &f.Category, &f.Message, &f.ContactEmail, &f.CreatedAt)
if err != nil {
return nil, fmt.Errorf("insert feedback: %w", err)
}
return f, nil
}
// FindByID returns one feedback row joined with the submitter's
// handle + name + email if a user_id is present. Used after Create to
// assemble the admin notification email with the proper author block.
func (r *FeedbackRepo) FindByID(ctx context.Context, id string) (*model.Feedback, error) {
f := &model.Feedback{}
err := r.pg.Pool.QueryRow(ctx, `
SELECT f.id, f.user_id, f.category, f.message, f.contact_email, f.created_at,
u.handle, u.name, u.email
FROM feedback f
LEFT JOIN users u ON u.id = f.user_id
WHERE f.id = $1`, id,
).Scan(&f.ID, &f.UserID, &f.Category, &f.Message, &f.ContactEmail, &f.CreatedAt,
&f.AuthorHandle, &f.AuthorName, &f.AuthorEmail)
if err != nil {
return nil, fmt.Errorf("find feedback: %w", err)
}
return f, nil
}
// 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).
func (r *FeedbackRepo) Recent(ctx context.Context, limit int) ([]model.Feedback, error) {
if limit <= 0 || limit > 200 {
limit = 25
}
rows, err := r.pg.Pool.Query(ctx, `
SELECT f.id, f.user_id, f.category, f.message, f.contact_email, f.created_at,
u.handle, u.name
FROM feedback f
LEFT JOIN users u ON u.id = f.user_id
ORDER BY f.created_at DESC
LIMIT $1`, limit,
)
if err != nil {
return nil, fmt.Errorf("query recent 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.AuthorHandle, &f.AuthorName); err != nil {
return nil, fmt.Errorf("scan feedback: %w", err)
}
out = append(out, f)
}
return out, rows.Err()
}
+66 -31
View File
@@ -126,41 +126,68 @@ func (r *PostRepo) FindByID(ctx context.Context, id string) (*model.Post, error)
return p, nil return p, nil
} }
// ListByHandle returns the public feed for a user, newest-first, paginated. // ListByHandle returns the profile feed for a user — authored posts
// plus posts the user reshared — newest-first, paginated. Resolves
// handle → user_id and then delegates to ListByUser so both code paths
// produce the same shape (and so the union query lives in one place).
// Unknown handles return an empty page (not an error) — that matches
// the pre-reshare behaviour.
func (r *PostRepo) ListByHandle(ctx context.Context, handle string, limit, offset int) ([]model.Post, int, error) { func (r *PostRepo) ListByHandle(ctx context.Context, handle string, limit, offset int) ([]model.Post, int, error) {
var total int var userID string
if err := r.pg.Pool.QueryRow(ctx, ` if err := r.pg.Pool.QueryRow(ctx, `SELECT id FROM users WHERE handle = $1`, handle).Scan(&userID); err != nil {
SELECT COUNT(*) FROM posts p if errors.Is(err, pgx.ErrNoRows) {
JOIN users u ON u.id = p.user_id return []model.Post{}, 0, nil
WHERE u.handle = $1`, handle, }
).Scan(&total); err != nil { return nil, 0, fmt.Errorf("lookup user by handle: %w", err)
return nil, 0, fmt.Errorf("count posts: %w", err)
} }
return r.ListByUser(ctx, userID, limit, offset)
const q = `
SELECT p.id, p.user_id, p.caption, p.source_url, COALESCE(p.source_title, ''),
p.created_at, p.updated_at, u.name, u.handle, u.avatar_url
FROM posts p
JOIN users u ON u.id = p.user_id
WHERE u.handle = $1
ORDER BY p.created_at DESC
LIMIT $2 OFFSET $3`
return r.collectPosts(ctx, total, q, handle, limit, offset)
} }
// ListByUser returns the user's own posts (private listing — same shape). // ListByUser returns the user's profile feed — authored posts plus
// posts they reshared. Each row carries a reshared_at timestamp that
// is NULL for authored rows and equal to post_reshares.created_at for
// reshared rows; rows are sorted by COALESCE(reshared_at, created_at)
// so fresh reshares of old posts surface at the top, matching the
// Twitter/X model.
//
// `total` is the union count so pagination spans the combined feed.
func (r *PostRepo) ListByUser(ctx context.Context, userID string, limit, offset int) ([]model.Post, int, error) { func (r *PostRepo) ListByUser(ctx context.Context, userID string, limit, offset int) ([]model.Post, int, error) {
var total int var total int
if err := r.pg.Pool.QueryRow(ctx, `SELECT COUNT(*) FROM posts WHERE user_id = $1`, userID).Scan(&total); err != nil { if err := r.pg.Pool.QueryRow(ctx, `
return nil, 0, fmt.Errorf("count posts: %w", err) SELECT
(SELECT COUNT(*) FROM posts WHERE user_id = $1)
+ (SELECT COUNT(*) FROM post_reshares WHERE user_id = $1)`,
userID,
).Scan(&total); err != nil {
return nil, 0, fmt.Errorf("count feed: %w", err)
} }
const q = ` const q = `
SELECT p.id, p.user_id, p.caption, p.source_url, COALESCE(p.source_title, ''), SELECT id, user_id, caption, source_url, source_title,
p.created_at, p.updated_at, u.name, u.handle, u.avatar_url created_at, updated_at, author_name, author_handle,
FROM posts p author_avatar_url, reshared_at
JOIN users u ON u.id = p.user_id FROM (
WHERE p.user_id = $1 SELECT p.id, p.user_id, p.caption, p.source_url,
ORDER BY p.created_at DESC COALESCE(p.source_title, '') AS source_title,
p.created_at, p.updated_at,
u.name AS author_name, u.handle AS author_handle,
u.avatar_url AS author_avatar_url,
NULL::timestamptz AS reshared_at
FROM posts p
JOIN users u ON u.id = p.user_id
WHERE p.user_id = $1
UNION ALL
SELECT p.id, p.user_id, p.caption, p.source_url,
COALESCE(p.source_title, '') AS source_title,
p.created_at, p.updated_at,
u.name AS author_name, u.handle AS author_handle,
u.avatar_url AS author_avatar_url,
r.created_at AS reshared_at
FROM post_reshares r
JOIN posts p ON p.id = r.post_id
JOIN users u ON u.id = p.user_id
WHERE r.user_id = $1
) feed
ORDER BY COALESCE(reshared_at, created_at) DESC, id
LIMIT $2 OFFSET $3` LIMIT $2 OFFSET $3`
return r.collectPosts(ctx, total, q, userID, limit, offset) return r.collectPosts(ctx, total, q, userID, limit, offset)
} }
@@ -178,13 +205,20 @@ func (r *PostRepo) Delete(ctx context.Context, postID, userID string) error {
return nil return nil
} }
// CountByUser is used by services that need to know how many posts a user // CountByUser returns the profile-feed total — authored + reshared —
// has — currently the public-profile endpoint only. // which is what the profile header's "N posts" badge displays. Stays
// in sync with ListByUser's total so the badge never disagrees with
// the page contents below it.
func (r *PostRepo) CountByUser(ctx context.Context, userID string) (int, error) { func (r *PostRepo) CountByUser(ctx context.Context, userID string) (int, error) {
var n int var n int
err := r.pg.Pool.QueryRow(ctx, `SELECT COUNT(*) FROM posts WHERE user_id = $1`, userID).Scan(&n) err := r.pg.Pool.QueryRow(ctx, `
SELECT
(SELECT COUNT(*) FROM posts WHERE user_id = $1)
+ (SELECT COUNT(*) FROM post_reshares WHERE user_id = $1)`,
userID,
).Scan(&n)
if err != nil { if err != nil {
return 0, fmt.Errorf("count user posts: %w", err) return 0, fmt.Errorf("count feed: %w", err)
} }
return n, nil return n, nil
} }
@@ -206,6 +240,7 @@ func (r *PostRepo) collectPosts(ctx context.Context, total int, query string, ar
&p.ID, &p.UserID, &p.Caption, &p.SourceURL, &p.SourceTitle, &p.ID, &p.UserID, &p.Caption, &p.SourceURL, &p.SourceTitle,
&p.CreatedAt, &p.UpdatedAt, &p.CreatedAt, &p.UpdatedAt,
&p.AuthorName, &p.AuthorHandle, &p.AuthorAvatarURL, &p.AuthorName, &p.AuthorHandle, &p.AuthorAvatarURL,
&p.ResharedAt,
); err != nil { ); err != nil {
return nil, 0, fmt.Errorf("scan post: %w", err) return nil, 0, fmt.Errorf("scan post: %w", err)
} }
+84
View File
@@ -0,0 +1,84 @@
package repository
import (
"context"
"fmt"
"time"
)
// StatsRepo runs the aggregate count queries that power /stats. Lives
// in its own file rather than spreading COUNT methods across every
// domain repo — keeps the at-a-glance "system snapshot" queries in
// one place and one trip into the pool.
type StatsRepo struct{ pg *Postgres }
func NewStatsRepo(pg *Postgres) *StatsRepo { return &StatsRepo{pg: pg} }
// StatsSnapshot bundles totals, last-7-days counts, and feedback
// category breakdowns. One value rendered by /stats per request.
type StatsSnapshot struct {
Users int
UsersLast7Days int
Posts int
PostsLast7Days int
Collections int
PublicCollections int
Bookmarks int
Reshares int
Feedback int
FeedbackLast7Days int
FeedbackByCategory []CategoryCount
}
type CategoryCount struct {
Category string
Count int
}
// Snapshot runs every count needed by /stats. Queries run in series —
// the table sizes are modest enough that chasing parallelism here
// would buy sub-ms wins for added complexity.
func (r *StatsRepo) Snapshot(ctx context.Context) (*StatsSnapshot, error) {
s := &StatsSnapshot{}
sevenAgo := time.Now().Add(-7 * 24 * time.Hour)
scalars := []struct {
sql string
args []any
dst *int
}{
{`SELECT COUNT(*) FROM users`, nil, &s.Users},
{`SELECT COUNT(*) FROM users WHERE created_at >= $1`, []any{sevenAgo}, &s.UsersLast7Days},
{`SELECT COUNT(*) FROM posts`, nil, &s.Posts},
{`SELECT COUNT(*) FROM posts WHERE created_at >= $1`, []any{sevenAgo}, &s.PostsLast7Days},
{`SELECT COUNT(*) FROM collections`, nil, &s.Collections},
{`SELECT COUNT(*) FROM collections WHERE is_public`, nil, &s.PublicCollections},
{`SELECT COUNT(*) FROM bookmarks`, nil, &s.Bookmarks},
{`SELECT COUNT(*) FROM post_reshares`, nil, &s.Reshares},
{`SELECT COUNT(*) FROM feedback`, nil, &s.Feedback},
{`SELECT COUNT(*) FROM feedback WHERE created_at >= $1`, []any{sevenAgo}, &s.FeedbackLast7Days},
}
for _, q := range scalars {
if err := r.pg.Pool.QueryRow(ctx, q.sql, q.args...).Scan(q.dst); err != nil {
return nil, fmt.Errorf("stats scalar %q: %w", q.sql, err)
}
}
rows, err := r.pg.Pool.Query(ctx, `
SELECT category, COUNT(*)
FROM feedback
GROUP BY category
ORDER BY COUNT(*) DESC, category ASC`)
if err != nil {
return nil, fmt.Errorf("feedback by category: %w", err)
}
defer rows.Close()
for rows.Next() {
var cc CategoryCount
if err := rows.Scan(&cc.Category, &cc.Count); err != nil {
return nil, fmt.Errorf("scan category: %w", err)
}
s.FeedbackByCategory = append(s.FeedbackByCategory, cc)
}
return s, rows.Err()
}
+32
View File
@@ -50,6 +50,8 @@ type Server struct {
bookmarkH *handler.BookmarkHandler bookmarkH *handler.BookmarkHandler
collectionH *handler.CollectionHandler collectionH *handler.CollectionHandler
profileH *handler.ProfileHandler profileH *handler.ProfileHandler
feedbackH *handler.FeedbackHandler
statsH *handler.StatsHandler
// Auth state (nil when Postgres is unavailable — /auth and /users return 503) // Auth state (nil when Postgres is unavailable — /auth and /users return 503)
authIssuer *auth.Issuer authIssuer *auth.Issuer
@@ -164,6 +166,14 @@ func (s *Server) initAuth(cfg *config.Config, logger *zap.Logger) {
s.bookmarkH = handler.NewBookmarkHandler(bookmarkSvc) s.bookmarkH = handler.NewBookmarkHandler(bookmarkSvc)
s.collectionH = handler.NewCollectionHandler(collectionSvc, postRepo, issuer, userRepo) s.collectionH = handler.NewCollectionHandler(collectionSvc, postRepo, issuer, userRepo)
s.profileH = handler.NewProfileHandler(userRepo, postRepo, collectionRepo) s.profileH = handler.NewProfileHandler(userRepo, postRepo, collectionRepo)
// Feedback + stats — share Postgres with the rest of /api/v1 and
// the existing mailer instance.
feedbackRepo := repository.NewFeedbackRepo(s.pg)
statsRepo := repository.NewStatsRepo(s.pg)
feedbackSvc := service.NewFeedbackService(feedbackRepo, mail, cfg.FeedbackAdminEmails, logger)
s.feedbackH = handler.NewFeedbackHandler(feedbackSvc)
s.statsH = handler.NewStatsHandler(statsRepo, feedbackRepo)
} }
// initShortener wires Postgres + Redis + the shorturl handler/workers. // initShortener wires Postgres + Redis + the shorturl handler/workers.
@@ -276,6 +286,14 @@ func (s *Server) setupRouter() {
w.Write([]byte(`{"status":"ok"}`)) w.Write([]byte(`{"status":"ok"}`))
}) })
// Public ops snapshot — aggregate counts + recent feedback rendered
// as HTML. Lives outside /api/v1 because it returns HTML, not JSON.
// Currently unauthenticated by product decision; gate here if that
// ever needs to change.
if s.statsH != nil {
r.Get("/stats", s.statsH.Render)
}
// API v1 // API v1
r.Route("/api/v1", func(r chi.Router) { r.Route("/api/v1", func(r chi.Router) {
// Books // Books
@@ -601,6 +619,20 @@ func (s *Server) setupRouter() {
} }
}) })
// --- Feedback ---
// OptionalAuth so authed callers' user_id is captured
// automatically (handler reads middleware.UserID), and
// anonymous callers are still accepted (they supply
// contact_email instead).
r.Group(func(r chi.Router) {
if s.feedbackH == nil {
r.HandleFunc("/feedback", s.authUnavailable)
return
}
r.Use(middleware.OptionalAuth(s.authIssuer, s.userRepo))
r.Post("/feedback", s.feedbackH.Submit)
})
// Push Notifications // Push Notifications
r.Route("/notifications", func(r chi.Router) { r.Route("/notifications", func(r chi.Router) {
// TODO: register push notification handlers // TODO: register push notification handlers
+167
View File
@@ -0,0 +1,167 @@
// Package service — feedback flow. Sits between the feedback handler
// and FeedbackRepo: validates the submission, writes the row, then
// fans an HTML notification email out to every address listed in
// FEEDBACK_ADMIN_EMAILS. The email is fire-and-forget — transient
// send failures are logged and don't fail the request, because the
// row is already in the DB and visible on /stats.
package service
import (
"bytes"
"context"
"errors"
"fmt"
"html/template"
"strings"
"github.com/prabhupadasays/spsays-bff/internal/model"
"github.com/prabhupadasays/spsays-bff/internal/repository"
"github.com/prabhupadasays/spsays-bff/pkg/mailer"
"go.uber.org/zap"
)
type FeedbackService struct {
repo *repository.FeedbackRepo
mail mailer.Mailer
admins []string
logger *zap.Logger
}
func NewFeedbackService(repo *repository.FeedbackRepo, mail mailer.Mailer, admins []string, logger *zap.Logger) *FeedbackService {
return &FeedbackService{repo: repo, mail: mail, admins: admins, logger: logger}
}
// Allowed categories — kept lowercase in storage so dashboards group
// cleanly. The frontend dropdown sends one of these exact strings.
var feedbackCategories = map[string]bool{
"bug": true,
"feature": true,
"general": true,
"other": true,
}
// Submit persists a feedback row and fires the admin email.
//
// userID is nil for anonymous submissions; contactEmail is the
// optional reply-to in that case. For authed users we ignore whatever
// the form sent and read the canonical email off the joined user row
// when assembling the notification — that keeps the reply path
// trustworthy (the form can't lie about who's writing).
func (s *FeedbackService) Submit(ctx context.Context, userID *string, category, message string, contactEmail *string) (*model.Feedback, error) {
category = strings.ToLower(strings.TrimSpace(category))
if !feedbackCategories[category] {
return nil, errors.New("invalid category")
}
message = strings.TrimSpace(message)
if message == "" {
return nil, errors.New("message is required")
}
if len(message) > 5000 {
return nil, errors.New("message too long (max 5000)")
}
if userID == nil {
if contactEmail == nil || strings.TrimSpace(*contactEmail) == "" {
return nil, errors.New("contact_email is required when not logged in")
}
trimmed := strings.TrimSpace(*contactEmail)
contactEmail = &trimmed
} else {
// Authed: ignore client-supplied contact_email; user row owns it.
contactEmail = nil
}
f, err := s.repo.Create(ctx, userID, category, message, contactEmail)
if err != nil {
return nil, err
}
// Hydrate the joined author fields for the email template. Failing
// the lookup isn't worth failing the request — we fall back to
// "anonymous" rather than 500ing on the user.
if userID != nil {
if full, err := s.repo.FindByID(ctx, f.ID); err == nil {
f = full
} else {
s.logger.Warn("feedback hydrate failed; sending email without author info",
zap.String("feedback_id", f.ID), zap.Error(err))
}
}
s.notifyAdmins(f)
return f, nil
}
// notifyAdmins fires one HTML email per configured admin. Each
// failure logs and falls through — the row is persisted so a missed
// email is annoying but not data-losing.
func (s *FeedbackService) notifyAdmins(f *model.Feedback) {
if len(s.admins) == 0 {
s.logger.Info("feedback received but no admin recipients configured",
zap.String("feedback_id", f.ID))
return
}
body, err := renderFeedbackEmail(f)
if err != nil {
s.logger.Warn("render feedback email failed", zap.Error(err))
return
}
subject := fmt.Sprintf("[Feedback · %s] %s", f.Category, summarize(f.Message, 60))
for _, to := range s.admins {
if err := s.mail.Send(to, subject, body); err != nil {
s.logger.Warn("feedback email send failed",
zap.String("to", to), zap.Error(err))
}
}
}
func summarize(s string, max int) string {
s = strings.TrimSpace(strings.ReplaceAll(s, "\n", " "))
runes := []rune(s)
if len(runes) <= max {
return s
}
return string(runes[:max-1]) + "…"
}
var feedbackEmailTpl = template.Must(template.New("feedback").Parse(`<p><strong>New feedback received</strong></p>
<p><strong>Category:</strong> {{.Category}}<br>
<strong>Submitted:</strong> {{.CreatedAt}}<br>
<strong>From:</strong> {{.From}}</p>
<p><strong>Message:</strong></p>
<pre style="white-space:pre-wrap;font-family:inherit;background:#fafafa;padding:0.75em 1em;border-radius:6px;border:1px solid #eee">{{.Message}}</pre>
<p style="color:#999;font-size:12px">Feedback ID: {{.ID}}</p>`))
func renderFeedbackEmail(f *model.Feedback) (string, error) {
var from string
switch {
case f.AuthorHandle != nil && *f.AuthorHandle != "":
from = fmt.Sprintf("%s (@%s)", derefOr(f.AuthorName, "—"), *f.AuthorHandle)
if f.AuthorEmail != nil && *f.AuthorEmail != "" {
from += " — " + *f.AuthorEmail
}
case f.ContactEmail != nil && *f.ContactEmail != "":
from = "anonymous — " + *f.ContactEmail
default:
from = "anonymous"
}
var buf bytes.Buffer
err := feedbackEmailTpl.Execute(&buf, map[string]any{
"Category": f.Category,
"Message": f.Message,
"From": from,
"CreatedAt": f.CreatedAt.Format("2006-01-02 15:04:05 MST"),
"ID": f.ID,
})
if err != nil {
return "", err
}
return buf.String(), nil
}
func derefOr(p *string, fallback string) string {
if p == nil || *p == "" {
return fallback
}
return *p
}
+16 -3
View File
@@ -180,8 +180,12 @@ func (s *PostService) ListByHandle(ctx context.Context, handle, viewerID string,
return posts, total, nil return posts, total, nil
} }
// ListMine is the owner's view of their own posts. Reshare hydration // ListMine is the owner's view of their own profile feed — authored
// is skipped — the caller can't reshare their own posts. // posts plus reshares (the repo's ListByUser unions the two). The
// viewer here is by construction the feed owner, so every reshared
// row is one the viewer themselves reshared — we flip
// ViewerHasReshared inline rather than spending a hydration round-trip
// to learn what we already know.
func (s *PostService) ListMine(ctx context.Context, userID string, limit, offset int) ([]model.Post, int, error) { func (s *PostService) ListMine(ctx context.Context, userID string, limit, offset int) ([]model.Post, int, error) {
if limit <= 0 || limit > 50 { if limit <= 0 || limit > 50 {
limit = 20 limit = 20
@@ -189,7 +193,16 @@ func (s *PostService) ListMine(ctx context.Context, userID string, limit, offset
if offset < 0 { if offset < 0 {
offset = 0 offset = 0
} }
return s.posts.ListByUser(ctx, userID, limit, offset) posts, total, err := s.posts.ListByUser(ctx, userID, limit, offset)
if err != nil {
return nil, 0, err
}
for i := range posts {
if posts[i].ResharedAt != nil {
posts[i].ViewerHasReshared = true
}
}
return posts, total, nil
} }
// ImagePresign returns a one-shot R2 PUT URL for a post image. // ImagePresign returns a one-shot R2 PUT URL for a post image.
+2
View File
@@ -0,0 +1,2 @@
DROP INDEX IF EXISTS idx_feedback_created;
DROP TABLE IF EXISTS feedback;
+33
View File
@@ -0,0 +1,33 @@
-- Feedback collects user-submitted bug reports, feature requests, and
-- general comments captured by the frontend's /feedback page. Two
-- consumers:
-- 1. The team — every insert fans out an HTML notification to every
-- address in FEEDBACK_ADMIN_EMAILS via the existing mailer.
-- 2. The /stats ops page — aggregate counts + the most-recent N rows
-- render together so we can scan recent feedback without having to
-- open the DB.
--
-- user_id is nullable (and ON DELETE SET NULL on cascade) because:
-- • Anonymous visitors submit feedback too — they fill in the optional
-- contact_email field instead of leaving an account behind.
-- • When a user later deletes their account we keep the row but
-- forget who sent it. The content stays useful as product signal
-- even after the author is gone.
--
-- contact_email is the soft reply-to surfaced only for the anonymous
-- case; the logged-in case derives reply-to from the joined user row's
-- email at admin-notification time.
--
-- Index on created_at DESC keeps the /stats "recent feedback" list
-- cheap as the table grows.
CREATE TABLE IF NOT EXISTS feedback (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID REFERENCES users(id) ON DELETE SET NULL,
category TEXT NOT NULL,
message TEXT NOT NULL,
contact_email TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_feedback_created
ON feedback (created_at DESC);