From da2bbc323df846a61f521d93a524b86e3c50c037 Mon Sep 17 00:00:00 2001 From: Krispire Admin Date: Tue, 9 Jun 2026 13:20:14 +0530 Subject: [PATCH] feat: add folloers/following list --- internal/handler/social.go | 37 +++++++++++++++ internal/model/user.go | 8 ++++ internal/repository/social_graph_repo.go | 60 ++++++++++++++++++++++++ internal/server/server.go | 2 + internal/service/social_graph_service.go | 19 ++++++++ 5 files changed, 126 insertions(+) diff --git a/internal/handler/social.go b/internal/handler/social.go index cd482f6..c1c2e6b 100644 --- a/internal/handler/social.go +++ b/internal/handler/social.go @@ -15,6 +15,7 @@ import ( "github.com/go-chi/chi/v5" "github.com/prabhupadasays/spsays-bff/internal/middleware" + "github.com/prabhupadasays/spsays-bff/internal/model" "github.com/prabhupadasays/spsays-bff/internal/repository" "github.com/prabhupadasays/spsays-bff/internal/service" "github.com/prabhupadasays/spsays-bff/pkg/auth" @@ -529,6 +530,42 @@ func (h *ProfileHandler) Feed(w http.ResponseWriter, r *http.Request) { response.JSONWithMeta(w, http.StatusOK, posts, paginationMeta(total, limit, offset)) } +// followListResp is the payload for the followers / following list endpoints: +// the page of users plus the total count (so the client can paginate). Total +// rides in `data` rather than meta because the frontend's fetch wrapper only +// surfaces `data`. +type followListResp struct { + Users []model.UserSummary `json:"users"` + Total int `json:"total"` +} + +// Followers — GET /u/{handle}/followers?limit=&offset=&q=. Paginated, optional +// name/handle search. Public (OptionalAuth). +func (h *ProfileHandler) Followers(w http.ResponseWriter, r *http.Request) { + handle := chi.URLParam(r, "handle") + limit, offset := parseLimitOffset(r) + q := strings.TrimSpace(r.URL.Query().Get("q")) + users, total, err := h.graph.Followers(r.Context(), handle, q, limit, offset) + if err != nil { + mapSocialErr(w, err) + return + } + response.JSON(w, http.StatusOK, followListResp{Users: users, Total: total}) +} + +// Following — GET /u/{handle}/following?limit=&offset=&q=. +func (h *ProfileHandler) Following(w http.ResponseWriter, r *http.Request) { + handle := chi.URLParam(r, "handle") + limit, offset := parseLimitOffset(r) + q := strings.TrimSpace(r.URL.Query().Get("q")) + users, total, err := h.graph.Following(r.Context(), handle, q, limit, offset) + if err != nil { + mapSocialErr(w, err) + return + } + response.JSON(w, http.StatusOK, followListResp{Users: users, Total: total}) +} + // --------------------------------------------------------------------------- // shared helpers // --------------------------------------------------------------------------- diff --git a/internal/model/user.go b/internal/model/user.go index 6c0f8f0..fb6a162 100644 --- a/internal/model/user.go +++ b/internal/model/user.go @@ -12,6 +12,14 @@ import ( // pointer because OAuth-only users have no password — *PasswordHash == nil // means "this account can only sign in via a linked OAuth provider". // +// UserSummary is the lightweight projection used in follower / following +// lists — just enough to render a row and link to the profile. +type UserSummary struct { + Handle string `json:"handle"` + Name string `json:"name"` + AvatarURL *string `json:"avatar_url"` +} + // EmailVerifiedAt is a pointer so JSON-encoded users carry an explicit `null` // when the user has not yet verified, instead of an omitted field — the // frontend uses this to decide whether to show the "verify your email" ribbon. diff --git a/internal/repository/social_graph_repo.go b/internal/repository/social_graph_repo.go index 58a07a2..0c95e15 100644 --- a/internal/repository/social_graph_repo.go +++ b/internal/repository/social_graph_repo.go @@ -93,6 +93,66 @@ func (r *FollowRepo) Status(ctx context.Context, followerID, followingID string) } // CountFollowers counts accepted edges pointing at userID. +// ListFollowers returns the accepted followers of userID (users who follow +// them), newest first, optionally filtered by a name/handle search `q`. +// Returns the page plus the total matching count (for pagination). +func (r *FollowRepo) ListFollowers(ctx context.Context, userID, q string, limit, offset int) ([]model.UserSummary, int, error) { + return r.listRelations(ctx, "f.following_id = $1", "f.follower_id", userID, q, limit, offset) +} + +// ListFollowing returns the users userID follows (accepted), newest first, +// optionally filtered by `q`. Returns the page plus the total count. +func (r *FollowRepo) ListFollowing(ctx context.Context, userID, q string, limit, offset int) ([]model.UserSummary, int, error) { + return r.listRelations(ctx, "f.follower_id = $1", "f.following_id", userID, q, limit, offset) +} + +// listRelations powers both directions: `whereOwn` pins the row to userID on +// one side, `joinCol` is the follows column holding the *other* user (the one +// we join to users and return). `q` filters by name/handle (ILIKE); empty `q` +// disables the filter. The column expressions are fixed internal constants — +// never user input — so this stays injection-safe. +func (r *FollowRepo) listRelations( + ctx context.Context, whereOwn, joinCol, userID, q string, limit, offset int, +) ([]model.UserSummary, int, error) { + like := "%" + q + "%" + + var total int + countSQL := ` + SELECT count(*) + FROM follows f JOIN users u ON u.id = ` + joinCol + ` + WHERE ` + whereOwn + ` AND f.status = 'accepted' + AND ($2 = '' OR u.name ILIKE $3 OR u.handle ILIKE $3)` + if err := r.pg.Pool.QueryRow(ctx, countSQL, userID, q, like).Scan(&total); err != nil { + return nil, 0, fmt.Errorf("count relations: %w", err) + } + if total == 0 { + return []model.UserSummary{}, 0, nil + } + + listSQL := ` + SELECT u.handle, u.name, u.avatar_url + FROM follows f JOIN users u ON u.id = ` + joinCol + ` + WHERE ` + whereOwn + ` AND f.status = 'accepted' + AND ($2 = '' OR u.name ILIKE $3 OR u.handle ILIKE $3) + ORDER BY f.created_at DESC, u.handle ASC + LIMIT $4 OFFSET $5` + rows, err := r.pg.Pool.Query(ctx, listSQL, userID, q, like, limit, offset) + if err != nil { + return nil, 0, fmt.Errorf("list relations: %w", err) + } + defer rows.Close() + + out := make([]model.UserSummary, 0, limit) + for rows.Next() { + var s model.UserSummary + if err := rows.Scan(&s.Handle, &s.Name, &s.AvatarURL); err != nil { + return nil, 0, fmt.Errorf("scan relation: %w", err) + } + out = append(out, s) + } + return out, total, rows.Err() +} + func (r *FollowRepo) CountFollowers(ctx context.Context, userID string) (int, error) { var n int err := r.pg.Pool.QueryRow(ctx, ` diff --git a/internal/server/server.go b/internal/server/server.go index a28d969..c6c96ef 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -709,6 +709,8 @@ func (s *Server) setupRouter() { r.Use(middleware.OptionalAuth(s.authIssuer, s.userRepo)) r.Get("/{handle}", s.profileH.Get) r.Get("/{handle}/feed", s.profileH.Feed) + r.Get("/{handle}/followers", s.profileH.Followers) + r.Get("/{handle}/following", s.profileH.Following) }) // Relationship actions — follow/block/report. All require a diff --git a/internal/service/social_graph_service.go b/internal/service/social_graph_service.go index 04439c3..1152720 100644 --- a/internal/service/social_graph_service.go +++ b/internal/service/social_graph_service.go @@ -310,6 +310,25 @@ func (s *SocialGraphService) Counts(ctx context.Context, userID string) (followe return followers, following, nil } +// Followers / Following resolve the handle and return one page of the +// profile's accepted followers (or followings), optionally filtered by a +// name/handle search `q`, plus the total matching count for pagination. +func (s *SocialGraphService) Followers(ctx context.Context, handle, q string, limit, offset int) ([]model.UserSummary, int, error) { + u, err := s.users.FindByHandle(ctx, handle) + if err != nil { + return nil, 0, err + } + return s.follows.ListFollowers(ctx, u.ID, q, limit, offset) +} + +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 { + return nil, 0, err + } + return s.follows.ListFollowing(ctx, u.ID, q, limit, offset) +} + // ProfileExtras is the read used by the public-profile endpoint: the // target's follower/following counts plus, when a non-owner viewer is // signed in, their relationship to the target. rel is nil for anonymous