A community based topic aggregation platform built on atproto
1package comments
2
3import (
4 "Coves/internal/core/communities"
5 "Coves/internal/core/posts"
6 "Coves/internal/core/users"
7 "context"
8 "encoding/json"
9 "errors"
10 "fmt"
11 "log"
12 "log/slog"
13 "net/url"
14 "strings"
15 "time"
16
17 "github.com/bluesky-social/indigo/atproto/auth/oauth"
18 "github.com/bluesky-social/indigo/atproto/syntax"
19 "github.com/rivo/uniseg"
20
21 oauthclient "Coves/internal/atproto/oauth"
22 "Coves/internal/atproto/pds"
23)
24
25const (
26 // DefaultRepliesPerParent defines how many nested replies to load per parent comment
27 // This balances UX (showing enough context) with performance (limiting query size)
28 // Can be made configurable via constructor if needed in the future
29 DefaultRepliesPerParent = 5
30
31 // commentCollection is the AT Protocol collection for comment records
32 commentCollection = "social.coves.community.comment"
33
34 // maxCommentGraphemes is the maximum length for comment content in graphemes
35 maxCommentGraphemes = 10000
36)
37
38// PDSClientFactory creates PDS clients from session data.
39// Used to allow injection of different auth mechanisms (OAuth for production, password for tests).
40type PDSClientFactory func(ctx context.Context, session *oauth.ClientSessionData) (pds.Client, error)
41
42// Service defines the business logic interface for comment operations
43// Orchestrates repository calls and builds view models for API responses
44type Service interface {
45 // GetComments retrieves and builds a threaded comment tree for a post
46 // Supports hot, top, and new sorting with configurable depth and pagination
47 GetComments(ctx context.Context, req *GetCommentsRequest) (*GetCommentsResponse, error)
48
49 // CreateComment creates a new comment or reply
50 CreateComment(ctx context.Context, session *oauth.ClientSessionData, req CreateCommentRequest) (*CreateCommentResponse, error)
51
52 // UpdateComment updates an existing comment's content
53 UpdateComment(ctx context.Context, session *oauth.ClientSessionData, req UpdateCommentRequest) (*UpdateCommentResponse, error)
54
55 // DeleteComment soft-deletes a comment
56 DeleteComment(ctx context.Context, session *oauth.ClientSessionData, req DeleteCommentRequest) error
57}
58
59// GetCommentsRequest defines the parameters for fetching comments
60type GetCommentsRequest struct {
61 Cursor *string
62 ViewerDID *string
63 PostURI string
64 Sort string
65 Timeframe string
66 Depth int
67 Limit int
68}
69
70// commentService implements the Service interface
71// Coordinates between repository layer and view model construction
72type commentService struct {
73 commentRepo Repository // Comment data access
74 userRepo users.UserRepository // User lookup for author hydration
75 postRepo posts.Repository // Post lookup for building post views
76 communityRepo communities.Repository // Community lookup for community hydration
77 oauthClient *oauthclient.OAuthClient // OAuth client for PDS authentication
78 oauthStore oauth.ClientAuthStore // OAuth session store
79 logger *slog.Logger // Structured logger
80 pdsClientFactory PDSClientFactory // Optional, for testing. If nil, uses OAuth.
81}
82
83// NewCommentService creates a new comment service instance
84// All repositories are required for proper view construction per lexicon requirements
85func NewCommentService(
86 commentRepo Repository,
87 userRepo users.UserRepository,
88 postRepo posts.Repository,
89 communityRepo communities.Repository,
90 oauthClient *oauthclient.OAuthClient,
91 oauthStore oauth.ClientAuthStore,
92 logger *slog.Logger,
93) Service {
94 if logger == nil {
95 logger = slog.Default()
96 }
97 return &commentService{
98 commentRepo: commentRepo,
99 userRepo: userRepo,
100 postRepo: postRepo,
101 communityRepo: communityRepo,
102 oauthClient: oauthClient,
103 oauthStore: oauthStore,
104 logger: logger,
105 }
106}
107
108// NewCommentServiceWithPDSFactory creates a comment service with a custom PDS client factory.
109// This is primarily for testing with password-based authentication.
110func NewCommentServiceWithPDSFactory(
111 commentRepo Repository,
112 userRepo users.UserRepository,
113 postRepo posts.Repository,
114 communityRepo communities.Repository,
115 logger *slog.Logger,
116 factory PDSClientFactory,
117) Service {
118 if logger == nil {
119 logger = slog.Default()
120 }
121 return &commentService{
122 commentRepo: commentRepo,
123 userRepo: userRepo,
124 postRepo: postRepo,
125 communityRepo: communityRepo,
126 logger: logger,
127 pdsClientFactory: factory,
128 }
129}
130
131// GetComments retrieves comments for a post with threading and pagination
132// Algorithm:
133// 1. Validate input parameters and apply defaults
134// 2. Fetch top-level comments with specified sorting
135// 3. Recursively load nested replies up to depth limit
136// 4. Build view models with author info and stats
137// 5. Return response with pagination cursor
138func (s *commentService) GetComments(ctx context.Context, req *GetCommentsRequest) (*GetCommentsResponse, error) {
139 // 1. Validate inputs and apply defaults/bounds FIRST (before expensive operations)
140 if err := validateGetCommentsRequest(req); err != nil {
141 return nil, fmt.Errorf("invalid request: %w", err)
142 }
143
144 // Add timeout to prevent runaway queries with deep nesting
145 ctx, cancel := context.WithTimeout(ctx, 10*time.Second)
146 defer cancel()
147
148 // 2. Fetch post for context
149 post, err := s.postRepo.GetByURI(ctx, req.PostURI)
150 if err != nil {
151 // Translate post not-found errors to comment-layer errors for proper HTTP status
152 if posts.IsNotFound(err) {
153 return nil, ErrRootNotFound
154 }
155 return nil, fmt.Errorf("failed to fetch post: %w", err)
156 }
157
158 // Build post view for response (hydrates author handle and community name)
159 postView := s.buildPostView(ctx, post, req.ViewerDID)
160
161 // 3. Fetch top-level comments with pagination
162 // Uses repository's hot rank sorting and cursor-based pagination
163 topComments, nextCursor, err := s.commentRepo.ListByParentWithHotRank(
164 ctx,
165 req.PostURI,
166 req.Sort,
167 req.Timeframe,
168 req.Limit,
169 req.Cursor,
170 )
171 if err != nil {
172 return nil, fmt.Errorf("failed to fetch top-level comments: %w", err)
173 }
174
175 // 4. Build threaded view with nested replies up to depth limit
176 // This iteratively loads child comments and builds the tree structure
177 threadViews := s.buildThreadViews(ctx, topComments, req.Depth, req.Sort, req.ViewerDID)
178
179 // 5. Return response with comments, post reference, and cursor
180 return &GetCommentsResponse{
181 Comments: threadViews,
182 Post: postView,
183 Cursor: nextCursor,
184 }, nil
185}
186
187// buildThreadViews constructs threaded comment views with nested replies using batch loading
188// Uses batch queries to prevent N+1 query problem when loading nested replies
189// Loads replies level-by-level up to the specified depth limit
190func (s *commentService) buildThreadViews(
191 ctx context.Context,
192 comments []*Comment,
193 remainingDepth int,
194 sort string,
195 viewerDID *string,
196) []*ThreadViewComment {
197 // Always return an empty slice, never nil (important for JSON serialization)
198 result := make([]*ThreadViewComment, 0, len(comments))
199
200 if len(comments) == 0 {
201 return result
202 }
203
204 // Batch fetch vote states for all comments at this level (Phase 2B)
205 var voteStates map[string]interface{}
206 if viewerDID != nil {
207 commentURIs := make([]string, 0, len(comments))
208 for _, comment := range comments {
209 if comment.DeletedAt == nil {
210 commentURIs = append(commentURIs, comment.URI)
211 }
212 }
213
214 if len(commentURIs) > 0 {
215 var err error
216 voteStates, err = s.commentRepo.GetVoteStateForComments(ctx, *viewerDID, commentURIs)
217 if err != nil {
218 // Log error but don't fail the request - vote state is optional
219 log.Printf("Warning: Failed to fetch vote states for comments: %v", err)
220 }
221 }
222 }
223
224 // Batch fetch user data for all comment authors (Phase 2C)
225 // Collect unique author DIDs to prevent duplicate queries
226 authorDIDs := make([]string, 0, len(comments))
227 seenDIDs := make(map[string]bool)
228 for _, comment := range comments {
229 if comment.DeletedAt == nil && !seenDIDs[comment.CommenterDID] {
230 authorDIDs = append(authorDIDs, comment.CommenterDID)
231 seenDIDs[comment.CommenterDID] = true
232 }
233 }
234
235 // Fetch all users in one query to avoid N+1 problem
236 var usersByDID map[string]*users.User
237 if len(authorDIDs) > 0 {
238 var err error
239 usersByDID, err = s.userRepo.GetByDIDs(ctx, authorDIDs)
240 if err != nil {
241 // Log error but don't fail the request - user data is optional
242 log.Printf("Warning: Failed to batch fetch users for comment authors: %v", err)
243 usersByDID = make(map[string]*users.User)
244 }
245 } else {
246 usersByDID = make(map[string]*users.User)
247 }
248
249 // Build thread views for current level
250 threadViews := make([]*ThreadViewComment, 0, len(comments))
251 commentsByURI := make(map[string]*ThreadViewComment)
252 parentsWithReplies := make([]string, 0)
253
254 for _, comment := range comments {
255 var commentView *CommentView
256
257 // Build appropriate view based on deletion status
258 if comment.DeletedAt != nil {
259 // Deleted comment - build placeholder view to preserve thread structure
260 commentView = s.buildDeletedCommentView(comment)
261 } else {
262 // Active comment - build full view with author info and stats
263 commentView = s.buildCommentView(comment, viewerDID, voteStates, usersByDID)
264 }
265
266 threadView := &ThreadViewComment{
267 Comment: commentView,
268 Replies: nil,
269 HasMore: comment.ReplyCount > 0 && remainingDepth == 0,
270 }
271
272 threadViews = append(threadViews, threadView)
273 commentsByURI[comment.URI] = threadView
274
275 // Collect parent URIs that have replies and depth remaining
276 // Include deleted comments so their children are still loaded
277 if remainingDepth > 0 && comment.ReplyCount > 0 {
278 parentsWithReplies = append(parentsWithReplies, comment.URI)
279 }
280 }
281
282 // Batch load all replies for this level in a single query
283 if len(parentsWithReplies) > 0 {
284 repliesByParent, err := s.commentRepo.ListByParentsBatch(
285 ctx,
286 parentsWithReplies,
287 sort,
288 DefaultRepliesPerParent,
289 )
290
291 // Process replies if batch query succeeded
292 if err == nil {
293 // Group child comments by parent for recursive processing
294 for parentURI, replies := range repliesByParent {
295 threadView := commentsByURI[parentURI]
296 if threadView != nil && len(replies) > 0 {
297 // Recursively build views for child comments
298 threadView.Replies = s.buildThreadViews(
299 ctx,
300 replies,
301 remainingDepth-1,
302 sort,
303 viewerDID,
304 )
305
306 // Update HasMore based on actual reply count vs loaded count
307 // Get the original comment to check reply count
308 for _, comment := range comments {
309 if comment.URI == parentURI {
310 threadView.HasMore = comment.ReplyCount > len(replies)
311 break
312 }
313 }
314 }
315 }
316 }
317 }
318
319 return threadViews
320}
321
322// buildCommentView converts a Comment entity to a CommentView with full metadata
323// Constructs author view, stats, and references to parent post/comment
324// voteStates map contains viewer's vote state for comments (from GetVoteStateForComments)
325// usersByDID map contains pre-loaded user data for batch author hydration (Phase 2C)
326func (s *commentService) buildCommentView(
327 comment *Comment,
328 viewerDID *string,
329 voteStates map[string]interface{},
330 usersByDID map[string]*users.User,
331) *CommentView {
332 // Build author view from comment data with full user hydration (Phase 2C)
333 // CommenterHandle is hydrated by ListByParentWithHotRank via JOIN (fallback)
334 // Prefer handle from usersByDID map for consistency
335 authorHandle := comment.CommenterHandle
336 if user, found := usersByDID[comment.CommenterDID]; found {
337 authorHandle = user.Handle
338 }
339
340 authorView := &posts.AuthorView{
341 DID: comment.CommenterDID,
342 Handle: authorHandle,
343 // DisplayName, Avatar, Reputation will be populated when user profile schema is extended
344 // Currently User model only has DID, Handle, PDSURL fields
345 DisplayName: nil,
346 Avatar: nil,
347 Reputation: nil,
348 }
349
350 // Build aggregated statistics
351 stats := &CommentStats{
352 Upvotes: comment.UpvoteCount,
353 Downvotes: comment.DownvoteCount,
354 Score: comment.Score,
355 ReplyCount: comment.ReplyCount,
356 }
357
358 // Build reference to parent post (always present)
359 postRef := &CommentRef{
360 URI: comment.RootURI,
361 CID: comment.RootCID,
362 }
363
364 // Build reference to parent comment (only if nested)
365 // Top-level comments have ParentURI == RootURI (both point to the post)
366 var parentRef *CommentRef
367 if comment.ParentURI != comment.RootURI {
368 parentRef = &CommentRef{
369 URI: comment.ParentURI,
370 CID: comment.ParentCID,
371 }
372 }
373
374 // Build viewer state - populate from vote states map (Phase 2B)
375 var viewer *CommentViewerState
376 if viewerDID != nil {
377 viewer = &CommentViewerState{
378 Vote: nil,
379 VoteURI: nil,
380 }
381
382 // Check if viewer has voted on this comment
383 if voteStates != nil {
384 if voteData, ok := voteStates[comment.URI]; ok {
385 voteMap, isMap := voteData.(map[string]interface{})
386 if isMap {
387 // Extract vote direction and URI
388 // Create copies before taking addresses to avoid pointer to loop variable issues
389 if direction, hasDirection := voteMap["direction"].(string); hasDirection {
390 directionCopy := direction
391 viewer.Vote = &directionCopy
392 }
393 if voteURI, hasVoteURI := voteMap["uri"].(string); hasVoteURI {
394 voteURICopy := voteURI
395 viewer.VoteURI = &voteURICopy
396 }
397 }
398 }
399 }
400 }
401
402 // Build minimal comment record to satisfy lexicon contract
403 // The record field is required by social.coves.community.comment.defs#commentView
404 commentRecord := s.buildCommentRecord(comment)
405
406 // Deserialize contentFacets from JSONB (Phase 2C)
407 // Parse facets from database JSON string to populate contentFacets field
408 var contentFacets []interface{}
409 if comment.ContentFacets != nil && *comment.ContentFacets != "" {
410 if err := json.Unmarshal([]byte(*comment.ContentFacets), &contentFacets); err != nil {
411 // Log error but don't fail request - facets are optional
412 log.Printf("Warning: Failed to unmarshal content facets for comment %s: %v", comment.URI, err)
413 }
414 }
415
416 // Deserialize embed from JSONB (Phase 2C)
417 // Parse embed from database JSON string to populate embed field
418 var embed interface{}
419 if comment.Embed != nil && *comment.Embed != "" {
420 var embedMap map[string]interface{}
421 if err := json.Unmarshal([]byte(*comment.Embed), &embedMap); err != nil {
422 // Log error but don't fail request - embed is optional
423 log.Printf("Warning: Failed to unmarshal embed for comment %s: %v", comment.URI, err)
424 } else {
425 embed = embedMap
426 }
427 }
428
429 return &CommentView{
430 URI: comment.URI,
431 CID: comment.CID,
432 Author: authorView,
433 Record: commentRecord,
434 Post: postRef,
435 Parent: parentRef,
436 Content: comment.Content,
437 ContentFacets: contentFacets,
438 Embed: embed,
439 CreatedAt: comment.CreatedAt.Format(time.RFC3339),
440 IndexedAt: comment.IndexedAt.Format(time.RFC3339),
441 Stats: stats,
442 Viewer: viewer,
443 }
444}
445
446// buildDeletedCommentView creates a placeholder view for a deleted comment
447// Preserves threading structure while hiding content
448// Shows as "[deleted]" in the UI with minimal metadata
449func (s *commentService) buildDeletedCommentView(comment *Comment) *CommentView {
450 // Build minimal author view - just DID for attribution
451 // Frontend will display "[deleted]" or "[deleted by @user]" based on deletion_reason
452 authorView := &posts.AuthorView{
453 DID: comment.CommenterDID,
454 Handle: "", // Empty - frontend handles display
455 DisplayName: nil,
456 Avatar: nil,
457 Reputation: nil,
458 }
459
460 // Build minimal stats - preserve reply count for threading indication
461 stats := &CommentStats{
462 Upvotes: 0,
463 Downvotes: 0,
464 Score: 0,
465 ReplyCount: comment.ReplyCount, // Keep this to show threading
466 }
467
468 // Build reference to parent post (always present)
469 postRef := &CommentRef{
470 URI: comment.RootURI,
471 CID: comment.RootCID,
472 }
473
474 // Build reference to parent comment (only if nested)
475 var parentRef *CommentRef
476 if comment.ParentURI != comment.RootURI {
477 parentRef = &CommentRef{
478 URI: comment.ParentURI,
479 CID: comment.ParentCID,
480 }
481 }
482
483 // Format deletion timestamp for frontend
484 var deletedAtStr *string
485 if comment.DeletedAt != nil {
486 ts := comment.DeletedAt.Format(time.RFC3339)
487 deletedAtStr = &ts
488 }
489
490 return &CommentView{
491 URI: comment.URI,
492 CID: comment.CID,
493 Author: authorView,
494 Record: nil, // No record for deleted comments
495 Post: postRef,
496 Parent: parentRef,
497 Content: "", // Blanked content
498 ContentFacets: nil,
499 Embed: nil,
500 CreatedAt: comment.CreatedAt.Format(time.RFC3339),
501 IndexedAt: comment.IndexedAt.Format(time.RFC3339),
502 Stats: stats,
503 Viewer: nil, // No viewer state for deleted comments
504 IsDeleted: true,
505 DeletionReason: comment.DeletionReason,
506 DeletedAt: deletedAtStr,
507 }
508}
509
510// buildCommentRecord constructs a complete CommentRecord from a Comment entity
511// Satisfies the lexicon requirement that commentView.record is a required field
512// Deserializes JSONB fields (embed, facets, labels) for complete record (Phase 2C)
513func (s *commentService) buildCommentRecord(comment *Comment) *CommentRecord {
514 record := &CommentRecord{
515 Type: "social.coves.community.comment",
516 Reply: ReplyRef{
517 Root: StrongRef{
518 URI: comment.RootURI,
519 CID: comment.RootCID,
520 },
521 Parent: StrongRef{
522 URI: comment.ParentURI,
523 CID: comment.ParentCID,
524 },
525 },
526 Content: comment.Content,
527 CreatedAt: comment.CreatedAt.Format(time.RFC3339),
528 Langs: comment.Langs,
529 }
530
531 // Deserialize facets from JSONB (Phase 2C)
532 if comment.ContentFacets != nil && *comment.ContentFacets != "" {
533 var facets []interface{}
534 if err := json.Unmarshal([]byte(*comment.ContentFacets), &facets); err != nil {
535 // Log error but don't fail request - facets are optional
536 log.Printf("Warning: Failed to unmarshal facets for record %s: %v", comment.URI, err)
537 } else {
538 record.Facets = facets
539 }
540 }
541
542 // Deserialize embed from JSONB (Phase 2C)
543 if comment.Embed != nil && *comment.Embed != "" {
544 var embed map[string]interface{}
545 if err := json.Unmarshal([]byte(*comment.Embed), &embed); err != nil {
546 // Log error but don't fail request - embed is optional
547 log.Printf("Warning: Failed to unmarshal embed for record %s: %v", comment.URI, err)
548 } else {
549 record.Embed = embed
550 }
551 }
552
553 // Deserialize labels from JSONB (Phase 2C)
554 if comment.ContentLabels != nil && *comment.ContentLabels != "" {
555 var labels SelfLabels
556 if err := json.Unmarshal([]byte(*comment.ContentLabels), &labels); err != nil {
557 // Log error but don't fail request - labels are optional
558 log.Printf("Warning: Failed to unmarshal labels for record %s: %v", comment.URI, err)
559 } else {
560 record.Labels = &labels
561 }
562 }
563
564 return record
565}
566
567// getPDSClient creates a PDS client from an OAuth session.
568// If a custom factory was provided (for testing), uses that.
569// Otherwise, uses DPoP authentication via indigo's APIClient for proper OAuth token handling.
570func (s *commentService) getPDSClient(ctx context.Context, session *oauth.ClientSessionData) (pds.Client, error) {
571 // Use custom factory if provided (e.g., for testing with password auth)
572 if s.pdsClientFactory != nil {
573 return s.pdsClientFactory(ctx, session)
574 }
575
576 // Production path: use OAuth with DPoP
577 if s.oauthClient == nil || s.oauthClient.ClientApp == nil {
578 return nil, fmt.Errorf("OAuth client not configured")
579 }
580
581 client, err := pds.NewFromOAuthSession(ctx, s.oauthClient.ClientApp, session)
582 if err != nil {
583 return nil, fmt.Errorf("failed to create PDS client: %w", err)
584 }
585
586 return client, nil
587}
588
589// CreateComment creates a new comment on a post or reply to another comment
590func (s *commentService) CreateComment(ctx context.Context, session *oauth.ClientSessionData, req CreateCommentRequest) (*CreateCommentResponse, error) {
591 // Validate content not empty
592 content := strings.TrimSpace(req.Content)
593 if content == "" {
594 return nil, ErrContentEmpty
595 }
596
597 // Validate content length (max 10000 graphemes)
598 if uniseg.GraphemeClusterCount(content) > maxCommentGraphemes {
599 return nil, ErrContentTooLong
600 }
601
602 // Validate reply references
603 if err := validateReplyRef(req.Reply); err != nil {
604 return nil, err
605 }
606
607 // Create PDS client for this session
608 pdsClient, err := s.getPDSClient(ctx, session)
609 if err != nil {
610 s.logger.Error("failed to create PDS client",
611 "error", err,
612 "commenter", session.AccountDID)
613 return nil, fmt.Errorf("failed to create PDS client: %w", err)
614 }
615
616 // Generate TID for the record key
617 tid := syntax.NewTIDNow(0)
618
619 // Build comment record following the lexicon schema
620 record := CommentRecord{
621 Type: commentCollection,
622 Reply: req.Reply,
623 Content: content,
624 Facets: req.Facets,
625 Embed: req.Embed,
626 Langs: req.Langs,
627 Labels: req.Labels,
628 CreatedAt: time.Now().UTC().Format(time.RFC3339),
629 }
630
631 // Create the comment record on the user's PDS
632 uri, cid, err := pdsClient.CreateRecord(ctx, commentCollection, tid.String(), record)
633 if err != nil {
634 s.logger.Error("failed to create comment on PDS",
635 "error", err,
636 "commenter", session.AccountDID,
637 "root", req.Reply.Root.URI,
638 "parent", req.Reply.Parent.URI)
639 if pds.IsAuthError(err) {
640 return nil, ErrNotAuthorized
641 }
642 return nil, fmt.Errorf("failed to create comment: %w", err)
643 }
644
645 s.logger.Info("comment created",
646 "commenter", session.AccountDID,
647 "uri", uri,
648 "cid", cid,
649 "root", req.Reply.Root.URI,
650 "parent", req.Reply.Parent.URI)
651
652 return &CreateCommentResponse{
653 URI: uri,
654 CID: cid,
655 }, nil
656}
657
658// UpdateComment updates an existing comment's content
659func (s *commentService) UpdateComment(ctx context.Context, session *oauth.ClientSessionData, req UpdateCommentRequest) (*UpdateCommentResponse, error) {
660 // Validate URI format
661 if req.URI == "" {
662 return nil, ErrCommentNotFound
663 }
664 if !strings.HasPrefix(req.URI, "at://") {
665 return nil, ErrCommentNotFound
666 }
667
668 // Extract DID and rkey from URI (at://did/collection/rkey)
669 parts := strings.Split(req.URI, "/")
670 if len(parts) < 5 || parts[3] != commentCollection {
671 return nil, ErrCommentNotFound
672 }
673 did := parts[2]
674 rkey := parts[4]
675
676 // Verify ownership: URI must belong to the authenticated user
677 if did != session.AccountDID.String() {
678 return nil, ErrNotAuthorized
679 }
680
681 // Validate new content
682 content := strings.TrimSpace(req.Content)
683 if content == "" {
684 return nil, ErrContentEmpty
685 }
686
687 // Validate content length (max 10000 graphemes)
688 if uniseg.GraphemeClusterCount(content) > maxCommentGraphemes {
689 return nil, ErrContentTooLong
690 }
691
692 // Create PDS client for this session
693 pdsClient, err := s.getPDSClient(ctx, session)
694 if err != nil {
695 s.logger.Error("failed to create PDS client",
696 "error", err,
697 "commenter", session.AccountDID)
698 return nil, fmt.Errorf("failed to create PDS client: %w", err)
699 }
700
701 // Fetch existing record from PDS to get the reply refs (immutable)
702 existingRecord, err := pdsClient.GetRecord(ctx, commentCollection, rkey)
703 if err != nil {
704 s.logger.Error("failed to fetch existing comment from PDS",
705 "error", err,
706 "uri", req.URI,
707 "rkey", rkey)
708 if pds.IsAuthError(err) {
709 return nil, ErrNotAuthorized
710 }
711 if errors.Is(err, pds.ErrNotFound) {
712 return nil, ErrCommentNotFound
713 }
714 return nil, fmt.Errorf("failed to fetch existing comment: %w", err)
715 }
716
717 // Extract reply refs from existing record (must be preserved)
718 replyData, ok := existingRecord.Value["reply"].(map[string]interface{})
719 if !ok {
720 s.logger.Error("invalid reply structure in existing comment",
721 "uri", req.URI)
722 return nil, fmt.Errorf("invalid existing comment structure")
723 }
724
725 // Parse reply refs
726 var reply ReplyRef
727 replyJSON, err := json.Marshal(replyData)
728 if err != nil {
729 return nil, fmt.Errorf("failed to marshal reply data: %w", err)
730 }
731 if err := json.Unmarshal(replyJSON, &reply); err != nil {
732 return nil, fmt.Errorf("failed to unmarshal reply data: %w", err)
733 }
734
735 // Extract original createdAt timestamp (immutable)
736 createdAt, _ := existingRecord.Value["createdAt"].(string)
737 if createdAt == "" {
738 createdAt = time.Now().UTC().Format(time.RFC3339)
739 }
740
741 // Build updated comment record
742 updatedRecord := CommentRecord{
743 Type: commentCollection,
744 Reply: reply, // Preserve original reply refs
745 Content: content,
746 Facets: req.Facets,
747 Embed: req.Embed,
748 Langs: req.Langs,
749 Labels: req.Labels,
750 CreatedAt: createdAt, // Preserve original timestamp
751 }
752
753 // Update the record on PDS with optimistic locking via swapRecord CID
754 uri, cid, err := pdsClient.PutRecord(ctx, commentCollection, rkey, updatedRecord, existingRecord.CID)
755 if err != nil {
756 s.logger.Error("failed to update comment on PDS",
757 "error", err,
758 "uri", req.URI,
759 "rkey", rkey)
760 if pds.IsAuthError(err) {
761 return nil, ErrNotAuthorized
762 }
763 if errors.Is(err, pds.ErrConflict) {
764 return nil, ErrConcurrentModification
765 }
766 return nil, fmt.Errorf("failed to update comment: %w", err)
767 }
768
769 s.logger.Info("comment updated",
770 "commenter", session.AccountDID,
771 "uri", uri,
772 "new_cid", cid,
773 "old_cid", existingRecord.CID)
774
775 return &UpdateCommentResponse{
776 URI: uri,
777 CID: cid,
778 }, nil
779}
780
781// DeleteComment soft-deletes a comment by removing it from the user's PDS
782func (s *commentService) DeleteComment(ctx context.Context, session *oauth.ClientSessionData, req DeleteCommentRequest) error {
783 // Validate URI format
784 if req.URI == "" {
785 return ErrCommentNotFound
786 }
787 if !strings.HasPrefix(req.URI, "at://") {
788 return ErrCommentNotFound
789 }
790
791 // Extract DID and rkey from URI (at://did/collection/rkey)
792 parts := strings.Split(req.URI, "/")
793 if len(parts) < 5 || parts[3] != commentCollection {
794 return ErrCommentNotFound
795 }
796 did := parts[2]
797 rkey := parts[4]
798
799 // Verify ownership: URI must belong to the authenticated user
800 if did != session.AccountDID.String() {
801 return ErrNotAuthorized
802 }
803
804 // Create PDS client for this session
805 pdsClient, err := s.getPDSClient(ctx, session)
806 if err != nil {
807 s.logger.Error("failed to create PDS client",
808 "error", err,
809 "commenter", session.AccountDID)
810 return fmt.Errorf("failed to create PDS client: %w", err)
811 }
812
813 // Verify comment exists on PDS before deleting
814 _, err = pdsClient.GetRecord(ctx, commentCollection, rkey)
815 if err != nil {
816 s.logger.Error("failed to verify comment exists on PDS",
817 "error", err,
818 "uri", req.URI,
819 "rkey", rkey)
820 if pds.IsAuthError(err) {
821 return ErrNotAuthorized
822 }
823 if errors.Is(err, pds.ErrNotFound) {
824 return ErrCommentNotFound
825 }
826 return fmt.Errorf("failed to verify comment: %w", err)
827 }
828
829 // Delete the comment record from user's PDS
830 if err := pdsClient.DeleteRecord(ctx, commentCollection, rkey); err != nil {
831 s.logger.Error("failed to delete comment on PDS",
832 "error", err,
833 "uri", req.URI,
834 "rkey", rkey)
835 if pds.IsAuthError(err) {
836 return ErrNotAuthorized
837 }
838 return fmt.Errorf("failed to delete comment: %w", err)
839 }
840
841 s.logger.Info("comment deleted",
842 "commenter", session.AccountDID,
843 "uri", req.URI)
844
845 return nil
846}
847
848// validateReplyRef validates that reply references are well-formed
849func validateReplyRef(reply ReplyRef) error {
850 // Validate root reference
851 if reply.Root.URI == "" {
852 return ErrInvalidReply
853 }
854 if !strings.HasPrefix(reply.Root.URI, "at://") {
855 return ErrInvalidReply
856 }
857 if reply.Root.CID == "" {
858 return ErrInvalidReply
859 }
860
861 // Validate parent reference
862 if reply.Parent.URI == "" {
863 return ErrInvalidReply
864 }
865 if !strings.HasPrefix(reply.Parent.URI, "at://") {
866 return ErrInvalidReply
867 }
868 if reply.Parent.CID == "" {
869 return ErrInvalidReply
870 }
871
872 return nil
873}
874
875// buildPostView converts a Post entity to a PostView for the comment response
876// Hydrates author handle and community name per lexicon requirements
877func (s *commentService) buildPostView(ctx context.Context, post *posts.Post, viewerDID *string) *posts.PostView {
878 // Build author view - fetch user to get handle (required by lexicon)
879 // The lexicon marks authorView.handle with format:"handle", so DIDs are invalid
880 authorHandle := post.AuthorDID // Fallback if user not found
881 if user, err := s.userRepo.GetByDID(ctx, post.AuthorDID); err == nil {
882 authorHandle = user.Handle
883 } else {
884 // Log warning but don't fail the entire request
885 log.Printf("Warning: Failed to fetch user for post author %s: %v", post.AuthorDID, err)
886 }
887
888 authorView := &posts.AuthorView{
889 DID: post.AuthorDID,
890 Handle: authorHandle,
891 // DisplayName, Avatar, Reputation will be populated when user profile schema is extended
892 // Currently User model only has DID, Handle, PDSURL fields
893 DisplayName: nil,
894 Avatar: nil,
895 Reputation: nil,
896 }
897
898 // Build community reference - fetch community to get name and avatar (required by lexicon)
899 // The lexicon marks communityRef.name and handle as required, so DIDs alone are insufficient
900 // DATA INTEGRITY: Community should always exist for posts. If missing, it indicates orphaned data.
901 community, err := s.communityRepo.GetByDID(ctx, post.CommunityDID)
902 if err != nil {
903 // This indicates a data integrity issue: post references non-existent community
904 // Log as ERROR (not warning) since this should never happen in normal operation
905 log.Printf("ERROR: Data integrity issue - post %s references non-existent community %s: %v",
906 post.URI, post.CommunityDID, err)
907 // Use DID as fallback for both handle and name to prevent breaking the API
908 // This allows the response to be returned while surfacing the integrity issue in logs
909 community = &communities.Community{
910 DID: post.CommunityDID,
911 Handle: post.CommunityDID, // Fallback: use DID as handle
912 Name: post.CommunityDID, // Fallback: use DID as name
913 }
914 }
915
916 // Capture handle for communityRef (required by lexicon)
917 communityHandle := community.Handle
918
919 // Determine display name: prefer DisplayName, fall back to Name, then Handle
920 var communityName string
921 if community.DisplayName != "" {
922 communityName = community.DisplayName
923 } else if community.Name != "" {
924 communityName = community.Name
925 } else {
926 communityName = community.Handle
927 }
928
929 // Build avatar URL from CID if available
930 // Avatar is stored as blob in community's repository
931 // Format: https://{pds}/xrpc/com.atproto.sync.getBlob?did={community_did}&cid={avatar_cid}
932 var avatarURL *string
933 if community.AvatarCID != "" && community.PDSURL != "" {
934 // Validate HTTPS for security (prevent mixed content warnings, MitM attacks)
935 if !strings.HasPrefix(community.PDSURL, "https://") {
936 log.Printf("Warning: Skipping non-HTTPS PDS URL for community %s", community.DID)
937 } else if !strings.HasPrefix(community.AvatarCID, "baf") {
938 // Validate CID format (IPFS CIDs start with "baf" for CIDv1 base32)
939 log.Printf("Warning: Invalid CID format for community %s", community.DID)
940 } else {
941 // Use proper URL escaping to prevent injection attacks
942 avatarURLString := fmt.Sprintf("%s/xrpc/com.atproto.sync.getBlob?did=%s&cid=%s",
943 strings.TrimSuffix(community.PDSURL, "/"),
944 url.QueryEscape(community.DID),
945 url.QueryEscape(community.AvatarCID))
946 avatarURL = &avatarURLString
947 }
948 }
949
950 communityRef := &posts.CommunityRef{
951 DID: post.CommunityDID,
952 Handle: communityHandle,
953 Name: communityName,
954 Avatar: avatarURL,
955 }
956
957 // Build aggregated statistics
958 stats := &posts.PostStats{
959 Upvotes: post.UpvoteCount,
960 Downvotes: post.DownvoteCount,
961 Score: post.Score,
962 CommentCount: post.CommentCount,
963 }
964
965 // Build viewer state if authenticated
966 var viewer *posts.ViewerState
967 if viewerDID != nil {
968 // TODO (Phase 2B): Query viewer's vote state
969 viewer = &posts.ViewerState{
970 Vote: nil,
971 VoteURI: nil,
972 Saved: false,
973 }
974 }
975
976 // Build minimal post record to satisfy lexicon contract
977 // The record field is required by social.coves.community.post.get#postView
978 postRecord := s.buildPostRecord(post)
979
980 return &posts.PostView{
981 URI: post.URI,
982 CID: post.CID,
983 RKey: post.RKey,
984 Author: authorView,
985 Record: postRecord,
986 Community: communityRef,
987 Title: post.Title,
988 Text: post.Content,
989 CreatedAt: post.CreatedAt,
990 IndexedAt: post.IndexedAt,
991 EditedAt: post.EditedAt,
992 Stats: stats,
993 Viewer: viewer,
994 }
995}
996
997// buildPostRecord constructs a minimal PostRecord from a Post entity
998// Satisfies the lexicon requirement that postView.record is a required field
999// TODO (Phase 2C): Unmarshal JSON fields (embed, facets, labels) for complete record
1000func (s *commentService) buildPostRecord(post *posts.Post) *posts.PostRecord {
1001 record := &posts.PostRecord{
1002 Type: "social.coves.community.post",
1003 Community: post.CommunityDID,
1004 Author: post.AuthorDID,
1005 CreatedAt: post.CreatedAt.Format(time.RFC3339),
1006 Title: post.Title,
1007 Content: post.Content,
1008 }
1009
1010 // TODO (Phase 2C): Parse JSON fields from database for complete record:
1011 // - Unmarshal post.Embed (*string) → record.Embed (map[string]interface{})
1012 // - Unmarshal post.ContentFacets (*string) → record.Facets ([]interface{})
1013 // - Unmarshal post.ContentLabels (*string) → record.Labels (*SelfLabels)
1014 // These fields are stored as JSONB in the database and need proper deserialization
1015
1016 return record
1017}
1018
1019// validateGetCommentsRequest validates and normalizes request parameters
1020// Applies default values and enforces bounds per API specification
1021func validateGetCommentsRequest(req *GetCommentsRequest) error {
1022 if req == nil {
1023 return errors.New("request cannot be nil")
1024 }
1025
1026 // Validate PostURI is present and well-formed
1027 if req.PostURI == "" {
1028 return errors.New("post URI is required")
1029 }
1030
1031 if !strings.HasPrefix(req.PostURI, "at://") {
1032 return errors.New("invalid AT-URI format: must start with 'at://'")
1033 }
1034
1035 // Apply depth defaults and bounds (0-100, default 10)
1036 if req.Depth < 0 {
1037 req.Depth = 10
1038 }
1039 if req.Depth > 100 {
1040 req.Depth = 100
1041 }
1042
1043 // Apply limit defaults and bounds (1-100, default 50)
1044 if req.Limit <= 0 {
1045 req.Limit = 50
1046 }
1047 if req.Limit > 100 {
1048 req.Limit = 100
1049 }
1050
1051 // Apply sort default and validate
1052 if req.Sort == "" {
1053 req.Sort = "hot"
1054 }
1055
1056 validSorts := map[string]bool{
1057 "hot": true,
1058 "top": true,
1059 "new": true,
1060 }
1061 if !validSorts[req.Sort] {
1062 return fmt.Errorf("invalid sort: must be one of [hot, top, new], got '%s'", req.Sort)
1063 }
1064
1065 // Validate timeframe (only applies to "top" sort)
1066 if req.Timeframe != "" {
1067 validTimeframes := map[string]bool{
1068 "hour": true,
1069 "day": true,
1070 "week": true,
1071 "month": true,
1072 "year": true,
1073 "all": true,
1074 }
1075 if !validTimeframes[req.Timeframe] {
1076 return fmt.Errorf("invalid timeframe: must be one of [hour, day, week, month, year, all], got '%s'", req.Timeframe)
1077 }
1078 }
1079
1080 return nil
1081}