// 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"` } 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) if err != nil { mapSocialErr(w, err) return } response.JSON(w, http.StatusCreated, p) } // 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) } 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 } 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) { 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, }) } // 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") limit, offset := parseLimitOffset(r) posts, total, err := h.posts.ListByHandle(r.Context(), handle, limit, offset) if err != nil { mapSocialErr(w, err) return } viewerID := middleware.UserID(r.Context()) if viewerID != "" && len(posts) > 0 { ids := make([]string, 0, len(posts)) for i := range posts { // Skip own posts — can't reshare yourself, and the swap // already triggers on isOwner. if posts[i].UserID != viewerID { ids = append(ids, posts[i].ID) } } if len(ids) > 0 { states, err := h.posts.ReshareStatesFor(r.Context(), viewerID, ids) if err != nil { mapSocialErr(w, err) return } for i := range posts { if states[posts[i].ID] { posts[i].ViewerHasReshared = true } } } } 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 // --------------------------------------------------------------------------- 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 }