···
"Coves/internal/core/communityFeeds"
14
-
"Coves/internal/core/posts"
type postgresFeedRepo struct {
// sortClauses maps sort types to safe SQL ORDER BY clauses
// This whitelist prevents SQL injection via dynamic ORDER BY construction
23
-
var sortClauses = map[string]string{
17
+
var communityFeedSortClauses = map[string]string{
"hot": `(p.score / POWER(EXTRACT(EPOCH FROM (NOW() - p.created_at))/3600 + 2, 1.5)) DESC, p.created_at DESC, p.uri DESC`,
"top": `p.score DESC, p.created_at DESC, p.uri DESC`,
"new": `p.created_at DESC, p.uri DESC`,
···
// NOTE: Uses NOW() which means hot_rank changes over time - this is expected behavior
// for hot sorting (posts naturally age out). Slight time drift between cursor creation
// and usage may cause minor reordering but won't drop posts entirely (unlike using raw score).
33
-
const hotRankExpression = `(p.score / POWER(EXTRACT(EPOCH FROM (NOW() - p.created_at))/3600 + 2, 1.5))`
27
+
const communityFeedHotRankExpression = `(p.score / POWER(EXTRACT(EPOCH FROM (NOW() - p.created_at))/3600 + 2, 1.5))`
// NewCommunityFeedRepository creates a new PostgreSQL feed repository
36
-
func NewCommunityFeedRepository(db *sql.DB) communityFeeds.Repository {
37
-
return &postgresFeedRepo{db: db}
30
+
func NewCommunityFeedRepository(db *sql.DB, cursorSecret string) communityFeeds.Repository {
31
+
return &postgresFeedRepo{
32
+
feedRepoBase: newFeedRepoBase(db, communityFeedHotRankExpression, communityFeedSortClauses, cursorSecret),
// GetCommunityFeed retrieves posts from a community with sorting and pagination
// Single query with JOINs for optimal performance
func (r *postgresFeedRepo) GetCommunityFeed(ctx context.Context, req communityFeeds.GetCommunityFeedRequest) ([]*communityFeeds.FeedViewPost, *string, error) {
// Build ORDER BY clause based on sort type
44
-
orderBy, timeFilter := r.buildSortClause(req.Sort, req.Timeframe)
40
+
orderBy, timeFilter := r.feedRepoBase.buildSortClause(req.Sort, req.Timeframe)
// Build cursor filter for pagination
47
-
cursorFilter, cursorValues, err := r.parseCursor(req.Cursor, req.Sort)
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)
return nil, nil, communityFeeds.ErrInvalidCursor
···
p.author_did, u.handle as author_handle,
60
-
p.community_did, c.name as community_name, c.avatar_cid as community_avatar,
57
+
p.community_did, c.handle as community_handle, c.name as community_name, c.avatar_cid as community_avatar,
p.title, p.content, p.content_facets, p.embed, p.content_labels,
p.created_at, p.edited_at, p.indexed_at,
p.upvote_count, p.downvote_count, p.score, p.comment_count,
65
-
FROM posts p`, hotRankExpression)
62
+
FROM posts p`, communityFeedHotRankExpression)
p.author_did, u.handle as author_handle,
71
-
p.community_did, c.name as community_name, c.avatar_cid as community_avatar,
68
+
p.community_did, c.handle as community_handle, c.name as community_name, c.avatar_cid as community_avatar,
p.title, p.content, p.content_facets, p.embed, p.content_labels,
p.created_at, p.edited_at, p.indexed_at,
p.upvote_count, p.downvote_count, p.score, p.comment_count,
···
var feedPosts []*communityFeeds.FeedViewPost
var hotRanks []float64 // Store hot ranks for cursor building
111
-
feedPost, hotRank, err := r.scanFeedViewPost(rows)
108
+
postView, hotRank, err := r.feedRepoBase.scanFeedPost(rows)
return nil, nil, fmt.Errorf("failed to scan feed post: %w", err)
115
-
feedPosts = append(feedPosts, feedPost)
112
+
feedPosts = append(feedPosts, &communityFeeds.FeedViewPost{Post: postView})
hotRanks = append(hotRanks, hotRank)
···
hotRanks = hotRanks[:req.Limit]
lastPost := feedPosts[len(feedPosts)-1].Post
lastHotRank := hotRanks[len(hotRanks)-1]
130
-
cursorStr := r.buildCursor(lastPost, req.Sort, lastHotRank)
127
+
cursorStr := r.feedRepoBase.buildCursor(lastPost, req.Sort, lastHotRank)
return feedPosts, cursor, nil
137
-
// buildSortClause returns the ORDER BY SQL and optional time filter
138
-
func (r *postgresFeedRepo) buildSortClause(sort, timeframe string) (string, string) {
139
-
// Use whitelist map for ORDER BY clause (defense-in-depth against SQL injection)
140
-
orderBy := sortClauses[sort]
142
-
orderBy = sortClauses["hot"] // safe default
145
-
// Add time filter for "top" sort
146
-
var timeFilter string
148
-
timeFilter = r.buildTimeFilter(timeframe)
151
-
return orderBy, timeFilter
154
-
// buildTimeFilter returns SQL filter for timeframe
155
-
func (r *postgresFeedRepo) buildTimeFilter(timeframe string) string {
156
-
if timeframe == "" || timeframe == "all" {
160
-
var interval string
163
-
interval = "1 hour"
167
-
interval = "1 week"
169
-
interval = "1 month"
171
-
interval = "1 year"
176
-
return fmt.Sprintf("AND p.created_at > NOW() - INTERVAL '%s'", interval)
179
-
// parseCursor decodes pagination cursor
180
-
func (r *postgresFeedRepo) parseCursor(cursor *string, sort string) (string, []interface{}, error) {
181
-
if cursor == nil || *cursor == "" {
182
-
return "", nil, nil
185
-
// Decode base64 cursor
186
-
decoded, err := base64.StdEncoding.DecodeString(*cursor)
188
-
return "", nil, fmt.Errorf("invalid cursor encoding")
191
-
// Parse cursor based on sort type using :: delimiter (Bluesky convention)
192
-
parts := strings.Split(string(decoded), "::")
196
-
// Cursor format: timestamp::uri
197
-
if len(parts) != 2 {
198
-
return "", nil, fmt.Errorf("invalid cursor format")
201
-
createdAt := parts[0]
204
-
// Validate timestamp format
205
-
if _, err := time.Parse(time.RFC3339Nano, createdAt); err != nil {
206
-
return "", nil, fmt.Errorf("invalid cursor timestamp")
209
-
// Validate URI format (must be AT-URI)
210
-
if !strings.HasPrefix(uri, "at://") {
211
-
return "", nil, fmt.Errorf("invalid cursor URI")
214
-
filter := `AND (p.created_at < $3 OR (p.created_at = $3 AND p.uri < $4))`
215
-
return filter, []interface{}{createdAt, uri}, nil
218
-
// Cursor format: score::timestamp::uri
219
-
if len(parts) != 3 {
220
-
return "", nil, fmt.Errorf("invalid cursor format for %s sort", sort)
223
-
scoreStr := parts[0]
224
-
createdAt := parts[1]
227
-
// Validate score is numeric
229
-
if _, err := fmt.Sscanf(scoreStr, "%d", &score); err != nil {
230
-
return "", nil, fmt.Errorf("invalid cursor score")
233
-
// Validate timestamp format
234
-
if _, err := time.Parse(time.RFC3339Nano, createdAt); err != nil {
235
-
return "", nil, fmt.Errorf("invalid cursor timestamp")
238
-
// Validate URI format (must be AT-URI)
239
-
if !strings.HasPrefix(uri, "at://") {
240
-
return "", nil, fmt.Errorf("invalid cursor URI")
243
-
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))`
244
-
return filter, []interface{}{score, createdAt, uri}, nil
247
-
// Cursor format: hot_rank::timestamp::uri
248
-
// CRITICAL: Must use computed hot_rank, not raw score, to prevent pagination bugs
249
-
if len(parts) != 3 {
250
-
return "", nil, fmt.Errorf("invalid cursor format for hot sort")
253
-
hotRankStr := parts[0]
254
-
createdAt := parts[1]
257
-
// Validate hot_rank is numeric (float)
259
-
if _, err := fmt.Sscanf(hotRankStr, "%f", &hotRank); err != nil {
260
-
return "", nil, fmt.Errorf("invalid cursor hot rank")
263
-
// Validate timestamp format
264
-
if _, err := time.Parse(time.RFC3339Nano, createdAt); err != nil {
265
-
return "", nil, fmt.Errorf("invalid cursor timestamp")
268
-
// Validate URI format (must be AT-URI)
269
-
if !strings.HasPrefix(uri, "at://") {
270
-
return "", nil, fmt.Errorf("invalid cursor URI")
273
-
// CRITICAL: Compare against the computed hot_rank expression, not p.score
274
-
// This prevents dropping posts with higher raw scores but lower hot ranks
276
-
// NOTE: We exclude the exact cursor post by URI to handle time drift in hot_rank
277
-
// (hot_rank changes with NOW(), so the same post may have different ranks over time)
278
-
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)`,
279
-
hotRankExpression, hotRankExpression, hotRankExpression)
280
-
return filter, []interface{}{hotRank, createdAt, uri, uri}, nil
283
-
return "", nil, nil
287
-
// buildCursor creates pagination cursor from last post
288
-
func (r *postgresFeedRepo) buildCursor(post *posts.PostView, sort string, hotRank float64) string {
289
-
var cursorStr string
290
-
// Use :: as delimiter following Bluesky convention
291
-
// Safe because :: doesn't appear in ISO timestamps or AT-URIs
292
-
const delimiter = "::"
296
-
// Format: timestamp::uri (following Bluesky pattern)
297
-
cursorStr = fmt.Sprintf("%s%s%s", post.CreatedAt.Format(time.RFC3339Nano), delimiter, post.URI)
300
-
// Format: score::timestamp::uri
302
-
if post.Stats != nil {
303
-
score = post.Stats.Score
305
-
cursorStr = fmt.Sprintf("%d%s%s%s%s", score, delimiter, post.CreatedAt.Format(time.RFC3339Nano), delimiter, post.URI)
308
-
// Format: hot_rank::timestamp::uri
309
-
// CRITICAL: Use computed hot_rank with full precision to prevent pagination bugs
310
-
// Using 'g' format with -1 precision gives us full float64 precision without trailing zeros
311
-
// This prevents posts being dropped when hot ranks differ by <1e-6
312
-
hotRankStr := strconv.FormatFloat(hotRank, 'g', -1, 64)
313
-
cursorStr = fmt.Sprintf("%s%s%s%s%s", hotRankStr, delimiter, post.CreatedAt.Format(time.RFC3339Nano), delimiter, post.URI)
316
-
cursorStr = post.URI
319
-
return base64.StdEncoding.EncodeToString([]byte(cursorStr))
322
-
// scanFeedViewPost scans a row into FeedViewPost
323
-
// Alpha: No viewer state - basic community feed only
324
-
func (r *postgresFeedRepo) scanFeedViewPost(rows *sql.Rows) (*communityFeeds.FeedViewPost, float64, error) {
326
-
postView posts.PostView
327
-
authorView posts.AuthorView
328
-
communityRef posts.CommunityRef
329
-
title, content sql.NullString
330
-
facets, embed sql.NullString
331
-
labelsJSON sql.NullString
332
-
editedAt sql.NullTime
333
-
communityAvatar sql.NullString
334
-
hotRank sql.NullFloat64
338
-
&postView.URI, &postView.CID, &postView.RKey,
339
-
&authorView.DID, &authorView.Handle,
340
-
&communityRef.DID, &communityRef.Name, &communityAvatar,
341
-
&title, &content, &facets, &embed, &labelsJSON,
342
-
&postView.CreatedAt, &editedAt, &postView.IndexedAt,
343
-
&postView.UpvoteCount, &postView.DownvoteCount, &postView.Score, &postView.CommentCount,
350
-
// Build author view (no display_name or avatar in users table yet)
351
-
postView.Author = &authorView
353
-
// Build community ref
354
-
communityRef.Avatar = nullStringPtr(communityAvatar)
355
-
postView.Community = &communityRef
357
-
// Set optional fields
358
-
postView.Title = nullStringPtr(title)
359
-
postView.Text = nullStringPtr(content)
361
-
// Parse facets JSON
363
-
var facetArray []interface{}
364
-
if err := json.Unmarshal([]byte(facets.String), &facetArray); err == nil {
365
-
postView.TextFacets = facetArray
369
-
// Parse embed JSON
371
-
var embedData interface{}
372
-
if err := json.Unmarshal([]byte(embed.String), &embedData); err == nil {
373
-
postView.Embed = embedData
378
-
postView.Stats = &posts.PostStats{
379
-
Upvotes: postView.UpvoteCount,
380
-
Downvotes: postView.DownvoteCount,
381
-
Score: postView.Score,
382
-
CommentCount: postView.CommentCount,
385
-
// Alpha: No viewer state for basic feed
386
-
// TODO(feed-generator): Implement viewer state (saved, voted, blocked) in feed generator skeleton
388
-
// Build the record (required by lexicon - social.coves.community.post structure)
389
-
record := map[string]interface{}{
390
-
"$type": "social.coves.community.post",
391
-
"community": communityRef.DID,
392
-
"author": authorView.DID,
393
-
"createdAt": postView.CreatedAt.Format(time.RFC3339),
396
-
// Add optional fields to record if present
398
-
record["title"] = title.String
401
-
record["content"] = content.String
404
-
var facetArray []interface{}
405
-
if err := json.Unmarshal([]byte(facets.String), &facetArray); err == nil {
406
-
record["facets"] = facetArray
410
-
var embedData interface{}
411
-
if err := json.Unmarshal([]byte(embed.String), &embedData); err == nil {
412
-
record["embed"] = embedData
415
-
if labelsJSON.Valid {
416
-
// Labels are stored as JSONB containing full com.atproto.label.defs#selfLabels structure
417
-
// Deserialize and include in record
418
-
var selfLabels posts.SelfLabels
419
-
if err := json.Unmarshal([]byte(labelsJSON.String), &selfLabels); err == nil {
420
-
record["labels"] = selfLabels
424
-
postView.Record = record
426
-
// Wrap in FeedViewPost
427
-
feedPost := &communityFeeds.FeedViewPost{
429
-
// Reason: nil, // TODO(feed-generator): Implement pinned posts
430
-
// Reply: nil, // TODO(feed-generator): Implement reply context
433
-
// Return the computed hot_rank (0.0 if NULL for non-hot sorts)
434
-
hotRankValue := 0.0
436
-
hotRankValue = hotRank.Float64
439
-
return feedPost, hotRankValue, nil
442
-
// Helper function to convert sql.NullString to *string
443
-
func nullStringPtr(ns sql.NullString) *string {