A community based topic aggregation platform built on atproto
1package comments
2
3import (
4 "context"
5 "errors"
6 "fmt"
7 "log"
8 "strings"
9 "time"
10
11 "Coves/internal/core/communities"
12 "Coves/internal/core/posts"
13 "Coves/internal/core/users"
14)
15
16const (
17 // DefaultRepliesPerParent defines how many nested replies to load per parent comment
18 // This balances UX (showing enough context) with performance (limiting query size)
19 // Can be made configurable via constructor if needed in the future
20 DefaultRepliesPerParent = 5
21)
22
23// Service defines the business logic interface for comment operations
24// Orchestrates repository calls and builds view models for API responses
25type Service interface {
26 // GetComments retrieves and builds a threaded comment tree for a post
27 // Supports hot, top, and new sorting with configurable depth and pagination
28 GetComments(ctx context.Context, req *GetCommentsRequest) (*GetCommentsResponse, error)
29}
30
31// GetCommentsRequest defines the parameters for fetching comments
32type GetCommentsRequest struct {
33 Cursor *string
34 ViewerDID *string
35 PostURI string
36 Sort string
37 Timeframe string
38 Depth int
39 Limit int
40}
41
42// commentService implements the Service interface
43// Coordinates between repository layer and view model construction
44type commentService struct {
45 commentRepo Repository // Comment data access
46 userRepo users.UserRepository // User lookup for author hydration
47 postRepo posts.Repository // Post lookup for building post views
48 communityRepo communities.Repository // Community lookup for community hydration
49}
50
51// NewCommentService creates a new comment service instance
52// All repositories are required for proper view construction per lexicon requirements
53func NewCommentService(
54 commentRepo Repository,
55 userRepo users.UserRepository,
56 postRepo posts.Repository,
57 communityRepo communities.Repository,
58) Service {
59 return &commentService{
60 commentRepo: commentRepo,
61 userRepo: userRepo,
62 postRepo: postRepo,
63 communityRepo: communityRepo,
64 }
65}
66
67// GetComments retrieves comments for a post with threading and pagination
68// Algorithm:
69// 1. Validate input parameters and apply defaults
70// 2. Fetch top-level comments with specified sorting
71// 3. Recursively load nested replies up to depth limit
72// 4. Build view models with author info and stats
73// 5. Return response with pagination cursor
74func (s *commentService) GetComments(ctx context.Context, req *GetCommentsRequest) (*GetCommentsResponse, error) {
75 // 1. Validate inputs and apply defaults/bounds FIRST (before expensive operations)
76 if err := validateGetCommentsRequest(req); err != nil {
77 return nil, fmt.Errorf("invalid request: %w", err)
78 }
79
80 // Add timeout to prevent runaway queries with deep nesting
81 ctx, cancel := context.WithTimeout(ctx, 10*time.Second)
82 defer cancel()
83
84 // 2. Fetch post for context
85 post, err := s.postRepo.GetByURI(ctx, req.PostURI)
86 if err != nil {
87 // Translate post not-found errors to comment-layer errors for proper HTTP status
88 if posts.IsNotFound(err) {
89 return nil, ErrRootNotFound
90 }
91 return nil, fmt.Errorf("failed to fetch post: %w", err)
92 }
93
94 // Build post view for response (hydrates author handle and community name)
95 postView := s.buildPostView(ctx, post, req.ViewerDID)
96
97 // 3. Fetch top-level comments with pagination
98 // Uses repository's hot rank sorting and cursor-based pagination
99 topComments, nextCursor, err := s.commentRepo.ListByParentWithHotRank(
100 ctx,
101 req.PostURI,
102 req.Sort,
103 req.Timeframe,
104 req.Limit,
105 req.Cursor,
106 )
107 if err != nil {
108 return nil, fmt.Errorf("failed to fetch top-level comments: %w", err)
109 }
110
111 // 4. Build threaded view with nested replies up to depth limit
112 // This iteratively loads child comments and builds the tree structure
113 threadViews := s.buildThreadViews(ctx, topComments, req.Depth, req.Sort, req.ViewerDID)
114
115 // 5. Return response with comments, post reference, and cursor
116 return &GetCommentsResponse{
117 Comments: threadViews,
118 Post: postView,
119 Cursor: nextCursor,
120 }, nil
121}
122
123// buildThreadViews constructs threaded comment views with nested replies using batch loading
124// Uses batch queries to prevent N+1 query problem when loading nested replies
125// Loads replies level-by-level up to the specified depth limit
126func (s *commentService) buildThreadViews(
127 ctx context.Context,
128 comments []*Comment,
129 remainingDepth int,
130 sort string,
131 viewerDID *string,
132) []*ThreadViewComment {
133 // Always return an empty slice, never nil (important for JSON serialization)
134 result := make([]*ThreadViewComment, 0, len(comments))
135
136 if len(comments) == 0 {
137 return result
138 }
139
140 // Batch fetch vote states for all comments at this level (Phase 2B)
141 var voteStates map[string]interface{}
142 if viewerDID != nil {
143 commentURIs := make([]string, 0, len(comments))
144 for _, comment := range comments {
145 if comment.DeletedAt == nil {
146 commentURIs = append(commentURIs, comment.URI)
147 }
148 }
149
150 if len(commentURIs) > 0 {
151 var err error
152 voteStates, err = s.commentRepo.GetVoteStateForComments(ctx, *viewerDID, commentURIs)
153 if err != nil {
154 // Log error but don't fail the request - vote state is optional
155 log.Printf("Warning: Failed to fetch vote states for comments: %v", err)
156 }
157 }
158 }
159
160 // Build thread views for current level
161 threadViews := make([]*ThreadViewComment, 0, len(comments))
162 commentsByURI := make(map[string]*ThreadViewComment)
163 parentsWithReplies := make([]string, 0)
164
165 for _, comment := range comments {
166 // Skip deleted comments (soft-deleted records)
167 if comment.DeletedAt != nil {
168 continue
169 }
170
171 // Build the comment view with author info and stats
172 commentView := s.buildCommentView(comment, viewerDID, voteStates)
173
174 threadView := &ThreadViewComment{
175 Comment: commentView,
176 Replies: nil,
177 HasMore: comment.ReplyCount > 0 && remainingDepth == 0,
178 }
179
180 threadViews = append(threadViews, threadView)
181 commentsByURI[comment.URI] = threadView
182
183 // Collect parent URIs that have replies and depth remaining
184 if remainingDepth > 0 && comment.ReplyCount > 0 {
185 parentsWithReplies = append(parentsWithReplies, comment.URI)
186 }
187 }
188
189 // Batch load all replies for this level in a single query
190 if len(parentsWithReplies) > 0 {
191 repliesByParent, err := s.commentRepo.ListByParentsBatch(
192 ctx,
193 parentsWithReplies,
194 sort,
195 DefaultRepliesPerParent,
196 )
197
198 // Process replies if batch query succeeded
199 if err == nil {
200 // Group child comments by parent for recursive processing
201 for parentURI, replies := range repliesByParent {
202 threadView := commentsByURI[parentURI]
203 if threadView != nil && len(replies) > 0 {
204 // Recursively build views for child comments
205 threadView.Replies = s.buildThreadViews(
206 ctx,
207 replies,
208 remainingDepth-1,
209 sort,
210 viewerDID,
211 )
212
213 // Update HasMore based on actual reply count vs loaded count
214 // Get the original comment to check reply count
215 for _, comment := range comments {
216 if comment.URI == parentURI {
217 threadView.HasMore = comment.ReplyCount > len(replies)
218 break
219 }
220 }
221 }
222 }
223 }
224 }
225
226 return threadViews
227}
228
229// buildCommentView converts a Comment entity to a CommentView with full metadata
230// Constructs author view, stats, and references to parent post/comment
231// voteStates map contains viewer's vote state for comments (from GetVoteStateForComments)
232func (s *commentService) buildCommentView(
233 comment *Comment,
234 viewerDID *string,
235 voteStates map[string]interface{},
236) *CommentView {
237 // Build author view from comment data
238 // CommenterHandle is hydrated by ListByParentWithHotRank via JOIN
239 authorView := &posts.AuthorView{
240 DID: comment.CommenterDID,
241 Handle: comment.CommenterHandle,
242 // TODO: Add DisplayName, Avatar, Reputation when user service is integrated (Phase 2C)
243 }
244
245 // Build aggregated statistics
246 stats := &CommentStats{
247 Upvotes: comment.UpvoteCount,
248 Downvotes: comment.DownvoteCount,
249 Score: comment.Score,
250 ReplyCount: comment.ReplyCount,
251 }
252
253 // Build reference to parent post (always present)
254 postRef := &CommentRef{
255 URI: comment.RootURI,
256 CID: comment.RootCID,
257 }
258
259 // Build reference to parent comment (only if nested)
260 // Top-level comments have ParentURI == RootURI (both point to the post)
261 var parentRef *CommentRef
262 if comment.ParentURI != comment.RootURI {
263 parentRef = &CommentRef{
264 URI: comment.ParentURI,
265 CID: comment.ParentCID,
266 }
267 }
268
269 // Build viewer state - populate from vote states map (Phase 2B)
270 var viewer *CommentViewerState
271 if viewerDID != nil {
272 viewer = &CommentViewerState{
273 Vote: nil,
274 VoteURI: nil,
275 }
276
277 // Check if viewer has voted on this comment
278 if voteStates != nil {
279 if voteData, ok := voteStates[comment.URI]; ok {
280 voteMap, isMap := voteData.(map[string]interface{})
281 if isMap {
282 // Extract vote direction and URI
283 // Create copies before taking addresses to avoid pointer to loop variable issues
284 if direction, hasDirection := voteMap["direction"].(string); hasDirection {
285 directionCopy := direction
286 viewer.Vote = &directionCopy
287 }
288 if voteURI, hasVoteURI := voteMap["uri"].(string); hasVoteURI {
289 voteURICopy := voteURI
290 viewer.VoteURI = &voteURICopy
291 }
292 }
293 }
294 }
295 }
296
297 // Build minimal comment record to satisfy lexicon contract
298 // The record field is required by social.coves.community.comment.defs#commentView
299 commentRecord := s.buildCommentRecord(comment)
300
301 return &CommentView{
302 URI: comment.URI,
303 CID: comment.CID,
304 Author: authorView,
305 Record: commentRecord,
306 Post: postRef,
307 Parent: parentRef,
308 Content: comment.Content,
309 CreatedAt: comment.CreatedAt.Format(time.RFC3339),
310 IndexedAt: comment.IndexedAt.Format(time.RFC3339),
311 Stats: stats,
312 Viewer: viewer,
313 }
314}
315
316// buildCommentRecord constructs a minimal CommentRecord from a Comment entity
317// Satisfies the lexicon requirement that commentView.record is a required field
318// TODO (Phase 2C): Unmarshal JSON fields (embed, facets, labels) for complete record
319func (s *commentService) buildCommentRecord(comment *Comment) *CommentRecord {
320 record := &CommentRecord{
321 Type: "social.coves.feed.comment",
322 Reply: ReplyRef{
323 Root: StrongRef{
324 URI: comment.RootURI,
325 CID: comment.RootCID,
326 },
327 Parent: StrongRef{
328 URI: comment.ParentURI,
329 CID: comment.ParentCID,
330 },
331 },
332 Content: comment.Content,
333 CreatedAt: comment.CreatedAt.Format(time.RFC3339),
334 Langs: comment.Langs,
335 }
336
337 // TODO (Phase 2C): Parse JSON fields from database for complete record:
338 // - Unmarshal comment.Embed (*string) → record.Embed (map[string]interface{})
339 // - Unmarshal comment.ContentFacets (*string) → record.Facets ([]interface{})
340 // - Unmarshal comment.ContentLabels (*string) → record.Labels (*SelfLabels)
341 // These fields are stored as JSONB in the database and need proper deserialization
342
343 return record
344}
345
346// buildPostView converts a Post entity to a PostView for the comment response
347// Hydrates author handle and community name per lexicon requirements
348func (s *commentService) buildPostView(ctx context.Context, post *posts.Post, viewerDID *string) *posts.PostView {
349 // Build author view - fetch user to get handle (required by lexicon)
350 // The lexicon marks authorView.handle with format:"handle", so DIDs are invalid
351 authorHandle := post.AuthorDID // Fallback if user not found
352 if user, err := s.userRepo.GetByDID(ctx, post.AuthorDID); err == nil {
353 authorHandle = user.Handle
354 } else {
355 // Log warning but don't fail the entire request
356 log.Printf("Warning: Failed to fetch user for post author %s: %v", post.AuthorDID, err)
357 }
358
359 authorView := &posts.AuthorView{
360 DID: post.AuthorDID,
361 Handle: authorHandle,
362 // TODO (Phase 2C): Add DisplayName, Avatar, Reputation from user profile
363 }
364
365 // Build community reference - fetch community to get name (required by lexicon)
366 // The lexicon marks communityRef.name as required, so DIDs are insufficient
367 communityName := post.CommunityDID // Fallback if community not found
368 if community, err := s.communityRepo.GetByDID(ctx, post.CommunityDID); err == nil {
369 communityName = community.Handle // Use handle as display name
370 // TODO (Phase 2C): Use community.DisplayName or community.Name if available
371 } else {
372 // Log warning but don't fail the entire request
373 log.Printf("Warning: Failed to fetch community for post %s: %v", post.CommunityDID, err)
374 }
375
376 communityRef := &posts.CommunityRef{
377 DID: post.CommunityDID,
378 Name: communityName,
379 // TODO (Phase 2C): Add Avatar from community profile
380 }
381
382 // Build aggregated statistics
383 stats := &posts.PostStats{
384 Upvotes: post.UpvoteCount,
385 Downvotes: post.DownvoteCount,
386 Score: post.Score,
387 CommentCount: post.CommentCount,
388 }
389
390 // Build viewer state if authenticated
391 var viewer *posts.ViewerState
392 if viewerDID != nil {
393 // TODO (Phase 2B): Query viewer's vote state
394 viewer = &posts.ViewerState{
395 Vote: nil,
396 VoteURI: nil,
397 Saved: false,
398 }
399 }
400
401 // Build minimal post record to satisfy lexicon contract
402 // The record field is required by social.coves.community.post.get#postView
403 postRecord := s.buildPostRecord(post)
404
405 return &posts.PostView{
406 URI: post.URI,
407 CID: post.CID,
408 RKey: post.RKey,
409 Author: authorView,
410 Record: postRecord,
411 Community: communityRef,
412 Title: post.Title,
413 Text: post.Content,
414 CreatedAt: post.CreatedAt,
415 IndexedAt: post.IndexedAt,
416 EditedAt: post.EditedAt,
417 Stats: stats,
418 Viewer: viewer,
419 }
420}
421
422// buildPostRecord constructs a minimal PostRecord from a Post entity
423// Satisfies the lexicon requirement that postView.record is a required field
424// TODO (Phase 2C): Unmarshal JSON fields (embed, facets, labels) for complete record
425func (s *commentService) buildPostRecord(post *posts.Post) *posts.PostRecord {
426 record := &posts.PostRecord{
427 Type: "social.coves.community.post",
428 Community: post.CommunityDID,
429 Author: post.AuthorDID,
430 CreatedAt: post.CreatedAt.Format(time.RFC3339),
431 Title: post.Title,
432 Content: post.Content,
433 }
434
435 // TODO (Phase 2C): Parse JSON fields from database for complete record:
436 // - Unmarshal post.Embed (*string) → record.Embed (map[string]interface{})
437 // - Unmarshal post.ContentFacets (*string) → record.Facets ([]interface{})
438 // - Unmarshal post.ContentLabels (*string) → record.Labels (*SelfLabels)
439 // These fields are stored as JSONB in the database and need proper deserialization
440
441 return record
442}
443
444// validateGetCommentsRequest validates and normalizes request parameters
445// Applies default values and enforces bounds per API specification
446func validateGetCommentsRequest(req *GetCommentsRequest) error {
447 if req == nil {
448 return errors.New("request cannot be nil")
449 }
450
451 // Validate PostURI is present and well-formed
452 if req.PostURI == "" {
453 return errors.New("post URI is required")
454 }
455
456 if !strings.HasPrefix(req.PostURI, "at://") {
457 return errors.New("invalid AT-URI format: must start with 'at://'")
458 }
459
460 // Apply depth defaults and bounds (0-100, default 10)
461 if req.Depth < 0 {
462 req.Depth = 10
463 }
464 if req.Depth > 100 {
465 req.Depth = 100
466 }
467
468 // Apply limit defaults and bounds (1-100, default 50)
469 if req.Limit <= 0 {
470 req.Limit = 50
471 }
472 if req.Limit > 100 {
473 req.Limit = 100
474 }
475
476 // Apply sort default and validate
477 if req.Sort == "" {
478 req.Sort = "hot"
479 }
480
481 validSorts := map[string]bool{
482 "hot": true,
483 "top": true,
484 "new": true,
485 }
486 if !validSorts[req.Sort] {
487 return fmt.Errorf("invalid sort: must be one of [hot, top, new], got '%s'", req.Sort)
488 }
489
490 // Validate timeframe (only applies to "top" sort)
491 if req.Timeframe != "" {
492 validTimeframes := map[string]bool{
493 "hour": true,
494 "day": true,
495 "week": true,
496 "month": true,
497 "year": true,
498 "all": true,
499 }
500 if !validTimeframes[req.Timeframe] {
501 return fmt.Errorf("invalid timeframe: must be one of [hour, day, week, month, year, all], got '%s'", req.Timeframe)
502 }
503 }
504
505 return nil
506}