···
4
+
"Coves/internal/core/communityFeeds"
5
+
"Coves/internal/core/posts"
18
+
type postgresFeedRepo struct {
22
+
// sortClauses maps sort types to safe SQL ORDER BY clauses
23
+
// This whitelist prevents SQL injection via dynamic ORDER BY construction
24
+
var sortClauses = map[string]string{
25
+
"hot": `(p.score / POWER(EXTRACT(EPOCH FROM (NOW() - p.created_at))/3600 + 2, 1.5)) DESC, p.created_at DESC, p.uri DESC`,
26
+
"top": `p.score DESC, p.created_at DESC, p.uri DESC`,
27
+
"new": `p.created_at DESC, p.uri DESC`,
30
+
// hotRankExpression is the SQL expression for computing the hot rank
31
+
// NOTE: Uses NOW() which means hot_rank changes over time - this is expected behavior
32
+
// for hot sorting (posts naturally age out). Slight time drift between cursor creation
33
+
// and usage may cause minor reordering but won't drop posts entirely (unlike using raw score).
34
+
const hotRankExpression = `(p.score / POWER(EXTRACT(EPOCH FROM (NOW() - p.created_at))/3600 + 2, 1.5))`
36
+
// NewCommunityFeedRepository creates a new PostgreSQL feed repository
37
+
func NewCommunityFeedRepository(db *sql.DB) communityFeeds.Repository {
38
+
return &postgresFeedRepo{db: db}
41
+
// GetCommunityFeed retrieves posts from a community with sorting and pagination
42
+
// Single query with JOINs for optimal performance
43
+
func (r *postgresFeedRepo) GetCommunityFeed(ctx context.Context, req communityFeeds.GetCommunityFeedRequest) ([]*communityFeeds.FeedViewPost, *string, error) {
44
+
// Build ORDER BY clause based on sort type
45
+
orderBy, timeFilter := r.buildSortClause(req.Sort, req.Timeframe)
47
+
// Build cursor filter for pagination
48
+
cursorFilter, cursorValues, err := r.parseCursor(req.Cursor, req.Sort)
50
+
return nil, nil, communityFeeds.ErrInvalidCursor
53
+
// Build the main query
54
+
// For hot sort, we need to compute and return the hot_rank for cursor building
55
+
var selectClause string
56
+
if req.Sort == "hot" {
57
+
selectClause = fmt.Sprintf(`
59
+
p.uri, p.cid, p.rkey,
60
+
p.author_did, u.handle as author_handle,
61
+
p.community_did, c.name as community_name, c.avatar_cid as community_avatar,
62
+
p.title, p.content, p.content_facets, p.embed, p.content_labels,
63
+
p.created_at, p.edited_at, p.indexed_at,
64
+
p.upvote_count, p.downvote_count, p.score, p.comment_count,
66
+
FROM posts p`, hotRankExpression)
70
+
p.uri, p.cid, p.rkey,
71
+
p.author_did, u.handle as author_handle,
72
+
p.community_did, c.name as community_name, c.avatar_cid as community_avatar,
73
+
p.title, p.content, p.content_facets, p.embed, p.content_labels,
74
+
p.created_at, p.edited_at, p.indexed_at,
75
+
p.upvote_count, p.downvote_count, p.score, p.comment_count,
76
+
NULL::numeric as hot_rank
80
+
query := fmt.Sprintf(`
82
+
INNER JOIN users u ON p.author_did = u.did
83
+
INNER JOIN communities c ON p.community_did = c.did
84
+
WHERE p.community_did = $1
85
+
AND p.deleted_at IS NULL
90
+
`, selectClause, timeFilter, cursorFilter, orderBy)
92
+
// Prepare query arguments
93
+
args := []interface{}{req.Community, req.Limit + 1} // +1 to check for next page
94
+
args = append(args, cursorValues...)
97
+
rows, err := r.db.QueryContext(ctx, query, args...)
99
+
return nil, nil, fmt.Errorf("failed to query community feed: %w", err)
102
+
if err := rows.Close(); err != nil {
103
+
// Log close errors (non-fatal but worth noting)
104
+
fmt.Printf("Warning: failed to close rows: %v\n", err)
109
+
var feedPosts []*communityFeeds.FeedViewPost
110
+
var hotRanks []float64 // Store hot ranks for cursor building
112
+
feedPost, hotRank, err := r.scanFeedViewPost(rows)
114
+
return nil, nil, fmt.Errorf("failed to scan feed post: %w", err)
116
+
feedPosts = append(feedPosts, feedPost)
117
+
hotRanks = append(hotRanks, hotRank)
120
+
if err := rows.Err(); err != nil {
121
+
return nil, nil, fmt.Errorf("error iterating feed results: %w", err)
124
+
// Handle pagination cursor
126
+
if len(feedPosts) > req.Limit && req.Limit > 0 {
127
+
feedPosts = feedPosts[:req.Limit]
128
+
hotRanks = hotRanks[:req.Limit]
129
+
lastPost := feedPosts[len(feedPosts)-1].Post
130
+
lastHotRank := hotRanks[len(hotRanks)-1]
131
+
cursorStr := r.buildCursor(lastPost, req.Sort, lastHotRank)
132
+
cursor = &cursorStr
135
+
return feedPosts, cursor, nil
138
+
// buildSortClause returns the ORDER BY SQL and optional time filter
139
+
func (r *postgresFeedRepo) buildSortClause(sort, timeframe string) (string, string) {
140
+
// Use whitelist map for ORDER BY clause (defense-in-depth against SQL injection)
141
+
orderBy := sortClauses[sort]
143
+
orderBy = sortClauses["hot"] // safe default
146
+
// Add time filter for "top" sort
147
+
var timeFilter string
149
+
timeFilter = r.buildTimeFilter(timeframe)
152
+
return orderBy, timeFilter
155
+
// buildTimeFilter returns SQL filter for timeframe
156
+
func (r *postgresFeedRepo) buildTimeFilter(timeframe string) string {
157
+
if timeframe == "" || timeframe == "all" {
161
+
var interval string
164
+
interval = "1 hour"
168
+
interval = "1 week"
170
+
interval = "1 month"
172
+
interval = "1 year"
177
+
return fmt.Sprintf("AND p.created_at > NOW() - INTERVAL '%s'", interval)
180
+
// parseCursor decodes pagination cursor
181
+
func (r *postgresFeedRepo) parseCursor(cursor *string, sort string) (string, []interface{}, error) {
182
+
if cursor == nil || *cursor == "" {
183
+
return "", nil, nil
186
+
// Decode base64 cursor
187
+
decoded, err := base64.StdEncoding.DecodeString(*cursor)
189
+
return "", nil, fmt.Errorf("invalid cursor encoding")
192
+
// Parse cursor based on sort type using :: delimiter (Bluesky convention)
193
+
parts := strings.Split(string(decoded), "::")
197
+
// Cursor format: timestamp::uri
198
+
if len(parts) != 2 {
199
+
return "", nil, fmt.Errorf("invalid cursor format")
202
+
createdAt := parts[0]
205
+
// Validate timestamp format
206
+
if _, err := time.Parse(time.RFC3339Nano, createdAt); err != nil {
207
+
return "", nil, fmt.Errorf("invalid cursor timestamp")
210
+
// Validate URI format (must be AT-URI)
211
+
if !strings.HasPrefix(uri, "at://") {
212
+
return "", nil, fmt.Errorf("invalid cursor URI")
215
+
filter := `AND (p.created_at < $3 OR (p.created_at = $3 AND p.uri < $4))`
216
+
return filter, []interface{}{createdAt, uri}, nil
219
+
// Cursor format: score::timestamp::uri
220
+
if len(parts) != 3 {
221
+
return "", nil, fmt.Errorf("invalid cursor format for %s sort", sort)
224
+
scoreStr := parts[0]
225
+
createdAt := parts[1]
228
+
// Validate score is numeric
230
+
if _, err := fmt.Sscanf(scoreStr, "%d", &score); err != nil {
231
+
return "", nil, fmt.Errorf("invalid cursor score")
234
+
// Validate timestamp format
235
+
if _, err := time.Parse(time.RFC3339Nano, createdAt); err != nil {
236
+
return "", nil, fmt.Errorf("invalid cursor timestamp")
239
+
// Validate URI format (must be AT-URI)
240
+
if !strings.HasPrefix(uri, "at://") {
241
+
return "", nil, fmt.Errorf("invalid cursor URI")
244
+
filter := `AND (p.score < $3 OR (p.score = $3 AND p.created_at < $4) OR (p.score = $3 AND p.created_at = $4 AND p.uri < $5))`
245
+
return filter, []interface{}{score, createdAt, uri}, nil
248
+
// Cursor format: hot_rank::timestamp::uri
249
+
// CRITICAL: Must use computed hot_rank, not raw score, to prevent pagination bugs
250
+
if len(parts) != 3 {
251
+
return "", nil, fmt.Errorf("invalid cursor format for hot sort")
254
+
hotRankStr := parts[0]
255
+
createdAt := parts[1]
258
+
// Validate hot_rank is numeric (float)
260
+
if _, err := fmt.Sscanf(hotRankStr, "%f", &hotRank); err != nil {
261
+
return "", nil, fmt.Errorf("invalid cursor hot rank")
264
+
// Validate timestamp format
265
+
if _, err := time.Parse(time.RFC3339Nano, createdAt); err != nil {
266
+
return "", nil, fmt.Errorf("invalid cursor timestamp")
269
+
// Validate URI format (must be AT-URI)
270
+
if !strings.HasPrefix(uri, "at://") {
271
+
return "", nil, fmt.Errorf("invalid cursor URI")
274
+
// CRITICAL: Compare against the computed hot_rank expression, not p.score
275
+
// This prevents dropping posts with higher raw scores but lower hot ranks
277
+
// NOTE: We exclude the exact cursor post by URI to handle time drift in hot_rank
278
+
// (hot_rank changes with NOW(), so the same post may have different ranks over time)
279
+
filter := fmt.Sprintf(`AND ((%s < $3 OR (%s = $3 AND p.created_at < $4) OR (%s = $3 AND p.created_at = $4 AND p.uri < $5)) AND p.uri != $6)`,
280
+
hotRankExpression, hotRankExpression, hotRankExpression)
281
+
return filter, []interface{}{hotRank, createdAt, uri, uri}, nil
284
+
return "", nil, nil
288
+
// buildCursor creates pagination cursor from last post
289
+
func (r *postgresFeedRepo) buildCursor(post *posts.PostView, sort string, hotRank float64) string {
290
+
var cursorStr string
291
+
// Use :: as delimiter following Bluesky convention
292
+
// Safe because :: doesn't appear in ISO timestamps or AT-URIs
293
+
const delimiter = "::"
297
+
// Format: timestamp::uri (following Bluesky pattern)
298
+
cursorStr = fmt.Sprintf("%s%s%s", post.CreatedAt.Format(time.RFC3339Nano), delimiter, post.URI)
301
+
// Format: score::timestamp::uri
303
+
if post.Stats != nil {
304
+
score = post.Stats.Score
306
+
cursorStr = fmt.Sprintf("%d%s%s%s%s", score, delimiter, post.CreatedAt.Format(time.RFC3339Nano), delimiter, post.URI)
309
+
// Format: hot_rank::timestamp::uri
310
+
// CRITICAL: Use computed hot_rank with full precision to prevent pagination bugs
311
+
// Using 'g' format with -1 precision gives us full float64 precision without trailing zeros
312
+
// This prevents posts being dropped when hot ranks differ by <1e-6
313
+
hotRankStr := strconv.FormatFloat(hotRank, 'g', -1, 64)
314
+
cursorStr = fmt.Sprintf("%s%s%s%s%s", hotRankStr, delimiter, post.CreatedAt.Format(time.RFC3339Nano), delimiter, post.URI)
317
+
cursorStr = post.URI
320
+
return base64.StdEncoding.EncodeToString([]byte(cursorStr))
323
+
// scanFeedViewPost scans a row into FeedViewPost
324
+
// Alpha: No viewer state - basic community feed only
325
+
func (r *postgresFeedRepo) scanFeedViewPost(rows *sql.Rows) (*communityFeeds.FeedViewPost, float64, error) {
327
+
postView posts.PostView
328
+
authorView posts.AuthorView
329
+
communityRef posts.CommunityRef
330
+
title, content sql.NullString
331
+
facets, embed sql.NullString
332
+
labels pq.StringArray
333
+
editedAt sql.NullTime
334
+
communityAvatar sql.NullString
335
+
hotRank sql.NullFloat64
339
+
&postView.URI, &postView.CID, &postView.RKey,
340
+
&authorView.DID, &authorView.Handle,
341
+
&communityRef.DID, &communityRef.Name, &communityAvatar,
342
+
&title, &content, &facets, &embed, &labels,
343
+
&postView.CreatedAt, &editedAt, &postView.IndexedAt,
344
+
&postView.UpvoteCount, &postView.DownvoteCount, &postView.Score, &postView.CommentCount,
351
+
// Build author view (no display_name or avatar in users table yet)
352
+
postView.Author = &authorView
354
+
// Build community ref
355
+
communityRef.Avatar = nullStringPtr(communityAvatar)
356
+
postView.Community = &communityRef
358
+
// Set optional fields
359
+
postView.Title = nullStringPtr(title)
360
+
postView.Text = nullStringPtr(content)
362
+
// Parse facets JSON
364
+
var facetArray []interface{}
365
+
if err := json.Unmarshal([]byte(facets.String), &facetArray); err == nil {
366
+
postView.TextFacets = facetArray
370
+
// Parse embed JSON
372
+
var embedData interface{}
373
+
if err := json.Unmarshal([]byte(embed.String), &embedData); err == nil {
374
+
postView.Embed = embedData
379
+
postView.Stats = &posts.PostStats{
380
+
Upvotes: postView.UpvoteCount,
381
+
Downvotes: postView.DownvoteCount,
382
+
Score: postView.Score,
383
+
CommentCount: postView.CommentCount,
386
+
// Alpha: No viewer state for basic feed
387
+
// TODO(feed-generator): Implement viewer state (saved, voted, blocked) in feed generator skeleton
389
+
// Build the record (required by lexicon - social.coves.post.record structure)
390
+
record := map[string]interface{}{
391
+
"$type": "social.coves.post.record",
392
+
"community": communityRef.DID,
393
+
"author": authorView.DID,
394
+
"createdAt": postView.CreatedAt.Format(time.RFC3339),
397
+
// Add optional fields to record if present
399
+
record["title"] = title.String
402
+
record["content"] = content.String
405
+
var facetArray []interface{}
406
+
if err := json.Unmarshal([]byte(facets.String), &facetArray); err == nil {
407
+
record["facets"] = facetArray
411
+
var embedData interface{}
412
+
if err := json.Unmarshal([]byte(embed.String), &embedData); err == nil {
413
+
record["embed"] = embedData
416
+
if len(labels) > 0 {
417
+
record["contentLabels"] = labels
420
+
postView.Record = record
422
+
// Wrap in FeedViewPost
423
+
feedPost := &communityFeeds.FeedViewPost{
425
+
// Reason: nil, // TODO(feed-generator): Implement pinned posts
426
+
// Reply: nil, // TODO(feed-generator): Implement reply context
429
+
// Return the computed hot_rank (0.0 if NULL for non-hot sorts)
430
+
hotRankValue := 0.0
432
+
hotRankValue = hotRank.Float64
435
+
return feedPost, hotRankValue, nil
438
+
// Helper function to convert sql.NullString to *string
439
+
func nullStringPtr(ns sql.NullString) *string {