From 5c0581a1da79b865b2278b35f97d6fe87fb57d41 Mon Sep 17 00:00:00 2001 From: Krispire Admin Date: Thu, 18 Jun 2026 22:45:16 +0530 Subject: [PATCH] feat: friend request notifications --- internal/handler/social.go | 15 +++++++++++++ internal/repository/social_graph_repo.go | 27 +++++++++++++++--------- internal/server/server.go | 7 ++++++ internal/service/social_graph_service.go | 7 ++++++ 4 files changed, 46 insertions(+), 10 deletions(-) diff --git a/internal/handler/social.go b/internal/handler/social.go index c88ae83..34be5b7 100644 --- a/internal/handler/social.go +++ b/internal/handler/social.go @@ -835,6 +835,21 @@ func (h *ProfileHandler) Following(w http.ResponseWriter, r *http.Request) { response.JSON(w, http.StatusOK, followListResp{Users: users, Total: total}) } +// IncomingFollowRequests — GET /follow-requests?limit=&offset=&q=. The caller's +// pending incoming follow requests (auth required), for the notifications +// "Friend Requests" tab where they're accepted/rejected. +func (h *ProfileHandler) IncomingFollowRequests(w http.ResponseWriter, r *http.Request) { + uid := middleware.UserID(r.Context()) + limit, offset := parseLimitOffset(r) + q := strings.TrimSpace(r.URL.Query().Get("q")) + users, total, err := h.graph.IncomingRequests(r.Context(), uid, 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/repository/social_graph_repo.go b/internal/repository/social_graph_repo.go index e5e88b2..6faafe7 100644 --- a/internal/repository/social_graph_repo.go +++ b/internal/repository/social_graph_repo.go @@ -98,22 +98,29 @@ func (r *FollowRepo) Status(ctx context.Context, followerID, followingID string) // 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) + return r.listRelations(ctx, "f.following_id = $1", "f.follower_id", "accepted", userID, q, limit, offset) +} + +// ListIncomingRequests returns the users with a pending request to follow +// userID (newest first), optionally filtered by `q`, plus the total count. +func (r *FollowRepo) ListIncomingRequests(ctx context.Context, userID, q string, limit, offset int) ([]model.UserSummary, int, error) { + return r.listRelations(ctx, "f.following_id = $1", "f.follower_id", "pending", 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) + return r.listRelations(ctx, "f.follower_id = $1", "f.following_id", "accepted", 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. +// listRelations powers follower/following/pending-request listings: `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), `status` is the edge +// status to match ('accepted' or 'pending'). `q` filters by name/handle +// (ILIKE); empty `q` disables the filter. The column/status 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, + ctx context.Context, whereOwn, joinCol, status, userID, q string, limit, offset int, ) ([]model.UserSummary, int, error) { like := "%" + q + "%" @@ -121,7 +128,7 @@ func (r *FollowRepo) listRelations( countSQL := ` SELECT count(*) FROM follows f JOIN users u ON u.id = ` + joinCol + ` - WHERE ` + whereOwn + ` AND f.status = 'accepted' + WHERE ` + whereOwn + ` AND f.status = '` + status + `' 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) @@ -133,7 +140,7 @@ func (r *FollowRepo) listRelations( 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' + WHERE ` + whereOwn + ` AND f.status = '` + status + `' 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` diff --git a/internal/server/server.go b/internal/server/server.go index a0cbbf7..61cb428 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -859,6 +859,13 @@ func (s *Server) setupRouter() { }) }) + // Caller's pending incoming follow requests (the notifications + // "Friend Requests" tab). Caller-scoped, so it lives outside /u/{handle}. + if s.profileH != nil { + r.With(middleware.RequireAuth(s.authIssuer, s.userRepo)). + Get("/follow-requests", s.profileH.IncomingFollowRequests) + } + 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 f2b9713..7c84f08 100644 --- a/internal/service/social_graph_service.go +++ b/internal/service/social_graph_service.go @@ -354,6 +354,13 @@ func (s *SocialGraphService) Following(ctx context.Context, handle, q string, li return s.follows.ListFollowing(ctx, u.ID, q, limit, offset) } +// IncomingRequests returns the pending follow requests addressed to userID +// (the caller) — the "Friend Requests" tab. Caller-scoped, so it takes the id +// directly rather than a handle. +func (s *SocialGraphService) IncomingRequests(ctx context.Context, userID, q string, limit, offset int) ([]model.UserSummary, int, error) { + return s.follows.ListIncomingRequests(ctx, userID, 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