package service import ( "context" "errors" "fmt" "regexp" "strings" "github.com/prabhupadasays/spsays-bff/internal/model" "github.com/prabhupadasays/spsays-bff/internal/repository" ) // mentionHandleRe matches @handle tokens. The leading boundary (line start or // a non-handle character that isn't itself "@") keeps it from firing inside // emails (foo@bar) or "@@". var mentionHandleRe = regexp.MustCompile(`(^|[^A-Za-z0-9_@])@([A-Za-z0-9_]{1,30})`) // mentionHandles pulls unique @handles out of a comment body (capped, so a // pathological body can't fan out unboundedly). func mentionHandles(body string) []string { matches := mentionHandleRe.FindAllStringSubmatch(body, -1) if matches == nil { return nil } seen := map[string]bool{} out := make([]string, 0, len(matches)) for _, m := range matches { key := strings.ToLower(m[2]) if seen[key] { continue } seen[key] = true out = append(out, m[2]) if len(out) >= 20 { break } } return out } // maxCommentLen caps a comment/reply body. const maxCommentLen = 2000 // CommentService is the comment / reply / like / pin / report brain. It sits // over CommentRepo, enforces the rules (non-empty body, author-only pin, // author-or-post-owner delete, one-level threading), and routes new // comments/replies through the NotificationService. type CommentService struct { comments *repository.CommentRepo notif *NotificationService // optional; set via SetNotifier } func NewCommentService(comments *repository.CommentRepo) *CommentService { return &CommentService{comments: comments} } // SetNotifier wires the notification seam after construction. func (s *CommentService) SetNotifier(n *NotificationService) { s.notif = n } // Add posts a comment or reply. parentID nil → a top-level comment (the post // author is notified). Non-nil → a reply: the new comment is flattened onto // the thread root and addressed to the target comment's author, who is the one // notified. Anyone signed in can comment. Returns ErrDuplicateComment when the // same user re-posts identical text in the same scope. func (s *CommentService) Add(ctx context.Context, viewerID, postID string, parentID *string, body string) (*model.Comment, error) { body = strings.TrimSpace(body) if body == "" { return nil, errors.New("comment can't be empty") } if len(body) > maxCommentLen { return nil, fmt.Errorf("comment too long (max %d)", maxCommentLen) } // Validate the post exists + grab its author for the comment notification. postAuthor, err := s.comments.PostAuthor(ctx, postID) if err != nil { return nil, err // ErrPostNotFound → 404 } var rootID *string // thread root the new comment hangs under var replyToUserID *string // who a reply addresses (for the @handle prefix) notifyUserID := postAuthor isReply := parentID != nil if isReply { target, err := s.comments.Get(ctx, *parentID, viewerID) if err != nil { return nil, err // ErrCommentNotFound → 404 } if target.PostID != postID { return nil, ErrNotFound // parent belongs to a different post } // Flatten: replies always hang under the thread ROOT, never under // another reply — so every reply lives in one thread. The reply is // addressed to whoever we replied to, and they're the one notified. root := target.ID if target.ParentID != nil { root = *target.ParentID } rootID = &root replyToUserID = &target.UserID notifyUserID = target.UserID } c, err := s.comments.Create(ctx, postID, viewerID, rootID, replyToUserID, body) if err != nil { return nil, err // ErrDuplicateComment → 409 via mapSocialErr } // Fire-and-forget on a detached context so it isn't cancelled when the // request returns. Never notify yourself. if s.notif != nil && notifyUserID != "" && notifyUserID != viewerID { if isReply { go s.notif.NotifyCommentReply(context.Background(), postID, notifyUserID, viewerID) } else { go s.notif.NotifyPostComment(context.Background(), postID, notifyUserID, viewerID) } } // Ping anyone @mentioned in the body — skipping the actor and whoever was // already notified above (post/parent author) so they aren't double-pinged. if s.notif != nil { if handles := mentionHandles(body); len(handles) > 0 { go s.notif.NotifyCommentMentions(context.Background(), postID, viewerID, handles, notifyUserID) } } return c, nil } // List returns a page of a post's top-level comments (pinned first) + total. func (s *CommentService) List(ctx context.Context, postID, viewerID string, limit, offset int) ([]model.Comment, int, error) { return s.comments.ListTopLevel(ctx, postID, viewerID, limit, offset) } // Replies returns a page of a thread's replies (oldest first) + total. func (s *CommentService) Replies(ctx context.Context, parentID, viewerID string, limit, offset int) ([]model.Comment, int, error) { return s.comments.ListReplies(ctx, parentID, viewerID, limit, offset) } // Like / Unlike toggle the caller's like on a comment; return the new count. func (s *CommentService) Like(ctx context.Context, viewerID, commentID string) (int, error) { return s.comments.Like(ctx, commentID, viewerID) } func (s *CommentService) Unlike(ctx context.Context, viewerID, commentID string) (int, error) { return s.comments.Unlike(ctx, commentID, viewerID) } // Pin / Unpin — post author only, and only top-level comments are pinnable. func (s *CommentService) Pin(ctx context.Context, viewerID, commentID string) error { c, err := s.requirePostOwner(ctx, viewerID, commentID) if err != nil { return err } if c.ParentID != nil { return errors.New("only top-level comments can be pinned") } return s.comments.Pin(ctx, c.PostID, commentID) } func (s *CommentService) Unpin(ctx context.Context, viewerID, commentID string) error { c, err := s.requirePostOwner(ctx, viewerID, commentID) if err != nil { return err } return s.comments.Unpin(ctx, c.PostID, commentID) } // Delete removes a comment (and, for a thread root, its whole thread). Allowed // for the comment's author or the post's author. func (s *CommentService) Delete(ctx context.Context, viewerID, commentID string) error { c, err := s.comments.Get(ctx, commentID, viewerID) if err != nil { return err } if c.UserID != viewerID { author, err := s.comments.PostAuthor(ctx, c.PostID) if err != nil { return err } if author != viewerID { return ErrForbidden } } return s.comments.Delete(ctx, commentID) } // Report files a report against a comment. Anyone signed in can report; the // category must be in the (post) report allow-list and the reason is optional. func (s *CommentService) Report(ctx context.Context, viewerID, commentID, category, reason string) error { category = strings.ToLower(strings.TrimSpace(category)) if !postReportCategories[category] { return errors.New("invalid report category") } reason = strings.TrimSpace(reason) if len(reason) > 2000 { return errors.New("reason too long (max 2000)") } if _, err := s.comments.Get(ctx, commentID, viewerID); err != nil { return err // ErrCommentNotFound → 404 } return s.comments.CreateReport(ctx, viewerID, commentID, category, reason) } // requirePostOwner loads the comment and verifies the viewer owns the post it // belongs to (used to gate pin/unpin). Returns the comment on success. func (s *CommentService) requirePostOwner(ctx context.Context, viewerID, commentID string) (*model.Comment, error) { c, err := s.comments.Get(ctx, commentID, viewerID) if err != nil { return nil, err } author, err := s.comments.PostAuthor(ctx, c.PostID) if err != nil { return nil, err } if author != viewerID { return nil, ErrForbidden } return c, nil }