feat: add folloers/following list
This commit is contained in:
@@ -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
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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, `
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user