feat: add feed
This commit is contained in:
@@ -245,6 +245,36 @@ func (h *PostHandler) ListMine(w http.ResponseWriter, r *http.Request) {
|
|||||||
response.JSONWithMeta(w, http.StatusOK, posts, paginationMeta(total, limit, offset))
|
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 {
|
type replaceImagesReq struct {
|
||||||
ImageURLs []string `json:"image_urls"`
|
ImageURLs []string `json:"image_urls"`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -59,6 +59,13 @@ type Post struct {
|
|||||||
// CommentsCount is the total number of comments + replies on the post
|
// CommentsCount is the total number of comments + replies on the post
|
||||||
// (drives the footer's comment count). Hydrated by the comment layer.
|
// (drives the footer's comment count). Hydrated by the comment layer.
|
||||||
CommentsCount int `json:"comments_count"`
|
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:
|
// Comment is one comment (or reply) on a post. Threading is one level deep:
|
||||||
|
|||||||
@@ -213,6 +213,103 @@ func (r *PostRepo) ListByUser(ctx context.Context, userID string, limit, offset
|
|||||||
return r.collectPosts(ctx, total, q, userID, 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:
|
// Delete removes a post only if it belongs to the caller. Cascades:
|
||||||
// - the post's images (post_images.post_id FK)
|
// - the post's images (post_images.post_id FK)
|
||||||
// - any reshares of this post made by other users (posts.reshared_from_post_id FK)
|
// - any reshares of this post made by other users (posts.reshared_from_post_id FK)
|
||||||
|
|||||||
@@ -379,6 +379,47 @@ func (r *UserRepo) Search(ctx context.Context, q string, limit, offset int) ([]m
|
|||||||
return out, total, rows.Err()
|
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
|
// GetReadingPrefs returns the raw JSONB bytes for the user's reading
|
||||||
// prefs (empty object when never set). The frontend owns the shape, so
|
// prefs (empty object when never set). The frontend owns the shape, so
|
||||||
// we round-trip the payload verbatim — no server-side schema enforced.
|
// we round-trip the payload verbatim — no server-side schema enforced.
|
||||||
|
|||||||
@@ -765,6 +765,21 @@ func (s *Server) setupRouter() {
|
|||||||
// present, so the feed can carry `viewer_has_reshared` on each
|
// present, so the feed can carry `viewer_has_reshared` on each
|
||||||
// post. Without a cookie the routes still respond — they just
|
// post. Without a cookie the routes still respond — they just
|
||||||
// return the anonymous shape (all-false).
|
// 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) {
|
r.Route("/u", func(r chi.Router) {
|
||||||
if s.profileH == nil {
|
if s.profileH == nil {
|
||||||
r.HandleFunc("/*", s.authUnavailable)
|
r.HandleFunc("/*", s.authUnavailable)
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"html/template"
|
"html/template"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/prabhupadasays/spsays-bff/internal/model"
|
"github.com/prabhupadasays/spsays-bff/internal/model"
|
||||||
"github.com/prabhupadasays/spsays-bff/internal/repository"
|
"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)
|
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) {
|
func (s *SocialGraphService) Following(ctx context.Context, handle, q string, limit, offset int) ([]model.UserSummary, int, error) {
|
||||||
u, err := s.users.FindByHandle(ctx, handle)
|
u, err := s.users.FindByHandle(ctx, handle)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -212,6 +212,40 @@ func (s *PostService) ListByHandle(ctx context.Context, handle, viewerID string,
|
|||||||
return posts, total, nil
|
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.
|
// hydratePinnedSlice fills PinnedComment on a value slice.
|
||||||
func (s *PostService) hydratePinnedSlice(ctx context.Context, viewerID string, posts []model.Post) error {
|
func (s *PostService) hydratePinnedSlice(ctx context.Context, viewerID string, posts []model.Post) error {
|
||||||
if len(posts) == 0 {
|
if len(posts) == 0 {
|
||||||
|
|||||||
Reference in New Issue
Block a user