feat: add stats page
This commit is contained in:
+6
-1
@@ -25,13 +25,18 @@ R2_BUCKET=
|
||||
# Public base URL where bucket objects are served (custom domain or pub-XXX.r2.dev)
|
||||
R2_PUBLIC_URL=
|
||||
|
||||
# SMTP (password-reset + email-verification)
|
||||
# SMTP (password-reset + email-verification + /feedback admin notifications)
|
||||
SMTP_HOST=
|
||||
SMTP_PORT=587
|
||||
SMTP_USERNAME=
|
||||
SMTP_PASSWORD=
|
||||
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
|
||||
FCM_CREDENTIALS_FILE=
|
||||
APNS_KEY_FILE=
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"fmt"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/joho/godotenv"
|
||||
@@ -36,13 +37,19 @@ type Config struct {
|
||||
R2Bucket string
|
||||
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
|
||||
SMTPPort int
|
||||
SMTPUsername string
|
||||
SMTPPassword string
|
||||
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).
|
||||
FCMCredentialsFile string
|
||||
APNsKeyFile string
|
||||
@@ -95,6 +102,8 @@ func Load() (*Config, error) {
|
||||
SMTPPassword: getEnv("SMTP_PASSWORD", ""),
|
||||
SMTPFrom: getEnv("SMTP_FROM", "noreply@localhost"),
|
||||
|
||||
FeedbackAdminEmails: parseList(getEnv("FEEDBACK_ADMIN_EMAILS", "")),
|
||||
|
||||
FCMCredentialsFile: getEnv("FCM_CREDENTIALS_FILE", ""),
|
||||
APNsKeyFile: getEnv("APNS_KEY_FILE", ""),
|
||||
APNsKeyID: getEnv("APNS_KEY_ID", ""),
|
||||
@@ -147,3 +156,20 @@ func getEnvBool(key string, fallback bool) bool {
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
@@ -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>`
|
||||
@@ -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
|
||||
}
|
||||
@@ -25,6 +25,14 @@ type Post struct {
|
||||
// always false for anonymous viewers (no viewer = no reshare state).
|
||||
// Drives the post-card "Reshare ↔ Send" swap on the frontend.
|
||||
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.
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
@@ -126,41 +126,68 @@ func (r *PostRepo) FindByID(ctx context.Context, id string) (*model.Post, error)
|
||||
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) {
|
||||
var total int
|
||||
if err := r.pg.Pool.QueryRow(ctx, `
|
||||
SELECT COUNT(*) FROM posts p
|
||||
JOIN users u ON u.id = p.user_id
|
||||
WHERE u.handle = $1`, handle,
|
||||
).Scan(&total); err != nil {
|
||||
return nil, 0, fmt.Errorf("count posts: %w", err)
|
||||
var userID string
|
||||
if err := r.pg.Pool.QueryRow(ctx, `SELECT id FROM users WHERE handle = $1`, handle).Scan(&userID); err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return []model.Post{}, 0, nil
|
||||
}
|
||||
|
||||
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)
|
||||
return nil, 0, fmt.Errorf("lookup user by handle: %w", err)
|
||||
}
|
||||
return r.ListByUser(ctx, userID, 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) {
|
||||
var total int
|
||||
if err := r.pg.Pool.QueryRow(ctx, `SELECT COUNT(*) FROM posts WHERE user_id = $1`, userID).Scan(&total); err != nil {
|
||||
return nil, 0, fmt.Errorf("count posts: %w", err)
|
||||
if 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(&total); err != nil {
|
||||
return nil, 0, fmt.Errorf("count feed: %w", err)
|
||||
}
|
||||
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
|
||||
SELECT id, user_id, caption, source_url, source_title,
|
||||
created_at, updated_at, author_name, author_handle,
|
||||
author_avatar_url, reshared_at
|
||||
FROM (
|
||||
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,
|
||||
NULL::timestamptz AS reshared_at
|
||||
FROM posts p
|
||||
JOIN users u ON u.id = p.user_id
|
||||
WHERE p.user_id = $1
|
||||
ORDER BY p.created_at DESC
|
||||
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`
|
||||
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
|
||||
}
|
||||
|
||||
// CountByUser is used by services that need to know how many posts a user
|
||||
// has — currently the public-profile endpoint only.
|
||||
// CountByUser returns the profile-feed total — authored + reshared —
|
||||
// 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) {
|
||||
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 {
|
||||
return 0, fmt.Errorf("count user posts: %w", err)
|
||||
return 0, fmt.Errorf("count feed: %w", err)
|
||||
}
|
||||
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.CreatedAt, &p.UpdatedAt,
|
||||
&p.AuthorName, &p.AuthorHandle, &p.AuthorAvatarURL,
|
||||
&p.ResharedAt,
|
||||
); err != nil {
|
||||
return nil, 0, fmt.Errorf("scan post: %w", err)
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
@@ -50,6 +50,8 @@ type Server struct {
|
||||
bookmarkH *handler.BookmarkHandler
|
||||
collectionH *handler.CollectionHandler
|
||||
profileH *handler.ProfileHandler
|
||||
feedbackH *handler.FeedbackHandler
|
||||
statsH *handler.StatsHandler
|
||||
|
||||
// Auth state (nil when Postgres is unavailable — /auth and /users return 503)
|
||||
authIssuer *auth.Issuer
|
||||
@@ -164,6 +166,14 @@ func (s *Server) initAuth(cfg *config.Config, logger *zap.Logger) {
|
||||
s.bookmarkH = handler.NewBookmarkHandler(bookmarkSvc)
|
||||
s.collectionH = handler.NewCollectionHandler(collectionSvc, postRepo, issuer, userRepo)
|
||||
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.
|
||||
@@ -276,6 +286,14 @@ func (s *Server) setupRouter() {
|
||||
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
|
||||
r.Route("/api/v1", func(r chi.Router) {
|
||||
// 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
|
||||
r.Route("/notifications", func(r chi.Router) {
|
||||
// TODO: register push notification handlers
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -180,8 +180,12 @@ func (s *PostService) ListByHandle(ctx context.Context, handle, viewerID string,
|
||||
return posts, total, nil
|
||||
}
|
||||
|
||||
// ListMine is the owner's view of their own posts. Reshare hydration
|
||||
// is skipped — the caller can't reshare their own posts.
|
||||
// ListMine is the owner's view of their own profile feed — authored
|
||||
// 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) {
|
||||
if limit <= 0 || limit > 50 {
|
||||
limit = 20
|
||||
@@ -189,7 +193,16 @@ func (s *PostService) ListMine(ctx context.Context, userID string, limit, offset
|
||||
if 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.
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
DROP INDEX IF EXISTS idx_feedback_created;
|
||||
DROP TABLE IF EXISTS feedback;
|
||||
@@ -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);
|
||||
Reference in New Issue
Block a user