package repository import ( "context" "errors" "fmt" "github.com/jackc/pgx/v5" "github.com/prabhupadasays/spsays-bff/internal/model" ) // AdminRepo holds the read-mostly queries that back the admin panel. Every // list method returns its page plus the unfiltered total (via COUNT(*) OVER()) // so the UI can paginate without a second round-trip. All queries are new — // they read existing tables but don't alter any existing repo behaviour. type AdminRepo struct{ pg *Postgres } func NewAdminRepo(pg *Postgres) *AdminRepo { return &AdminRepo{pg: pg} } // clampLimit normalises pagination so a bad client can't ask for everything. func clampLimit(limit int) int { if limit <= 0 { return 25 } if limit > 100 { return 100 } return limit } // ListUsers returns a page of users filtered by an optional query (matched // against name, handle, or email), newest first, with the total count. func (r *AdminRepo) ListUsers(ctx context.Context, q string, limit, offset int) ([]model.AdminUser, int, error) { limit = clampLimit(limit) rows, err := r.pg.Pool.Query(ctx, ` SELECT id, email, handle, name, avatar_url, role, plan, email_verified_at, banned_at, created_at, COUNT(*) OVER() AS total FROM users WHERE ($1 = '' OR name ILIKE '%'||$1||'%' OR handle ILIKE '%'||$1||'%' OR COALESCE(email, '') ILIKE '%'||$1||'%') ORDER BY created_at DESC LIMIT $2 OFFSET $3`, q, limit, offset) if err != nil { return nil, 0, fmt.Errorf("admin list users: %w", err) } defer rows.Close() out := []model.AdminUser{} total := 0 for rows.Next() { var u model.AdminUser if err := rows.Scan(&u.ID, &u.Email, &u.Handle, &u.Name, &u.AvatarURL, &u.Role, &u.Plan, &u.EmailVerifiedAt, &u.BannedAt, &u.CreatedAt, &total); err != nil { return nil, 0, fmt.Errorf("scan admin user: %w", err) } out = append(out, u) } return out, total, rows.Err() } // UserExists reports whether a user id is real (used before sending a // notification to it). func (r *AdminRepo) UserExists(ctx context.Context, id string) (bool, error) { var exists bool err := r.pg.Pool.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM users WHERE id = $1)`, id).Scan(&exists) if err != nil { return false, fmt.Errorf("admin user exists: %w", err) } return exists, nil } // ListPosts returns a page of posts filtered by an optional query (caption, // source title, or author handle), newest first, with the total count. func (r *AdminRepo) ListPosts(ctx context.Context, q string, limit, offset int) ([]model.AdminPost, int, error) { limit = clampLimit(limit) rows, err := r.pg.Pool.Query(ctx, ` SELECT p.id, p.user_id, p.caption, p.source_title, p.source_url, p.is_hidden, p.deleted_at, p.created_at, u.name, u.handle, p.likes_count, (SELECT COUNT(*) FROM post_comments c WHERE c.post_id = p.id AND c.deleted_at IS NULL) AS comments_count, COUNT(*) OVER() AS total FROM posts p JOIN users u ON u.id = p.user_id WHERE ($1 = '' OR p.caption ILIKE '%'||$1||'%' OR p.source_title ILIKE '%'||$1||'%' OR u.handle ILIKE '%'||$1||'%') ORDER BY p.created_at DESC LIMIT $2 OFFSET $3`, q, limit, offset) if err != nil { return nil, 0, fmt.Errorf("admin list posts: %w", err) } defer rows.Close() out := []model.AdminPost{} total := 0 for rows.Next() { var p model.AdminPost if err := rows.Scan(&p.ID, &p.UserID, &p.Caption, &p.SourceTitle, &p.SourceURL, &p.IsHidden, &p.DeletedAt, &p.CreatedAt, &p.AuthorName, &p.AuthorHandle, &p.LikesCount, &p.CommentsCount, &total); err != nil { return nil, 0, fmt.Errorf("scan admin post: %w", err) } out = append(out, p) } return out, total, rows.Err() } // ListCollections returns a page of collections filtered by an optional query // (title, description, or owner handle), newest first, with the total count. func (r *AdminRepo) ListCollections(ctx context.Context, q string, limit, offset int) ([]model.AdminCollection, int, error) { limit = clampLimit(limit) rows, err := r.pg.Pool.Query(ctx, ` SELECT c.id, c.user_id, c.title, c.description, c.is_public, c.created_at, (SELECT COUNT(*) FROM collection_items ci WHERE ci.collection_id = c.id) AS item_count, u.name, u.handle, COUNT(*) OVER() AS total FROM collections c JOIN users u ON u.id = c.user_id WHERE ($1 = '' OR c.title ILIKE '%'||$1||'%' OR COALESCE(c.description, '') ILIKE '%'||$1||'%' OR u.handle ILIKE '%'||$1||'%') ORDER BY c.created_at DESC LIMIT $2 OFFSET $3`, q, limit, offset) if err != nil { return nil, 0, fmt.Errorf("admin list collections: %w", err) } defer rows.Close() out := []model.AdminCollection{} total := 0 for rows.Next() { var c model.AdminCollection if err := rows.Scan(&c.ID, &c.UserID, &c.Title, &c.Description, &c.IsPublic, &c.CreatedAt, &c.ItemCount, &c.OwnerName, &c.OwnerHandle, &total); err != nil { return nil, 0, fmt.Errorf("scan admin collection: %w", err) } out = append(out, c) } return out, total, rows.Err() } // ListFeedback returns a page of feedback. status is "all" | "open" | // "resolved". Newest first, with the total count for the chosen filter. func (r *AdminRepo) ListFeedback(ctx context.Context, status string, limit, offset int) ([]model.AdminFeedback, int, error) { limit = clampLimit(limit) if status != "open" && status != "resolved" { status = "all" } rows, err := r.pg.Pool.Query(ctx, ` SELECT f.id, f.category, f.message, f.contact_email, f.created_at, f.resolved_at, f.replied_at, f.reply_message, u.handle, u.name, u.email, COUNT(*) OVER() AS total FROM feedback f LEFT JOIN users u ON u.id = f.user_id WHERE ($1 = 'all' OR ($1 = 'open' AND f.resolved_at IS NULL) OR ($1 = 'resolved' AND f.resolved_at IS NOT NULL)) ORDER BY f.created_at DESC LIMIT $2 OFFSET $3`, status, limit, offset) if err != nil { return nil, 0, fmt.Errorf("admin list feedback: %w", err) } defer rows.Close() out := []model.AdminFeedback{} total := 0 for rows.Next() { var f model.AdminFeedback if err := rows.Scan(&f.ID, &f.Category, &f.Message, &f.ContactEmail, &f.CreatedAt, &f.ResolvedAt, &f.RepliedAt, &f.ReplyMessage, &f.AuthorHandle, &f.AuthorName, &f.AuthorEmail, &total); err != nil { return nil, 0, fmt.Errorf("scan admin feedback: %w", err) } out = append(out, f) } return out, total, rows.Err() } // SaveReply stores an admin reply on a feedback row, stamps replied_at, and // resolves it (if not already). It returns the recipient's email (the // submitter's account email, else the anonymous contact_email — may be nil if // neither is known) and name so the caller can send the reply email. ok is // false when the id doesn't exist. func (r *AdminRepo) SaveReply(ctx context.Context, id, message string) (email *string, name string, ok bool, err error) { err = r.pg.Pool.QueryRow(ctx, ` UPDATE feedback f SET reply_message = $2, replied_at = now(), resolved_at = COALESCE(resolved_at, now()) WHERE f.id = $1 RETURNING COALESCE((SELECT email FROM users WHERE id = f.user_id), f.contact_email), COALESCE((SELECT name FROM users WHERE id = f.user_id), '')`, id, message, ).Scan(&email, &name) if errors.Is(err, pgx.ErrNoRows) { return nil, "", false, nil } if err != nil { return nil, "", false, fmt.Errorf("admin save feedback reply: %w", err) } return email, name, true, nil } // ListReports returns a page of reports of one type ("post" | "comment" | // "user"), unresolved first then newest, filtered by an optional query, with // the total count. func (r *AdminRepo) ListReports(ctx context.Context, reportType, q string, limit, offset int) ([]model.AdminReport, int, error) { limit = clampLimit(limit) var sql string switch reportType { case "post": sql = ` SELECT r.id, r.category, r.reason, r.created_at, r.resolved_at, ru.handle, ru.name, r.post_id, p.caption, pu.handle, COUNT(*) OVER() AS total FROM post_reports r LEFT JOIN users ru ON ru.id = r.reporter_id LEFT JOIN posts p ON p.id = r.post_id LEFT JOIN users pu ON pu.id = p.user_id WHERE ($1 = '' OR COALESCE(p.caption, '') ILIKE '%'||$1||'%' OR COALESCE(ru.handle, '') ILIKE '%'||$1||'%' OR COALESCE(pu.handle, '') ILIKE '%'||$1||'%') ORDER BY (r.resolved_at IS NOT NULL), r.created_at DESC LIMIT $2 OFFSET $3` case "comment": sql = ` SELECT r.id, r.category, r.reason, r.created_at, r.resolved_at, ru.handle, ru.name, r.comment_id, c.body, cu.handle, COUNT(*) OVER() AS total FROM comment_reports r LEFT JOIN users ru ON ru.id = r.reporter_id LEFT JOIN post_comments c ON c.id = r.comment_id LEFT JOIN users cu ON cu.id = c.user_id WHERE ($1 = '' OR COALESCE(c.body, '') ILIKE '%'||$1||'%' OR COALESCE(ru.handle, '') ILIKE '%'||$1||'%' OR COALESCE(cu.handle, '') ILIKE '%'||$1||'%') ORDER BY (r.resolved_at IS NOT NULL), r.created_at DESC LIMIT $2 OFFSET $3` case "user": sql = ` SELECT r.id, r.category, r.reason, r.created_at, r.resolved_at, ru.handle, ru.name, r.reported_id, tu.handle, tu.handle , COUNT(*) OVER() AS total FROM user_reports r LEFT JOIN users ru ON ru.id = r.reporter_id LEFT JOIN users tu ON tu.id = r.reported_id WHERE ($1 = '' OR COALESCE(tu.handle, '') ILIKE '%'||$1||'%' OR COALESCE(tu.name, '') ILIKE '%'||$1||'%' OR COALESCE(ru.handle, '') ILIKE '%'||$1||'%') ORDER BY (r.resolved_at IS NOT NULL), r.created_at DESC LIMIT $2 OFFSET $3` default: return nil, 0, fmt.Errorf("unknown report type %q", reportType) } rows, err := r.pg.Pool.Query(ctx, sql, q, limit, offset) if err != nil { return nil, 0, fmt.Errorf("admin list %s reports: %w", reportType, err) } defer rows.Close() out := []model.AdminReport{} total := 0 for rows.Next() { rep := model.AdminReport{Type: reportType} if err := rows.Scan(&rep.ID, &rep.Category, &rep.Reason, &rep.CreatedAt, &rep.ResolvedAt, &rep.ReporterHandle, &rep.ReporterName, &rep.TargetID, &rep.TargetText, &rep.TargetAuthorHandle, &total); err != nil { return nil, 0, fmt.Errorf("scan admin report: %w", err) } out = append(out, rep) } return out, total, rows.Err() } // ResolveReport stamps resolved_at=now() on a still-open report of the given // type. Idempotent and one-way (the WHERE guard). Returns true when this call // is the one that resolved it. func (r *AdminRepo) ResolveReport(ctx context.Context, reportType, id string) (bool, error) { var table string switch reportType { case "post": table = "post_reports" case "comment": table = "comment_reports" case "user": table = "user_reports" default: return false, fmt.Errorf("unknown report type %q", reportType) } // table is from a fixed whitelist above — safe to interpolate. tag, err := r.pg.Pool.Exec(ctx, fmt.Sprintf(`UPDATE %s SET resolved_at = now() WHERE id = $1 AND resolved_at IS NULL`, table), id, ) if err != nil { return false, fmt.Errorf("admin resolve %s report: %w", reportType, err) } return tag.RowsAffected() == 1, nil } // ─── Moderation actions ────────────────────────────────────────────────────── // SetPostHidden hides/unhides any post (admin override — not owner-scoped). // Returns false if the id doesn't exist. func (r *AdminRepo) SetPostHidden(ctx context.Context, postID string, hidden bool) (bool, error) { tag, err := r.pg.Pool.Exec(ctx, `UPDATE posts SET is_hidden = $2, updated_at = now() WHERE id = $1`, postID, hidden) if err != nil { return false, fmt.Errorf("admin set post hidden: %w", err) } return tag.RowsAffected() == 1, nil } // SetPostDeleted soft-deletes or restores a post by stamping/clearing // deleted_at. A soft-deleted post is filtered out of every read path (feeds, // profile, hidden tab, collections, detail) but kept in the table so it can be // restored. Returns false if the id doesn't exist. func (r *AdminRepo) SetPostDeleted(ctx context.Context, postID string, deleted bool) (bool, error) { var sql string if deleted { sql = `UPDATE posts SET deleted_at = now(), updated_at = now() WHERE id = $1` } else { sql = `UPDATE posts SET deleted_at = NULL, updated_at = now() WHERE id = $1` } tag, err := r.pg.Pool.Exec(ctx, sql, postID) if err != nil { return false, fmt.Errorf("admin set post deleted: %w", err) } return tag.RowsAffected() == 1, nil } // SetCommentDeleted soft-deletes or restores a comment (same semantics as // SetPostDeleted). Returns false if the id doesn't exist. func (r *AdminRepo) SetCommentDeleted(ctx context.Context, commentID string, deleted bool) (bool, error) { var sql string if deleted { sql = `UPDATE post_comments SET deleted_at = now(), updated_at = now() WHERE id = $1` } else { sql = `UPDATE post_comments SET deleted_at = NULL, updated_at = now() WHERE id = $1` } tag, err := r.pg.Pool.Exec(ctx, sql, commentID) if err != nil { return false, fmt.Errorf("admin set comment deleted: %w", err) } return tag.RowsAffected() == 1, nil } // SetUserBanned suspends/un-suspends an account. Banning also bumps // token_version, which invalidates the user's existing JWTs so the lock-out is // immediate (RequireAuth additionally rejects banned users). Returns false if // the id doesn't exist. func (r *AdminRepo) SetUserBanned(ctx context.Context, userID string, banned bool) (bool, error) { var sql string if banned { sql = `UPDATE users SET banned_at = now(), token_version = token_version + 1, updated_at = now() WHERE id = $1` } else { sql = `UPDATE users SET banned_at = NULL, updated_at = now() WHERE id = $1` } tag, err := r.pg.Pool.Exec(ctx, sql, userID) if err != nil { return false, fmt.Errorf("admin set user banned: %w", err) } return tag.RowsAffected() == 1, nil }