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/discover"
9)
10
11type postgresDiscoverRepo struct {
12 *feedRepoBase
13}
14
15// sortClauses maps sort types to safe SQL ORDER BY clauses
16var discoverSortClauses = 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 for discover feed
23const discoverHotRankExpression = `(p.score / POWER(EXTRACT(EPOCH FROM (NOW() - p.created_at))/3600 + 2, 1.5))`
24
25// NewDiscoverRepository creates a new PostgreSQL discover repository
26func NewDiscoverRepository(db *sql.DB, cursorSecret string) discover.Repository {
27 return &postgresDiscoverRepo{
28 feedRepoBase: newFeedRepoBase(db, discoverHotRankExpression, discoverSortClauses, cursorSecret),
29 }
30}
31
32// GetDiscover retrieves posts from ALL communities (public feed)
33func (r *postgresDiscoverRepo) GetDiscover(ctx context.Context, req discover.GetDiscoverRequest) ([]*discover.FeedViewPost, *string, error) {
34 // Build ORDER BY clause based on sort type
35 orderBy, timeFilter := r.buildSortClause(req.Sort, req.Timeframe)
36
37 // Build cursor filter for pagination
38 // Discover uses $2+ for cursor params (after $1=limit)
39 cursorFilter, cursorValues, err := r.feedRepoBase.parseCursor(req.Cursor, req.Sort, 2)
40 if err != nil {
41 return nil, nil, discover.ErrInvalidCursor
42 }
43
44 // Build the main query
45 var selectClause string
46 if req.Sort == "hot" {
47 selectClause = fmt.Sprintf(`
48 SELECT
49 p.uri, p.cid, p.rkey,
50 p.author_did, u.handle as author_handle,
51 p.community_did, c.name as community_name, c.avatar_cid as community_avatar,
52 p.title, p.content, p.content_facets, p.embed, p.content_labels,
53 p.created_at, p.edited_at, p.indexed_at,
54 p.upvote_count, p.downvote_count, p.score, p.comment_count,
55 %s as hot_rank
56 FROM posts p`, discoverHotRankExpression)
57 } else {
58 selectClause = `
59 SELECT
60 p.uri, p.cid, p.rkey,
61 p.author_did, u.handle as author_handle,
62 p.community_did, c.name as community_name, c.avatar_cid as community_avatar,
63 p.title, p.content, p.content_facets, p.embed, p.content_labels,
64 p.created_at, p.edited_at, p.indexed_at,
65 p.upvote_count, p.downvote_count, p.score, p.comment_count,
66 NULL::numeric as hot_rank
67 FROM posts p`
68 }
69
70 // No subscription filter - show ALL posts from ALL communities
71 query := fmt.Sprintf(`
72 %s
73 INNER JOIN users u ON p.author_did = u.did
74 INNER JOIN communities c ON p.community_did = c.did
75 WHERE p.deleted_at IS NULL
76 %s
77 %s
78 ORDER BY %s
79 LIMIT $1
80 `, selectClause, timeFilter, cursorFilter, orderBy)
81
82 // Prepare query arguments
83 args := []interface{}{req.Limit + 1} // +1 to check for next page
84 args = append(args, cursorValues...)
85
86 // Execute query
87 rows, err := r.db.QueryContext(ctx, query, args...)
88 if err != nil {
89 return nil, nil, fmt.Errorf("failed to query discover feed: %w", err)
90 }
91 defer func() {
92 if err := rows.Close(); err != nil {
93 fmt.Printf("Warning: failed to close rows: %v\n", err)
94 }
95 }()
96
97 // Scan results
98 var feedPosts []*discover.FeedViewPost
99 var hotRanks []float64
100 for rows.Next() {
101 postView, hotRank, err := r.feedRepoBase.scanFeedPost(rows)
102 if err != nil {
103 return nil, nil, fmt.Errorf("failed to scan discover post: %w", err)
104 }
105 feedPosts = append(feedPosts, &discover.FeedViewPost{Post: postView})
106 hotRanks = append(hotRanks, hotRank)
107 }
108
109 if err := rows.Err(); err != nil {
110 return nil, nil, fmt.Errorf("error iterating discover results: %w", err)
111 }
112
113 // Handle pagination cursor
114 var cursor *string
115 if len(feedPosts) > req.Limit && req.Limit > 0 {
116 feedPosts = feedPosts[:req.Limit]
117 hotRanks = hotRanks[:req.Limit]
118 lastPost := feedPosts[len(feedPosts)-1].Post
119 lastHotRank := hotRanks[len(hotRanks)-1]
120 cursorStr := r.feedRepoBase.buildCursor(lastPost, req.Sort, lastHotRank)
121 cursor = &cursorStr
122 }
123
124 return feedPosts, cursor, nil
125}