Files
spsays-bff/internal/service/social_service.go
T
2026-06-13 01:29:55 +05:30

691 lines
23 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Package service — social bits of the BFF.
//
// `social_service.go` bundles three lightweight services (Post, Bookmark,
// Collection) because each is small (10–40 LOC of logic) and they share the
// same R2 + UserRepo dependencies. Keeping them together keeps `server.go`'s
// init-wiring tidy without sprawling into 4 separate files.
package service
import (
"context"
"crypto/rand"
"encoding/hex"
"errors"
"fmt"
"path"
"strings"
"time"
"github.com/prabhupadasays/spsays-bff/internal/model"
"github.com/prabhupadasays/spsays-bff/internal/repository"
)
// Sentinels surfaced to handlers.
var (
ErrForbidden = errors.New("forbidden")
ErrNotFound = errors.New("not found")
)
// ---------------------------------------------------------------------------
// PostService
// ---------------------------------------------------------------------------
type PostService struct {
posts *repository.PostRepo
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, comments *repository.CommentRepo) *PostService {
return &PostService{posts: posts, r2: r2, likes: likes, mentions: mentions, comments: comments}
}
// 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
// was provided so links can't be hijacked.
func (s *PostService) Create(ctx context.Context, userID, caption, sourceURL, sourceTitle string, imageURLs, mentionUserIDs []string) (*model.Post, error) {
caption = strings.TrimSpace(caption)
if caption == "" {
return nil, errors.New("caption is required")
}
if len(caption) > 4000 {
return nil, errors.New("caption too long (max 4000)")
}
sourceURL = stripHost(strings.TrimSpace(sourceURL))
if sourceURL == "" || !strings.HasPrefix(sourceURL, "/") {
return nil, errors.New("source_url must be an absolute path starting with /")
}
if len(imageURLs) > 5 {
return nil, errors.New("a post can hold up to 5 images")
}
p, err := s.posts.Create(ctx, userID, caption, sourceURL, sourceTitle, imageURLs)
if err != nil {
return nil, err
}
// Tag mentioned users (best-effort, additive — a bad id never fails the
// post itself). Reflect the count on the returned post, then notify the
// tagged people (fire-and-forget on a detached context).
if len(mentionUserIDs) > 0 {
_ = s.mentions.AddMany(ctx, p.ID, mentionUserIDs)
_ = s.mentions.HydrateCounts(ctx, []*model.Post{p})
go s.notifyMentions(context.Background(), p.ID, userID, mentionUserIDs)
}
return p, nil
}
// Get returns one post (public). `viewerID` is "" for anonymous callers;
// when set, the returned post carries the viewer's reshare state so the
// frontend can render the right action button without a second request.
func (s *PostService) Get(ctx context.Context, id, viewerID string) (*model.Post, error) {
p, err := s.posts.FindByID(ctx, id)
if err != nil {
return nil, err
}
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
}
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
}
// Reshare materialises a reshare as a brand-new posts row owned by the
// caller, with reshared_from_post_id pointing to the canonical original.
// Idempotent — calling it twice returns the same reshare row. Resharing
// one's own post (canonical or chained) is rejected at the repo layer.
//
// Returns the new post so the frontend can drop it into local state
// without a round-trip.
func (s *PostService) Reshare(ctx context.Context, postID, callerID string) (*model.Post, error) {
return s.posts.Reshare(ctx, postID, callerID)
}
// Unreshare removes the caller's reshare of a canonical post by deleting
// their own reshare posts row. Idempotent — no-ops when nothing to delete.
func (s *PostService) Unreshare(ctx context.Context, postID, callerID string) error {
return s.posts.Unreshare(ctx, postID, callerID)
}
// hydrateReshareStates fills in ViewerHasReshared on each post via one
// batched lookup. Skips the query when the viewer is anonymous (the
// answer is uniformly false) or the slice is empty.
func (s *PostService) hydrateReshareStates(ctx context.Context, viewerID string, posts []*model.Post) error {
if viewerID == "" || len(posts) == 0 {
return nil
}
ids := make([]string, 0, len(posts))
for _, p := range posts {
if p == nil {
continue
}
ids = append(ids, p.ID)
}
if len(ids) == 0 {
return nil
}
states, err := s.posts.ReshareStatesFor(ctx, viewerID, ids)
if err != nil {
return err
}
for _, p := range posts {
if p == nil {
continue
}
if states[p.ID] {
p.ViewerHasReshared = true
}
}
return nil
}
// hydrateReshareStatesSlice is the value-slice variant of
// hydrateReshareStates. Repository list methods return []model.Post
// (not pointers) — we wrap each element so the helper can mutate in
// place via a stable pointer.
func (s *PostService) hydrateReshareStatesSlice(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.hydrateReshareStates(ctx, viewerID, ptrs)
}
// Delete checks ownership and removes the post.
func (s *PostService) Delete(ctx context.Context, postID, callerID string) error {
return s.posts.Delete(ctx, postID, callerID)
}
// ReplaceImages swaps the image set on a post. Owner-only — every
// reshare is its own posts row, owned by the resharer, so this works
// uniformly for canonical posts and reshares.
func (s *PostService) ReplaceImages(ctx context.Context, postID, callerID string, imageURLs []string) (*model.Post, error) {
if err := s.posts.ReplaceImages(ctx, postID, callerID, imageURLs); err != nil {
return nil, err
}
return s.posts.FindByID(ctx, postID)
}
// ListByHandle is the public feed view. `viewerID` is "" for anonymous
// callers; when set, each returned post carries the viewer's reshare
// state.
func (s *PostService) ListByHandle(ctx context.Context, handle, viewerID string, limit, offset int) ([]model.Post, int, error) {
if limit <= 0 || limit > 50 {
limit = 20
}
if offset < 0 {
offset = 0
}
posts, total, err := s.posts.ListByHandle(ctx, handle, limit, offset)
if err != nil {
return nil, 0, err
}
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
}
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 {
return nil
}
ptrs := make([]*model.Post, len(posts))
for i := range posts {
ptrs[i] = &posts[i]
}
return s.likes.Hydrate(ctx, viewerID, ptrs)
}
// hydrateMentionsSlice fills MentionsCount on a value slice.
func (s *PostService) hydrateMentionsSlice(ctx context.Context, 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.mentions.HydrateCounts(ctx, ptrs)
}
// SuggestMentions returns prioritised mention suggestions for the composer.
func (s *PostService) SuggestMentions(ctx context.Context, viewerID, q string, limit, offset int) ([]model.UserSummary, error) {
return s.mentions.Suggest(ctx, viewerID, q, limit, offset)
}
// Mentions lists the users tagged on a post.
func (s *PostService) Mentions(ctx context.Context, postID string) ([]model.UserSummary, error) {
return s.mentions.ListForPost(ctx, postID)
}
// AddMention / RemoveMention edit a post's tags — author only.
func (s *PostService) AddMention(ctx context.Context, viewerID, postID, targetUserID string) error {
if err := s.requireAuthor(ctx, viewerID, postID); err != nil {
return err
}
if err := s.mentions.Add(ctx, postID, targetUserID); err != nil {
return err
}
if s.notif != nil {
go s.notif.NotifyPostMention(context.Background(), targetUserID, viewerID, postID)
}
return nil
}
// notifyMentions notifies each freshly-tagged user (deduped, never the author).
func (s *PostService) notifyMentions(ctx context.Context, postID, authorID string, userIDs []string) {
if s.notif == nil {
return
}
seen := make(map[string]bool, len(userIDs))
for _, uid := range userIDs {
if uid == "" || uid == authorID || seen[uid] {
continue
}
seen[uid] = true
s.notif.NotifyPostMention(ctx, uid, authorID, postID)
}
}
func (s *PostService) RemoveMention(ctx context.Context, viewerID, postID, targetUserID string) error {
if err := s.requireAuthor(ctx, viewerID, postID); err != nil {
return err
}
return s.mentions.Remove(ctx, postID, targetUserID)
}
func (s *PostService) requireAuthor(ctx context.Context, viewerID, postID string) error {
p, err := s.posts.FindByID(ctx, postID)
if err != nil {
return err
}
if p.UserID != viewerID {
return ErrForbidden
}
return nil
}
// 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
// reshare rows (the user IS the resharer) and false for canonical
// rows (you don't "reshare your own post").
func (s *PostService) ListMine(ctx context.Context, userID string, limit, offset int) ([]model.Post, int, error) {
if limit <= 0 || limit > 50 {
limit = 20
}
if offset < 0 {
offset = 0
}
posts, total, err := s.posts.ListByUser(ctx, userID, limit, offset)
if err != nil {
return nil, 0, err
}
for i := range posts {
if posts[i].ResharedFromPostID != nil {
posts[i].ViewerHasReshared = true
}
}
if err := s.hydrateLikesSlice(ctx, userID, posts); err != nil {
return nil, 0, err
}
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
}
// ImagePresign returns a one-shot R2 PUT URL for a post image.
// The key is namespaced under post-images/<user_id>/<random>.<ext>. We
// don't tie it to a post_id at presign time because the post might not
// exist yet — the user uploads images first, then submits the post with
// the public URLs.
func (s *PostService) ImagePresign(ctx context.Context, userID, contentType string) (uploadURL, publicURL string, err error) {
return presignImage(ctx, s.r2, "post-images", userID, contentType)
}
// ---------------------------------------------------------------------------
// BookmarkService
// ---------------------------------------------------------------------------
type BookmarkService struct {
users *repository.UserRepo
bookmarks *repository.BookmarkRepo
}
func NewBookmarkService(users *repository.UserRepo, bookmarks *repository.BookmarkRepo) *BookmarkService {
return &BookmarkService{users: users, bookmarks: bookmarks}
}
// Create enforces the plan cap and inserts a new bookmark.
//
// If the same URL is already bookmarked (UNIQUE constraint), we return the
// existing row as a 200 idempotent response instead of an error — clicking
// "Bookmark" twice on the same passage should feel like a no-op.
func (s *BookmarkService) Create(ctx context.Context, userID, sourceURL, sourceTitle string, excerpt, note *string) (*model.Bookmark, error) {
u, err := s.users.FindByID(ctx, userID)
if err != nil {
return nil, err
}
current, err := s.bookmarks.CountByUser(ctx, userID)
if err != nil {
return nil, err
}
if err := EnforceBookmarkLimit(u.Plan, current); err != nil {
return nil, err
}
b, err := s.bookmarks.Create(ctx, userID, stripHost(sourceURL), sourceTitle, excerpt, note)
if err == nil {
return b, nil
}
if errors.Is(err, repository.ErrBookmarkExists) {
// Idempotent: return whatever is already there.
list, lerr := s.bookmarks.ListByUser(ctx, userID)
if lerr != nil {
return nil, lerr
}
for _, existing := range list {
if existing.SourceURL == stripHost(sourceURL) {
return &existing, nil
}
}
}
return nil, err
}
// List returns the caller's bookmarks.
func (s *BookmarkService) List(ctx context.Context, userID string) ([]model.Bookmark, error) {
return s.bookmarks.ListByUser(ctx, userID)
}
// Delete removes a bookmark belonging to the caller.
func (s *BookmarkService) Delete(ctx context.Context, id, userID string) error {
return s.bookmarks.Delete(ctx, id, userID)
}
// ---------------------------------------------------------------------------
// CollectionService
// ---------------------------------------------------------------------------
type CollectionService struct {
users *repository.UserRepo
collections *repository.CollectionRepo
posts *repository.PostRepo
r2 *repository.R2
}
func NewCollectionService(users *repository.UserRepo, collections *repository.CollectionRepo, posts *repository.PostRepo, r2 *repository.R2) *CollectionService {
return &CollectionService{users: users, collections: collections, posts: posts, r2: r2}
}
// Create enforces per-visibility plan caps and inserts.
func (s *CollectionService) Create(ctx context.Context, userID, title string, description, coverURL *string, isPublic bool) (*model.CollectionRecord, error) {
if title = strings.TrimSpace(title); title == "" {
return nil, errors.New("title is required")
}
if len(title) > 120 {
return nil, errors.New("title too long (max 120)")
}
u, err := s.users.FindByID(ctx, userID)
if err != nil {
return nil, err
}
current, err := s.collections.CountByUserAndVisibility(ctx, userID, isPublic)
if err != nil {
return nil, err
}
if err := EnforceCollectionLimit(u.Plan, isPublic, current); err != nil {
return nil, err
}
return s.collections.Create(ctx, userID, title, description, coverURL, isPublic)
}
// Update — only the owner may edit. Visibility changes that would exceed
// the plan cap are blocked.
func (s *CollectionService) Update(ctx context.Context, id, userID string, title, description, coverURL *string, isPublic *bool) (*model.CollectionRecord, error) {
c, err := s.collections.FindByID(ctx, id)
if err != nil {
return nil, err
}
if c.UserID != userID {
return nil, ErrForbidden
}
// If the caller is flipping visibility, make sure they wouldn't blow the cap.
if isPublic != nil && *isPublic != c.IsPublic {
u, err := s.users.FindByID(ctx, userID)
if err != nil {
return nil, err
}
current, err := s.collections.CountByUserAndVisibility(ctx, userID, *isPublic)
if err != nil {
return nil, err
}
if err := EnforceCollectionLimit(u.Plan, *isPublic, current); err != nil {
return nil, err
}
}
return s.collections.Update(ctx, id, userID, title, description, coverURL, isPublic)
}
// Delete — only the owner may delete.
func (s *CollectionService) Delete(ctx context.Context, id, userID string) error {
c, err := s.collections.FindByID(ctx, id)
if err != nil {
return err
}
if c.UserID != userID {
return ErrForbidden
}
return s.collections.Delete(ctx, id, userID)
}
// ListMine returns the caller's collections (both public and private).
func (s *CollectionService) ListMine(ctx context.Context, userID string) ([]model.CollectionRecord, error) {
return s.collections.ListByUser(ctx, userID)
}
// AddItem inserts a post into a collection. Caller must own the collection.
// We don't require ownership of the *post* — collections can curate other
// users' public posts in a future iteration; for v1 the toolbar always
// creates a fresh post owned by the caller before calling this.
func (s *CollectionService) AddItem(ctx context.Context, collectionID, postID, callerID string) error {
c, err := s.collections.FindByID(ctx, collectionID)
if err != nil {
return err
}
if c.UserID != callerID {
return ErrForbidden
}
if _, err := s.posts.FindByID(ctx, postID); err != nil {
return err
}
return s.collections.AddItem(ctx, collectionID, postID)
}
// SetItemOrder updates positions for the given post ids so the new order
// matches the input array. Owner-only.
func (s *CollectionService) SetItemOrder(ctx context.Context, collectionID, callerID string, postIDs []string) error {
c, err := s.collections.FindByID(ctx, collectionID)
if err != nil {
return err
}
if c.UserID != callerID {
return ErrForbidden
}
return s.collections.SetItemOrder(ctx, collectionID, postIDs)
}
// CollectionsContaining returns the ids of the caller's collections
// that already include the given post. Used by the save-to-collection
// drawer to disable rows the post is already in.
func (s *CollectionService) CollectionsContaining(ctx context.Context, postID, callerID string) ([]string, error) {
return s.collections.CollectionsContaining(ctx, postID, callerID)
}
// RemoveItem detaches a post from a collection (owner only).
func (s *CollectionService) RemoveItem(ctx context.Context, collectionID, postID, callerID string) error {
c, err := s.collections.FindByID(ctx, collectionID)
if err != nil {
return err
}
if c.UserID != callerID {
return ErrForbidden
}
return s.collections.RemoveItem(ctx, collectionID, postID)
}
// Get returns one collection. Visibility is enforced by the caller —
// `viewerID` is the calling user id ("" if anonymous). If the collection is
// private and the viewer isn't the owner, returns ErrForbidden.
func (s *CollectionService) Get(ctx context.Context, id, viewerID string) (*model.CollectionRecord, []model.Post, error) {
c, err := s.collections.FindByID(ctx, id)
if err != nil {
return nil, nil, err
}
if !c.IsPublic && c.UserID != viewerID {
return nil, nil, ErrForbidden
}
posts, err := s.collections.PostsIn(ctx, id)
if err != nil {
return nil, nil, err
}
// PostsIn returns posts without images (single-table boundary). Hydrate
// them in one batched query so callers — and the post cards rendered
// inside collection carousels — can show the post's first image as
// background instead of falling back to the empty quote panel.
if len(posts) > 0 {
ids := make([]string, len(posts))
for i := range posts {
ids[i] = posts[i].ID
}
imgs, err := s.posts.ImagesFor(ctx, ids)
if err != nil {
return nil, nil, err
}
for i := range posts {
posts[i].Images = imgs[posts[i].ID]
}
// Same batched approach for reshare states so the action footer
// on each card swaps Reshare↔Send correctly. Anonymous viewers
// (viewerID == "") get all-false at zero cost.
if viewerID != "" {
states, err := s.posts.ReshareStatesFor(ctx, viewerID, ids)
if err != nil {
return nil, nil, err
}
for i := range posts {
if states[posts[i].ID] {
posts[i].ViewerHasReshared = true
}
}
}
}
return c, posts, nil
}
// CoverPresign returns a one-shot R2 PUT URL for a collection cover image.
func (s *CollectionService) CoverPresign(ctx context.Context, userID, contentType string) (uploadURL, publicURL string, err error) {
return presignImage(ctx, s.r2, "collection-covers", userID, contentType)
}
// ---------------------------------------------------------------------------
// shared helpers
// ---------------------------------------------------------------------------
// presignImage builds an R2 presign for a user-scoped media key under the
// given prefix. The random suffix invalidates browser caches when the image
// is replaced.
func presignImage(ctx context.Context, r2 *repository.R2, prefix, userID, contentType string) (uploadURL, publicURL string, err error) {
if r2 == nil {
return "", "", errors.New("media uploads are not configured")
}
ext, ok := imageExtensionFor(contentType)
if !ok {
return "", "", fmt.Errorf("unsupported content type: %s", contentType)
}
suffix := make([]byte, 8)
if _, err := rand.Read(suffix); err != nil {
return "", "", err
}
key := path.Join(prefix, userID, hex.EncodeToString(suffix)+ext)
return r2.PresignPut(ctx, key, contentType, 5*time.Minute)
}
func imageExtensionFor(ct string) (string, bool) {
switch ct {
case "image/png":
return ".png", true
case "image/jpeg":
return ".jpg", true
case "image/webp":
return ".webp", true
case "image/gif":
return ".gif", true
default:
return "", false
}
}
// stripHost reduces "https://prabhupadasays.org/books/bg/1/1?hl=x" to
// "/books/bg/1/1?hl=x" so we never store arbitrary external URLs.
// Anything that doesn't parse cleanly is returned unchanged (and rejected
// later by the prefix-check in the service).
func stripHost(u string) string {
u = strings.TrimSpace(u)
if u == "" {
return u
}
if strings.HasPrefix(u, "/") {
return u
}
for _, prefix := range []string{"http://", "https://"} {
if strings.HasPrefix(u, prefix) {
rest := u[len(prefix):]
if i := strings.IndexByte(rest, '/'); i >= 0 {
return rest[i:]
}
return "/"
}
}
return u
}