feat: add admin apis
This commit is contained in:
@@ -0,0 +1,367 @@
|
||||
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
|
||||
}
|
||||
@@ -211,7 +211,7 @@ func (r *CollectionRepo) PostsIn(ctx context.Context, collectionID string) ([]mo
|
||||
FROM collection_items ci
|
||||
JOIN posts p ON p.id = ci.post_id
|
||||
JOIN users u ON u.id = p.user_id
|
||||
WHERE ci.collection_id = $1
|
||||
WHERE ci.collection_id = $1 AND p.deleted_at IS NULL
|
||||
ORDER BY ci.position, ci.added_at`,
|
||||
collectionID,
|
||||
)
|
||||
|
||||
@@ -49,7 +49,7 @@ const commentSelect = `
|
||||
u.name, u.handle, u.avatar_url,
|
||||
ru.handle,
|
||||
(vl.comment_id IS NOT NULL),
|
||||
(SELECT count(*) FROM post_comments rc WHERE rc.parent_id = c.id)`
|
||||
(SELECT count(*) FROM post_comments rc WHERE rc.parent_id = c.id AND rc.deleted_at IS NULL)`
|
||||
|
||||
// The viewer id always rides in parameter slot $2 across the read queries
|
||||
// below, so the shared viewer-scoped like join can hardcode it.
|
||||
@@ -120,7 +120,7 @@ func (r *CommentRepo) Create(ctx context.Context, postID, userID string, parentI
|
||||
|
||||
// Get returns one comment hydrated with author + viewer-like + reply count.
|
||||
func (r *CommentRepo) Get(ctx context.Context, id, viewerID string) (*model.Comment, error) {
|
||||
q := `SELECT ` + commentSelect + commentFrom + ` WHERE c.id = $1`
|
||||
q := `SELECT ` + commentSelect + commentFrom + ` WHERE c.id = $1 AND c.deleted_at IS NULL`
|
||||
c := &model.Comment{}
|
||||
err := scanComment(r.pg.Pool.QueryRow(ctx, q, id, nullUUIDArg(viewerID)), c)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
@@ -137,7 +137,7 @@ func (r *CommentRepo) Get(ctx context.Context, id, viewerID string) (*model.Comm
|
||||
func (r *CommentRepo) ListTopLevel(ctx context.Context, postID, viewerID string, limit, offset int) ([]model.Comment, int, error) {
|
||||
var total int
|
||||
if err := r.pg.Pool.QueryRow(ctx,
|
||||
`SELECT count(*) FROM post_comments WHERE post_id = $1 AND parent_id IS NULL`,
|
||||
`SELECT count(*) FROM post_comments WHERE post_id = $1 AND parent_id IS NULL AND deleted_at IS NULL`,
|
||||
postID).Scan(&total); err != nil {
|
||||
return nil, 0, fmt.Errorf("count comments: %w", err)
|
||||
}
|
||||
@@ -145,7 +145,7 @@ func (r *CommentRepo) ListTopLevel(ctx context.Context, postID, viewerID string,
|
||||
return []model.Comment{}, 0, nil
|
||||
}
|
||||
q := `SELECT ` + commentSelect + commentFrom + `
|
||||
WHERE c.post_id = $1 AND c.parent_id IS NULL
|
||||
WHERE c.post_id = $1 AND c.parent_id IS NULL AND c.deleted_at IS NULL
|
||||
ORDER BY (c.pinned_at IS NOT NULL) DESC, c.pinned_at DESC NULLS LAST, c.created_at DESC
|
||||
LIMIT $3 OFFSET $4`
|
||||
rows, err := r.pg.Pool.Query(ctx, q, postID, nullUUIDArg(viewerID), limit, offset)
|
||||
@@ -160,14 +160,14 @@ func (r *CommentRepo) ListTopLevel(ctx context.Context, postID, viewerID string,
|
||||
func (r *CommentRepo) ListReplies(ctx context.Context, parentID, viewerID string, limit, offset int) ([]model.Comment, int, error) {
|
||||
var total int
|
||||
if err := r.pg.Pool.QueryRow(ctx,
|
||||
`SELECT count(*) FROM post_comments WHERE parent_id = $1`, parentID).Scan(&total); err != nil {
|
||||
`SELECT count(*) FROM post_comments WHERE parent_id = $1 AND deleted_at IS NULL`, parentID).Scan(&total); err != nil {
|
||||
return nil, 0, fmt.Errorf("count replies: %w", err)
|
||||
}
|
||||
if total == 0 {
|
||||
return []model.Comment{}, 0, nil
|
||||
}
|
||||
q := `SELECT ` + commentSelect + commentFrom + `
|
||||
WHERE c.parent_id = $1
|
||||
WHERE c.parent_id = $1 AND c.deleted_at IS NULL
|
||||
ORDER BY c.created_at ASC
|
||||
LIMIT $3 OFFSET $4`
|
||||
rows, err := r.pg.Pool.Query(ctx, q, parentID, nullUUIDArg(viewerID), limit, offset)
|
||||
@@ -211,7 +211,7 @@ func (r *CommentRepo) Hydrate(ctx context.Context, viewerID string, posts []*mod
|
||||
// Total comment count per post (top-level + replies).
|
||||
counts := make(map[string]int, len(ids))
|
||||
crows, err := r.pg.Pool.Query(ctx,
|
||||
`SELECT post_id, count(*) FROM post_comments WHERE post_id = ANY($1) GROUP BY post_id`,
|
||||
`SELECT post_id, count(*) FROM post_comments WHERE post_id = ANY($1) AND deleted_at IS NULL GROUP BY post_id`,
|
||||
ids)
|
||||
if err != nil {
|
||||
return fmt.Errorf("hydrate comment counts: %w", err)
|
||||
|
||||
@@ -144,7 +144,7 @@ func (r *PostRepo) ReplaceImages(ctx context.Context, postID, userID string, ima
|
||||
// FindByID returns one post with its images and author info (plus original
|
||||
// author info if this row is a reshare).
|
||||
func (r *PostRepo) FindByID(ctx context.Context, id string) (*model.Post, error) {
|
||||
q := `SELECT ` + postSelectColumns + postSelectFrom + `WHERE p.id = $1`
|
||||
q := `SELECT ` + postSelectColumns + postSelectFrom + `WHERE p.id = $1 AND p.deleted_at IS NULL`
|
||||
p := &model.Post{}
|
||||
if err := scanPost(r.pg.Pool.QueryRow(ctx, q, id), p); err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
@@ -188,14 +188,14 @@ func (r *PostRepo) ListMentioningByHandle(ctx context.Context, handle string, li
|
||||
// user is not the author, so there is no owner exception here).
|
||||
var total int
|
||||
if err := r.pg.Pool.QueryRow(ctx,
|
||||
`SELECT count(*) FROM posts p JOIN post_mentions pm ON pm.post_id = p.id WHERE pm.user_id = $1 AND p.is_hidden = FALSE`,
|
||||
`SELECT count(*) FROM posts p JOIN post_mentions pm ON pm.post_id = p.id WHERE pm.user_id = $1 AND p.is_hidden = FALSE AND p.deleted_at IS NULL`,
|
||||
userID,
|
||||
).Scan(&total); err != nil {
|
||||
return nil, 0, fmt.Errorf("count mentioning posts: %w", err)
|
||||
}
|
||||
q := `SELECT ` + postSelectColumns + postSelectFrom + `
|
||||
JOIN post_mentions pm ON pm.post_id = p.id
|
||||
WHERE pm.user_id = $1 AND p.is_hidden = FALSE
|
||||
WHERE pm.user_id = $1 AND p.is_hidden = FALSE AND p.deleted_at IS NULL
|
||||
ORDER BY p.created_at DESC, p.id
|
||||
LIMIT $2 OFFSET $3`
|
||||
return r.collectPosts(ctx, total, q, userID, limit, offset)
|
||||
@@ -214,7 +214,7 @@ func (r *PostRepo) ListByUser(ctx context.Context, userID string, limit, offset
|
||||
return nil, 0, err
|
||||
}
|
||||
q := `SELECT ` + postSelectColumns + postSelectFrom + `
|
||||
WHERE p.user_id = $1 AND p.is_hidden = FALSE
|
||||
WHERE p.user_id = $1 AND p.is_hidden = FALSE AND p.deleted_at IS NULL
|
||||
ORDER BY p.created_at DESC, p.id
|
||||
LIMIT $2 OFFSET $3`
|
||||
return r.collectPosts(ctx, total, q, userID, limit, offset)
|
||||
@@ -228,7 +228,7 @@ func (r *PostRepo) ListHiddenByUser(ctx context.Context, userID string, limit, o
|
||||
return nil, 0, err
|
||||
}
|
||||
q := `SELECT ` + postSelectColumns + postSelectFrom + `
|
||||
WHERE p.user_id = $1 AND p.is_hidden = TRUE
|
||||
WHERE p.user_id = $1 AND p.is_hidden = TRUE AND p.deleted_at IS NULL
|
||||
ORDER BY p.created_at DESC, p.id
|
||||
LIMIT $2 OFFSET $3`
|
||||
return r.collectPosts(ctx, total, q, userID, limit, offset)
|
||||
@@ -260,7 +260,7 @@ func (r *PostRepo) FeedForViewer(ctx context.Context, viewerID string, limit, of
|
||||
}
|
||||
q := `SELECT ` + postSelectColumns + postSelectFrom + `
|
||||
WHERE p.user_id <> $1
|
||||
AND p.is_hidden = FALSE
|
||||
AND p.is_hidden = FALSE AND p.deleted_at IS NULL
|
||||
AND p.id <> ALL($4)
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM user_blocks b
|
||||
@@ -383,7 +383,7 @@ func (r *PostRepo) SetHidden(ctx context.Context, postID, userID string, hidden
|
||||
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 AND is_hidden = FALSE`, userID,
|
||||
`SELECT COUNT(*) FROM posts WHERE user_id = $1 AND is_hidden = FALSE AND deleted_at IS NULL`, userID,
|
||||
).Scan(&n)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("count posts: %w", err)
|
||||
@@ -396,7 +396,7 @@ func (r *PostRepo) CountByUser(ctx context.Context, userID string) (int, error)
|
||||
func (r *PostRepo) CountHiddenByUser(ctx context.Context, userID string) (int, error) {
|
||||
var n int
|
||||
err := r.pg.Pool.QueryRow(ctx,
|
||||
`SELECT COUNT(*) FROM posts WHERE user_id = $1 AND is_hidden = TRUE`, userID,
|
||||
`SELECT COUNT(*) FROM posts WHERE user_id = $1 AND is_hidden = TRUE AND deleted_at IS NULL`, userID,
|
||||
).Scan(&n)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("count hidden posts: %w", err)
|
||||
|
||||
@@ -315,6 +315,18 @@ func (r *NotificationRepo) Create(ctx context.Context, userID string, actorID *s
|
||||
return n, nil
|
||||
}
|
||||
|
||||
// CreateAdminMessage persists an admin → user direct message. Unlike the
|
||||
// event-driven kinds it has no actor and carries free text in `body`.
|
||||
func (r *NotificationRepo) CreateAdminMessage(ctx context.Context, userID, body string) error {
|
||||
if _, err := r.pg.Pool.Exec(ctx,
|
||||
`INSERT INTO notifications (user_id, kind, body) VALUES ($1, 'admin_message', $2)`,
|
||||
userID, body,
|
||||
); err != nil {
|
||||
return fmt.Errorf("create admin message notification: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ListByUser returns a recipient's notifications, newest first, with the
|
||||
// actor's display fields joined in for rendering (nil for system
|
||||
// notifications or since-deleted actors). Kinds the user has turned off for
|
||||
@@ -325,7 +337,7 @@ func (r *NotificationRepo) ListByUser(ctx context.Context, userID string, limit,
|
||||
SELECT n.id, n.user_id, n.actor_id, n.kind, n.post_id, n.actor_count,
|
||||
n.created_at, n.read_at,
|
||||
u.name, u.handle, u.avatar_url,
|
||||
pu.handle
|
||||
pu.handle, n.body
|
||||
FROM notifications n
|
||||
LEFT JOIN users u ON u.id = n.actor_id
|
||||
LEFT JOIN posts p ON p.id = n.post_id
|
||||
@@ -348,7 +360,7 @@ func (r *NotificationRepo) ListByUser(ctx context.Context, userID string, limit,
|
||||
if err := rows.Scan(&n.ID, &n.UserID, &n.ActorID, &n.Kind, &n.PostID, &n.ActorCount,
|
||||
&n.CreatedAt, &n.ReadAt,
|
||||
&n.ActorName, &n.ActorHandle, &n.ActorAvatar,
|
||||
&n.PostAuthorHandle); err != nil {
|
||||
&n.PostAuthorHandle, &n.Body); err != nil {
|
||||
return nil, fmt.Errorf("scan notification: %w", err)
|
||||
}
|
||||
out = append(out, n)
|
||||
|
||||
@@ -36,7 +36,7 @@ func NewUserRepo(pg *Postgres) *UserRepo {
|
||||
const userColumns = `id, email, handle, password_hash, name, avatar_url, role, plan,
|
||||
email_verified_at, token_version, created_at, updated_at, country,
|
||||
sampradaya, gender, initiated, initiated_at, daily_chanting_rounds,
|
||||
preferred_chanting_time, peak_active_time`
|
||||
preferred_chanting_time, peak_active_time, banned_at`
|
||||
|
||||
// rowScanner is satisfied by both pgx.Row (QueryRow) and pgx.Rows, so
|
||||
// scanUser works for single-row and iterated reads alike.
|
||||
@@ -50,7 +50,7 @@ func scanUser(row rowScanner, u *model.User) error {
|
||||
&u.ID, &u.Email, &u.Handle, &u.PasswordHash, &u.Name, &u.AvatarURL, &u.Role, &u.Plan,
|
||||
&u.EmailVerifiedAt, &u.TokenVersion, &u.CreatedAt, &u.UpdatedAt, &u.Country,
|
||||
&u.Sampradaya, &u.Gender, &u.Initiated, &u.InitiatedAt, &u.DailyChantingRounds,
|
||||
&u.PreferredChantingTime, &u.PeakActiveTime,
|
||||
&u.PreferredChantingTime, &u.PeakActiveTime, &u.BannedAt,
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user