// 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 } func NewPostService(posts *repository.PostRepo, r2 *repository.R2) *PostService { return &PostService{posts: posts, r2: r2} } // 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 []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") } return s.posts.Create(ctx, userID, caption, sourceURL, sourceTitle, imageURLs) } // 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 } 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 } return posts, total, nil } // 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 } } return posts, total, nil } // ImagePresign returns a one-shot R2 PUT URL for a post image. // The key is namespaced under post-images//.. 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 }