From fe0bee4ce76e60e741ab0ccb3d942dbef3b8f4d2 Mon Sep 17 00:00:00 2001 From: Krispire Admin Date: Sun, 21 Jun 2026 21:20:30 +0530 Subject: [PATCH] fix: api rate limit --- internal/handler/social.go | 44 ++++++++++++++++++++++++++++++ internal/repository/post_repo.go | 30 +++++++++++++++++++- internal/service/social_service.go | 27 ++++++++++++++++++ 3 files changed, 100 insertions(+), 1 deletion(-) diff --git a/internal/handler/social.go b/internal/handler/social.go index 34be5b7..b37a8c6 100644 --- a/internal/handler/social.go +++ b/internal/handler/social.go @@ -12,6 +12,7 @@ import ( "net/http" "strconv" "strings" + "time" "github.com/go-chi/chi/v5" "github.com/prabhupadasays/spsays-bff/internal/middleware" @@ -258,6 +259,17 @@ func (h *PostHandler) Unhide(w http.ResponseWriter, r *http.Request) { func (h *PostHandler) ListMine(w http.ResponseWriter, r *http.Request) { uid := middleware.UserID(r.Context()) limit, offset := parseLimitOffset(r) + // Keyset path (scroll feed): a `before` cursor pages older posts. Without a + // cursor we keep the offset+meta path the profile grid relies on. + if beforeAt, beforeID, ok := parseCursor(r); ok { + posts, err := h.svc.ListMineKeyset(r.Context(), uid, beforeAt, beforeID, limit) + if err != nil { + mapSocialErr(w, err) + return + } + response.JSON(w, http.StatusOK, posts) + return + } posts, total, err := h.svc.ListMine(r.Context(), uid, limit, offset) if err != nil { mapSocialErr(w, err) @@ -719,6 +731,22 @@ func (h *ProfileHandler) Feed(w http.ResponseWriter, r *http.Request) { // gateByBlock already resolved the owner, so list by id rather than // re-resolving the handle. Hidden posts never appear here (they live in // the owner's separate Hidden tab). + // + // Keyset path (scroll feed): a `before` cursor pages older posts. Without a + // cursor we keep the offset+meta path the profile grid relies on. + if beforeAt, beforeID, ok := parseCursor(r); ok { + posts, err := h.posts.ListByUserKeyset(r.Context(), owner.ID, beforeAt, beforeID, limit) + if err != nil { + mapSocialErr(w, err) + return + } + if err := h.hydrateFeed(r, posts); err != nil { + mapSocialErr(w, err) + return + } + response.JSON(w, http.StatusOK, posts) + return + } posts, total, err := h.posts.ListByUser(r.Context(), owner.ID, limit, offset) if err != nil { mapSocialErr(w, err) @@ -896,6 +924,22 @@ func parseLimitOffset(r *http.Request) (limit, offset int) { return limit, offset } +// parseCursor reads an optional keyset cursor for the scroll feed: `before` is +// the last seen post's created_at (RFC3339Nano) and `before_id` its id. ok is +// false when either is missing or unparseable (callers then page by offset). +func parseCursor(r *http.Request) (beforeAt time.Time, beforeID string, ok bool) { + at := strings.TrimSpace(r.URL.Query().Get("before")) + id := strings.TrimSpace(r.URL.Query().Get("before_id")) + if at == "" || id == "" { + return time.Time{}, "", false + } + t, err := time.Parse(time.RFC3339Nano, at) + if err != nil { + return time.Time{}, "", false + } + return t, id, true +} + func paginationMeta(total, limit, offset int) *response.Meta { if limit == 0 { limit = 20 diff --git a/internal/repository/post_repo.go b/internal/repository/post_repo.go index ee3485b..30af635 100644 --- a/internal/repository/post_repo.go +++ b/internal/repository/post_repo.go @@ -213,13 +213,41 @@ func (r *PostRepo) ListByUser(ctx context.Context, userID string, limit, offset if err != nil { return nil, 0, err } + // id DESC tiebreak must match ListByUserKeyset so the scroll feed's first + // (offset) page and its keyset continuation agree on the order of + // same-created_at posts — otherwise the keyset page re-returns already-shown + // rows, the client's cursor never advances, and it loops forever. q := `SELECT ` + postSelectColumns + postSelectFrom + ` WHERE p.user_id = $1 AND p.is_hidden = FALSE AND p.deleted_at IS NULL - ORDER BY p.created_at DESC, p.id + ORDER BY p.created_at DESC, p.id DESC LIMIT $2 OFFSET $3` return r.collectPosts(ctx, total, q, userID, limit, offset) } +// ListByUserKeyset returns the user's visible posts older than the cursor +// (created_at, id), newest-first — keyset pagination for the Instagram-style +// scroll feed. A zero beforeAt returns the newest page. No total is computed +// (infinite scroll infers "more" from a full page, like FeedForViewer). The +// (created_at, id) DESC ordering matches the tuple comparison so paging never +// skips or repeats a row even when timestamps tie. +func (r *PostRepo) ListByUserKeyset(ctx context.Context, userID string, beforeAt time.Time, beforeID string, limit int) ([]model.Post, error) { + base := `SELECT ` + postSelectColumns + postSelectFrom + ` + WHERE p.user_id = $1 AND p.is_hidden = FALSE AND p.deleted_at IS NULL` + if beforeAt.IsZero() { + q := base + ` + ORDER BY p.created_at DESC, p.id DESC + LIMIT $2` + posts, _, err := r.collectPosts(ctx, 0, q, userID, limit) + return posts, err + } + q := base + ` + AND (p.created_at < $2 OR (p.created_at = $2 AND p.id < $3)) + ORDER BY p.created_at DESC, p.id DESC + LIMIT $4` + posts, _, err := r.collectPosts(ctx, 0, q, userID, beforeAt, beforeID, limit) + return posts, err +} + // ListHiddenByUser returns the owner's hidden posts, newest first — the // /u/me "Hidden" tab. Owner-only (the caller enforces auth + scoping). func (r *PostRepo) ListHiddenByUser(ctx context.Context, userID string, limit, offset int) ([]model.Post, int, error) { diff --git a/internal/service/social_service.go b/internal/service/social_service.go index 85c187c..cc63230 100644 --- a/internal/service/social_service.go +++ b/internal/service/social_service.go @@ -444,6 +444,33 @@ func (s *PostService) ListMine(ctx context.Context, userID string, limit, offset return posts, total, nil } +// ListMineKeyset is the owner's scroll feed via keyset pagination (posts older +// than the cursor, newest-first). Same hydration as ListMine; no total. +func (s *PostService) ListMineKeyset(ctx context.Context, userID string, beforeAt time.Time, beforeID string, limit int) ([]model.Post, error) { + if limit <= 0 || limit > 50 { + limit = 20 + } + posts, err := s.posts.ListByUserKeyset(ctx, userID, beforeAt, beforeID, limit) + if err != nil { + return nil, 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, err + } + if err := s.hydrateMentionsSlice(ctx, posts); err != nil { + return nil, err + } + if err := s.hydratePinnedSlice(ctx, userID, posts); err != nil { + return nil, err + } + return posts, nil +} + // ListHidden is the owner's "Hidden" tab — their hidden posts only, hydrated // like ListMine so the cards render fully and the menu offers Unhide. func (s *PostService) ListHidden(ctx context.Context, userID string, limit, offset int) ([]model.Post, int, error) {