feat: social graph features

This commit is contained in:
Krispire Admin
2026-06-04 19:29:48 +05:30
parent d9678ebb3b
commit efa1eaf23b
28 changed files with 1882 additions and 78 deletions
+30
View File
@@ -1,13 +1,16 @@
package handler
import (
"context"
"encoding/json"
"errors"
"net/http"
"time"
"github.com/prabhupadasays/spsays-bff/internal/middleware"
"github.com/prabhupadasays/spsays-bff/internal/service"
"github.com/prabhupadasays/spsays-bff/pkg/auth"
"github.com/prabhupadasays/spsays-bff/pkg/geoip"
"github.com/prabhupadasays/spsays-bff/pkg/response"
"github.com/prabhupadasays/spsays-bff/pkg/validator"
)
@@ -94,10 +97,37 @@ func (h *AuthHandler) SignUp(w http.ResponseWriter, r *http.Request) {
h.renderAuthErr(w, err)
return
}
h.captureCountry(r, res.User.ID)
auth.Set(w, res.Token, res.Expires, h.cookies)
response.JSON(w, http.StatusCreated, res.User)
}
// captureCountry records the new account's country off the critical path.
// Cloudflare's CF-IPCountry header is preferred (no network); when absent
// — as in the current DNS-only setup — we fall back to a GeoIP lookup on
// the client IP. The header read + IP extraction happen synchronously
// (they need the live request), then the network lookup + DB write run in
// a goroutine so signup latency is untouched. Any failure is silent: the
// account simply keeps country = NULL until the user sets it themselves.
func (h *AuthHandler) captureCountry(r *http.Request, userID string) {
cfCountry := geoip.CountryFromCloudflare(r)
ip := geoip.ClientIP(r)
go func() {
cc := cfCountry
if cc == "" {
lookupCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
cc = geoip.LookupCountryByIP(lookupCtx, ip)
cancel()
}
if cc == "" {
return
}
writeCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
h.svc.CaptureCountry(writeCtx, userID, cc)
}()
}
// LogIn authenticates an existing user. The response is one of:
//
// - 200 OK with the User JSON — credentials verified, session cookie set,
+51
View File
@@ -0,0 +1,51 @@
package handler
import (
"net/http"
"github.com/prabhupadasays/spsays-bff/internal/middleware"
"github.com/prabhupadasays/spsays-bff/internal/service"
"github.com/prabhupadasays/spsays-bff/pkg/response"
)
// NotificationHandler serves the signed-in user's notification feed +
// unread badge count, and marks them read. All routes sit behind
// RequireAuth, so middleware.UserID is always populated.
type NotificationHandler struct{ svc *service.NotificationService }
func NewNotificationHandler(svc *service.NotificationService) *NotificationHandler {
return &NotificationHandler{svc: svc}
}
// List returns the caller's notifications, newest first.
func (h *NotificationHandler) List(w http.ResponseWriter, r *http.Request) {
uid := middleware.UserID(r.Context())
limit, offset := parseLimitOffset(r)
items, err := h.svc.List(r.Context(), uid, limit, offset)
if err != nil {
response.Error(w, http.StatusInternalServerError, "could not load notifications")
return
}
response.JSON(w, http.StatusOK, items)
}
// UnreadCount returns {"count": N} for the bell badge.
func (h *NotificationHandler) UnreadCount(w http.ResponseWriter, r *http.Request) {
uid := middleware.UserID(r.Context())
n, err := h.svc.UnreadCount(r.Context(), uid)
if err != nil {
response.Error(w, http.StatusInternalServerError, "could not load unread count")
return
}
response.JSON(w, http.StatusOK, map[string]int{"count": n})
}
// MarkAllRead marks every notification read (called when the page opens).
func (h *NotificationHandler) MarkAllRead(w http.ResponseWriter, r *http.Request) {
uid := middleware.UserID(r.Context())
if err := h.svc.MarkAllRead(r.Context(), uid); err != nil {
response.Error(w, http.StatusInternalServerError, "could not mark notifications read")
return
}
response.NoContent(w)
}
+101
View File
@@ -0,0 +1,101 @@
package handler
import (
"context"
"encoding/json"
"net/http"
"github.com/go-chi/chi/v5"
"github.com/prabhupadasays/spsays-bff/internal/middleware"
"github.com/prabhupadasays/spsays-bff/internal/service"
"github.com/prabhupadasays/spsays-bff/pkg/response"
)
// RelationshipHandler exposes the follow / block / report actions, all
// scoped to a target user identified by {handle} in the path and a
// signed-in viewer pulled from the auth middleware. Every mutating
// endpoint returns the recomputed RelationshipResult (viewer's state +
// the target's live counts) so the frontend updates its buttons and
// stat line from the single response — except Report, which returns 204.
//
// These routes live under /api/v1/u/{handle}/… in a RequireAuth group;
// the public profile GET stays anonymous-friendly in its own group.
type RelationshipHandler struct{ svc *service.SocialGraphService }
func NewRelationshipHandler(svc *service.SocialGraphService) *RelationshipHandler {
return &RelationshipHandler{svc: svc}
}
// Follow sends a follow REQUEST (pending until the target accepts).
func (h *RelationshipHandler) Follow(w http.ResponseWriter, r *http.Request) {
h.act(w, r, h.svc.RequestFollow)
}
// Unfollow removes the viewer's edge — stops following or cancels a
// still-pending request.
func (h *RelationshipHandler) Unfollow(w http.ResponseWriter, r *http.Request) {
h.act(w, r, h.svc.Unfollow)
}
// AcceptFollow accepts the target's pending request to follow the viewer.
func (h *RelationshipHandler) AcceptFollow(w http.ResponseWriter, r *http.Request) {
h.act(w, r, h.svc.AcceptFollow)
}
// RejectFollow declines the target's pending request.
func (h *RelationshipHandler) RejectFollow(w http.ResponseWriter, r *http.Request) {
h.act(w, r, h.svc.RejectFollow)
}
// RemoveFollower drops the target from the viewer's followers.
func (h *RelationshipHandler) RemoveFollower(w http.ResponseWriter, r *http.Request) {
h.act(w, r, h.svc.RemoveFollower)
}
// Block blocks the target (and severs follows both ways).
func (h *RelationshipHandler) Block(w http.ResponseWriter, r *http.Request) {
h.act(w, r, h.svc.Block)
}
// Unblock removes the viewer's block on the target.
func (h *RelationshipHandler) Unblock(w http.ResponseWriter, r *http.Request) {
h.act(w, r, h.svc.Unblock)
}
type reportReq struct {
Category string `json:"category"`
Reason string `json:"reason"`
}
// Report files a report against the target. Body: {category, reason}.
func (h *RelationshipHandler) Report(w http.ResponseWriter, r *http.Request) {
viewerID := middleware.UserID(r.Context())
handle := chi.URLParam(r, "handle")
var req reportReq
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
response.Error(w, http.StatusBadRequest, "invalid JSON body")
return
}
if err := h.svc.Report(r.Context(), viewerID, handle, req.Category, req.Reason); err != nil {
mapSocialErr(w, err)
return
}
response.NoContent(w)
}
// act is the shared shim for every RelationshipResult-returning action:
// pull viewer + handle, run the service method, map errors, return JSON.
func (h *RelationshipHandler) act(
w http.ResponseWriter,
r *http.Request,
fn func(ctx context.Context, viewerID, handle string) (*service.RelationshipResult, error),
) {
viewerID := middleware.UserID(r.Context())
handle := chi.URLParam(r, "handle")
res, err := fn(r.Context(), viewerID, handle)
if err != nil {
mapSocialErr(w, err)
return
}
response.JSON(w, http.StatusOK, res)
}
+49 -4
View File
@@ -26,9 +26,36 @@ import (
// PostHandler
// ---------------------------------------------------------------------------
type PostHandler struct{ svc *service.PostService }
type PostHandler struct {
svc *service.PostService
graph *service.SocialGraphService // post reporting
}
func NewPostHandler(svc *service.PostService) *PostHandler { return &PostHandler{svc: svc} }
func NewPostHandler(svc *service.PostService, graph *service.SocialGraphService) *PostHandler {
return &PostHandler{svc: svc, graph: graph}
}
type reportPostReq struct {
Category string `json:"category"`
Reason string `json:"reason"`
}
// Report files a report against this post. Body: {category, reason?}.
// Auth-required; the reporter is the signed-in viewer.
func (h *PostHandler) Report(w http.ResponseWriter, r *http.Request) {
uid := middleware.UserID(r.Context())
id := chi.URLParam(r, "id")
var req reportPostReq
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
response.Error(w, http.StatusBadRequest, "invalid JSON body")
return
}
if err := h.graph.ReportPost(r.Context(), uid, id, req.Category, req.Reason); err != nil {
mapSocialErr(w, err)
return
}
response.NoContent(w)
}
type createPostReq struct {
Caption string `json:"caption"`
@@ -414,10 +441,11 @@ type ProfileHandler struct {
users *repository.UserRepo
posts *repository.PostRepo
collections *repository.CollectionRepo
graph *service.SocialGraphService
}
func NewProfileHandler(users *repository.UserRepo, posts *repository.PostRepo, collections *repository.CollectionRepo) *ProfileHandler {
return &ProfileHandler{users: users, posts: posts, collections: collections}
func NewProfileHandler(users *repository.UserRepo, posts *repository.PostRepo, collections *repository.CollectionRepo, graph *service.SocialGraphService) *ProfileHandler {
return &ProfileHandler{users: users, posts: posts, collections: collections, graph: graph}
}
func (h *ProfileHandler) Get(w http.ResponseWriter, r *http.Request) {
@@ -437,12 +465,27 @@ func (h *ProfileHandler) Get(w http.ResponseWriter, r *http.Request) {
response.Error(w, http.StatusInternalServerError, "could not load collections")
return
}
// Follower/following counts + the viewer's relationship to this
// profile. viewerID is "" for anonymous viewers (OptionalAuth) and
// equal to u.ID when the owner hits their own /u/{handle} — both
// cases yield a nil `relationship` so the frontend hides the
// follow/block affordances.
viewerID := middleware.UserID(r.Context())
followers, following, rel, err := h.graph.ProfileExtras(r.Context(), viewerID, u)
if err != nil {
response.Error(w, http.StatusInternalServerError, "could not load relationship")
return
}
response.JSON(w, http.StatusOK, map[string]any{
"handle": u.Handle,
"name": u.Name,
"avatar_url": u.AvatarURL,
"joined_at": u.CreatedAt,
"country": u.Country,
"post_count": count,
"follower_count": followers,
"following_count": following,
"relationship": rel,
"public_collections": cols,
})
}
@@ -500,6 +543,8 @@ func mapSocialErr(w http.ResponseWriter, err error) {
response.Error(w, http.StatusNotFound, "not found")
case errors.Is(err, service.ErrForbidden):
response.Error(w, http.StatusForbidden, "not allowed")
case errors.Is(err, service.ErrBlocked):
response.Error(w, http.StatusForbidden, "this action isn't available between you and this user")
case errors.Is(err, service.ErrPlanLimit):
response.Error(w, http.StatusPaymentRequired, err.Error())
default:
+25 -2
View File
@@ -30,9 +30,20 @@ type updateProfileReq struct {
Name *string `json:"name"`
AvatarURL *string `json:"avatar_url"`
Handle *string `json:"handle"`
Country *string `json:"country"`
// Devotional profile (user-editable).
Sampradaya *string `json:"sampradaya"`
Gender *string `json:"gender"`
Initiated *bool `json:"initiated"`
InitiatedAt *string `json:"initiated_at"`
DailyChantingRounds *int `json:"daily_chanting_rounds"`
// System-managed notification fields — accepted here so future logic
// can set them via the same endpoint; no UI sends them today.
PreferredChantingTime *string `json:"preferred_chanting_time"`
PeakActiveTime *string `json:"peak_active_time"`
}
// UpdateProfile patches name, avatar, and/or handle.
// UpdateProfile patches any subset of the editable profile fields.
func (h *UserHandler) UpdateProfile(w http.ResponseWriter, r *http.Request) {
uid := middleware.UserID(r.Context())
@@ -42,7 +53,19 @@ func (h *UserHandler) UpdateProfile(w http.ResponseWriter, r *http.Request) {
return
}
u, err := h.svc.UpdateProfile(r.Context(), uid, req.Name, req.AvatarURL, req.Handle)
u, err := h.svc.UpdateProfile(r.Context(), uid, service.UpdateProfileInput{
Name: req.Name,
AvatarURL: req.AvatarURL,
Handle: req.Handle,
Country: req.Country,
Sampradaya: req.Sampradaya,
Gender: req.Gender,
Initiated: req.Initiated,
InitiatedAt: req.InitiatedAt,
DailyChantingRounds: req.DailyChantingRounds,
PreferredChantingTime: req.PreferredChantingTime,
PeakActiveTime: req.PeakActiveTime,
})
if err != nil {
if errors.Is(err, repository.ErrHandleTaken) {
response.Error(w, http.StatusConflict, "that handle is already in use")