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