A community based topic aggregation platform built on atproto
1package postgres 2 3import ( 4 "context" 5 "database/sql" 6 "fmt" 7 8 "Coves/internal/core/communityFeeds" 9) 10 11type postgresFeedRepo struct { 12 *feedRepoBase 13} 14 15// sortClauses maps sort types to safe SQL ORDER BY clauses 16// This whitelist prevents SQL injection via dynamic ORDER BY construction 17var communityFeedSortClauses = map[string]string{ 18 "hot": `(p.score / POWER(EXTRACT(EPOCH FROM (NOW() - p.created_at))/3600 + 2, 1.5)) DESC, p.created_at DESC, p.uri DESC`, 19 "top": `p.score DESC, p.created_at DESC, p.uri DESC`, 20 "new": `p.created_at DESC, p.uri DESC`, 21} 22 23// hotRankExpression is the SQL expression for computing the hot rank 24// NOTE: Uses NOW() which means hot_rank changes over time - this is expected behavior 25// for hot sorting (posts naturally age out). Slight time drift between cursor creation 26// and usage may cause minor reordering but won't drop posts entirely (unlike using raw score). 27const communityFeedHotRankExpression = `(p.score / POWER(EXTRACT(EPOCH FROM (NOW() - p.created_at))/3600 + 2, 1.5))` 28 29// NewCommunityFeedRepository creates a new PostgreSQL feed repository 30func NewCommunityFeedRepository(db *sql.DB, cursorSecret string) communityFeeds.Repository { 31 return &postgresFeedRepo{ 32 feedRepoBase: newFeedRepoBase(db, communityFeedHotRankExpression, communityFeedSortClauses, cursorSecret), 33 } 34} 35 36// GetCommunityFeed retrieves posts from a community with sorting and pagination 37// Single query with JOINs for optimal performance 38func (r *postgresFeedRepo) GetCommunityFeed(ctx context.Context, req communityFeeds.GetCommunityFeedRequest) ([]*communityFeeds.FeedViewPost, *string, error) { 39 // Build ORDER BY clause based on sort type 40 orderBy, timeFilter := r.feedRepoBase.buildSortClause(req.Sort, req.Timeframe) 41 42 // Build cursor filter for pagination 43 // Community feed uses $3+ for cursor params (after $1=community and $2=limit) 44 cursorFilter, cursorValues, err := r.feedRepoBase.parseCursor(req.Cursor, req.Sort, 3) 45 if err != nil { 46 return nil, nil, communityFeeds.ErrInvalidCursor 47 } 48 49 // Build the main query 50 // For hot sort, we need to compute and return the hot_rank for cursor building 51 var selectClause string 52 if req.Sort == "hot" { 53 selectClause = fmt.Sprintf(` 54 SELECT 55 p.uri, p.cid, p.rkey, 56 p.author_did, u.handle as author_handle, 57 p.community_did, c.handle as community_handle, c.name as community_name, c.avatar_cid as community_avatar, 58 p.title, p.content, p.content_facets, p.embed, p.content_labels, 59 p.created_at, p.edited_at, p.indexed_at, 60 p.upvote_count, p.downvote_count, p.score, p.comment_count, 61 %s as hot_rank 62 FROM posts p`, communityFeedHotRankExpression) 63 } else { 64 selectClause = ` 65 SELECT 66 p.uri, p.cid, p.rkey, 67 p.author_did, u.handle as author_handle, 68 p.community_did, c.handle as community_handle, c.name as community_name, c.avatar_cid as community_avatar, 69 p.title, p.content, p.content_facets, p.embed, p.content_labels, 70 p.created_at, p.edited_at, p.indexed_at, 71 p.upvote_count, p.downvote_count, p.score, p.comment_count, 72 NULL::numeric as hot_rank 73 FROM posts p` 74 } 75 76 query := fmt.Sprintf(` 77 %s 78 INNER JOIN users u ON p.author_did = u.did 79 INNER JOIN communities c ON p.community_did = c.did 80 WHERE p.community_did = $1 81 AND p.deleted_at IS NULL 82 %s 83 %s 84 ORDER BY %s 85 LIMIT $2 86 `, selectClause, timeFilter, cursorFilter, orderBy) 87 88 // Prepare query arguments 89 args := []interface{}{req.Community, req.Limit + 1} // +1 to check for next page 90 args = append(args, cursorValues...) 91 92 // Execute query 93 rows, err := r.db.QueryContext(ctx, query, args...) 94 if err != nil { 95 return nil, nil, fmt.Errorf("failed to query community feed: %w", err) 96 } 97 defer func() { 98 if err := rows.Close(); err != nil { 99 // Log close errors (non-fatal but worth noting) 100 fmt.Printf("Warning: failed to close rows: %v\n", err) 101 } 102 }() 103 104 // Scan results 105 var feedPosts []*communityFeeds.FeedViewPost 106 var hotRanks []float64 // Store hot ranks for cursor building 107 for rows.Next() { 108 postView, hotRank, err := r.feedRepoBase.scanFeedPost(rows) 109 if err != nil { 110 return nil, nil, fmt.Errorf("failed to scan feed post: %w", err) 111 } 112 feedPosts = append(feedPosts, &communityFeeds.FeedViewPost{Post: postView}) 113 hotRanks = append(hotRanks, hotRank) 114 } 115 116 if err := rows.Err(); err != nil { 117 return nil, nil, fmt.Errorf("error iterating feed results: %w", err) 118 } 119 120 // Handle pagination cursor 121 var cursor *string 122 if len(feedPosts) > req.Limit && req.Limit > 0 { 123 feedPosts = feedPosts[:req.Limit] 124 hotRanks = hotRanks[:req.Limit] 125 lastPost := feedPosts[len(feedPosts)-1].Post 126 lastHotRank := hotRanks[len(hotRanks)-1] 127 cursorStr := r.feedRepoBase.buildCursor(lastPost, req.Sort, lastHotRank) 128 cursor = &cursorStr 129 } 130 131 return feedPosts, cursor, nil 132}