Files
spsays-bff/internal/handler/social.go
T

826 lines
27 KiB
Go

// Package handler — social endpoints for posts, bookmarks, collections, and
// public profiles. Three small handler structs share this file because each
// is itself just a thin shim over its service. mapSocialErr at the bottom
// centralizes the error→HTTP-status mapping so every endpoint surfaces the
// same shapes for plan-limit / forbidden / not-found.
package handler
import (
"encoding/json"
"errors"
"math"
"net/http"
"strconv"
"strings"
"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"
"github.com/prabhupadasays/spsays-bff/pkg/response"
"github.com/prabhupadasays/spsays-bff/pkg/validator"
)
// ---------------------------------------------------------------------------
// PostHandler
// ---------------------------------------------------------------------------
type PostHandler struct {
svc *service.PostService
graph *service.SocialGraphService // post reporting
}
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"`
SourceURL string `json:"source_url"`
SourceTitle string `json:"source_title"`
ImageURLs []string `json:"image_urls"`
MentionUserIDs []string `json:"mention_user_ids"`
}
func (h *PostHandler) Create(w http.ResponseWriter, r *http.Request) {
uid := middleware.UserID(r.Context())
var req createPostReq
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
response.Error(w, http.StatusBadRequest, "invalid JSON body")
return
}
v := validator.New()
validator.Required(v, "caption", req.Caption)
validator.Required(v, "source_url", req.SourceURL)
if !v.Valid() {
response.Error(w, http.StatusBadRequest, v.Error())
return
}
p, err := h.svc.Create(r.Context(), uid, req.Caption, req.SourceURL, req.SourceTitle, req.ImageURLs, req.MentionUserIDs)
if err != nil {
mapSocialErr(w, err)
return
}
response.JSON(w, http.StatusCreated, p)
}
// MentionSuggest — GET /posts/mention-suggest?q=&limit=&offset=. Prioritised
// user suggestions for the composer (following → followers → others).
func (h *PostHandler) MentionSuggest(w http.ResponseWriter, r *http.Request) {
uid := middleware.UserID(r.Context())
limit, offset := parseLimitOffset(r)
q := strings.TrimSpace(r.URL.Query().Get("q"))
users, err := h.svc.SuggestMentions(r.Context(), uid, q, limit, offset)
if err != nil {
mapSocialErr(w, err)
return
}
if users == nil {
users = []model.UserSummary{}
}
response.JSON(w, http.StatusOK, users)
}
// ListMentions — GET /posts/{id}/mentions. The users tagged on a post. Public.
func (h *PostHandler) ListMentions(w http.ResponseWriter, r *http.Request) {
users, err := h.svc.Mentions(r.Context(), chi.URLParam(r, "id"))
if err != nil {
mapSocialErr(w, err)
return
}
response.JSON(w, http.StatusOK, users)
}
// AddMention — POST /posts/{id}/mentions { user_id }. Author only.
func (h *PostHandler) AddMention(w http.ResponseWriter, r *http.Request) {
uid := middleware.UserID(r.Context())
var req struct {
UserID string `json:"user_id"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil || strings.TrimSpace(req.UserID) == "" {
response.Error(w, http.StatusBadRequest, "missing user_id")
return
}
if err := h.svc.AddMention(r.Context(), uid, chi.URLParam(r, "id"), req.UserID); err != nil {
mapSocialErr(w, err)
return
}
response.NoContent(w)
}
// RemoveMention — DELETE /posts/{id}/mentions/{userID}. Author only.
func (h *PostHandler) RemoveMention(w http.ResponseWriter, r *http.Request) {
uid := middleware.UserID(r.Context())
if err := h.svc.RemoveMention(r.Context(), uid, chi.URLParam(r, "id"), chi.URLParam(r, "userID")); err != nil {
mapSocialErr(w, err)
return
}
response.NoContent(w)
}
// Reshare materialises a reshare of the post identified by {id} as a
// new posts row owned by the caller. Idempotent — second call returns
// the same row (still 201). The body is the new (or existing) post so
// the frontend can drop it into its local feed without a refetch.
func (h *PostHandler) Reshare(w http.ResponseWriter, r *http.Request) {
uid := middleware.UserID(r.Context())
id := chi.URLParam(r, "id")
p, err := h.svc.Reshare(r.Context(), id, uid)
if err != nil {
mapSocialErr(w, err)
return
}
response.JSON(w, http.StatusCreated, p)
}
// Unreshare removes a previously recorded reshare. Idempotent.
func (h *PostHandler) Unreshare(w http.ResponseWriter, r *http.Request) {
uid := middleware.UserID(r.Context())
id := chi.URLParam(r, "id")
if err := h.svc.Unreshare(r.Context(), id, uid); err != nil {
mapSocialErr(w, err)
return
}
w.WriteHeader(http.StatusNoContent)
}
func (h *PostHandler) Get(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
// viewerID is "" for anonymous viewers — the service treats that as
// "no reshare hydration" so the public-cache shape is unchanged.
p, err := h.svc.Get(r.Context(), id, middleware.UserID(r.Context()))
if err != nil {
mapSocialErr(w, err)
return
}
response.JSON(w, http.StatusOK, p)
}
// likeResp is returned by Like / Unlike — the new viewer state + effective
// total (likes_count + pending Redis delta) so the client can reconcile.
type likeResp struct {
Liked bool `json:"liked"`
LikesCount int `json:"likes_count"`
}
// Like — POST /posts/{id}/like. Idempotent. RequireAuth.
func (h *PostHandler) Like(w http.ResponseWriter, r *http.Request) {
uid := middleware.UserID(r.Context())
id := chi.URLParam(r, "id")
count, err := h.svc.Like(r.Context(), uid, id)
if err != nil {
mapSocialErr(w, err)
return
}
response.JSON(w, http.StatusOK, likeResp{Liked: true, LikesCount: count})
}
// Unlike — DELETE /posts/{id}/like. Idempotent. RequireAuth.
func (h *PostHandler) Unlike(w http.ResponseWriter, r *http.Request) {
uid := middleware.UserID(r.Context())
id := chi.URLParam(r, "id")
count, err := h.svc.Unlike(r.Context(), uid, id)
if err != nil {
mapSocialErr(w, err)
return
}
response.JSON(w, http.StatusOK, likeResp{Liked: false, LikesCount: count})
}
// ListLikes — GET /posts/{id}/likes?limit=&offset=&q=. Paginated "liked by"
// users with optional name/handle search. Public (OptionalAuth).
func (h *PostHandler) ListLikes(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
limit, offset := parseLimitOffset(r)
q := strings.TrimSpace(r.URL.Query().Get("q"))
users, total, err := h.svc.Likers(r.Context(), id, q, limit, offset)
if err != nil {
mapSocialErr(w, err)
return
}
response.JSON(w, http.StatusOK, followListResp{Users: users, Total: total})
}
func (h *PostHandler) Delete(w http.ResponseWriter, r *http.Request) {
uid := middleware.UserID(r.Context())
if err := h.svc.Delete(r.Context(), chi.URLParam(r, "id"), uid); err != nil {
mapSocialErr(w, err)
return
}
response.NoContent(w)
}
func (h *PostHandler) ListMine(w http.ResponseWriter, r *http.Request) {
uid := middleware.UserID(r.Context())
limit, offset := parseLimitOffset(r)
posts, total, err := h.svc.ListMine(r.Context(), uid, limit, offset)
if err != nil {
mapSocialErr(w, err)
return
}
response.JSONWithMeta(w, http.StatusOK, posts, paginationMeta(total, limit, offset))
}
type replaceImagesReq struct {
ImageURLs []string `json:"image_urls"`
}
// ReplaceImages swaps the post's image set. Body: {"image_urls": [...]}.
// An empty array removes all images (used for the "remove background" flow).
// Owner-only; non-owners and missing posts both 404 to avoid leaking IDs.
func (h *PostHandler) ReplaceImages(w http.ResponseWriter, r *http.Request) {
uid := middleware.UserID(r.Context())
id := chi.URLParam(r, "id")
var req replaceImagesReq
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
response.Error(w, http.StatusBadRequest, "invalid JSON body")
return
}
// Defensive cap — posts in this app only display one background, but
// the schema supports many. Cap at 5 to match the compose-post limit.
if len(req.ImageURLs) > 5 {
response.Error(w, http.StatusBadRequest, "at most 5 images")
return
}
p, err := h.svc.ReplaceImages(r.Context(), id, uid, req.ImageURLs)
if err != nil {
mapSocialErr(w, err)
return
}
response.JSON(w, http.StatusOK, p)
}
type imagePresignReq struct {
ContentType string `json:"content_type"`
}
func (h *PostHandler) ImagePresign(w http.ResponseWriter, r *http.Request) {
uid := middleware.UserID(r.Context())
var req imagePresignReq
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
response.Error(w, http.StatusBadRequest, "invalid JSON body")
return
}
upload, public, err := h.svc.ImagePresign(r.Context(), uid, strings.TrimSpace(req.ContentType))
if err != nil {
mapSocialErr(w, err)
return
}
response.JSON(w, http.StatusOK, map[string]string{
"upload_url": upload,
"public_url": public,
})
}
// ---------------------------------------------------------------------------
// BookmarkHandler
// ---------------------------------------------------------------------------
type BookmarkHandler struct{ svc *service.BookmarkService }
func NewBookmarkHandler(svc *service.BookmarkService) *BookmarkHandler {
return &BookmarkHandler{svc: svc}
}
type createBookmarkReq struct {
SourceURL string `json:"source_url"`
SourceTitle string `json:"source_title"`
Excerpt *string `json:"excerpt"`
Note *string `json:"note"`
}
func (h *BookmarkHandler) Create(w http.ResponseWriter, r *http.Request) {
uid := middleware.UserID(r.Context())
var req createBookmarkReq
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
response.Error(w, http.StatusBadRequest, "invalid JSON body")
return
}
if strings.TrimSpace(req.SourceURL) == "" {
response.Error(w, http.StatusBadRequest, "source_url is required")
return
}
b, err := h.svc.Create(r.Context(), uid, req.SourceURL, req.SourceTitle, req.Excerpt, req.Note)
if err != nil {
mapSocialErr(w, err)
return
}
response.JSON(w, http.StatusCreated, b)
}
func (h *BookmarkHandler) List(w http.ResponseWriter, r *http.Request) {
uid := middleware.UserID(r.Context())
out, err := h.svc.List(r.Context(), uid)
if err != nil {
mapSocialErr(w, err)
return
}
response.JSON(w, http.StatusOK, out)
}
func (h *BookmarkHandler) Delete(w http.ResponseWriter, r *http.Request) {
uid := middleware.UserID(r.Context())
if err := h.svc.Delete(r.Context(), chi.URLParam(r, "id"), uid); err != nil {
mapSocialErr(w, err)
return
}
response.NoContent(w)
}
// ---------------------------------------------------------------------------
// CollectionHandler
// ---------------------------------------------------------------------------
type CollectionHandler struct {
svc *service.CollectionService
postRepo *repository.PostRepo
auth *service.AuthService // unused for now; placeholder for follow/mute later
issuer *auth.Issuer // used to detect viewer id on optionally-authed routes
users *repository.UserRepo
}
func NewCollectionHandler(svc *service.CollectionService, posts *repository.PostRepo, issuer *auth.Issuer, users *repository.UserRepo) *CollectionHandler {
return &CollectionHandler{svc: svc, postRepo: posts, issuer: issuer, users: users}
}
type createCollectionReq struct {
Title string `json:"title"`
Description *string `json:"description"`
CoverURL *string `json:"cover_url"`
IsPublic bool `json:"is_public"`
}
func (h *CollectionHandler) Create(w http.ResponseWriter, r *http.Request) {
uid := middleware.UserID(r.Context())
var req createCollectionReq
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
response.Error(w, http.StatusBadRequest, "invalid JSON body")
return
}
c, err := h.svc.Create(r.Context(), uid, req.Title, req.Description, req.CoverURL, req.IsPublic)
if err != nil {
mapSocialErr(w, err)
return
}
response.JSON(w, http.StatusCreated, c)
}
type updateCollectionReq struct {
Title *string `json:"title"`
Description *string `json:"description"`
CoverURL *string `json:"cover_url"`
IsPublic *bool `json:"is_public"`
}
func (h *CollectionHandler) Update(w http.ResponseWriter, r *http.Request) {
uid := middleware.UserID(r.Context())
id := chi.URLParam(r, "id")
var req updateCollectionReq
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
response.Error(w, http.StatusBadRequest, "invalid JSON body")
return
}
c, err := h.svc.Update(r.Context(), id, uid, req.Title, req.Description, req.CoverURL, req.IsPublic)
if err != nil {
mapSocialErr(w, err)
return
}
response.JSON(w, http.StatusOK, c)
}
func (h *CollectionHandler) Delete(w http.ResponseWriter, r *http.Request) {
uid := middleware.UserID(r.Context())
if err := h.svc.Delete(r.Context(), chi.URLParam(r, "id"), uid); err != nil {
mapSocialErr(w, err)
return
}
response.NoContent(w)
}
func (h *CollectionHandler) ListMine(w http.ResponseWriter, r *http.Request) {
uid := middleware.UserID(r.Context())
out, err := h.svc.ListMine(r.Context(), uid)
if err != nil {
mapSocialErr(w, err)
return
}
response.JSON(w, http.StatusOK, out)
}
// Get is public-or-private depending on the collection's `is_public` flag.
// Anonymous viewers can read public collections; private ones require the
// owner to be logged in. We try to read the JWT cookie best-effort to
// detect the viewer's identity.
func (h *CollectionHandler) Get(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
viewerID := optionalUserID(r, h.issuer, h.users)
c, posts, err := h.svc.Get(r.Context(), id, viewerID)
if err != nil {
mapSocialErr(w, err)
return
}
response.JSON(w, http.StatusOK, map[string]any{
"collection": c,
"posts": posts,
})
}
type collectionItemReq struct {
PostID string `json:"post_id"`
}
func (h *CollectionHandler) AddItem(w http.ResponseWriter, r *http.Request) {
uid := middleware.UserID(r.Context())
collectionID := chi.URLParam(r, "id")
var req collectionItemReq
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
response.Error(w, http.StatusBadRequest, "invalid JSON body")
return
}
if err := h.svc.AddItem(r.Context(), collectionID, req.PostID, uid); err != nil {
mapSocialErr(w, err)
return
}
response.JSON(w, http.StatusOK, map[string]bool{"ok": true})
}
func (h *CollectionHandler) RemoveItem(w http.ResponseWriter, r *http.Request) {
uid := middleware.UserID(r.Context())
collectionID := chi.URLParam(r, "id")
postID := chi.URLParam(r, "post_id")
if err := h.svc.RemoveItem(r.Context(), collectionID, postID, uid); err != nil {
mapSocialErr(w, err)
return
}
response.NoContent(w)
}
type reorderItemsReq struct {
PostIDs []string `json:"post_ids"`
}
// ReorderItems takes an ordered list of post ids and rewrites their
// positions in the collection. Owner-only. Body: {"post_ids": [...]}.
func (h *CollectionHandler) ReorderItems(w http.ResponseWriter, r *http.Request) {
uid := middleware.UserID(r.Context())
collectionID := chi.URLParam(r, "id")
var req reorderItemsReq
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
response.Error(w, http.StatusBadRequest, "invalid JSON body")
return
}
if err := h.svc.SetItemOrder(r.Context(), collectionID, uid, req.PostIDs); err != nil {
mapSocialErr(w, err)
return
}
response.NoContent(w)
}
// ContainingPost answers "which of my collections already contain post
// X?" — used by the save-to-collection drawer so a post can't be added
// twice. Response: {"collection_ids": ["uuid", ...]}.
func (h *CollectionHandler) ContainingPost(w http.ResponseWriter, r *http.Request) {
uid := middleware.UserID(r.Context())
postID := chi.URLParam(r, "post_id")
ids, err := h.svc.CollectionsContaining(r.Context(), postID, uid)
if err != nil {
mapSocialErr(w, err)
return
}
response.JSON(w, http.StatusOK, map[string][]string{
"collection_ids": ids,
})
}
func (h *CollectionHandler) CoverPresign(w http.ResponseWriter, r *http.Request) {
uid := middleware.UserID(r.Context())
var req imagePresignReq
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
response.Error(w, http.StatusBadRequest, "invalid JSON body")
return
}
upload, public, err := h.svc.CoverPresign(r.Context(), uid, strings.TrimSpace(req.ContentType))
if err != nil {
mapSocialErr(w, err)
return
}
response.JSON(w, http.StatusOK, map[string]string{
"upload_url": upload,
"public_url": public,
})
}
// ---------------------------------------------------------------------------
// ProfileHandler — public /api/v1/u/{handle}
// ---------------------------------------------------------------------------
type ProfileHandler struct {
users *repository.UserRepo
posts *repository.PostRepo
collections *repository.CollectionRepo
graph *service.SocialGraphService
likes *repository.LikeRepo
mentions *repository.MentionRepo
}
func NewProfileHandler(users *repository.UserRepo, posts *repository.PostRepo, collections *repository.CollectionRepo, graph *service.SocialGraphService, likes *repository.LikeRepo, mentions *repository.MentionRepo) *ProfileHandler {
return &ProfileHandler{users: users, posts: posts, collections: collections, graph: graph, likes: likes, mentions: mentions}
}
func (h *ProfileHandler) Get(w http.ResponseWriter, r *http.Request) {
handle := chi.URLParam(r, "handle")
u, err := h.users.FindByHandle(r.Context(), handle)
if err != nil {
mapSocialErr(w, err)
return
}
count, err := h.posts.CountByUser(r.Context(), u.ID)
if err != nil {
response.Error(w, http.StatusInternalServerError, "could not load profile")
return
}
cols, err := h.collections.ListPublicByUser(r.Context(), u.ID)
if err != nil {
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,
})
}
// gateByBlock resolves the profile owner by handle and returns ErrBlocked
// (→403) when a block exists in either direction between the signed-in
// viewer and the owner — so a blocked viewer can't read the owner's feed,
// tagged posts, or follower lists, even by hitting the API directly. It
// returns the resolved owner so callers needn't look it up again, and
// propagates ErrUserNotFound (→404) for an unknown handle. Anonymous
// viewers and owners viewing their own profile are never gated.
func (h *ProfileHandler) gateByBlock(r *http.Request, handle string) (*model.User, error) {
owner, err := h.users.FindByHandle(r.Context(), handle)
if err != nil {
return nil, err
}
viewerID := middleware.UserID(r.Context())
if viewerID == "" || viewerID == owner.ID {
return owner, nil
}
blocked, err := h.graph.IsBlockedBetween(r.Context(), viewerID, owner.ID)
if err != nil {
return nil, err
}
if blocked {
return nil, service.ErrBlocked
}
return owner, nil
}
// Feed returns posts for the public feed. Same payload as ListMine but
// looked up by handle so it's reachable anonymously. When an
// authenticated viewer hits this route (via OptionalAuth) each post
// carries `viewer_has_reshared` so the action footer renders the right
// button. Anonymous viewers get `false` for every post at zero cost.
func (h *ProfileHandler) Feed(w http.ResponseWriter, r *http.Request) {
handle := chi.URLParam(r, "handle")
owner, err := h.gateByBlock(r, handle)
if err != nil {
mapSocialErr(w, err)
return
}
limit, offset := parseLimitOffset(r)
// gateByBlock already resolved the owner, so list by id rather than
// re-resolving the handle.
posts, total, err := h.posts.ListByUser(r.Context(), owner.ID, limit, offset)
if err != nil {
mapSocialErr(w, err)
return
}
if err := h.hydrateFeed(r, posts); err != nil {
mapSocialErr(w, err)
return
}
response.JSONWithMeta(w, http.StatusOK, posts, paginationMeta(total, limit, offset))
}
// MentionsFeed — GET /u/{handle}/mentions?limit=&offset=. Posts that tag this
// user, newest first. Public (OptionalAuth) with viewer-state hydration.
func (h *ProfileHandler) MentionsFeed(w http.ResponseWriter, r *http.Request) {
handle := chi.URLParam(r, "handle")
if _, err := h.gateByBlock(r, handle); err != nil {
mapSocialErr(w, err)
return
}
limit, offset := parseLimitOffset(r)
posts, total, err := h.posts.ListMentioningByHandle(r.Context(), handle, limit, offset)
if err != nil {
mapSocialErr(w, err)
return
}
if err := h.hydrateFeed(r, posts); err != nil {
mapSocialErr(w, err)
return
}
response.JSONWithMeta(w, http.StatusOK, posts, paginationMeta(total, limit, offset))
}
// hydrateFeed fills viewer reshare state + like counts/state + mention counts
// across a page of posts. Shared by the profile feed and the mentions feed.
func (h *ProfileHandler) hydrateFeed(r *http.Request, posts []model.Post) error {
if len(posts) == 0 {
return nil
}
viewerID := middleware.UserID(r.Context())
if viewerID != "" {
ids := make([]string, 0, len(posts))
for i := range posts {
if posts[i].UserID != viewerID { // can't reshare your own post
ids = append(ids, posts[i].ID)
}
}
if len(ids) > 0 {
states, err := h.posts.ReshareStatesFor(r.Context(), viewerID, ids)
if err != nil {
return err
}
for i := range posts {
if states[posts[i].ID] {
posts[i].ViewerHasReshared = true
}
}
}
}
ptrs := make([]*model.Post, len(posts))
for i := range posts {
ptrs[i] = &posts[i]
}
if err := h.likes.Hydrate(r.Context(), viewerID, ptrs); err != nil {
return err
}
return h.mentions.HydrateCounts(r.Context(), ptrs)
}
// 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")
if _, err := h.gateByBlock(r, handle); err != nil {
mapSocialErr(w, err)
return
}
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")
if _, err := h.gateByBlock(r, handle); err != nil {
mapSocialErr(w, err)
return
}
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
// ---------------------------------------------------------------------------
func mapSocialErr(w http.ResponseWriter, err error) {
switch {
case errors.Is(err, repository.ErrUserNotFound),
errors.Is(err, repository.ErrPostNotFound),
errors.Is(err, repository.ErrBookmarkNotFound),
errors.Is(err, repository.ErrCollectionNotFound),
errors.Is(err, service.ErrNotFound):
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:
response.Error(w, http.StatusBadRequest, err.Error())
}
}
func parseLimitOffset(r *http.Request) (limit, offset int) {
if v := r.URL.Query().Get("limit"); v != "" {
if n, err := strconv.Atoi(v); err == nil {
limit = n
}
}
if v := r.URL.Query().Get("offset"); v != "" {
if n, err := strconv.Atoi(v); err == nil {
offset = n
}
}
if limit <= 0 || limit > 50 {
limit = 20
}
if offset < 0 {
offset = 0
}
return limit, offset
}
func paginationMeta(total, limit, offset int) *response.Meta {
if limit == 0 {
limit = 20
}
totalPages := int(math.Ceil(float64(total) / float64(limit)))
return &response.Meta{
Page: (offset / limit) + 1,
Limit: limit,
Total: int64(total),
TotalPages: totalPages,
}
}
// optionalUserID reads the auth cookie if present and verifies the JWT —
// used by routes that show different data to logged-in vs anonymous viewers
// (e.g. private collections). Returns "" on any failure (treated as anon).
func optionalUserID(r *http.Request, issuer *auth.Issuer, users *repository.UserRepo) string {
raw := auth.Read(r)
if raw == "" || issuer == nil {
return ""
}
claims, err := issuer.Parse(raw)
if err != nil {
return ""
}
if users != nil {
u, err := users.FindByID(r.Context(), claims.UserID)
if err != nil || u.TokenVersion != claims.TokenVersion {
return ""
}
}
return claims.UserID
}