feat: add stats page
This commit is contained in:
@@ -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
|
||||
}
|
||||
return nil, 0, fmt.Errorf("lookup user by handle: %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
|
||||
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 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
|
||||
FROM posts p
|
||||
JOIN users u ON u.id = p.user_id
|
||||
WHERE p.user_id = $1
|
||||
ORDER BY p.created_at DESC
|
||||
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
|
||||
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()
|
||||
}
|
||||
Reference in New Issue
Block a user