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 // Skip deleted comments (soft-deleted records)
256 if comment.DeletedAt != nil {
257 continue
258 }
259
260 // Build the comment view with author info and stats
261 commentView := s.buildCommentView(comment, viewerDID, voteStates, usersByDID)
262
263 threadView := &ThreadViewComment{
264 Comment: commentView,
265 Replies: nil,
266 HasMore: comment.ReplyCount > 0 && remainingDepth == 0,
267 }
268
269 threadViews = append(threadViews, threadView)
270 commentsByURI[comment.URI] = threadView
271
272 // Collect parent URIs that have replies and depth remaining
273 if remainingDepth > 0 && comment.ReplyCount > 0 {
274 parentsWithReplies = append(parentsWithReplies, comment.URI)
275 }
276 }
277
278 // Batch load all replies for this level in a single query
279 if len(parentsWithReplies) > 0 {
280 repliesByParent, err := s.commentRepo.ListByParentsBatch(
281 ctx,
282 parentsWithReplies,
283 sort,
284 DefaultRepliesPerParent,
285 )
286
287 // Process replies if batch query succeeded
288 if err == nil {
289 // Group child comments by parent for recursive processing
290 for parentURI, replies := range repliesByParent {
291 threadView := commentsByURI[parentURI]
292 if threadView != nil && len(replies) > 0 {
293 // Recursively build views for child comments
294 threadView.Replies = s.buildThreadViews(
295 ctx,
296 replies,
297 remainingDepth-1,
298 sort,
299 viewerDID,
300 )
301
302 // Update HasMore based on actual reply count vs loaded count
303 // Get the original comment to check reply count
304 for _, comment := range comments {
305 if comment.URI == parentURI {
306 threadView.HasMore = comment.ReplyCount > len(replies)
307 break
308 }
309 }
310 }
311 }
312 }
313 }
314
315 return threadViews
316}
317
318// buildCommentView converts a Comment entity to a CommentView with full metadata
319// Constructs author view, stats, and references to parent post/comment
320// voteStates map contains viewer's vote state for comments (from GetVoteStateForComments)
321// usersByDID map contains pre-loaded user data for batch author hydration (Phase 2C)
322func (s *commentService) buildCommentView(
323 comment *Comment,
324 viewerDID *string,
325 voteStates map[string]interface{},
326 usersByDID map[string]*users.User,
327) *CommentView {
328 // Build author view from comment data with full user hydration (Phase 2C)
329 // CommenterHandle is hydrated by ListByParentWithHotRank via JOIN (fallback)
330 // Prefer handle from usersByDID map for consistency
331 authorHandle := comment.CommenterHandle
332 if user, found := usersByDID[comment.CommenterDID]; found {
333 authorHandle = user.Handle
334 }
335
336 authorView := &posts.AuthorView{
337 DID: comment.CommenterDID,
338 Handle: authorHandle,
339 // DisplayName, Avatar, Reputation will be populated when user profile schema is extended
340 // Currently User model only has DID, Handle, PDSURL fields
341 DisplayName: nil,
342 Avatar: nil,
343 Reputation: nil,
344 }
345
346 // Build aggregated statistics
347 stats := &CommentStats{
348 Upvotes: comment.UpvoteCount,
349 Downvotes: comment.DownvoteCount,
350 Score: comment.Score,
351 ReplyCount: comment.ReplyCount,
352 }
353
354 // Build reference to parent post (always present)
355 postRef := &CommentRef{
356 URI: comment.RootURI,
357 CID: comment.RootCID,
358 }
359
360 // Build reference to parent comment (only if nested)
361 // Top-level comments have ParentURI == RootURI (both point to the post)
362 var parentRef *CommentRef
363 if comment.ParentURI != comment.RootURI {
364 parentRef = &CommentRef{
365 URI: comment.ParentURI,
366 CID: comment.ParentCID,
367 }
368 }
369
370 // Build viewer state - populate from vote states map (Phase 2B)
371 var viewer *CommentViewerState
372 if viewerDID != nil {
373 viewer = &CommentViewerState{
374 Vote: nil,
375 VoteURI: nil,
376 }
377
378 // Check if viewer has voted on this comment
379 if voteStates != nil {
380 if voteData, ok := voteStates[comment.URI]; ok {
381 voteMap, isMap := voteData.(map[string]interface{})
382 if isMap {
383 // Extract vote direction and URI
384 // Create copies before taking addresses to avoid pointer to loop variable issues
385 if direction, hasDirection := voteMap["direction"].(string); hasDirection {
386 directionCopy := direction
387 viewer.Vote = &directionCopy
388 }
389 if voteURI, hasVoteURI := voteMap["uri"].(string); hasVoteURI {
390 voteURICopy := voteURI
391 viewer.VoteURI = &voteURICopy
392 }
393 }
394 }
395 }
396 }
397
398 // Build minimal comment record to satisfy lexicon contract
399 // The record field is required by social.coves.community.comment.defs#commentView
400 commentRecord := s.buildCommentRecord(comment)
401
402 // Deserialize contentFacets from JSONB (Phase 2C)
403 // Parse facets from database JSON string to populate contentFacets field
404 var contentFacets []interface{}
405 if comment.ContentFacets != nil && *comment.ContentFacets != "" {
406 if err := json.Unmarshal([]byte(*comment.ContentFacets), &contentFacets); err != nil {
407 // Log error but don't fail request - facets are optional
408 log.Printf("Warning: Failed to unmarshal content facets for comment %s: %v", comment.URI, err)
409 }
410 }
411
412 // Deserialize embed from JSONB (Phase 2C)
413 // Parse embed from database JSON string to populate embed field
414 var embed interface{}
415 if comment.Embed != nil && *comment.Embed != "" {
416 var embedMap map[string]interface{}
417 if err := json.Unmarshal([]byte(*comment.Embed), &embedMap); err != nil {
418 // Log error but don't fail request - embed is optional
419 log.Printf("Warning: Failed to unmarshal embed for comment %s: %v", comment.URI, err)
420 } else {
421 embed = embedMap
422 }
423 }
424
425 return &CommentView{
426 URI: comment.URI,
427 CID: comment.CID,
428 Author: authorView,
429 Record: commentRecord,
430 Post: postRef,
431 Parent: parentRef,
432 Content: comment.Content,
433 ContentFacets: contentFacets,
434 Embed: embed,
435 CreatedAt: comment.CreatedAt.Format(time.RFC3339),
436 IndexedAt: comment.IndexedAt.Format(time.RFC3339),
437 Stats: stats,
438 Viewer: viewer,
439 }
440}
441
442// buildCommentRecord constructs a complete CommentRecord from a Comment entity
443// Satisfies the lexicon requirement that commentView.record is a required field
444// Deserializes JSONB fields (embed, facets, labels) for complete record (Phase 2C)
445func (s *commentService) buildCommentRecord(comment *Comment) *CommentRecord {
446 record := &CommentRecord{
447 Type: "social.coves.community.comment",
448 Reply: ReplyRef{
449 Root: StrongRef{
450 URI: comment.RootURI,
451 CID: comment.RootCID,
452 },
453 Parent: StrongRef{
454 URI: comment.ParentURI,
455 CID: comment.ParentCID,
456 },
457 },
458 Content: comment.Content,
459 CreatedAt: comment.CreatedAt.Format(time.RFC3339),
460 Langs: comment.Langs,
461 }
462
463 // Deserialize facets from JSONB (Phase 2C)
464 if comment.ContentFacets != nil && *comment.ContentFacets != "" {
465 var facets []interface{}
466 if err := json.Unmarshal([]byte(*comment.ContentFacets), &facets); err != nil {
467 // Log error but don't fail request - facets are optional
468 log.Printf("Warning: Failed to unmarshal facets for record %s: %v", comment.URI, err)
469 } else {
470 record.Facets = facets
471 }
472 }
473
474 // Deserialize embed from JSONB (Phase 2C)
475 if comment.Embed != nil && *comment.Embed != "" {
476 var embed map[string]interface{}
477 if err := json.Unmarshal([]byte(*comment.Embed), &embed); err != nil {
478 // Log error but don't fail request - embed is optional
479 log.Printf("Warning: Failed to unmarshal embed for record %s: %v", comment.URI, err)
480 } else {
481 record.Embed = embed
482 }
483 }
484
485 // Deserialize labels from JSONB (Phase 2C)
486 if comment.ContentLabels != nil && *comment.ContentLabels != "" {
487 var labels SelfLabels
488 if err := json.Unmarshal([]byte(*comment.ContentLabels), &labels); err != nil {
489 // Log error but don't fail request - labels are optional
490 log.Printf("Warning: Failed to unmarshal labels for record %s: %v", comment.URI, err)
491 } else {
492 record.Labels = &labels
493 }
494 }
495
496 return record
497}
498
499// getPDSClient creates a PDS client from an OAuth session.
500// If a custom factory was provided (for testing), uses that.
501// Otherwise, uses DPoP authentication via indigo's APIClient for proper OAuth token handling.
502func (s *commentService) getPDSClient(ctx context.Context, session *oauth.ClientSessionData) (pds.Client, error) {
503 // Use custom factory if provided (e.g., for testing with password auth)
504 if s.pdsClientFactory != nil {
505 return s.pdsClientFactory(ctx, session)
506 }
507
508 // Production path: use OAuth with DPoP
509 if s.oauthClient == nil || s.oauthClient.ClientApp == nil {
510 return nil, fmt.Errorf("OAuth client not configured")
511 }
512
513 client, err := pds.NewFromOAuthSession(ctx, s.oauthClient.ClientApp, session)
514 if err != nil {
515 return nil, fmt.Errorf("failed to create PDS client: %w", err)
516 }
517
518 return client, nil
519}
520
521// CreateComment creates a new comment on a post or reply to another comment
522func (s *commentService) CreateComment(ctx context.Context, session *oauth.ClientSessionData, req CreateCommentRequest) (*CreateCommentResponse, error) {
523 // Validate content not empty
524 content := strings.TrimSpace(req.Content)
525 if content == "" {
526 return nil, ErrContentEmpty
527 }
528
529 // Validate content length (max 10000 graphemes)
530 if uniseg.GraphemeClusterCount(content) > maxCommentGraphemes {
531 return nil, ErrContentTooLong
532 }
533
534 // Validate reply references
535 if err := validateReplyRef(req.Reply); err != nil {
536 return nil, err
537 }
538
539 // Create PDS client for this session
540 pdsClient, err := s.getPDSClient(ctx, session)
541 if err != nil {
542 s.logger.Error("failed to create PDS client",
543 "error", err,
544 "commenter", session.AccountDID)
545 return nil, fmt.Errorf("failed to create PDS client: %w", err)
546 }
547
548 // Generate TID for the record key
549 tid := syntax.NewTIDNow(0)
550
551 // Build comment record following the lexicon schema
552 record := CommentRecord{
553 Type: commentCollection,
554 Reply: req.Reply,
555 Content: content,
556 Facets: req.Facets,
557 Embed: req.Embed,
558 Langs: req.Langs,
559 Labels: req.Labels,
560 CreatedAt: time.Now().UTC().Format(time.RFC3339),
561 }
562
563 // Create the comment record on the user's PDS
564 uri, cid, err := pdsClient.CreateRecord(ctx, commentCollection, tid.String(), record)
565 if err != nil {
566 s.logger.Error("failed to create comment on PDS",
567 "error", err,
568 "commenter", session.AccountDID,
569 "root", req.Reply.Root.URI,
570 "parent", req.Reply.Parent.URI)
571 if pds.IsAuthError(err) {
572 return nil, ErrNotAuthorized
573 }
574 return nil, fmt.Errorf("failed to create comment: %w", err)
575 }
576
577 s.logger.Info("comment created",
578 "commenter", session.AccountDID,
579 "uri", uri,
580 "cid", cid,
581 "root", req.Reply.Root.URI,
582 "parent", req.Reply.Parent.URI)
583
584 return &CreateCommentResponse{
585 URI: uri,
586 CID: cid,
587 }, nil
588}
589
590// UpdateComment updates an existing comment's content
591func (s *commentService) UpdateComment(ctx context.Context, session *oauth.ClientSessionData, req UpdateCommentRequest) (*UpdateCommentResponse, error) {
592 // Validate URI format
593 if req.URI == "" {
594 return nil, ErrCommentNotFound
595 }
596 if !strings.HasPrefix(req.URI, "at://") {
597 return nil, ErrCommentNotFound
598 }
599
600 // Extract DID and rkey from URI (at://did/collection/rkey)
601 parts := strings.Split(req.URI, "/")
602 if len(parts) < 5 || parts[3] != commentCollection {
603 return nil, ErrCommentNotFound
604 }
605 did := parts[2]
606 rkey := parts[4]
607
608 // Verify ownership: URI must belong to the authenticated user
609 if did != session.AccountDID.String() {
610 return nil, ErrNotAuthorized
611 }
612
613 // Validate new content
614 content := strings.TrimSpace(req.Content)
615 if content == "" {
616 return nil, ErrContentEmpty
617 }
618
619 // Validate content length (max 10000 graphemes)
620 if uniseg.GraphemeClusterCount(content) > maxCommentGraphemes {
621 return nil, ErrContentTooLong
622 }
623
624 // Create PDS client for this session
625 pdsClient, err := s.getPDSClient(ctx, session)
626 if err != nil {
627 s.logger.Error("failed to create PDS client",
628 "error", err,
629 "commenter", session.AccountDID)
630 return nil, fmt.Errorf("failed to create PDS client: %w", err)
631 }
632
633 // Fetch existing record from PDS to get the reply refs (immutable)
634 existingRecord, err := pdsClient.GetRecord(ctx, commentCollection, rkey)
635 if err != nil {
636 s.logger.Error("failed to fetch existing comment from PDS",
637 "error", err,
638 "uri", req.URI,
639 "rkey", rkey)
640 if pds.IsAuthError(err) {
641 return nil, ErrNotAuthorized
642 }
643 if errors.Is(err, pds.ErrNotFound) {
644 return nil, ErrCommentNotFound
645 }
646 return nil, fmt.Errorf("failed to fetch existing comment: %w", err)
647 }
648
649 // Extract reply refs from existing record (must be preserved)
650 replyData, ok := existingRecord.Value["reply"].(map[string]interface{})
651 if !ok {
652 s.logger.Error("invalid reply structure in existing comment",
653 "uri", req.URI)
654 return nil, fmt.Errorf("invalid existing comment structure")
655 }
656
657 // Parse reply refs
658 var reply ReplyRef
659 replyJSON, err := json.Marshal(replyData)
660 if err != nil {
661 return nil, fmt.Errorf("failed to marshal reply data: %w", err)
662 }
663 if err := json.Unmarshal(replyJSON, &reply); err != nil {
664 return nil, fmt.Errorf("failed to unmarshal reply data: %w", err)
665 }
666
667 // Extract original createdAt timestamp (immutable)
668 createdAt, _ := existingRecord.Value["createdAt"].(string)
669 if createdAt == "" {
670 createdAt = time.Now().UTC().Format(time.RFC3339)
671 }
672
673 // Build updated comment record
674 updatedRecord := CommentRecord{
675 Type: commentCollection,
676 Reply: reply, // Preserve original reply refs
677 Content: content,
678 Facets: req.Facets,
679 Embed: req.Embed,
680 Langs: req.Langs,
681 Labels: req.Labels,
682 CreatedAt: createdAt, // Preserve original timestamp
683 }
684
685 // Update the record on PDS (putRecord)
686 // Note: This creates a new CID even though the URI stays the same
687 // TODO: Use PutRecord instead of CreateRecord for proper update semantics with optimistic locking.
688 // PutRecord should accept the existing CID (existingRecord.CID) to ensure concurrent updates are detected.
689 // However, PutRecord is not yet implemented in internal/atproto/pds/client.go.
690 uri, cid, err := pdsClient.CreateRecord(ctx, commentCollection, rkey, updatedRecord)
691 if err != nil {
692 s.logger.Error("failed to update comment on PDS",
693 "error", err,
694 "uri", req.URI,
695 "rkey", rkey)
696 if pds.IsAuthError(err) {
697 return nil, ErrNotAuthorized
698 }
699 return nil, fmt.Errorf("failed to update comment: %w", err)
700 }
701
702 s.logger.Info("comment updated",
703 "commenter", session.AccountDID,
704 "uri", uri,
705 "new_cid", cid,
706 "old_cid", existingRecord.CID)
707
708 return &UpdateCommentResponse{
709 URI: uri,
710 CID: cid,
711 }, nil
712}
713
714// DeleteComment soft-deletes a comment by removing it from the user's PDS
715func (s *commentService) DeleteComment(ctx context.Context, session *oauth.ClientSessionData, req DeleteCommentRequest) error {
716 // Validate URI format
717 if req.URI == "" {
718 return ErrCommentNotFound
719 }
720 if !strings.HasPrefix(req.URI, "at://") {
721 return ErrCommentNotFound
722 }
723
724 // Extract DID and rkey from URI (at://did/collection/rkey)
725 parts := strings.Split(req.URI, "/")
726 if len(parts) < 5 || parts[3] != commentCollection {
727 return ErrCommentNotFound
728 }
729 did := parts[2]
730 rkey := parts[4]
731
732 // Verify ownership: URI must belong to the authenticated user
733 if did != session.AccountDID.String() {
734 return ErrNotAuthorized
735 }
736
737 // Create PDS client for this session
738 pdsClient, err := s.getPDSClient(ctx, session)
739 if err != nil {
740 s.logger.Error("failed to create PDS client",
741 "error", err,
742 "commenter", session.AccountDID)
743 return fmt.Errorf("failed to create PDS client: %w", err)
744 }
745
746 // Verify comment exists on PDS before deleting
747 _, err = pdsClient.GetRecord(ctx, commentCollection, rkey)
748 if err != nil {
749 s.logger.Error("failed to verify comment exists on PDS",
750 "error", err,
751 "uri", req.URI,
752 "rkey", rkey)
753 if pds.IsAuthError(err) {
754 return ErrNotAuthorized
755 }
756 if errors.Is(err, pds.ErrNotFound) {
757 return ErrCommentNotFound
758 }
759 return fmt.Errorf("failed to verify comment: %w", err)
760 }
761
762 // Delete the comment record from user's PDS
763 if err := pdsClient.DeleteRecord(ctx, commentCollection, rkey); err != nil {
764 s.logger.Error("failed to delete comment on PDS",
765 "error", err,
766 "uri", req.URI,
767 "rkey", rkey)
768 if pds.IsAuthError(err) {
769 return ErrNotAuthorized
770 }
771 return fmt.Errorf("failed to delete comment: %w", err)
772 }
773
774 s.logger.Info("comment deleted",
775 "commenter", session.AccountDID,
776 "uri", req.URI)
777
778 return nil
779}
780
781// validateReplyRef validates that reply references are well-formed
782func validateReplyRef(reply ReplyRef) error {
783 // Validate root reference
784 if reply.Root.URI == "" {
785 return ErrInvalidReply
786 }
787 if !strings.HasPrefix(reply.Root.URI, "at://") {
788 return ErrInvalidReply
789 }
790 if reply.Root.CID == "" {
791 return ErrInvalidReply
792 }
793
794 // Validate parent reference
795 if reply.Parent.URI == "" {
796 return ErrInvalidReply
797 }
798 if !strings.HasPrefix(reply.Parent.URI, "at://") {
799 return ErrInvalidReply
800 }
801 if reply.Parent.CID == "" {
802 return ErrInvalidReply
803 }
804
805 return nil
806}
807
808// buildPostView converts a Post entity to a PostView for the comment response
809// Hydrates author handle and community name per lexicon requirements
810func (s *commentService) buildPostView(ctx context.Context, post *posts.Post, viewerDID *string) *posts.PostView {
811 // Build author view - fetch user to get handle (required by lexicon)
812 // The lexicon marks authorView.handle with format:"handle", so DIDs are invalid
813 authorHandle := post.AuthorDID // Fallback if user not found
814 if user, err := s.userRepo.GetByDID(ctx, post.AuthorDID); err == nil {
815 authorHandle = user.Handle
816 } else {
817 // Log warning but don't fail the entire request
818 log.Printf("Warning: Failed to fetch user for post author %s: %v", post.AuthorDID, err)
819 }
820
821 authorView := &posts.AuthorView{
822 DID: post.AuthorDID,
823 Handle: authorHandle,
824 // DisplayName, Avatar, Reputation will be populated when user profile schema is extended
825 // Currently User model only has DID, Handle, PDSURL fields
826 DisplayName: nil,
827 Avatar: nil,
828 Reputation: nil,
829 }
830
831 // Build community reference - fetch community to get name and avatar (required by lexicon)
832 // The lexicon marks communityRef.name and handle as required, so DIDs alone are insufficient
833 // DATA INTEGRITY: Community should always exist for posts. If missing, it indicates orphaned data.
834 community, err := s.communityRepo.GetByDID(ctx, post.CommunityDID)
835 if err != nil {
836 // This indicates a data integrity issue: post references non-existent community
837 // Log as ERROR (not warning) since this should never happen in normal operation
838 log.Printf("ERROR: Data integrity issue - post %s references non-existent community %s: %v",
839 post.URI, post.CommunityDID, err)
840 // Use DID as fallback for both handle and name to prevent breaking the API
841 // This allows the response to be returned while surfacing the integrity issue in logs
842 community = &communities.Community{
843 DID: post.CommunityDID,
844 Handle: post.CommunityDID, // Fallback: use DID as handle
845 Name: post.CommunityDID, // Fallback: use DID as name
846 }
847 }
848
849 // Capture handle for communityRef (required by lexicon)
850 communityHandle := community.Handle
851
852 // Determine display name: prefer DisplayName, fall back to Name, then Handle
853 var communityName string
854 if community.DisplayName != "" {
855 communityName = community.DisplayName
856 } else if community.Name != "" {
857 communityName = community.Name
858 } else {
859 communityName = community.Handle
860 }
861
862 // Build avatar URL from CID if available
863 // Avatar is stored as blob in community's repository
864 // Format: https://{pds}/xrpc/com.atproto.sync.getBlob?did={community_did}&cid={avatar_cid}
865 var avatarURL *string
866 if community.AvatarCID != "" && community.PDSURL != "" {
867 // Validate HTTPS for security (prevent mixed content warnings, MitM attacks)
868 if !strings.HasPrefix(community.PDSURL, "https://") {
869 log.Printf("Warning: Skipping non-HTTPS PDS URL for community %s", community.DID)
870 } else if !strings.HasPrefix(community.AvatarCID, "baf") {
871 // Validate CID format (IPFS CIDs start with "baf" for CIDv1 base32)
872 log.Printf("Warning: Invalid CID format for community %s", community.DID)
873 } else {
874 // Use proper URL escaping to prevent injection attacks
875 avatarURLString := fmt.Sprintf("%s/xrpc/com.atproto.sync.getBlob?did=%s&cid=%s",
876 strings.TrimSuffix(community.PDSURL, "/"),
877 url.QueryEscape(community.DID),
878 url.QueryEscape(community.AvatarCID))
879 avatarURL = &avatarURLString
880 }
881 }
882
883 communityRef := &posts.CommunityRef{
884 DID: post.CommunityDID,
885 Handle: communityHandle,
886 Name: communityName,
887 Avatar: avatarURL,
888 }
889
890 // Build aggregated statistics
891 stats := &posts.PostStats{
892 Upvotes: post.UpvoteCount,
893 Downvotes: post.DownvoteCount,
894 Score: post.Score,
895 CommentCount: post.CommentCount,
896 }
897
898 // Build viewer state if authenticated
899 var viewer *posts.ViewerState
900 if viewerDID != nil {
901 // TODO (Phase 2B): Query viewer's vote state
902 viewer = &posts.ViewerState{
903 Vote: nil,
904 VoteURI: nil,
905 Saved: false,
906 }
907 }
908
909 // Build minimal post record to satisfy lexicon contract
910 // The record field is required by social.coves.community.post.get#postView
911 postRecord := s.buildPostRecord(post)
912
913 return &posts.PostView{
914 URI: post.URI,
915 CID: post.CID,
916 RKey: post.RKey,
917 Author: authorView,
918 Record: postRecord,
919 Community: communityRef,
920 Title: post.Title,
921 Text: post.Content,
922 CreatedAt: post.CreatedAt,
923 IndexedAt: post.IndexedAt,
924 EditedAt: post.EditedAt,
925 Stats: stats,
926 Viewer: viewer,
927 }
928}
929
930// buildPostRecord constructs a minimal PostRecord from a Post entity
931// Satisfies the lexicon requirement that postView.record is a required field
932// TODO (Phase 2C): Unmarshal JSON fields (embed, facets, labels) for complete record
933func (s *commentService) buildPostRecord(post *posts.Post) *posts.PostRecord {
934 record := &posts.PostRecord{
935 Type: "social.coves.community.post",
936 Community: post.CommunityDID,
937 Author: post.AuthorDID,
938 CreatedAt: post.CreatedAt.Format(time.RFC3339),
939 Title: post.Title,
940 Content: post.Content,
941 }
942
943 // TODO (Phase 2C): Parse JSON fields from database for complete record:
944 // - Unmarshal post.Embed (*string) → record.Embed (map[string]interface{})
945 // - Unmarshal post.ContentFacets (*string) → record.Facets ([]interface{})
946 // - Unmarshal post.ContentLabels (*string) → record.Labels (*SelfLabels)
947 // These fields are stored as JSONB in the database and need proper deserialization
948
949 return record
950}
951
952// validateGetCommentsRequest validates and normalizes request parameters
953// Applies default values and enforces bounds per API specification
954func validateGetCommentsRequest(req *GetCommentsRequest) error {
955 if req == nil {
956 return errors.New("request cannot be nil")
957 }
958
959 // Validate PostURI is present and well-formed
960 if req.PostURI == "" {
961 return errors.New("post URI is required")
962 }
963
964 if !strings.HasPrefix(req.PostURI, "at://") {
965 return errors.New("invalid AT-URI format: must start with 'at://'")
966 }
967
968 // Apply depth defaults and bounds (0-100, default 10)
969 if req.Depth < 0 {
970 req.Depth = 10
971 }
972 if req.Depth > 100 {
973 req.Depth = 100
974 }
975
976 // Apply limit defaults and bounds (1-100, default 50)
977 if req.Limit <= 0 {
978 req.Limit = 50
979 }
980 if req.Limit > 100 {
981 req.Limit = 100
982 }
983
984 // Apply sort default and validate
985 if req.Sort == "" {
986 req.Sort = "hot"
987 }
988
989 validSorts := map[string]bool{
990 "hot": true,
991 "top": true,
992 "new": true,
993 }
994 if !validSorts[req.Sort] {
995 return fmt.Errorf("invalid sort: must be one of [hot, top, new], got '%s'", req.Sort)
996 }
997
998 // Validate timeframe (only applies to "top" sort)
999 if req.Timeframe != "" {
1000 validTimeframes := map[string]bool{
1001 "hour": true,
1002 "day": true,
1003 "week": true,
1004 "month": true,
1005 "year": true,
1006 "all": true,
1007 }
1008 if !validTimeframes[req.Timeframe] {
1009 return fmt.Errorf("invalid timeframe: must be one of [hour, day, week, month, year, all], got '%s'", req.Timeframe)
1010 }
1011 }
1012
1013 return nil
1014}