221 lines
7.5 KiB
Go
221 lines
7.5 KiB
Go
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
|
|
}
|