package repository import ( "context" "errors" "fmt" "github.com/jackc/pgx/v5" "github.com/prabhupadasays/spsays-bff/internal/model" "go.uber.org/zap" ) var ( // ErrCommentNotFound — no comment with that id (or not in the expected // scope, e.g. pinning a reply). ErrCommentNotFound = errors.New("comment not found") // ErrDuplicateComment — the same user re-posted identical text in the same // scope (caught by the dedup unique index). ErrDuplicateComment = errors.New("duplicate comment") ) // CommentRepo handles post comments, replies, comment-likes, and pinning. // Comment likes are far lower-volume than post likes, so likes_count is kept // in sync transactionally rather than batched through Redis. type CommentRepo struct { pg *Postgres logger *zap.Logger } func NewCommentRepo(pg *Postgres, logger *zap.Logger) *CommentRepo { return &CommentRepo{pg: pg, logger: logger} } // nullUUIDArg returns nil for an empty id so a `$n::uuid` parameter binds to // SQL NULL (anonymous viewer) instead of failing uuid parsing on "". func nullUUIDArg(s string) *string { if s == "" { return nil } return &s } // commentSelect is the shared column list for reading a comment with its // author, the reply-to handle, the viewer's like state, and the reply count. // `vl` is a viewer-scoped LEFT JOIN on comment_likes; `ru` the reply-to user. const commentSelect = ` c.id, c.post_id, c.user_id, c.parent_id, c.body, c.likes_count, c.pinned_at, c.created_at, c.updated_at, 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 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. const commentFrom = ` FROM post_comments c JOIN users u ON u.id = c.user_id LEFT JOIN users ru ON ru.id = c.reply_to_user_id LEFT JOIN comment_likes vl ON vl.comment_id = c.id AND vl.user_id = $2::uuid` func scanComment(row pgx.Row, c *model.Comment) error { return row.Scan(&c.ID, &c.PostID, &c.UserID, &c.ParentID, &c.Body, &c.LikesCount, &c.PinnedAt, &c.CreatedAt, &c.UpdatedAt, &c.AuthorName, &c.AuthorHandle, &c.AuthorAvatarURL, &c.ReplyToHandle, &c.ViewerHasLiked, &c.ReplyCount) } func scanComments(rows pgx.Rows) ([]model.Comment, error) { defer rows.Close() out := []model.Comment{} for rows.Next() { var c model.Comment if err := scanComment(rows, &c); err != nil { return nil, fmt.Errorf("scan comment: %w", err) } out = append(out, c) } return out, rows.Err() } // PostAuthor returns the id of the user who owns the post, or ErrPostNotFound. func (r *CommentRepo) PostAuthor(ctx context.Context, postID string) (string, error) { var uid string err := r.pg.Pool.QueryRow(ctx, `SELECT user_id FROM posts WHERE id = $1`, postID).Scan(&uid) if errors.Is(err, pgx.ErrNoRows) { return "", ErrPostNotFound } if err != nil { return "", fmt.Errorf("post author: %w", err) } return uid, nil } // Create inserts a comment. parentID is the thread root (nil for a top-level // comment); replyToUserID is who a reply addresses (nil for top-level). // Returns ErrDuplicateComment when the same user re-posts identical text in // the same scope. The returned Comment carries author fields. func (r *CommentRepo) Create(ctx context.Context, postID, userID string, parentID, replyToUserID *string, body string) (*model.Comment, error) { var id string err := r.pg.Pool.QueryRow(ctx, ` INSERT INTO post_comments (post_id, user_id, parent_id, reply_to_user_id, body) VALUES ($1, $2, $3, $4, $5) ON CONFLICT (post_id, user_id, COALESCE(parent_id, '00000000-0000-0000-0000-000000000000'::uuid), md5(body)) DO NOTHING RETURNING id`, postID, userID, parentID, replyToUserID, body, ).Scan(&id) if errors.Is(err, pgx.ErrNoRows) { return nil, ErrDuplicateComment } if err != nil { return nil, fmt.Errorf("insert comment: %w", err) } // The author hasn't liked their own brand-new comment, so viewer state is // resolved against userID (yields false) — fine for the create response. return r.Get(ctx, id, userID) } // 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 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) { return nil, ErrCommentNotFound } if err != nil { return nil, fmt.Errorf("get comment: %w", err) } return c, nil } // ListTopLevel returns a page of a post's top-level comments — pinned first, // then newest — plus the total top-level count. 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 AND deleted_at IS NULL`, postID).Scan(&total); err != nil { return nil, 0, fmt.Errorf("count comments: %w", err) } if total == 0 { return []model.Comment{}, 0, nil } q := `SELECT ` + commentSelect + commentFrom + ` 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) if err != nil { return nil, 0, fmt.Errorf("list comments: %w", err) } out, err := scanComments(rows) return out, total, err } // ListReplies returns a page of a thread's replies (oldest first) + the total. 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 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 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) if err != nil { return nil, 0, fmt.Errorf("list replies: %w", err) } out, err := scanComments(rows) return out, total, err } // Hydrate fills each post's comment summary — its pinned comment (if any) and // total comment count (top-level + replies) — in two batched queries for the // page. Drives the card footer's pinned line and comment count. func (r *CommentRepo) Hydrate(ctx context.Context, viewerID string, posts []*model.Post) error { ids := make([]string, 0, len(posts)) for _, p := range posts { if p != nil { ids = append(ids, p.ID) } } if len(ids) == 0 { return nil } // Pinned comment per post. q := `SELECT ` + commentSelect + commentFrom + ` WHERE c.post_id = ANY($1) AND c.pinned_at IS NOT NULL` rows, err := r.pg.Pool.Query(ctx, q, ids, nullUUIDArg(viewerID)) if err != nil { return fmt.Errorf("hydrate pinned: %w", err) } list, err := scanComments(rows) // closes rows if err != nil { return err } pinnedByPost := make(map[string]*model.Comment, len(list)) for i := range list { pinnedByPost[list[i].PostID] = &list[i] } // 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) AND deleted_at IS NULL GROUP BY post_id`, ids) if err != nil { return fmt.Errorf("hydrate comment counts: %w", err) } defer crows.Close() for crows.Next() { var id string var n int if err := crows.Scan(&id, &n); err != nil { return fmt.Errorf("scan comment count: %w", err) } counts[id] = n } if err := crows.Err(); err != nil { return err } for _, p := range posts { if p == nil { continue } if c, ok := pinnedByPost[p.ID]; ok { p.PinnedComment = c } p.CommentsCount = counts[p.ID] } return nil } // Like / Unlike toggle the viewer's like on a comment and return the new // likes_count. Idempotent; the membership change and counter update happen in // one transaction so concurrent likes stay consistent. func (r *CommentRepo) Like(ctx context.Context, commentID, userID string) (int, error) { return r.setLike(ctx, commentID, userID, true) } func (r *CommentRepo) Unlike(ctx context.Context, commentID, userID string) (int, error) { return r.setLike(ctx, commentID, userID, false) } func (r *CommentRepo) setLike(ctx context.Context, commentID, userID string, like bool) (int, error) { tx, err := r.pg.Pool.Begin(ctx) if err != nil { return 0, fmt.Errorf("begin like txn: %w", err) } defer tx.Rollback(ctx) // Lock the comment row up front: this validates existence (clean 404 // instead of a leaked FK violation) and serializes concurrent likes so // the counter stays exact. var count int if err := tx.QueryRow(ctx, `SELECT likes_count FROM post_comments WHERE id = $1 FOR UPDATE`, commentID).Scan(&count); err != nil { if errors.Is(err, pgx.ErrNoRows) { return 0, ErrCommentNotFound } return 0, fmt.Errorf("read comment likes_count: %w", err) } if like { tag, err := tx.Exec(ctx, `INSERT INTO comment_likes (comment_id, user_id) VALUES ($1, $2) ON CONFLICT DO NOTHING`, commentID, userID) if err != nil { return 0, fmt.Errorf("insert comment like: %w", err) } if tag.RowsAffected() > 0 { count++ if _, err := tx.Exec(ctx, `UPDATE post_comments SET likes_count = $2 WHERE id = $1`, commentID, count); err != nil { return 0, fmt.Errorf("bump comment likes: %w", err) } } } else { tag, err := tx.Exec(ctx, `DELETE FROM comment_likes WHERE comment_id = $1 AND user_id = $2`, commentID, userID) if err != nil { return 0, fmt.Errorf("delete comment like: %w", err) } if tag.RowsAffected() > 0 { if count > 0 { count-- } if _, err := tx.Exec(ctx, `UPDATE post_comments SET likes_count = $2 WHERE id = $1`, commentID, count); err != nil { return 0, fmt.Errorf("drop comment likes: %w", err) } } } if err := tx.Commit(ctx); err != nil { return 0, fmt.Errorf("commit like txn: %w", err) } return count, nil } // Pin makes commentID the post's single pinned comment (top-level only), // unpinning any previously pinned comment in the same transaction. Returns // ErrCommentNotFound when the comment isn't a top-level comment of postID. func (r *CommentRepo) Pin(ctx context.Context, postID, commentID string) error { tx, err := r.pg.Pool.Begin(ctx) if err != nil { return fmt.Errorf("begin pin txn: %w", err) } defer tx.Rollback(ctx) if _, err := tx.Exec(ctx, `UPDATE post_comments SET pinned_at = NULL WHERE post_id = $1 AND pinned_at IS NOT NULL`, postID); err != nil { return fmt.Errorf("unpin existing: %w", err) } tag, err := tx.Exec(ctx, `UPDATE post_comments SET pinned_at = now(), updated_at = now() WHERE id = $1 AND post_id = $2 AND parent_id IS NULL`, commentID, postID) if err != nil { return fmt.Errorf("pin comment: %w", err) } if tag.RowsAffected() == 0 { return ErrCommentNotFound } return tx.Commit(ctx) } // Unpin clears the pin on commentID for postID. func (r *CommentRepo) Unpin(ctx context.Context, postID, commentID string) error { tag, err := r.pg.Pool.Exec(ctx, `UPDATE post_comments SET pinned_at = NULL WHERE id = $1 AND post_id = $2`, commentID, postID) if err != nil { return fmt.Errorf("unpin comment: %w", err) } if tag.RowsAffected() == 0 { return ErrCommentNotFound } return nil } // Delete removes the comment. For a thread root this cascades to its replies // (and all comment_likes) via ON DELETE CASCADE. Authorization is the caller's // responsibility. func (r *CommentRepo) Delete(ctx context.Context, commentID string) error { tag, err := r.pg.Pool.Exec(ctx, `DELETE FROM post_comments WHERE id = $1`, commentID) if err != nil { return fmt.Errorf("delete comment: %w", err) } if tag.RowsAffected() == 0 { return ErrCommentNotFound } return nil } // CreateReport files a report against a comment. func (r *CommentRepo) CreateReport(ctx context.Context, reporterID, commentID, category, reason string) error { if _, err := r.pg.Pool.Exec(ctx, `INSERT INTO comment_reports (reporter_id, comment_id, category, reason) VALUES ($1, $2, $3, $4)`, reporterID, commentID, category, reason, ); err != nil { return fmt.Errorf("create comment report: %w", err) } return nil }