feat: add post like
This commit is contained in:
@@ -15,6 +15,7 @@ package service
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"html/template"
|
||||
"strings"
|
||||
|
||||
@@ -29,6 +30,7 @@ import (
|
||||
const (
|
||||
NotifFollowRequest = "follow_request"
|
||||
NotifFollowAccepted = "follow_accepted"
|
||||
NotifPostLike = "post_like"
|
||||
|
||||
// Reminder kinds are delivered live (email + push + a websocket toast)
|
||||
// but NOT persisted — they're ephemeral nudges, not history.
|
||||
@@ -96,6 +98,76 @@ func (s *NotificationService) NotifyFollowAccepted(ctx context.Context, recipien
|
||||
"%s accepted your follow request — Prabhupāda Says")
|
||||
}
|
||||
|
||||
// NotifyPostLike tells the post author that `likerID` liked their post. Sent
|
||||
// only when the liker isn't the author (callers also guard). Unlike follows,
|
||||
// likes are high-volume, so this delivers a persisted row + realtime nudge +
|
||||
// Web Push but NO email. The push is tagged per-post so repeated likes on the
|
||||
// same post collapse into one notification rather than stacking.
|
||||
func (s *NotificationService) NotifyPostLike(ctx context.Context, postID, authorID, likerID string) {
|
||||
if authorID == "" || likerID == "" || authorID == likerID {
|
||||
return
|
||||
}
|
||||
// Aggregate into the single (author, post) like-notification row, getting
|
||||
// back how many likers it now represents.
|
||||
count, err := s.notifs.UpsertLike(ctx, authorID, likerID, postID)
|
||||
if err != nil {
|
||||
s.logger.Warn("aggregate like notification failed",
|
||||
zap.String("recipient", authorID), zap.Error(err))
|
||||
count = 1
|
||||
}
|
||||
|
||||
actorUser, _ := s.users.FindByID(ctx, likerID)
|
||||
who := "Someone"
|
||||
if actorUser != nil {
|
||||
who = actorDisplay(actorUser)
|
||||
}
|
||||
body := likeMessage(who, count)
|
||||
|
||||
// Realtime nudge — carry actor_count so the toast can read "and N others".
|
||||
if s.rt != nil {
|
||||
payload := map[string]any{"kind": NotifPostLike, "actor_count": count}
|
||||
if actorUser != nil {
|
||||
payload["actor_name"] = actorDisplay(actorUser)
|
||||
payload["actor_handle"] = actorUser.Handle
|
||||
payload["actor_avatar_url"] = actorUser.AvatarURL
|
||||
}
|
||||
if err := s.rt.Trigger(ctx, "notifications."+authorID, "notification", payload); err != nil {
|
||||
s.logger.Warn("realtime like publish failed", zap.String("recipient", authorID), zap.Error(err))
|
||||
}
|
||||
}
|
||||
|
||||
// Web Push — tapping opens the author's own feed anchored to the post.
|
||||
// Tagged per-post so repeated likes collapse into one notification.
|
||||
if s.push == nil {
|
||||
return
|
||||
}
|
||||
url := s.frontendURL + "/notifications"
|
||||
if author, err := s.users.FindByID(ctx, authorID); err == nil && author != nil {
|
||||
url = s.frontendURL + "/u/" + author.Handle + "/posts#post-" + postID
|
||||
}
|
||||
s.push.SendToUser(ctx, authorID, PushPayload{
|
||||
Title: "New Like",
|
||||
Body: body,
|
||||
URL: url,
|
||||
Icon: s.iconURL(),
|
||||
Tag: NotifPostLike + ":" + 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 {
|
||||
if count <= 1 {
|
||||
return who + " liked your post"
|
||||
}
|
||||
others := count - 1
|
||||
noun := "others"
|
||||
if others == 1 {
|
||||
noun = "other"
|
||||
}
|
||||
return fmt.Sprintf("%s and %d %s liked your post", who, others, noun)
|
||||
}
|
||||
|
||||
// dispatch persists the notification and sends the matching email. All
|
||||
// failures are logged, never propagated.
|
||||
func (s *NotificationService) dispatch(ctx context.Context, recipientID, actorID, kind, subjectFmt string) {
|
||||
|
||||
@@ -33,12 +33,18 @@ var (
|
||||
type PostService struct {
|
||||
posts *repository.PostRepo
|
||||
r2 *repository.R2
|
||||
likes *repository.LikeRepo
|
||||
notif *NotificationService // optional; set via SetNotifier
|
||||
}
|
||||
|
||||
func NewPostService(posts *repository.PostRepo, r2 *repository.R2) *PostService {
|
||||
return &PostService{posts: posts, r2: r2}
|
||||
func NewPostService(posts *repository.PostRepo, r2 *repository.R2, likes *repository.LikeRepo) *PostService {
|
||||
return &PostService{posts: posts, r2: r2, likes: likes}
|
||||
}
|
||||
|
||||
// SetNotifier wires the notification seam after construction (the
|
||||
// NotificationService is built later in server wiring than PostService).
|
||||
func (s *PostService) SetNotifier(n *NotificationService) { s.notif = n }
|
||||
|
||||
// Create persists a new post. The caption is treated as opaque — we trust
|
||||
// the frontend to send the exact text the user selected. Source URL must be
|
||||
// a same-origin path (e.g. /books/bg/1/1?hl=…) — we strip the host if one
|
||||
@@ -72,6 +78,9 @@ func (s *PostService) Get(ctx context.Context, id, viewerID string) (*model.Post
|
||||
if err := s.hydrateReshareStates(ctx, viewerID, []*model.Post{p}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := s.likes.Hydrate(ctx, viewerID, []*model.Post{p}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return p, nil
|
||||
}
|
||||
|
||||
@@ -171,9 +180,58 @@ func (s *PostService) ListByHandle(ctx context.Context, handle, viewerID string,
|
||||
if err := s.hydrateReshareStatesSlice(ctx, viewerID, posts); err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
if err := s.hydrateLikesSlice(ctx, viewerID, posts); err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
return posts, total, nil
|
||||
}
|
||||
|
||||
// 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 {
|
||||
return nil
|
||||
}
|
||||
ptrs := make([]*model.Post, len(posts))
|
||||
for i := range posts {
|
||||
ptrs[i] = &posts[i]
|
||||
}
|
||||
return s.likes.Hydrate(ctx, viewerID, ptrs)
|
||||
}
|
||||
|
||||
// Like / Unlike toggle the caller's like on a post and return the effective
|
||||
// total (likes_count + pending Redis delta).
|
||||
func (s *PostService) Like(ctx context.Context, userID, postID string) (int, error) {
|
||||
changed, err := s.likes.Like(ctx, postID, userID)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
count, err := s.likes.EffectiveCount(ctx, postID)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
// Notify the author on a genuinely new like by someone else. Fire-and-
|
||||
// forget on a detached context so it isn't cancelled when the request
|
||||
// returns; NotifyPostLike re-checks self-like internally too.
|
||||
if changed && s.notif != nil {
|
||||
if authorID, aerr := s.likes.PostAuthor(ctx, postID); aerr == nil && authorID != userID {
|
||||
go s.notif.NotifyPostLike(context.Background(), postID, authorID, userID)
|
||||
}
|
||||
}
|
||||
return count, nil
|
||||
}
|
||||
|
||||
func (s *PostService) Unlike(ctx context.Context, userID, postID string) (int, error) {
|
||||
if _, err := s.likes.Unlike(ctx, postID, userID); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return s.likes.EffectiveCount(ctx, postID)
|
||||
}
|
||||
|
||||
// Likers returns one page of users who liked the post + the total count.
|
||||
func (s *PostService) Likers(ctx context.Context, postID, q string, limit, offset int) ([]model.UserSummary, int, error) {
|
||||
return s.likes.ListLikers(ctx, postID, q, limit, offset)
|
||||
}
|
||||
|
||||
// ListMine is the owner's view of their own profile feed. Every post
|
||||
// they own — authored or reshared — comes from posts.user_id, so the
|
||||
// viewer is by construction the owner. ViewerHasReshared is true for
|
||||
@@ -195,6 +253,9 @@ func (s *PostService) ListMine(ctx context.Context, userID string, limit, offset
|
||||
posts[i].ViewerHasReshared = true
|
||||
}
|
||||
}
|
||||
if err := s.hydrateLikesSlice(ctx, userID, posts); err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
return posts, total, nil
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user