feat: add comments
This commit is contained in:
@@ -0,0 +1,183 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/prabhupadasays/spsays-bff/internal/model"
|
||||
"github.com/prabhupadasays/spsays-bff/internal/repository"
|
||||
)
|
||||
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
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
|
||||
}
|
||||
@@ -35,6 +35,8 @@ const (
|
||||
NotifFollowAccepted = "follow_accepted"
|
||||
NotifPostLike = "post_like"
|
||||
NotifPostMention = "post_mention"
|
||||
NotifPostComment = "post_comment"
|
||||
NotifCommentReply = "comment_reply"
|
||||
|
||||
// Reminder kinds are delivered live (email + push + a websocket toast)
|
||||
// but NOT persisted — they're ephemeral nudges, not history.
|
||||
@@ -48,7 +50,7 @@ const (
|
||||
// opt-outs map exactly), but the UI reads/writes whole categories.
|
||||
var PrefCategories = map[string][]string{
|
||||
"follows": {NotifFollowRequest, NotifFollowAccepted},
|
||||
"post_interactions": {NotifPostLike, NotifPostMention},
|
||||
"post_interactions": {NotifPostLike, NotifPostMention, NotifPostComment, NotifCommentReply},
|
||||
"sadhna_reminders": {NotifChantReminder, NotifQuoteReminder, NotifChantIncomplete},
|
||||
}
|
||||
|
||||
@@ -351,6 +353,59 @@ func (s *NotificationService) NotifyPostMention(ctx context.Context, mentionedID
|
||||
})
|
||||
}
|
||||
|
||||
// NotifyPostComment tells the post author that `commenterID` commented on
|
||||
// their post. NotifyCommentReply tells `recipientID` (the author of the
|
||||
// comment being replied to) that `replierID` replied. Both: persisted row +
|
||||
// realtime + Web Push, no email (comments are interactive, not digest-worthy).
|
||||
// Self-actions are skipped.
|
||||
func (s *NotificationService) NotifyPostComment(ctx context.Context, postID, authorID, commenterID string) {
|
||||
s.notifyComment(ctx, NotifPostComment, postID, authorID, commenterID)
|
||||
}
|
||||
|
||||
func (s *NotificationService) NotifyCommentReply(ctx context.Context, postID, recipientID, replierID string) {
|
||||
s.notifyComment(ctx, NotifCommentReply, postID, recipientID, replierID)
|
||||
}
|
||||
|
||||
func (s *NotificationService) notifyComment(ctx context.Context, kind, postID, recipientID, actorID string) {
|
||||
if recipientID == "" || actorID == "" || recipientID == actorID {
|
||||
return
|
||||
}
|
||||
off := s.disabledPrefs(ctx, recipientID)
|
||||
if err := s.notifs.CreateComment(ctx, recipientID, actorID, postID, kind); err != nil {
|
||||
s.logger.Warn("persist comment notification failed",
|
||||
zap.String("recipient", recipientID), zap.String("kind", kind), zap.Error(err))
|
||||
}
|
||||
actorUser, _ := s.users.FindByID(ctx, actorID)
|
||||
if !prefOff(off, kind, repository.ChannelInApp) {
|
||||
s.publishRealtime(ctx, recipientID, kind, actorUser)
|
||||
}
|
||||
|
||||
if s.push == nil || prefOff(off, kind, repository.ChannelPush) {
|
||||
return
|
||||
}
|
||||
who := "Someone"
|
||||
if actorUser != nil {
|
||||
who = actorDisplay(actorUser)
|
||||
}
|
||||
// Tapping it opens the post (via its author's handle — the actor isn't the
|
||||
// post owner on a reply). Falls back to the notification center.
|
||||
url := s.frontendURL + "/notifications"
|
||||
if h, err := s.notifs.PostAuthorHandle(ctx, postID); err == nil && h != "" {
|
||||
url = s.frontendURL + "/u/" + h + "/posts#post-" + postID
|
||||
}
|
||||
title, body := "New comment", who+" commented on your post"
|
||||
if kind == NotifCommentReply {
|
||||
title, body = "New reply", who+" replied to your comment"
|
||||
}
|
||||
s.push.SendToUser(ctx, recipientID, PushPayload{
|
||||
Title: title,
|
||||
Body: body,
|
||||
URL: url,
|
||||
Icon: s.iconURL(),
|
||||
Tag: kind + ":" + postID,
|
||||
})
|
||||
}
|
||||
|
||||
// likeMessage renders the aggregated like text: "X liked your post" or
|
||||
// "X and N others liked your post".
|
||||
func likeMessage(who string, count int) string {
|
||||
|
||||
@@ -35,11 +35,12 @@ type PostService struct {
|
||||
r2 *repository.R2
|
||||
likes *repository.LikeRepo
|
||||
mentions *repository.MentionRepo
|
||||
comments *repository.CommentRepo
|
||||
notif *NotificationService // optional; set via SetNotifier
|
||||
}
|
||||
|
||||
func NewPostService(posts *repository.PostRepo, r2 *repository.R2, likes *repository.LikeRepo, mentions *repository.MentionRepo) *PostService {
|
||||
return &PostService{posts: posts, r2: r2, likes: likes, mentions: mentions}
|
||||
func NewPostService(posts *repository.PostRepo, r2 *repository.R2, likes *repository.LikeRepo, mentions *repository.MentionRepo, comments *repository.CommentRepo) *PostService {
|
||||
return &PostService{posts: posts, r2: r2, likes: likes, mentions: mentions, comments: comments}
|
||||
}
|
||||
|
||||
// SetNotifier wires the notification seam after construction (the
|
||||
@@ -97,6 +98,9 @@ func (s *PostService) Get(ctx context.Context, id, viewerID string) (*model.Post
|
||||
if err := s.mentions.HydrateCounts(ctx, []*model.Post{p}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := s.comments.HydratePinned(ctx, viewerID, []*model.Post{p}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return p, nil
|
||||
}
|
||||
|
||||
@@ -202,9 +206,24 @@ func (s *PostService) ListByHandle(ctx context.Context, handle, viewerID string,
|
||||
if err := s.hydrateMentionsSlice(ctx, posts); err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
if err := s.hydratePinnedSlice(ctx, viewerID, posts); err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
return posts, total, nil
|
||||
}
|
||||
|
||||
// hydratePinnedSlice fills PinnedComment on a value slice.
|
||||
func (s *PostService) hydratePinnedSlice(ctx context.Context, viewerID string, posts []model.Post) error {
|
||||
if len(posts) == 0 {
|
||||
return nil
|
||||
}
|
||||
ptrs := make([]*model.Post, len(posts))
|
||||
for i := range posts {
|
||||
ptrs[i] = &posts[i]
|
||||
}
|
||||
return s.comments.HydratePinned(ctx, viewerID, ptrs)
|
||||
}
|
||||
|
||||
// hydrateLikesSlice fills LikesCount + ViewerHasLiked on a value slice.
|
||||
func (s *PostService) hydrateLikesSlice(ctx context.Context, viewerID string, posts []model.Post) error {
|
||||
if len(posts) == 0 {
|
||||
@@ -347,6 +366,9 @@ func (s *PostService) ListMine(ctx context.Context, userID string, limit, offset
|
||||
if err := s.hydrateMentionsSlice(ctx, posts); err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
if err := s.hydratePinnedSlice(ctx, userID, posts); err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
return posts, total, nil
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user