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