···11+# Comment System Implementation
22+33+## Overview
44+55+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.
66+77+**Implementation Date:** November 4-6, 2025
88+**Status:** ✅ Phase 1 & 2A Complete - Production-Ready with All PR Fixes
99+**Test Coverage:** 29 integration tests (18 indexing + 11 query), all passing
1010+**Last Updated:** November 6, 2025 (Final PR review fixes complete - lexicon compliance, data integrity, SQL correctness)
1111+1212+---
1313+1414+## Development Phases
1515+1616+This implementation follows a phased approach for maintainability and proper scoping:
1717+1818+### ✅ Phase 1: Indexing Infrastructure (Current - COMPLETE)
1919+**What was built:**
2020+- Jetstream consumer for indexing comment CREATE/UPDATE/DELETE events
2121+- PostgreSQL schema with proper indexes and denormalized counts
2222+- Repository layer with comprehensive query methods
2323+- Atomic parent count updates (posts.comment_count, comments.reply_count)
2424+- Out-of-order event handling with reconciliation
2525+- Soft delete support preserving thread structure
2626+- Full integration test coverage (20 tests)
2727+2828+**What works:**
2929+- Comments are indexed from Jetstream firehose as users create them
3030+- Threading relationships tracked (root + parent references)
3131+- Parent counts automatically maintained
3232+- Comment updates and deletes processed correctly
3333+- Out-of-order events reconciled automatically
3434+3535+**What's NOT in this phase:**
3636+- ❌ No HTTP API endpoints for querying comments
3737+- ❌ No service layer (repository is sufficient for indexing)
3838+- ❌ No rate limiting or auth middleware
3939+- ❌ No API documentation
4040+4141+### ✅ Phase 2A: Query API - COMPLETE (November 5, 2025)
4242+4343+**What was built:**
4444+- Lexicon definitions: `social.coves.community.comment.defs` and `getComments`
4545+- Database query methods with Lemmy hot ranking algorithm
4646+- Service layer with iterative loading strategy for nested replies
4747+- XRPC HTTP handler with optional authentication
4848+- Comprehensive integration test suite (11 test scenarios)
4949+5050+**What works:**
5151+- Fetch comments on any post with sorting (hot/top/new)
5252+- Nested replies up to configurable depth (default 10, max 100)
5353+- Lemmy hot ranking: `log(greatest(2, score + 2)) / power(time_decay, 1.8)`
5454+- Cursor-based pagination for stable scrolling
5555+- Optional authentication for viewer state (stubbed for Phase 2B)
5656+- Timeframe filtering for "top" sort (hour/day/week/month/year/all)
5757+5858+**Endpoints:**
5959+- `GET /xrpc/social.coves.community.comment.getComments`
6060+ - Required: `post` (AT-URI)
6161+ - Optional: `sort` (hot/top/new), `depth` (0-100), `limit` (1-100), `cursor`, `timeframe`
6262+ - Returns: Array of `threadViewComment` with nested replies + post context
6363+ - Supports Bearer token for authenticated requests (viewer state)
6464+6565+**Files created (9):**
6666+1. `internal/atproto/lexicon/social/coves/community/comment/defs.json` - View definitions
6767+2. `internal/atproto/lexicon/social/coves/community/comment/getComments.json` - Query endpoint
6868+3. `internal/core/comments/comment_service.go` - Business logic layer
6969+4. `internal/core/comments/view_models.go` - API response types
7070+5. `internal/api/handlers/comments/get_comments.go` - HTTP handler
7171+6. `internal/api/handlers/comments/errors.go` - Error handling utilities
7272+7. `internal/api/handlers/comments/middleware.go` - Auth middleware
7373+8. `internal/api/handlers/comments/service_adapter.go` - Service layer adapter
7474+9. `tests/integration/comment_query_test.go` - Integration tests
7575+7676+**Files modified (7):**
7777+1. `internal/db/postgres/comment_repo.go` - Added query methods (~450 lines), fixed INNER→LEFT JOIN, fixed window function SQL
7878+2. `internal/core/comments/interfaces.go` - Added service interface
7979+3. `internal/core/comments/comment.go` - Added CommenterHandle field
8080+4. `internal/core/comments/errors.go` - Added IsValidationError helper
8181+5. `cmd/server/main.go` - Wired up routes and service with all repositories
8282+6. `tests/integration/comment_query_test.go` - Updated test helpers for new service signature
8383+7. `docs/COMMENT_SYSTEM_IMPLEMENTATION.md` - This document
8484+8585+**Total new code:** ~2,400 lines
8686+8787+**Test coverage:**
8888+- 11 integration test scenarios covering:
8989+ - Basic fetch, nested replies, depth limits
9090+ - Hot/top/new sorting algorithms
9191+ - Pagination with cursor stability
9292+ - Empty threads, deleted comments
9393+ - Invalid input handling
9494+ - HTTP handler end-to-end
9595+- Repository layer tested (hot ranking formula, pagination)
9696+- Service layer tested (threading, depth limits)
9797+- Handler tested (input validation, error cases)
9898+- All tests passing ✅
9999+100100+### 🔒 Production Hardening (PR Review Fixes - November 5, 2025)
101101+102102+After initial implementation, a thorough PR review identified several critical issues that were addressed before production deployment:
103103+104104+#### Critical Issues Fixed
105105+106106+**1. N+1 Query Problem (99.7% reduction in queries)**
107107+- **Problem:** Nested reply loading made separate DB queries for each comment's children
108108+- **Impact:** Could execute 1,551 queries for a post with 50 comments at depth 3
109109+- **Solution:** Implemented batch loading with PostgreSQL window functions
110110+ - Added `ListByParentsBatch()` method using `ROW_NUMBER() OVER (PARTITION BY parent_uri)`
111111+ - Refactored `buildThreadViews()` to collect parent URIs per level and fetch in one query
112112+ - **Result:** Reduced from 1,551 queries → 4 queries (1 per depth level)
113113+- **Files:** `internal/core/comments/interfaces.go`, `internal/db/postgres/comment_repo.go`, `internal/core/comments/comment_service.go`
114114+115115+**2. Post Not Found Returns 500 Instead of 404**
116116+- **Problem:** When fetching comments for non-existent post, service returned wrapped `posts.ErrNotFound` which handler didn't recognize
117117+- **Impact:** Clients got HTTP 500 instead of proper HTTP 404
118118+- **Solution:** Added error translation in service layer
119119+ ```go
120120+ if posts.IsNotFound(err) {
121121+ return nil, ErrRootNotFound // Recognized by comments.IsNotFound()
122122+ }
123123+ ```
124124+- **File:** `internal/core/comments/comment_service.go:68-72`
125125+126126+#### Important Issues Fixed
127127+128128+**3. Missing Endpoint-Specific Rate Limiting**
129129+- **Problem:** Comment queries with deep nesting expensive but only protected by global 100 req/min limit
130130+- **Solution:** Added dedicated rate limiter at 20 req/min for comment endpoint
131131+- **File:** `cmd/server/main.go:429-439`
132132+133133+**4. Unbounded Cursor Size (DoS Vector)**
134134+- **Problem:** No validation before base64 decoding - attacker could send massive cursor string
135135+- **Solution:** Added 1024-byte max size check before decoding
136136+- **File:** `internal/db/postgres/comment_repo.go:547-551`
137137+138138+**5. Missing Query Timeout**
139139+- **Problem:** Deep nested queries could run indefinitely
140140+- **Solution:** Added 10-second context timeout to `GetComments()`
141141+- **File:** `internal/core/comments/comment_service.go:62-64`
142142+143143+**6. Post View Not Populated (P0 Blocker)**
144144+- **Problem:** Lexicon marked `post` field as required but response always returned `null`
145145+- **Impact:** Violated schema contract, would break client deserialization
146146+- **Solution:**
147147+ - Updated service to accept `posts.Repository` instead of `interface{}`
148148+ - Added `buildPostView()` method to construct post views with author/community/stats
149149+ - Fetch post before returning response
150150+- **Files:** `internal/core/comments/comment_service.go:33-36`, `:66-73`, `:224-274`
151151+152152+**7. Missing Record Fields (P0 Blocker)**
153153+- **Problem:** Both `postView.record` and `commentView.record` fields were null despite lexicon marking them as required
154154+- **Impact:** Violated lexicon contract, would break strict client deserialization
155155+- **Solution:**
156156+ - Added `buildPostRecord()` method to construct minimal PostRecord from Post entity
157157+ - Added `buildCommentRecord()` method to construct minimal CommentRecord from Comment entity
158158+ - Both methods populate required fields (type, reply refs, content, timestamps)
159159+ - Added TODOs for Phase 2C to unmarshal JSON fields (embed, facets, labels)
160160+- **Files:** `internal/core/comments/comment_service.go:260-288`, `:366-386`
161161+162162+**8. Handle/Name Format Violations (P0 & Important)**
163163+- **Problem:**
164164+ - `postView.author.handle` contained DID instead of proper handle (violates `format:"handle"`)
165165+ - `postView.community.name` contained DID instead of community name
166166+- **Impact:** Lexicon format constraints violated, poor UX showing DIDs instead of readable names
167167+- **Solution:**
168168+ - Added `users.UserRepository` to service for author handle hydration
169169+ - Added `communities.Repository` to service for community name hydration
170170+ - Updated `buildPostView()` to fetch user and community records with DID fallback
171171+ - Log warnings for missing records but don't fail entire request
172172+- **Files:** `internal/core/comments/comment_service.go:34-37`, `:292-325`, `cmd/server/main.go:297`
173173+174174+**9. Data Loss from INNER JOIN (P1 Critical)**
175175+- **Problem:** Three query methods used `INNER JOIN users` which dropped comments when user not indexed yet
176176+- **Impact:** New user's first comments would disappear until user consumer caught up (violates out-of-order design)
177177+- **Solution:**
178178+ - Changed `INNER JOIN users` → `LEFT JOIN users` in all three methods
179179+ - Added `COALESCE(u.handle, c.commenter_did)` to gracefully fall back to DID
180180+ - Preserves all comments while still hydrating handles when available
181181+- **Files:** `internal/db/postgres/comment_repo.go:396`, `:407`, `:415`, `:694-706`, `:761-836`
182182+183183+**10. Window Function SQL Bug (P0 Critical)**
184184+- **Problem:** `ListByParentsBatch` used `ORDER BY hot_rank DESC` in window function, but PostgreSQL doesn't allow SELECT aliases in window ORDER BY
185185+- **Impact:** SQL error "column hot_rank does not exist" caused silent failure, dropping ALL nested replies in hot sort mode
186186+- **Solution:**
187187+ - Created separate `windowOrderBy` variable that inlines full hot_rank formula
188188+ - PostgreSQL evaluates window ORDER BY before SELECT, so must use full expression
189189+ - Hot sort now works correctly with nested replies
190190+- **Files:** `internal/db/postgres/comment_repo.go:776`, `:808`
191191+- **Critical Note:** This affected default sorting mode (hot) and would have broken production UX
192192+193193+#### Documentation Added
194194+195195+**11. Hot Rank Caching Strategy**
196196+- Documented when and how to implement cached hot rank column
197197+- Specified observability metrics to monitor (p95 latency, CPU usage)
198198+- Documented trade-offs between cached vs on-demand computation
199199+200200+**Test Coverage:**
201201+- All fixes verified with existing integration test suite
202202+- Added test cases for error handling scenarios
203203+- All integration tests passing (comment_query_test.go: 11 tests)
204204+205205+**Rationale for phased approach:**
206206+1. **Separation of concerns**: Indexing and querying are distinct responsibilities
207207+2. **Testability**: Phase 1 can be fully tested without API layer
208208+3. **Incremental delivery**: Indexing can run in production while API is developed
209209+4. **Scope management**: Prevents feature creep and allows focused code review
210210+211211+---
212212+213213+## Hot Ranking Algorithm (Lemmy-Based)
214214+215215+### Formula
216216+217217+```sql
218218+log(greatest(2, score + 2)) /
219219+ power(((EXTRACT(EPOCH FROM (NOW() - created_at)) / 3600) + 2), 1.8)
220220+```
221221+222222+### Explanation
223223+224224+**Components:**
225225+- `greatest(2, score + 2)`: Ensures log input never goes below 2
226226+ - Prevents negative log values for heavily downvoted comments
227227+ - Score of -5 → log(2), same as score of 0
228228+ - Prevents brigading from creating "anti-viral" comments
229229+230230+- `power(..., 1.8)`: Time decay exponent
231231+ - Higher than posts (1.5) for faster comment aging
232232+ - Comments should be fresher than posts
233233+234234+- `+ 2` offsets: Prevent divide-by-zero for very new comments
235235+236236+**Behavior:**
237237+- High score + old = lower rank (content ages naturally)
238238+- Low score + new = higher rank (fresh content gets visibility)
239239+- Negative scores don't break the formula (bounded at log(2))
240240+241241+### Sort Modes
242242+243243+**Hot (default):**
244244+```sql
245245+ORDER BY hot_rank DESC, score DESC, created_at DESC
246246+```
247247+248248+**Top (with timeframe):**
249249+```sql
250250+WHERE created_at >= NOW() - INTERVAL '1 day'
251251+ORDER BY score DESC, created_at DESC
252252+```
253253+254254+**New (chronological):**
255255+```sql
256256+ORDER BY created_at DESC
257257+```
258258+259259+### Path-Based Ordering
260260+261261+Comments are ordered within their tree level:
262262+```sql
263263+ORDER BY
264264+ path ASC, -- Maintains parent-child structure
265265+ hot_rank DESC, -- Sorts siblings by rank
266266+ score DESC, -- Tiebreaker
267267+ created_at DESC -- Final tiebreaker
268268+```
269269+270270+**Result:** Siblings compete with siblings, but children never outrank their parent.
271271+272272+---
273273+274274+## Architecture
275275+276276+### Data Flow
277277+278278+```
279279+Client → User's PDS → Jetstream Firehose → Comment Consumer → PostgreSQL AppView
280280+ ↓
281281+ Atomic updates to parent counts
282282+ (posts.comment_count OR comments.reply_count)
283283+```
284284+285285+### Key Design Principles
286286+287287+1. **User-Owned Records**: Comments live in user repositories (like votes), not community repositories (like posts)
288288+2. **atProto Native**: Uses `com.atproto.repo.createRecord/updateRecord/deleteRecord`
289289+3. **Threading via Strong References**: Root + parent system allows unlimited nesting depth
290290+4. **Out-of-Order Indexing**: No foreign key constraints to allow Jetstream events to arrive in any order
291291+5. **Idempotent Operations**: Safe for Jetstream replays and duplicate events
292292+6. **Atomic Count Updates**: Database transactions ensure consistency
293293+7. **Soft Deletes**: Preserves thread structure when comments are deleted
294294+295295+---
296296+297297+## Implementation Details
298298+299299+### 1. Lexicon Definition
300300+301301+**Location:** `internal/atproto/lexicon/social/coves/feed/comment.json`
302302+303303+The lexicon was already defined and follows atProto best practices:
304304+305305+```json
306306+{
307307+ "lexicon": 1,
308308+ "id": "social.coves.feed.comment",
309309+ "defs": {
310310+ "main": {
311311+ "type": "record",
312312+ "key": "tid",
313313+ "required": ["reply", "content", "createdAt"],
314314+ "properties": {
315315+ "reply": {
316316+ "type": "ref",
317317+ "ref": "#replyRef",
318318+ "description": "Reference to the post and parent being replied to"
319319+ },
320320+ "content": {
321321+ "type": "string",
322322+ "maxGraphemes": 3000,
323323+ "maxLength": 30000
324324+ },
325325+ "facets": { /* Rich text annotations */ },
326326+ "embed": { /* Images, quoted posts */ },
327327+ "langs": { /* ISO 639-1 language codes */ },
328328+ "labels": { /* Self-applied content labels */ },
329329+ "createdAt": { /* RFC3339 timestamp */ }
330330+ }
331331+ },
332332+ "replyRef": {
333333+ "required": ["root", "parent"],
334334+ "properties": {
335335+ "root": {
336336+ "type": "ref",
337337+ "ref": "com.atproto.repo.strongRef",
338338+ "description": "Strong reference to the original post"
339339+ },
340340+ "parent": {
341341+ "type": "ref",
342342+ "ref": "com.atproto.repo.strongRef",
343343+ "description": "Strong reference to immediate parent (post or comment)"
344344+ }
345345+ }
346346+ }
347347+ }
348348+}
349349+```
350350+351351+**Threading Model:**
352352+- `root`: Always points to the original post that started the thread
353353+- `parent`: Points to the immediate parent (can be a post or another comment)
354354+- This enables unlimited nested threading while maintaining the root reference
355355+356356+---
357357+358358+### 2. Database Schema
359359+360360+**Migration:** `internal/db/migrations/016_create_comments_table.sql`
361361+362362+```sql
363363+CREATE TABLE comments (
364364+ id BIGSERIAL PRIMARY KEY,
365365+ uri TEXT UNIQUE NOT NULL, -- AT-URI (at://commenter_did/social.coves.feed.comment/rkey)
366366+ cid TEXT NOT NULL, -- Content ID
367367+ rkey TEXT NOT NULL, -- Record key (TID)
368368+ commenter_did TEXT NOT NULL, -- User who commented (from AT-URI repo field)
369369+370370+ -- Threading structure (reply references)
371371+ root_uri TEXT NOT NULL, -- Strong reference to original post
372372+ root_cid TEXT NOT NULL, -- CID of root post (version pinning)
373373+ parent_uri TEXT NOT NULL, -- Strong reference to immediate parent
374374+ parent_cid TEXT NOT NULL, -- CID of parent (version pinning)
375375+376376+ -- Content
377377+ content TEXT NOT NULL, -- Comment text (max 3000 graphemes, 30000 bytes)
378378+ content_facets JSONB, -- Rich text facets
379379+ embed JSONB, -- Embedded content (images, quoted posts)
380380+ content_labels JSONB, -- Self-applied labels (com.atproto.label.defs#selfLabels)
381381+ langs TEXT[], -- Languages (ISO 639-1, max 3)
382382+383383+ -- Timestamps
384384+ created_at TIMESTAMPTZ NOT NULL, -- Commenter's timestamp from record
385385+ indexed_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
386386+ deleted_at TIMESTAMPTZ, -- Soft delete
387387+388388+ -- Stats (denormalized for performance)
389389+ upvote_count INT NOT NULL DEFAULT 0, -- Comments CAN be voted on
390390+ downvote_count INT NOT NULL DEFAULT 0,
391391+ score INT NOT NULL DEFAULT 0, -- upvote_count - downvote_count
392392+ reply_count INT NOT NULL DEFAULT 0 -- Number of direct replies
393393+);
394394+```
395395+396396+**Key Indexes:**
397397+```sql
398398+-- Threading queries (most important for UX)
399399+CREATE INDEX idx_comments_root ON comments(root_uri, created_at DESC)
400400+ WHERE deleted_at IS NULL;
401401+CREATE INDEX idx_comments_parent ON comments(parent_uri, created_at DESC)
402402+ WHERE deleted_at IS NULL;
403403+CREATE INDEX idx_comments_parent_score ON comments(parent_uri, score DESC, created_at DESC)
404404+ WHERE deleted_at IS NULL;
405405+406406+-- User queries
407407+CREATE INDEX idx_comments_commenter ON comments(commenter_did, created_at DESC);
408408+409409+-- Vote targeting
410410+CREATE INDEX idx_comments_uri_active ON comments(uri)
411411+ WHERE deleted_at IS NULL;
412412+```
413413+414414+**Design Decisions:**
415415+- **No FK on `commenter_did`**: Allows out-of-order Jetstream indexing (comment events may arrive before user events)
416416+- **Soft delete pattern**: `deleted_at IS NULL` in indexes for performance
417417+- **Vote counts included**: The vote lexicon explicitly allows voting on comments (not just posts)
418418+- **StrongRef with CID**: Version pinning prevents confusion when parent content changes
419419+420420+---
421421+422422+### 3. Domain Layer
423423+424424+#### Comment Entity
425425+**File:** `internal/core/comments/comment.go`
426426+427427+```go
428428+type Comment struct {
429429+ ID int64
430430+ URI string
431431+ CID string
432432+ RKey string
433433+ CommenterDID string
434434+435435+ // Threading
436436+ RootURI string
437437+ RootCID string
438438+ ParentURI string
439439+ ParentCID string
440440+441441+ // Content
442442+ Content string
443443+ ContentFacets *string
444444+ Embed *string
445445+ ContentLabels *string
446446+ Langs []string
447447+448448+ // Timestamps
449449+ CreatedAt time.Time
450450+ IndexedAt time.Time
451451+ DeletedAt *time.Time
452452+453453+ // Stats
454454+ UpvoteCount int
455455+ DownvoteCount int
456456+ Score int
457457+ ReplyCount int
458458+}
459459+```
460460+461461+#### Repository Interface
462462+**File:** `internal/core/comments/interfaces.go`
463463+464464+```go
465465+type Repository interface {
466466+ Create(ctx context.Context, comment *Comment) error
467467+ Update(ctx context.Context, comment *Comment) error
468468+ GetByURI(ctx context.Context, uri string) (*Comment, error)
469469+ Delete(ctx context.Context, uri string) error
470470+471471+ // Threading queries
472472+ ListByRoot(ctx context.Context, rootURI string, limit, offset int) ([]*Comment, error)
473473+ ListByParent(ctx context.Context, parentURI string, limit, offset int) ([]*Comment, error)
474474+ CountByParent(ctx context.Context, parentURI string) (int, error)
475475+476476+ // User queries
477477+ ListByCommenter(ctx context.Context, commenterDID string, limit, offset int) ([]*Comment, error)
478478+}
479479+```
480480+481481+#### Error Types
482482+**File:** `internal/core/comments/errors.go`
483483+484484+Standard error types following the vote system pattern, with helper functions `IsNotFound()` and `IsConflict()`.
485485+486486+---
487487+488488+### 4. Repository Implementation
489489+490490+**File:** `internal/db/postgres/comment_repo.go`
491491+492492+#### Idempotent Create Pattern
493493+```go
494494+func (r *postgresCommentRepo) Create(ctx context.Context, comment *Comment) error {
495495+ query := `
496496+ INSERT INTO comments (...)
497497+ VALUES (...)
498498+ ON CONFLICT (uri) DO NOTHING
499499+ RETURNING id, indexed_at
500500+ `
501501+502502+ err := r.db.QueryRowContext(ctx, query, ...).Scan(&comment.ID, &comment.IndexedAt)
503503+504504+ // ON CONFLICT DO NOTHING returns no rows if duplicate
505505+ if err == sql.ErrNoRows {
506506+ return nil // Already exists - OK for Jetstream replays
507507+ }
508508+509509+ return err
510510+}
511511+```
512512+513513+#### Update Preserving Vote Counts
514514+```go
515515+func (r *postgresCommentRepo) Update(ctx context.Context, comment *Comment) error {
516516+ query := `
517517+ UPDATE comments
518518+ SET cid = $1, content = $2, content_facets = $3,
519519+ embed = $4, content_labels = $5, langs = $6
520520+ WHERE uri = $7 AND deleted_at IS NULL
521521+ RETURNING id, indexed_at, created_at,
522522+ upvote_count, downvote_count, score, reply_count
523523+ `
524524+525525+ // Vote counts and created_at are preserved (not in SET clause)
526526+ err := r.db.QueryRowContext(ctx, query, ...).Scan(...)
527527+ return err
528528+}
529529+```
530530+531531+#### Soft Delete
532532+```go
533533+func (r *postgresCommentRepo) Delete(ctx context.Context, uri string) error {
534534+ query := `
535535+ UPDATE comments
536536+ SET deleted_at = NOW()
537537+ WHERE uri = $1 AND deleted_at IS NULL
538538+ `
539539+540540+ result, err := r.db.ExecContext(ctx, query, uri)
541541+ // Idempotent: Returns success even if already deleted
542542+ return err
543543+}
544544+```
545545+546546+---
547547+548548+### 5. Jetstream Consumer
549549+550550+**File:** `internal/atproto/jetstream/comment_consumer.go`
551551+552552+#### Event Handler
553553+```go
554554+func (c *CommentEventConsumer) HandleEvent(ctx context.Context, event *JetstreamEvent) error {
555555+ if event.Kind != "commit" || event.Commit == nil {
556556+ return nil
557557+ }
558558+559559+ if event.Commit.Collection == "social.coves.feed.comment" {
560560+ switch event.Commit.Operation {
561561+ case "create":
562562+ return c.createComment(ctx, event.Did, commit)
563563+ case "update":
564564+ return c.updateComment(ctx, event.Did, commit)
565565+ case "delete":
566566+ return c.deleteComment(ctx, event.Did, commit)
567567+ }
568568+ }
569569+570570+ return nil
571571+}
572572+```
573573+574574+#### Atomic Count Updates
575575+```go
576576+func (c *CommentEventConsumer) indexCommentAndUpdateCounts(ctx, comment *Comment) error {
577577+ tx, _ := c.db.BeginTx(ctx, nil)
578578+ defer tx.Rollback()
579579+580580+ // 1. Insert comment (idempotent)
581581+ err = tx.QueryRowContext(ctx, `
582582+ INSERT INTO comments (...) VALUES (...)
583583+ ON CONFLICT (uri) DO NOTHING
584584+ RETURNING id
585585+ `).Scan(&commentID)
586586+587587+ if err == sql.ErrNoRows {
588588+ tx.Commit()
589589+ return nil // Already indexed
590590+ }
591591+592592+ // 2. Update parent counts atomically
593593+ // Try posts table first
594594+ tx.ExecContext(ctx, `
595595+ UPDATE posts
596596+ SET comment_count = comment_count + 1
597597+ WHERE uri = $1 AND deleted_at IS NULL
598598+ `, comment.ParentURI)
599599+600600+ // If no post updated, parent is probably a comment
601601+ tx.ExecContext(ctx, `
602602+ UPDATE comments
603603+ SET reply_count = reply_count + 1
604604+ WHERE uri = $1 AND deleted_at IS NULL
605605+ `, comment.ParentURI)
606606+607607+ return tx.Commit()
608608+}
609609+```
610610+611611+#### Security Validation
612612+```go
613613+func (c *CommentEventConsumer) validateCommentEvent(ctx, repoDID string, comment *CommentRecord) error {
614614+ // Comments MUST come from user repositories (repo owner = commenter DID)
615615+ if !strings.HasPrefix(repoDID, "did:") {
616616+ return fmt.Errorf("invalid commenter DID format: %s", repoDID)
617617+ }
618618+619619+ // Content is required
620620+ if comment.Content == "" {
621621+ return fmt.Errorf("comment content is required")
622622+ }
623623+624624+ // Reply references must have both URI and CID
625625+ if comment.Reply.Root.URI == "" || comment.Reply.Root.CID == "" {
626626+ return fmt.Errorf("invalid root reference: must have both URI and CID")
627627+ }
628628+629629+ if comment.Reply.Parent.URI == "" || comment.Reply.Parent.CID == "" {
630630+ return fmt.Errorf("invalid parent reference: must have both URI and CID")
631631+ }
632632+633633+ return nil
634634+}
635635+```
636636+637637+**Security Note:** We do NOT verify that the user exists in the AppView because:
638638+1. Comment events may arrive before user events in Jetstream (race condition)
639639+2. The comment came from the user's PDS repository (authenticated by PDS)
640640+3. No database FK constraint allows out-of-order indexing
641641+4. Orphaned comments (from never-indexed users) are harmless
642642+643643+---
644644+645645+### 6. WebSocket Connector
646646+647647+**File:** `internal/atproto/jetstream/comment_jetstream_connector.go`
648648+649649+Follows the standard Jetstream connector pattern with:
650650+- Auto-reconnect on errors (5-second retry)
651651+- Ping/pong keepalive (30-second ping, 60-second read deadline)
652652+- Graceful shutdown via context cancellation
653653+- Subscribes to: `wantedCollections=social.coves.feed.comment`
654654+655655+---
656656+657657+### 7. Server Integration
658658+659659+**File:** `cmd/server/main.go` (lines 289-396)
660660+661661+```go
662662+// Initialize comment repository
663663+commentRepo := postgresRepo.NewCommentRepository(db)
664664+log.Println("✅ Comment repository initialized (Jetstream indexing only)")
665665+666666+// Start Jetstream consumer for comments
667667+commentJetstreamURL := os.Getenv("COMMENT_JETSTREAM_URL")
668668+if commentJetstreamURL == "" {
669669+ commentJetstreamURL = "ws://localhost:6008/subscribe?wantedCollections=social.coves.feed.comment"
670670+}
671671+672672+commentEventConsumer := jetstream.NewCommentEventConsumer(commentRepo, db)
673673+commentJetstreamConnector := jetstream.NewCommentJetstreamConnector(commentEventConsumer, commentJetstreamURL)
674674+675675+go func() {
676676+ if startErr := commentJetstreamConnector.Start(ctx); startErr != nil {
677677+ log.Printf("Comment Jetstream consumer stopped: %v", startErr)
678678+ }
679679+}()
680680+681681+log.Printf("Started Jetstream comment consumer: %s", commentJetstreamURL)
682682+log.Println(" - Indexing: social.coves.feed.comment CREATE/UPDATE/DELETE operations")
683683+log.Println(" - Updating: Post comment counts and comment reply counts atomically")
684684+```
685685+686686+---
687687+688688+## Testing
689689+690690+### Test Suite
691691+692692+**File:** `tests/integration/comment_consumer_test.go`
693693+694694+**Test Coverage:** 6 test suites, 18 test cases, **100% passing**
695695+696696+#### 1. TestCommentConsumer_CreateComment
697697+- ✅ Create comment on post
698698+- ✅ Verify comment is indexed correctly
699699+- ✅ Verify post comment count is incremented
700700+- ✅ Idempotent create - duplicate events don't double-count
701701+702702+#### 2. TestCommentConsumer_Threading
703703+- ✅ Create first-level comment (reply to post)
704704+- ✅ Create second-level comment (reply to comment)
705705+- ✅ Verify both comments have same root (original post)
706706+- ✅ Verify parent relationships are correct
707707+- ✅ Verify reply counts are updated
708708+- ✅ Query all comments by root (flat list)
709709+- ✅ Query direct replies to post
710710+- ✅ Query direct replies to comment
711711+712712+#### 3. TestCommentConsumer_UpdateComment
713713+- ✅ Create comment with initial content
714714+- ✅ Manually set vote counts to simulate votes
715715+- ✅ Update comment content
716716+- ✅ Verify content is updated
717717+- ✅ Verify CID is updated
718718+- ✅ **Verify vote counts are preserved**
719719+- ✅ **Verify created_at is preserved**
720720+721721+#### 4. TestCommentConsumer_DeleteComment
722722+- ✅ Create comment
723723+- ✅ Delete comment (soft delete)
724724+- ✅ Verify deleted_at is set
725725+- ✅ Verify post comment count is decremented
726726+- ✅ Idempotent delete - duplicate deletes don't double-decrement
727727+728728+#### 5. TestCommentConsumer_SecurityValidation
729729+- ✅ Reject comment with empty content
730730+- ✅ Reject comment with invalid root reference (missing URI)
731731+- ✅ Reject comment with invalid parent reference (missing CID)
732732+- ✅ Reject comment with invalid DID format
733733+734734+#### 6. TestCommentRepository_Queries
735735+- ✅ ListByRoot returns all comments in thread (4 comments)
736736+- ✅ ListByParent returns direct replies to post (2 comments)
737737+- ✅ ListByParent returns direct replies to comment (2 comments)
738738+- ✅ CountByParent returns correct counts
739739+- ✅ ListByCommenter returns all user's comments
740740+741741+### Test Results
742742+743743+```
744744+=== Test Summary ===
745745+PASS: TestCommentConsumer_CreateComment (0.02s)
746746+PASS: TestCommentConsumer_Threading (0.02s)
747747+PASS: TestCommentConsumer_UpdateComment (0.02s)
748748+PASS: TestCommentConsumer_DeleteComment (0.02s)
749749+PASS: TestCommentConsumer_SecurityValidation (0.01s)
750750+PASS: TestCommentRepository_Queries (0.02s)
751751+752752+✅ ALL 18 TESTS PASS
753753+Total time: 0.115s
754754+```
755755+756756+---
757757+758758+## Key Features
759759+760760+### ✅ Comments ARE Votable
761761+The vote lexicon explicitly states: *"Record declaring a vote (upvote or downvote) on a **post or comment**"*
762762+763763+Comments include full vote tracking:
764764+- `upvote_count`
765765+- `downvote_count`
766766+- `score` (calculated as upvote_count - downvote_count)
767767+768768+### ✅ Comments ARE Editable
769769+Unlike votes (which are immutable), comments support UPDATE operations:
770770+- Content, facets, embed, and labels can be updated
771771+- Vote counts and created_at are preserved
772772+- CID is updated to reflect new version
773773+774774+### ✅ Threading Support
775775+Unlimited nesting depth via root + parent system:
776776+- Every comment knows its root post
777777+- Every comment knows its immediate parent
778778+- Easy to query entire threads or direct replies
779779+- Soft deletes preserve thread structure
780780+781781+### ✅ Out-of-Order Indexing
782782+No foreign key constraints allow events to arrive in any order:
783783+- Comment events may arrive before user events
784784+- Comment events may arrive before post events
785785+- All operations are idempotent
786786+- Safe for Jetstream replays
787787+788788+### ✅ Atomic Consistency
789789+Database transactions ensure counts are always accurate:
790790+- Comment creation increments parent count
791791+- Comment deletion decrements parent count
792792+- No race conditions
793793+- No orphaned counts
794794+795795+---
796796+797797+## Implementation Statistics
798798+799799+### Phase 1 - Indexing Infrastructure
800800+801801+**Files Created: 8**
802802+1. `internal/db/migrations/016_create_comments_table.sql` - 60 lines
803803+2. `internal/core/comments/comment.go` - 80 lines
804804+3. `internal/core/comments/interfaces.go` - 45 lines
805805+4. `internal/core/comments/errors.go` - 40 lines
806806+5. `internal/db/postgres/comment_repo.go` - 340 lines
807807+6. `internal/atproto/jetstream/comment_consumer.go` - 530 lines
808808+7. `internal/atproto/jetstream/comment_jetstream_connector.go` - 130 lines
809809+8. `tests/integration/comment_consumer_test.go` - 930 lines
810810+811811+**Files Modified: 1**
812812+1. `cmd/server/main.go` - Added 20 lines for Jetstream consumer
813813+814814+**Phase 1 Total:** ~2,175 lines
815815+816816+### Phase 2A - Query API
817817+818818+**Files Created: 9** (listed above in Phase 2A section)
819819+820820+**Files Modified: 6** (listed above in Phase 2A section)
821821+822822+**Phase 2A Total:** ~2,400 lines
823823+824824+### Combined Total: ~4,575 lines
825825+826826+---
827827+828828+## Reference Pattern: Vote System
829829+830830+The comment implementation closely follows the vote system pattern:
831831+832832+| Aspect | Votes | Comments |
833833+|--------|-------|----------|
834834+| **Location** | User repositories | User repositories |
835835+| **Lexicon** | `social.coves.feed.vote` | `social.coves.feed.comment` |
836836+| **Operations** | CREATE, DELETE | CREATE, UPDATE, DELETE |
837837+| **Mutability** | Immutable | Editable |
838838+| **Foreign Keys** | None (out-of-order indexing) | None (out-of-order indexing) |
839839+| **Delete Pattern** | Soft delete | Soft delete |
840840+| **Idempotency** | ON CONFLICT DO NOTHING | ON CONFLICT DO NOTHING |
841841+| **Count Updates** | Atomic transaction | Atomic transaction |
842842+| **Security** | PDS authentication | PDS authentication |
843843+844844+---
845845+846846+## Future Phases
847847+848848+### 📋 Phase 2B: Vote Integration (Planned)
849849+850850+**Scope:**
851851+- Update vote consumer to handle comment votes
852852+- Integrate `GetVoteStateForComments()` in service layer
853853+- Populate viewer.vote and viewer.voteUri in commentView
854854+- Test vote creation on comments end-to-end
855855+- Atomic updates to comments.upvote_count, downvote_count, score
856856+857857+**Dependencies:**
858858+- Phase 1 indexing (✅ Complete)
859859+- Phase 2A query API (✅ Complete)
860860+- Vote consumer (already exists for posts)
861861+862862+**Estimated effort:** 2-3 hours
863863+864864+---
865865+866866+### 📋 Phase 2C: Post/User Integration (Partially Complete)
867867+868868+**Completed (PR Review):**
869869+- ✅ Integrated post repository in comment service
870870+- ✅ Return postView in getComments response with basic fields
871871+- ✅ Populate post author DID, community DID, stats (upvotes, downvotes, score, comment count)
872872+873873+**Remaining Work:**
874874+- ❌ Integrate user repository for full AuthorView
875875+- ❌ Add display name and avatar to comment/post authors (currently returns DID as handle)
876876+- ❌ Add community name and avatar (currently returns DID as name)
877877+- ❌ Parse and include original record in commentView
878878+879879+**Dependencies:**
880880+- Phase 2A query API (✅ Complete)
881881+- Post repository integration (✅ Complete)
882882+- User repository integration (⏳ Pending)
883883+884884+**Estimated effort for remaining work:** 1-2 hours
885885+886886+---
887887+888888+### 📋 Phase 3: Advanced Features (Future)
889889+890890+#### 3A: Distinguished Comments
891891+- Moderator/admin comment flags
892892+- Priority sorting for distinguished comments
893893+- Visual indicators in UI
894894+895895+#### 3B: Comment Search & Filtering
896896+- Full-text search within threads
897897+- Filter by author, time range, score
898898+- Search across community comments
899899+900900+#### 3C: Moderation Tools
901901+- Hide/remove comments
902902+- Flag system for user reports
903903+- Moderator queue
904904+- Audit log
905905+906906+#### 3D: Notifications
907907+- Notify users of replies to their comments
908908+- Notify post authors of new comments
909909+- Mention notifications (@user)
910910+- Customizable notification preferences
911911+912912+#### 3E: Enhanced Features
913913+- Comment edit history tracking
914914+- Save/bookmark comments
915915+- Sort by "controversial" (high engagement, low score)
916916+- Collapsible comment threads
917917+- User-specific comment history API
918918+- Community-wide comment stats/analytics
919919+920920+---
921921+922922+### 📋 Phase 4: Namespace Migration (Separate Task)
923923+924924+**Scope:**
925925+- Migrate existing `social.coves.feed.comment` records to `social.coves.community.comment`
926926+- Update all AT-URIs in database
927927+- Update Jetstream consumer collection filter
928928+- Migration script with rollback capability
929929+- Zero-downtime deployment strategy
930930+931931+**Note:** Currently out of scope - will be tackled separately when needed.
932932+933933+---
934934+935935+## Performance Considerations
936936+937937+### Database Indexes
938938+939939+All critical query patterns are indexed:
940940+- **Threading queries**: `idx_comments_root`, `idx_comments_parent`
941941+- **Sorting by score**: `idx_comments_parent_score`
942942+- **User history**: `idx_comments_commenter`
943943+- **Vote targeting**: `idx_comments_uri_active`
944944+945945+### Denormalized Counts
946946+947947+Vote counts and reply counts are denormalized for performance:
948948+- Avoids `COUNT(*)` queries on large datasets
949949+- Updated atomically with comment operations
950950+- Indexed for fast sorting
951951+952952+### Pagination Support
953953+954954+All list queries support limit/offset pagination:
955955+- `ListByRoot(ctx, rootURI, limit, offset)`
956956+- `ListByParent(ctx, parentURI, limit, offset)`
957957+- `ListByCommenter(ctx, commenterDID, limit, offset)`
958958+959959+### N+1 Query Prevention
960960+961961+**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.
962962+963963+**Solution Implemented:** Batch loading strategy using window functions:
964964+1. Collect all parent URIs at each depth level
965965+2. Execute single batch query using `ListByParentsBatch()` with PostgreSQL window functions
966966+3. Group results by parent URI in memory
967967+4. Recursively process next level
968968+969969+**Performance Improvement:**
970970+- Old: 1 + N + (N × M) + (N × M × P) queries per request
971971+- New: 1 query per depth level (max 4 queries for depth 3)
972972+- Example with depth 3, 50 comments: 1,551 queries → 4 queries (99.7% reduction)
973973+974974+**Implementation Details:**
975975+```sql
976976+-- Uses ROW_NUMBER() window function to limit per parent efficiently
977977+WITH ranked_comments AS (
978978+ SELECT *,
979979+ ROW_NUMBER() OVER (
980980+ PARTITION BY parent_uri
981981+ ORDER BY hot_rank DESC
982982+ ) as rn
983983+ FROM comments
984984+ WHERE parent_uri = ANY($1)
985985+)
986986+SELECT * FROM ranked_comments WHERE rn <= $2
987987+```
988988+989989+### Hot Rank Caching Strategy
990990+991991+**Current Implementation:**
992992+Hot rank is computed on-demand for every query using the Lemmy algorithm:
993993+```sql
994994+log(greatest(2, score + 2)) /
995995+ power(((EXTRACT(EPOCH FROM (NOW() - created_at)) / 3600) + 2), 1.8)
996996+```
997997+998998+**Performance Impact:**
999999+- Computed for every comment in every hot-sorted query
10001000+- PostgreSQL handles this efficiently for moderate loads (<1000 comments per post)
10011001+- No noticeable performance degradation in testing
10021002+10031003+**Future Optimization (if needed):**
10041004+10051005+If hot rank computation becomes a bottleneck at scale:
10061006+10071007+1. **Add cached column:**
10081008+```sql
10091009+ALTER TABLE comments ADD COLUMN hot_rank_cached NUMERIC;
10101010+CREATE INDEX idx_comments_parent_hot_rank_cached
10111011+ ON comments(parent_uri, hot_rank_cached DESC)
10121012+ WHERE deleted_at IS NULL;
10131013+```
10141014+10151015+2. **Background recomputation job:**
10161016+```go
10171017+// Run every 5-15 minutes
10181018+func (j *HotRankJob) UpdateHotRanks(ctx context.Context) error {
10191019+ query := `
10201020+ UPDATE comments
10211021+ SET hot_rank_cached = log(greatest(2, score + 2)) /
10221022+ power(((EXTRACT(EPOCH FROM (NOW() - created_at)) / 3600) + 2), 1.8)
10231023+ WHERE deleted_at IS NULL
10241024+ `
10251025+ _, err := j.db.ExecContext(ctx, query)
10261026+ return err
10271027+}
10281028+```
10291029+10301030+3. **Use cached value in queries:**
10311031+```sql
10321032+SELECT * FROM comments
10331033+WHERE parent_uri = $1
10341034+ORDER BY hot_rank_cached DESC, score DESC
10351035+```
10361036+10371037+**When to implement:**
10381038+- Monitor query performance in production
10391039+- If p95 query latency > 200ms for hot-sorted queries
10401040+- If database CPU usage from hot rank computation > 20%
10411041+- Only optimize if measurements show actual bottleneck
10421042+10431043+**Trade-offs:**
10441044+- **Cached approach:** Faster queries, but ranks update every 5-15 minutes (slightly stale)
10451045+- **On-demand approach:** Always fresh ranks, slightly higher query cost
10461046+- For comment discussions, 5-15 minute staleness is acceptable (comments age slowly)
10471047+10481048+---
10491049+10501050+## Conclusion
10511051+10521052+The comment system has successfully completed **Phase 1 (Indexing)** and **Phase 2A (Query API)**, providing a production-ready threaded discussion system for Coves:
10531053+10541054+✅ **Phase 1 Complete**: Full indexing infrastructure with Jetstream consumer
10551055+✅ **Phase 2A Complete**: Query API with hot ranking, threading, and pagination
10561056+✅ **Fully Tested**: 30+ integration tests across indexing and query layers
10571057+✅ **Secure**: Input validation, parameterized queries, optional auth
10581058+✅ **Scalable**: Indexed queries, denormalized counts, cursor pagination
10591059+✅ **atProto Native**: User-owned records, Jetstream indexing, Bluesky patterns
10601060+10611061+**Next milestones:**
10621062+- Phase 2B: Vote integration for comment voting
10631063+- Phase 2C: Post/user integration for complete views
10641064+- Phase 3: Advanced features (moderation, notifications, search)
10651065+10661066+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.
10671067+10681068+---
10691069+10701070+## Appendix: Command Reference
10711071+10721072+### Run Tests
10731073+10741074+**Phase 1 - Indexing Tests:**
10751075+```bash
10761076+TEST_DATABASE_URL="postgres://test_user:test_password@localhost:5434/coves_test?sslmode=disable" \
10771077+ go test -v ./tests/integration/comment_consumer_test.go \
10781078+ ./tests/integration/user_test.go \
10791079+ ./tests/integration/helpers.go \
10801080+ -run "TestCommentConsumer" -timeout 60s
10811081+```
10821082+10831083+**Phase 2A - Query API Tests:**
10841084+```bash
10851085+TEST_DATABASE_URL="postgres://test_user:test_password@localhost:5434/coves_test?sslmode=disable" \
10861086+ go test -v ./tests/integration/comment_query_test.go \
10871087+ ./tests/integration/user_test.go \
10881088+ ./tests/integration/helpers.go \
10891089+ -run "TestCommentQuery" -timeout 120s
10901090+```
10911091+10921092+**All Comment Tests:**
10931093+```bash
10941094+TEST_DATABASE_URL="postgres://test_user:test_password@localhost:5434/coves_test?sslmode=disable" \
10951095+ go test -v ./tests/integration/comment_*.go \
10961096+ ./tests/integration/user_test.go \
10971097+ ./tests/integration/helpers.go \
10981098+ -timeout 120s
10991099+```
11001100+11011101+### Apply Migration
11021102+```bash
11031103+GOOSE_DRIVER=postgres \
11041104+GOOSE_DBSTRING="postgres://test_user:test_password@localhost:5434/coves_test?sslmode=disable" \
11051105+ goose -dir internal/db/migrations up
11061106+```
11071107+11081108+### Build Server
11091109+```bash
11101110+go build ./cmd/server
11111111+```
11121112+11131113+### Environment Variables
11141114+```bash
11151115+# Jetstream URL (optional, defaults to localhost:6008)
11161116+export COMMENT_JETSTREAM_URL="ws://localhost:6008/subscribe?wantedCollections=social.coves.feed.comment"
11171117+11181118+# Database URL
11191119+export TEST_DATABASE_URL="postgres://test_user:test_password@localhost:5434/coves_test?sslmode=disable"
11201120+```
11211121+11221122+---
11231123+11241124+**Last Updated:** November 6, 2025
11251125+**Status:** ✅ Phase 1 & 2A Complete - Production-Ready with All PR Fixes
+44
internal/api/handlers/comments/errors.go
···11+package comments
22+33+import (
44+ "Coves/internal/core/comments"
55+ "encoding/json"
66+ "log"
77+ "net/http"
88+)
99+1010+// errorResponse represents a standardized JSON error response
1111+type errorResponse struct {
1212+ Error string `json:"error"`
1313+ Message string `json:"message"`
1414+}
1515+1616+// writeError writes a JSON error response with the given status code
1717+func writeError(w http.ResponseWriter, statusCode int, errorType, message string) {
1818+ w.Header().Set("Content-Type", "application/json")
1919+ w.WriteHeader(statusCode)
2020+ if err := json.NewEncoder(w).Encode(errorResponse{
2121+ Error: errorType,
2222+ Message: message,
2323+ }); err != nil {
2424+ log.Printf("Failed to encode error response: %v", err)
2525+ }
2626+}
2727+2828+// handleServiceError maps service-layer errors to HTTP responses
2929+// This follows the error handling pattern from other handlers (post, community)
3030+func handleServiceError(w http.ResponseWriter, err error) {
3131+ switch {
3232+ case comments.IsNotFound(err):
3333+ writeError(w, http.StatusNotFound, "NotFound", err.Error())
3434+3535+ case comments.IsValidationError(err):
3636+ writeError(w, http.StatusBadRequest, "InvalidRequest", err.Error())
3737+3838+ default:
3939+ // Don't leak internal error details to clients
4040+ log.Printf("Unexpected error in comments handler: %v", err)
4141+ writeError(w, http.StatusInternalServerError, "InternalServerError",
4242+ "An internal error occurred")
4343+ }
4444+}
+167
internal/api/handlers/comments/get_comments.go
···11+// Package comments provides HTTP handlers for the comment query API.
22+// These handlers follow XRPC conventions and integrate with the comments service layer.
33+package comments
44+55+import (
66+ "Coves/internal/api/middleware"
77+ "Coves/internal/core/comments"
88+ "encoding/json"
99+ "log"
1010+ "net/http"
1111+ "strconv"
1212+)
1313+1414+// GetCommentsHandler handles comment retrieval for posts
1515+type GetCommentsHandler struct {
1616+ service Service
1717+}
1818+1919+// Service defines the interface for comment business logic
2020+// This will be implemented by the comments service layer in Phase 2
2121+type Service interface {
2222+ GetComments(r *http.Request, req *GetCommentsRequest) (*comments.GetCommentsResponse, error)
2323+}
2424+2525+// GetCommentsRequest represents the query parameters for fetching comments
2626+// Matches social.coves.feed.getComments lexicon input
2727+type GetCommentsRequest struct {
2828+ Cursor *string `json:"cursor,omitempty"`
2929+ ViewerDID *string `json:"-"`
3030+ PostURI string `json:"post"`
3131+ Sort string `json:"sort,omitempty"`
3232+ Timeframe string `json:"timeframe,omitempty"`
3333+ Depth int `json:"depth,omitempty"`
3434+ Limit int `json:"limit,omitempty"`
3535+}
3636+3737+// NewGetCommentsHandler creates a new handler for fetching comments
3838+func NewGetCommentsHandler(service Service) *GetCommentsHandler {
3939+ return &GetCommentsHandler{
4040+ service: service,
4141+ }
4242+}
4343+4444+// HandleGetComments handles GET /xrpc/social.coves.feed.getComments
4545+// Retrieves comments on a post with threading support
4646+func (h *GetCommentsHandler) HandleGetComments(w http.ResponseWriter, r *http.Request) {
4747+ // 1. Only allow GET method
4848+ if r.Method != http.MethodGet {
4949+ http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
5050+ return
5151+ }
5252+5353+ // 2. Parse query parameters
5454+ query := r.URL.Query()
5555+ post := query.Get("post")
5656+ sort := query.Get("sort")
5757+ timeframe := query.Get("timeframe")
5858+ depthStr := query.Get("depth")
5959+ limitStr := query.Get("limit")
6060+ cursor := query.Get("cursor")
6161+6262+ // 3. Validate required parameters
6363+ if post == "" {
6464+ writeError(w, http.StatusBadRequest, "InvalidRequest", "post parameter is required")
6565+ return
6666+ }
6767+6868+ // 4. Parse and validate depth with default
6969+ depth := 10 // Default depth
7070+ if depthStr != "" {
7171+ parsed, err := strconv.Atoi(depthStr)
7272+ if err != nil {
7373+ writeError(w, http.StatusBadRequest, "InvalidRequest", "depth must be a valid integer")
7474+ return
7575+ }
7676+ if parsed < 0 {
7777+ writeError(w, http.StatusBadRequest, "InvalidRequest", "depth must be non-negative")
7878+ return
7979+ }
8080+ depth = parsed
8181+ }
8282+8383+ // 5. Parse and validate limit with default and max
8484+ limit := 50 // Default limit
8585+ if limitStr != "" {
8686+ parsed, err := strconv.Atoi(limitStr)
8787+ if err != nil {
8888+ writeError(w, http.StatusBadRequest, "InvalidRequest", "limit must be a valid integer")
8989+ return
9090+ }
9191+ if parsed < 1 {
9292+ writeError(w, http.StatusBadRequest, "InvalidRequest", "limit must be positive")
9393+ return
9494+ }
9595+ if parsed > 100 {
9696+ writeError(w, http.StatusBadRequest, "InvalidRequest", "limit cannot exceed 100")
9797+ return
9898+ }
9999+ limit = parsed
100100+ }
101101+102102+ // 6. Validate sort parameter (if provided)
103103+ if sort != "" && sort != "hot" && sort != "top" && sort != "new" {
104104+ writeError(w, http.StatusBadRequest, "InvalidRequest",
105105+ "sort must be one of: hot, top, new")
106106+ return
107107+ }
108108+109109+ // 7. Validate timeframe parameter (only valid with "top" sort)
110110+ if timeframe != "" {
111111+ if sort != "top" {
112112+ writeError(w, http.StatusBadRequest, "InvalidRequest",
113113+ "timeframe can only be used with sort=top")
114114+ return
115115+ }
116116+ validTimeframes := map[string]bool{
117117+ "hour": true, "day": true, "week": true,
118118+ "month": true, "year": true, "all": true,
119119+ }
120120+ if !validTimeframes[timeframe] {
121121+ writeError(w, http.StatusBadRequest, "InvalidRequest",
122122+ "timeframe must be one of: hour, day, week, month, year, all")
123123+ return
124124+ }
125125+ }
126126+127127+ // 8. Extract viewer DID from context (set by OptionalAuth middleware)
128128+ viewerDID := middleware.GetUserDID(r)
129129+ var viewerPtr *string
130130+ if viewerDID != "" {
131131+ viewerPtr = &viewerDID
132132+ }
133133+134134+ // 9. Build service request
135135+ req := &GetCommentsRequest{
136136+ PostURI: post,
137137+ Sort: sort,
138138+ Timeframe: timeframe,
139139+ Depth: depth,
140140+ Limit: limit,
141141+ Cursor: ptrOrNil(cursor),
142142+ ViewerDID: viewerPtr,
143143+ }
144144+145145+ // 10. Call service layer
146146+ resp, err := h.service.GetComments(r, req)
147147+ if err != nil {
148148+ handleServiceError(w, err)
149149+ return
150150+ }
151151+152152+ // 11. Return JSON response
153153+ w.Header().Set("Content-Type", "application/json")
154154+ w.WriteHeader(http.StatusOK)
155155+ if err := json.NewEncoder(w).Encode(resp); err != nil {
156156+ // Log encoding errors but don't return error response (headers already sent)
157157+ log.Printf("Failed to encode comments response: %v", err)
158158+ }
159159+}
160160+161161+// ptrOrNil converts an empty string to nil pointer, otherwise returns pointer to string
162162+func ptrOrNil(s string) *string {
163163+ if s == "" {
164164+ return nil
165165+ }
166166+ return &s
167167+}
+22
internal/api/handlers/comments/middleware.go
···11+package comments
22+33+import (
44+ "Coves/internal/api/middleware"
55+ "net/http"
66+)
77+88+// OptionalAuthMiddleware wraps the existing OptionalAuth middleware from the middleware package.
99+// This ensures comment handlers can access viewer identity when available, but don't require authentication.
1010+//
1111+// Usage in router setup:
1212+//
1313+// commentHandler := comments.NewGetCommentsHandler(commentService)
1414+// router.Handle("/xrpc/social.coves.feed.getComments",
1515+// comments.OptionalAuthMiddleware(authMiddleware, commentHandler.HandleGetComments))
1616+//
1717+// The middleware extracts the viewer DID from the Authorization header if present and valid,
1818+// making it available via middleware.GetUserDID(r) in the handler.
1919+// If no valid token is present, the request continues as anonymous (empty DID).
2020+func OptionalAuthMiddleware(authMiddleware *middleware.AtProtoAuthMiddleware, next http.HandlerFunc) http.Handler {
2121+ return authMiddleware.OptionalAuth(http.HandlerFunc(next))
2222+}
+37
internal/api/handlers/comments/service_adapter.go
···11+package comments
22+33+import (
44+ "Coves/internal/core/comments"
55+ "net/http"
66+)
77+88+// ServiceAdapter adapts the core comments.Service to the handler's Service interface
99+// This bridges the gap between HTTP-layer concerns (http.Request) and domain-layer concerns (context.Context)
1010+type ServiceAdapter struct {
1111+ coreService comments.Service
1212+}
1313+1414+// NewServiceAdapter creates a new service adapter wrapping the core comment service
1515+func NewServiceAdapter(coreService comments.Service) Service {
1616+ return &ServiceAdapter{
1717+ coreService: coreService,
1818+ }
1919+}
2020+2121+// GetComments adapts the handler request to the core service request
2222+// Converts handler-specific GetCommentsRequest to core GetCommentsRequest
2323+func (a *ServiceAdapter) GetComments(r *http.Request, req *GetCommentsRequest) (*comments.GetCommentsResponse, error) {
2424+ // Convert handler request to core service request
2525+ coreReq := &comments.GetCommentsRequest{
2626+ PostURI: req.PostURI,
2727+ Sort: req.Sort,
2828+ Timeframe: req.Timeframe,
2929+ Depth: req.Depth,
3030+ Limit: req.Limit,
3131+ Cursor: req.Cursor,
3232+ ViewerDID: req.ViewerDID,
3333+ }
3434+3535+ // Call core service with request context
3636+ return a.coreService.GetComments(r.Context(), coreReq)
3737+}
+6-8
internal/atproto/jetstream/comment_consumer.go
···300300 time.Now(),
301301 commentID,
302302 )
303303-304303 if err != nil {
305304 return fmt.Errorf("failed to resurrect comment: %w", err)
306305 }
···329328 comment.Content, comment.ContentFacets, comment.Embed, comment.ContentLabels, pq.Array(comment.Langs),
330329 comment.CreatedAt, time.Now(),
331330 ).Scan(&commentID)
332332-333331 if err != nil {
334332 return fmt.Errorf("failed to insert comment: %w", err)
335333 }
···593591// CommentRecordFromJetstream represents a comment record as received from Jetstream
594592// Matches social.coves.feed.comment lexicon
595593type CommentRecordFromJetstream struct {
596596- Type string `json:"$type"`
594594+ Labels interface{} `json:"labels,omitempty"`
595595+ Embed map[string]interface{} `json:"embed,omitempty"`
597596 Reply ReplyRefFromJetstream `json:"reply"`
597597+ Type string `json:"$type"`
598598 Content string `json:"content"`
599599+ CreatedAt string `json:"createdAt"`
599600 Facets []interface{} `json:"facets,omitempty"`
600600- Embed map[string]interface{} `json:"embed,omitempty"`
601601 Langs []string `json:"langs,omitempty"`
602602- Labels interface{} `json:"labels,omitempty"`
603603- CreatedAt string `json:"createdAt"`
604602}
605603606604// ReplyRefFromJetstream represents the threading structure
···638636// Returns nil pointers for empty/nil fields (DRY helper to avoid duplication)
639637func serializeOptionalFields(commentRecord *CommentRecordFromJetstream) (facetsJSON, embedJSON, labelsJSON *string) {
640638 // Serialize facets if present
641641- if commentRecord.Facets != nil && len(commentRecord.Facets) > 0 {
639639+ if len(commentRecord.Facets) > 0 {
642640 if facetsBytes, err := json.Marshal(commentRecord.Facets); err == nil {
643641 facetsStr := string(facetsBytes)
644642 facetsJSON = &facetsStr
···646644 }
647645648646 // Serialize embed if present
649649- if commentRecord.Embed != nil && len(commentRecord.Embed) > 0 {
647647+ if len(commentRecord.Embed) > 0 {
650648 if embedBytes, err := json.Marshal(commentRecord.Embed); err == nil {
651649 embedStr := string(embedBytes)
652650 embedJSON = &embedStr
+8-3
internal/atproto/jetstream/community_consumer.go
···19192020// CommunityEventConsumer consumes community-related events from Jetstream
2121type CommunityEventConsumer struct {
2222- repo communities.Repository // Repository for community operations
2323- identityResolver interface{ Resolve(context.Context, string) (*identity.Identity, error) } // For resolving handles from DIDs
2222+ repo communities.Repository // Repository for community operations
2323+ identityResolver interface {
2424+ Resolve(context.Context, string) (*identity.Identity, error)
2525+ } // For resolving handles from DIDs
2426 httpClient *http.Client // Shared HTTP client with connection pooling
2527 didCache *lru.Cache[string, cachedDIDDoc] // Bounded LRU cache for .well-known verification results
2628 wellKnownLimiter *rate.Limiter // Rate limiter for .well-known fetches
···3840// instanceDID: The DID of this Coves instance (for hostedBy verification)
3941// skipVerification: Skip did:web verification (for dev mode)
4042// identityResolver: Optional resolver for resolving handles from DIDs (can be nil for tests)
4141-func NewCommunityEventConsumer(repo communities.Repository, instanceDID string, skipVerification bool, identityResolver interface{ Resolve(context.Context, string) (*identity.Identity, error) }) *CommunityEventConsumer {
4343+func NewCommunityEventConsumer(repo communities.Repository, instanceDID string, skipVerification bool, identityResolver interface {
4444+ Resolve(context.Context, string) (*identity.Identity, error)
4545+},
4646+) *CommunityEventConsumer {
4247 // Create bounded LRU cache for DID document verification results
4348 // Max 1000 entries to prevent unbounded memory growth (PR review feedback)
4449 // Each entry ~100 bytes → max ~100KB memory overhead
+3-3
internal/atproto/jetstream/post_consumer.go
···1515// PostEventConsumer consumes post-related events from Jetstream
1616// Currently handles only CREATE operations for social.coves.community.post
1717// UPDATE and DELETE handlers will be added when those features are implemented
1818-type PostEventConsumer struct{
1818+type PostEventConsumer struct {
1919 postRepo posts.Repository
2020 communityRepo communities.Repository
2121 userService users.UserService
···200200201201// PostRecordFromJetstream represents a post record as received from Jetstream
202202// Matches the structure written to PDS via social.coves.community.post
203203-type PostRecordFromJetstream struct{
203203+type PostRecordFromJetstream struct {
204204 OriginalAuthor interface{} `json:"originalAuthor,omitempty"`
205205 FederatedFrom interface{} `json:"federatedFrom,omitempty"`
206206 Location interface{} `json:"location,omitempty"`
207207 Title *string `json:"title,omitempty"`
208208 Content *string `json:"content,omitempty"`
209209 Embed map[string]interface{} `json:"embed,omitempty"`
210210+ Labels *posts.SelfLabels `json:"labels,omitempty"`
210211 Type string `json:"$type"`
211212 Community string `json:"community"`
212213 Author string `json:"author"`
213214 CreatedAt string `json:"createdAt"`
214215 Facets []interface{} `json:"facets,omitempty"`
215215- Labels *posts.SelfLabels `json:"labels,omitempty"`
216216}
217217218218// parsePostRecord converts a raw Jetstream record map to a PostRecordFromJetstream
···11+{
22+ "lexicon": 1,
33+ "id": "social.coves.community.comment.getComments",
44+ "defs": {
55+ "main": {
66+ "type": "query",
77+ "description": "Get comments for a post with threading and sorting support. Supports hot/top/new sorting, configurable nesting depth, and pagination.",
88+ "parameters": {
99+ "type": "params",
1010+ "required": ["post"],
1111+ "properties": {
1212+ "post": {
1313+ "type": "string",
1414+ "format": "at-uri",
1515+ "description": "AT-URI of the post to get comments for"
1616+ },
1717+ "sort": {
1818+ "type": "string",
1919+ "default": "hot",
2020+ "knownValues": ["hot", "top", "new"],
2121+ "description": "Sort order: hot (trending), top (highest score), new (most recent)"
2222+ },
2323+ "timeframe": {
2424+ "type": "string",
2525+ "knownValues": ["hour", "day", "week", "month", "year", "all"],
2626+ "description": "Timeframe for 'top' sort. Ignored for other sort types."
2727+ },
2828+ "depth": {
2929+ "type": "integer",
3030+ "default": 10,
3131+ "minimum": 0,
3232+ "maximum": 100,
3333+ "description": "Maximum reply nesting depth to return. 0 returns only top-level comments."
3434+ },
3535+ "limit": {
3636+ "type": "integer",
3737+ "default": 50,
3838+ "minimum": 1,
3939+ "maximum": 100,
4040+ "description": "Maximum number of top-level comments to return per page"
4141+ },
4242+ "cursor": {
4343+ "type": "string",
4444+ "description": "Pagination cursor from previous response"
4545+ }
4646+ }
4747+ },
4848+ "output": {
4949+ "encoding": "application/json",
5050+ "schema": {
5151+ "type": "object",
5252+ "required": ["comments", "post"],
5353+ "properties": {
5454+ "comments": {
5555+ "type": "array",
5656+ "description": "Top-level comments with nested replies up to requested depth",
5757+ "items": {
5858+ "type": "ref",
5959+ "ref": "social.coves.community.comment.defs#threadViewComment"
6060+ }
6161+ },
6262+ "post": {
6363+ "type": "ref",
6464+ "ref": "social.coves.community.post.get#postView",
6565+ "description": "The post these comments belong to"
6666+ },
6767+ "cursor": {
6868+ "type": "string",
6969+ "description": "Pagination cursor for fetching next page of top-level comments"
7070+ }
7171+ }
7272+ }
7373+ },
7474+ "errors": [
7575+ {
7676+ "name": "NotFound",
7777+ "description": "Post not found"
7878+ },
7979+ {
8080+ "name": "InvalidRequest",
8181+ "description": "Invalid parameters (malformed URI, invalid sort/timeframe combination, etc.)"
8282+ }
8383+ ]
8484+ }
8585+ }
8686+}
+32-39
internal/core/comments/comment.go
···77// Comment represents a comment in the AppView database
88// Comments are indexed from the firehose after being written to user repositories
99type Comment struct {
1010- ID int64 `json:"id" db:"id"`
1111- URI string `json:"uri" db:"uri"`
1212- CID string `json:"cid" db:"cid"`
1313- RKey string `json:"rkey" db:"rkey"`
1414- CommenterDID string `json:"commenterDid" db:"commenter_did"`
1515-1616- // Threading (reply references)
1717- RootURI string `json:"rootUri" db:"root_uri"`
1818- RootCID string `json:"rootCid" db:"root_cid"`
1919- ParentURI string `json:"parentUri" db:"parent_uri"`
2020- ParentCID string `json:"parentCid" db:"parent_cid"`
2121-2222- // Content
2323- Content string `json:"content" db:"content"`
2424- ContentFacets *string `json:"contentFacets,omitempty" db:"content_facets"`
2525- Embed *string `json:"embed,omitempty" db:"embed"`
2626- ContentLabels *string `json:"labels,omitempty" db:"content_labels"`
2727- Langs []string `json:"langs,omitempty" db:"langs"`
2828-2929- // Timestamps
3030- CreatedAt time.Time `json:"createdAt" db:"created_at"`
3131- IndexedAt time.Time `json:"indexedAt" db:"indexed_at"`
3232- DeletedAt *time.Time `json:"deletedAt,omitempty" db:"deleted_at"`
3333-3434- // Stats (denormalized for performance)
3535- UpvoteCount int `json:"upvoteCount" db:"upvote_count"`
3636- DownvoteCount int `json:"downvoteCount" db:"downvote_count"`
3737- Score int `json:"score" db:"score"`
3838- ReplyCount int `json:"replyCount" db:"reply_count"`
1010+ IndexedAt time.Time `json:"indexedAt" db:"indexed_at"`
1111+ CreatedAt time.Time `json:"createdAt" db:"created_at"`
1212+ ContentFacets *string `json:"contentFacets,omitempty" db:"content_facets"`
1313+ DeletedAt *time.Time `json:"deletedAt,omitempty" db:"deleted_at"`
1414+ ContentLabels *string `json:"labels,omitempty" db:"content_labels"`
1515+ Embed *string `json:"embed,omitempty" db:"embed"`
1616+ CommenterHandle string `json:"commenterHandle,omitempty" db:"-"`
1717+ CommenterDID string `json:"commenterDid" db:"commenter_did"`
1818+ ParentURI string `json:"parentUri" db:"parent_uri"`
1919+ ParentCID string `json:"parentCid" db:"parent_cid"`
2020+ Content string `json:"content" db:"content"`
2121+ RootURI string `json:"rootUri" db:"root_uri"`
2222+ URI string `json:"uri" db:"uri"`
2323+ RootCID string `json:"rootCid" db:"root_cid"`
2424+ CID string `json:"cid" db:"cid"`
2525+ RKey string `json:"rkey" db:"rkey"`
2626+ Langs []string `json:"langs,omitempty" db:"langs"`
2727+ ID int64 `json:"id" db:"id"`
2828+ UpvoteCount int `json:"upvoteCount" db:"upvote_count"`
2929+ DownvoteCount int `json:"downvoteCount" db:"downvote_count"`
3030+ Score int `json:"score" db:"score"`
3131+ ReplyCount int `json:"replyCount" db:"reply_count"`
3932}
40334134// CommentRecord represents the atProto record structure indexed from Jetstream
4235// This is the data structure that gets stored in the user's repository
4336// Matches social.coves.feed.comment lexicon
4437type CommentRecord struct {
4545- Type string `json:"$type"`
4646- Reply ReplyRef `json:"reply"`
4747- Content string `json:"content"`
4848- Facets []interface{} `json:"facets,omitempty"`
4949- Embed map[string]interface{} `json:"embed,omitempty"`
5050- Langs []string `json:"langs,omitempty"`
5151- Labels *SelfLabels `json:"labels,omitempty"`
5252- CreatedAt string `json:"createdAt"`
3838+ Embed map[string]interface{} `json:"embed,omitempty"`
3939+ Labels *SelfLabels `json:"labels,omitempty"`
4040+ Reply ReplyRef `json:"reply"`
4141+ Type string `json:"$type"`
4242+ Content string `json:"content"`
4343+ CreatedAt string `json:"createdAt"`
4444+ Facets []interface{} `json:"facets,omitempty"`
4545+ Langs []string `json:"langs,omitempty"`
5346}
54475548// ReplyRef represents the threading structure from the comment lexicon
···7568// SelfLabel represents a single label value per com.atproto.label.defs#selfLabel
7669// Neg is optional and negates the label when true
7770type SelfLabel struct {
7878- Val string `json:"val"` // Required: label value (max 128 chars)
7979- Neg *bool `json:"neg,omitempty"` // Optional: negates the label if true
7171+ Neg *bool `json:"neg,omitempty"`
7272+ Val string `json:"val"`
8073}
+460
internal/core/comments/comment_service.go
···11+package comments
22+33+import (
44+ "Coves/internal/core/communities"
55+ "Coves/internal/core/posts"
66+ "Coves/internal/core/users"
77+ "context"
88+ "errors"
99+ "fmt"
1010+ "log"
1111+ "strings"
1212+ "time"
1313+)
1414+1515+// Service defines the business logic interface for comment operations
1616+// Orchestrates repository calls and builds view models for API responses
1717+type Service interface {
1818+ // GetComments retrieves and builds a threaded comment tree for a post
1919+ // Supports hot, top, and new sorting with configurable depth and pagination
2020+ GetComments(ctx context.Context, req *GetCommentsRequest) (*GetCommentsResponse, error)
2121+}
2222+2323+// GetCommentsRequest defines the parameters for fetching comments
2424+type GetCommentsRequest struct {
2525+ Cursor *string
2626+ ViewerDID *string
2727+ PostURI string
2828+ Sort string
2929+ Timeframe string
3030+ Depth int
3131+ Limit int
3232+}
3333+3434+// commentService implements the Service interface
3535+// Coordinates between repository layer and view model construction
3636+type commentService struct {
3737+ commentRepo Repository // Comment data access
3838+ userRepo users.UserRepository // User lookup for author hydration
3939+ postRepo posts.Repository // Post lookup for building post views
4040+ communityRepo communities.Repository // Community lookup for community hydration
4141+}
4242+4343+// NewCommentService creates a new comment service instance
4444+// All repositories are required for proper view construction per lexicon requirements
4545+func NewCommentService(
4646+ commentRepo Repository,
4747+ userRepo users.UserRepository,
4848+ postRepo posts.Repository,
4949+ communityRepo communities.Repository,
5050+) Service {
5151+ return &commentService{
5252+ commentRepo: commentRepo,
5353+ userRepo: userRepo,
5454+ postRepo: postRepo,
5555+ communityRepo: communityRepo,
5656+ }
5757+}
5858+5959+// GetComments retrieves comments for a post with threading and pagination
6060+// Algorithm:
6161+// 1. Validate input parameters and apply defaults
6262+// 2. Fetch top-level comments with specified sorting
6363+// 3. Recursively load nested replies up to depth limit
6464+// 4. Build view models with author info and stats
6565+// 5. Return response with pagination cursor
6666+func (s *commentService) GetComments(ctx context.Context, req *GetCommentsRequest) (*GetCommentsResponse, error) {
6767+ // 1. Validate inputs and apply defaults/bounds FIRST (before expensive operations)
6868+ if err := validateGetCommentsRequest(req); err != nil {
6969+ return nil, fmt.Errorf("invalid request: %w", err)
7070+ }
7171+7272+ // Add timeout to prevent runaway queries with deep nesting
7373+ ctx, cancel := context.WithTimeout(ctx, 10*time.Second)
7474+ defer cancel()
7575+7676+ // 2. Fetch post for context
7777+ post, err := s.postRepo.GetByURI(ctx, req.PostURI)
7878+ if err != nil {
7979+ // Translate post not-found errors to comment-layer errors for proper HTTP status
8080+ if posts.IsNotFound(err) {
8181+ return nil, ErrRootNotFound
8282+ }
8383+ return nil, fmt.Errorf("failed to fetch post: %w", err)
8484+ }
8585+8686+ // Build post view for response (hydrates author handle and community name)
8787+ postView := s.buildPostView(ctx, post, req.ViewerDID)
8888+8989+ // 3. Fetch top-level comments with pagination
9090+ // Uses repository's hot rank sorting and cursor-based pagination
9191+ topComments, nextCursor, err := s.commentRepo.ListByParentWithHotRank(
9292+ ctx,
9393+ req.PostURI,
9494+ req.Sort,
9595+ req.Timeframe,
9696+ req.Limit,
9797+ req.Cursor,
9898+ )
9999+ if err != nil {
100100+ return nil, fmt.Errorf("failed to fetch top-level comments: %w", err)
101101+ }
102102+103103+ // 4. Build threaded view with nested replies up to depth limit
104104+ // This iteratively loads child comments and builds the tree structure
105105+ threadViews := s.buildThreadViews(ctx, topComments, req.Depth, req.Sort, req.ViewerDID)
106106+107107+ // 5. Return response with comments, post reference, and cursor
108108+ return &GetCommentsResponse{
109109+ Comments: threadViews,
110110+ Post: postView,
111111+ Cursor: nextCursor,
112112+ }, nil
113113+}
114114+115115+// buildThreadViews constructs threaded comment views with nested replies using batch loading
116116+// Uses batch queries to prevent N+1 query problem when loading nested replies
117117+// Loads replies level-by-level up to the specified depth limit
118118+func (s *commentService) buildThreadViews(
119119+ ctx context.Context,
120120+ comments []*Comment,
121121+ remainingDepth int,
122122+ sort string,
123123+ viewerDID *string,
124124+) []*ThreadViewComment {
125125+ // Always return an empty slice, never nil (important for JSON serialization)
126126+ result := make([]*ThreadViewComment, 0, len(comments))
127127+128128+ if len(comments) == 0 {
129129+ return result
130130+ }
131131+132132+ // Build thread views for current level
133133+ threadViews := make([]*ThreadViewComment, 0, len(comments))
134134+ commentsByURI := make(map[string]*ThreadViewComment)
135135+ parentsWithReplies := make([]string, 0)
136136+137137+ for _, comment := range comments {
138138+ // Skip deleted comments (soft-deleted records)
139139+ if comment.DeletedAt != nil {
140140+ continue
141141+ }
142142+143143+ // Build the comment view with author info and stats
144144+ commentView := s.buildCommentView(comment, viewerDID)
145145+146146+ threadView := &ThreadViewComment{
147147+ Comment: commentView,
148148+ Replies: nil,
149149+ HasMore: comment.ReplyCount > 0 && remainingDepth == 0,
150150+ }
151151+152152+ threadViews = append(threadViews, threadView)
153153+ commentsByURI[comment.URI] = threadView
154154+155155+ // Collect parent URIs that have replies and depth remaining
156156+ if remainingDepth > 0 && comment.ReplyCount > 0 {
157157+ parentsWithReplies = append(parentsWithReplies, comment.URI)
158158+ }
159159+ }
160160+161161+ // Batch load all replies for this level in a single query
162162+ if len(parentsWithReplies) > 0 {
163163+ const repliesPerParent = 5 // Load top 5 replies per comment
164164+165165+ repliesByParent, err := s.commentRepo.ListByParentsBatch(
166166+ ctx,
167167+ parentsWithReplies,
168168+ sort,
169169+ repliesPerParent,
170170+ )
171171+172172+ // Process replies if batch query succeeded
173173+ if err == nil {
174174+ // Group child comments by parent for recursive processing
175175+ for parentURI, replies := range repliesByParent {
176176+ threadView := commentsByURI[parentURI]
177177+ if threadView != nil && len(replies) > 0 {
178178+ // Recursively build views for child comments
179179+ threadView.Replies = s.buildThreadViews(
180180+ ctx,
181181+ replies,
182182+ remainingDepth-1,
183183+ sort,
184184+ viewerDID,
185185+ )
186186+187187+ // Update HasMore based on actual reply count vs loaded count
188188+ // Get the original comment to check reply count
189189+ for _, comment := range comments {
190190+ if comment.URI == parentURI {
191191+ threadView.HasMore = comment.ReplyCount > len(replies)
192192+ break
193193+ }
194194+ }
195195+ }
196196+ }
197197+ }
198198+ }
199199+200200+ return threadViews
201201+}
202202+203203+// buildCommentView converts a Comment entity to a CommentView with full metadata
204204+// Constructs author view, stats, and references to parent post/comment
205205+func (s *commentService) buildCommentView(comment *Comment, viewerDID *string) *CommentView {
206206+ // Build author view from comment data
207207+ // CommenterHandle is hydrated by ListByParentWithHotRank via JOIN
208208+ authorView := &posts.AuthorView{
209209+ DID: comment.CommenterDID,
210210+ Handle: comment.CommenterHandle,
211211+ // TODO: Add DisplayName, Avatar, Reputation when user service is integrated (Phase 2B)
212212+ }
213213+214214+ // Build aggregated statistics
215215+ stats := &CommentStats{
216216+ Upvotes: comment.UpvoteCount,
217217+ Downvotes: comment.DownvoteCount,
218218+ Score: comment.Score,
219219+ ReplyCount: comment.ReplyCount,
220220+ }
221221+222222+ // Build reference to parent post (always present)
223223+ postRef := &CommentRef{
224224+ URI: comment.RootURI,
225225+ CID: comment.RootCID,
226226+ }
227227+228228+ // Build reference to parent comment (only if nested)
229229+ // Top-level comments have ParentURI == RootURI (both point to the post)
230230+ var parentRef *CommentRef
231231+ if comment.ParentURI != comment.RootURI {
232232+ parentRef = &CommentRef{
233233+ URI: comment.ParentURI,
234234+ CID: comment.ParentCID,
235235+ }
236236+ }
237237+238238+ // Build viewer state (stubbed for now - Phase 2B)
239239+ // Future: Fetch viewer's vote state from GetVoteStateForComments
240240+ var viewer *CommentViewerState
241241+ if viewerDID != nil {
242242+ // TODO: Query voter state
243243+ // voteState, err := s.commentRepo.GetVoteStateForComments(ctx, *viewerDID, []string{comment.URI})
244244+ // For now, return empty viewer state to indicate authenticated request
245245+ viewer = &CommentViewerState{
246246+ Vote: nil,
247247+ VoteURI: nil,
248248+ }
249249+ }
250250+251251+ // Build minimal comment record to satisfy lexicon contract
252252+ // The record field is required by social.coves.community.comment.defs#commentView
253253+ commentRecord := s.buildCommentRecord(comment)
254254+255255+ return &CommentView{
256256+ URI: comment.URI,
257257+ CID: comment.CID,
258258+ Author: authorView,
259259+ Record: commentRecord,
260260+ Post: postRef,
261261+ Parent: parentRef,
262262+ Content: comment.Content,
263263+ CreatedAt: comment.CreatedAt.Format(time.RFC3339),
264264+ IndexedAt: comment.IndexedAt.Format(time.RFC3339),
265265+ Stats: stats,
266266+ Viewer: viewer,
267267+ }
268268+}
269269+270270+// buildCommentRecord constructs a minimal CommentRecord from a Comment entity
271271+// Satisfies the lexicon requirement that commentView.record is a required field
272272+// TODO (Phase 2C): Unmarshal JSON fields (embed, facets, labels) for complete record
273273+func (s *commentService) buildCommentRecord(comment *Comment) *CommentRecord {
274274+ record := &CommentRecord{
275275+ Type: "social.coves.feed.comment",
276276+ Reply: ReplyRef{
277277+ Root: StrongRef{
278278+ URI: comment.RootURI,
279279+ CID: comment.RootCID,
280280+ },
281281+ Parent: StrongRef{
282282+ URI: comment.ParentURI,
283283+ CID: comment.ParentCID,
284284+ },
285285+ },
286286+ Content: comment.Content,
287287+ CreatedAt: comment.CreatedAt.Format(time.RFC3339),
288288+ Langs: comment.Langs,
289289+ }
290290+291291+ // TODO (Phase 2C): Parse JSON fields from database for complete record:
292292+ // - Unmarshal comment.Embed (*string) → record.Embed (map[string]interface{})
293293+ // - Unmarshal comment.ContentFacets (*string) → record.Facets ([]interface{})
294294+ // - Unmarshal comment.ContentLabels (*string) → record.Labels (*SelfLabels)
295295+ // These fields are stored as JSONB in the database and need proper deserialization
296296+297297+ return record
298298+}
299299+300300+// buildPostView converts a Post entity to a PostView for the comment response
301301+// Hydrates author handle and community name per lexicon requirements
302302+func (s *commentService) buildPostView(ctx context.Context, post *posts.Post, viewerDID *string) *posts.PostView {
303303+ // Build author view - fetch user to get handle (required by lexicon)
304304+ // The lexicon marks authorView.handle with format:"handle", so DIDs are invalid
305305+ authorHandle := post.AuthorDID // Fallback if user not found
306306+ if user, err := s.userRepo.GetByDID(ctx, post.AuthorDID); err == nil {
307307+ authorHandle = user.Handle
308308+ } else {
309309+ // Log warning but don't fail the entire request
310310+ log.Printf("Warning: Failed to fetch user for post author %s: %v", post.AuthorDID, err)
311311+ }
312312+313313+ authorView := &posts.AuthorView{
314314+ DID: post.AuthorDID,
315315+ Handle: authorHandle,
316316+ // TODO (Phase 2C): Add DisplayName, Avatar, Reputation from user profile
317317+ }
318318+319319+ // Build community reference - fetch community to get name (required by lexicon)
320320+ // The lexicon marks communityRef.name as required, so DIDs are insufficient
321321+ communityName := post.CommunityDID // Fallback if community not found
322322+ if community, err := s.communityRepo.GetByDID(ctx, post.CommunityDID); err == nil {
323323+ communityName = community.Handle // Use handle as display name
324324+ // TODO (Phase 2C): Use community.DisplayName or community.Name if available
325325+ } else {
326326+ // Log warning but don't fail the entire request
327327+ log.Printf("Warning: Failed to fetch community for post %s: %v", post.CommunityDID, err)
328328+ }
329329+330330+ communityRef := &posts.CommunityRef{
331331+ DID: post.CommunityDID,
332332+ Name: communityName,
333333+ // TODO (Phase 2C): Add Avatar from community profile
334334+ }
335335+336336+ // Build aggregated statistics
337337+ stats := &posts.PostStats{
338338+ Upvotes: post.UpvoteCount,
339339+ Downvotes: post.DownvoteCount,
340340+ Score: post.Score,
341341+ CommentCount: post.CommentCount,
342342+ }
343343+344344+ // Build viewer state if authenticated
345345+ var viewer *posts.ViewerState
346346+ if viewerDID != nil {
347347+ // TODO (Phase 2B): Query viewer's vote state
348348+ viewer = &posts.ViewerState{
349349+ Vote: nil,
350350+ VoteURI: nil,
351351+ Saved: false,
352352+ }
353353+ }
354354+355355+ // Build minimal post record to satisfy lexicon contract
356356+ // The record field is required by social.coves.community.post.get#postView
357357+ postRecord := s.buildPostRecord(post)
358358+359359+ return &posts.PostView{
360360+ URI: post.URI,
361361+ CID: post.CID,
362362+ RKey: post.RKey,
363363+ Author: authorView,
364364+ Record: postRecord,
365365+ Community: communityRef,
366366+ Title: post.Title,
367367+ Text: post.Content,
368368+ CreatedAt: post.CreatedAt,
369369+ IndexedAt: post.IndexedAt,
370370+ EditedAt: post.EditedAt,
371371+ Stats: stats,
372372+ Viewer: viewer,
373373+ }
374374+}
375375+376376+// buildPostRecord constructs a minimal PostRecord from a Post entity
377377+// Satisfies the lexicon requirement that postView.record is a required field
378378+// TODO (Phase 2C): Unmarshal JSON fields (embed, facets, labels) for complete record
379379+func (s *commentService) buildPostRecord(post *posts.Post) *posts.PostRecord {
380380+ record := &posts.PostRecord{
381381+ Type: "social.coves.community.post",
382382+ Community: post.CommunityDID,
383383+ Author: post.AuthorDID,
384384+ CreatedAt: post.CreatedAt.Format(time.RFC3339),
385385+ Title: post.Title,
386386+ Content: post.Content,
387387+ }
388388+389389+ // TODO (Phase 2C): Parse JSON fields from database for complete record:
390390+ // - Unmarshal post.Embed (*string) → record.Embed (map[string]interface{})
391391+ // - Unmarshal post.ContentFacets (*string) → record.Facets ([]interface{})
392392+ // - Unmarshal post.ContentLabels (*string) → record.Labels (*SelfLabels)
393393+ // These fields are stored as JSONB in the database and need proper deserialization
394394+395395+ return record
396396+}
397397+398398+// validateGetCommentsRequest validates and normalizes request parameters
399399+// Applies default values and enforces bounds per API specification
400400+func validateGetCommentsRequest(req *GetCommentsRequest) error {
401401+ if req == nil {
402402+ return errors.New("request cannot be nil")
403403+ }
404404+405405+ // Validate PostURI is present and well-formed
406406+ if req.PostURI == "" {
407407+ return errors.New("post URI is required")
408408+ }
409409+410410+ if !strings.HasPrefix(req.PostURI, "at://") {
411411+ return errors.New("invalid AT-URI format: must start with 'at://'")
412412+ }
413413+414414+ // Apply depth defaults and bounds (0-100, default 10)
415415+ if req.Depth < 0 {
416416+ req.Depth = 10
417417+ }
418418+ if req.Depth > 100 {
419419+ req.Depth = 100
420420+ }
421421+422422+ // Apply limit defaults and bounds (1-100, default 50)
423423+ if req.Limit <= 0 {
424424+ req.Limit = 50
425425+ }
426426+ if req.Limit > 100 {
427427+ req.Limit = 100
428428+ }
429429+430430+ // Apply sort default and validate
431431+ if req.Sort == "" {
432432+ req.Sort = "hot"
433433+ }
434434+435435+ validSorts := map[string]bool{
436436+ "hot": true,
437437+ "top": true,
438438+ "new": true,
439439+ }
440440+ if !validSorts[req.Sort] {
441441+ return fmt.Errorf("invalid sort: must be one of [hot, top, new], got '%s'", req.Sort)
442442+ }
443443+444444+ // Validate timeframe (only applies to "top" sort)
445445+ if req.Timeframe != "" {
446446+ validTimeframes := map[string]bool{
447447+ "hour": true,
448448+ "day": true,
449449+ "week": true,
450450+ "month": true,
451451+ "year": true,
452452+ "all": true,
453453+ }
454454+ if !validTimeframes[req.Timeframe] {
455455+ return fmt.Errorf("invalid timeframe: must be one of [hour, day, week, month, year, all], got '%s'", req.Timeframe)
456456+ }
457457+ }
458458+459459+ return nil
460460+}
+7
internal/core/comments/errors.go
···4242func IsConflict(err error) bool {
4343 return errors.Is(err, ErrCommentAlreadyExists)
4444}
4545+4646+// IsValidationError checks if an error is a validation error
4747+func IsValidationError(err error) bool {
4848+ return errors.Is(err, ErrInvalidReply) ||
4949+ errors.Is(err, ErrContentTooLong) ||
5050+ errors.Is(err, ErrContentEmpty)
5151+}
+33
internal/core/comments/interfaces.go
···4242 // ListByCommenter retrieves all comments by a specific user
4343 // Future: Used for user comment history
4444 ListByCommenter(ctx context.Context, commenterDID string, limit, offset int) ([]*Comment, error)
4545+4646+ // ListByParentWithHotRank retrieves direct replies to a post or comment with sorting and pagination
4747+ // Supports hot, top, and new sorting with cursor-based pagination
4848+ // Returns comments with author info hydrated and next page cursor
4949+ ListByParentWithHotRank(
5050+ ctx context.Context,
5151+ parentURI string,
5252+ sort string, // "hot", "top", "new"
5353+ timeframe string, // "hour", "day", "week", "month", "year", "all" (for "top" only)
5454+ limit int,
5555+ cursor *string,
5656+ ) ([]*Comment, *string, error)
5757+5858+ // GetByURIsBatch retrieves multiple comments by their AT-URIs in a single query
5959+ // Returns map[uri]*Comment for efficient lookups
6060+ // Used for hydrating comment threads without N+1 queries
6161+ GetByURIsBatch(ctx context.Context, uris []string) (map[string]*Comment, error)
6262+6363+ // GetVoteStateForComments retrieves the viewer's votes on a batch of comments
6464+ // Returns map[commentURI]*Vote for efficient lookups
6565+ // Future: Used when votes table is implemented
6666+ GetVoteStateForComments(ctx context.Context, viewerDID string, commentURIs []string) (map[string]interface{}, error)
6767+6868+ // ListByParentsBatch retrieves direct replies to multiple parents in a single query
6969+ // Returns map[parentURI][]*Comment grouped by parent
7070+ // Used to prevent N+1 queries when loading nested replies
7171+ // Limits results per parent to avoid memory exhaustion
7272+ ListByParentsBatch(
7373+ ctx context.Context,
7474+ parentURIs []string,
7575+ sort string,
7676+ limitPerParent int,
7777+ ) (map[string][]*Comment, error)
4578}
+65
internal/core/comments/view_models.go
···11+package comments
22+33+import (
44+ "Coves/internal/core/posts"
55+)
66+77+// CommentView represents the full view of a comment with all metadata
88+// Matches social.coves.feed.getComments#commentView lexicon
99+// Used in thread views and get endpoints
1010+type CommentView struct {
1111+ Embed interface{} `json:"embed,omitempty"`
1212+ Record interface{} `json:"record"`
1313+ Viewer *CommentViewerState `json:"viewer,omitempty"`
1414+ Author *posts.AuthorView `json:"author"`
1515+ Post *CommentRef `json:"post"`
1616+ Parent *CommentRef `json:"parent,omitempty"`
1717+ Stats *CommentStats `json:"stats"`
1818+ Content string `json:"content"`
1919+ CreatedAt string `json:"createdAt"`
2020+ IndexedAt string `json:"indexedAt"`
2121+ URI string `json:"uri"`
2222+ CID string `json:"cid"`
2323+ ContentFacets []interface{} `json:"contentFacets,omitempty"`
2424+}
2525+2626+// ThreadViewComment represents a comment with its nested replies
2727+// Matches social.coves.feed.getComments#threadViewComment lexicon
2828+// Supports recursive threading for comment trees
2929+type ThreadViewComment struct {
3030+ Comment *CommentView `json:"comment"`
3131+ Replies []*ThreadViewComment `json:"replies,omitempty"` // Recursive nested replies
3232+ HasMore bool `json:"hasMore,omitempty"` // Indicates more replies exist
3333+}
3434+3535+// CommentRef is a minimal reference to a post or comment (URI + CID)
3636+// Used for threading references (post and parent comment)
3737+type CommentRef struct {
3838+ URI string `json:"uri"`
3939+ CID string `json:"cid"`
4040+}
4141+4242+// CommentStats represents aggregated statistics for a comment
4343+// Includes voting metrics and reply counts
4444+type CommentStats struct {
4545+ Upvotes int `json:"upvotes"`
4646+ Downvotes int `json:"downvotes"`
4747+ Score int `json:"score"`
4848+ ReplyCount int `json:"replyCount"`
4949+}
5050+5151+// CommentViewerState represents the viewer's relationship with the comment
5252+// Includes voting state and vote record reference
5353+type CommentViewerState struct {
5454+ Vote *string `json:"vote,omitempty"` // "up" or "down"
5555+ VoteURI *string `json:"voteUri,omitempty"` // URI of the vote record
5656+}
5757+5858+// GetCommentsResponse represents the response for fetching comments on a post
5959+// Matches social.coves.feed.getComments lexicon output
6060+// Includes the full comment thread tree and original post reference
6161+type GetCommentsResponse struct {
6262+ Post interface{} `json:"post"`
6363+ Cursor *string `json:"cursor,omitempty"`
6464+ Comments []*ThreadViewComment `json:"comments"`
6565+}
+5-5
internal/core/posts/post.go
···1313// SelfLabel represents a single label value per com.atproto.label.defs#selfLabel
1414// Neg is optional and negates the label when true
1515type SelfLabel struct {
1616- Val string `json:"val"` // Required: label value (max 128 chars)
1717- Neg *bool `json:"neg,omitempty"` // Optional: negates the label if true
1616+ Neg *bool `json:"neg,omitempty"`
1717+ Val string `json:"val"`
1818}
19192020// Post represents a post in the AppView database
···5050 Title *string `json:"title,omitempty"`
5151 Content *string `json:"content,omitempty"`
5252 Embed map[string]interface{} `json:"embed,omitempty"`
5353+ Labels *SelfLabels `json:"labels,omitempty"`
5354 Community string `json:"community"`
5455 AuthorDID string `json:"authorDid"`
5556 Facets []interface{} `json:"facets,omitempty"`
5656- Labels *SelfLabels `json:"labels,omitempty"`
5757}
58585959// CreatePostResponse represents the response from creating a post
6060// Matches social.coves.community.post.create lexicon output schema
6161-type CreatePostResponse struct{
6161+type CreatePostResponse struct {
6262 URI string `json:"uri"` // AT-URI of created post
6363 CID string `json:"cid"` // CID of created post
6464}
···7272 Title *string `json:"title,omitempty"`
7373 Content *string `json:"content,omitempty"`
7474 Embed map[string]interface{} `json:"embed,omitempty"`
7575+ Labels *SelfLabels `json:"labels,omitempty"`
7576 Type string `json:"$type"`
7677 Community string `json:"community"`
7778 Author string `json:"author"`
7879 CreatedAt string `json:"createdAt"`
7980 Facets []interface{} `json:"facets,omitempty"`
8080- Labels *SelfLabels `json:"labels,omitempty"`
8181}
82828383// PostView represents the full view of a post with all metadata
+2-2
internal/core/posts/service.go
···259259 // IMPORTANT: repo is set to community DID, not author DID
260260 // This writes the post to the community's repository
261261 payload := map[string]interface{}{
262262- "repo": community.DID, // Community's repository
262262+ "repo": community.DID, // Community's repository
263263 "collection": "social.coves.community.post", // Collection type
264264- "record": record, // The post record
264264+ "record": record, // The post record
265265 // "rkey" omitted - PDS will auto-generate TID
266266 }
267267
+587
internal/db/postgres/comment_repo.go
···44 "Coves/internal/core/comments"
55 "context"
66 "database/sql"
77+ "encoding/base64"
78 "fmt"
89 "log"
910 "strings"
···352353353354 return result, nil
354355}
356356+357357+// ListByParentWithHotRank retrieves direct replies to a post or comment with sorting and pagination
358358+// Supports three sort modes: hot (Lemmy algorithm), top (by score + timeframe), and new (by created_at)
359359+// Uses cursor-based pagination with composite keys for consistent ordering
360360+// Hydrates author info (handle, display_name, avatar) via JOIN with users table
361361+func (r *postgresCommentRepo) ListByParentWithHotRank(
362362+ ctx context.Context,
363363+ parentURI string,
364364+ sort string,
365365+ timeframe string,
366366+ limit int,
367367+ cursor *string,
368368+) ([]*comments.Comment, *string, error) {
369369+ // Build ORDER BY clause and time filter based on sort type
370370+ orderBy, timeFilter := r.buildCommentSortClause(sort, timeframe)
371371+372372+ // Parse cursor for pagination
373373+ cursorFilter, cursorValues, err := r.parseCommentCursor(cursor, sort)
374374+ if err != nil {
375375+ return nil, nil, fmt.Errorf("invalid cursor: %w", err)
376376+ }
377377+378378+ // Build SELECT clause - compute hot_rank for "hot" sort
379379+ // Hot rank formula (Lemmy algorithm):
380380+ // log(greatest(2, score + 2)) / power(((EXTRACT(EPOCH FROM (NOW() - created_at)) / 3600) + 2), 1.8)
381381+ //
382382+ // This formula:
383383+ // - Gives logarithmic weight to score (prevents high-score dominance)
384384+ // - Decays over time with power 1.8 (faster than linear, slower than quadratic)
385385+ // - Uses hours as time unit (3600 seconds)
386386+ // - Adds constants to prevent division by zero and ensure positive values
387387+ var selectClause string
388388+ if sort == "hot" {
389389+ selectClause = `
390390+ SELECT
391391+ c.id, c.uri, c.cid, c.rkey, c.commenter_did,
392392+ c.root_uri, c.root_cid, c.parent_uri, c.parent_cid,
393393+ c.content, c.content_facets, c.embed, c.content_labels, c.langs,
394394+ c.created_at, c.indexed_at, c.deleted_at,
395395+ c.upvote_count, c.downvote_count, c.score, c.reply_count,
396396+ log(greatest(2, c.score + 2)) / power(((EXTRACT(EPOCH FROM (NOW() - c.created_at)) / 3600) + 2), 1.8) as hot_rank,
397397+ COALESCE(u.handle, c.commenter_did) as author_handle
398398+ FROM comments c`
399399+ } else {
400400+ selectClause = `
401401+ SELECT
402402+ c.id, c.uri, c.cid, c.rkey, c.commenter_did,
403403+ c.root_uri, c.root_cid, c.parent_uri, c.parent_cid,
404404+ c.content, c.content_facets, c.embed, c.content_labels, c.langs,
405405+ c.created_at, c.indexed_at, c.deleted_at,
406406+ c.upvote_count, c.downvote_count, c.score, c.reply_count,
407407+ NULL::numeric as hot_rank,
408408+ COALESCE(u.handle, c.commenter_did) as author_handle
409409+ FROM comments c`
410410+ }
411411+412412+ // Build complete query with JOINs and filters
413413+ // LEFT JOIN prevents data loss when user record hasn't been indexed yet (out-of-order Jetstream events)
414414+ query := fmt.Sprintf(`
415415+ %s
416416+ LEFT JOIN users u ON c.commenter_did = u.did
417417+ WHERE c.parent_uri = $1 AND c.deleted_at IS NULL
418418+ %s
419419+ %s
420420+ ORDER BY %s
421421+ LIMIT $2
422422+ `, selectClause, timeFilter, cursorFilter, orderBy)
423423+424424+ // Prepare query arguments
425425+ args := []interface{}{parentURI, limit + 1} // +1 to detect next page
426426+ args = append(args, cursorValues...)
427427+428428+ // Execute query
429429+ rows, err := r.db.QueryContext(ctx, query, args...)
430430+ if err != nil {
431431+ return nil, nil, fmt.Errorf("failed to query comments with hot rank: %w", err)
432432+ }
433433+ defer func() {
434434+ if err := rows.Close(); err != nil {
435435+ log.Printf("Failed to close rows: %v", err)
436436+ }
437437+ }()
438438+439439+ // Scan results
440440+ var result []*comments.Comment
441441+ var hotRanks []float64
442442+ for rows.Next() {
443443+ var comment comments.Comment
444444+ var langs pq.StringArray
445445+ var hotRank sql.NullFloat64
446446+ var authorHandle string
447447+448448+ err := rows.Scan(
449449+ &comment.ID, &comment.URI, &comment.CID, &comment.RKey, &comment.CommenterDID,
450450+ &comment.RootURI, &comment.RootCID, &comment.ParentURI, &comment.ParentCID,
451451+ &comment.Content, &comment.ContentFacets, &comment.Embed, &comment.ContentLabels, &langs,
452452+ &comment.CreatedAt, &comment.IndexedAt, &comment.DeletedAt,
453453+ &comment.UpvoteCount, &comment.DownvoteCount, &comment.Score, &comment.ReplyCount,
454454+ &hotRank, &authorHandle,
455455+ )
456456+ if err != nil {
457457+ return nil, nil, fmt.Errorf("failed to scan comment: %w", err)
458458+ }
459459+460460+ comment.Langs = langs
461461+ comment.CommenterHandle = authorHandle
462462+463463+ // Store hot_rank for cursor building
464464+ hotRankValue := 0.0
465465+ if hotRank.Valid {
466466+ hotRankValue = hotRank.Float64
467467+ }
468468+ hotRanks = append(hotRanks, hotRankValue)
469469+470470+ result = append(result, &comment)
471471+ }
472472+473473+ if err = rows.Err(); err != nil {
474474+ return nil, nil, fmt.Errorf("error iterating comments: %w", err)
475475+ }
476476+477477+ // Handle pagination cursor
478478+ var nextCursor *string
479479+ if len(result) > limit && limit > 0 {
480480+ result = result[:limit]
481481+ hotRanks = hotRanks[:limit]
482482+ lastComment := result[len(result)-1]
483483+ lastHotRank := hotRanks[len(hotRanks)-1]
484484+ cursorStr := r.buildCommentCursor(lastComment, sort, lastHotRank)
485485+ nextCursor = &cursorStr
486486+ }
487487+488488+ return result, nextCursor, nil
489489+}
490490+491491+// buildCommentSortClause returns the ORDER BY SQL and optional time filter
492492+func (r *postgresCommentRepo) buildCommentSortClause(sort, timeframe string) (string, string) {
493493+ var orderBy string
494494+ switch sort {
495495+ case "hot":
496496+ // Hot rank DESC, then score DESC as tiebreaker, then created_at DESC, then uri DESC
497497+ orderBy = `hot_rank DESC, c.score DESC, c.created_at DESC, c.uri DESC`
498498+ case "top":
499499+ // Score DESC, then created_at DESC, then uri DESC
500500+ orderBy = `c.score DESC, c.created_at DESC, c.uri DESC`
501501+ case "new":
502502+ // Created at DESC, then uri DESC
503503+ orderBy = `c.created_at DESC, c.uri DESC`
504504+ default:
505505+ // Default to hot
506506+ orderBy = `hot_rank DESC, c.score DESC, c.created_at DESC, c.uri DESC`
507507+ }
508508+509509+ // Add time filter for "top" sort
510510+ var timeFilter string
511511+ if sort == "top" {
512512+ timeFilter = r.buildCommentTimeFilter(timeframe)
513513+ }
514514+515515+ return orderBy, timeFilter
516516+}
517517+518518+// buildCommentTimeFilter returns SQL filter for timeframe
519519+func (r *postgresCommentRepo) buildCommentTimeFilter(timeframe string) string {
520520+ if timeframe == "" || timeframe == "all" {
521521+ return ""
522522+ }
523523+524524+ var interval string
525525+ switch timeframe {
526526+ case "hour":
527527+ interval = "1 hour"
528528+ case "day":
529529+ interval = "1 day"
530530+ case "week":
531531+ interval = "7 days"
532532+ case "month":
533533+ interval = "30 days"
534534+ case "year":
535535+ interval = "1 year"
536536+ default:
537537+ return ""
538538+ }
539539+540540+ return fmt.Sprintf("AND c.created_at >= NOW() - INTERVAL '%s'", interval)
541541+}
542542+543543+// parseCommentCursor decodes pagination cursor for comments
544544+func (r *postgresCommentRepo) parseCommentCursor(cursor *string, sort string) (string, []interface{}, error) {
545545+ if cursor == nil || *cursor == "" {
546546+ return "", nil, nil
547547+ }
548548+549549+ // Validate cursor size to prevent DoS via massive base64 strings
550550+ const maxCursorSize = 1024
551551+ if len(*cursor) > maxCursorSize {
552552+ return "", nil, fmt.Errorf("cursor too large: maximum %d bytes", maxCursorSize)
553553+ }
554554+555555+ // Decode base64 cursor
556556+ decoded, err := base64.URLEncoding.DecodeString(*cursor)
557557+ if err != nil {
558558+ return "", nil, fmt.Errorf("invalid cursor encoding")
559559+ }
560560+561561+ // Parse cursor based on sort type using | delimiter
562562+ // Format: hotRank|score|createdAt|uri (for hot)
563563+ // score|createdAt|uri (for top)
564564+ // createdAt|uri (for new)
565565+ parts := strings.Split(string(decoded), "|")
566566+567567+ switch sort {
568568+ case "new":
569569+ // Cursor format: createdAt|uri
570570+ if len(parts) != 2 {
571571+ return "", nil, fmt.Errorf("invalid cursor format for new sort")
572572+ }
573573+574574+ createdAt := parts[0]
575575+ uri := parts[1]
576576+577577+ // Validate AT-URI format
578578+ if !strings.HasPrefix(uri, "at://") {
579579+ return "", nil, fmt.Errorf("invalid cursor URI")
580580+ }
581581+582582+ filter := `AND (c.created_at < $3 OR (c.created_at = $3 AND c.uri < $4))`
583583+ return filter, []interface{}{createdAt, uri}, nil
584584+585585+ case "top":
586586+ // Cursor format: score|createdAt|uri
587587+ if len(parts) != 3 {
588588+ return "", nil, fmt.Errorf("invalid cursor format for top sort")
589589+ }
590590+591591+ scoreStr := parts[0]
592592+ createdAt := parts[1]
593593+ uri := parts[2]
594594+595595+ // Parse score as integer
596596+ score := 0
597597+ if _, err := fmt.Sscanf(scoreStr, "%d", &score); err != nil {
598598+ return "", nil, fmt.Errorf("invalid cursor score")
599599+ }
600600+601601+ // Validate AT-URI format
602602+ if !strings.HasPrefix(uri, "at://") {
603603+ return "", nil, fmt.Errorf("invalid cursor URI")
604604+ }
605605+606606+ 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))`
607607+ return filter, []interface{}{score, createdAt, uri}, nil
608608+609609+ case "hot":
610610+ // Cursor format: hotRank|score|createdAt|uri
611611+ if len(parts) != 4 {
612612+ return "", nil, fmt.Errorf("invalid cursor format for hot sort")
613613+ }
614614+615615+ hotRankStr := parts[0]
616616+ scoreStr := parts[1]
617617+ createdAt := parts[2]
618618+ uri := parts[3]
619619+620620+ // Parse hot_rank as float
621621+ hotRank := 0.0
622622+ if _, err := fmt.Sscanf(hotRankStr, "%f", &hotRank); err != nil {
623623+ return "", nil, fmt.Errorf("invalid cursor hot rank")
624624+ }
625625+626626+ // Parse score as integer
627627+ score := 0
628628+ if _, err := fmt.Sscanf(scoreStr, "%d", &score); err != nil {
629629+ return "", nil, fmt.Errorf("invalid cursor score")
630630+ }
631631+632632+ // Validate AT-URI format
633633+ if !strings.HasPrefix(uri, "at://") {
634634+ return "", nil, fmt.Errorf("invalid cursor URI")
635635+ }
636636+637637+ // Use computed hot_rank expression in comparison
638638+ hotRankExpr := `log(greatest(2, c.score + 2)) / power(((EXTRACT(EPOCH FROM (NOW() - c.created_at)) / 3600) + 2), 1.8)`
639639+ 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)`,
640640+ hotRankExpr, hotRankExpr, hotRankExpr, hotRankExpr)
641641+ return filter, []interface{}{hotRank, score, createdAt, uri, uri}, nil
642642+643643+ default:
644644+ return "", nil, nil
645645+ }
646646+}
647647+648648+// buildCommentCursor creates pagination cursor from last comment
649649+func (r *postgresCommentRepo) buildCommentCursor(comment *comments.Comment, sort string, hotRank float64) string {
650650+ var cursorStr string
651651+ const delimiter = "|"
652652+653653+ switch sort {
654654+ case "new":
655655+ // Format: createdAt|uri
656656+ cursorStr = fmt.Sprintf("%s%s%s",
657657+ comment.CreatedAt.Format("2006-01-02T15:04:05.999999999Z07:00"),
658658+ delimiter,
659659+ comment.URI)
660660+661661+ case "top":
662662+ // Format: score|createdAt|uri
663663+ cursorStr = fmt.Sprintf("%d%s%s%s%s",
664664+ comment.Score,
665665+ delimiter,
666666+ comment.CreatedAt.Format("2006-01-02T15:04:05.999999999Z07:00"),
667667+ delimiter,
668668+ comment.URI)
669669+670670+ case "hot":
671671+ // Format: hotRank|score|createdAt|uri
672672+ cursorStr = fmt.Sprintf("%f%s%d%s%s%s%s",
673673+ hotRank,
674674+ delimiter,
675675+ comment.Score,
676676+ delimiter,
677677+ comment.CreatedAt.Format("2006-01-02T15:04:05.999999999Z07:00"),
678678+ delimiter,
679679+ comment.URI)
680680+681681+ default:
682682+ cursorStr = comment.URI
683683+ }
684684+685685+ return base64.URLEncoding.EncodeToString([]byte(cursorStr))
686686+}
687687+688688+// GetByURIsBatch retrieves multiple comments by their AT-URIs in a single query
689689+// Returns map[uri]*Comment for efficient lookups without N+1 queries
690690+func (r *postgresCommentRepo) GetByURIsBatch(ctx context.Context, uris []string) (map[string]*comments.Comment, error) {
691691+ if len(uris) == 0 {
692692+ return make(map[string]*comments.Comment), nil
693693+ }
694694+695695+ // LEFT JOIN prevents data loss when user record hasn't been indexed yet (out-of-order Jetstream events)
696696+ // COALESCE falls back to DID when handle is NULL (user not yet in users table)
697697+ query := `
698698+ SELECT
699699+ c.id, c.uri, c.cid, c.rkey, c.commenter_did,
700700+ c.root_uri, c.root_cid, c.parent_uri, c.parent_cid,
701701+ c.content, c.content_facets, c.embed, c.content_labels, c.langs,
702702+ c.created_at, c.indexed_at, c.deleted_at,
703703+ c.upvote_count, c.downvote_count, c.score, c.reply_count,
704704+ COALESCE(u.handle, c.commenter_did) as author_handle
705705+ FROM comments c
706706+ LEFT JOIN users u ON c.commenter_did = u.did
707707+ WHERE c.uri = ANY($1) AND c.deleted_at IS NULL
708708+ `
709709+710710+ rows, err := r.db.QueryContext(ctx, query, pq.Array(uris))
711711+ if err != nil {
712712+ return nil, fmt.Errorf("failed to batch get comments by URIs: %w", err)
713713+ }
714714+ defer func() {
715715+ if err := rows.Close(); err != nil {
716716+ log.Printf("Failed to close rows: %v", err)
717717+ }
718718+ }()
719719+720720+ result := make(map[string]*comments.Comment)
721721+ for rows.Next() {
722722+ var comment comments.Comment
723723+ var langs pq.StringArray
724724+ var authorHandle string
725725+726726+ err := rows.Scan(
727727+ &comment.ID, &comment.URI, &comment.CID, &comment.RKey, &comment.CommenterDID,
728728+ &comment.RootURI, &comment.RootCID, &comment.ParentURI, &comment.ParentCID,
729729+ &comment.Content, &comment.ContentFacets, &comment.Embed, &comment.ContentLabels, &langs,
730730+ &comment.CreatedAt, &comment.IndexedAt, &comment.DeletedAt,
731731+ &comment.UpvoteCount, &comment.DownvoteCount, &comment.Score, &comment.ReplyCount,
732732+ &authorHandle,
733733+ )
734734+ if err != nil {
735735+ return nil, fmt.Errorf("failed to scan comment: %w", err)
736736+ }
737737+738738+ comment.Langs = langs
739739+ result[comment.URI] = &comment
740740+ }
741741+742742+ if err = rows.Err(); err != nil {
743743+ return nil, fmt.Errorf("error iterating comments: %w", err)
744744+ }
745745+746746+ return result, nil
747747+}
748748+749749+// ListByParentsBatch retrieves direct replies to multiple parents in a single query
750750+// Groups results by parent URI to prevent N+1 queries when loading nested replies
751751+// Uses window functions to limit results per parent efficiently
752752+func (r *postgresCommentRepo) ListByParentsBatch(
753753+ ctx context.Context,
754754+ parentURIs []string,
755755+ sort string,
756756+ limitPerParent int,
757757+) (map[string][]*comments.Comment, error) {
758758+ if len(parentURIs) == 0 {
759759+ return make(map[string][]*comments.Comment), nil
760760+ }
761761+762762+ // Build ORDER BY clause based on sort type
763763+ // windowOrderBy must inline expressions (can't use SELECT aliases in window functions)
764764+ var windowOrderBy string
765765+ var selectClause string
766766+ switch sort {
767767+ case "hot":
768768+ selectClause = `
769769+ c.id, c.uri, c.cid, c.rkey, c.commenter_did,
770770+ c.root_uri, c.root_cid, c.parent_uri, c.parent_cid,
771771+ c.content, c.content_facets, c.embed, c.content_labels, c.langs,
772772+ c.created_at, c.indexed_at, c.deleted_at,
773773+ c.upvote_count, c.downvote_count, c.score, c.reply_count,
774774+ log(greatest(2, c.score + 2)) / power(((EXTRACT(EPOCH FROM (NOW() - c.created_at)) / 3600) + 2), 1.8) as hot_rank,
775775+ COALESCE(u.handle, c.commenter_did) as author_handle`
776776+ // CRITICAL: Must inline hot_rank formula - PostgreSQL doesn't allow SELECT aliases in window ORDER BY
777777+ 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`
778778+ case "top":
779779+ selectClause = `
780780+ c.id, c.uri, c.cid, c.rkey, c.commenter_did,
781781+ c.root_uri, c.root_cid, c.parent_uri, c.parent_cid,
782782+ c.content, c.content_facets, c.embed, c.content_labels, c.langs,
783783+ c.created_at, c.indexed_at, c.deleted_at,
784784+ c.upvote_count, c.downvote_count, c.score, c.reply_count,
785785+ NULL::numeric as hot_rank,
786786+ COALESCE(u.handle, c.commenter_did) as author_handle`
787787+ windowOrderBy = `c.score DESC, c.created_at DESC`
788788+ case "new":
789789+ selectClause = `
790790+ c.id, c.uri, c.cid, c.rkey, c.commenter_did,
791791+ c.root_uri, c.root_cid, c.parent_uri, c.parent_cid,
792792+ c.content, c.content_facets, c.embed, c.content_labels, c.langs,
793793+ c.created_at, c.indexed_at, c.deleted_at,
794794+ c.upvote_count, c.downvote_count, c.score, c.reply_count,
795795+ NULL::numeric as hot_rank,
796796+ COALESCE(u.handle, c.commenter_did) as author_handle`
797797+ windowOrderBy = `c.created_at DESC`
798798+ default:
799799+ // Default to hot
800800+ selectClause = `
801801+ c.id, c.uri, c.cid, c.rkey, c.commenter_did,
802802+ c.root_uri, c.root_cid, c.parent_uri, c.parent_cid,
803803+ c.content, c.content_facets, c.embed, c.content_labels, c.langs,
804804+ c.created_at, c.indexed_at, c.deleted_at,
805805+ c.upvote_count, c.downvote_count, c.score, c.reply_count,
806806+ log(greatest(2, c.score + 2)) / power(((EXTRACT(EPOCH FROM (NOW() - c.created_at)) / 3600) + 2), 1.8) as hot_rank,
807807+ COALESCE(u.handle, c.commenter_did) as author_handle`
808808+ // CRITICAL: Must inline hot_rank formula - PostgreSQL doesn't allow SELECT aliases in window ORDER BY
809809+ 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`
810810+ }
811811+812812+ // Use window function to limit results per parent
813813+ // This is more efficient than LIMIT in a subquery per parent
814814+ // LEFT JOIN prevents data loss when user record hasn't been indexed yet (out-of-order Jetstream events)
815815+ query := fmt.Sprintf(`
816816+ WITH ranked_comments AS (
817817+ SELECT
818818+ %s,
819819+ ROW_NUMBER() OVER (
820820+ PARTITION BY c.parent_uri
821821+ ORDER BY %s
822822+ ) as rn
823823+ FROM comments c
824824+ LEFT JOIN users u ON c.commenter_did = u.did
825825+ WHERE c.parent_uri = ANY($1) AND c.deleted_at IS NULL
826826+ )
827827+ SELECT
828828+ id, uri, cid, rkey, commenter_did,
829829+ root_uri, root_cid, parent_uri, parent_cid,
830830+ content, content_facets, embed, content_labels, langs,
831831+ created_at, indexed_at, deleted_at,
832832+ upvote_count, downvote_count, score, reply_count,
833833+ hot_rank, author_handle
834834+ FROM ranked_comments
835835+ WHERE rn <= $2
836836+ ORDER BY parent_uri, rn
837837+ `, selectClause, windowOrderBy)
838838+839839+ rows, err := r.db.QueryContext(ctx, query, pq.Array(parentURIs), limitPerParent)
840840+ if err != nil {
841841+ return nil, fmt.Errorf("failed to batch query comments by parents: %w", err)
842842+ }
843843+ defer func() {
844844+ if err := rows.Close(); err != nil {
845845+ log.Printf("Failed to close rows: %v", err)
846846+ }
847847+ }()
848848+849849+ // Group results by parent URI
850850+ result := make(map[string][]*comments.Comment)
851851+ for rows.Next() {
852852+ var comment comments.Comment
853853+ var langs pq.StringArray
854854+ var hotRank sql.NullFloat64
855855+ var authorHandle string
856856+857857+ err := rows.Scan(
858858+ &comment.ID, &comment.URI, &comment.CID, &comment.RKey, &comment.CommenterDID,
859859+ &comment.RootURI, &comment.RootCID, &comment.ParentURI, &comment.ParentCID,
860860+ &comment.Content, &comment.ContentFacets, &comment.Embed, &comment.ContentLabels, &langs,
861861+ &comment.CreatedAt, &comment.IndexedAt, &comment.DeletedAt,
862862+ &comment.UpvoteCount, &comment.DownvoteCount, &comment.Score, &comment.ReplyCount,
863863+ &hotRank, &authorHandle,
864864+ )
865865+ if err != nil {
866866+ return nil, fmt.Errorf("failed to scan comment: %w", err)
867867+ }
868868+869869+ comment.Langs = langs
870870+ comment.CommenterHandle = authorHandle
871871+872872+ // Group by parent URI
873873+ result[comment.ParentURI] = append(result[comment.ParentURI], &comment)
874874+ }
875875+876876+ if err = rows.Err(); err != nil {
877877+ return nil, fmt.Errorf("error iterating comments: %w", err)
878878+ }
879879+880880+ return result, nil
881881+}
882882+883883+// GetVoteStateForComments retrieves the viewer's votes on a batch of comments
884884+// Returns map[commentURI]*Vote for efficient lookups
885885+// Note: This implementation is prepared for when the votes table indexing is implemented
886886+// Currently returns an empty map as votes may not be fully indexed yet
887887+func (r *postgresCommentRepo) GetVoteStateForComments(ctx context.Context, viewerDID string, commentURIs []string) (map[string]interface{}, error) {
888888+ if len(commentURIs) == 0 || viewerDID == "" {
889889+ return make(map[string]interface{}), nil
890890+ }
891891+892892+ // Query votes table for viewer's votes on these comments
893893+ // Note: This assumes votes table exists and is being indexed
894894+ // If votes table doesn't exist yet, this query will fail gracefully
895895+ query := `
896896+ SELECT subject_uri, direction, uri, cid, created_at
897897+ FROM votes
898898+ WHERE voter_did = $1 AND subject_uri = ANY($2) AND deleted_at IS NULL
899899+ `
900900+901901+ rows, err := r.db.QueryContext(ctx, query, viewerDID, pq.Array(commentURIs))
902902+ if err != nil {
903903+ // If votes table doesn't exist yet, return empty map instead of error
904904+ // This allows the API to work before votes indexing is fully implemented
905905+ if strings.Contains(err.Error(), "does not exist") {
906906+ return make(map[string]interface{}), nil
907907+ }
908908+ return nil, fmt.Errorf("failed to get vote state for comments: %w", err)
909909+ }
910910+ defer func() {
911911+ if err := rows.Close(); err != nil {
912912+ log.Printf("Failed to close rows: %v", err)
913913+ }
914914+ }()
915915+916916+ // Build result map with vote information
917917+ result := make(map[string]interface{})
918918+ for rows.Next() {
919919+ var subjectURI, direction, uri, cid string
920920+ var createdAt sql.NullTime
921921+922922+ err := rows.Scan(&subjectURI, &direction, &uri, &cid, &createdAt)
923923+ if err != nil {
924924+ return nil, fmt.Errorf("failed to scan vote: %w", err)
925925+ }
926926+927927+ // Store vote info as a simple map (can be enhanced later with proper Vote struct)
928928+ result[subjectURI] = map[string]interface{}{
929929+ "direction": direction,
930930+ "uri": uri,
931931+ "cid": cid,
932932+ "createdAt": createdAt.Time,
933933+ }
934934+ }
935935+936936+ if err = rows.Err(); err != nil {
937937+ return nil, fmt.Errorf("error iterating votes: %w", err)
938938+ }
939939+940940+ return result, nil
941941+}
+2-2
tests/integration/comment_consumer_test.go
···4545 RKey: rkey,
4646 CID: "bafytest123",
4747 Record: map[string]interface{}{
4848- "$type": "social.coves.feed.comment",
4848+ "$type": "social.coves.feed.comment",
4949 "content": "This is a test comment on a post!",
5050 "reply": map[string]interface{}{
5151 "root": map[string]interface{}{
···119119 RKey: rkey,
120120 CID: "bafytest456",
121121 Record: map[string]interface{}{
122122- "$type": "social.coves.feed.comment",
122122+ "$type": "social.coves.feed.comment",
123123 "content": "Idempotent test comment",
124124 "reply": map[string]interface{}{
125125 "root": map[string]interface{}{
+928
tests/integration/comment_query_test.go
···11+package integration
22+33+import (
44+ "Coves/internal/atproto/jetstream"
55+ "Coves/internal/core/comments"
66+ "Coves/internal/db/postgres"
77+ "context"
88+ "database/sql"
99+ "encoding/json"
1010+ "fmt"
1111+ "net/http"
1212+ "net/http/httptest"
1313+ "strings"
1414+ "testing"
1515+ "time"
1616+1717+ "github.com/stretchr/testify/assert"
1818+ "github.com/stretchr/testify/require"
1919+)
2020+2121+// TestCommentQuery_BasicFetch tests fetching top-level comments with default params
2222+func TestCommentQuery_BasicFetch(t *testing.T) {
2323+ db := setupTestDB(t)
2424+ defer func() {
2525+ if err := db.Close(); err != nil {
2626+ t.Logf("Failed to close database: %v", err)
2727+ }
2828+ }()
2929+3030+ ctx := context.Background()
3131+ testUser := createTestUser(t, db, "basicfetch.test", "did:plc:basicfetch123")
3232+ testCommunity, err := createFeedTestCommunity(db, ctx, "basicfetchcomm", "ownerbasic.test")
3333+ require.NoError(t, err, "Failed to create test community")
3434+3535+ postURI := createTestPost(t, db, testCommunity, testUser.DID, "Basic Fetch Test Post", 0, time.Now())
3636+3737+ // Create 3 top-level comments with different scores and ages
3838+ comment1 := createTestCommentWithScore(t, db, testUser.DID, postURI, postURI, "First comment", 10, 2, time.Now().Add(-2*time.Hour))
3939+ comment2 := createTestCommentWithScore(t, db, testUser.DID, postURI, postURI, "Second comment", 5, 1, time.Now().Add(-30*time.Minute))
4040+ comment3 := createTestCommentWithScore(t, db, testUser.DID, postURI, postURI, "Third comment", 3, 0, time.Now().Add(-5*time.Minute))
4141+4242+ // Fetch comments with default params (hot sort)
4343+ service := setupCommentService(db)
4444+ req := &comments.GetCommentsRequest{
4545+ PostURI: postURI,
4646+ Sort: "hot",
4747+ Depth: 10,
4848+ Limit: 50,
4949+ }
5050+5151+ resp, err := service.GetComments(ctx, req)
5252+ require.NoError(t, err, "GetComments should not return error")
5353+ require.NotNil(t, resp, "Response should not be nil")
5454+5555+ // Verify all 3 comments returned
5656+ assert.Len(t, resp.Comments, 3, "Should return all 3 top-level comments")
5757+5858+ // Verify stats are correct
5959+ for _, threadView := range resp.Comments {
6060+ commentView := threadView.Comment
6161+ assert.NotNil(t, commentView.Stats, "Stats should not be nil")
6262+6363+ // Verify upvotes, downvotes, score, reply count present
6464+ assert.GreaterOrEqual(t, commentView.Stats.Upvotes, 0, "Upvotes should be non-negative")
6565+ assert.GreaterOrEqual(t, commentView.Stats.Downvotes, 0, "Downvotes should be non-negative")
6666+ assert.Equal(t, 0, commentView.Stats.ReplyCount, "Top-level comments should have 0 replies")
6767+ }
6868+6969+ // Verify URIs match
7070+ commentURIs := []string{comment1, comment2, comment3}
7171+ returnedURIs := make(map[string]bool)
7272+ for _, tv := range resp.Comments {
7373+ returnedURIs[tv.Comment.URI] = true
7474+ }
7575+7676+ for _, uri := range commentURIs {
7777+ assert.True(t, returnedURIs[uri], "Comment URI %s should be in results", uri)
7878+ }
7979+}
8080+8181+// TestCommentQuery_NestedReplies tests fetching comments with nested reply structure
8282+func TestCommentQuery_NestedReplies(t *testing.T) {
8383+ db := setupTestDB(t)
8484+ defer func() {
8585+ if err := db.Close(); err != nil {
8686+ t.Logf("Failed to close database: %v", err)
8787+ }
8888+ }()
8989+9090+ ctx := context.Background()
9191+ testUser := createTestUser(t, db, "nested.test", "did:plc:nested123")
9292+ testCommunity, err := createFeedTestCommunity(db, ctx, "nestedcomm", "ownernested.test")
9393+ require.NoError(t, err)
9494+9595+ postURI := createTestPost(t, db, testCommunity, testUser.DID, "Nested Test Post", 0, time.Now())
9696+9797+ // Create nested structure:
9898+ // Post
9999+ // |- Comment A (top-level)
100100+ // |- Reply A1
101101+ // |- Reply A1a
102102+ // |- Reply A1b
103103+ // |- Reply A2
104104+ // |- Comment B (top-level)
105105+106106+ commentA := createTestCommentWithScore(t, db, testUser.DID, postURI, postURI, "Comment A", 5, 0, time.Now().Add(-1*time.Hour))
107107+ replyA1 := createTestCommentWithScore(t, db, testUser.DID, postURI, commentA, "Reply A1", 3, 0, time.Now().Add(-50*time.Minute))
108108+ replyA1a := createTestCommentWithScore(t, db, testUser.DID, postURI, replyA1, "Reply A1a", 2, 0, time.Now().Add(-40*time.Minute))
109109+ replyA1b := createTestCommentWithScore(t, db, testUser.DID, postURI, replyA1, "Reply A1b", 1, 0, time.Now().Add(-30*time.Minute))
110110+ replyA2 := createTestCommentWithScore(t, db, testUser.DID, postURI, commentA, "Reply A2", 2, 0, time.Now().Add(-20*time.Minute))
111111+ commentB := createTestCommentWithScore(t, db, testUser.DID, postURI, postURI, "Comment B", 4, 0, time.Now().Add(-10*time.Minute))
112112+113113+ // Fetch with depth=2 (should get 2 levels of nesting)
114114+ service := setupCommentService(db)
115115+ req := &comments.GetCommentsRequest{
116116+ PostURI: postURI,
117117+ Sort: "new",
118118+ Depth: 2,
119119+ Limit: 50,
120120+ }
121121+122122+ resp, err := service.GetComments(ctx, req)
123123+ require.NoError(t, err)
124124+ require.Len(t, resp.Comments, 2, "Should return 2 top-level comments")
125125+126126+ // Find Comment A in results
127127+ var commentAThread *comments.ThreadViewComment
128128+ for _, tv := range resp.Comments {
129129+ if tv.Comment.URI == commentA {
130130+ commentAThread = tv
131131+ break
132132+ }
133133+ }
134134+ require.NotNil(t, commentAThread, "Comment A should be in results")
135135+136136+ // Verify Comment A has replies
137137+ require.NotNil(t, commentAThread.Replies, "Comment A should have replies")
138138+ assert.Len(t, commentAThread.Replies, 2, "Comment A should have 2 direct replies (A1 and A2)")
139139+140140+ // Find Reply A1
141141+ var replyA1Thread *comments.ThreadViewComment
142142+ for _, reply := range commentAThread.Replies {
143143+ if reply.Comment.URI == replyA1 {
144144+ replyA1Thread = reply
145145+ break
146146+ }
147147+ }
148148+ require.NotNil(t, replyA1Thread, "Reply A1 should be in results")
149149+150150+ // Verify Reply A1 has nested replies (at depth 2)
151151+ require.NotNil(t, replyA1Thread.Replies, "Reply A1 should have nested replies at depth 2")
152152+ assert.Len(t, replyA1Thread.Replies, 2, "Reply A1 should have 2 nested replies (A1a and A1b)")
153153+154154+ // Verify reply URIs
155155+ replyURIs := make(map[string]bool)
156156+ for _, r := range replyA1Thread.Replies {
157157+ replyURIs[r.Comment.URI] = true
158158+ }
159159+ assert.True(t, replyURIs[replyA1a], "Reply A1a should be present")
160160+ assert.True(t, replyURIs[replyA1b], "Reply A1b should be present")
161161+162162+ // Verify no deeper nesting (depth limit enforced)
163163+ for _, r := range replyA1Thread.Replies {
164164+ assert.Nil(t, r.Replies, "Replies at depth 2 should not have further nesting")
165165+ }
166166+167167+ _ = commentB
168168+ _ = replyA2
169169+}
170170+171171+// TestCommentQuery_DepthLimit tests depth limiting works correctly
172172+func TestCommentQuery_DepthLimit(t *testing.T) {
173173+ db := setupTestDB(t)
174174+ defer func() {
175175+ if err := db.Close(); err != nil {
176176+ t.Logf("Failed to close database: %v", err)
177177+ }
178178+ }()
179179+180180+ ctx := context.Background()
181181+ testUser := createTestUser(t, db, "depth.test", "did:plc:depth123")
182182+ testCommunity, err := createFeedTestCommunity(db, ctx, "depthcomm", "ownerdepth.test")
183183+ require.NoError(t, err)
184184+185185+ postURI := createTestPost(t, db, testCommunity, testUser.DID, "Depth Test Post", 0, time.Now())
186186+187187+ // Create deeply nested thread (5 levels)
188188+ // Post -> C1 -> C2 -> C3 -> C4 -> C5
189189+ c1 := createTestCommentWithScore(t, db, testUser.DID, postURI, postURI, "Level 1", 5, 0, time.Now().Add(-5*time.Minute))
190190+ c2 := createTestCommentWithScore(t, db, testUser.DID, postURI, c1, "Level 2", 4, 0, time.Now().Add(-4*time.Minute))
191191+ c3 := createTestCommentWithScore(t, db, testUser.DID, postURI, c2, "Level 3", 3, 0, time.Now().Add(-3*time.Minute))
192192+ c4 := createTestCommentWithScore(t, db, testUser.DID, postURI, c3, "Level 4", 2, 0, time.Now().Add(-2*time.Minute))
193193+ c5 := createTestCommentWithScore(t, db, testUser.DID, postURI, c4, "Level 5", 1, 0, time.Now().Add(-1*time.Minute))
194194+195195+ t.Run("Depth 0 returns flat list", func(t *testing.T) {
196196+ service := setupCommentService(db)
197197+ req := &comments.GetCommentsRequest{
198198+ PostURI: postURI,
199199+ Sort: "new",
200200+ Depth: 0,
201201+ Limit: 50,
202202+ }
203203+204204+ resp, err := service.GetComments(ctx, req)
205205+ require.NoError(t, err)
206206+ require.Len(t, resp.Comments, 1, "Should return 1 top-level comment")
207207+208208+ // Verify no replies included
209209+ assert.Nil(t, resp.Comments[0].Replies, "Depth 0 should not include replies")
210210+211211+ // Verify HasMore flag is set (c1 has replies)
212212+ assert.True(t, resp.Comments[0].HasMore, "HasMore should be true when replies exist but depth=0")
213213+ })
214214+215215+ t.Run("Depth 3 returns exactly 3 levels", func(t *testing.T) {
216216+ service := setupCommentService(db)
217217+ req := &comments.GetCommentsRequest{
218218+ PostURI: postURI,
219219+ Sort: "new",
220220+ Depth: 3,
221221+ Limit: 50,
222222+ }
223223+224224+ resp, err := service.GetComments(ctx, req)
225225+ require.NoError(t, err)
226226+ require.Len(t, resp.Comments, 1, "Should return 1 top-level comment")
227227+228228+ // Traverse and verify exactly 3 levels
229229+ level1 := resp.Comments[0]
230230+ require.NotNil(t, level1.Replies, "Level 1 should have replies")
231231+ require.Len(t, level1.Replies, 1, "Level 1 should have 1 reply")
232232+233233+ level2 := level1.Replies[0]
234234+ require.NotNil(t, level2.Replies, "Level 2 should have replies")
235235+ require.Len(t, level2.Replies, 1, "Level 2 should have 1 reply")
236236+237237+ level3 := level2.Replies[0]
238238+ require.NotNil(t, level3.Replies, "Level 3 should have replies")
239239+ require.Len(t, level3.Replies, 1, "Level 3 should have 1 reply")
240240+241241+ // Level 4 should NOT have replies (depth limit)
242242+ level4 := level3.Replies[0]
243243+ assert.Nil(t, level4.Replies, "Level 4 should not have replies (depth limit)")
244244+245245+ // Verify HasMore is set correctly at depth boundary
246246+ assert.True(t, level4.HasMore, "HasMore should be true at depth boundary when more replies exist")
247247+ })
248248+249249+ _ = c2
250250+ _ = c3
251251+ _ = c4
252252+ _ = c5
253253+}
254254+255255+// TestCommentQuery_HotSorting tests hot sorting with Lemmy algorithm
256256+func TestCommentQuery_HotSorting(t *testing.T) {
257257+ db := setupTestDB(t)
258258+ defer func() {
259259+ if err := db.Close(); err != nil {
260260+ t.Logf("Failed to close database: %v", err)
261261+ }
262262+ }()
263263+264264+ ctx := context.Background()
265265+ testUser := createTestUser(t, db, "hot.test", "did:plc:hot123")
266266+ testCommunity, err := createFeedTestCommunity(db, ctx, "hotcomm", "ownerhot.test")
267267+ require.NoError(t, err)
268268+269269+ postURI := createTestPost(t, db, testCommunity, testUser.DID, "Hot Sorting Test", 0, time.Now())
270270+271271+ // Create 3 comments with different scores and ages
272272+ // Comment 1: score=10, created 1 hour ago
273273+ c1 := createTestCommentWithScore(t, db, testUser.DID, postURI, postURI, "Old high score", 10, 0, time.Now().Add(-1*time.Hour))
274274+275275+ // Comment 2: score=5, created 5 minutes ago (should rank higher due to recency)
276276+ c2 := createTestCommentWithScore(t, db, testUser.DID, postURI, postURI, "Recent medium score", 5, 0, time.Now().Add(-5*time.Minute))
277277+278278+ // Comment 3: score=-2, created now (negative score should rank lower)
279279+ c3 := createTestCommentWithScore(t, db, testUser.DID, postURI, postURI, "Negative score", 0, 2, time.Now())
280280+281281+ service := setupCommentService(db)
282282+ req := &comments.GetCommentsRequest{
283283+ PostURI: postURI,
284284+ Sort: "hot",
285285+ Depth: 0,
286286+ Limit: 50,
287287+ }
288288+289289+ resp, err := service.GetComments(ctx, req)
290290+ require.NoError(t, err)
291291+ require.Len(t, resp.Comments, 3, "Should return all 3 comments")
292292+293293+ // Verify hot sorting order
294294+ // Recent comment with medium score should rank higher than old comment with high score
295295+ assert.Equal(t, c2, resp.Comments[0].Comment.URI, "Recent medium score should rank first")
296296+ assert.Equal(t, c1, resp.Comments[1].Comment.URI, "Old high score should rank second")
297297+ assert.Equal(t, c3, resp.Comments[2].Comment.URI, "Negative score should rank last")
298298+299299+ // Verify negative scores are handled gracefully
300300+ negativeComment := resp.Comments[2].Comment
301301+ assert.Equal(t, -2, negativeComment.Stats.Score, "Negative score should be preserved")
302302+ assert.Equal(t, 0, negativeComment.Stats.Upvotes, "Upvotes should be 0")
303303+ assert.Equal(t, 2, negativeComment.Stats.Downvotes, "Downvotes should be 2")
304304+}
305305+306306+// TestCommentQuery_TopSorting tests top sorting with score-based ordering
307307+func TestCommentQuery_TopSorting(t *testing.T) {
308308+ db := setupTestDB(t)
309309+ defer func() {
310310+ if err := db.Close(); err != nil {
311311+ t.Logf("Failed to close database: %v", err)
312312+ }
313313+ }()
314314+315315+ ctx := context.Background()
316316+ testUser := createTestUser(t, db, "top.test", "did:plc:top123")
317317+ testCommunity, err := createFeedTestCommunity(db, ctx, "topcomm", "ownertop.test")
318318+ require.NoError(t, err)
319319+320320+ postURI := createTestPost(t, db, testCommunity, testUser.DID, "Top Sorting Test", 0, time.Now())
321321+322322+ // Create comments with different scores
323323+ c1 := createTestCommentWithScore(t, db, testUser.DID, postURI, postURI, "Low score", 2, 0, time.Now().Add(-30*time.Minute))
324324+ c2 := createTestCommentWithScore(t, db, testUser.DID, postURI, postURI, "High score", 10, 0, time.Now().Add(-1*time.Hour))
325325+ c3 := createTestCommentWithScore(t, db, testUser.DID, postURI, postURI, "Medium score", 5, 0, time.Now().Add(-15*time.Minute))
326326+327327+ t.Run("Top sort without timeframe", func(t *testing.T) {
328328+ service := setupCommentService(db)
329329+ req := &comments.GetCommentsRequest{
330330+ PostURI: postURI,
331331+ Sort: "top",
332332+ Depth: 0,
333333+ Limit: 50,
334334+ }
335335+336336+ resp, err := service.GetComments(ctx, req)
337337+ require.NoError(t, err)
338338+ require.Len(t, resp.Comments, 3)
339339+340340+ // Verify highest score first
341341+ assert.Equal(t, c2, resp.Comments[0].Comment.URI, "Highest score should be first")
342342+ assert.Equal(t, c3, resp.Comments[1].Comment.URI, "Medium score should be second")
343343+ assert.Equal(t, c1, resp.Comments[2].Comment.URI, "Low score should be third")
344344+ })
345345+346346+ t.Run("Top sort with hour timeframe", func(t *testing.T) {
347347+ service := setupCommentService(db)
348348+ req := &comments.GetCommentsRequest{
349349+ PostURI: postURI,
350350+ Sort: "top",
351351+ Timeframe: "hour",
352352+ Depth: 0,
353353+ Limit: 50,
354354+ }
355355+356356+ resp, err := service.GetComments(ctx, req)
357357+ require.NoError(t, err)
358358+359359+ // Only comments from last hour should be included (c1 and c3, not c2)
360360+ assert.LessOrEqual(t, len(resp.Comments), 2, "Should exclude comments older than 1 hour")
361361+362362+ // Verify c2 (created 1 hour ago) is excluded
363363+ for _, tv := range resp.Comments {
364364+ assert.NotEqual(t, c2, tv.Comment.URI, "Comment older than 1 hour should be excluded")
365365+ }
366366+ })
367367+}
368368+369369+// TestCommentQuery_NewSorting tests chronological sorting
370370+func TestCommentQuery_NewSorting(t *testing.T) {
371371+ db := setupTestDB(t)
372372+ defer func() {
373373+ if err := db.Close(); err != nil {
374374+ t.Logf("Failed to close database: %v", err)
375375+ }
376376+ }()
377377+378378+ ctx := context.Background()
379379+ testUser := createTestUser(t, db, "new.test", "did:plc:new123")
380380+ testCommunity, err := createFeedTestCommunity(db, ctx, "newcomm", "ownernew.test")
381381+ require.NoError(t, err)
382382+383383+ postURI := createTestPost(t, db, testCommunity, testUser.DID, "New Sorting Test", 0, time.Now())
384384+385385+ // Create comments at different times (different scores to verify time is priority)
386386+ c1 := createTestCommentWithScore(t, db, testUser.DID, postURI, postURI, "Oldest", 10, 0, time.Now().Add(-1*time.Hour))
387387+ c2 := createTestCommentWithScore(t, db, testUser.DID, postURI, postURI, "Middle", 5, 0, time.Now().Add(-30*time.Minute))
388388+ c3 := createTestCommentWithScore(t, db, testUser.DID, postURI, postURI, "Newest", 2, 0, time.Now().Add(-5*time.Minute))
389389+390390+ service := setupCommentService(db)
391391+ req := &comments.GetCommentsRequest{
392392+ PostURI: postURI,
393393+ Sort: "new",
394394+ Depth: 0,
395395+ Limit: 50,
396396+ }
397397+398398+ resp, err := service.GetComments(ctx, req)
399399+ require.NoError(t, err)
400400+ require.Len(t, resp.Comments, 3)
401401+402402+ // Verify chronological order (newest first)
403403+ assert.Equal(t, c3, resp.Comments[0].Comment.URI, "Newest comment should be first")
404404+ assert.Equal(t, c2, resp.Comments[1].Comment.URI, "Middle comment should be second")
405405+ assert.Equal(t, c1, resp.Comments[2].Comment.URI, "Oldest comment should be third")
406406+}
407407+408408+// TestCommentQuery_Pagination tests cursor-based pagination
409409+func TestCommentQuery_Pagination(t *testing.T) {
410410+ db := setupTestDB(t)
411411+ defer func() {
412412+ if err := db.Close(); err != nil {
413413+ t.Logf("Failed to close database: %v", err)
414414+ }
415415+ }()
416416+417417+ ctx := context.Background()
418418+ testUser := createTestUser(t, db, "page.test", "did:plc:page123")
419419+ testCommunity, err := createFeedTestCommunity(db, ctx, "pagecomm", "ownerpage.test")
420420+ require.NoError(t, err)
421421+422422+ postURI := createTestPost(t, db, testCommunity, testUser.DID, "Pagination Test", 0, time.Now())
423423+424424+ // Create 60 comments
425425+ allCommentURIs := make([]string, 60)
426426+ for i := 0; i < 60; i++ {
427427+ uri := createTestCommentWithScore(t, db, testUser.DID, postURI, postURI,
428428+ fmt.Sprintf("Comment %d", i), i, 0, time.Now().Add(-time.Duration(60-i)*time.Minute))
429429+ allCommentURIs[i] = uri
430430+ }
431431+432432+ service := setupCommentService(db)
433433+434434+ // Fetch first page (limit=50)
435435+ req1 := &comments.GetCommentsRequest{
436436+ PostURI: postURI,
437437+ Sort: "new",
438438+ Depth: 0,
439439+ Limit: 50,
440440+ }
441441+442442+ resp1, err := service.GetComments(ctx, req1)
443443+ require.NoError(t, err)
444444+ assert.Len(t, resp1.Comments, 50, "First page should have 50 comments")
445445+ require.NotNil(t, resp1.Cursor, "Cursor should be present for next page")
446446+447447+ // Fetch second page with cursor
448448+ req2 := &comments.GetCommentsRequest{
449449+ PostURI: postURI,
450450+ Sort: "new",
451451+ Depth: 0,
452452+ Limit: 50,
453453+ Cursor: resp1.Cursor,
454454+ }
455455+456456+ resp2, err := service.GetComments(ctx, req2)
457457+ require.NoError(t, err)
458458+ assert.Len(t, resp2.Comments, 10, "Second page should have remaining 10 comments")
459459+ assert.Nil(t, resp2.Cursor, "Cursor should be nil on last page")
460460+461461+ // Verify no duplicates between pages
462462+ page1URIs := make(map[string]bool)
463463+ for _, tv := range resp1.Comments {
464464+ page1URIs[tv.Comment.URI] = true
465465+ }
466466+467467+ for _, tv := range resp2.Comments {
468468+ assert.False(t, page1URIs[tv.Comment.URI], "Comment %s should not appear in both pages", tv.Comment.URI)
469469+ }
470470+471471+ // Verify all comments eventually retrieved
472472+ allRetrieved := make(map[string]bool)
473473+ for _, tv := range resp1.Comments {
474474+ allRetrieved[tv.Comment.URI] = true
475475+ }
476476+ for _, tv := range resp2.Comments {
477477+ allRetrieved[tv.Comment.URI] = true
478478+ }
479479+ assert.Len(t, allRetrieved, 60, "All 60 comments should be retrieved across pages")
480480+}
481481+482482+// TestCommentQuery_EmptyThread tests fetching comments from a post with no comments
483483+func TestCommentQuery_EmptyThread(t *testing.T) {
484484+ db := setupTestDB(t)
485485+ defer func() {
486486+ if err := db.Close(); err != nil {
487487+ t.Logf("Failed to close database: %v", err)
488488+ }
489489+ }()
490490+491491+ ctx := context.Background()
492492+ testUser := createTestUser(t, db, "empty.test", "did:plc:empty123")
493493+ testCommunity, err := createFeedTestCommunity(db, ctx, "emptycomm", "ownerempty.test")
494494+ require.NoError(t, err)
495495+496496+ postURI := createTestPost(t, db, testCommunity, testUser.DID, "Empty Thread Test", 0, time.Now())
497497+498498+ service := setupCommentService(db)
499499+ req := &comments.GetCommentsRequest{
500500+ PostURI: postURI,
501501+ Sort: "hot",
502502+ Depth: 10,
503503+ Limit: 50,
504504+ }
505505+506506+ resp, err := service.GetComments(ctx, req)
507507+ require.NoError(t, err)
508508+ require.NotNil(t, resp, "Response should not be nil")
509509+510510+ // Verify empty array (not null)
511511+ assert.NotNil(t, resp.Comments, "Comments array should not be nil")
512512+ assert.Len(t, resp.Comments, 0, "Comments array should be empty")
513513+514514+ // Verify no cursor returned
515515+ assert.Nil(t, resp.Cursor, "Cursor should be nil for empty results")
516516+}
517517+518518+// TestCommentQuery_DeletedComments tests that soft-deleted comments are excluded
519519+func TestCommentQuery_DeletedComments(t *testing.T) {
520520+ db := setupTestDB(t)
521521+ defer func() {
522522+ if err := db.Close(); err != nil {
523523+ t.Logf("Failed to close database: %v", err)
524524+ }
525525+ }()
526526+527527+ ctx := context.Background()
528528+ commentRepo := postgres.NewCommentRepository(db)
529529+ consumer := jetstream.NewCommentEventConsumer(commentRepo, db)
530530+531531+ testUser := createTestUser(t, db, "deleted.test", "did:plc:deleted123")
532532+ testCommunity, err := createFeedTestCommunity(db, ctx, "deletedcomm", "ownerdeleted.test")
533533+ require.NoError(t, err)
534534+535535+ postURI := createTestPost(t, db, testCommunity, testUser.DID, "Deleted Comments Test", 0, time.Now())
536536+537537+ // Create 5 comments via Jetstream consumer
538538+ commentURIs := make([]string, 5)
539539+ for i := 0; i < 5; i++ {
540540+ rkey := generateTID()
541541+ uri := fmt.Sprintf("at://%s/social.coves.feed.comment/%s", testUser.DID, rkey)
542542+ commentURIs[i] = uri
543543+544544+ event := &jetstream.JetstreamEvent{
545545+ Did: testUser.DID,
546546+ Kind: "commit",
547547+ Commit: &jetstream.CommitEvent{
548548+ Operation: "create",
549549+ Collection: "social.coves.feed.comment",
550550+ RKey: rkey,
551551+ CID: fmt.Sprintf("bafyc%d", i),
552552+ Record: map[string]interface{}{
553553+ "$type": "social.coves.feed.comment",
554554+ "content": fmt.Sprintf("Comment %d", i),
555555+ "reply": map[string]interface{}{
556556+ "root": map[string]interface{}{
557557+ "uri": postURI,
558558+ "cid": "bafypost",
559559+ },
560560+ "parent": map[string]interface{}{
561561+ "uri": postURI,
562562+ "cid": "bafypost",
563563+ },
564564+ },
565565+ "createdAt": time.Now().Add(time.Duration(i) * time.Minute).Format(time.RFC3339),
566566+ },
567567+ },
568568+ }
569569+570570+ require.NoError(t, consumer.HandleEvent(ctx, event))
571571+ }
572572+573573+ // Soft-delete 2 comments (index 1 and 3)
574574+ deleteEvent1 := &jetstream.JetstreamEvent{
575575+ Did: testUser.DID,
576576+ Kind: "commit",
577577+ Commit: &jetstream.CommitEvent{
578578+ Operation: "delete",
579579+ Collection: "social.coves.feed.comment",
580580+ RKey: strings.Split(commentURIs[1], "/")[4],
581581+ },
582582+ }
583583+ require.NoError(t, consumer.HandleEvent(ctx, deleteEvent1))
584584+585585+ deleteEvent2 := &jetstream.JetstreamEvent{
586586+ Did: testUser.DID,
587587+ Kind: "commit",
588588+ Commit: &jetstream.CommitEvent{
589589+ Operation: "delete",
590590+ Collection: "social.coves.feed.comment",
591591+ RKey: strings.Split(commentURIs[3], "/")[4],
592592+ },
593593+ }
594594+ require.NoError(t, consumer.HandleEvent(ctx, deleteEvent2))
595595+596596+ // Fetch comments
597597+ service := setupCommentService(db)
598598+ req := &comments.GetCommentsRequest{
599599+ PostURI: postURI,
600600+ Sort: "new",
601601+ Depth: 0,
602602+ Limit: 50,
603603+ }
604604+605605+ resp, err := service.GetComments(ctx, req)
606606+ require.NoError(t, err)
607607+608608+ // Verify only 3 comments returned (2 were deleted)
609609+ assert.Len(t, resp.Comments, 3, "Should only return non-deleted comments")
610610+611611+ // Verify deleted comments are not in results
612612+ returnedURIs := make(map[string]bool)
613613+ for _, tv := range resp.Comments {
614614+ returnedURIs[tv.Comment.URI] = true
615615+ }
616616+617617+ assert.False(t, returnedURIs[commentURIs[1]], "Deleted comment 1 should not be in results")
618618+ assert.False(t, returnedURIs[commentURIs[3]], "Deleted comment 3 should not be in results")
619619+ assert.True(t, returnedURIs[commentURIs[0]], "Non-deleted comment 0 should be in results")
620620+ assert.True(t, returnedURIs[commentURIs[2]], "Non-deleted comment 2 should be in results")
621621+ assert.True(t, returnedURIs[commentURIs[4]], "Non-deleted comment 4 should be in results")
622622+}
623623+624624+// TestCommentQuery_InvalidInputs tests error handling for invalid inputs
625625+func TestCommentQuery_InvalidInputs(t *testing.T) {
626626+ db := setupTestDB(t)
627627+ defer func() {
628628+ if err := db.Close(); err != nil {
629629+ t.Logf("Failed to close database: %v", err)
630630+ }
631631+ }()
632632+633633+ ctx := context.Background()
634634+ service := setupCommentService(db)
635635+636636+ t.Run("Invalid post URI", func(t *testing.T) {
637637+ req := &comments.GetCommentsRequest{
638638+ PostURI: "not-an-at-uri",
639639+ Sort: "hot",
640640+ Depth: 10,
641641+ Limit: 50,
642642+ }
643643+644644+ _, err := service.GetComments(ctx, req)
645645+ assert.Error(t, err, "Should return error for invalid AT-URI")
646646+ assert.Contains(t, err.Error(), "invalid", "Error should mention invalid")
647647+ })
648648+649649+ t.Run("Negative depth", func(t *testing.T) {
650650+ req := &comments.GetCommentsRequest{
651651+ PostURI: "at://did:plc:test/social.coves.feed.post/abc123",
652652+ Sort: "hot",
653653+ Depth: -5,
654654+ Limit: 50,
655655+ }
656656+657657+ resp, err := service.GetComments(ctx, req)
658658+ // Should not error, but should clamp to default (10)
659659+ require.NoError(t, err)
660660+ // Depth is normalized in validation
661661+ _ = resp
662662+ })
663663+664664+ t.Run("Depth exceeds max", func(t *testing.T) {
665665+ req := &comments.GetCommentsRequest{
666666+ PostURI: "at://did:plc:test/social.coves.feed.post/abc123",
667667+ Sort: "hot",
668668+ Depth: 150, // Exceeds max of 100
669669+ Limit: 50,
670670+ }
671671+672672+ resp, err := service.GetComments(ctx, req)
673673+ // Should not error, but should clamp to 100
674674+ require.NoError(t, err)
675675+ _ = resp
676676+ })
677677+678678+ t.Run("Limit exceeds max", func(t *testing.T) {
679679+ req := &comments.GetCommentsRequest{
680680+ PostURI: "at://did:plc:test/social.coves.feed.post/abc123",
681681+ Sort: "hot",
682682+ Depth: 10,
683683+ Limit: 150, // Exceeds max of 100
684684+ }
685685+686686+ resp, err := service.GetComments(ctx, req)
687687+ // Should not error, but should clamp to 100
688688+ require.NoError(t, err)
689689+ _ = resp
690690+ })
691691+692692+ t.Run("Invalid sort", func(t *testing.T) {
693693+ req := &comments.GetCommentsRequest{
694694+ PostURI: "at://did:plc:test/social.coves.feed.post/abc123",
695695+ Sort: "invalid",
696696+ Depth: 10,
697697+ Limit: 50,
698698+ }
699699+700700+ _, err := service.GetComments(ctx, req)
701701+ assert.Error(t, err, "Should return error for invalid sort")
702702+ assert.Contains(t, err.Error(), "invalid sort", "Error should mention invalid sort")
703703+ })
704704+705705+ t.Run("Empty post URI", func(t *testing.T) {
706706+ req := &comments.GetCommentsRequest{
707707+ PostURI: "",
708708+ Sort: "hot",
709709+ Depth: 10,
710710+ Limit: 50,
711711+ }
712712+713713+ _, err := service.GetComments(ctx, req)
714714+ assert.Error(t, err, "Should return error for empty post URI")
715715+ })
716716+}
717717+718718+// TestCommentQuery_HTTPHandler tests the HTTP handler end-to-end
719719+func TestCommentQuery_HTTPHandler(t *testing.T) {
720720+ db := setupTestDB(t)
721721+ defer func() {
722722+ if err := db.Close(); err != nil {
723723+ t.Logf("Failed to close database: %v", err)
724724+ }
725725+ }()
726726+727727+ ctx := context.Background()
728728+ testUser := createTestUser(t, db, "http.test", "did:plc:http123")
729729+ testCommunity, err := createFeedTestCommunity(db, ctx, "httpcomm", "ownerhttp.test")
730730+ require.NoError(t, err)
731731+732732+ postURI := createTestPost(t, db, testCommunity, testUser.DID, "HTTP Handler Test", 0, time.Now())
733733+734734+ // Create test comments
735735+ createTestCommentWithScore(t, db, testUser.DID, postURI, postURI, "Test comment 1", 5, 0, time.Now().Add(-30*time.Minute))
736736+ createTestCommentWithScore(t, db, testUser.DID, postURI, postURI, "Test comment 2", 3, 0, time.Now().Add(-15*time.Minute))
737737+738738+ // Setup service adapter for HTTP handler
739739+ service := setupCommentServiceAdapter(db)
740740+ handler := &testGetCommentsHandler{service: service}
741741+742742+ t.Run("Valid GET request", func(t *testing.T) {
743743+ req := httptest.NewRequest("GET", fmt.Sprintf("/xrpc/social.coves.feed.getComments?post=%s&sort=hot&depth=10&limit=50", postURI), nil)
744744+ w := httptest.NewRecorder()
745745+746746+ handler.ServeHTTP(w, req)
747747+748748+ assert.Equal(t, http.StatusOK, w.Code)
749749+ assert.Equal(t, "application/json", w.Header().Get("Content-Type"))
750750+751751+ var resp comments.GetCommentsResponse
752752+ err := json.NewDecoder(w.Body).Decode(&resp)
753753+ require.NoError(t, err)
754754+ assert.Len(t, resp.Comments, 2, "Should return 2 comments")
755755+ })
756756+757757+ t.Run("Missing post parameter", func(t *testing.T) {
758758+ req := httptest.NewRequest("GET", "/xrpc/social.coves.feed.getComments?sort=hot", nil)
759759+ w := httptest.NewRecorder()
760760+761761+ handler.ServeHTTP(w, req)
762762+763763+ assert.Equal(t, http.StatusBadRequest, w.Code)
764764+ })
765765+766766+ t.Run("Invalid depth parameter", func(t *testing.T) {
767767+ req := httptest.NewRequest("GET", fmt.Sprintf("/xrpc/social.coves.feed.getComments?post=%s&depth=invalid", postURI), nil)
768768+ w := httptest.NewRecorder()
769769+770770+ handler.ServeHTTP(w, req)
771771+772772+ assert.Equal(t, http.StatusBadRequest, w.Code)
773773+ })
774774+}
775775+776776+// Helper: setupCommentService creates a comment service for testing
777777+func setupCommentService(db *sql.DB) comments.Service {
778778+ commentRepo := postgres.NewCommentRepository(db)
779779+ postRepo := postgres.NewPostRepository(db)
780780+ userRepo := postgres.NewUserRepository(db)
781781+ communityRepo := postgres.NewCommunityRepository(db)
782782+ return comments.NewCommentService(commentRepo, userRepo, postRepo, communityRepo)
783783+}
784784+785785+// Helper: createTestCommentWithScore creates a comment with specific vote counts
786786+func createTestCommentWithScore(t *testing.T, db *sql.DB, commenterDID, rootURI, parentURI, content string, upvotes, downvotes int, createdAt time.Time) string {
787787+ t.Helper()
788788+789789+ ctx := context.Background()
790790+ rkey := generateTID()
791791+ uri := fmt.Sprintf("at://%s/social.coves.feed.comment/%s", commenterDID, rkey)
792792+793793+ // Insert comment directly for speed
794794+ _, err := db.ExecContext(ctx, `
795795+ INSERT INTO comments (
796796+ uri, cid, rkey, commenter_did,
797797+ root_uri, root_cid, parent_uri, parent_cid,
798798+ content, created_at, indexed_at,
799799+ upvote_count, downvote_count, score
800800+ ) VALUES (
801801+ $1, $2, $3, $4,
802802+ $5, $6, $7, $8,
803803+ $9, $10, NOW(),
804804+ $11, $12, $13
805805+ )
806806+ `, uri, fmt.Sprintf("bafyc%s", rkey), rkey, commenterDID,
807807+ rootURI, "bafyroot", parentURI, "bafyparent",
808808+ content, createdAt,
809809+ upvotes, downvotes, upvotes-downvotes)
810810+811811+ require.NoError(t, err, "Failed to create test comment")
812812+813813+ // Update reply count on parent if it's a nested comment
814814+ if parentURI != rootURI {
815815+ _, _ = db.ExecContext(ctx, `
816816+ UPDATE comments
817817+ SET reply_count = reply_count + 1
818818+ WHERE uri = $1
819819+ `, parentURI)
820820+ } else {
821821+ // Update comment count on post if top-level
822822+ _, _ = db.ExecContext(ctx, `
823823+ UPDATE posts
824824+ SET comment_count = comment_count + 1
825825+ WHERE uri = $1
826826+ `, parentURI)
827827+ }
828828+829829+ return uri
830830+}
831831+832832+// Helper: Service adapter for HTTP handler testing
833833+type testCommentServiceAdapter struct {
834834+ service comments.Service
835835+}
836836+837837+func (s *testCommentServiceAdapter) GetComments(r *http.Request, req *testGetCommentsRequest) (*comments.GetCommentsResponse, error) {
838838+ ctx := r.Context()
839839+840840+ serviceReq := &comments.GetCommentsRequest{
841841+ PostURI: req.PostURI,
842842+ Sort: req.Sort,
843843+ Timeframe: req.Timeframe,
844844+ Depth: req.Depth,
845845+ Limit: req.Limit,
846846+ Cursor: req.Cursor,
847847+ ViewerDID: req.ViewerDID,
848848+ }
849849+850850+ return s.service.GetComments(ctx, serviceReq)
851851+}
852852+853853+type testGetCommentsRequest struct {
854854+ Cursor *string
855855+ ViewerDID *string
856856+ PostURI string
857857+ Sort string
858858+ Timeframe string
859859+ Depth int
860860+ Limit int
861861+}
862862+863863+func setupCommentServiceAdapter(db *sql.DB) *testCommentServiceAdapter {
864864+ commentRepo := postgres.NewCommentRepository(db)
865865+ postRepo := postgres.NewPostRepository(db)
866866+ userRepo := postgres.NewUserRepository(db)
867867+ communityRepo := postgres.NewCommunityRepository(db)
868868+ service := comments.NewCommentService(commentRepo, userRepo, postRepo, communityRepo)
869869+ return &testCommentServiceAdapter{service: service}
870870+}
871871+872872+// Helper: Simple HTTP handler wrapper for testing
873873+type testGetCommentsHandler struct {
874874+ service *testCommentServiceAdapter
875875+}
876876+877877+func (h *testGetCommentsHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
878878+ if r.Method != http.MethodGet {
879879+ http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
880880+ return
881881+ }
882882+883883+ query := r.URL.Query()
884884+ post := query.Get("post")
885885+886886+ if post == "" {
887887+ http.Error(w, "post parameter is required", http.StatusBadRequest)
888888+ return
889889+ }
890890+891891+ sort := query.Get("sort")
892892+ if sort == "" {
893893+ sort = "hot"
894894+ }
895895+896896+ depth := 10
897897+ if d := query.Get("depth"); d != "" {
898898+ if _, err := fmt.Sscanf(d, "%d", &depth); err != nil {
899899+ http.Error(w, "invalid depth", http.StatusBadRequest)
900900+ return
901901+ }
902902+ }
903903+904904+ limit := 50
905905+ if l := query.Get("limit"); l != "" {
906906+ if _, err := fmt.Sscanf(l, "%d", &limit); err != nil {
907907+ http.Error(w, "invalid limit", http.StatusBadRequest)
908908+ return
909909+ }
910910+ }
911911+912912+ req := &testGetCommentsRequest{
913913+ PostURI: post,
914914+ Sort: sort,
915915+ Depth: depth,
916916+ Limit: limit,
917917+ }
918918+919919+ resp, err := h.service.GetComments(r, req)
920920+ if err != nil {
921921+ http.Error(w, err.Error(), http.StatusInternalServerError)
922922+ return
923923+ }
924924+925925+ w.Header().Set("Content-Type", "application/json")
926926+ w.WriteHeader(http.StatusOK)
927927+ _ = json.NewEncoder(w).Encode(resp)
928928+}
+3-6
tests/integration/community_consumer_test.go
···369369370370// mockIdentityResolver is a test double for identity resolution
371371type mockIdentityResolver struct {
372372- // Map of DID -> handle for successful resolutions
373372 resolutions map[string]string
374374- // If true, Resolve returns an error
375375- shouldFail bool
376376- // Track calls to verify invocation
377377- callCount int
378378- lastDID string
373373+ lastDID string
374374+ callCount int
375375+ shouldFail bool
379376}
380377381378func newMockIdentityResolver() *mockIdentityResolver {
-12
tests/integration/user_test.go
···7070 return db
7171}
72727373-// setupIdentityResolver creates an identity resolver configured for local PLC testing
7474-func setupIdentityResolver(db *sql.DB) interface{ Resolve(context.Context, string) (*identity.Identity, error) } {
7575- plcURL := os.Getenv("PLC_DIRECTORY_URL")
7676- if plcURL == "" {
7777- plcURL = "http://localhost:3002" // Local PLC directory
7878- }
7979-8080- config := identity.DefaultConfig()
8181- config.PLCURL = plcURL
8282- return identity.NewResolver(db, config)
8383-}
8484-8573// generateTestDID generates a unique test DID for integration tests
8674// V2.0: No longer uses DID generator - just creates valid did:plc strings
8775func generateTestDID(suffix string) string {
+6-6
tests/lexicon_validation_test.go
···72727373 // Test specific cross-references that should work
7474 crossRefs := map[string]string{
7575- "social.coves.richtext.facet#byteSlice": "byteSlice definition in facet schema",
7676- "social.coves.community.rules#rule": "rule definition in community rules",
7777- "social.coves.actor.defs#profileView": "profileView definition in actor defs",
7878- "social.coves.actor.defs#profileStats": "profileStats definition in actor defs",
7979- "social.coves.actor.defs#viewerState": "viewerState definition in actor defs",
8080- "social.coves.community.defs#communityView": "communityView definition in community defs",
7575+ "social.coves.richtext.facet#byteSlice": "byteSlice definition in facet schema",
7676+ "social.coves.community.rules#rule": "rule definition in community rules",
7777+ "social.coves.actor.defs#profileView": "profileView definition in actor defs",
7878+ "social.coves.actor.defs#profileStats": "profileStats definition in actor defs",
7979+ "social.coves.actor.defs#viewerState": "viewerState definition in actor defs",
8080+ "social.coves.community.defs#communityView": "communityView definition in community defs",
8181 "social.coves.community.defs#communityStats": "communityStats definition in community defs",
8282 }
8383