A community based topic aggregation platform built on atproto

Merge branch 'feat/comment-query-api-phase2a'

+22
cmd/server/main.go
···
"Coves/internal/atproto/identity"
"Coves/internal/atproto/jetstream"
"Coves/internal/core/aggregators"
"Coves/internal/core/communities"
"Coves/internal/core/communityFeeds"
"Coves/internal/core/discover"
···
chiMiddleware "github.com/go-chi/chi/v5/middleware"
_ "github.com/lib/pq"
"github.com/pressly/goose/v3"
postgresRepo "Coves/internal/db/postgres"
)
···
commentRepo := postgresRepo.NewCommentRepository(db)
log.Println("✅ Comment repository initialized (Jetstream indexing only)")
// Initialize feed service
feedRepo := postgresRepo.NewCommunityFeedRepository(db)
feedService := communityFeeds.NewCommunityFeedService(feedRepo, communityService)
···
routes.RegisterAggregatorRoutes(r, aggregatorService)
log.Println("Aggregator XRPC endpoints registered (query endpoints public)")
r.Get("/health", func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
···
"Coves/internal/atproto/identity"
"Coves/internal/atproto/jetstream"
"Coves/internal/core/aggregators"
+
"Coves/internal/core/comments"
"Coves/internal/core/communities"
"Coves/internal/core/communityFeeds"
"Coves/internal/core/discover"
···
chiMiddleware "github.com/go-chi/chi/v5/middleware"
_ "github.com/lib/pq"
"github.com/pressly/goose/v3"
+
+
commentsAPI "Coves/internal/api/handlers/comments"
postgresRepo "Coves/internal/db/postgres"
)
···
commentRepo := postgresRepo.NewCommentRepository(db)
log.Println("✅ Comment repository initialized (Jetstream indexing only)")
+
// Initialize comment service (for query API)
+
// Requires user and community repos for proper author/community hydration per lexicon
+
commentService := comments.NewCommentService(commentRepo, userRepo, postRepo, communityRepo)
+
log.Println("✅ Comment service initialized (with author/community hydration)")
+
// Initialize feed service
feedRepo := postgresRepo.NewCommunityFeedRepository(db)
feedService := communityFeeds.NewCommunityFeedService(feedRepo, communityService)
···
routes.RegisterAggregatorRoutes(r, aggregatorService)
log.Println("Aggregator XRPC endpoints registered (query endpoints public)")
+
+
// Comment query API - supports optional authentication for viewer state
+
// Stricter rate limiting for expensive nested comment queries
+
commentRateLimiter := middleware.NewRateLimiter(20, 1*time.Minute)
+
commentServiceAdapter := commentsAPI.NewServiceAdapter(commentService)
+
commentHandler := commentsAPI.NewGetCommentsHandler(commentServiceAdapter)
+
r.Handle(
+
"/xrpc/social.coves.community.comment.getComments",
+
commentRateLimiter.Middleware(
+
commentsAPI.OptionalAuthMiddleware(authMiddleware, commentHandler.HandleGetComments),
+
),
+
)
+
log.Println("✅ Comment query API registered (20 req/min rate limit)")
+
log.Println(" - GET /xrpc/social.coves.community.comment.getComments")
r.Get("/health", func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
+1125
docs/COMMENT_SYSTEM_IMPLEMENTATION.md
···
···
+
# Comment System Implementation
+
+
## Overview
+
+
This document details the complete implementation of the comment system for Coves, a forum-like atProto social media platform. The comment system follows the established vote system pattern, with comments living in user repositories and being indexed by the AppView via Jetstream firehose.
+
+
**Implementation Date:** November 4-6, 2025
+
**Status:** ✅ Phase 1 & 2A Complete - Production-Ready with All PR Fixes
+
**Test Coverage:** 29 integration tests (18 indexing + 11 query), all passing
+
**Last Updated:** November 6, 2025 (Final PR review fixes complete - lexicon compliance, data integrity, SQL correctness)
+
+
---
+
+
## Development Phases
+
+
This implementation follows a phased approach for maintainability and proper scoping:
+
+
### ✅ Phase 1: Indexing Infrastructure (Current - COMPLETE)
+
**What was built:**
+
- Jetstream consumer for indexing comment CREATE/UPDATE/DELETE events
+
- PostgreSQL schema with proper indexes and denormalized counts
+
- Repository layer with comprehensive query methods
+
- Atomic parent count updates (posts.comment_count, comments.reply_count)
+
- Out-of-order event handling with reconciliation
+
- Soft delete support preserving thread structure
+
- Full integration test coverage (20 tests)
+
+
**What works:**
+
- Comments are indexed from Jetstream firehose as users create them
+
- Threading relationships tracked (root + parent references)
+
- Parent counts automatically maintained
+
- Comment updates and deletes processed correctly
+
- Out-of-order events reconciled automatically
+
+
**What's NOT in this phase:**
+
- ❌ No HTTP API endpoints for querying comments
+
- ❌ No service layer (repository is sufficient for indexing)
+
- ❌ No rate limiting or auth middleware
+
- ❌ No API documentation
+
+
### ✅ Phase 2A: Query API - COMPLETE (November 5, 2025)
+
+
**What was built:**
+
- Lexicon definitions: `social.coves.community.comment.defs` and `getComments`
+
- Database query methods with Lemmy hot ranking algorithm
+
- Service layer with iterative loading strategy for nested replies
+
- XRPC HTTP handler with optional authentication
+
- Comprehensive integration test suite (11 test scenarios)
+
+
**What works:**
+
- Fetch comments on any post with sorting (hot/top/new)
+
- Nested replies up to configurable depth (default 10, max 100)
+
- Lemmy hot ranking: `log(greatest(2, score + 2)) / power(time_decay, 1.8)`
+
- Cursor-based pagination for stable scrolling
+
- Optional authentication for viewer state (stubbed for Phase 2B)
+
- Timeframe filtering for "top" sort (hour/day/week/month/year/all)
+
+
**Endpoints:**
+
- `GET /xrpc/social.coves.community.comment.getComments`
+
- Required: `post` (AT-URI)
+
- Optional: `sort` (hot/top/new), `depth` (0-100), `limit` (1-100), `cursor`, `timeframe`
+
- Returns: Array of `threadViewComment` with nested replies + post context
+
- Supports Bearer token for authenticated requests (viewer state)
+
+
**Files created (9):**
+
1. `internal/atproto/lexicon/social/coves/community/comment/defs.json` - View definitions
+
2. `internal/atproto/lexicon/social/coves/community/comment/getComments.json` - Query endpoint
+
3. `internal/core/comments/comment_service.go` - Business logic layer
+
4. `internal/core/comments/view_models.go` - API response types
+
5. `internal/api/handlers/comments/get_comments.go` - HTTP handler
+
6. `internal/api/handlers/comments/errors.go` - Error handling utilities
+
7. `internal/api/handlers/comments/middleware.go` - Auth middleware
+
8. `internal/api/handlers/comments/service_adapter.go` - Service layer adapter
+
9. `tests/integration/comment_query_test.go` - Integration tests
+
+
**Files modified (7):**
+
1. `internal/db/postgres/comment_repo.go` - Added query methods (~450 lines), fixed INNER→LEFT JOIN, fixed window function SQL
+
2. `internal/core/comments/interfaces.go` - Added service interface
+
3. `internal/core/comments/comment.go` - Added CommenterHandle field
+
4. `internal/core/comments/errors.go` - Added IsValidationError helper
+
5. `cmd/server/main.go` - Wired up routes and service with all repositories
+
6. `tests/integration/comment_query_test.go` - Updated test helpers for new service signature
+
7. `docs/COMMENT_SYSTEM_IMPLEMENTATION.md` - This document
+
+
**Total new code:** ~2,400 lines
+
+
**Test coverage:**
+
- 11 integration test scenarios covering:
+
- Basic fetch, nested replies, depth limits
+
- Hot/top/new sorting algorithms
+
- Pagination with cursor stability
+
- Empty threads, deleted comments
+
- Invalid input handling
+
- HTTP handler end-to-end
+
- Repository layer tested (hot ranking formula, pagination)
+
- Service layer tested (threading, depth limits)
+
- Handler tested (input validation, error cases)
+
- All tests passing ✅
+
+
### 🔒 Production Hardening (PR Review Fixes - November 5, 2025)
+
+
After initial implementation, a thorough PR review identified several critical issues that were addressed before production deployment:
+
+
#### Critical Issues Fixed
+
+
**1. N+1 Query Problem (99.7% reduction in queries)**
+
- **Problem:** Nested reply loading made separate DB queries for each comment's children
+
- **Impact:** Could execute 1,551 queries for a post with 50 comments at depth 3
+
- **Solution:** Implemented batch loading with PostgreSQL window functions
+
- Added `ListByParentsBatch()` method using `ROW_NUMBER() OVER (PARTITION BY parent_uri)`
+
- Refactored `buildThreadViews()` to collect parent URIs per level and fetch in one query
+
- **Result:** Reduced from 1,551 queries → 4 queries (1 per depth level)
+
- **Files:** `internal/core/comments/interfaces.go`, `internal/db/postgres/comment_repo.go`, `internal/core/comments/comment_service.go`
+
+
**2. Post Not Found Returns 500 Instead of 404**
+
- **Problem:** When fetching comments for non-existent post, service returned wrapped `posts.ErrNotFound` which handler didn't recognize
+
- **Impact:** Clients got HTTP 500 instead of proper HTTP 404
+
- **Solution:** Added error translation in service layer
+
```go
+
if posts.IsNotFound(err) {
+
return nil, ErrRootNotFound // Recognized by comments.IsNotFound()
+
}
+
```
+
- **File:** `internal/core/comments/comment_service.go:68-72`
+
+
#### Important Issues Fixed
+
+
**3. Missing Endpoint-Specific Rate Limiting**
+
- **Problem:** Comment queries with deep nesting expensive but only protected by global 100 req/min limit
+
- **Solution:** Added dedicated rate limiter at 20 req/min for comment endpoint
+
- **File:** `cmd/server/main.go:429-439`
+
+
**4. Unbounded Cursor Size (DoS Vector)**
+
- **Problem:** No validation before base64 decoding - attacker could send massive cursor string
+
- **Solution:** Added 1024-byte max size check before decoding
+
- **File:** `internal/db/postgres/comment_repo.go:547-551`
+
+
**5. Missing Query Timeout**
+
- **Problem:** Deep nested queries could run indefinitely
+
- **Solution:** Added 10-second context timeout to `GetComments()`
+
- **File:** `internal/core/comments/comment_service.go:62-64`
+
+
**6. Post View Not Populated (P0 Blocker)**
+
- **Problem:** Lexicon marked `post` field as required but response always returned `null`
+
- **Impact:** Violated schema contract, would break client deserialization
+
- **Solution:**
+
- Updated service to accept `posts.Repository` instead of `interface{}`
+
- Added `buildPostView()` method to construct post views with author/community/stats
+
- Fetch post before returning response
+
- **Files:** `internal/core/comments/comment_service.go:33-36`, `:66-73`, `:224-274`
+
+
**7. Missing Record Fields (P0 Blocker)**
+
- **Problem:** Both `postView.record` and `commentView.record` fields were null despite lexicon marking them as required
+
- **Impact:** Violated lexicon contract, would break strict client deserialization
+
- **Solution:**
+
- Added `buildPostRecord()` method to construct minimal PostRecord from Post entity
+
- Added `buildCommentRecord()` method to construct minimal CommentRecord from Comment entity
+
- Both methods populate required fields (type, reply refs, content, timestamps)
+
- Added TODOs for Phase 2C to unmarshal JSON fields (embed, facets, labels)
+
- **Files:** `internal/core/comments/comment_service.go:260-288`, `:366-386`
+
+
**8. Handle/Name Format Violations (P0 & Important)**
+
- **Problem:**
+
- `postView.author.handle` contained DID instead of proper handle (violates `format:"handle"`)
+
- `postView.community.name` contained DID instead of community name
+
- **Impact:** Lexicon format constraints violated, poor UX showing DIDs instead of readable names
+
- **Solution:**
+
- Added `users.UserRepository` to service for author handle hydration
+
- Added `communities.Repository` to service for community name hydration
+
- Updated `buildPostView()` to fetch user and community records with DID fallback
+
- Log warnings for missing records but don't fail entire request
+
- **Files:** `internal/core/comments/comment_service.go:34-37`, `:292-325`, `cmd/server/main.go:297`
+
+
**9. Data Loss from INNER JOIN (P1 Critical)**
+
- **Problem:** Three query methods used `INNER JOIN users` which dropped comments when user not indexed yet
+
- **Impact:** New user's first comments would disappear until user consumer caught up (violates out-of-order design)
+
- **Solution:**
+
- Changed `INNER JOIN users` → `LEFT JOIN users` in all three methods
+
- Added `COALESCE(u.handle, c.commenter_did)` to gracefully fall back to DID
+
- Preserves all comments while still hydrating handles when available
+
- **Files:** `internal/db/postgres/comment_repo.go:396`, `:407`, `:415`, `:694-706`, `:761-836`
+
+
**10. Window Function SQL Bug (P0 Critical)**
+
- **Problem:** `ListByParentsBatch` used `ORDER BY hot_rank DESC` in window function, but PostgreSQL doesn't allow SELECT aliases in window ORDER BY
+
- **Impact:** SQL error "column hot_rank does not exist" caused silent failure, dropping ALL nested replies in hot sort mode
+
- **Solution:**
+
- Created separate `windowOrderBy` variable that inlines full hot_rank formula
+
- PostgreSQL evaluates window ORDER BY before SELECT, so must use full expression
+
- Hot sort now works correctly with nested replies
+
- **Files:** `internal/db/postgres/comment_repo.go:776`, `:808`
+
- **Critical Note:** This affected default sorting mode (hot) and would have broken production UX
+
+
#### Documentation Added
+
+
**11. Hot Rank Caching Strategy**
+
- Documented when and how to implement cached hot rank column
+
- Specified observability metrics to monitor (p95 latency, CPU usage)
+
- Documented trade-offs between cached vs on-demand computation
+
+
**Test Coverage:**
+
- All fixes verified with existing integration test suite
+
- Added test cases for error handling scenarios
+
- All integration tests passing (comment_query_test.go: 11 tests)
+
+
**Rationale for phased approach:**
+
1. **Separation of concerns**: Indexing and querying are distinct responsibilities
+
2. **Testability**: Phase 1 can be fully tested without API layer
+
3. **Incremental delivery**: Indexing can run in production while API is developed
+
4. **Scope management**: Prevents feature creep and allows focused code review
+
+
---
+
+
## Hot Ranking Algorithm (Lemmy-Based)
+
+
### Formula
+
+
```sql
+
log(greatest(2, score + 2)) /
+
power(((EXTRACT(EPOCH FROM (NOW() - created_at)) / 3600) + 2), 1.8)
+
```
+
+
### Explanation
+
+
**Components:**
+
- `greatest(2, score + 2)`: Ensures log input never goes below 2
+
- Prevents negative log values for heavily downvoted comments
+
- Score of -5 → log(2), same as score of 0
+
- Prevents brigading from creating "anti-viral" comments
+
+
- `power(..., 1.8)`: Time decay exponent
+
- Higher than posts (1.5) for faster comment aging
+
- Comments should be fresher than posts
+
+
- `+ 2` offsets: Prevent divide-by-zero for very new comments
+
+
**Behavior:**
+
- High score + old = lower rank (content ages naturally)
+
- Low score + new = higher rank (fresh content gets visibility)
+
- Negative scores don't break the formula (bounded at log(2))
+
+
### Sort Modes
+
+
**Hot (default):**
+
```sql
+
ORDER BY hot_rank DESC, score DESC, created_at DESC
+
```
+
+
**Top (with timeframe):**
+
```sql
+
WHERE created_at >= NOW() - INTERVAL '1 day'
+
ORDER BY score DESC, created_at DESC
+
```
+
+
**New (chronological):**
+
```sql
+
ORDER BY created_at DESC
+
```
+
+
### Path-Based Ordering
+
+
Comments are ordered within their tree level:
+
```sql
+
ORDER BY
+
path ASC, -- Maintains parent-child structure
+
hot_rank DESC, -- Sorts siblings by rank
+
score DESC, -- Tiebreaker
+
created_at DESC -- Final tiebreaker
+
```
+
+
**Result:** Siblings compete with siblings, but children never outrank their parent.
+
+
---
+
+
## Architecture
+
+
### Data Flow
+
+
```
+
Client → User's PDS → Jetstream Firehose → Comment Consumer → PostgreSQL AppView
+
+
Atomic updates to parent counts
+
(posts.comment_count OR comments.reply_count)
+
```
+
+
### Key Design Principles
+
+
1. **User-Owned Records**: Comments live in user repositories (like votes), not community repositories (like posts)
+
2. **atProto Native**: Uses `com.atproto.repo.createRecord/updateRecord/deleteRecord`
+
3. **Threading via Strong References**: Root + parent system allows unlimited nesting depth
+
4. **Out-of-Order Indexing**: No foreign key constraints to allow Jetstream events to arrive in any order
+
5. **Idempotent Operations**: Safe for Jetstream replays and duplicate events
+
6. **Atomic Count Updates**: Database transactions ensure consistency
+
7. **Soft Deletes**: Preserves thread structure when comments are deleted
+
+
---
+
+
## Implementation Details
+
+
### 1. Lexicon Definition
+
+
**Location:** `internal/atproto/lexicon/social/coves/feed/comment.json`
+
+
The lexicon was already defined and follows atProto best practices:
+
+
```json
+
{
+
"lexicon": 1,
+
"id": "social.coves.feed.comment",
+
"defs": {
+
"main": {
+
"type": "record",
+
"key": "tid",
+
"required": ["reply", "content", "createdAt"],
+
"properties": {
+
"reply": {
+
"type": "ref",
+
"ref": "#replyRef",
+
"description": "Reference to the post and parent being replied to"
+
},
+
"content": {
+
"type": "string",
+
"maxGraphemes": 3000,
+
"maxLength": 30000
+
},
+
"facets": { /* Rich text annotations */ },
+
"embed": { /* Images, quoted posts */ },
+
"langs": { /* ISO 639-1 language codes */ },
+
"labels": { /* Self-applied content labels */ },
+
"createdAt": { /* RFC3339 timestamp */ }
+
}
+
},
+
"replyRef": {
+
"required": ["root", "parent"],
+
"properties": {
+
"root": {
+
"type": "ref",
+
"ref": "com.atproto.repo.strongRef",
+
"description": "Strong reference to the original post"
+
},
+
"parent": {
+
"type": "ref",
+
"ref": "com.atproto.repo.strongRef",
+
"description": "Strong reference to immediate parent (post or comment)"
+
}
+
}
+
}
+
}
+
}
+
```
+
+
**Threading Model:**
+
- `root`: Always points to the original post that started the thread
+
- `parent`: Points to the immediate parent (can be a post or another comment)
+
- This enables unlimited nested threading while maintaining the root reference
+
+
---
+
+
### 2. Database Schema
+
+
**Migration:** `internal/db/migrations/016_create_comments_table.sql`
+
+
```sql
+
CREATE TABLE comments (
+
id BIGSERIAL PRIMARY KEY,
+
uri TEXT UNIQUE NOT NULL, -- AT-URI (at://commenter_did/social.coves.feed.comment/rkey)
+
cid TEXT NOT NULL, -- Content ID
+
rkey TEXT NOT NULL, -- Record key (TID)
+
commenter_did TEXT NOT NULL, -- User who commented (from AT-URI repo field)
+
+
-- Threading structure (reply references)
+
root_uri TEXT NOT NULL, -- Strong reference to original post
+
root_cid TEXT NOT NULL, -- CID of root post (version pinning)
+
parent_uri TEXT NOT NULL, -- Strong reference to immediate parent
+
parent_cid TEXT NOT NULL, -- CID of parent (version pinning)
+
+
-- Content
+
content TEXT NOT NULL, -- Comment text (max 3000 graphemes, 30000 bytes)
+
content_facets JSONB, -- Rich text facets
+
embed JSONB, -- Embedded content (images, quoted posts)
+
content_labels JSONB, -- Self-applied labels (com.atproto.label.defs#selfLabels)
+
langs TEXT[], -- Languages (ISO 639-1, max 3)
+
+
-- Timestamps
+
created_at TIMESTAMPTZ NOT NULL, -- Commenter's timestamp from record
+
indexed_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
+
deleted_at TIMESTAMPTZ, -- Soft delete
+
+
-- Stats (denormalized for performance)
+
upvote_count INT NOT NULL DEFAULT 0, -- Comments CAN be voted on
+
downvote_count INT NOT NULL DEFAULT 0,
+
score INT NOT NULL DEFAULT 0, -- upvote_count - downvote_count
+
reply_count INT NOT NULL DEFAULT 0 -- Number of direct replies
+
);
+
```
+
+
**Key Indexes:**
+
```sql
+
-- Threading queries (most important for UX)
+
CREATE INDEX idx_comments_root ON comments(root_uri, created_at DESC)
+
WHERE deleted_at IS NULL;
+
CREATE INDEX idx_comments_parent ON comments(parent_uri, created_at DESC)
+
WHERE deleted_at IS NULL;
+
CREATE INDEX idx_comments_parent_score ON comments(parent_uri, score DESC, created_at DESC)
+
WHERE deleted_at IS NULL;
+
+
-- User queries
+
CREATE INDEX idx_comments_commenter ON comments(commenter_did, created_at DESC);
+
+
-- Vote targeting
+
CREATE INDEX idx_comments_uri_active ON comments(uri)
+
WHERE deleted_at IS NULL;
+
```
+
+
**Design Decisions:**
+
- **No FK on `commenter_did`**: Allows out-of-order Jetstream indexing (comment events may arrive before user events)
+
- **Soft delete pattern**: `deleted_at IS NULL` in indexes for performance
+
- **Vote counts included**: The vote lexicon explicitly allows voting on comments (not just posts)
+
- **StrongRef with CID**: Version pinning prevents confusion when parent content changes
+
+
---
+
+
### 3. Domain Layer
+
+
#### Comment Entity
+
**File:** `internal/core/comments/comment.go`
+
+
```go
+
type Comment struct {
+
ID int64
+
URI string
+
CID string
+
RKey string
+
CommenterDID string
+
+
// Threading
+
RootURI string
+
RootCID string
+
ParentURI string
+
ParentCID string
+
+
// Content
+
Content string
+
ContentFacets *string
+
Embed *string
+
ContentLabels *string
+
Langs []string
+
+
// Timestamps
+
CreatedAt time.Time
+
IndexedAt time.Time
+
DeletedAt *time.Time
+
+
// Stats
+
UpvoteCount int
+
DownvoteCount int
+
Score int
+
ReplyCount int
+
}
+
```
+
+
#### Repository Interface
+
**File:** `internal/core/comments/interfaces.go`
+
+
```go
+
type Repository interface {
+
Create(ctx context.Context, comment *Comment) error
+
Update(ctx context.Context, comment *Comment) error
+
GetByURI(ctx context.Context, uri string) (*Comment, error)
+
Delete(ctx context.Context, uri string) error
+
+
// Threading queries
+
ListByRoot(ctx context.Context, rootURI string, limit, offset int) ([]*Comment, error)
+
ListByParent(ctx context.Context, parentURI string, limit, offset int) ([]*Comment, error)
+
CountByParent(ctx context.Context, parentURI string) (int, error)
+
+
// User queries
+
ListByCommenter(ctx context.Context, commenterDID string, limit, offset int) ([]*Comment, error)
+
}
+
```
+
+
#### Error Types
+
**File:** `internal/core/comments/errors.go`
+
+
Standard error types following the vote system pattern, with helper functions `IsNotFound()` and `IsConflict()`.
+
+
---
+
+
### 4. Repository Implementation
+
+
**File:** `internal/db/postgres/comment_repo.go`
+
+
#### Idempotent Create Pattern
+
```go
+
func (r *postgresCommentRepo) Create(ctx context.Context, comment *Comment) error {
+
query := `
+
INSERT INTO comments (...)
+
VALUES (...)
+
ON CONFLICT (uri) DO NOTHING
+
RETURNING id, indexed_at
+
`
+
+
err := r.db.QueryRowContext(ctx, query, ...).Scan(&comment.ID, &comment.IndexedAt)
+
+
// ON CONFLICT DO NOTHING returns no rows if duplicate
+
if err == sql.ErrNoRows {
+
return nil // Already exists - OK for Jetstream replays
+
}
+
+
return err
+
}
+
```
+
+
#### Update Preserving Vote Counts
+
```go
+
func (r *postgresCommentRepo) Update(ctx context.Context, comment *Comment) error {
+
query := `
+
UPDATE comments
+
SET cid = $1, content = $2, content_facets = $3,
+
embed = $4, content_labels = $5, langs = $6
+
WHERE uri = $7 AND deleted_at IS NULL
+
RETURNING id, indexed_at, created_at,
+
upvote_count, downvote_count, score, reply_count
+
`
+
+
// Vote counts and created_at are preserved (not in SET clause)
+
err := r.db.QueryRowContext(ctx, query, ...).Scan(...)
+
return err
+
}
+
```
+
+
#### Soft Delete
+
```go
+
func (r *postgresCommentRepo) Delete(ctx context.Context, uri string) error {
+
query := `
+
UPDATE comments
+
SET deleted_at = NOW()
+
WHERE uri = $1 AND deleted_at IS NULL
+
`
+
+
result, err := r.db.ExecContext(ctx, query, uri)
+
// Idempotent: Returns success even if already deleted
+
return err
+
}
+
```
+
+
---
+
+
### 5. Jetstream Consumer
+
+
**File:** `internal/atproto/jetstream/comment_consumer.go`
+
+
#### Event Handler
+
```go
+
func (c *CommentEventConsumer) HandleEvent(ctx context.Context, event *JetstreamEvent) error {
+
if event.Kind != "commit" || event.Commit == nil {
+
return nil
+
}
+
+
if event.Commit.Collection == "social.coves.feed.comment" {
+
switch event.Commit.Operation {
+
case "create":
+
return c.createComment(ctx, event.Did, commit)
+
case "update":
+
return c.updateComment(ctx, event.Did, commit)
+
case "delete":
+
return c.deleteComment(ctx, event.Did, commit)
+
}
+
}
+
+
return nil
+
}
+
```
+
+
#### Atomic Count Updates
+
```go
+
func (c *CommentEventConsumer) indexCommentAndUpdateCounts(ctx, comment *Comment) error {
+
tx, _ := c.db.BeginTx(ctx, nil)
+
defer tx.Rollback()
+
+
// 1. Insert comment (idempotent)
+
err = tx.QueryRowContext(ctx, `
+
INSERT INTO comments (...) VALUES (...)
+
ON CONFLICT (uri) DO NOTHING
+
RETURNING id
+
`).Scan(&commentID)
+
+
if err == sql.ErrNoRows {
+
tx.Commit()
+
return nil // Already indexed
+
}
+
+
// 2. Update parent counts atomically
+
// Try posts table first
+
tx.ExecContext(ctx, `
+
UPDATE posts
+
SET comment_count = comment_count + 1
+
WHERE uri = $1 AND deleted_at IS NULL
+
`, comment.ParentURI)
+
+
// If no post updated, parent is probably a comment
+
tx.ExecContext(ctx, `
+
UPDATE comments
+
SET reply_count = reply_count + 1
+
WHERE uri = $1 AND deleted_at IS NULL
+
`, comment.ParentURI)
+
+
return tx.Commit()
+
}
+
```
+
+
#### Security Validation
+
```go
+
func (c *CommentEventConsumer) validateCommentEvent(ctx, repoDID string, comment *CommentRecord) error {
+
// Comments MUST come from user repositories (repo owner = commenter DID)
+
if !strings.HasPrefix(repoDID, "did:") {
+
return fmt.Errorf("invalid commenter DID format: %s", repoDID)
+
}
+
+
// Content is required
+
if comment.Content == "" {
+
return fmt.Errorf("comment content is required")
+
}
+
+
// Reply references must have both URI and CID
+
if comment.Reply.Root.URI == "" || comment.Reply.Root.CID == "" {
+
return fmt.Errorf("invalid root reference: must have both URI and CID")
+
}
+
+
if comment.Reply.Parent.URI == "" || comment.Reply.Parent.CID == "" {
+
return fmt.Errorf("invalid parent reference: must have both URI and CID")
+
}
+
+
return nil
+
}
+
```
+
+
**Security Note:** We do NOT verify that the user exists in the AppView because:
+
1. Comment events may arrive before user events in Jetstream (race condition)
+
2. The comment came from the user's PDS repository (authenticated by PDS)
+
3. No database FK constraint allows out-of-order indexing
+
4. Orphaned comments (from never-indexed users) are harmless
+
+
---
+
+
### 6. WebSocket Connector
+
+
**File:** `internal/atproto/jetstream/comment_jetstream_connector.go`
+
+
Follows the standard Jetstream connector pattern with:
+
- Auto-reconnect on errors (5-second retry)
+
- Ping/pong keepalive (30-second ping, 60-second read deadline)
+
- Graceful shutdown via context cancellation
+
- Subscribes to: `wantedCollections=social.coves.feed.comment`
+
+
---
+
+
### 7. Server Integration
+
+
**File:** `cmd/server/main.go` (lines 289-396)
+
+
```go
+
// Initialize comment repository
+
commentRepo := postgresRepo.NewCommentRepository(db)
+
log.Println("✅ Comment repository initialized (Jetstream indexing only)")
+
+
// Start Jetstream consumer for comments
+
commentJetstreamURL := os.Getenv("COMMENT_JETSTREAM_URL")
+
if commentJetstreamURL == "" {
+
commentJetstreamURL = "ws://localhost:6008/subscribe?wantedCollections=social.coves.feed.comment"
+
}
+
+
commentEventConsumer := jetstream.NewCommentEventConsumer(commentRepo, db)
+
commentJetstreamConnector := jetstream.NewCommentJetstreamConnector(commentEventConsumer, commentJetstreamURL)
+
+
go func() {
+
if startErr := commentJetstreamConnector.Start(ctx); startErr != nil {
+
log.Printf("Comment Jetstream consumer stopped: %v", startErr)
+
}
+
}()
+
+
log.Printf("Started Jetstream comment consumer: %s", commentJetstreamURL)
+
log.Println(" - Indexing: social.coves.feed.comment CREATE/UPDATE/DELETE operations")
+
log.Println(" - Updating: Post comment counts and comment reply counts atomically")
+
```
+
+
---
+
+
## Testing
+
+
### Test Suite
+
+
**File:** `tests/integration/comment_consumer_test.go`
+
+
**Test Coverage:** 6 test suites, 18 test cases, **100% passing**
+
+
#### 1. TestCommentConsumer_CreateComment
+
- ✅ Create comment on post
+
- ✅ Verify comment is indexed correctly
+
- ✅ Verify post comment count is incremented
+
- ✅ Idempotent create - duplicate events don't double-count
+
+
#### 2. TestCommentConsumer_Threading
+
- ✅ Create first-level comment (reply to post)
+
- ✅ Create second-level comment (reply to comment)
+
- ✅ Verify both comments have same root (original post)
+
- ✅ Verify parent relationships are correct
+
- ✅ Verify reply counts are updated
+
- ✅ Query all comments by root (flat list)
+
- ✅ Query direct replies to post
+
- ✅ Query direct replies to comment
+
+
#### 3. TestCommentConsumer_UpdateComment
+
- ✅ Create comment with initial content
+
- ✅ Manually set vote counts to simulate votes
+
- ✅ Update comment content
+
- ✅ Verify content is updated
+
- ✅ Verify CID is updated
+
- ✅ **Verify vote counts are preserved**
+
- ✅ **Verify created_at is preserved**
+
+
#### 4. TestCommentConsumer_DeleteComment
+
- ✅ Create comment
+
- ✅ Delete comment (soft delete)
+
- ✅ Verify deleted_at is set
+
- ✅ Verify post comment count is decremented
+
- ✅ Idempotent delete - duplicate deletes don't double-decrement
+
+
#### 5. TestCommentConsumer_SecurityValidation
+
- ✅ Reject comment with empty content
+
- ✅ Reject comment with invalid root reference (missing URI)
+
- ✅ Reject comment with invalid parent reference (missing CID)
+
- ✅ Reject comment with invalid DID format
+
+
#### 6. TestCommentRepository_Queries
+
- ✅ ListByRoot returns all comments in thread (4 comments)
+
- ✅ ListByParent returns direct replies to post (2 comments)
+
- ✅ ListByParent returns direct replies to comment (2 comments)
+
- ✅ CountByParent returns correct counts
+
- ✅ ListByCommenter returns all user's comments
+
+
### Test Results
+
+
```
+
=== Test Summary ===
+
PASS: TestCommentConsumer_CreateComment (0.02s)
+
PASS: TestCommentConsumer_Threading (0.02s)
+
PASS: TestCommentConsumer_UpdateComment (0.02s)
+
PASS: TestCommentConsumer_DeleteComment (0.02s)
+
PASS: TestCommentConsumer_SecurityValidation (0.01s)
+
PASS: TestCommentRepository_Queries (0.02s)
+
+
✅ ALL 18 TESTS PASS
+
Total time: 0.115s
+
```
+
+
---
+
+
## Key Features
+
+
### ✅ Comments ARE Votable
+
The vote lexicon explicitly states: *"Record declaring a vote (upvote or downvote) on a **post or comment**"*
+
+
Comments include full vote tracking:
+
- `upvote_count`
+
- `downvote_count`
+
- `score` (calculated as upvote_count - downvote_count)
+
+
### ✅ Comments ARE Editable
+
Unlike votes (which are immutable), comments support UPDATE operations:
+
- Content, facets, embed, and labels can be updated
+
- Vote counts and created_at are preserved
+
- CID is updated to reflect new version
+
+
### ✅ Threading Support
+
Unlimited nesting depth via root + parent system:
+
- Every comment knows its root post
+
- Every comment knows its immediate parent
+
- Easy to query entire threads or direct replies
+
- Soft deletes preserve thread structure
+
+
### ✅ Out-of-Order Indexing
+
No foreign key constraints allow events to arrive in any order:
+
- Comment events may arrive before user events
+
- Comment events may arrive before post events
+
- All operations are idempotent
+
- Safe for Jetstream replays
+
+
### ✅ Atomic Consistency
+
Database transactions ensure counts are always accurate:
+
- Comment creation increments parent count
+
- Comment deletion decrements parent count
+
- No race conditions
+
- No orphaned counts
+
+
---
+
+
## Implementation Statistics
+
+
### Phase 1 - Indexing Infrastructure
+
+
**Files Created: 8**
+
1. `internal/db/migrations/016_create_comments_table.sql` - 60 lines
+
2. `internal/core/comments/comment.go` - 80 lines
+
3. `internal/core/comments/interfaces.go` - 45 lines
+
4. `internal/core/comments/errors.go` - 40 lines
+
5. `internal/db/postgres/comment_repo.go` - 340 lines
+
6. `internal/atproto/jetstream/comment_consumer.go` - 530 lines
+
7. `internal/atproto/jetstream/comment_jetstream_connector.go` - 130 lines
+
8. `tests/integration/comment_consumer_test.go` - 930 lines
+
+
**Files Modified: 1**
+
1. `cmd/server/main.go` - Added 20 lines for Jetstream consumer
+
+
**Phase 1 Total:** ~2,175 lines
+
+
### Phase 2A - Query API
+
+
**Files Created: 9** (listed above in Phase 2A section)
+
+
**Files Modified: 6** (listed above in Phase 2A section)
+
+
**Phase 2A Total:** ~2,400 lines
+
+
### Combined Total: ~4,575 lines
+
+
---
+
+
## Reference Pattern: Vote System
+
+
The comment implementation closely follows the vote system pattern:
+
+
| Aspect | Votes | Comments |
+
|--------|-------|----------|
+
| **Location** | User repositories | User repositories |
+
| **Lexicon** | `social.coves.feed.vote` | `social.coves.feed.comment` |
+
| **Operations** | CREATE, DELETE | CREATE, UPDATE, DELETE |
+
| **Mutability** | Immutable | Editable |
+
| **Foreign Keys** | None (out-of-order indexing) | None (out-of-order indexing) |
+
| **Delete Pattern** | Soft delete | Soft delete |
+
| **Idempotency** | ON CONFLICT DO NOTHING | ON CONFLICT DO NOTHING |
+
| **Count Updates** | Atomic transaction | Atomic transaction |
+
| **Security** | PDS authentication | PDS authentication |
+
+
---
+
+
## Future Phases
+
+
### 📋 Phase 2B: Vote Integration (Planned)
+
+
**Scope:**
+
- Update vote consumer to handle comment votes
+
- Integrate `GetVoteStateForComments()` in service layer
+
- Populate viewer.vote and viewer.voteUri in commentView
+
- Test vote creation on comments end-to-end
+
- Atomic updates to comments.upvote_count, downvote_count, score
+
+
**Dependencies:**
+
- Phase 1 indexing (✅ Complete)
+
- Phase 2A query API (✅ Complete)
+
- Vote consumer (already exists for posts)
+
+
**Estimated effort:** 2-3 hours
+
+
---
+
+
### 📋 Phase 2C: Post/User Integration (Partially Complete)
+
+
**Completed (PR Review):**
+
- ✅ Integrated post repository in comment service
+
- ✅ Return postView in getComments response with basic fields
+
- ✅ Populate post author DID, community DID, stats (upvotes, downvotes, score, comment count)
+
+
**Remaining Work:**
+
- ❌ Integrate user repository for full AuthorView
+
- ❌ Add display name and avatar to comment/post authors (currently returns DID as handle)
+
- ❌ Add community name and avatar (currently returns DID as name)
+
- ❌ Parse and include original record in commentView
+
+
**Dependencies:**
+
- Phase 2A query API (✅ Complete)
+
- Post repository integration (✅ Complete)
+
- User repository integration (⏳ Pending)
+
+
**Estimated effort for remaining work:** 1-2 hours
+
+
---
+
+
### 📋 Phase 3: Advanced Features (Future)
+
+
#### 3A: Distinguished Comments
+
- Moderator/admin comment flags
+
- Priority sorting for distinguished comments
+
- Visual indicators in UI
+
+
#### 3B: Comment Search & Filtering
+
- Full-text search within threads
+
- Filter by author, time range, score
+
- Search across community comments
+
+
#### 3C: Moderation Tools
+
- Hide/remove comments
+
- Flag system for user reports
+
- Moderator queue
+
- Audit log
+
+
#### 3D: Notifications
+
- Notify users of replies to their comments
+
- Notify post authors of new comments
+
- Mention notifications (@user)
+
- Customizable notification preferences
+
+
#### 3E: Enhanced Features
+
- Comment edit history tracking
+
- Save/bookmark comments
+
- Sort by "controversial" (high engagement, low score)
+
- Collapsible comment threads
+
- User-specific comment history API
+
- Community-wide comment stats/analytics
+
+
---
+
+
### 📋 Phase 4: Namespace Migration (Separate Task)
+
+
**Scope:**
+
- Migrate existing `social.coves.feed.comment` records to `social.coves.community.comment`
+
- Update all AT-URIs in database
+
- Update Jetstream consumer collection filter
+
- Migration script with rollback capability
+
- Zero-downtime deployment strategy
+
+
**Note:** Currently out of scope - will be tackled separately when needed.
+
+
---
+
+
## Performance Considerations
+
+
### Database Indexes
+
+
All critical query patterns are indexed:
+
- **Threading queries**: `idx_comments_root`, `idx_comments_parent`
+
- **Sorting by score**: `idx_comments_parent_score`
+
- **User history**: `idx_comments_commenter`
+
- **Vote targeting**: `idx_comments_uri_active`
+
+
### Denormalized Counts
+
+
Vote counts and reply counts are denormalized for performance:
+
- Avoids `COUNT(*)` queries on large datasets
+
- Updated atomically with comment operations
+
- Indexed for fast sorting
+
+
### Pagination Support
+
+
All list queries support limit/offset pagination:
+
- `ListByRoot(ctx, rootURI, limit, offset)`
+
- `ListByParent(ctx, parentURI, limit, offset)`
+
- `ListByCommenter(ctx, commenterDID, limit, offset)`
+
+
### N+1 Query Prevention
+
+
**Problem Solved:** The initial implementation had a classic N+1 query problem where nested reply loading made separate database queries for each comment's children. For a post with 50 top-level comments and 3 levels of depth, this could result in ~1,551 queries.
+
+
**Solution Implemented:** Batch loading strategy using window functions:
+
1. Collect all parent URIs at each depth level
+
2. Execute single batch query using `ListByParentsBatch()` with PostgreSQL window functions
+
3. Group results by parent URI in memory
+
4. Recursively process next level
+
+
**Performance Improvement:**
+
- Old: 1 + N + (N × M) + (N × M × P) queries per request
+
- New: 1 query per depth level (max 4 queries for depth 3)
+
- Example with depth 3, 50 comments: 1,551 queries → 4 queries (99.7% reduction)
+
+
**Implementation Details:**
+
```sql
+
-- Uses ROW_NUMBER() window function to limit per parent efficiently
+
WITH ranked_comments AS (
+
SELECT *,
+
ROW_NUMBER() OVER (
+
PARTITION BY parent_uri
+
ORDER BY hot_rank DESC
+
) as rn
+
FROM comments
+
WHERE parent_uri = ANY($1)
+
)
+
SELECT * FROM ranked_comments WHERE rn <= $2
+
```
+
+
### Hot Rank Caching Strategy
+
+
**Current Implementation:**
+
Hot rank is computed on-demand for every query using the Lemmy algorithm:
+
```sql
+
log(greatest(2, score + 2)) /
+
power(((EXTRACT(EPOCH FROM (NOW() - created_at)) / 3600) + 2), 1.8)
+
```
+
+
**Performance Impact:**
+
- Computed for every comment in every hot-sorted query
+
- PostgreSQL handles this efficiently for moderate loads (<1000 comments per post)
+
- No noticeable performance degradation in testing
+
+
**Future Optimization (if needed):**
+
+
If hot rank computation becomes a bottleneck at scale:
+
+
1. **Add cached column:**
+
```sql
+
ALTER TABLE comments ADD COLUMN hot_rank_cached NUMERIC;
+
CREATE INDEX idx_comments_parent_hot_rank_cached
+
ON comments(parent_uri, hot_rank_cached DESC)
+
WHERE deleted_at IS NULL;
+
```
+
+
2. **Background recomputation job:**
+
```go
+
// Run every 5-15 minutes
+
func (j *HotRankJob) UpdateHotRanks(ctx context.Context) error {
+
query := `
+
UPDATE comments
+
SET hot_rank_cached = log(greatest(2, score + 2)) /
+
power(((EXTRACT(EPOCH FROM (NOW() - created_at)) / 3600) + 2), 1.8)
+
WHERE deleted_at IS NULL
+
`
+
_, err := j.db.ExecContext(ctx, query)
+
return err
+
}
+
```
+
+
3. **Use cached value in queries:**
+
```sql
+
SELECT * FROM comments
+
WHERE parent_uri = $1
+
ORDER BY hot_rank_cached DESC, score DESC
+
```
+
+
**When to implement:**
+
- Monitor query performance in production
+
- If p95 query latency > 200ms for hot-sorted queries
+
- If database CPU usage from hot rank computation > 20%
+
- Only optimize if measurements show actual bottleneck
+
+
**Trade-offs:**
+
- **Cached approach:** Faster queries, but ranks update every 5-15 minutes (slightly stale)
+
- **On-demand approach:** Always fresh ranks, slightly higher query cost
+
- For comment discussions, 5-15 minute staleness is acceptable (comments age slowly)
+
+
---
+
+
## Conclusion
+
+
The comment system has successfully completed **Phase 1 (Indexing)** and **Phase 2A (Query API)**, providing a production-ready threaded discussion system for Coves:
+
+
✅ **Phase 1 Complete**: Full indexing infrastructure with Jetstream consumer
+
✅ **Phase 2A Complete**: Query API with hot ranking, threading, and pagination
+
✅ **Fully Tested**: 30+ integration tests across indexing and query layers
+
✅ **Secure**: Input validation, parameterized queries, optional auth
+
✅ **Scalable**: Indexed queries, denormalized counts, cursor pagination
+
✅ **atProto Native**: User-owned records, Jetstream indexing, Bluesky patterns
+
+
**Next milestones:**
+
- Phase 2B: Vote integration for comment voting
+
- Phase 2C: Post/user integration for complete views
+
- Phase 3: Advanced features (moderation, notifications, search)
+
+
The implementation provides a solid foundation for building rich threaded discussions in Coves while maintaining compatibility with the broader atProto ecosystem and following established patterns from platforms like Lemmy and Reddit.
+
+
---
+
+
## Appendix: Command Reference
+
+
### Run Tests
+
+
**Phase 1 - Indexing Tests:**
+
```bash
+
TEST_DATABASE_URL="postgres://test_user:test_password@localhost:5434/coves_test?sslmode=disable" \
+
go test -v ./tests/integration/comment_consumer_test.go \
+
./tests/integration/user_test.go \
+
./tests/integration/helpers.go \
+
-run "TestCommentConsumer" -timeout 60s
+
```
+
+
**Phase 2A - Query API Tests:**
+
```bash
+
TEST_DATABASE_URL="postgres://test_user:test_password@localhost:5434/coves_test?sslmode=disable" \
+
go test -v ./tests/integration/comment_query_test.go \
+
./tests/integration/user_test.go \
+
./tests/integration/helpers.go \
+
-run "TestCommentQuery" -timeout 120s
+
```
+
+
**All Comment Tests:**
+
```bash
+
TEST_DATABASE_URL="postgres://test_user:test_password@localhost:5434/coves_test?sslmode=disable" \
+
go test -v ./tests/integration/comment_*.go \
+
./tests/integration/user_test.go \
+
./tests/integration/helpers.go \
+
-timeout 120s
+
```
+
+
### Apply Migration
+
```bash
+
GOOSE_DRIVER=postgres \
+
GOOSE_DBSTRING="postgres://test_user:test_password@localhost:5434/coves_test?sslmode=disable" \
+
goose -dir internal/db/migrations up
+
```
+
+
### Build Server
+
```bash
+
go build ./cmd/server
+
```
+
+
### Environment Variables
+
```bash
+
# Jetstream URL (optional, defaults to localhost:6008)
+
export COMMENT_JETSTREAM_URL="ws://localhost:6008/subscribe?wantedCollections=social.coves.feed.comment"
+
+
# Database URL
+
export TEST_DATABASE_URL="postgres://test_user:test_password@localhost:5434/coves_test?sslmode=disable"
+
```
+
+
---
+
+
**Last Updated:** November 6, 2025
+
**Status:** ✅ Phase 1 & 2A Complete - Production-Ready with All PR Fixes
+44
internal/api/handlers/comments/errors.go
···
···
+
package comments
+
+
import (
+
"Coves/internal/core/comments"
+
"encoding/json"
+
"log"
+
"net/http"
+
)
+
+
// errorResponse represents a standardized JSON error response
+
type errorResponse struct {
+
Error string `json:"error"`
+
Message string `json:"message"`
+
}
+
+
// writeError writes a JSON error response with the given status code
+
func writeError(w http.ResponseWriter, statusCode int, errorType, message string) {
+
w.Header().Set("Content-Type", "application/json")
+
w.WriteHeader(statusCode)
+
if err := json.NewEncoder(w).Encode(errorResponse{
+
Error: errorType,
+
Message: message,
+
}); err != nil {
+
log.Printf("Failed to encode error response: %v", err)
+
}
+
}
+
+
// handleServiceError maps service-layer errors to HTTP responses
+
// This follows the error handling pattern from other handlers (post, community)
+
func handleServiceError(w http.ResponseWriter, err error) {
+
switch {
+
case comments.IsNotFound(err):
+
writeError(w, http.StatusNotFound, "NotFound", err.Error())
+
+
case comments.IsValidationError(err):
+
writeError(w, http.StatusBadRequest, "InvalidRequest", err.Error())
+
+
default:
+
// Don't leak internal error details to clients
+
log.Printf("Unexpected error in comments handler: %v", err)
+
writeError(w, http.StatusInternalServerError, "InternalServerError",
+
"An internal error occurred")
+
}
+
}
+167
internal/api/handlers/comments/get_comments.go
···
···
+
// Package comments provides HTTP handlers for the comment query API.
+
// These handlers follow XRPC conventions and integrate with the comments service layer.
+
package comments
+
+
import (
+
"Coves/internal/api/middleware"
+
"Coves/internal/core/comments"
+
"encoding/json"
+
"log"
+
"net/http"
+
"strconv"
+
)
+
+
// GetCommentsHandler handles comment retrieval for posts
+
type GetCommentsHandler struct {
+
service Service
+
}
+
+
// Service defines the interface for comment business logic
+
// This will be implemented by the comments service layer in Phase 2
+
type Service interface {
+
GetComments(r *http.Request, req *GetCommentsRequest) (*comments.GetCommentsResponse, error)
+
}
+
+
// GetCommentsRequest represents the query parameters for fetching comments
+
// Matches social.coves.feed.getComments lexicon input
+
type GetCommentsRequest struct {
+
Cursor *string `json:"cursor,omitempty"`
+
ViewerDID *string `json:"-"`
+
PostURI string `json:"post"`
+
Sort string `json:"sort,omitempty"`
+
Timeframe string `json:"timeframe,omitempty"`
+
Depth int `json:"depth,omitempty"`
+
Limit int `json:"limit,omitempty"`
+
}
+
+
// NewGetCommentsHandler creates a new handler for fetching comments
+
func NewGetCommentsHandler(service Service) *GetCommentsHandler {
+
return &GetCommentsHandler{
+
service: service,
+
}
+
}
+
+
// HandleGetComments handles GET /xrpc/social.coves.feed.getComments
+
// Retrieves comments on a post with threading support
+
func (h *GetCommentsHandler) HandleGetComments(w http.ResponseWriter, r *http.Request) {
+
// 1. Only allow GET method
+
if r.Method != http.MethodGet {
+
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
+
return
+
}
+
+
// 2. Parse query parameters
+
query := r.URL.Query()
+
post := query.Get("post")
+
sort := query.Get("sort")
+
timeframe := query.Get("timeframe")
+
depthStr := query.Get("depth")
+
limitStr := query.Get("limit")
+
cursor := query.Get("cursor")
+
+
// 3. Validate required parameters
+
if post == "" {
+
writeError(w, http.StatusBadRequest, "InvalidRequest", "post parameter is required")
+
return
+
}
+
+
// 4. Parse and validate depth with default
+
depth := 10 // Default depth
+
if depthStr != "" {
+
parsed, err := strconv.Atoi(depthStr)
+
if err != nil {
+
writeError(w, http.StatusBadRequest, "InvalidRequest", "depth must be a valid integer")
+
return
+
}
+
if parsed < 0 {
+
writeError(w, http.StatusBadRequest, "InvalidRequest", "depth must be non-negative")
+
return
+
}
+
depth = parsed
+
}
+
+
// 5. Parse and validate limit with default and max
+
limit := 50 // Default limit
+
if limitStr != "" {
+
parsed, err := strconv.Atoi(limitStr)
+
if err != nil {
+
writeError(w, http.StatusBadRequest, "InvalidRequest", "limit must be a valid integer")
+
return
+
}
+
if parsed < 1 {
+
writeError(w, http.StatusBadRequest, "InvalidRequest", "limit must be positive")
+
return
+
}
+
if parsed > 100 {
+
writeError(w, http.StatusBadRequest, "InvalidRequest", "limit cannot exceed 100")
+
return
+
}
+
limit = parsed
+
}
+
+
// 6. Validate sort parameter (if provided)
+
if sort != "" && sort != "hot" && sort != "top" && sort != "new" {
+
writeError(w, http.StatusBadRequest, "InvalidRequest",
+
"sort must be one of: hot, top, new")
+
return
+
}
+
+
// 7. Validate timeframe parameter (only valid with "top" sort)
+
if timeframe != "" {
+
if sort != "top" {
+
writeError(w, http.StatusBadRequest, "InvalidRequest",
+
"timeframe can only be used with sort=top")
+
return
+
}
+
validTimeframes := map[string]bool{
+
"hour": true, "day": true, "week": true,
+
"month": true, "year": true, "all": true,
+
}
+
if !validTimeframes[timeframe] {
+
writeError(w, http.StatusBadRequest, "InvalidRequest",
+
"timeframe must be one of: hour, day, week, month, year, all")
+
return
+
}
+
}
+
+
// 8. Extract viewer DID from context (set by OptionalAuth middleware)
+
viewerDID := middleware.GetUserDID(r)
+
var viewerPtr *string
+
if viewerDID != "" {
+
viewerPtr = &viewerDID
+
}
+
+
// 9. Build service request
+
req := &GetCommentsRequest{
+
PostURI: post,
+
Sort: sort,
+
Timeframe: timeframe,
+
Depth: depth,
+
Limit: limit,
+
Cursor: ptrOrNil(cursor),
+
ViewerDID: viewerPtr,
+
}
+
+
// 10. Call service layer
+
resp, err := h.service.GetComments(r, req)
+
if err != nil {
+
handleServiceError(w, err)
+
return
+
}
+
+
// 11. Return JSON response
+
w.Header().Set("Content-Type", "application/json")
+
w.WriteHeader(http.StatusOK)
+
if err := json.NewEncoder(w).Encode(resp); err != nil {
+
// Log encoding errors but don't return error response (headers already sent)
+
log.Printf("Failed to encode comments response: %v", err)
+
}
+
}
+
+
// ptrOrNil converts an empty string to nil pointer, otherwise returns pointer to string
+
func ptrOrNil(s string) *string {
+
if s == "" {
+
return nil
+
}
+
return &s
+
}
+22
internal/api/handlers/comments/middleware.go
···
···
+
package comments
+
+
import (
+
"Coves/internal/api/middleware"
+
"net/http"
+
)
+
+
// OptionalAuthMiddleware wraps the existing OptionalAuth middleware from the middleware package.
+
// This ensures comment handlers can access viewer identity when available, but don't require authentication.
+
//
+
// Usage in router setup:
+
//
+
// commentHandler := comments.NewGetCommentsHandler(commentService)
+
// router.Handle("/xrpc/social.coves.feed.getComments",
+
// comments.OptionalAuthMiddleware(authMiddleware, commentHandler.HandleGetComments))
+
//
+
// The middleware extracts the viewer DID from the Authorization header if present and valid,
+
// making it available via middleware.GetUserDID(r) in the handler.
+
// If no valid token is present, the request continues as anonymous (empty DID).
+
func OptionalAuthMiddleware(authMiddleware *middleware.AtProtoAuthMiddleware, next http.HandlerFunc) http.Handler {
+
return authMiddleware.OptionalAuth(http.HandlerFunc(next))
+
}
+37
internal/api/handlers/comments/service_adapter.go
···
···
+
package comments
+
+
import (
+
"Coves/internal/core/comments"
+
"net/http"
+
)
+
+
// ServiceAdapter adapts the core comments.Service to the handler's Service interface
+
// This bridges the gap between HTTP-layer concerns (http.Request) and domain-layer concerns (context.Context)
+
type ServiceAdapter struct {
+
coreService comments.Service
+
}
+
+
// NewServiceAdapter creates a new service adapter wrapping the core comment service
+
func NewServiceAdapter(coreService comments.Service) Service {
+
return &ServiceAdapter{
+
coreService: coreService,
+
}
+
}
+
+
// GetComments adapts the handler request to the core service request
+
// Converts handler-specific GetCommentsRequest to core GetCommentsRequest
+
func (a *ServiceAdapter) GetComments(r *http.Request, req *GetCommentsRequest) (*comments.GetCommentsResponse, error) {
+
// Convert handler request to core service request
+
coreReq := &comments.GetCommentsRequest{
+
PostURI: req.PostURI,
+
Sort: req.Sort,
+
Timeframe: req.Timeframe,
+
Depth: req.Depth,
+
Limit: req.Limit,
+
Cursor: req.Cursor,
+
ViewerDID: req.ViewerDID,
+
}
+
+
// Call core service with request context
+
return a.coreService.GetComments(r.Context(), coreReq)
+
}
+6 -8
internal/atproto/jetstream/comment_consumer.go
···
time.Now(),
commentID,
)
-
if err != nil {
return fmt.Errorf("failed to resurrect comment: %w", err)
}
···
comment.Content, comment.ContentFacets, comment.Embed, comment.ContentLabels, pq.Array(comment.Langs),
comment.CreatedAt, time.Now(),
).Scan(&commentID)
-
if err != nil {
return fmt.Errorf("failed to insert comment: %w", err)
}
···
// CommentRecordFromJetstream represents a comment record as received from Jetstream
// Matches social.coves.feed.comment lexicon
type CommentRecordFromJetstream struct {
-
Type string `json:"$type"`
Reply ReplyRefFromJetstream `json:"reply"`
Content string `json:"content"`
Facets []interface{} `json:"facets,omitempty"`
-
Embed map[string]interface{} `json:"embed,omitempty"`
Langs []string `json:"langs,omitempty"`
-
Labels interface{} `json:"labels,omitempty"`
-
CreatedAt string `json:"createdAt"`
}
// ReplyRefFromJetstream represents the threading structure
···
// Returns nil pointers for empty/nil fields (DRY helper to avoid duplication)
func serializeOptionalFields(commentRecord *CommentRecordFromJetstream) (facetsJSON, embedJSON, labelsJSON *string) {
// Serialize facets if present
-
if commentRecord.Facets != nil && len(commentRecord.Facets) > 0 {
if facetsBytes, err := json.Marshal(commentRecord.Facets); err == nil {
facetsStr := string(facetsBytes)
facetsJSON = &facetsStr
···
}
// Serialize embed if present
-
if commentRecord.Embed != nil && len(commentRecord.Embed) > 0 {
if embedBytes, err := json.Marshal(commentRecord.Embed); err == nil {
embedStr := string(embedBytes)
embedJSON = &embedStr
···
time.Now(),
commentID,
)
if err != nil {
return fmt.Errorf("failed to resurrect comment: %w", err)
}
···
comment.Content, comment.ContentFacets, comment.Embed, comment.ContentLabels, pq.Array(comment.Langs),
comment.CreatedAt, time.Now(),
).Scan(&commentID)
if err != nil {
return fmt.Errorf("failed to insert comment: %w", err)
}
···
// CommentRecordFromJetstream represents a comment record as received from Jetstream
// Matches social.coves.feed.comment lexicon
type CommentRecordFromJetstream struct {
+
Labels interface{} `json:"labels,omitempty"`
+
Embed map[string]interface{} `json:"embed,omitempty"`
Reply ReplyRefFromJetstream `json:"reply"`
+
Type string `json:"$type"`
Content string `json:"content"`
+
CreatedAt string `json:"createdAt"`
Facets []interface{} `json:"facets,omitempty"`
Langs []string `json:"langs,omitempty"`
}
// ReplyRefFromJetstream represents the threading structure
···
// Returns nil pointers for empty/nil fields (DRY helper to avoid duplication)
func serializeOptionalFields(commentRecord *CommentRecordFromJetstream) (facetsJSON, embedJSON, labelsJSON *string) {
// Serialize facets if present
+
if len(commentRecord.Facets) > 0 {
if facetsBytes, err := json.Marshal(commentRecord.Facets); err == nil {
facetsStr := string(facetsBytes)
facetsJSON = &facetsStr
···
}
// Serialize embed if present
+
if len(commentRecord.Embed) > 0 {
if embedBytes, err := json.Marshal(commentRecord.Embed); err == nil {
embedStr := string(embedBytes)
embedJSON = &embedStr
+8 -3
internal/atproto/jetstream/community_consumer.go
···
// CommunityEventConsumer consumes community-related events from Jetstream
type CommunityEventConsumer struct {
-
repo communities.Repository // Repository for community operations
-
identityResolver interface{ Resolve(context.Context, string) (*identity.Identity, error) } // For resolving handles from DIDs
httpClient *http.Client // Shared HTTP client with connection pooling
didCache *lru.Cache[string, cachedDIDDoc] // Bounded LRU cache for .well-known verification results
wellKnownLimiter *rate.Limiter // Rate limiter for .well-known fetches
···
// instanceDID: The DID of this Coves instance (for hostedBy verification)
// skipVerification: Skip did:web verification (for dev mode)
// identityResolver: Optional resolver for resolving handles from DIDs (can be nil for tests)
-
func NewCommunityEventConsumer(repo communities.Repository, instanceDID string, skipVerification bool, identityResolver interface{ Resolve(context.Context, string) (*identity.Identity, error) }) *CommunityEventConsumer {
// Create bounded LRU cache for DID document verification results
// Max 1000 entries to prevent unbounded memory growth (PR review feedback)
// Each entry ~100 bytes → max ~100KB memory overhead
···
// CommunityEventConsumer consumes community-related events from Jetstream
type CommunityEventConsumer struct {
+
repo communities.Repository // Repository for community operations
+
identityResolver interface {
+
Resolve(context.Context, string) (*identity.Identity, error)
+
} // For resolving handles from DIDs
httpClient *http.Client // Shared HTTP client with connection pooling
didCache *lru.Cache[string, cachedDIDDoc] // Bounded LRU cache for .well-known verification results
wellKnownLimiter *rate.Limiter // Rate limiter for .well-known fetches
···
// instanceDID: The DID of this Coves instance (for hostedBy verification)
// skipVerification: Skip did:web verification (for dev mode)
// identityResolver: Optional resolver for resolving handles from DIDs (can be nil for tests)
+
func NewCommunityEventConsumer(repo communities.Repository, instanceDID string, skipVerification bool, identityResolver interface {
+
Resolve(context.Context, string) (*identity.Identity, error)
+
},
+
) *CommunityEventConsumer {
// Create bounded LRU cache for DID document verification results
// Max 1000 entries to prevent unbounded memory growth (PR review feedback)
// Each entry ~100 bytes → max ~100KB memory overhead
+3 -3
internal/atproto/jetstream/post_consumer.go
···
// PostEventConsumer consumes post-related events from Jetstream
// Currently handles only CREATE operations for social.coves.community.post
// UPDATE and DELETE handlers will be added when those features are implemented
-
type PostEventConsumer struct{
postRepo posts.Repository
communityRepo communities.Repository
userService users.UserService
···
// PostRecordFromJetstream represents a post record as received from Jetstream
// Matches the structure written to PDS via social.coves.community.post
-
type PostRecordFromJetstream struct{
OriginalAuthor interface{} `json:"originalAuthor,omitempty"`
FederatedFrom interface{} `json:"federatedFrom,omitempty"`
Location interface{} `json:"location,omitempty"`
Title *string `json:"title,omitempty"`
Content *string `json:"content,omitempty"`
Embed map[string]interface{} `json:"embed,omitempty"`
Type string `json:"$type"`
Community string `json:"community"`
Author string `json:"author"`
CreatedAt string `json:"createdAt"`
Facets []interface{} `json:"facets,omitempty"`
-
Labels *posts.SelfLabels `json:"labels,omitempty"`
}
// parsePostRecord converts a raw Jetstream record map to a PostRecordFromJetstream
···
// PostEventConsumer consumes post-related events from Jetstream
// Currently handles only CREATE operations for social.coves.community.post
// UPDATE and DELETE handlers will be added when those features are implemented
+
type PostEventConsumer struct {
postRepo posts.Repository
communityRepo communities.Repository
userService users.UserService
···
// PostRecordFromJetstream represents a post record as received from Jetstream
// Matches the structure written to PDS via social.coves.community.post
+
type PostRecordFromJetstream struct {
OriginalAuthor interface{} `json:"originalAuthor,omitempty"`
FederatedFrom interface{} `json:"federatedFrom,omitempty"`
Location interface{} `json:"location,omitempty"`
Title *string `json:"title,omitempty"`
Content *string `json:"content,omitempty"`
Embed map[string]interface{} `json:"embed,omitempty"`
+
Labels *posts.SelfLabels `json:"labels,omitempty"`
Type string `json:"$type"`
Community string `json:"community"`
Author string `json:"author"`
CreatedAt string `json:"createdAt"`
Facets []interface{} `json:"facets,omitempty"`
}
// parsePostRecord converts a raw Jetstream record map to a PostRecordFromJetstream
+221
internal/atproto/lexicon/social/coves/community/comment/defs.json
···
···
+
{
+
"lexicon": 1,
+
"id": "social.coves.community.comment.defs",
+
"defs": {
+
"commentView": {
+
"type": "object",
+
"description": "Base view for a single comment with voting, stats, and viewer state",
+
"required": ["uri", "cid", "author", "record", "post", "content", "createdAt", "indexedAt", "stats"],
+
"properties": {
+
"uri": {
+
"type": "string",
+
"format": "at-uri",
+
"description": "AT-URI of the comment record"
+
},
+
"cid": {
+
"type": "string",
+
"format": "cid",
+
"description": "CID of the comment record"
+
},
+
"author": {
+
"type": "ref",
+
"ref": "social.coves.community.post.get#authorView",
+
"description": "Comment author information"
+
},
+
"record": {
+
"type": "unknown",
+
"description": "The actual comment record verbatim"
+
},
+
"post": {
+
"type": "ref",
+
"ref": "#postRef",
+
"description": "Reference to the parent post"
+
},
+
"parent": {
+
"type": "ref",
+
"ref": "#commentRef",
+
"description": "Reference to parent comment if this is a nested reply"
+
},
+
"content": {
+
"type": "string",
+
"description": "Comment text content"
+
},
+
"contentFacets": {
+
"type": "array",
+
"description": "Rich text annotations for mentions, links, formatting",
+
"items": {
+
"type": "ref",
+
"ref": "social.coves.richtext.facet"
+
}
+
},
+
"embed": {
+
"type": "union",
+
"description": "Embedded content in the comment (images or quoted post)",
+
"refs": [
+
"social.coves.embed.images#view",
+
"social.coves.embed.post#view"
+
]
+
},
+
"createdAt": {
+
"type": "string",
+
"format": "datetime",
+
"description": "When the comment was created"
+
},
+
"indexedAt": {
+
"type": "string",
+
"format": "datetime",
+
"description": "When this comment was indexed by the AppView"
+
},
+
"stats": {
+
"type": "ref",
+
"ref": "#commentStats",
+
"description": "Comment statistics (votes, replies)"
+
},
+
"viewer": {
+
"type": "ref",
+
"ref": "#commentViewerState",
+
"description": "Viewer-specific state (vote, saved, etc.)"
+
}
+
}
+
},
+
"threadViewComment": {
+
"type": "object",
+
"description": "Wrapper for threaded comment structure, similar to Bluesky's threadViewPost pattern",
+
"required": ["comment"],
+
"properties": {
+
"comment": {
+
"type": "ref",
+
"ref": "#commentView",
+
"description": "The comment itself"
+
},
+
"replies": {
+
"type": "array",
+
"description": "Nested replies to this comment",
+
"items": {
+
"type": "union",
+
"refs": ["#threadViewComment", "#notFoundComment", "#blockedComment"]
+
}
+
},
+
"hasMore": {
+
"type": "boolean",
+
"description": "True if more replies exist but are not included in this response"
+
}
+
}
+
},
+
"commentRef": {
+
"type": "object",
+
"description": "Reference to a comment record",
+
"required": ["uri", "cid"],
+
"properties": {
+
"uri": {
+
"type": "string",
+
"format": "at-uri",
+
"description": "AT-URI of the comment"
+
},
+
"cid": {
+
"type": "string",
+
"format": "cid",
+
"description": "CID of the comment record"
+
}
+
}
+
},
+
"postRef": {
+
"type": "object",
+
"description": "Reference to a post record",
+
"required": ["uri", "cid"],
+
"properties": {
+
"uri": {
+
"type": "string",
+
"format": "at-uri",
+
"description": "AT-URI of the post"
+
},
+
"cid": {
+
"type": "string",
+
"format": "cid",
+
"description": "CID of the post record"
+
}
+
}
+
},
+
"notFoundComment": {
+
"type": "object",
+
"description": "Comment was not found (deleted, never indexed, or invalid URI)",
+
"required": ["uri", "notFound"],
+
"properties": {
+
"uri": {
+
"type": "string",
+
"format": "at-uri",
+
"description": "AT-URI of the missing comment"
+
},
+
"notFound": {
+
"type": "boolean",
+
"const": true,
+
"description": "Always true for not found comments"
+
}
+
}
+
},
+
"blockedComment": {
+
"type": "object",
+
"description": "Comment is blocked due to viewer blocking author or moderation action",
+
"required": ["uri", "blocked"],
+
"properties": {
+
"uri": {
+
"type": "string",
+
"format": "at-uri",
+
"description": "AT-URI of the blocked comment"
+
},
+
"blocked": {
+
"type": "boolean",
+
"const": true,
+
"description": "Always true for blocked comments"
+
},
+
"blockedBy": {
+
"type": "string",
+
"knownValues": ["author", "moderator"],
+
"description": "What caused the block: viewer blocked author, or comment was removed by moderators"
+
}
+
}
+
},
+
"commentStats": {
+
"type": "object",
+
"description": "Statistics for a comment",
+
"required": ["upvotes", "downvotes", "score", "replyCount"],
+
"properties": {
+
"upvotes": {
+
"type": "integer",
+
"minimum": 0,
+
"description": "Number of upvotes"
+
},
+
"downvotes": {
+
"type": "integer",
+
"minimum": 0,
+
"description": "Number of downvotes"
+
},
+
"score": {
+
"type": "integer",
+
"description": "Calculated score (upvotes - downvotes)"
+
},
+
"replyCount": {
+
"type": "integer",
+
"minimum": 0,
+
"description": "Number of direct replies to this comment"
+
}
+
}
+
},
+
"commentViewerState": {
+
"type": "object",
+
"description": "Viewer-specific state for a comment",
+
"properties": {
+
"vote": {
+
"type": "string",
+
"knownValues": ["up", "down"],
+
"description": "Viewer's vote on this comment"
+
},
+
"voteUri": {
+
"type": "string",
+
"format": "at-uri",
+
"description": "AT-URI of the viewer's vote record"
+
}
+
}
+
}
+
}
+
}
+86
internal/atproto/lexicon/social/coves/community/comment/getComments.json
···
···
+
{
+
"lexicon": 1,
+
"id": "social.coves.community.comment.getComments",
+
"defs": {
+
"main": {
+
"type": "query",
+
"description": "Get comments for a post with threading and sorting support. Supports hot/top/new sorting, configurable nesting depth, and pagination.",
+
"parameters": {
+
"type": "params",
+
"required": ["post"],
+
"properties": {
+
"post": {
+
"type": "string",
+
"format": "at-uri",
+
"description": "AT-URI of the post to get comments for"
+
},
+
"sort": {
+
"type": "string",
+
"default": "hot",
+
"knownValues": ["hot", "top", "new"],
+
"description": "Sort order: hot (trending), top (highest score), new (most recent)"
+
},
+
"timeframe": {
+
"type": "string",
+
"knownValues": ["hour", "day", "week", "month", "year", "all"],
+
"description": "Timeframe for 'top' sort. Ignored for other sort types."
+
},
+
"depth": {
+
"type": "integer",
+
"default": 10,
+
"minimum": 0,
+
"maximum": 100,
+
"description": "Maximum reply nesting depth to return. 0 returns only top-level comments."
+
},
+
"limit": {
+
"type": "integer",
+
"default": 50,
+
"minimum": 1,
+
"maximum": 100,
+
"description": "Maximum number of top-level comments to return per page"
+
},
+
"cursor": {
+
"type": "string",
+
"description": "Pagination cursor from previous response"
+
}
+
}
+
},
+
"output": {
+
"encoding": "application/json",
+
"schema": {
+
"type": "object",
+
"required": ["comments", "post"],
+
"properties": {
+
"comments": {
+
"type": "array",
+
"description": "Top-level comments with nested replies up to requested depth",
+
"items": {
+
"type": "ref",
+
"ref": "social.coves.community.comment.defs#threadViewComment"
+
}
+
},
+
"post": {
+
"type": "ref",
+
"ref": "social.coves.community.post.get#postView",
+
"description": "The post these comments belong to"
+
},
+
"cursor": {
+
"type": "string",
+
"description": "Pagination cursor for fetching next page of top-level comments"
+
}
+
}
+
}
+
},
+
"errors": [
+
{
+
"name": "NotFound",
+
"description": "Post not found"
+
},
+
{
+
"name": "InvalidRequest",
+
"description": "Invalid parameters (malformed URI, invalid sort/timeframe combination, etc.)"
+
}
+
]
+
}
+
}
+
}
+32 -39
internal/core/comments/comment.go
···
// Comment represents a comment in the AppView database
// Comments are indexed from the firehose after being written to user repositories
type Comment struct {
-
ID int64 `json:"id" db:"id"`
-
URI string `json:"uri" db:"uri"`
-
CID string `json:"cid" db:"cid"`
-
RKey string `json:"rkey" db:"rkey"`
-
CommenterDID string `json:"commenterDid" db:"commenter_did"`
-
-
// Threading (reply references)
-
RootURI string `json:"rootUri" db:"root_uri"`
-
RootCID string `json:"rootCid" db:"root_cid"`
-
ParentURI string `json:"parentUri" db:"parent_uri"`
-
ParentCID string `json:"parentCid" db:"parent_cid"`
-
-
// Content
-
Content string `json:"content" db:"content"`
-
ContentFacets *string `json:"contentFacets,omitempty" db:"content_facets"`
-
Embed *string `json:"embed,omitempty" db:"embed"`
-
ContentLabels *string `json:"labels,omitempty" db:"content_labels"`
-
Langs []string `json:"langs,omitempty" db:"langs"`
-
-
// Timestamps
-
CreatedAt time.Time `json:"createdAt" db:"created_at"`
-
IndexedAt time.Time `json:"indexedAt" db:"indexed_at"`
-
DeletedAt *time.Time `json:"deletedAt,omitempty" db:"deleted_at"`
-
-
// Stats (denormalized for performance)
-
UpvoteCount int `json:"upvoteCount" db:"upvote_count"`
-
DownvoteCount int `json:"downvoteCount" db:"downvote_count"`
-
Score int `json:"score" db:"score"`
-
ReplyCount int `json:"replyCount" db:"reply_count"`
}
// CommentRecord represents the atProto record structure indexed from Jetstream
// This is the data structure that gets stored in the user's repository
// Matches social.coves.feed.comment lexicon
type CommentRecord struct {
-
Type string `json:"$type"`
-
Reply ReplyRef `json:"reply"`
-
Content string `json:"content"`
-
Facets []interface{} `json:"facets,omitempty"`
-
Embed map[string]interface{} `json:"embed,omitempty"`
-
Langs []string `json:"langs,omitempty"`
-
Labels *SelfLabels `json:"labels,omitempty"`
-
CreatedAt string `json:"createdAt"`
}
// ReplyRef represents the threading structure from the comment lexicon
···
// SelfLabel represents a single label value per com.atproto.label.defs#selfLabel
// Neg is optional and negates the label when true
type SelfLabel struct {
-
Val string `json:"val"` // Required: label value (max 128 chars)
-
Neg *bool `json:"neg,omitempty"` // Optional: negates the label if true
}
···
// Comment represents a comment in the AppView database
// Comments are indexed from the firehose after being written to user repositories
type Comment struct {
+
IndexedAt time.Time `json:"indexedAt" db:"indexed_at"`
+
CreatedAt time.Time `json:"createdAt" db:"created_at"`
+
ContentFacets *string `json:"contentFacets,omitempty" db:"content_facets"`
+
DeletedAt *time.Time `json:"deletedAt,omitempty" db:"deleted_at"`
+
ContentLabels *string `json:"labels,omitempty" db:"content_labels"`
+
Embed *string `json:"embed,omitempty" db:"embed"`
+
CommenterHandle string `json:"commenterHandle,omitempty" db:"-"`
+
CommenterDID string `json:"commenterDid" db:"commenter_did"`
+
ParentURI string `json:"parentUri" db:"parent_uri"`
+
ParentCID string `json:"parentCid" db:"parent_cid"`
+
Content string `json:"content" db:"content"`
+
RootURI string `json:"rootUri" db:"root_uri"`
+
URI string `json:"uri" db:"uri"`
+
RootCID string `json:"rootCid" db:"root_cid"`
+
CID string `json:"cid" db:"cid"`
+
RKey string `json:"rkey" db:"rkey"`
+
Langs []string `json:"langs,omitempty" db:"langs"`
+
ID int64 `json:"id" db:"id"`
+
UpvoteCount int `json:"upvoteCount" db:"upvote_count"`
+
DownvoteCount int `json:"downvoteCount" db:"downvote_count"`
+
Score int `json:"score" db:"score"`
+
ReplyCount int `json:"replyCount" db:"reply_count"`
}
// CommentRecord represents the atProto record structure indexed from Jetstream
// This is the data structure that gets stored in the user's repository
// Matches social.coves.feed.comment lexicon
type CommentRecord struct {
+
Embed map[string]interface{} `json:"embed,omitempty"`
+
Labels *SelfLabels `json:"labels,omitempty"`
+
Reply ReplyRef `json:"reply"`
+
Type string `json:"$type"`
+
Content string `json:"content"`
+
CreatedAt string `json:"createdAt"`
+
Facets []interface{} `json:"facets,omitempty"`
+
Langs []string `json:"langs,omitempty"`
}
// ReplyRef represents the threading structure from the comment lexicon
···
// SelfLabel represents a single label value per com.atproto.label.defs#selfLabel
// Neg is optional and negates the label when true
type SelfLabel struct {
+
Neg *bool `json:"neg,omitempty"`
+
Val string `json:"val"`
}
+460
internal/core/comments/comment_service.go
···
···
+
package comments
+
+
import (
+
"Coves/internal/core/communities"
+
"Coves/internal/core/posts"
+
"Coves/internal/core/users"
+
"context"
+
"errors"
+
"fmt"
+
"log"
+
"strings"
+
"time"
+
)
+
+
// Service defines the business logic interface for comment operations
+
// Orchestrates repository calls and builds view models for API responses
+
type Service interface {
+
// GetComments retrieves and builds a threaded comment tree for a post
+
// Supports hot, top, and new sorting with configurable depth and pagination
+
GetComments(ctx context.Context, req *GetCommentsRequest) (*GetCommentsResponse, error)
+
}
+
+
// GetCommentsRequest defines the parameters for fetching comments
+
type GetCommentsRequest struct {
+
Cursor *string
+
ViewerDID *string
+
PostURI string
+
Sort string
+
Timeframe string
+
Depth int
+
Limit int
+
}
+
+
// commentService implements the Service interface
+
// Coordinates between repository layer and view model construction
+
type commentService struct {
+
commentRepo Repository // Comment data access
+
userRepo users.UserRepository // User lookup for author hydration
+
postRepo posts.Repository // Post lookup for building post views
+
communityRepo communities.Repository // Community lookup for community hydration
+
}
+
+
// NewCommentService creates a new comment service instance
+
// All repositories are required for proper view construction per lexicon requirements
+
func NewCommentService(
+
commentRepo Repository,
+
userRepo users.UserRepository,
+
postRepo posts.Repository,
+
communityRepo communities.Repository,
+
) Service {
+
return &commentService{
+
commentRepo: commentRepo,
+
userRepo: userRepo,
+
postRepo: postRepo,
+
communityRepo: communityRepo,
+
}
+
}
+
+
// GetComments retrieves comments for a post with threading and pagination
+
// Algorithm:
+
// 1. Validate input parameters and apply defaults
+
// 2. Fetch top-level comments with specified sorting
+
// 3. Recursively load nested replies up to depth limit
+
// 4. Build view models with author info and stats
+
// 5. Return response with pagination cursor
+
func (s *commentService) GetComments(ctx context.Context, req *GetCommentsRequest) (*GetCommentsResponse, error) {
+
// 1. Validate inputs and apply defaults/bounds FIRST (before expensive operations)
+
if err := validateGetCommentsRequest(req); err != nil {
+
return nil, fmt.Errorf("invalid request: %w", err)
+
}
+
+
// Add timeout to prevent runaway queries with deep nesting
+
ctx, cancel := context.WithTimeout(ctx, 10*time.Second)
+
defer cancel()
+
+
// 2. Fetch post for context
+
post, err := s.postRepo.GetByURI(ctx, req.PostURI)
+
if err != nil {
+
// Translate post not-found errors to comment-layer errors for proper HTTP status
+
if posts.IsNotFound(err) {
+
return nil, ErrRootNotFound
+
}
+
return nil, fmt.Errorf("failed to fetch post: %w", err)
+
}
+
+
// Build post view for response (hydrates author handle and community name)
+
postView := s.buildPostView(ctx, post, req.ViewerDID)
+
+
// 3. Fetch top-level comments with pagination
+
// Uses repository's hot rank sorting and cursor-based pagination
+
topComments, nextCursor, err := s.commentRepo.ListByParentWithHotRank(
+
ctx,
+
req.PostURI,
+
req.Sort,
+
req.Timeframe,
+
req.Limit,
+
req.Cursor,
+
)
+
if err != nil {
+
return nil, fmt.Errorf("failed to fetch top-level comments: %w", err)
+
}
+
+
// 4. Build threaded view with nested replies up to depth limit
+
// This iteratively loads child comments and builds the tree structure
+
threadViews := s.buildThreadViews(ctx, topComments, req.Depth, req.Sort, req.ViewerDID)
+
+
// 5. Return response with comments, post reference, and cursor
+
return &GetCommentsResponse{
+
Comments: threadViews,
+
Post: postView,
+
Cursor: nextCursor,
+
}, nil
+
}
+
+
// buildThreadViews constructs threaded comment views with nested replies using batch loading
+
// Uses batch queries to prevent N+1 query problem when loading nested replies
+
// Loads replies level-by-level up to the specified depth limit
+
func (s *commentService) buildThreadViews(
+
ctx context.Context,
+
comments []*Comment,
+
remainingDepth int,
+
sort string,
+
viewerDID *string,
+
) []*ThreadViewComment {
+
// Always return an empty slice, never nil (important for JSON serialization)
+
result := make([]*ThreadViewComment, 0, len(comments))
+
+
if len(comments) == 0 {
+
return result
+
}
+
+
// Build thread views for current level
+
threadViews := make([]*ThreadViewComment, 0, len(comments))
+
commentsByURI := make(map[string]*ThreadViewComment)
+
parentsWithReplies := make([]string, 0)
+
+
for _, comment := range comments {
+
// Skip deleted comments (soft-deleted records)
+
if comment.DeletedAt != nil {
+
continue
+
}
+
+
// Build the comment view with author info and stats
+
commentView := s.buildCommentView(comment, viewerDID)
+
+
threadView := &ThreadViewComment{
+
Comment: commentView,
+
Replies: nil,
+
HasMore: comment.ReplyCount > 0 && remainingDepth == 0,
+
}
+
+
threadViews = append(threadViews, threadView)
+
commentsByURI[comment.URI] = threadView
+
+
// Collect parent URIs that have replies and depth remaining
+
if remainingDepth > 0 && comment.ReplyCount > 0 {
+
parentsWithReplies = append(parentsWithReplies, comment.URI)
+
}
+
}
+
+
// Batch load all replies for this level in a single query
+
if len(parentsWithReplies) > 0 {
+
const repliesPerParent = 5 // Load top 5 replies per comment
+
+
repliesByParent, err := s.commentRepo.ListByParentsBatch(
+
ctx,
+
parentsWithReplies,
+
sort,
+
repliesPerParent,
+
)
+
+
// Process replies if batch query succeeded
+
if err == nil {
+
// Group child comments by parent for recursive processing
+
for parentURI, replies := range repliesByParent {
+
threadView := commentsByURI[parentURI]
+
if threadView != nil && len(replies) > 0 {
+
// Recursively build views for child comments
+
threadView.Replies = s.buildThreadViews(
+
ctx,
+
replies,
+
remainingDepth-1,
+
sort,
+
viewerDID,
+
)
+
+
// Update HasMore based on actual reply count vs loaded count
+
// Get the original comment to check reply count
+
for _, comment := range comments {
+
if comment.URI == parentURI {
+
threadView.HasMore = comment.ReplyCount > len(replies)
+
break
+
}
+
}
+
}
+
}
+
}
+
}
+
+
return threadViews
+
}
+
+
// buildCommentView converts a Comment entity to a CommentView with full metadata
+
// Constructs author view, stats, and references to parent post/comment
+
func (s *commentService) buildCommentView(comment *Comment, viewerDID *string) *CommentView {
+
// Build author view from comment data
+
// CommenterHandle is hydrated by ListByParentWithHotRank via JOIN
+
authorView := &posts.AuthorView{
+
DID: comment.CommenterDID,
+
Handle: comment.CommenterHandle,
+
// TODO: Add DisplayName, Avatar, Reputation when user service is integrated (Phase 2B)
+
}
+
+
// Build aggregated statistics
+
stats := &CommentStats{
+
Upvotes: comment.UpvoteCount,
+
Downvotes: comment.DownvoteCount,
+
Score: comment.Score,
+
ReplyCount: comment.ReplyCount,
+
}
+
+
// Build reference to parent post (always present)
+
postRef := &CommentRef{
+
URI: comment.RootURI,
+
CID: comment.RootCID,
+
}
+
+
// Build reference to parent comment (only if nested)
+
// Top-level comments have ParentURI == RootURI (both point to the post)
+
var parentRef *CommentRef
+
if comment.ParentURI != comment.RootURI {
+
parentRef = &CommentRef{
+
URI: comment.ParentURI,
+
CID: comment.ParentCID,
+
}
+
}
+
+
// Build viewer state (stubbed for now - Phase 2B)
+
// Future: Fetch viewer's vote state from GetVoteStateForComments
+
var viewer *CommentViewerState
+
if viewerDID != nil {
+
// TODO: Query voter state
+
// voteState, err := s.commentRepo.GetVoteStateForComments(ctx, *viewerDID, []string{comment.URI})
+
// For now, return empty viewer state to indicate authenticated request
+
viewer = &CommentViewerState{
+
Vote: nil,
+
VoteURI: nil,
+
}
+
}
+
+
// Build minimal comment record to satisfy lexicon contract
+
// The record field is required by social.coves.community.comment.defs#commentView
+
commentRecord := s.buildCommentRecord(comment)
+
+
return &CommentView{
+
URI: comment.URI,
+
CID: comment.CID,
+
Author: authorView,
+
Record: commentRecord,
+
Post: postRef,
+
Parent: parentRef,
+
Content: comment.Content,
+
CreatedAt: comment.CreatedAt.Format(time.RFC3339),
+
IndexedAt: comment.IndexedAt.Format(time.RFC3339),
+
Stats: stats,
+
Viewer: viewer,
+
}
+
}
+
+
// buildCommentRecord constructs a minimal CommentRecord from a Comment entity
+
// Satisfies the lexicon requirement that commentView.record is a required field
+
// TODO (Phase 2C): Unmarshal JSON fields (embed, facets, labels) for complete record
+
func (s *commentService) buildCommentRecord(comment *Comment) *CommentRecord {
+
record := &CommentRecord{
+
Type: "social.coves.feed.comment",
+
Reply: ReplyRef{
+
Root: StrongRef{
+
URI: comment.RootURI,
+
CID: comment.RootCID,
+
},
+
Parent: StrongRef{
+
URI: comment.ParentURI,
+
CID: comment.ParentCID,
+
},
+
},
+
Content: comment.Content,
+
CreatedAt: comment.CreatedAt.Format(time.RFC3339),
+
Langs: comment.Langs,
+
}
+
+
// TODO (Phase 2C): Parse JSON fields from database for complete record:
+
// - Unmarshal comment.Embed (*string) → record.Embed (map[string]interface{})
+
// - Unmarshal comment.ContentFacets (*string) → record.Facets ([]interface{})
+
// - Unmarshal comment.ContentLabels (*string) → record.Labels (*SelfLabels)
+
// These fields are stored as JSONB in the database and need proper deserialization
+
+
return record
+
}
+
+
// buildPostView converts a Post entity to a PostView for the comment response
+
// Hydrates author handle and community name per lexicon requirements
+
func (s *commentService) buildPostView(ctx context.Context, post *posts.Post, viewerDID *string) *posts.PostView {
+
// Build author view - fetch user to get handle (required by lexicon)
+
// The lexicon marks authorView.handle with format:"handle", so DIDs are invalid
+
authorHandle := post.AuthorDID // Fallback if user not found
+
if user, err := s.userRepo.GetByDID(ctx, post.AuthorDID); err == nil {
+
authorHandle = user.Handle
+
} else {
+
// Log warning but don't fail the entire request
+
log.Printf("Warning: Failed to fetch user for post author %s: %v", post.AuthorDID, err)
+
}
+
+
authorView := &posts.AuthorView{
+
DID: post.AuthorDID,
+
Handle: authorHandle,
+
// TODO (Phase 2C): Add DisplayName, Avatar, Reputation from user profile
+
}
+
+
// Build community reference - fetch community to get name (required by lexicon)
+
// The lexicon marks communityRef.name as required, so DIDs are insufficient
+
communityName := post.CommunityDID // Fallback if community not found
+
if community, err := s.communityRepo.GetByDID(ctx, post.CommunityDID); err == nil {
+
communityName = community.Handle // Use handle as display name
+
// TODO (Phase 2C): Use community.DisplayName or community.Name if available
+
} else {
+
// Log warning but don't fail the entire request
+
log.Printf("Warning: Failed to fetch community for post %s: %v", post.CommunityDID, err)
+
}
+
+
communityRef := &posts.CommunityRef{
+
DID: post.CommunityDID,
+
Name: communityName,
+
// TODO (Phase 2C): Add Avatar from community profile
+
}
+
+
// Build aggregated statistics
+
stats := &posts.PostStats{
+
Upvotes: post.UpvoteCount,
+
Downvotes: post.DownvoteCount,
+
Score: post.Score,
+
CommentCount: post.CommentCount,
+
}
+
+
// Build viewer state if authenticated
+
var viewer *posts.ViewerState
+
if viewerDID != nil {
+
// TODO (Phase 2B): Query viewer's vote state
+
viewer = &posts.ViewerState{
+
Vote: nil,
+
VoteURI: nil,
+
Saved: false,
+
}
+
}
+
+
// Build minimal post record to satisfy lexicon contract
+
// The record field is required by social.coves.community.post.get#postView
+
postRecord := s.buildPostRecord(post)
+
+
return &posts.PostView{
+
URI: post.URI,
+
CID: post.CID,
+
RKey: post.RKey,
+
Author: authorView,
+
Record: postRecord,
+
Community: communityRef,
+
Title: post.Title,
+
Text: post.Content,
+
CreatedAt: post.CreatedAt,
+
IndexedAt: post.IndexedAt,
+
EditedAt: post.EditedAt,
+
Stats: stats,
+
Viewer: viewer,
+
}
+
}
+
+
// buildPostRecord constructs a minimal PostRecord from a Post entity
+
// Satisfies the lexicon requirement that postView.record is a required field
+
// TODO (Phase 2C): Unmarshal JSON fields (embed, facets, labels) for complete record
+
func (s *commentService) buildPostRecord(post *posts.Post) *posts.PostRecord {
+
record := &posts.PostRecord{
+
Type: "social.coves.community.post",
+
Community: post.CommunityDID,
+
Author: post.AuthorDID,
+
CreatedAt: post.CreatedAt.Format(time.RFC3339),
+
Title: post.Title,
+
Content: post.Content,
+
}
+
+
// TODO (Phase 2C): Parse JSON fields from database for complete record:
+
// - Unmarshal post.Embed (*string) → record.Embed (map[string]interface{})
+
// - Unmarshal post.ContentFacets (*string) → record.Facets ([]interface{})
+
// - Unmarshal post.ContentLabels (*string) → record.Labels (*SelfLabels)
+
// These fields are stored as JSONB in the database and need proper deserialization
+
+
return record
+
}
+
+
// validateGetCommentsRequest validates and normalizes request parameters
+
// Applies default values and enforces bounds per API specification
+
func validateGetCommentsRequest(req *GetCommentsRequest) error {
+
if req == nil {
+
return errors.New("request cannot be nil")
+
}
+
+
// Validate PostURI is present and well-formed
+
if req.PostURI == "" {
+
return errors.New("post URI is required")
+
}
+
+
if !strings.HasPrefix(req.PostURI, "at://") {
+
return errors.New("invalid AT-URI format: must start with 'at://'")
+
}
+
+
// Apply depth defaults and bounds (0-100, default 10)
+
if req.Depth < 0 {
+
req.Depth = 10
+
}
+
if req.Depth > 100 {
+
req.Depth = 100
+
}
+
+
// Apply limit defaults and bounds (1-100, default 50)
+
if req.Limit <= 0 {
+
req.Limit = 50
+
}
+
if req.Limit > 100 {
+
req.Limit = 100
+
}
+
+
// Apply sort default and validate
+
if req.Sort == "" {
+
req.Sort = "hot"
+
}
+
+
validSorts := map[string]bool{
+
"hot": true,
+
"top": true,
+
"new": true,
+
}
+
if !validSorts[req.Sort] {
+
return fmt.Errorf("invalid sort: must be one of [hot, top, new], got '%s'", req.Sort)
+
}
+
+
// Validate timeframe (only applies to "top" sort)
+
if req.Timeframe != "" {
+
validTimeframes := map[string]bool{
+
"hour": true,
+
"day": true,
+
"week": true,
+
"month": true,
+
"year": true,
+
"all": true,
+
}
+
if !validTimeframes[req.Timeframe] {
+
return fmt.Errorf("invalid timeframe: must be one of [hour, day, week, month, year, all], got '%s'", req.Timeframe)
+
}
+
}
+
+
return nil
+
}
+7
internal/core/comments/errors.go
···
func IsConflict(err error) bool {
return errors.Is(err, ErrCommentAlreadyExists)
}
···
func IsConflict(err error) bool {
return errors.Is(err, ErrCommentAlreadyExists)
}
+
+
// IsValidationError checks if an error is a validation error
+
func IsValidationError(err error) bool {
+
return errors.Is(err, ErrInvalidReply) ||
+
errors.Is(err, ErrContentTooLong) ||
+
errors.Is(err, ErrContentEmpty)
+
}
+33
internal/core/comments/interfaces.go
···
// ListByCommenter retrieves all comments by a specific user
// Future: Used for user comment history
ListByCommenter(ctx context.Context, commenterDID string, limit, offset int) ([]*Comment, error)
}
···
// ListByCommenter retrieves all comments by a specific user
// Future: Used for user comment history
ListByCommenter(ctx context.Context, commenterDID string, limit, offset int) ([]*Comment, error)
+
+
// ListByParentWithHotRank retrieves direct replies to a post or comment with sorting and pagination
+
// Supports hot, top, and new sorting with cursor-based pagination
+
// Returns comments with author info hydrated and next page cursor
+
ListByParentWithHotRank(
+
ctx context.Context,
+
parentURI string,
+
sort string, // "hot", "top", "new"
+
timeframe string, // "hour", "day", "week", "month", "year", "all" (for "top" only)
+
limit int,
+
cursor *string,
+
) ([]*Comment, *string, error)
+
+
// GetByURIsBatch retrieves multiple comments by their AT-URIs in a single query
+
// Returns map[uri]*Comment for efficient lookups
+
// Used for hydrating comment threads without N+1 queries
+
GetByURIsBatch(ctx context.Context, uris []string) (map[string]*Comment, error)
+
+
// GetVoteStateForComments retrieves the viewer's votes on a batch of comments
+
// Returns map[commentURI]*Vote for efficient lookups
+
// Future: Used when votes table is implemented
+
GetVoteStateForComments(ctx context.Context, viewerDID string, commentURIs []string) (map[string]interface{}, error)
+
+
// ListByParentsBatch retrieves direct replies to multiple parents in a single query
+
// Returns map[parentURI][]*Comment grouped by parent
+
// Used to prevent N+1 queries when loading nested replies
+
// Limits results per parent to avoid memory exhaustion
+
ListByParentsBatch(
+
ctx context.Context,
+
parentURIs []string,
+
sort string,
+
limitPerParent int,
+
) (map[string][]*Comment, error)
}
+65
internal/core/comments/view_models.go
···
···
+
package comments
+
+
import (
+
"Coves/internal/core/posts"
+
)
+
+
// CommentView represents the full view of a comment with all metadata
+
// Matches social.coves.feed.getComments#commentView lexicon
+
// Used in thread views and get endpoints
+
type CommentView struct {
+
Embed interface{} `json:"embed,omitempty"`
+
Record interface{} `json:"record"`
+
Viewer *CommentViewerState `json:"viewer,omitempty"`
+
Author *posts.AuthorView `json:"author"`
+
Post *CommentRef `json:"post"`
+
Parent *CommentRef `json:"parent,omitempty"`
+
Stats *CommentStats `json:"stats"`
+
Content string `json:"content"`
+
CreatedAt string `json:"createdAt"`
+
IndexedAt string `json:"indexedAt"`
+
URI string `json:"uri"`
+
CID string `json:"cid"`
+
ContentFacets []interface{} `json:"contentFacets,omitempty"`
+
}
+
+
// ThreadViewComment represents a comment with its nested replies
+
// Matches social.coves.feed.getComments#threadViewComment lexicon
+
// Supports recursive threading for comment trees
+
type ThreadViewComment struct {
+
Comment *CommentView `json:"comment"`
+
Replies []*ThreadViewComment `json:"replies,omitempty"` // Recursive nested replies
+
HasMore bool `json:"hasMore,omitempty"` // Indicates more replies exist
+
}
+
+
// CommentRef is a minimal reference to a post or comment (URI + CID)
+
// Used for threading references (post and parent comment)
+
type CommentRef struct {
+
URI string `json:"uri"`
+
CID string `json:"cid"`
+
}
+
+
// CommentStats represents aggregated statistics for a comment
+
// Includes voting metrics and reply counts
+
type CommentStats struct {
+
Upvotes int `json:"upvotes"`
+
Downvotes int `json:"downvotes"`
+
Score int `json:"score"`
+
ReplyCount int `json:"replyCount"`
+
}
+
+
// CommentViewerState represents the viewer's relationship with the comment
+
// Includes voting state and vote record reference
+
type CommentViewerState struct {
+
Vote *string `json:"vote,omitempty"` // "up" or "down"
+
VoteURI *string `json:"voteUri,omitempty"` // URI of the vote record
+
}
+
+
// GetCommentsResponse represents the response for fetching comments on a post
+
// Matches social.coves.feed.getComments lexicon output
+
// Includes the full comment thread tree and original post reference
+
type GetCommentsResponse struct {
+
Post interface{} `json:"post"`
+
Cursor *string `json:"cursor,omitempty"`
+
Comments []*ThreadViewComment `json:"comments"`
+
}
+5 -5
internal/core/posts/post.go
···
// SelfLabel represents a single label value per com.atproto.label.defs#selfLabel
// Neg is optional and negates the label when true
type SelfLabel struct {
-
Val string `json:"val"` // Required: label value (max 128 chars)
-
Neg *bool `json:"neg,omitempty"` // Optional: negates the label if true
}
// Post represents a post in the AppView database
···
Title *string `json:"title,omitempty"`
Content *string `json:"content,omitempty"`
Embed map[string]interface{} `json:"embed,omitempty"`
Community string `json:"community"`
AuthorDID string `json:"authorDid"`
Facets []interface{} `json:"facets,omitempty"`
-
Labels *SelfLabels `json:"labels,omitempty"`
}
// CreatePostResponse represents the response from creating a post
// Matches social.coves.community.post.create lexicon output schema
-
type CreatePostResponse struct{
URI string `json:"uri"` // AT-URI of created post
CID string `json:"cid"` // CID of created post
}
···
Title *string `json:"title,omitempty"`
Content *string `json:"content,omitempty"`
Embed map[string]interface{} `json:"embed,omitempty"`
Type string `json:"$type"`
Community string `json:"community"`
Author string `json:"author"`
CreatedAt string `json:"createdAt"`
Facets []interface{} `json:"facets,omitempty"`
-
Labels *SelfLabels `json:"labels,omitempty"`
}
// PostView represents the full view of a post with all metadata
···
// SelfLabel represents a single label value per com.atproto.label.defs#selfLabel
// Neg is optional and negates the label when true
type SelfLabel struct {
+
Neg *bool `json:"neg,omitempty"`
+
Val string `json:"val"`
}
// Post represents a post in the AppView database
···
Title *string `json:"title,omitempty"`
Content *string `json:"content,omitempty"`
Embed map[string]interface{} `json:"embed,omitempty"`
+
Labels *SelfLabels `json:"labels,omitempty"`
Community string `json:"community"`
AuthorDID string `json:"authorDid"`
Facets []interface{} `json:"facets,omitempty"`
}
// CreatePostResponse represents the response from creating a post
// Matches social.coves.community.post.create lexicon output schema
+
type CreatePostResponse struct {
URI string `json:"uri"` // AT-URI of created post
CID string `json:"cid"` // CID of created post
}
···
Title *string `json:"title,omitempty"`
Content *string `json:"content,omitempty"`
Embed map[string]interface{} `json:"embed,omitempty"`
+
Labels *SelfLabels `json:"labels,omitempty"`
Type string `json:"$type"`
Community string `json:"community"`
Author string `json:"author"`
CreatedAt string `json:"createdAt"`
Facets []interface{} `json:"facets,omitempty"`
}
// PostView represents the full view of a post with all metadata
+2 -2
internal/core/posts/service.go
···
// IMPORTANT: repo is set to community DID, not author DID
// This writes the post to the community's repository
payload := map[string]interface{}{
-
"repo": community.DID, // Community's repository
"collection": "social.coves.community.post", // Collection type
-
"record": record, // The post record
// "rkey" omitted - PDS will auto-generate TID
}
···
// IMPORTANT: repo is set to community DID, not author DID
// This writes the post to the community's repository
payload := map[string]interface{}{
+
"repo": community.DID, // Community's repository
"collection": "social.coves.community.post", // Collection type
+
"record": record, // The post record
// "rkey" omitted - PDS will auto-generate TID
}
+587
internal/db/postgres/comment_repo.go
···
"Coves/internal/core/comments"
"context"
"database/sql"
"fmt"
"log"
"strings"
···
return result, nil
}
···
"Coves/internal/core/comments"
"context"
"database/sql"
+
"encoding/base64"
"fmt"
"log"
"strings"
···
return result, nil
}
+
+
// ListByParentWithHotRank retrieves direct replies to a post or comment with sorting and pagination
+
// Supports three sort modes: hot (Lemmy algorithm), top (by score + timeframe), and new (by created_at)
+
// Uses cursor-based pagination with composite keys for consistent ordering
+
// Hydrates author info (handle, display_name, avatar) via JOIN with users table
+
func (r *postgresCommentRepo) ListByParentWithHotRank(
+
ctx context.Context,
+
parentURI string,
+
sort string,
+
timeframe string,
+
limit int,
+
cursor *string,
+
) ([]*comments.Comment, *string, error) {
+
// Build ORDER BY clause and time filter based on sort type
+
orderBy, timeFilter := r.buildCommentSortClause(sort, timeframe)
+
+
// Parse cursor for pagination
+
cursorFilter, cursorValues, err := r.parseCommentCursor(cursor, sort)
+
if err != nil {
+
return nil, nil, fmt.Errorf("invalid cursor: %w", err)
+
}
+
+
// Build SELECT clause - compute hot_rank for "hot" sort
+
// Hot rank formula (Lemmy algorithm):
+
// log(greatest(2, score + 2)) / power(((EXTRACT(EPOCH FROM (NOW() - created_at)) / 3600) + 2), 1.8)
+
//
+
// This formula:
+
// - Gives logarithmic weight to score (prevents high-score dominance)
+
// - Decays over time with power 1.8 (faster than linear, slower than quadratic)
+
// - Uses hours as time unit (3600 seconds)
+
// - Adds constants to prevent division by zero and ensure positive values
+
var selectClause string
+
if sort == "hot" {
+
selectClause = `
+
SELECT
+
c.id, c.uri, c.cid, c.rkey, c.commenter_did,
+
c.root_uri, c.root_cid, c.parent_uri, c.parent_cid,
+
c.content, c.content_facets, c.embed, c.content_labels, c.langs,
+
c.created_at, c.indexed_at, c.deleted_at,
+
c.upvote_count, c.downvote_count, c.score, c.reply_count,
+
log(greatest(2, c.score + 2)) / power(((EXTRACT(EPOCH FROM (NOW() - c.created_at)) / 3600) + 2), 1.8) as hot_rank,
+
COALESCE(u.handle, c.commenter_did) as author_handle
+
FROM comments c`
+
} else {
+
selectClause = `
+
SELECT
+
c.id, c.uri, c.cid, c.rkey, c.commenter_did,
+
c.root_uri, c.root_cid, c.parent_uri, c.parent_cid,
+
c.content, c.content_facets, c.embed, c.content_labels, c.langs,
+
c.created_at, c.indexed_at, c.deleted_at,
+
c.upvote_count, c.downvote_count, c.score, c.reply_count,
+
NULL::numeric as hot_rank,
+
COALESCE(u.handle, c.commenter_did) as author_handle
+
FROM comments c`
+
}
+
+
// Build complete query with JOINs and filters
+
// LEFT JOIN prevents data loss when user record hasn't been indexed yet (out-of-order Jetstream events)
+
query := fmt.Sprintf(`
+
%s
+
LEFT JOIN users u ON c.commenter_did = u.did
+
WHERE c.parent_uri = $1 AND c.deleted_at IS NULL
+
%s
+
%s
+
ORDER BY %s
+
LIMIT $2
+
`, selectClause, timeFilter, cursorFilter, orderBy)
+
+
// Prepare query arguments
+
args := []interface{}{parentURI, limit + 1} // +1 to detect next page
+
args = append(args, cursorValues...)
+
+
// Execute query
+
rows, err := r.db.QueryContext(ctx, query, args...)
+
if err != nil {
+
return nil, nil, fmt.Errorf("failed to query comments with hot rank: %w", err)
+
}
+
defer func() {
+
if err := rows.Close(); err != nil {
+
log.Printf("Failed to close rows: %v", err)
+
}
+
}()
+
+
// Scan results
+
var result []*comments.Comment
+
var hotRanks []float64
+
for rows.Next() {
+
var comment comments.Comment
+
var langs pq.StringArray
+
var hotRank sql.NullFloat64
+
var authorHandle string
+
+
err := rows.Scan(
+
&comment.ID, &comment.URI, &comment.CID, &comment.RKey, &comment.CommenterDID,
+
&comment.RootURI, &comment.RootCID, &comment.ParentURI, &comment.ParentCID,
+
&comment.Content, &comment.ContentFacets, &comment.Embed, &comment.ContentLabels, &langs,
+
&comment.CreatedAt, &comment.IndexedAt, &comment.DeletedAt,
+
&comment.UpvoteCount, &comment.DownvoteCount, &comment.Score, &comment.ReplyCount,
+
&hotRank, &authorHandle,
+
)
+
if err != nil {
+
return nil, nil, fmt.Errorf("failed to scan comment: %w", err)
+
}
+
+
comment.Langs = langs
+
comment.CommenterHandle = authorHandle
+
+
// Store hot_rank for cursor building
+
hotRankValue := 0.0
+
if hotRank.Valid {
+
hotRankValue = hotRank.Float64
+
}
+
hotRanks = append(hotRanks, hotRankValue)
+
+
result = append(result, &comment)
+
}
+
+
if err = rows.Err(); err != nil {
+
return nil, nil, fmt.Errorf("error iterating comments: %w", err)
+
}
+
+
// Handle pagination cursor
+
var nextCursor *string
+
if len(result) > limit && limit > 0 {
+
result = result[:limit]
+
hotRanks = hotRanks[:limit]
+
lastComment := result[len(result)-1]
+
lastHotRank := hotRanks[len(hotRanks)-1]
+
cursorStr := r.buildCommentCursor(lastComment, sort, lastHotRank)
+
nextCursor = &cursorStr
+
}
+
+
return result, nextCursor, nil
+
}
+
+
// buildCommentSortClause returns the ORDER BY SQL and optional time filter
+
func (r *postgresCommentRepo) buildCommentSortClause(sort, timeframe string) (string, string) {
+
var orderBy string
+
switch sort {
+
case "hot":
+
// Hot rank DESC, then score DESC as tiebreaker, then created_at DESC, then uri DESC
+
orderBy = `hot_rank DESC, c.score DESC, c.created_at DESC, c.uri DESC`
+
case "top":
+
// Score DESC, then created_at DESC, then uri DESC
+
orderBy = `c.score DESC, c.created_at DESC, c.uri DESC`
+
case "new":
+
// Created at DESC, then uri DESC
+
orderBy = `c.created_at DESC, c.uri DESC`
+
default:
+
// Default to hot
+
orderBy = `hot_rank DESC, c.score DESC, c.created_at DESC, c.uri DESC`
+
}
+
+
// Add time filter for "top" sort
+
var timeFilter string
+
if sort == "top" {
+
timeFilter = r.buildCommentTimeFilter(timeframe)
+
}
+
+
return orderBy, timeFilter
+
}
+
+
// buildCommentTimeFilter returns SQL filter for timeframe
+
func (r *postgresCommentRepo) buildCommentTimeFilter(timeframe string) string {
+
if timeframe == "" || timeframe == "all" {
+
return ""
+
}
+
+
var interval string
+
switch timeframe {
+
case "hour":
+
interval = "1 hour"
+
case "day":
+
interval = "1 day"
+
case "week":
+
interval = "7 days"
+
case "month":
+
interval = "30 days"
+
case "year":
+
interval = "1 year"
+
default:
+
return ""
+
}
+
+
return fmt.Sprintf("AND c.created_at >= NOW() - INTERVAL '%s'", interval)
+
}
+
+
// parseCommentCursor decodes pagination cursor for comments
+
func (r *postgresCommentRepo) parseCommentCursor(cursor *string, sort string) (string, []interface{}, error) {
+
if cursor == nil || *cursor == "" {
+
return "", nil, nil
+
}
+
+
// Validate cursor size to prevent DoS via massive base64 strings
+
const maxCursorSize = 1024
+
if len(*cursor) > maxCursorSize {
+
return "", nil, fmt.Errorf("cursor too large: maximum %d bytes", maxCursorSize)
+
}
+
+
// Decode base64 cursor
+
decoded, err := base64.URLEncoding.DecodeString(*cursor)
+
if err != nil {
+
return "", nil, fmt.Errorf("invalid cursor encoding")
+
}
+
+
// Parse cursor based on sort type using | delimiter
+
// Format: hotRank|score|createdAt|uri (for hot)
+
// score|createdAt|uri (for top)
+
// createdAt|uri (for new)
+
parts := strings.Split(string(decoded), "|")
+
+
switch sort {
+
case "new":
+
// Cursor format: createdAt|uri
+
if len(parts) != 2 {
+
return "", nil, fmt.Errorf("invalid cursor format for new sort")
+
}
+
+
createdAt := parts[0]
+
uri := parts[1]
+
+
// Validate AT-URI format
+
if !strings.HasPrefix(uri, "at://") {
+
return "", nil, fmt.Errorf("invalid cursor URI")
+
}
+
+
filter := `AND (c.created_at < $3 OR (c.created_at = $3 AND c.uri < $4))`
+
return filter, []interface{}{createdAt, uri}, nil
+
+
case "top":
+
// Cursor format: score|createdAt|uri
+
if len(parts) != 3 {
+
return "", nil, fmt.Errorf("invalid cursor format for top sort")
+
}
+
+
scoreStr := parts[0]
+
createdAt := parts[1]
+
uri := parts[2]
+
+
// Parse score as integer
+
score := 0
+
if _, err := fmt.Sscanf(scoreStr, "%d", &score); err != nil {
+
return "", nil, fmt.Errorf("invalid cursor score")
+
}
+
+
// Validate AT-URI format
+
if !strings.HasPrefix(uri, "at://") {
+
return "", nil, fmt.Errorf("invalid cursor URI")
+
}
+
+
filter := `AND (c.score < $3 OR (c.score = $3 AND c.created_at < $4) OR (c.score = $3 AND c.created_at = $4 AND c.uri < $5))`
+
return filter, []interface{}{score, createdAt, uri}, nil
+
+
case "hot":
+
// Cursor format: hotRank|score|createdAt|uri
+
if len(parts) != 4 {
+
return "", nil, fmt.Errorf("invalid cursor format for hot sort")
+
}
+
+
hotRankStr := parts[0]
+
scoreStr := parts[1]
+
createdAt := parts[2]
+
uri := parts[3]
+
+
// Parse hot_rank as float
+
hotRank := 0.0
+
if _, err := fmt.Sscanf(hotRankStr, "%f", &hotRank); err != nil {
+
return "", nil, fmt.Errorf("invalid cursor hot rank")
+
}
+
+
// Parse score as integer
+
score := 0
+
if _, err := fmt.Sscanf(scoreStr, "%d", &score); err != nil {
+
return "", nil, fmt.Errorf("invalid cursor score")
+
}
+
+
// Validate AT-URI format
+
if !strings.HasPrefix(uri, "at://") {
+
return "", nil, fmt.Errorf("invalid cursor URI")
+
}
+
+
// Use computed hot_rank expression in comparison
+
hotRankExpr := `log(greatest(2, c.score + 2)) / power(((EXTRACT(EPOCH FROM (NOW() - c.created_at)) / 3600) + 2), 1.8)`
+
filter := fmt.Sprintf(`AND ((%s < $3 OR (%s = $3 AND c.score < $4) OR (%s = $3 AND c.score = $4 AND c.created_at < $5) OR (%s = $3 AND c.score = $4 AND c.created_at = $5 AND c.uri < $6)) AND c.uri != $7)`,
+
hotRankExpr, hotRankExpr, hotRankExpr, hotRankExpr)
+
return filter, []interface{}{hotRank, score, createdAt, uri, uri}, nil
+
+
default:
+
return "", nil, nil
+
}
+
}
+
+
// buildCommentCursor creates pagination cursor from last comment
+
func (r *postgresCommentRepo) buildCommentCursor(comment *comments.Comment, sort string, hotRank float64) string {
+
var cursorStr string
+
const delimiter = "|"
+
+
switch sort {
+
case "new":
+
// Format: createdAt|uri
+
cursorStr = fmt.Sprintf("%s%s%s",
+
comment.CreatedAt.Format("2006-01-02T15:04:05.999999999Z07:00"),
+
delimiter,
+
comment.URI)
+
+
case "top":
+
// Format: score|createdAt|uri
+
cursorStr = fmt.Sprintf("%d%s%s%s%s",
+
comment.Score,
+
delimiter,
+
comment.CreatedAt.Format("2006-01-02T15:04:05.999999999Z07:00"),
+
delimiter,
+
comment.URI)
+
+
case "hot":
+
// Format: hotRank|score|createdAt|uri
+
cursorStr = fmt.Sprintf("%f%s%d%s%s%s%s",
+
hotRank,
+
delimiter,
+
comment.Score,
+
delimiter,
+
comment.CreatedAt.Format("2006-01-02T15:04:05.999999999Z07:00"),
+
delimiter,
+
comment.URI)
+
+
default:
+
cursorStr = comment.URI
+
}
+
+
return base64.URLEncoding.EncodeToString([]byte(cursorStr))
+
}
+
+
// GetByURIsBatch retrieves multiple comments by their AT-URIs in a single query
+
// Returns map[uri]*Comment for efficient lookups without N+1 queries
+
func (r *postgresCommentRepo) GetByURIsBatch(ctx context.Context, uris []string) (map[string]*comments.Comment, error) {
+
if len(uris) == 0 {
+
return make(map[string]*comments.Comment), nil
+
}
+
+
// LEFT JOIN prevents data loss when user record hasn't been indexed yet (out-of-order Jetstream events)
+
// COALESCE falls back to DID when handle is NULL (user not yet in users table)
+
query := `
+
SELECT
+
c.id, c.uri, c.cid, c.rkey, c.commenter_did,
+
c.root_uri, c.root_cid, c.parent_uri, c.parent_cid,
+
c.content, c.content_facets, c.embed, c.content_labels, c.langs,
+
c.created_at, c.indexed_at, c.deleted_at,
+
c.upvote_count, c.downvote_count, c.score, c.reply_count,
+
COALESCE(u.handle, c.commenter_did) as author_handle
+
FROM comments c
+
LEFT JOIN users u ON c.commenter_did = u.did
+
WHERE c.uri = ANY($1) AND c.deleted_at IS NULL
+
`
+
+
rows, err := r.db.QueryContext(ctx, query, pq.Array(uris))
+
if err != nil {
+
return nil, fmt.Errorf("failed to batch get comments by URIs: %w", err)
+
}
+
defer func() {
+
if err := rows.Close(); err != nil {
+
log.Printf("Failed to close rows: %v", err)
+
}
+
}()
+
+
result := make(map[string]*comments.Comment)
+
for rows.Next() {
+
var comment comments.Comment
+
var langs pq.StringArray
+
var authorHandle string
+
+
err := rows.Scan(
+
&comment.ID, &comment.URI, &comment.CID, &comment.RKey, &comment.CommenterDID,
+
&comment.RootURI, &comment.RootCID, &comment.ParentURI, &comment.ParentCID,
+
&comment.Content, &comment.ContentFacets, &comment.Embed, &comment.ContentLabels, &langs,
+
&comment.CreatedAt, &comment.IndexedAt, &comment.DeletedAt,
+
&comment.UpvoteCount, &comment.DownvoteCount, &comment.Score, &comment.ReplyCount,
+
&authorHandle,
+
)
+
if err != nil {
+
return nil, fmt.Errorf("failed to scan comment: %w", err)
+
}
+
+
comment.Langs = langs
+
result[comment.URI] = &comment
+
}
+
+
if err = rows.Err(); err != nil {
+
return nil, fmt.Errorf("error iterating comments: %w", err)
+
}
+
+
return result, nil
+
}
+
+
// ListByParentsBatch retrieves direct replies to multiple parents in a single query
+
// Groups results by parent URI to prevent N+1 queries when loading nested replies
+
// Uses window functions to limit results per parent efficiently
+
func (r *postgresCommentRepo) ListByParentsBatch(
+
ctx context.Context,
+
parentURIs []string,
+
sort string,
+
limitPerParent int,
+
) (map[string][]*comments.Comment, error) {
+
if len(parentURIs) == 0 {
+
return make(map[string][]*comments.Comment), nil
+
}
+
+
// Build ORDER BY clause based on sort type
+
// windowOrderBy must inline expressions (can't use SELECT aliases in window functions)
+
var windowOrderBy string
+
var selectClause string
+
switch sort {
+
case "hot":
+
selectClause = `
+
c.id, c.uri, c.cid, c.rkey, c.commenter_did,
+
c.root_uri, c.root_cid, c.parent_uri, c.parent_cid,
+
c.content, c.content_facets, c.embed, c.content_labels, c.langs,
+
c.created_at, c.indexed_at, c.deleted_at,
+
c.upvote_count, c.downvote_count, c.score, c.reply_count,
+
log(greatest(2, c.score + 2)) / power(((EXTRACT(EPOCH FROM (NOW() - c.created_at)) / 3600) + 2), 1.8) as hot_rank,
+
COALESCE(u.handle, c.commenter_did) as author_handle`
+
// CRITICAL: Must inline hot_rank formula - PostgreSQL doesn't allow SELECT aliases in window ORDER BY
+
windowOrderBy = `log(greatest(2, c.score + 2)) / power(((EXTRACT(EPOCH FROM (NOW() - c.created_at)) / 3600) + 2), 1.8) DESC, c.score DESC, c.created_at DESC`
+
case "top":
+
selectClause = `
+
c.id, c.uri, c.cid, c.rkey, c.commenter_did,
+
c.root_uri, c.root_cid, c.parent_uri, c.parent_cid,
+
c.content, c.content_facets, c.embed, c.content_labels, c.langs,
+
c.created_at, c.indexed_at, c.deleted_at,
+
c.upvote_count, c.downvote_count, c.score, c.reply_count,
+
NULL::numeric as hot_rank,
+
COALESCE(u.handle, c.commenter_did) as author_handle`
+
windowOrderBy = `c.score DESC, c.created_at DESC`
+
case "new":
+
selectClause = `
+
c.id, c.uri, c.cid, c.rkey, c.commenter_did,
+
c.root_uri, c.root_cid, c.parent_uri, c.parent_cid,
+
c.content, c.content_facets, c.embed, c.content_labels, c.langs,
+
c.created_at, c.indexed_at, c.deleted_at,
+
c.upvote_count, c.downvote_count, c.score, c.reply_count,
+
NULL::numeric as hot_rank,
+
COALESCE(u.handle, c.commenter_did) as author_handle`
+
windowOrderBy = `c.created_at DESC`
+
default:
+
// Default to hot
+
selectClause = `
+
c.id, c.uri, c.cid, c.rkey, c.commenter_did,
+
c.root_uri, c.root_cid, c.parent_uri, c.parent_cid,
+
c.content, c.content_facets, c.embed, c.content_labels, c.langs,
+
c.created_at, c.indexed_at, c.deleted_at,
+
c.upvote_count, c.downvote_count, c.score, c.reply_count,
+
log(greatest(2, c.score + 2)) / power(((EXTRACT(EPOCH FROM (NOW() - c.created_at)) / 3600) + 2), 1.8) as hot_rank,
+
COALESCE(u.handle, c.commenter_did) as author_handle`
+
// CRITICAL: Must inline hot_rank formula - PostgreSQL doesn't allow SELECT aliases in window ORDER BY
+
windowOrderBy = `log(greatest(2, c.score + 2)) / power(((EXTRACT(EPOCH FROM (NOW() - c.created_at)) / 3600) + 2), 1.8) DESC, c.score DESC, c.created_at DESC`
+
}
+
+
// Use window function to limit results per parent
+
// This is more efficient than LIMIT in a subquery per parent
+
// LEFT JOIN prevents data loss when user record hasn't been indexed yet (out-of-order Jetstream events)
+
query := fmt.Sprintf(`
+
WITH ranked_comments AS (
+
SELECT
+
%s,
+
ROW_NUMBER() OVER (
+
PARTITION BY c.parent_uri
+
ORDER BY %s
+
) as rn
+
FROM comments c
+
LEFT JOIN users u ON c.commenter_did = u.did
+
WHERE c.parent_uri = ANY($1) AND c.deleted_at IS NULL
+
)
+
SELECT
+
id, uri, cid, rkey, commenter_did,
+
root_uri, root_cid, parent_uri, parent_cid,
+
content, content_facets, embed, content_labels, langs,
+
created_at, indexed_at, deleted_at,
+
upvote_count, downvote_count, score, reply_count,
+
hot_rank, author_handle
+
FROM ranked_comments
+
WHERE rn <= $2
+
ORDER BY parent_uri, rn
+
`, selectClause, windowOrderBy)
+
+
rows, err := r.db.QueryContext(ctx, query, pq.Array(parentURIs), limitPerParent)
+
if err != nil {
+
return nil, fmt.Errorf("failed to batch query comments by parents: %w", err)
+
}
+
defer func() {
+
if err := rows.Close(); err != nil {
+
log.Printf("Failed to close rows: %v", err)
+
}
+
}()
+
+
// Group results by parent URI
+
result := make(map[string][]*comments.Comment)
+
for rows.Next() {
+
var comment comments.Comment
+
var langs pq.StringArray
+
var hotRank sql.NullFloat64
+
var authorHandle string
+
+
err := rows.Scan(
+
&comment.ID, &comment.URI, &comment.CID, &comment.RKey, &comment.CommenterDID,
+
&comment.RootURI, &comment.RootCID, &comment.ParentURI, &comment.ParentCID,
+
&comment.Content, &comment.ContentFacets, &comment.Embed, &comment.ContentLabels, &langs,
+
&comment.CreatedAt, &comment.IndexedAt, &comment.DeletedAt,
+
&comment.UpvoteCount, &comment.DownvoteCount, &comment.Score, &comment.ReplyCount,
+
&hotRank, &authorHandle,
+
)
+
if err != nil {
+
return nil, fmt.Errorf("failed to scan comment: %w", err)
+
}
+
+
comment.Langs = langs
+
comment.CommenterHandle = authorHandle
+
+
// Group by parent URI
+
result[comment.ParentURI] = append(result[comment.ParentURI], &comment)
+
}
+
+
if err = rows.Err(); err != nil {
+
return nil, fmt.Errorf("error iterating comments: %w", err)
+
}
+
+
return result, nil
+
}
+
+
// GetVoteStateForComments retrieves the viewer's votes on a batch of comments
+
// Returns map[commentURI]*Vote for efficient lookups
+
// Note: This implementation is prepared for when the votes table indexing is implemented
+
// Currently returns an empty map as votes may not be fully indexed yet
+
func (r *postgresCommentRepo) GetVoteStateForComments(ctx context.Context, viewerDID string, commentURIs []string) (map[string]interface{}, error) {
+
if len(commentURIs) == 0 || viewerDID == "" {
+
return make(map[string]interface{}), nil
+
}
+
+
// Query votes table for viewer's votes on these comments
+
// Note: This assumes votes table exists and is being indexed
+
// If votes table doesn't exist yet, this query will fail gracefully
+
query := `
+
SELECT subject_uri, direction, uri, cid, created_at
+
FROM votes
+
WHERE voter_did = $1 AND subject_uri = ANY($2) AND deleted_at IS NULL
+
`
+
+
rows, err := r.db.QueryContext(ctx, query, viewerDID, pq.Array(commentURIs))
+
if err != nil {
+
// If votes table doesn't exist yet, return empty map instead of error
+
// This allows the API to work before votes indexing is fully implemented
+
if strings.Contains(err.Error(), "does not exist") {
+
return make(map[string]interface{}), nil
+
}
+
return nil, fmt.Errorf("failed to get vote state for comments: %w", err)
+
}
+
defer func() {
+
if err := rows.Close(); err != nil {
+
log.Printf("Failed to close rows: %v", err)
+
}
+
}()
+
+
// Build result map with vote information
+
result := make(map[string]interface{})
+
for rows.Next() {
+
var subjectURI, direction, uri, cid string
+
var createdAt sql.NullTime
+
+
err := rows.Scan(&subjectURI, &direction, &uri, &cid, &createdAt)
+
if err != nil {
+
return nil, fmt.Errorf("failed to scan vote: %w", err)
+
}
+
+
// Store vote info as a simple map (can be enhanced later with proper Vote struct)
+
result[subjectURI] = map[string]interface{}{
+
"direction": direction,
+
"uri": uri,
+
"cid": cid,
+
"createdAt": createdAt.Time,
+
}
+
}
+
+
if err = rows.Err(); err != nil {
+
return nil, fmt.Errorf("error iterating votes: %w", err)
+
}
+
+
return result, nil
+
}
+2 -2
tests/integration/comment_consumer_test.go
···
RKey: rkey,
CID: "bafytest123",
Record: map[string]interface{}{
-
"$type": "social.coves.feed.comment",
"content": "This is a test comment on a post!",
"reply": map[string]interface{}{
"root": map[string]interface{}{
···
RKey: rkey,
CID: "bafytest456",
Record: map[string]interface{}{
-
"$type": "social.coves.feed.comment",
"content": "Idempotent test comment",
"reply": map[string]interface{}{
"root": map[string]interface{}{
···
RKey: rkey,
CID: "bafytest123",
Record: map[string]interface{}{
+
"$type": "social.coves.feed.comment",
"content": "This is a test comment on a post!",
"reply": map[string]interface{}{
"root": map[string]interface{}{
···
RKey: rkey,
CID: "bafytest456",
Record: map[string]interface{}{
+
"$type": "social.coves.feed.comment",
"content": "Idempotent test comment",
"reply": map[string]interface{}{
"root": map[string]interface{}{
+928
tests/integration/comment_query_test.go
···
···
+
package integration
+
+
import (
+
"Coves/internal/atproto/jetstream"
+
"Coves/internal/core/comments"
+
"Coves/internal/db/postgres"
+
"context"
+
"database/sql"
+
"encoding/json"
+
"fmt"
+
"net/http"
+
"net/http/httptest"
+
"strings"
+
"testing"
+
"time"
+
+
"github.com/stretchr/testify/assert"
+
"github.com/stretchr/testify/require"
+
)
+
+
// TestCommentQuery_BasicFetch tests fetching top-level comments with default params
+
func TestCommentQuery_BasicFetch(t *testing.T) {
+
db := setupTestDB(t)
+
defer func() {
+
if err := db.Close(); err != nil {
+
t.Logf("Failed to close database: %v", err)
+
}
+
}()
+
+
ctx := context.Background()
+
testUser := createTestUser(t, db, "basicfetch.test", "did:plc:basicfetch123")
+
testCommunity, err := createFeedTestCommunity(db, ctx, "basicfetchcomm", "ownerbasic.test")
+
require.NoError(t, err, "Failed to create test community")
+
+
postURI := createTestPost(t, db, testCommunity, testUser.DID, "Basic Fetch Test Post", 0, time.Now())
+
+
// Create 3 top-level comments with different scores and ages
+
comment1 := createTestCommentWithScore(t, db, testUser.DID, postURI, postURI, "First comment", 10, 2, time.Now().Add(-2*time.Hour))
+
comment2 := createTestCommentWithScore(t, db, testUser.DID, postURI, postURI, "Second comment", 5, 1, time.Now().Add(-30*time.Minute))
+
comment3 := createTestCommentWithScore(t, db, testUser.DID, postURI, postURI, "Third comment", 3, 0, time.Now().Add(-5*time.Minute))
+
+
// Fetch comments with default params (hot sort)
+
service := setupCommentService(db)
+
req := &comments.GetCommentsRequest{
+
PostURI: postURI,
+
Sort: "hot",
+
Depth: 10,
+
Limit: 50,
+
}
+
+
resp, err := service.GetComments(ctx, req)
+
require.NoError(t, err, "GetComments should not return error")
+
require.NotNil(t, resp, "Response should not be nil")
+
+
// Verify all 3 comments returned
+
assert.Len(t, resp.Comments, 3, "Should return all 3 top-level comments")
+
+
// Verify stats are correct
+
for _, threadView := range resp.Comments {
+
commentView := threadView.Comment
+
assert.NotNil(t, commentView.Stats, "Stats should not be nil")
+
+
// Verify upvotes, downvotes, score, reply count present
+
assert.GreaterOrEqual(t, commentView.Stats.Upvotes, 0, "Upvotes should be non-negative")
+
assert.GreaterOrEqual(t, commentView.Stats.Downvotes, 0, "Downvotes should be non-negative")
+
assert.Equal(t, 0, commentView.Stats.ReplyCount, "Top-level comments should have 0 replies")
+
}
+
+
// Verify URIs match
+
commentURIs := []string{comment1, comment2, comment3}
+
returnedURIs := make(map[string]bool)
+
for _, tv := range resp.Comments {
+
returnedURIs[tv.Comment.URI] = true
+
}
+
+
for _, uri := range commentURIs {
+
assert.True(t, returnedURIs[uri], "Comment URI %s should be in results", uri)
+
}
+
}
+
+
// TestCommentQuery_NestedReplies tests fetching comments with nested reply structure
+
func TestCommentQuery_NestedReplies(t *testing.T) {
+
db := setupTestDB(t)
+
defer func() {
+
if err := db.Close(); err != nil {
+
t.Logf("Failed to close database: %v", err)
+
}
+
}()
+
+
ctx := context.Background()
+
testUser := createTestUser(t, db, "nested.test", "did:plc:nested123")
+
testCommunity, err := createFeedTestCommunity(db, ctx, "nestedcomm", "ownernested.test")
+
require.NoError(t, err)
+
+
postURI := createTestPost(t, db, testCommunity, testUser.DID, "Nested Test Post", 0, time.Now())
+
+
// Create nested structure:
+
// Post
+
// |- Comment A (top-level)
+
// |- Reply A1
+
// |- Reply A1a
+
// |- Reply A1b
+
// |- Reply A2
+
// |- Comment B (top-level)
+
+
commentA := createTestCommentWithScore(t, db, testUser.DID, postURI, postURI, "Comment A", 5, 0, time.Now().Add(-1*time.Hour))
+
replyA1 := createTestCommentWithScore(t, db, testUser.DID, postURI, commentA, "Reply A1", 3, 0, time.Now().Add(-50*time.Minute))
+
replyA1a := createTestCommentWithScore(t, db, testUser.DID, postURI, replyA1, "Reply A1a", 2, 0, time.Now().Add(-40*time.Minute))
+
replyA1b := createTestCommentWithScore(t, db, testUser.DID, postURI, replyA1, "Reply A1b", 1, 0, time.Now().Add(-30*time.Minute))
+
replyA2 := createTestCommentWithScore(t, db, testUser.DID, postURI, commentA, "Reply A2", 2, 0, time.Now().Add(-20*time.Minute))
+
commentB := createTestCommentWithScore(t, db, testUser.DID, postURI, postURI, "Comment B", 4, 0, time.Now().Add(-10*time.Minute))
+
+
// Fetch with depth=2 (should get 2 levels of nesting)
+
service := setupCommentService(db)
+
req := &comments.GetCommentsRequest{
+
PostURI: postURI,
+
Sort: "new",
+
Depth: 2,
+
Limit: 50,
+
}
+
+
resp, err := service.GetComments(ctx, req)
+
require.NoError(t, err)
+
require.Len(t, resp.Comments, 2, "Should return 2 top-level comments")
+
+
// Find Comment A in results
+
var commentAThread *comments.ThreadViewComment
+
for _, tv := range resp.Comments {
+
if tv.Comment.URI == commentA {
+
commentAThread = tv
+
break
+
}
+
}
+
require.NotNil(t, commentAThread, "Comment A should be in results")
+
+
// Verify Comment A has replies
+
require.NotNil(t, commentAThread.Replies, "Comment A should have replies")
+
assert.Len(t, commentAThread.Replies, 2, "Comment A should have 2 direct replies (A1 and A2)")
+
+
// Find Reply A1
+
var replyA1Thread *comments.ThreadViewComment
+
for _, reply := range commentAThread.Replies {
+
if reply.Comment.URI == replyA1 {
+
replyA1Thread = reply
+
break
+
}
+
}
+
require.NotNil(t, replyA1Thread, "Reply A1 should be in results")
+
+
// Verify Reply A1 has nested replies (at depth 2)
+
require.NotNil(t, replyA1Thread.Replies, "Reply A1 should have nested replies at depth 2")
+
assert.Len(t, replyA1Thread.Replies, 2, "Reply A1 should have 2 nested replies (A1a and A1b)")
+
+
// Verify reply URIs
+
replyURIs := make(map[string]bool)
+
for _, r := range replyA1Thread.Replies {
+
replyURIs[r.Comment.URI] = true
+
}
+
assert.True(t, replyURIs[replyA1a], "Reply A1a should be present")
+
assert.True(t, replyURIs[replyA1b], "Reply A1b should be present")
+
+
// Verify no deeper nesting (depth limit enforced)
+
for _, r := range replyA1Thread.Replies {
+
assert.Nil(t, r.Replies, "Replies at depth 2 should not have further nesting")
+
}
+
+
_ = commentB
+
_ = replyA2
+
}
+
+
// TestCommentQuery_DepthLimit tests depth limiting works correctly
+
func TestCommentQuery_DepthLimit(t *testing.T) {
+
db := setupTestDB(t)
+
defer func() {
+
if err := db.Close(); err != nil {
+
t.Logf("Failed to close database: %v", err)
+
}
+
}()
+
+
ctx := context.Background()
+
testUser := createTestUser(t, db, "depth.test", "did:plc:depth123")
+
testCommunity, err := createFeedTestCommunity(db, ctx, "depthcomm", "ownerdepth.test")
+
require.NoError(t, err)
+
+
postURI := createTestPost(t, db, testCommunity, testUser.DID, "Depth Test Post", 0, time.Now())
+
+
// Create deeply nested thread (5 levels)
+
// Post -> C1 -> C2 -> C3 -> C4 -> C5
+
c1 := createTestCommentWithScore(t, db, testUser.DID, postURI, postURI, "Level 1", 5, 0, time.Now().Add(-5*time.Minute))
+
c2 := createTestCommentWithScore(t, db, testUser.DID, postURI, c1, "Level 2", 4, 0, time.Now().Add(-4*time.Minute))
+
c3 := createTestCommentWithScore(t, db, testUser.DID, postURI, c2, "Level 3", 3, 0, time.Now().Add(-3*time.Minute))
+
c4 := createTestCommentWithScore(t, db, testUser.DID, postURI, c3, "Level 4", 2, 0, time.Now().Add(-2*time.Minute))
+
c5 := createTestCommentWithScore(t, db, testUser.DID, postURI, c4, "Level 5", 1, 0, time.Now().Add(-1*time.Minute))
+
+
t.Run("Depth 0 returns flat list", func(t *testing.T) {
+
service := setupCommentService(db)
+
req := &comments.GetCommentsRequest{
+
PostURI: postURI,
+
Sort: "new",
+
Depth: 0,
+
Limit: 50,
+
}
+
+
resp, err := service.GetComments(ctx, req)
+
require.NoError(t, err)
+
require.Len(t, resp.Comments, 1, "Should return 1 top-level comment")
+
+
// Verify no replies included
+
assert.Nil(t, resp.Comments[0].Replies, "Depth 0 should not include replies")
+
+
// Verify HasMore flag is set (c1 has replies)
+
assert.True(t, resp.Comments[0].HasMore, "HasMore should be true when replies exist but depth=0")
+
})
+
+
t.Run("Depth 3 returns exactly 3 levels", func(t *testing.T) {
+
service := setupCommentService(db)
+
req := &comments.GetCommentsRequest{
+
PostURI: postURI,
+
Sort: "new",
+
Depth: 3,
+
Limit: 50,
+
}
+
+
resp, err := service.GetComments(ctx, req)
+
require.NoError(t, err)
+
require.Len(t, resp.Comments, 1, "Should return 1 top-level comment")
+
+
// Traverse and verify exactly 3 levels
+
level1 := resp.Comments[0]
+
require.NotNil(t, level1.Replies, "Level 1 should have replies")
+
require.Len(t, level1.Replies, 1, "Level 1 should have 1 reply")
+
+
level2 := level1.Replies[0]
+
require.NotNil(t, level2.Replies, "Level 2 should have replies")
+
require.Len(t, level2.Replies, 1, "Level 2 should have 1 reply")
+
+
level3 := level2.Replies[0]
+
require.NotNil(t, level3.Replies, "Level 3 should have replies")
+
require.Len(t, level3.Replies, 1, "Level 3 should have 1 reply")
+
+
// Level 4 should NOT have replies (depth limit)
+
level4 := level3.Replies[0]
+
assert.Nil(t, level4.Replies, "Level 4 should not have replies (depth limit)")
+
+
// Verify HasMore is set correctly at depth boundary
+
assert.True(t, level4.HasMore, "HasMore should be true at depth boundary when more replies exist")
+
})
+
+
_ = c2
+
_ = c3
+
_ = c4
+
_ = c5
+
}
+
+
// TestCommentQuery_HotSorting tests hot sorting with Lemmy algorithm
+
func TestCommentQuery_HotSorting(t *testing.T) {
+
db := setupTestDB(t)
+
defer func() {
+
if err := db.Close(); err != nil {
+
t.Logf("Failed to close database: %v", err)
+
}
+
}()
+
+
ctx := context.Background()
+
testUser := createTestUser(t, db, "hot.test", "did:plc:hot123")
+
testCommunity, err := createFeedTestCommunity(db, ctx, "hotcomm", "ownerhot.test")
+
require.NoError(t, err)
+
+
postURI := createTestPost(t, db, testCommunity, testUser.DID, "Hot Sorting Test", 0, time.Now())
+
+
// Create 3 comments with different scores and ages
+
// Comment 1: score=10, created 1 hour ago
+
c1 := createTestCommentWithScore(t, db, testUser.DID, postURI, postURI, "Old high score", 10, 0, time.Now().Add(-1*time.Hour))
+
+
// Comment 2: score=5, created 5 minutes ago (should rank higher due to recency)
+
c2 := createTestCommentWithScore(t, db, testUser.DID, postURI, postURI, "Recent medium score", 5, 0, time.Now().Add(-5*time.Minute))
+
+
// Comment 3: score=-2, created now (negative score should rank lower)
+
c3 := createTestCommentWithScore(t, db, testUser.DID, postURI, postURI, "Negative score", 0, 2, time.Now())
+
+
service := setupCommentService(db)
+
req := &comments.GetCommentsRequest{
+
PostURI: postURI,
+
Sort: "hot",
+
Depth: 0,
+
Limit: 50,
+
}
+
+
resp, err := service.GetComments(ctx, req)
+
require.NoError(t, err)
+
require.Len(t, resp.Comments, 3, "Should return all 3 comments")
+
+
// Verify hot sorting order
+
// Recent comment with medium score should rank higher than old comment with high score
+
assert.Equal(t, c2, resp.Comments[0].Comment.URI, "Recent medium score should rank first")
+
assert.Equal(t, c1, resp.Comments[1].Comment.URI, "Old high score should rank second")
+
assert.Equal(t, c3, resp.Comments[2].Comment.URI, "Negative score should rank last")
+
+
// Verify negative scores are handled gracefully
+
negativeComment := resp.Comments[2].Comment
+
assert.Equal(t, -2, negativeComment.Stats.Score, "Negative score should be preserved")
+
assert.Equal(t, 0, negativeComment.Stats.Upvotes, "Upvotes should be 0")
+
assert.Equal(t, 2, negativeComment.Stats.Downvotes, "Downvotes should be 2")
+
}
+
+
// TestCommentQuery_TopSorting tests top sorting with score-based ordering
+
func TestCommentQuery_TopSorting(t *testing.T) {
+
db := setupTestDB(t)
+
defer func() {
+
if err := db.Close(); err != nil {
+
t.Logf("Failed to close database: %v", err)
+
}
+
}()
+
+
ctx := context.Background()
+
testUser := createTestUser(t, db, "top.test", "did:plc:top123")
+
testCommunity, err := createFeedTestCommunity(db, ctx, "topcomm", "ownertop.test")
+
require.NoError(t, err)
+
+
postURI := createTestPost(t, db, testCommunity, testUser.DID, "Top Sorting Test", 0, time.Now())
+
+
// Create comments with different scores
+
c1 := createTestCommentWithScore(t, db, testUser.DID, postURI, postURI, "Low score", 2, 0, time.Now().Add(-30*time.Minute))
+
c2 := createTestCommentWithScore(t, db, testUser.DID, postURI, postURI, "High score", 10, 0, time.Now().Add(-1*time.Hour))
+
c3 := createTestCommentWithScore(t, db, testUser.DID, postURI, postURI, "Medium score", 5, 0, time.Now().Add(-15*time.Minute))
+
+
t.Run("Top sort without timeframe", func(t *testing.T) {
+
service := setupCommentService(db)
+
req := &comments.GetCommentsRequest{
+
PostURI: postURI,
+
Sort: "top",
+
Depth: 0,
+
Limit: 50,
+
}
+
+
resp, err := service.GetComments(ctx, req)
+
require.NoError(t, err)
+
require.Len(t, resp.Comments, 3)
+
+
// Verify highest score first
+
assert.Equal(t, c2, resp.Comments[0].Comment.URI, "Highest score should be first")
+
assert.Equal(t, c3, resp.Comments[1].Comment.URI, "Medium score should be second")
+
assert.Equal(t, c1, resp.Comments[2].Comment.URI, "Low score should be third")
+
})
+
+
t.Run("Top sort with hour timeframe", func(t *testing.T) {
+
service := setupCommentService(db)
+
req := &comments.GetCommentsRequest{
+
PostURI: postURI,
+
Sort: "top",
+
Timeframe: "hour",
+
Depth: 0,
+
Limit: 50,
+
}
+
+
resp, err := service.GetComments(ctx, req)
+
require.NoError(t, err)
+
+
// Only comments from last hour should be included (c1 and c3, not c2)
+
assert.LessOrEqual(t, len(resp.Comments), 2, "Should exclude comments older than 1 hour")
+
+
// Verify c2 (created 1 hour ago) is excluded
+
for _, tv := range resp.Comments {
+
assert.NotEqual(t, c2, tv.Comment.URI, "Comment older than 1 hour should be excluded")
+
}
+
})
+
}
+
+
// TestCommentQuery_NewSorting tests chronological sorting
+
func TestCommentQuery_NewSorting(t *testing.T) {
+
db := setupTestDB(t)
+
defer func() {
+
if err := db.Close(); err != nil {
+
t.Logf("Failed to close database: %v", err)
+
}
+
}()
+
+
ctx := context.Background()
+
testUser := createTestUser(t, db, "new.test", "did:plc:new123")
+
testCommunity, err := createFeedTestCommunity(db, ctx, "newcomm", "ownernew.test")
+
require.NoError(t, err)
+
+
postURI := createTestPost(t, db, testCommunity, testUser.DID, "New Sorting Test", 0, time.Now())
+
+
// Create comments at different times (different scores to verify time is priority)
+
c1 := createTestCommentWithScore(t, db, testUser.DID, postURI, postURI, "Oldest", 10, 0, time.Now().Add(-1*time.Hour))
+
c2 := createTestCommentWithScore(t, db, testUser.DID, postURI, postURI, "Middle", 5, 0, time.Now().Add(-30*time.Minute))
+
c3 := createTestCommentWithScore(t, db, testUser.DID, postURI, postURI, "Newest", 2, 0, time.Now().Add(-5*time.Minute))
+
+
service := setupCommentService(db)
+
req := &comments.GetCommentsRequest{
+
PostURI: postURI,
+
Sort: "new",
+
Depth: 0,
+
Limit: 50,
+
}
+
+
resp, err := service.GetComments(ctx, req)
+
require.NoError(t, err)
+
require.Len(t, resp.Comments, 3)
+
+
// Verify chronological order (newest first)
+
assert.Equal(t, c3, resp.Comments[0].Comment.URI, "Newest comment should be first")
+
assert.Equal(t, c2, resp.Comments[1].Comment.URI, "Middle comment should be second")
+
assert.Equal(t, c1, resp.Comments[2].Comment.URI, "Oldest comment should be third")
+
}
+
+
// TestCommentQuery_Pagination tests cursor-based pagination
+
func TestCommentQuery_Pagination(t *testing.T) {
+
db := setupTestDB(t)
+
defer func() {
+
if err := db.Close(); err != nil {
+
t.Logf("Failed to close database: %v", err)
+
}
+
}()
+
+
ctx := context.Background()
+
testUser := createTestUser(t, db, "page.test", "did:plc:page123")
+
testCommunity, err := createFeedTestCommunity(db, ctx, "pagecomm", "ownerpage.test")
+
require.NoError(t, err)
+
+
postURI := createTestPost(t, db, testCommunity, testUser.DID, "Pagination Test", 0, time.Now())
+
+
// Create 60 comments
+
allCommentURIs := make([]string, 60)
+
for i := 0; i < 60; i++ {
+
uri := createTestCommentWithScore(t, db, testUser.DID, postURI, postURI,
+
fmt.Sprintf("Comment %d", i), i, 0, time.Now().Add(-time.Duration(60-i)*time.Minute))
+
allCommentURIs[i] = uri
+
}
+
+
service := setupCommentService(db)
+
+
// Fetch first page (limit=50)
+
req1 := &comments.GetCommentsRequest{
+
PostURI: postURI,
+
Sort: "new",
+
Depth: 0,
+
Limit: 50,
+
}
+
+
resp1, err := service.GetComments(ctx, req1)
+
require.NoError(t, err)
+
assert.Len(t, resp1.Comments, 50, "First page should have 50 comments")
+
require.NotNil(t, resp1.Cursor, "Cursor should be present for next page")
+
+
// Fetch second page with cursor
+
req2 := &comments.GetCommentsRequest{
+
PostURI: postURI,
+
Sort: "new",
+
Depth: 0,
+
Limit: 50,
+
Cursor: resp1.Cursor,
+
}
+
+
resp2, err := service.GetComments(ctx, req2)
+
require.NoError(t, err)
+
assert.Len(t, resp2.Comments, 10, "Second page should have remaining 10 comments")
+
assert.Nil(t, resp2.Cursor, "Cursor should be nil on last page")
+
+
// Verify no duplicates between pages
+
page1URIs := make(map[string]bool)
+
for _, tv := range resp1.Comments {
+
page1URIs[tv.Comment.URI] = true
+
}
+
+
for _, tv := range resp2.Comments {
+
assert.False(t, page1URIs[tv.Comment.URI], "Comment %s should not appear in both pages", tv.Comment.URI)
+
}
+
+
// Verify all comments eventually retrieved
+
allRetrieved := make(map[string]bool)
+
for _, tv := range resp1.Comments {
+
allRetrieved[tv.Comment.URI] = true
+
}
+
for _, tv := range resp2.Comments {
+
allRetrieved[tv.Comment.URI] = true
+
}
+
assert.Len(t, allRetrieved, 60, "All 60 comments should be retrieved across pages")
+
}
+
+
// TestCommentQuery_EmptyThread tests fetching comments from a post with no comments
+
func TestCommentQuery_EmptyThread(t *testing.T) {
+
db := setupTestDB(t)
+
defer func() {
+
if err := db.Close(); err != nil {
+
t.Logf("Failed to close database: %v", err)
+
}
+
}()
+
+
ctx := context.Background()
+
testUser := createTestUser(t, db, "empty.test", "did:plc:empty123")
+
testCommunity, err := createFeedTestCommunity(db, ctx, "emptycomm", "ownerempty.test")
+
require.NoError(t, err)
+
+
postURI := createTestPost(t, db, testCommunity, testUser.DID, "Empty Thread Test", 0, time.Now())
+
+
service := setupCommentService(db)
+
req := &comments.GetCommentsRequest{
+
PostURI: postURI,
+
Sort: "hot",
+
Depth: 10,
+
Limit: 50,
+
}
+
+
resp, err := service.GetComments(ctx, req)
+
require.NoError(t, err)
+
require.NotNil(t, resp, "Response should not be nil")
+
+
// Verify empty array (not null)
+
assert.NotNil(t, resp.Comments, "Comments array should not be nil")
+
assert.Len(t, resp.Comments, 0, "Comments array should be empty")
+
+
// Verify no cursor returned
+
assert.Nil(t, resp.Cursor, "Cursor should be nil for empty results")
+
}
+
+
// TestCommentQuery_DeletedComments tests that soft-deleted comments are excluded
+
func TestCommentQuery_DeletedComments(t *testing.T) {
+
db := setupTestDB(t)
+
defer func() {
+
if err := db.Close(); err != nil {
+
t.Logf("Failed to close database: %v", err)
+
}
+
}()
+
+
ctx := context.Background()
+
commentRepo := postgres.NewCommentRepository(db)
+
consumer := jetstream.NewCommentEventConsumer(commentRepo, db)
+
+
testUser := createTestUser(t, db, "deleted.test", "did:plc:deleted123")
+
testCommunity, err := createFeedTestCommunity(db, ctx, "deletedcomm", "ownerdeleted.test")
+
require.NoError(t, err)
+
+
postURI := createTestPost(t, db, testCommunity, testUser.DID, "Deleted Comments Test", 0, time.Now())
+
+
// Create 5 comments via Jetstream consumer
+
commentURIs := make([]string, 5)
+
for i := 0; i < 5; i++ {
+
rkey := generateTID()
+
uri := fmt.Sprintf("at://%s/social.coves.feed.comment/%s", testUser.DID, rkey)
+
commentURIs[i] = uri
+
+
event := &jetstream.JetstreamEvent{
+
Did: testUser.DID,
+
Kind: "commit",
+
Commit: &jetstream.CommitEvent{
+
Operation: "create",
+
Collection: "social.coves.feed.comment",
+
RKey: rkey,
+
CID: fmt.Sprintf("bafyc%d", i),
+
Record: map[string]interface{}{
+
"$type": "social.coves.feed.comment",
+
"content": fmt.Sprintf("Comment %d", i),
+
"reply": map[string]interface{}{
+
"root": map[string]interface{}{
+
"uri": postURI,
+
"cid": "bafypost",
+
},
+
"parent": map[string]interface{}{
+
"uri": postURI,
+
"cid": "bafypost",
+
},
+
},
+
"createdAt": time.Now().Add(time.Duration(i) * time.Minute).Format(time.RFC3339),
+
},
+
},
+
}
+
+
require.NoError(t, consumer.HandleEvent(ctx, event))
+
}
+
+
// Soft-delete 2 comments (index 1 and 3)
+
deleteEvent1 := &jetstream.JetstreamEvent{
+
Did: testUser.DID,
+
Kind: "commit",
+
Commit: &jetstream.CommitEvent{
+
Operation: "delete",
+
Collection: "social.coves.feed.comment",
+
RKey: strings.Split(commentURIs[1], "/")[4],
+
},
+
}
+
require.NoError(t, consumer.HandleEvent(ctx, deleteEvent1))
+
+
deleteEvent2 := &jetstream.JetstreamEvent{
+
Did: testUser.DID,
+
Kind: "commit",
+
Commit: &jetstream.CommitEvent{
+
Operation: "delete",
+
Collection: "social.coves.feed.comment",
+
RKey: strings.Split(commentURIs[3], "/")[4],
+
},
+
}
+
require.NoError(t, consumer.HandleEvent(ctx, deleteEvent2))
+
+
// Fetch comments
+
service := setupCommentService(db)
+
req := &comments.GetCommentsRequest{
+
PostURI: postURI,
+
Sort: "new",
+
Depth: 0,
+
Limit: 50,
+
}
+
+
resp, err := service.GetComments(ctx, req)
+
require.NoError(t, err)
+
+
// Verify only 3 comments returned (2 were deleted)
+
assert.Len(t, resp.Comments, 3, "Should only return non-deleted comments")
+
+
// Verify deleted comments are not in results
+
returnedURIs := make(map[string]bool)
+
for _, tv := range resp.Comments {
+
returnedURIs[tv.Comment.URI] = true
+
}
+
+
assert.False(t, returnedURIs[commentURIs[1]], "Deleted comment 1 should not be in results")
+
assert.False(t, returnedURIs[commentURIs[3]], "Deleted comment 3 should not be in results")
+
assert.True(t, returnedURIs[commentURIs[0]], "Non-deleted comment 0 should be in results")
+
assert.True(t, returnedURIs[commentURIs[2]], "Non-deleted comment 2 should be in results")
+
assert.True(t, returnedURIs[commentURIs[4]], "Non-deleted comment 4 should be in results")
+
}
+
+
// TestCommentQuery_InvalidInputs tests error handling for invalid inputs
+
func TestCommentQuery_InvalidInputs(t *testing.T) {
+
db := setupTestDB(t)
+
defer func() {
+
if err := db.Close(); err != nil {
+
t.Logf("Failed to close database: %v", err)
+
}
+
}()
+
+
ctx := context.Background()
+
service := setupCommentService(db)
+
+
t.Run("Invalid post URI", func(t *testing.T) {
+
req := &comments.GetCommentsRequest{
+
PostURI: "not-an-at-uri",
+
Sort: "hot",
+
Depth: 10,
+
Limit: 50,
+
}
+
+
_, err := service.GetComments(ctx, req)
+
assert.Error(t, err, "Should return error for invalid AT-URI")
+
assert.Contains(t, err.Error(), "invalid", "Error should mention invalid")
+
})
+
+
t.Run("Negative depth", func(t *testing.T) {
+
req := &comments.GetCommentsRequest{
+
PostURI: "at://did:plc:test/social.coves.feed.post/abc123",
+
Sort: "hot",
+
Depth: -5,
+
Limit: 50,
+
}
+
+
resp, err := service.GetComments(ctx, req)
+
// Should not error, but should clamp to default (10)
+
require.NoError(t, err)
+
// Depth is normalized in validation
+
_ = resp
+
})
+
+
t.Run("Depth exceeds max", func(t *testing.T) {
+
req := &comments.GetCommentsRequest{
+
PostURI: "at://did:plc:test/social.coves.feed.post/abc123",
+
Sort: "hot",
+
Depth: 150, // Exceeds max of 100
+
Limit: 50,
+
}
+
+
resp, err := service.GetComments(ctx, req)
+
// Should not error, but should clamp to 100
+
require.NoError(t, err)
+
_ = resp
+
})
+
+
t.Run("Limit exceeds max", func(t *testing.T) {
+
req := &comments.GetCommentsRequest{
+
PostURI: "at://did:plc:test/social.coves.feed.post/abc123",
+
Sort: "hot",
+
Depth: 10,
+
Limit: 150, // Exceeds max of 100
+
}
+
+
resp, err := service.GetComments(ctx, req)
+
// Should not error, but should clamp to 100
+
require.NoError(t, err)
+
_ = resp
+
})
+
+
t.Run("Invalid sort", func(t *testing.T) {
+
req := &comments.GetCommentsRequest{
+
PostURI: "at://did:plc:test/social.coves.feed.post/abc123",
+
Sort: "invalid",
+
Depth: 10,
+
Limit: 50,
+
}
+
+
_, err := service.GetComments(ctx, req)
+
assert.Error(t, err, "Should return error for invalid sort")
+
assert.Contains(t, err.Error(), "invalid sort", "Error should mention invalid sort")
+
})
+
+
t.Run("Empty post URI", func(t *testing.T) {
+
req := &comments.GetCommentsRequest{
+
PostURI: "",
+
Sort: "hot",
+
Depth: 10,
+
Limit: 50,
+
}
+
+
_, err := service.GetComments(ctx, req)
+
assert.Error(t, err, "Should return error for empty post URI")
+
})
+
}
+
+
// TestCommentQuery_HTTPHandler tests the HTTP handler end-to-end
+
func TestCommentQuery_HTTPHandler(t *testing.T) {
+
db := setupTestDB(t)
+
defer func() {
+
if err := db.Close(); err != nil {
+
t.Logf("Failed to close database: %v", err)
+
}
+
}()
+
+
ctx := context.Background()
+
testUser := createTestUser(t, db, "http.test", "did:plc:http123")
+
testCommunity, err := createFeedTestCommunity(db, ctx, "httpcomm", "ownerhttp.test")
+
require.NoError(t, err)
+
+
postURI := createTestPost(t, db, testCommunity, testUser.DID, "HTTP Handler Test", 0, time.Now())
+
+
// Create test comments
+
createTestCommentWithScore(t, db, testUser.DID, postURI, postURI, "Test comment 1", 5, 0, time.Now().Add(-30*time.Minute))
+
createTestCommentWithScore(t, db, testUser.DID, postURI, postURI, "Test comment 2", 3, 0, time.Now().Add(-15*time.Minute))
+
+
// Setup service adapter for HTTP handler
+
service := setupCommentServiceAdapter(db)
+
handler := &testGetCommentsHandler{service: service}
+
+
t.Run("Valid GET request", func(t *testing.T) {
+
req := httptest.NewRequest("GET", fmt.Sprintf("/xrpc/social.coves.feed.getComments?post=%s&sort=hot&depth=10&limit=50", postURI), nil)
+
w := httptest.NewRecorder()
+
+
handler.ServeHTTP(w, req)
+
+
assert.Equal(t, http.StatusOK, w.Code)
+
assert.Equal(t, "application/json", w.Header().Get("Content-Type"))
+
+
var resp comments.GetCommentsResponse
+
err := json.NewDecoder(w.Body).Decode(&resp)
+
require.NoError(t, err)
+
assert.Len(t, resp.Comments, 2, "Should return 2 comments")
+
})
+
+
t.Run("Missing post parameter", func(t *testing.T) {
+
req := httptest.NewRequest("GET", "/xrpc/social.coves.feed.getComments?sort=hot", nil)
+
w := httptest.NewRecorder()
+
+
handler.ServeHTTP(w, req)
+
+
assert.Equal(t, http.StatusBadRequest, w.Code)
+
})
+
+
t.Run("Invalid depth parameter", func(t *testing.T) {
+
req := httptest.NewRequest("GET", fmt.Sprintf("/xrpc/social.coves.feed.getComments?post=%s&depth=invalid", postURI), nil)
+
w := httptest.NewRecorder()
+
+
handler.ServeHTTP(w, req)
+
+
assert.Equal(t, http.StatusBadRequest, w.Code)
+
})
+
}
+
+
// Helper: setupCommentService creates a comment service for testing
+
func setupCommentService(db *sql.DB) comments.Service {
+
commentRepo := postgres.NewCommentRepository(db)
+
postRepo := postgres.NewPostRepository(db)
+
userRepo := postgres.NewUserRepository(db)
+
communityRepo := postgres.NewCommunityRepository(db)
+
return comments.NewCommentService(commentRepo, userRepo, postRepo, communityRepo)
+
}
+
+
// Helper: createTestCommentWithScore creates a comment with specific vote counts
+
func createTestCommentWithScore(t *testing.T, db *sql.DB, commenterDID, rootURI, parentURI, content string, upvotes, downvotes int, createdAt time.Time) string {
+
t.Helper()
+
+
ctx := context.Background()
+
rkey := generateTID()
+
uri := fmt.Sprintf("at://%s/social.coves.feed.comment/%s", commenterDID, rkey)
+
+
// Insert comment directly for speed
+
_, err := db.ExecContext(ctx, `
+
INSERT INTO comments (
+
uri, cid, rkey, commenter_did,
+
root_uri, root_cid, parent_uri, parent_cid,
+
content, created_at, indexed_at,
+
upvote_count, downvote_count, score
+
) VALUES (
+
$1, $2, $3, $4,
+
$5, $6, $7, $8,
+
$9, $10, NOW(),
+
$11, $12, $13
+
)
+
`, uri, fmt.Sprintf("bafyc%s", rkey), rkey, commenterDID,
+
rootURI, "bafyroot", parentURI, "bafyparent",
+
content, createdAt,
+
upvotes, downvotes, upvotes-downvotes)
+
+
require.NoError(t, err, "Failed to create test comment")
+
+
// Update reply count on parent if it's a nested comment
+
if parentURI != rootURI {
+
_, _ = db.ExecContext(ctx, `
+
UPDATE comments
+
SET reply_count = reply_count + 1
+
WHERE uri = $1
+
`, parentURI)
+
} else {
+
// Update comment count on post if top-level
+
_, _ = db.ExecContext(ctx, `
+
UPDATE posts
+
SET comment_count = comment_count + 1
+
WHERE uri = $1
+
`, parentURI)
+
}
+
+
return uri
+
}
+
+
// Helper: Service adapter for HTTP handler testing
+
type testCommentServiceAdapter struct {
+
service comments.Service
+
}
+
+
func (s *testCommentServiceAdapter) GetComments(r *http.Request, req *testGetCommentsRequest) (*comments.GetCommentsResponse, error) {
+
ctx := r.Context()
+
+
serviceReq := &comments.GetCommentsRequest{
+
PostURI: req.PostURI,
+
Sort: req.Sort,
+
Timeframe: req.Timeframe,
+
Depth: req.Depth,
+
Limit: req.Limit,
+
Cursor: req.Cursor,
+
ViewerDID: req.ViewerDID,
+
}
+
+
return s.service.GetComments(ctx, serviceReq)
+
}
+
+
type testGetCommentsRequest struct {
+
Cursor *string
+
ViewerDID *string
+
PostURI string
+
Sort string
+
Timeframe string
+
Depth int
+
Limit int
+
}
+
+
func setupCommentServiceAdapter(db *sql.DB) *testCommentServiceAdapter {
+
commentRepo := postgres.NewCommentRepository(db)
+
postRepo := postgres.NewPostRepository(db)
+
userRepo := postgres.NewUserRepository(db)
+
communityRepo := postgres.NewCommunityRepository(db)
+
service := comments.NewCommentService(commentRepo, userRepo, postRepo, communityRepo)
+
return &testCommentServiceAdapter{service: service}
+
}
+
+
// Helper: Simple HTTP handler wrapper for testing
+
type testGetCommentsHandler struct {
+
service *testCommentServiceAdapter
+
}
+
+
func (h *testGetCommentsHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+
if r.Method != http.MethodGet {
+
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
+
return
+
}
+
+
query := r.URL.Query()
+
post := query.Get("post")
+
+
if post == "" {
+
http.Error(w, "post parameter is required", http.StatusBadRequest)
+
return
+
}
+
+
sort := query.Get("sort")
+
if sort == "" {
+
sort = "hot"
+
}
+
+
depth := 10
+
if d := query.Get("depth"); d != "" {
+
if _, err := fmt.Sscanf(d, "%d", &depth); err != nil {
+
http.Error(w, "invalid depth", http.StatusBadRequest)
+
return
+
}
+
}
+
+
limit := 50
+
if l := query.Get("limit"); l != "" {
+
if _, err := fmt.Sscanf(l, "%d", &limit); err != nil {
+
http.Error(w, "invalid limit", http.StatusBadRequest)
+
return
+
}
+
}
+
+
req := &testGetCommentsRequest{
+
PostURI: post,
+
Sort: sort,
+
Depth: depth,
+
Limit: limit,
+
}
+
+
resp, err := h.service.GetComments(r, req)
+
if err != nil {
+
http.Error(w, err.Error(), http.StatusInternalServerError)
+
return
+
}
+
+
w.Header().Set("Content-Type", "application/json")
+
w.WriteHeader(http.StatusOK)
+
_ = json.NewEncoder(w).Encode(resp)
+
}
+3 -6
tests/integration/community_consumer_test.go
···
// mockIdentityResolver is a test double for identity resolution
type mockIdentityResolver struct {
-
// Map of DID -> handle for successful resolutions
resolutions map[string]string
-
// If true, Resolve returns an error
-
shouldFail bool
-
// Track calls to verify invocation
-
callCount int
-
lastDID string
}
func newMockIdentityResolver() *mockIdentityResolver {
···
// mockIdentityResolver is a test double for identity resolution
type mockIdentityResolver struct {
resolutions map[string]string
+
lastDID string
+
callCount int
+
shouldFail bool
}
func newMockIdentityResolver() *mockIdentityResolver {
-12
tests/integration/user_test.go
···
return db
}
-
// setupIdentityResolver creates an identity resolver configured for local PLC testing
-
func setupIdentityResolver(db *sql.DB) interface{ Resolve(context.Context, string) (*identity.Identity, error) } {
-
plcURL := os.Getenv("PLC_DIRECTORY_URL")
-
if plcURL == "" {
-
plcURL = "http://localhost:3002" // Local PLC directory
-
}
-
-
config := identity.DefaultConfig()
-
config.PLCURL = plcURL
-
return identity.NewResolver(db, config)
-
}
-
// generateTestDID generates a unique test DID for integration tests
// V2.0: No longer uses DID generator - just creates valid did:plc strings
func generateTestDID(suffix string) string {
···
return db
}
// generateTestDID generates a unique test DID for integration tests
// V2.0: No longer uses DID generator - just creates valid did:plc strings
func generateTestDID(suffix string) string {
+6 -6
tests/lexicon_validation_test.go
···
// Test specific cross-references that should work
crossRefs := map[string]string{
-
"social.coves.richtext.facet#byteSlice": "byteSlice definition in facet schema",
-
"social.coves.community.rules#rule": "rule definition in community rules",
-
"social.coves.actor.defs#profileView": "profileView definition in actor defs",
-
"social.coves.actor.defs#profileStats": "profileStats definition in actor defs",
-
"social.coves.actor.defs#viewerState": "viewerState definition in actor defs",
-
"social.coves.community.defs#communityView": "communityView definition in community defs",
"social.coves.community.defs#communityStats": "communityStats definition in community defs",
}
···
// Test specific cross-references that should work
crossRefs := map[string]string{
+
"social.coves.richtext.facet#byteSlice": "byteSlice definition in facet schema",
+
"social.coves.community.rules#rule": "rule definition in community rules",
+
"social.coves.actor.defs#profileView": "profileView definition in actor defs",
+
"social.coves.actor.defs#profileStats": "profileStats definition in actor defs",
+
"social.coves.actor.defs#viewerState": "viewerState definition in actor defs",
+
"social.coves.community.defs#communityView": "communityView definition in community defs",
"social.coves.community.defs#communityStats": "communityStats definition in community defs",
}