833 lines
27 KiB
Go
833 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
|
|
comments *repository.CommentRepo
|
|
}
|
|
|
|
func NewProfileHandler(users *repository.UserRepo, posts *repository.PostRepo, collections *repository.CollectionRepo, graph *service.SocialGraphService, likes *repository.LikeRepo, mentions *repository.MentionRepo, comments *repository.CommentRepo) *ProfileHandler {
|
|
return &ProfileHandler{users: users, posts: posts, collections: collections, graph: graph, likes: likes, mentions: mentions, comments: comments}
|
|
}
|
|
|
|
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
|
|
}
|
|
if err := h.mentions.HydrateCounts(r.Context(), ptrs); err != nil {
|
|
return err
|
|
}
|
|
return h.comments.HydratePinned(r.Context(), viewerID, 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.ErrCommentNotFound),
|
|
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, repository.ErrDuplicateComment):
|
|
response.Error(w, http.StatusConflict, "you've already posted this comment")
|
|
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
|
|
}
|