feat: add user centric features

This commit is contained in:
Krispire Admin
2026-05-30 23:43:27 +05:30
parent 97422c1ef5
commit aecdd1030a
21 changed files with 2037 additions and 69 deletions
+391
View File
@@ -0,0 +1,391 @@
// 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).
func (s *PostService) Get(ctx context.Context, id string) (*model.Post, error) {
return s.posts.FindByID(ctx, id)
}
// 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. The caller must own the
// post. Returns the fresh post payload so the client can reconcile its
// local state without a separate fetch.
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.
func (s *PostService) ListByHandle(ctx context.Context, handle string, limit, offset int) ([]model.Post, int, error) {
if limit <= 0 || limit > 50 {
limit = 20
}
if offset < 0 {
offset = 0
}
return s.posts.ListByHandle(ctx, handle, limit, offset)
}
// ListMine is the owner's view of their own posts (drafts feature isn't a
// thing yet, so this is equivalent to ListByHandle minus the handle lookup).
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
}
return s.posts.ListByUser(ctx, userID, limit, offset)
}
// 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
}
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
}