// 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/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 } func NewPostHandler(svc *service.PostService) *PostHandler { return &PostHandler{svc: svc} } 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) } func (h *PostHandler) Get(w http.ResponseWriter, r *http.Request) { id := chi.URLParam(r, "id") p, err := h.svc.Get(r.Context(), id) 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 } func NewProfileHandler(users *repository.UserRepo, posts *repository.PostRepo, collections *repository.CollectionRepo) *ProfileHandler { return &ProfileHandler{users: users, posts: posts, collections: collections} } 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 } response.JSON(w, http.StatusOK, map[string]any{ "handle": u.Handle, "name": u.Name, "avatar_url": u.AvatarURL, "joined_at": u.CreatedAt, "post_count": count, "public_collections": cols, }) } // Feed returns posts for the public feed. Same payload as ListMine but // looked up by handle so it's reachable anonymously. 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 } response.JSONWithMeta(w, http.StatusOK, posts, paginationMeta(total, limit, offset)) } // --------------------------------------------------------------------------- // 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.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 }