From 8a2bdf945a7a3a32b393bb85d7370e94d2d8d67f Mon Sep 17 00:00:00 2001 From: Krispire Admin Date: Sat, 13 Jun 2026 23:55:02 +0530 Subject: [PATCH] feat: add feed --- internal/handler/social.go | 30 ++++++++ internal/model/social.go | 7 ++ internal/repository/post_repo.go | 97 ++++++++++++++++++++++++ internal/repository/user_repo.go | 41 ++++++++++ internal/server/server.go | 15 ++++ internal/service/social_graph_service.go | 8 ++ internal/service/social_service.go | 34 +++++++++ 7 files changed, 232 insertions(+) diff --git a/internal/handler/social.go b/internal/handler/social.go index 0e7e6b6..36138af 100644 --- a/internal/handler/social.go +++ b/internal/handler/social.go @@ -245,6 +245,36 @@ func (h *PostHandler) ListMine(w http.ResponseWriter, r *http.Request) { response.JSONWithMeta(w, http.StatusOK, posts, paginationMeta(total, limit, offset)) } +// Feed — GET /feed?limit=&offset=. The global personalised home feed (auth +// required). Returns at most `limit` posts; the client infers "more" from a +// full page (no total count for an unbounded global feed). +func (h *PostHandler) Feed(w http.ResponseWriter, r *http.Request) { + uid := middleware.UserID(r.Context()) + limit, offset := parseLimitOffset(r) + posts, err := h.svc.Feed(r.Context(), uid, limit, offset) + if err != nil { + mapSocialErr(w, err) + return + } + response.JSON(w, http.StatusOK, posts) +} + +// FeedNewUsers — GET /feed/new-users. Up to 5 accounts that joined in the last +// 24h, for the feed's "new on the platform" discovery strip. Returns [] when +// there are none (the client hides the section). +func (h *PostHandler) FeedNewUsers(w http.ResponseWriter, r *http.Request) { + uid := middleware.UserID(r.Context()) + users, err := h.graph.NewUsers(r.Context(), uid, 5) + if err != nil { + mapSocialErr(w, err) + return + } + if users == nil { + users = []model.UserSummary{} + } + response.JSON(w, http.StatusOK, users) +} + type replaceImagesReq struct { ImageURLs []string `json:"image_urls"` } diff --git a/internal/model/social.go b/internal/model/social.go index 92ab12d..3af5dd1 100644 --- a/internal/model/social.go +++ b/internal/model/social.go @@ -59,6 +59,13 @@ type Post struct { // CommentsCount is the total number of comments + replies on the post // (drives the footer's comment count). Hydrated by the comment layer. CommentsCount int `json:"comments_count"` + + // Viewer↔author relationship, hydrated for the home feed so the card can + // render a Follow / Follow Back affordance. ViewerFollowsAuthor: the + // caller follows (accepted) this post's author. AuthorFollowsViewer: the + // author follows the caller. Both false for anonymous viewers / own posts. + ViewerFollowsAuthor bool `json:"viewer_follows_author"` + AuthorFollowsViewer bool `json:"author_follows_viewer"` } // Comment is one comment (or reply) on a post. Threading is one level deep: diff --git a/internal/repository/post_repo.go b/internal/repository/post_repo.go index 33dae9a..7139914 100644 --- a/internal/repository/post_repo.go +++ b/internal/repository/post_repo.go @@ -213,6 +213,103 @@ func (r *PostRepo) ListByUser(ctx context.Context, userID string, limit, offset return r.collectPosts(ctx, total, q, userID, limit, offset) } +// FeedForViewer returns one page of the global home feed for `viewerID`, +// newest-first within a two-tier priority: +// +// tier 0 — posts by people the viewer follows that they haven't liked yet +// tier 1 — everything else (followed-and-liked, followers', strangers') +// +// The viewer's own posts and anything involving a block (either direction) +// are excluded. The tier is computed in ORDER BY via correlated EXISTS, so no +// extra columns leak into scanPost. No total is computed (infinite scroll +// infers "more" from a full page); offset paging keeps it simple. At larger +// scale this would move to a precomputed fan-out / keyset cursor. +func (r *PostRepo) FeedForViewer(ctx context.Context, viewerID string, limit, offset int) ([]model.Post, error) { + q := `SELECT ` + postSelectColumns + postSelectFrom + ` + WHERE p.user_id <> $1 + AND NOT EXISTS ( + SELECT 1 FROM user_blocks b + WHERE (b.blocker_id = $1 AND b.blocked_id = p.user_id) + OR (b.blocker_id = p.user_id AND b.blocked_id = $1) + ) + ORDER BY + (CASE + WHEN EXISTS ( + SELECT 1 FROM follows f + WHERE f.follower_id = $1 AND f.following_id = p.user_id + AND f.status = 'accepted' + ) + AND NOT EXISTS ( + SELECT 1 FROM post_likes pl + WHERE pl.post_id = p.id AND pl.user_id = $1 + ) + THEN 0 ELSE 1 + END) ASC, + p.created_at DESC, p.id DESC + LIMIT $2 OFFSET $3` + posts, _, err := r.collectPosts(ctx, 0, q, viewerID, limit, offset) + return posts, err +} + +// HydrateViewerRelationships fills ViewerFollowsAuthor / AuthorFollowsViewer +// across a page of feed posts in two batched queries (one per direction). +// No-op for anonymous viewers. +func (r *PostRepo) HydrateViewerRelationships(ctx context.Context, viewerID string, posts []model.Post) error { + if viewerID == "" || len(posts) == 0 { + return nil + } + seen := map[string]bool{} + authorIDs := make([]string, 0, len(posts)) + for i := range posts { + id := posts[i].UserID + if id != "" && id != viewerID && !seen[id] { + seen[id] = true + authorIDs = append(authorIDs, id) + } + } + if len(authorIDs) == 0 { + return nil + } + + following, err := r.followEdges(ctx, + `SELECT following_id FROM follows + WHERE follower_id = $1 AND status = 'accepted' AND following_id = ANY($2)`, + viewerID, authorIDs) + if err != nil { + return err + } + followers, err := r.followEdges(ctx, + `SELECT follower_id FROM follows + WHERE following_id = $1 AND status = 'accepted' AND follower_id = ANY($2)`, + viewerID, authorIDs) + if err != nil { + return err + } + for i := range posts { + posts[i].ViewerFollowsAuthor = following[posts[i].UserID] + posts[i].AuthorFollowsViewer = followers[posts[i].UserID] + } + return nil +} + +// followEdges runs a one-column follow query and collects the ids into a set. +func (r *PostRepo) followEdges(ctx context.Context, query, viewerID string, ids []string) (map[string]bool, error) { + rows, err := r.pg.Pool.Query(ctx, query, viewerID, ids) + if err != nil { + return nil, fmt.Errorf("hydrate follow edges: %w", err) + } + defer rows.Close() + out := map[string]bool{} + for rows.Next() { + var id string + if err := rows.Scan(&id); err != nil { + return nil, fmt.Errorf("scan follow edge: %w", err) + } + out[id] = true + } + return out, rows.Err() +} + // Delete removes a post only if it belongs to the caller. Cascades: // - the post's images (post_images.post_id FK) // - any reshares of this post made by other users (posts.reshared_from_post_id FK) diff --git a/internal/repository/user_repo.go b/internal/repository/user_repo.go index 4f3bde5..21bf9ac 100644 --- a/internal/repository/user_repo.go +++ b/internal/repository/user_repo.go @@ -379,6 +379,47 @@ func (r *UserRepo) Search(ctx context.Context, q string, limit, offset int) ([]m return out, total, rows.Err() } +// RecentlyJoined returns up to `limit` users who registered on/after `since`, +// newest first, as a discovery list for the feed's "new on the platform" +// strip. Excludes the viewer, anyone the viewer already has a follow edge with +// (pending or accepted), and anyone involved in a block either direction — so +// every suggestion is a fresh account the viewer can actually follow. +func (r *UserRepo) RecentlyJoined(ctx context.Context, viewerID string, since time.Time, limit int) ([]model.UserSummary, error) { + if limit <= 0 || limit > 20 { + limit = 5 + } + const q = ` + SELECT u.id, u.handle, u.name, u.avatar_url + FROM users u + WHERE u.created_at >= $2 + AND u.id <> $1 + AND NOT EXISTS ( + SELECT 1 FROM follows f + WHERE f.follower_id = $1 AND f.following_id = u.id + ) + AND NOT EXISTS ( + SELECT 1 FROM user_blocks b + WHERE (b.blocker_id = $1 AND b.blocked_id = u.id) + OR (b.blocker_id = u.id AND b.blocked_id = $1) + ) + ORDER BY u.created_at DESC + LIMIT $3` + rows, err := r.pg.Pool.Query(ctx, q, viewerID, since, limit) + if err != nil { + return nil, fmt.Errorf("recently joined: %w", err) + } + defer rows.Close() + out := []model.UserSummary{} + for rows.Next() { + var u model.UserSummary + if err := rows.Scan(&u.ID, &u.Handle, &u.Name, &u.AvatarURL); err != nil { + return nil, fmt.Errorf("scan recently joined: %w", err) + } + out = append(out, u) + } + return out, rows.Err() +} + // GetReadingPrefs returns the raw JSONB bytes for the user's reading // prefs (empty object when never set). The frontend owns the shape, so // we round-trip the payload verbatim — no server-side schema enforced. diff --git a/internal/server/server.go b/internal/server/server.go index f41bc07..84007af 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -765,6 +765,21 @@ func (s *Server) setupRouter() { // present, so the feed can carry `viewer_has_reshared` on each // post. Without a cookie the routes still respond — they just // return the anonymous shape (all-false). + // Home feed — the global, personalised feed of posts + its "new on + // the platform" discovery strip. Auth-required (the feed is + // per-viewer: ordering + relationship flags depend on the caller). + r.Route("/feed", func(r chi.Router) { + if s.postH == nil { + r.HandleFunc("/*", s.authUnavailable) + return + } + r.Group(func(r chi.Router) { + r.Use(middleware.RequireAuth(s.authIssuer, s.userRepo)) + r.Get("/", s.postH.Feed) + r.Get("/new-users", s.postH.FeedNewUsers) + }) + }) + r.Route("/u", func(r chi.Router) { if s.profileH == nil { r.HandleFunc("/*", s.authUnavailable) diff --git a/internal/service/social_graph_service.go b/internal/service/social_graph_service.go index c0505ee..f2b9713 100644 --- a/internal/service/social_graph_service.go +++ b/internal/service/social_graph_service.go @@ -19,6 +19,7 @@ import ( "fmt" "html/template" "strings" + "time" "github.com/prabhupadasays/spsays-bff/internal/model" "github.com/prabhupadasays/spsays-bff/internal/repository" @@ -338,6 +339,13 @@ func (s *SocialGraphService) Followers(ctx context.Context, handle, q string, li return s.follows.ListFollowers(ctx, u.ID, q, limit, offset) } +// NewUsers returns up to `limit` accounts that joined in the last 24h for the +// feed's "new on the platform" discovery strip — excluding the viewer, anyone +// they already have a follow edge with, and blocked pairs. +func (s *SocialGraphService) NewUsers(ctx context.Context, viewerID string, limit int) ([]model.UserSummary, error) { + return s.users.RecentlyJoined(ctx, viewerID, time.Now().Add(-24*time.Hour), limit) +} + func (s *SocialGraphService) Following(ctx context.Context, handle, q string, limit, offset int) ([]model.UserSummary, int, error) { u, err := s.users.FindByHandle(ctx, handle) if err != nil { diff --git a/internal/service/social_service.go b/internal/service/social_service.go index 7cc8db3..0694174 100644 --- a/internal/service/social_service.go +++ b/internal/service/social_service.go @@ -212,6 +212,40 @@ func (s *PostService) ListByHandle(ctx context.Context, handle, viewerID string, return posts, total, nil } +// Feed is the global, personalised home feed: posts from across the platform, +// prioritising not-yet-liked posts from people the viewer follows. Fully +// hydrated (reshare/like/mention/comment state) plus the viewer↔author +// relationship so the card can show Follow / Follow Back. Returns at most +// `limit` posts; the caller infers "more" from a full page. +func (s *PostService) Feed(ctx context.Context, viewerID string, limit, offset int) ([]model.Post, error) { + if limit <= 0 || limit > 50 { + limit = 10 + } + if offset < 0 { + offset = 0 + } + posts, err := s.posts.FeedForViewer(ctx, viewerID, limit, offset) + if err != nil { + return nil, err + } + if err := s.hydrateReshareStatesSlice(ctx, viewerID, posts); err != nil { + return nil, err + } + if err := s.hydrateLikesSlice(ctx, viewerID, posts); err != nil { + return nil, err + } + if err := s.hydrateMentionsSlice(ctx, posts); err != nil { + return nil, err + } + if err := s.hydratePinnedSlice(ctx, viewerID, posts); err != nil { + return nil, err + } + if err := s.posts.HydrateViewerRelationships(ctx, viewerID, posts); err != nil { + return nil, err + } + return posts, 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 {