feat: add user centric features
This commit is contained in:
@@ -0,0 +1,508 @@
|
|||||||
|
// 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
|
||||||
|
}
|
||||||
@@ -2,10 +2,12 @@ package handler
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/prabhupadasays/spsays-bff/internal/middleware"
|
"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/internal/service"
|
||||||
"github.com/prabhupadasays/spsays-bff/pkg/response"
|
"github.com/prabhupadasays/spsays-bff/pkg/response"
|
||||||
)
|
)
|
||||||
@@ -26,9 +28,10 @@ func NewUserHandler(svc *service.UserService) *UserHandler {
|
|||||||
type updateProfileReq struct {
|
type updateProfileReq struct {
|
||||||
Name *string `json:"name"`
|
Name *string `json:"name"`
|
||||||
AvatarURL *string `json:"avatar_url"`
|
AvatarURL *string `json:"avatar_url"`
|
||||||
|
Handle *string `json:"handle"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// UpdateProfile patches name and/or avatar.
|
// UpdateProfile patches name, avatar, and/or handle.
|
||||||
func (h *UserHandler) UpdateProfile(w http.ResponseWriter, r *http.Request) {
|
func (h *UserHandler) UpdateProfile(w http.ResponseWriter, r *http.Request) {
|
||||||
uid := middleware.UserID(r.Context())
|
uid := middleware.UserID(r.Context())
|
||||||
|
|
||||||
@@ -38,8 +41,12 @@ func (h *UserHandler) UpdateProfile(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
u, err := h.svc.UpdateProfile(r.Context(), uid, req.Name, req.AvatarURL)
|
u, err := h.svc.UpdateProfile(r.Context(), uid, req.Name, req.AvatarURL, req.Handle)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
if errors.Is(err, repository.ErrHandleTaken) {
|
||||||
|
response.Error(w, http.StatusConflict, "that handle is already in use")
|
||||||
|
return
|
||||||
|
}
|
||||||
response.Error(w, http.StatusBadRequest, err.Error())
|
response.Error(w, http.StatusBadRequest, err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,68 @@
|
|||||||
|
package model
|
||||||
|
|
||||||
|
import "time"
|
||||||
|
|
||||||
|
// Post is a public quote on a user's feed. Caption is the verbatim selected
|
||||||
|
// text from the knowledgebase; SourceURL links back to the page the user
|
||||||
|
// was reading. Images are loaded separately via PostImage rows.
|
||||||
|
type Post struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
UserID string `json:"user_id"`
|
||||||
|
Caption string `json:"caption"`
|
||||||
|
SourceURL string `json:"source_url"`
|
||||||
|
SourceTitle string `json:"source_title"`
|
||||||
|
Images []PostImage `json:"images"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
|
|
||||||
|
// Author info — populated by repo joins for feed listings.
|
||||||
|
AuthorName string `json:"author_name,omitempty"`
|
||||||
|
AuthorHandle string `json:"author_handle,omitempty"`
|
||||||
|
AuthorAvatarURL *string `json:"author_avatar_url,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// PostImage represents one attached image on a post.
|
||||||
|
type PostImage struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
URL string `json:"url"`
|
||||||
|
Position int `json:"position"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bookmark is a private "save my place" marker.
|
||||||
|
type Bookmark struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
UserID string `json:"user_id"`
|
||||||
|
SourceURL string `json:"source_url"`
|
||||||
|
SourceTitle string `json:"source_title"`
|
||||||
|
Excerpt *string `json:"excerpt,omitempty"`
|
||||||
|
Note *string `json:"note,omitempty"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// CollectionRecord is a user-curated group of posts.
|
||||||
|
//
|
||||||
|
// Renamed to avoid colliding with the legacy `Collection` model in user.go
|
||||||
|
// from when bookmarks/collections were sketched out earlier. Once that old
|
||||||
|
// model is unused we can drop it; for now this keeps both compiling.
|
||||||
|
type CollectionRecord struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
UserID string `json:"user_id"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
Description *string `json:"description,omitempty"`
|
||||||
|
CoverURL *string `json:"cover_url,omitempty"`
|
||||||
|
IsPublic bool `json:"is_public"`
|
||||||
|
ItemCount int `json:"item_count"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// PublicProfile is what /u/{handle} returns — the parts of a User safe to
|
||||||
|
// expose to anyone, plus aggregate counts and the user's posts.
|
||||||
|
type PublicProfile struct {
|
||||||
|
Handle string `json:"handle"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
AvatarURL *string `json:"avatar_url"`
|
||||||
|
JoinedAt time.Time `json:"joined_at"`
|
||||||
|
PostCount int `json:"post_count"`
|
||||||
|
BookmarkCount int `json:"-"` // never serialized — private
|
||||||
|
}
|
||||||
+2
-35
@@ -13,9 +13,11 @@ import "time"
|
|||||||
type User struct {
|
type User struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
Email string `json:"email"`
|
Email string `json:"email"`
|
||||||
|
Handle string `json:"handle"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
AvatarURL *string `json:"avatar_url"`
|
AvatarURL *string `json:"avatar_url"`
|
||||||
Role string `json:"role"`
|
Role string `json:"role"`
|
||||||
|
Plan string `json:"plan"` // "free" | "premium"
|
||||||
EmailVerifiedAt *time.Time `json:"email_verified_at"`
|
EmailVerifiedAt *time.Time `json:"email_verified_at"`
|
||||||
PasswordHash string `json:"-"`
|
PasswordHash string `json:"-"`
|
||||||
TokenVersion int `json:"-"`
|
TokenVersion int `json:"-"`
|
||||||
@@ -40,41 +42,6 @@ const (
|
|||||||
PurposeEmailVerification = "email_verification"
|
PurposeEmailVerification = "email_verification"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Bookmark represents a user's bookmarked content.
|
|
||||||
type Bookmark struct {
|
|
||||||
ID string `json:"id"`
|
|
||||||
UserID string `json:"user_id"`
|
|
||||||
ContentType string `json:"content_type"`
|
|
||||||
ContentID string `json:"content_id"`
|
|
||||||
ContentRef string `json:"content_ref"`
|
|
||||||
SelectedText *string `json:"selected_text,omitempty"`
|
|
||||||
Note *string `json:"note,omitempty"`
|
|
||||||
CreatedAt time.Time `json:"created_at"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// Collection represents a user-created collection.
|
|
||||||
type Collection struct {
|
|
||||||
ID string `json:"id"`
|
|
||||||
UserID string `json:"user_id"`
|
|
||||||
Name string `json:"name"`
|
|
||||||
Description *string `json:"description,omitempty"`
|
|
||||||
IsPublic bool `json:"is_public"`
|
|
||||||
ItemCount int `json:"item_count"`
|
|
||||||
CreatedAt time.Time `json:"created_at"`
|
|
||||||
UpdatedAt time.Time `json:"updated_at"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// Mention represents a mention of content shared by a user.
|
|
||||||
type Mention struct {
|
|
||||||
ID string `json:"id"`
|
|
||||||
UserID string `json:"user_id"`
|
|
||||||
ContentType string `json:"content_type"`
|
|
||||||
ContentID string `json:"content_id"`
|
|
||||||
ContentRef string `json:"content_ref"`
|
|
||||||
Text string `json:"text"`
|
|
||||||
CreatedAt time.Time `json:"created_at"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// ShortURL represents a shortened URL.
|
// ShortURL represents a shortened URL.
|
||||||
type ShortURL struct {
|
type ShortURL struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
|
|||||||
@@ -0,0 +1,96 @@
|
|||||||
|
package repository
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5"
|
||||||
|
"github.com/prabhupadasays/spsays-bff/internal/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
var ErrBookmarkNotFound = errors.New("bookmark not found")
|
||||||
|
var ErrBookmarkExists = errors.New("bookmark already exists for this URL")
|
||||||
|
|
||||||
|
type BookmarkRepo struct{ pg *Postgres }
|
||||||
|
|
||||||
|
func NewBookmarkRepo(pg *Postgres) *BookmarkRepo { return &BookmarkRepo{pg: pg} }
|
||||||
|
|
||||||
|
// Create inserts a new bookmark. ErrBookmarkExists on (user_id, source_url) collision.
|
||||||
|
func (r *BookmarkRepo) Create(ctx context.Context, userID, sourceURL, sourceTitle string, excerpt, note *string) (*model.Bookmark, error) {
|
||||||
|
b := &model.Bookmark{}
|
||||||
|
err := r.pg.Pool.QueryRow(ctx, `
|
||||||
|
INSERT INTO bookmarks (user_id, source_url, source_title, excerpt, note)
|
||||||
|
VALUES ($1, $2, $3, $4, $5)
|
||||||
|
RETURNING id, user_id, source_url, COALESCE(source_title, ''), excerpt, note, created_at`,
|
||||||
|
userID, sourceURL, sourceTitle, excerpt, note,
|
||||||
|
).Scan(&b.ID, &b.UserID, &b.SourceURL, &b.SourceTitle, &b.Excerpt, &b.Note, &b.CreatedAt)
|
||||||
|
if err != nil {
|
||||||
|
if strings.Contains(err.Error(), "23505") {
|
||||||
|
return nil, ErrBookmarkExists
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("create bookmark: %w", err)
|
||||||
|
}
|
||||||
|
return b, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListByUser returns all bookmarks for the caller, newest first.
|
||||||
|
func (r *BookmarkRepo) ListByUser(ctx context.Context, userID string) ([]model.Bookmark, error) {
|
||||||
|
rows, err := r.pg.Pool.Query(ctx, `
|
||||||
|
SELECT id, user_id, source_url, COALESCE(source_title, ''), excerpt, note, created_at
|
||||||
|
FROM bookmarks WHERE user_id = $1 ORDER BY created_at DESC`, userID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("query bookmarks: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
out := []model.Bookmark{}
|
||||||
|
for rows.Next() {
|
||||||
|
var b model.Bookmark
|
||||||
|
if err := rows.Scan(&b.ID, &b.UserID, &b.SourceURL, &b.SourceTitle,
|
||||||
|
&b.Excerpt, &b.Note, &b.CreatedAt); err != nil {
|
||||||
|
return nil, fmt.Errorf("scan bookmark: %w", err)
|
||||||
|
}
|
||||||
|
out = append(out, b)
|
||||||
|
}
|
||||||
|
return out, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// CountByUser is used by the plan-limit enforcer.
|
||||||
|
func (r *BookmarkRepo) CountByUser(ctx context.Context, userID string) (int, error) {
|
||||||
|
var n int
|
||||||
|
err := r.pg.Pool.QueryRow(ctx, `SELECT COUNT(*) FROM bookmarks WHERE user_id = $1`, userID).Scan(&n)
|
||||||
|
if err != nil {
|
||||||
|
return 0, fmt.Errorf("count bookmarks: %w", err)
|
||||||
|
}
|
||||||
|
return n, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete removes a bookmark belonging to the caller.
|
||||||
|
func (r *BookmarkRepo) Delete(ctx context.Context, id, userID string) error {
|
||||||
|
tag, err := r.pg.Pool.Exec(ctx,
|
||||||
|
`DELETE FROM bookmarks WHERE id = $1 AND user_id = $2`, id, userID)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("delete bookmark: %w", err)
|
||||||
|
}
|
||||||
|
if tag.RowsAffected() == 0 {
|
||||||
|
return ErrBookmarkNotFound
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// FindByID returns one bookmark (used for the owner-check path).
|
||||||
|
func (r *BookmarkRepo) FindByID(ctx context.Context, id string) (*model.Bookmark, error) {
|
||||||
|
b := &model.Bookmark{}
|
||||||
|
err := r.pg.Pool.QueryRow(ctx, `
|
||||||
|
SELECT id, user_id, source_url, COALESCE(source_title, ''), excerpt, note, created_at
|
||||||
|
FROM bookmarks WHERE id = $1`, id,
|
||||||
|
).Scan(&b.ID, &b.UserID, &b.SourceURL, &b.SourceTitle, &b.Excerpt, &b.Note, &b.CreatedAt)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
return nil, ErrBookmarkNotFound
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("find bookmark: %w", err)
|
||||||
|
}
|
||||||
|
return b, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,263 @@
|
|||||||
|
package repository
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5"
|
||||||
|
"github.com/prabhupadasays/spsays-bff/internal/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
var ErrCollectionNotFound = errors.New("collection not found")
|
||||||
|
|
||||||
|
type CollectionRepo struct{ pg *Postgres }
|
||||||
|
|
||||||
|
func NewCollectionRepo(pg *Postgres) *CollectionRepo { return &CollectionRepo{pg: pg} }
|
||||||
|
|
||||||
|
// Create inserts a new collection owned by userID.
|
||||||
|
func (r *CollectionRepo) Create(ctx context.Context, userID, title string, description, coverURL *string, isPublic bool) (*model.CollectionRecord, error) {
|
||||||
|
c := &model.CollectionRecord{}
|
||||||
|
err := r.pg.Pool.QueryRow(ctx, `
|
||||||
|
INSERT INTO collections (user_id, title, description, cover_url, is_public)
|
||||||
|
VALUES ($1, $2, $3, $4, $5)
|
||||||
|
RETURNING id, user_id, title, description, cover_url, is_public, item_count, created_at, updated_at`,
|
||||||
|
userID, title, description, coverURL, isPublic,
|
||||||
|
).Scan(&c.ID, &c.UserID, &c.Title, &c.Description, &c.CoverURL,
|
||||||
|
&c.IsPublic, &c.ItemCount, &c.CreatedAt, &c.UpdatedAt)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("create collection: %w", err)
|
||||||
|
}
|
||||||
|
return c, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// FindByID returns one collection by id (no owner check — caller decides
|
||||||
|
// what to do with the visibility flag).
|
||||||
|
func (r *CollectionRepo) FindByID(ctx context.Context, id string) (*model.CollectionRecord, error) {
|
||||||
|
c := &model.CollectionRecord{}
|
||||||
|
err := r.pg.Pool.QueryRow(ctx, `
|
||||||
|
SELECT id, user_id, title, description, cover_url, is_public, item_count, created_at, updated_at
|
||||||
|
FROM collections WHERE id = $1`, id,
|
||||||
|
).Scan(&c.ID, &c.UserID, &c.Title, &c.Description, &c.CoverURL,
|
||||||
|
&c.IsPublic, &c.ItemCount, &c.CreatedAt, &c.UpdatedAt)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
return nil, ErrCollectionNotFound
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("find collection: %w", err)
|
||||||
|
}
|
||||||
|
return c, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListByUser returns all collections for the caller (own listing).
|
||||||
|
func (r *CollectionRepo) ListByUser(ctx context.Context, userID string) ([]model.CollectionRecord, error) {
|
||||||
|
return r.list(ctx, `WHERE user_id = $1 ORDER BY updated_at DESC`, userID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListPublicByUser returns only the public collections for /u/<handle>.
|
||||||
|
func (r *CollectionRepo) ListPublicByUser(ctx context.Context, userID string) ([]model.CollectionRecord, error) {
|
||||||
|
return r.list(ctx, `WHERE user_id = $1 AND is_public = TRUE ORDER BY updated_at DESC`, userID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// CountByUserAndVisibility — used by the plan-limit enforcer
|
||||||
|
// (free: max 5 public, 5 private).
|
||||||
|
func (r *CollectionRepo) CountByUserAndVisibility(ctx context.Context, userID string, isPublic bool) (int, error) {
|
||||||
|
var n int
|
||||||
|
err := r.pg.Pool.QueryRow(ctx, `
|
||||||
|
SELECT COUNT(*) FROM collections WHERE user_id = $1 AND is_public = $2`,
|
||||||
|
userID, isPublic,
|
||||||
|
).Scan(&n)
|
||||||
|
if err != nil {
|
||||||
|
return 0, fmt.Errorf("count collections: %w", err)
|
||||||
|
}
|
||||||
|
return n, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update modifies title/description/cover/visibility. Nil pointers mean
|
||||||
|
// "leave unchanged"; isPublic is unconditional (use a pointer if needed).
|
||||||
|
func (r *CollectionRepo) Update(ctx context.Context, id, userID string, title, description, coverURL *string, isPublic *bool) (*model.CollectionRecord, error) {
|
||||||
|
const q = `
|
||||||
|
UPDATE collections
|
||||||
|
SET title = COALESCE($3, title),
|
||||||
|
description = COALESCE($4, description),
|
||||||
|
cover_url = CASE WHEN $5::boolean THEN $6 ELSE cover_url END,
|
||||||
|
is_public = COALESCE($7, is_public),
|
||||||
|
updated_at = now()
|
||||||
|
WHERE id = $1 AND user_id = $2
|
||||||
|
RETURNING id, user_id, title, description, cover_url, is_public, item_count, created_at, updated_at`
|
||||||
|
updateCover := coverURL != nil
|
||||||
|
var coverVal *string
|
||||||
|
if updateCover && *coverURL != "" {
|
||||||
|
coverVal = coverURL
|
||||||
|
}
|
||||||
|
|
||||||
|
c := &model.CollectionRecord{}
|
||||||
|
err := r.pg.Pool.QueryRow(ctx, q, id, userID, title, description, updateCover, coverVal, isPublic).
|
||||||
|
Scan(&c.ID, &c.UserID, &c.Title, &c.Description, &c.CoverURL,
|
||||||
|
&c.IsPublic, &c.ItemCount, &c.CreatedAt, &c.UpdatedAt)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
return nil, ErrCollectionNotFound
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("update collection: %w", err)
|
||||||
|
}
|
||||||
|
return c, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete removes a collection (cascades to collection_items via FK).
|
||||||
|
func (r *CollectionRepo) Delete(ctx context.Context, id, userID string) error {
|
||||||
|
tag, err := r.pg.Pool.Exec(ctx,
|
||||||
|
`DELETE FROM collections WHERE id = $1 AND user_id = $2`, id, userID)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("delete collection: %w", err)
|
||||||
|
}
|
||||||
|
if tag.RowsAffected() == 0 {
|
||||||
|
return ErrCollectionNotFound
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// AddItem inserts a post into a collection. Idempotent — ON CONFLICT.
|
||||||
|
func (r *CollectionRepo) AddItem(ctx context.Context, collectionID, postID string) error {
|
||||||
|
_, err := r.pg.Pool.Exec(ctx, `
|
||||||
|
INSERT INTO collection_items (collection_id, post_id, position)
|
||||||
|
VALUES ($1, $2, COALESCE(
|
||||||
|
(SELECT MAX(position) + 1 FROM collection_items WHERE collection_id = $1),
|
||||||
|
0
|
||||||
|
))
|
||||||
|
ON CONFLICT (collection_id, post_id) DO NOTHING`,
|
||||||
|
collectionID, postID,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("add collection item: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetItemOrder rewrites positions for the given posts so the new order
|
||||||
|
// matches the input array. Runs in a single transaction so concurrent
|
||||||
|
// reads see either the old order or the new one — never a half-applied
|
||||||
|
// state. Posts not in the input list are left untouched (they keep
|
||||||
|
// whatever position they had); callers should pass the full membership
|
||||||
|
// when they want a clean ordering.
|
||||||
|
func (r *CollectionRepo) SetItemOrder(ctx context.Context, collectionID string, postIDs []string) error {
|
||||||
|
if len(postIDs) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
tx, err := r.pg.Pool.Begin(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("begin tx: %w", err)
|
||||||
|
}
|
||||||
|
defer func() { _ = tx.Rollback(ctx) }()
|
||||||
|
|
||||||
|
for i, pid := range postIDs {
|
||||||
|
if _, err := tx.Exec(ctx, `
|
||||||
|
UPDATE collection_items SET position = $1
|
||||||
|
WHERE collection_id = $2 AND post_id = $3`,
|
||||||
|
i, collectionID, pid,
|
||||||
|
); err != nil {
|
||||||
|
return fmt.Errorf("set position %d: %w", i, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return tx.Commit(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
// RemoveItem deletes a post from a collection.
|
||||||
|
func (r *CollectionRepo) RemoveItem(ctx context.Context, collectionID, postID string) error {
|
||||||
|
_, err := r.pg.Pool.Exec(ctx, `
|
||||||
|
DELETE FROM collection_items WHERE collection_id = $1 AND post_id = $2`,
|
||||||
|
collectionID, postID,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("remove collection item: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CollectionsContaining returns the ids of collections owned by
|
||||||
|
// `userID` that already include `postID`. The drawer uses this to grey
|
||||||
|
// out rows the caller can't add the post to a second time.
|
||||||
|
func (r *CollectionRepo) CollectionsContaining(ctx context.Context, postID, userID string) ([]string, error) {
|
||||||
|
rows, err := r.pg.Pool.Query(ctx, `
|
||||||
|
SELECT ci.collection_id
|
||||||
|
FROM collection_items ci
|
||||||
|
JOIN collections c ON c.id = ci.collection_id
|
||||||
|
WHERE ci.post_id = $1 AND c.user_id = $2`,
|
||||||
|
postID, userID,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("collections containing post: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
ids := []string{}
|
||||||
|
for rows.Next() {
|
||||||
|
var id string
|
||||||
|
if err := rows.Scan(&id); err != nil {
|
||||||
|
return nil, fmt.Errorf("scan collection id: %w", err)
|
||||||
|
}
|
||||||
|
ids = append(ids, id)
|
||||||
|
}
|
||||||
|
return ids, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// PostsIn returns the posts (with images + author) inside a collection.
|
||||||
|
func (r *CollectionRepo) PostsIn(ctx context.Context, collectionID string) ([]model.Post, error) {
|
||||||
|
rows, err := r.pg.Pool.Query(ctx, `
|
||||||
|
SELECT p.id, p.user_id, p.caption, p.source_url, COALESCE(p.source_title, ''),
|
||||||
|
p.created_at, p.updated_at, u.name, u.handle, u.avatar_url
|
||||||
|
FROM collection_items ci
|
||||||
|
JOIN posts p ON p.id = ci.post_id
|
||||||
|
JOIN users u ON u.id = p.user_id
|
||||||
|
WHERE ci.collection_id = $1
|
||||||
|
ORDER BY ci.position, ci.added_at`,
|
||||||
|
collectionID,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("query collection posts: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
posts := []model.Post{}
|
||||||
|
ids := []string{}
|
||||||
|
for rows.Next() {
|
||||||
|
var p model.Post
|
||||||
|
if err := rows.Scan(
|
||||||
|
&p.ID, &p.UserID, &p.Caption, &p.SourceURL, &p.SourceTitle,
|
||||||
|
&p.CreatedAt, &p.UpdatedAt,
|
||||||
|
&p.AuthorName, &p.AuthorHandle, &p.AuthorAvatarURL,
|
||||||
|
); err != nil {
|
||||||
|
return nil, fmt.Errorf("scan post: %w", err)
|
||||||
|
}
|
||||||
|
posts = append(posts, p)
|
||||||
|
ids = append(ids, p.ID)
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, fmt.Errorf("iterate collection posts: %w", err)
|
||||||
|
}
|
||||||
|
// Defer image hydration to caller via PostRepo if needed; for v1 we just
|
||||||
|
// return without images here (the management page can fetch separately).
|
||||||
|
return posts, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- internal helpers ---
|
||||||
|
|
||||||
|
func (r *CollectionRepo) list(ctx context.Context, where string, args ...any) ([]model.CollectionRecord, error) {
|
||||||
|
rows, err := r.pg.Pool.Query(ctx, `
|
||||||
|
SELECT id, user_id, title, description, cover_url, is_public, item_count, created_at, updated_at
|
||||||
|
FROM collections `+where, args...,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("query collections: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
out := []model.CollectionRecord{}
|
||||||
|
for rows.Next() {
|
||||||
|
var c model.CollectionRecord
|
||||||
|
if err := rows.Scan(&c.ID, &c.UserID, &c.Title, &c.Description, &c.CoverURL,
|
||||||
|
&c.IsPublic, &c.ItemCount, &c.CreatedAt, &c.UpdatedAt); err != nil {
|
||||||
|
return nil, fmt.Errorf("scan collection: %w", err)
|
||||||
|
}
|
||||||
|
out = append(out, c)
|
||||||
|
}
|
||||||
|
return out, rows.Err()
|
||||||
|
}
|
||||||
@@ -0,0 +1,257 @@
|
|||||||
|
package repository
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5"
|
||||||
|
"github.com/prabhupadasays/spsays-bff/internal/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ErrPostNotFound is returned by lookup methods when no row matches.
|
||||||
|
var ErrPostNotFound = errors.New("post not found")
|
||||||
|
|
||||||
|
// PostRepo wraps Postgres access for posts + post_images.
|
||||||
|
type PostRepo struct{ pg *Postgres }
|
||||||
|
|
||||||
|
func NewPostRepo(pg *Postgres) *PostRepo { return &PostRepo{pg: pg} }
|
||||||
|
|
||||||
|
// Create inserts a post + its images in a single transaction so we never
|
||||||
|
// end up with orphan images.
|
||||||
|
func (r *PostRepo) Create(ctx context.Context, userID, caption, sourceURL, sourceTitle string, imageURLs []string) (*model.Post, error) {
|
||||||
|
tx, err := r.pg.Pool.Begin(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("begin tx: %w", err)
|
||||||
|
}
|
||||||
|
defer func() { _ = tx.Rollback(ctx) }()
|
||||||
|
|
||||||
|
p := &model.Post{}
|
||||||
|
err = tx.QueryRow(ctx, `
|
||||||
|
INSERT INTO posts (user_id, caption, source_url, source_title)
|
||||||
|
VALUES ($1, $2, $3, $4)
|
||||||
|
RETURNING id, user_id, caption, source_url, COALESCE(source_title, ''), created_at, updated_at`,
|
||||||
|
userID, caption, sourceURL, sourceTitle,
|
||||||
|
).Scan(&p.ID, &p.UserID, &p.Caption, &p.SourceURL, &p.SourceTitle, &p.CreatedAt, &p.UpdatedAt)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("insert post: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
for i, url := range imageURLs {
|
||||||
|
var img model.PostImage
|
||||||
|
err = tx.QueryRow(ctx, `
|
||||||
|
INSERT INTO post_images (post_id, url, position)
|
||||||
|
VALUES ($1, $2, $3)
|
||||||
|
RETURNING id, url, position`,
|
||||||
|
p.ID, url, i,
|
||||||
|
).Scan(&img.ID, &img.URL, &img.Position)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("insert post image %d: %w", i, err)
|
||||||
|
}
|
||||||
|
p.Images = append(p.Images, img)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := tx.Commit(ctx); err != nil {
|
||||||
|
return nil, fmt.Errorf("commit: %w", err)
|
||||||
|
}
|
||||||
|
return p, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReplaceImages atomically swaps a post's image set. Ownership is
|
||||||
|
// enforced inside the tx by locking the post row and checking user_id —
|
||||||
|
// non-owners get ErrPostNotFound (we don't distinguish "not yours" from
|
||||||
|
// "doesn't exist" to non-owners). Existing rows are wiped, the new URLs
|
||||||
|
// are inserted with sequential positions, and the post's `updated_at`
|
||||||
|
// is touched so list views can sort by recency if needed later.
|
||||||
|
func (r *PostRepo) ReplaceImages(ctx context.Context, postID, userID string, imageURLs []string) error {
|
||||||
|
tx, err := r.pg.Pool.Begin(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("begin tx: %w", err)
|
||||||
|
}
|
||||||
|
defer func() { _ = tx.Rollback(ctx) }()
|
||||||
|
|
||||||
|
var owner string
|
||||||
|
if err := tx.QueryRow(ctx, `SELECT user_id FROM posts WHERE id = $1 FOR UPDATE`, postID).Scan(&owner); err != nil {
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
return ErrPostNotFound
|
||||||
|
}
|
||||||
|
return fmt.Errorf("lock post: %w", err)
|
||||||
|
}
|
||||||
|
if owner != userID {
|
||||||
|
return ErrPostNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := tx.Exec(ctx, `DELETE FROM post_images WHERE post_id = $1`, postID); err != nil {
|
||||||
|
return fmt.Errorf("delete images: %w", err)
|
||||||
|
}
|
||||||
|
for i, url := range imageURLs {
|
||||||
|
if _, err := tx.Exec(ctx, `
|
||||||
|
INSERT INTO post_images (post_id, url, position)
|
||||||
|
VALUES ($1, $2, $3)`, postID, url, i,
|
||||||
|
); err != nil {
|
||||||
|
return fmt.Errorf("insert image %d: %w", i, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if _, err := tx.Exec(ctx, `UPDATE posts SET updated_at = now() WHERE id = $1`, postID); err != nil {
|
||||||
|
return fmt.Errorf("touch post: %w", err)
|
||||||
|
}
|
||||||
|
return tx.Commit(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
// FindByID returns one post with its images and author info.
|
||||||
|
func (r *PostRepo) FindByID(ctx context.Context, id string) (*model.Post, error) {
|
||||||
|
const q = `
|
||||||
|
SELECT p.id, p.user_id, p.caption, p.source_url, COALESCE(p.source_title, ''),
|
||||||
|
p.created_at, p.updated_at, u.name, u.handle, u.avatar_url
|
||||||
|
FROM posts p
|
||||||
|
JOIN users u ON u.id = p.user_id
|
||||||
|
WHERE p.id = $1`
|
||||||
|
p := &model.Post{}
|
||||||
|
err := r.pg.Pool.QueryRow(ctx, q, id).Scan(
|
||||||
|
&p.ID, &p.UserID, &p.Caption, &p.SourceURL, &p.SourceTitle,
|
||||||
|
&p.CreatedAt, &p.UpdatedAt,
|
||||||
|
&p.AuthorName, &p.AuthorHandle, &p.AuthorAvatarURL,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
return nil, ErrPostNotFound
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("find post: %w", err)
|
||||||
|
}
|
||||||
|
imgs, err := r.imagesFor(ctx, []string{p.ID})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
p.Images = imgs[p.ID]
|
||||||
|
return p, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListByHandle returns the public feed for a user, newest-first, paginated.
|
||||||
|
func (r *PostRepo) ListByHandle(ctx context.Context, handle string, limit, offset int) ([]model.Post, int, error) {
|
||||||
|
var total int
|
||||||
|
if err := r.pg.Pool.QueryRow(ctx, `
|
||||||
|
SELECT COUNT(*) FROM posts p
|
||||||
|
JOIN users u ON u.id = p.user_id
|
||||||
|
WHERE u.handle = $1`, handle,
|
||||||
|
).Scan(&total); err != nil {
|
||||||
|
return nil, 0, fmt.Errorf("count posts: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
const q = `
|
||||||
|
SELECT p.id, p.user_id, p.caption, p.source_url, COALESCE(p.source_title, ''),
|
||||||
|
p.created_at, p.updated_at, u.name, u.handle, u.avatar_url
|
||||||
|
FROM posts p
|
||||||
|
JOIN users u ON u.id = p.user_id
|
||||||
|
WHERE u.handle = $1
|
||||||
|
ORDER BY p.created_at DESC
|
||||||
|
LIMIT $2 OFFSET $3`
|
||||||
|
return r.collectPosts(ctx, total, q, handle, limit, offset)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListByUser returns the user's own posts (private listing — same shape).
|
||||||
|
func (r *PostRepo) ListByUser(ctx context.Context, userID string, limit, offset int) ([]model.Post, int, error) {
|
||||||
|
var total int
|
||||||
|
if err := r.pg.Pool.QueryRow(ctx, `SELECT COUNT(*) FROM posts WHERE user_id = $1`, userID).Scan(&total); err != nil {
|
||||||
|
return nil, 0, fmt.Errorf("count posts: %w", err)
|
||||||
|
}
|
||||||
|
const q = `
|
||||||
|
SELECT p.id, p.user_id, p.caption, p.source_url, COALESCE(p.source_title, ''),
|
||||||
|
p.created_at, p.updated_at, u.name, u.handle, u.avatar_url
|
||||||
|
FROM posts p
|
||||||
|
JOIN users u ON u.id = p.user_id
|
||||||
|
WHERE p.user_id = $1
|
||||||
|
ORDER BY p.created_at DESC
|
||||||
|
LIMIT $2 OFFSET $3`
|
||||||
|
return r.collectPosts(ctx, total, q, userID, limit, offset)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete removes a post only if it belongs to the caller.
|
||||||
|
func (r *PostRepo) Delete(ctx context.Context, postID, userID string) error {
|
||||||
|
tag, err := r.pg.Pool.Exec(ctx,
|
||||||
|
`DELETE FROM posts WHERE id = $1 AND user_id = $2`, postID, userID)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("delete post: %w", err)
|
||||||
|
}
|
||||||
|
if tag.RowsAffected() == 0 {
|
||||||
|
return ErrPostNotFound
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CountByUser is used by services that need to know how many posts a user
|
||||||
|
// has — currently the public-profile endpoint only.
|
||||||
|
func (r *PostRepo) CountByUser(ctx context.Context, userID string) (int, error) {
|
||||||
|
var n int
|
||||||
|
err := r.pg.Pool.QueryRow(ctx, `SELECT COUNT(*) FROM posts WHERE user_id = $1`, userID).Scan(&n)
|
||||||
|
if err != nil {
|
||||||
|
return 0, fmt.Errorf("count user posts: %w", err)
|
||||||
|
}
|
||||||
|
return n, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- internal helpers ---
|
||||||
|
|
||||||
|
func (r *PostRepo) collectPosts(ctx context.Context, total int, query string, args ...any) ([]model.Post, int, error) {
|
||||||
|
rows, err := r.pg.Pool.Query(ctx, query, args...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, 0, fmt.Errorf("query posts: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
posts := []model.Post{}
|
||||||
|
ids := []string{}
|
||||||
|
for rows.Next() {
|
||||||
|
var p model.Post
|
||||||
|
if err := rows.Scan(
|
||||||
|
&p.ID, &p.UserID, &p.Caption, &p.SourceURL, &p.SourceTitle,
|
||||||
|
&p.CreatedAt, &p.UpdatedAt,
|
||||||
|
&p.AuthorName, &p.AuthorHandle, &p.AuthorAvatarURL,
|
||||||
|
); err != nil {
|
||||||
|
return nil, 0, fmt.Errorf("scan post: %w", err)
|
||||||
|
}
|
||||||
|
posts = append(posts, p)
|
||||||
|
ids = append(ids, p.ID)
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, 0, fmt.Errorf("iterate posts: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(ids) > 0 {
|
||||||
|
imgs, err := r.imagesFor(ctx, ids)
|
||||||
|
if err != nil {
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
for i := range posts {
|
||||||
|
posts[i].Images = imgs[posts[i].ID]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return posts, total, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// imagesFor returns a map post_id → [images...] for a batch of post IDs in
|
||||||
|
// a single query so we don't N+1 the feed endpoint.
|
||||||
|
func (r *PostRepo) imagesFor(ctx context.Context, postIDs []string) (map[string][]model.PostImage, error) {
|
||||||
|
out := make(map[string][]model.PostImage, len(postIDs))
|
||||||
|
if len(postIDs) == 0 {
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
rows, err := r.pg.Pool.Query(ctx, `
|
||||||
|
SELECT post_id, id, url, position
|
||||||
|
FROM post_images
|
||||||
|
WHERE post_id = ANY($1)
|
||||||
|
ORDER BY post_id, position`, postIDs,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("query post images: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
for rows.Next() {
|
||||||
|
var postID string
|
||||||
|
var img model.PostImage
|
||||||
|
if err := rows.Scan(&postID, &img.ID, &img.URL, &img.Position); err != nil {
|
||||||
|
return nil, fmt.Errorf("scan post image: %w", err)
|
||||||
|
}
|
||||||
|
out[postID] = append(out[postID], img)
|
||||||
|
}
|
||||||
|
return out, rows.Err()
|
||||||
|
}
|
||||||
@@ -17,6 +17,9 @@ var ErrUserNotFound = errors.New("user not found")
|
|||||||
// ErrEmailTaken is returned by Create when the email already exists.
|
// ErrEmailTaken is returned by Create when the email already exists.
|
||||||
var ErrEmailTaken = errors.New("email already registered")
|
var ErrEmailTaken = errors.New("email already registered")
|
||||||
|
|
||||||
|
// ErrHandleTaken is returned when the chosen handle collides with an existing user.
|
||||||
|
var ErrHandleTaken = errors.New("handle already in use")
|
||||||
|
|
||||||
// UserRepo provides CRUD on the users table.
|
// UserRepo provides CRUD on the users table.
|
||||||
type UserRepo struct {
|
type UserRepo struct {
|
||||||
pg *Postgres
|
pg *Postgres
|
||||||
@@ -26,21 +29,25 @@ func NewUserRepo(pg *Postgres) *UserRepo {
|
|||||||
return &UserRepo{pg: pg}
|
return &UserRepo{pg: pg}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create inserts a new user. Returns ErrEmailTaken on unique-violation.
|
// Create inserts a new user. Returns ErrEmailTaken or ErrHandleTaken on
|
||||||
// `email` is lowercased before insert; the column is CITEXT so case doesn't
|
// unique-violation. The caller is responsible for picking a unique handle —
|
||||||
// matter for lookups, but lower-casing keeps stored values tidy.
|
// the auth service does this with a retry loop.
|
||||||
func (r *UserRepo) Create(ctx context.Context, email, passwordHash, name string) (*model.User, error) {
|
func (r *UserRepo) Create(ctx context.Context, email, passwordHash, name, handle string) (*model.User, error) {
|
||||||
const q = `
|
const q = `
|
||||||
INSERT INTO users (email, password_hash, name)
|
INSERT INTO users (email, password_hash, name, handle)
|
||||||
VALUES ($1, $2, $3)
|
VALUES ($1, $2, $3, $4)
|
||||||
RETURNING id, email, password_hash, name, avatar_url, role,
|
RETURNING id, email, handle, password_hash, name, avatar_url, role, plan,
|
||||||
email_verified_at, token_version, created_at, updated_at`
|
email_verified_at, token_version, created_at, updated_at`
|
||||||
|
|
||||||
u := &model.User{}
|
u := &model.User{}
|
||||||
err := r.pg.Pool.QueryRow(ctx, q, strings.ToLower(strings.TrimSpace(email)), passwordHash, name).
|
err := r.pg.Pool.QueryRow(ctx, q,
|
||||||
Scan(&u.ID, &u.Email, &u.PasswordHash, &u.Name, &u.AvatarURL, &u.Role,
|
strings.ToLower(strings.TrimSpace(email)), passwordHash, name, handle,
|
||||||
&u.EmailVerifiedAt, &u.TokenVersion, &u.CreatedAt, &u.UpdatedAt)
|
).Scan(&u.ID, &u.Email, &u.Handle, &u.PasswordHash, &u.Name, &u.AvatarURL, &u.Role, &u.Plan,
|
||||||
|
&u.EmailVerifiedAt, &u.TokenVersion, &u.CreatedAt, &u.UpdatedAt)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
if isUniqueViolation(err, "users_handle_key") {
|
||||||
|
return nil, ErrHandleTaken
|
||||||
|
}
|
||||||
if isUniqueViolation(err, "users_email_key") {
|
if isUniqueViolation(err, "users_email_key") {
|
||||||
return nil, ErrEmailTaken
|
return nil, ErrEmailTaken
|
||||||
}
|
}
|
||||||
@@ -60,15 +67,21 @@ func (r *UserRepo) FindByID(ctx context.Context, id string) (*model.User, error)
|
|||||||
return r.findOne(ctx, `WHERE id = $1`, id)
|
return r.findOne(ctx, `WHERE id = $1`, id)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// FindByHandle returns the user with the given handle (case-insensitive
|
||||||
|
// thanks to the citext column), or ErrUserNotFound.
|
||||||
|
func (r *UserRepo) FindByHandle(ctx context.Context, handle string) (*model.User, error) {
|
||||||
|
return r.findOne(ctx, `WHERE handle = $1`, strings.TrimSpace(handle))
|
||||||
|
}
|
||||||
|
|
||||||
func (r *UserRepo) findOne(ctx context.Context, where string, args ...any) (*model.User, error) {
|
func (r *UserRepo) findOne(ctx context.Context, where string, args ...any) (*model.User, error) {
|
||||||
q := `
|
q := `
|
||||||
SELECT id, email, password_hash, name, avatar_url, role,
|
SELECT id, email, handle, password_hash, name, avatar_url, role, plan,
|
||||||
email_verified_at, token_version, created_at, updated_at
|
email_verified_at, token_version, created_at, updated_at
|
||||||
FROM users ` + where + ` LIMIT 1`
|
FROM users ` + where + ` LIMIT 1`
|
||||||
|
|
||||||
u := &model.User{}
|
u := &model.User{}
|
||||||
err := r.pg.Pool.QueryRow(ctx, q, args...).
|
err := r.pg.Pool.QueryRow(ctx, q, args...).
|
||||||
Scan(&u.ID, &u.Email, &u.PasswordHash, &u.Name, &u.AvatarURL, &u.Role,
|
Scan(&u.ID, &u.Email, &u.Handle, &u.PasswordHash, &u.Name, &u.AvatarURL, &u.Role, &u.Plan,
|
||||||
&u.EmailVerifiedAt, &u.TokenVersion, &u.CreatedAt, &u.UpdatedAt)
|
&u.EmailVerifiedAt, &u.TokenVersion, &u.CreatedAt, &u.UpdatedAt)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, pgx.ErrNoRows) {
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
@@ -79,16 +92,20 @@ func (r *UserRepo) findOne(ctx context.Context, where string, args ...any) (*mod
|
|||||||
return u, nil
|
return u, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// UpdateProfile updates the user's name and/or avatar. Pass nil to leave a field unchanged.
|
// UpdateProfile updates the user's name, avatar, and/or handle. Pass nil to
|
||||||
// Returns the updated user.
|
// leave a field unchanged. Returns the updated user.
|
||||||
func (r *UserRepo) UpdateProfile(ctx context.Context, id string, name *string, avatarURL *string) (*model.User, error) {
|
//
|
||||||
|
// `handle` collisions surface as ErrHandleTaken so the handler can render
|
||||||
|
// a useful message.
|
||||||
|
func (r *UserRepo) UpdateProfile(ctx context.Context, id string, name, avatarURL, handle *string) (*model.User, error) {
|
||||||
const q = `
|
const q = `
|
||||||
UPDATE users
|
UPDATE users
|
||||||
SET name = COALESCE($2, name),
|
SET name = COALESCE($2, name),
|
||||||
avatar_url = CASE WHEN $3::boolean THEN $4 ELSE avatar_url END,
|
avatar_url = CASE WHEN $3::boolean THEN $4 ELSE avatar_url END,
|
||||||
|
handle = COALESCE($5, handle),
|
||||||
updated_at = now()
|
updated_at = now()
|
||||||
WHERE id = $1
|
WHERE id = $1
|
||||||
RETURNING id, email, password_hash, name, avatar_url, role,
|
RETURNING id, email, handle, password_hash, name, avatar_url, role, plan,
|
||||||
email_verified_at, token_version, created_at, updated_at`
|
email_verified_at, token_version, created_at, updated_at`
|
||||||
|
|
||||||
// $3 is a flag: "did the caller intend to set avatar_url (including to NULL)?"
|
// $3 is a flag: "did the caller intend to set avatar_url (including to NULL)?"
|
||||||
@@ -101,13 +118,16 @@ func (r *UserRepo) UpdateProfile(ctx context.Context, id string, name *string, a
|
|||||||
}
|
}
|
||||||
|
|
||||||
u := &model.User{}
|
u := &model.User{}
|
||||||
err := r.pg.Pool.QueryRow(ctx, q, id, name, updateAvatar, avatarVal).
|
err := r.pg.Pool.QueryRow(ctx, q, id, name, updateAvatar, avatarVal, handle).
|
||||||
Scan(&u.ID, &u.Email, &u.PasswordHash, &u.Name, &u.AvatarURL, &u.Role,
|
Scan(&u.ID, &u.Email, &u.Handle, &u.PasswordHash, &u.Name, &u.AvatarURL, &u.Role, &u.Plan,
|
||||||
&u.EmailVerifiedAt, &u.TokenVersion, &u.CreatedAt, &u.UpdatedAt)
|
&u.EmailVerifiedAt, &u.TokenVersion, &u.CreatedAt, &u.UpdatedAt)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, pgx.ErrNoRows) {
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
return nil, ErrUserNotFound
|
return nil, ErrUserNotFound
|
||||||
}
|
}
|
||||||
|
if isUniqueViolation(err, "users_handle_key") {
|
||||||
|
return nil, ErrHandleTaken
|
||||||
|
}
|
||||||
return nil, fmt.Errorf("update profile: %w", err)
|
return nil, fmt.Errorf("update profile: %w", err)
|
||||||
}
|
}
|
||||||
return u, nil
|
return u, nil
|
||||||
|
|||||||
@@ -46,6 +46,10 @@ type Server struct {
|
|||||||
shortURLH *handler.ShortURLHandler
|
shortURLH *handler.ShortURLHandler
|
||||||
authH *handler.AuthHandler
|
authH *handler.AuthHandler
|
||||||
userH *handler.UserHandler
|
userH *handler.UserHandler
|
||||||
|
postH *handler.PostHandler
|
||||||
|
bookmarkH *handler.BookmarkHandler
|
||||||
|
collectionH *handler.CollectionHandler
|
||||||
|
profileH *handler.ProfileHandler
|
||||||
|
|
||||||
// Auth state (nil when Postgres is unavailable — /auth and /users return 503)
|
// Auth state (nil when Postgres is unavailable — /auth and /users return 503)
|
||||||
authIssuer *auth.Issuer
|
authIssuer *auth.Issuer
|
||||||
@@ -142,12 +146,24 @@ func (s *Server) initAuth(cfg *config.Config, logger *zap.Logger) {
|
|||||||
authSvc := service.NewAuthService(userRepo, tokenRepo, issuer, mail, logger, cfg.FrontendURL)
|
authSvc := service.NewAuthService(userRepo, tokenRepo, issuer, mail, logger, cfg.FrontendURL)
|
||||||
userSvc := service.NewUserService(userRepo, r2)
|
userSvc := service.NewUserService(userRepo, r2)
|
||||||
|
|
||||||
|
// Social repositories + services share the same Postgres pool and R2 client.
|
||||||
|
postRepo := repository.NewPostRepo(s.pg)
|
||||||
|
bookmarkRepo := repository.NewBookmarkRepo(s.pg)
|
||||||
|
collectionRepo := repository.NewCollectionRepo(s.pg)
|
||||||
|
postSvc := service.NewPostService(postRepo, r2)
|
||||||
|
bookmarkSvc := service.NewBookmarkService(userRepo, bookmarkRepo)
|
||||||
|
collectionSvc := service.NewCollectionService(userRepo, collectionRepo, postRepo, r2)
|
||||||
|
|
||||||
cookieOpts := auth.CookieOptions{Domain: cfg.CookieDomain, Secure: cfg.CookieSecure}
|
cookieOpts := auth.CookieOptions{Domain: cfg.CookieDomain, Secure: cfg.CookieSecure}
|
||||||
|
|
||||||
s.authIssuer = issuer
|
s.authIssuer = issuer
|
||||||
s.userRepo = userRepo
|
s.userRepo = userRepo
|
||||||
s.authH = handler.NewAuthHandler(authSvc, cookieOpts)
|
s.authH = handler.NewAuthHandler(authSvc, cookieOpts)
|
||||||
s.userH = handler.NewUserHandler(userSvc)
|
s.userH = handler.NewUserHandler(userSvc)
|
||||||
|
s.postH = handler.NewPostHandler(postSvc)
|
||||||
|
s.bookmarkH = handler.NewBookmarkHandler(bookmarkSvc)
|
||||||
|
s.collectionH = handler.NewCollectionHandler(collectionSvc, postRepo, issuer, userRepo)
|
||||||
|
s.profileH = handler.NewProfileHandler(userRepo, postRepo, collectionRepo)
|
||||||
}
|
}
|
||||||
|
|
||||||
// initShortener wires Postgres + Redis + the shorturl handler/workers.
|
// initShortener wires Postgres + Redis + the shorturl handler/workers.
|
||||||
@@ -471,13 +487,70 @@ func (s *Server) setupRouter() {
|
|||||||
r.Post("/me/avatar-presign", s.userH.AvatarPresign)
|
r.Post("/me/avatar-presign", s.userH.AvatarPresign)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
r.Route("/collections", func(r chi.Router) {
|
|
||||||
// TODO: register collection/bookmark handlers
|
// --- Social: posts ---
|
||||||
})
|
r.Route("/posts", func(r chi.Router) {
|
||||||
r.Route("/mentions", func(r chi.Router) {
|
if s.postH == nil {
|
||||||
// TODO: register mention handlers
|
r.HandleFunc("/*", s.authUnavailable)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Public — read individual posts without auth.
|
||||||
|
r.Get("/{id}", s.postH.Get)
|
||||||
|
// Protected — create / delete / image presign / "my posts".
|
||||||
|
r.Group(func(r chi.Router) {
|
||||||
|
r.Use(middleware.RequireAuth(s.authIssuer, s.userRepo))
|
||||||
|
r.Post("/", s.postH.Create)
|
||||||
|
r.Delete("/{id}", s.postH.Delete)
|
||||||
|
r.Put("/{id}/images", s.postH.ReplaceImages)
|
||||||
|
r.Get("/mine", s.postH.ListMine)
|
||||||
|
r.Post("/image-presign", s.postH.ImagePresign)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// --- Social: bookmarks (all protected; bookmarks are private by design) ---
|
||||||
|
r.Route("/bookmarks", func(r chi.Router) {
|
||||||
|
if s.bookmarkH == nil {
|
||||||
|
r.HandleFunc("/*", s.authUnavailable)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
r.Use(middleware.RequireAuth(s.authIssuer, s.userRepo))
|
||||||
|
r.Post("/", s.bookmarkH.Create)
|
||||||
|
r.Get("/", s.bookmarkH.List)
|
||||||
|
r.Delete("/{id}", s.bookmarkH.Delete)
|
||||||
|
})
|
||||||
|
|
||||||
|
// --- Social: collections ---
|
||||||
|
// The single-collection GET is the only route that may be hit
|
||||||
|
// anonymously (public collections); everything else needs auth.
|
||||||
|
r.Route("/collections", func(r chi.Router) {
|
||||||
|
if s.collectionH == nil {
|
||||||
|
r.HandleFunc("/*", s.authUnavailable)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
r.Get("/{id}", s.collectionH.Get)
|
||||||
|
r.Group(func(r chi.Router) {
|
||||||
|
r.Use(middleware.RequireAuth(s.authIssuer, s.userRepo))
|
||||||
|
r.Post("/", s.collectionH.Create)
|
||||||
|
r.Get("/", s.collectionH.ListMine)
|
||||||
|
r.Patch("/{id}", s.collectionH.Update)
|
||||||
|
r.Delete("/{id}", s.collectionH.Delete)
|
||||||
|
r.Post("/{id}/items", s.collectionH.AddItem)
|
||||||
|
r.Put("/{id}/items/order", s.collectionH.ReorderItems)
|
||||||
|
r.Delete("/{id}/items/{post_id}", s.collectionH.RemoveItem)
|
||||||
|
r.Get("/containing/{post_id}", s.collectionH.ContainingPost)
|
||||||
|
r.Post("/cover-presign", s.collectionH.CoverPresign)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// --- Public profile + feed ---
|
||||||
|
r.Route("/u", func(r chi.Router) {
|
||||||
|
if s.profileH == nil {
|
||||||
|
r.HandleFunc("/*", s.authUnavailable)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
r.Get("/{handle}", s.profileH.Get)
|
||||||
|
r.Get("/{handle}/feed", s.profileH.Feed)
|
||||||
|
})
|
||||||
// URL Shortener — Postgres-backed, Redis-queued.
|
// URL Shortener — Postgres-backed, Redis-queued.
|
||||||
// The handler short-circuits to 503 if Postgres or Redis are unavailable.
|
// The handler short-circuits to 503 if Postgres or Redis are unavailable.
|
||||||
r.Route("/share", func(r chi.Router) {
|
r.Route("/share", func(r chi.Router) {
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ var (
|
|||||||
ErrInvalidOrExpiredLink = errors.New("link is invalid or has expired")
|
ErrInvalidOrExpiredLink = errors.New("link is invalid or has expired")
|
||||||
ErrEmailAlreadyVerified = errors.New("email already verified")
|
ErrEmailAlreadyVerified = errors.New("email already verified")
|
||||||
ErrWeakPassword = errors.New("password must be at least 8 characters")
|
ErrWeakPassword = errors.New("password must be at least 8 characters")
|
||||||
|
ErrHandleTaken = errors.New("handle already in use")
|
||||||
)
|
)
|
||||||
|
|
||||||
// AuthService bundles every flow that mutates auth state.
|
// AuthService bundles every flow that mutates auth state.
|
||||||
@@ -91,12 +92,29 @@ func (s *AuthService) SignUp(ctx context.Context, email, password, name string)
|
|||||||
return nil, fmt.Errorf("hash password: %w", err)
|
return nil, fmt.Errorf("hash password: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
u, err := s.users.Create(ctx, email, string(hash), strings.TrimSpace(name))
|
// Generate a handle and retry up to 8 times if it collides with an
|
||||||
if err != nil {
|
// existing user. The slug seed is derived from the email's local-part,
|
||||||
|
// which is the closest thing to a free-form identifier the user gave us.
|
||||||
|
base := slugForHandle(email)
|
||||||
|
var u *model.User
|
||||||
|
for attempt := 0; attempt < 8; attempt++ {
|
||||||
|
handle := base
|
||||||
|
if attempt > 0 {
|
||||||
|
handle = fmt.Sprintf("%s-%s", base, randomHandleSuffix())
|
||||||
|
}
|
||||||
|
u, err = s.users.Create(ctx, email, string(hash), strings.TrimSpace(name), handle)
|
||||||
|
if err == nil {
|
||||||
|
break
|
||||||
|
}
|
||||||
if errors.Is(err, repository.ErrEmailTaken) {
|
if errors.Is(err, repository.ErrEmailTaken) {
|
||||||
return nil, ErrEmailAlreadyTaken
|
return nil, ErrEmailAlreadyTaken
|
||||||
}
|
}
|
||||||
return nil, err
|
if !errors.Is(err, repository.ErrHandleTaken) {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("create user: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := s.sendVerificationEmail(ctx, u); err != nil {
|
if err := s.sendVerificationEmail(ctx, u); err != nil {
|
||||||
@@ -265,6 +283,43 @@ func newToken() (raw, hash string, err error) {
|
|||||||
return raw, hash, nil
|
return raw, hash, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// slugForHandle turns the local-part of an email into a kebab-case handle.
|
||||||
|
// Falls back to "user" for fully-stripped inputs so we always have a seed
|
||||||
|
// the retry loop can append a suffix to.
|
||||||
|
func slugForHandle(email string) string {
|
||||||
|
at := strings.IndexByte(email, '@')
|
||||||
|
local := email
|
||||||
|
if at >= 0 {
|
||||||
|
local = email[:at]
|
||||||
|
}
|
||||||
|
var b strings.Builder
|
||||||
|
b.Grow(len(local))
|
||||||
|
for _, r := range strings.ToLower(local) {
|
||||||
|
switch {
|
||||||
|
case r >= 'a' && r <= 'z', r >= '0' && r <= '9':
|
||||||
|
b.WriteRune(r)
|
||||||
|
case r == '.', r == '_', r == '-':
|
||||||
|
b.WriteByte('-')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out := strings.Trim(b.String(), "-")
|
||||||
|
if out == "" {
|
||||||
|
out = "user"
|
||||||
|
}
|
||||||
|
if len(out) > 32 {
|
||||||
|
out = out[:32]
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// randomHandleSuffix returns 5 hex characters (~20 bits of entropy) — plenty
|
||||||
|
// to disambiguate names that already exist in the user base.
|
||||||
|
func randomHandleSuffix() string {
|
||||||
|
b := make([]byte, 3)
|
||||||
|
_, _ = rand.Read(b)
|
||||||
|
return hex.EncodeToString(b)[:5]
|
||||||
|
}
|
||||||
|
|
||||||
func hashToken(raw string) string {
|
func hashToken(raw string) string {
|
||||||
sum := sha256.Sum256([]byte(raw))
|
sum := sha256.Sum256([]byte(raw))
|
||||||
return hex.EncodeToString(sum[:])
|
return hex.EncodeToString(sum[:])
|
||||||
|
|||||||
@@ -0,0 +1,87 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Plan caps for the social features. Updates here propagate everywhere via
|
||||||
|
// EnforceBookmarkLimit / EnforceCollectionLimit.
|
||||||
|
const (
|
||||||
|
PlanFree = "free"
|
||||||
|
PlanPremium = "premium"
|
||||||
|
|
||||||
|
freeBookmarkLimit = 1
|
||||||
|
premiumBookmarkLimit = 5
|
||||||
|
|
||||||
|
freeCollectionPerVisibilityLimit = 5 // 5 public AND 5 private separately
|
||||||
|
// Premium has no collection cap.
|
||||||
|
)
|
||||||
|
|
||||||
|
// ErrPlanLimit signals that a free-plan limit blocks the requested action.
|
||||||
|
// Handlers surface this as HTTP 402 (Payment Required) so clients can show
|
||||||
|
// an upsell. The message tells the user what cap was hit.
|
||||||
|
var ErrPlanLimit = errors.New("plan limit reached")
|
||||||
|
|
||||||
|
// PlanLimitError carries detail about which limit was hit so the frontend
|
||||||
|
// can render a helpful message instead of a generic "upgrade to premium".
|
||||||
|
type PlanLimitError struct {
|
||||||
|
Resource string // e.g. "bookmarks", "private collections"
|
||||||
|
Limit int
|
||||||
|
Plan string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *PlanLimitError) Error() string {
|
||||||
|
return fmt.Sprintf("%s limit reached (%d on %s plan)", e.Resource, e.Limit, e.Plan)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *PlanLimitError) Is(target error) bool { return target == ErrPlanLimit }
|
||||||
|
|
||||||
|
// EnforceBookmarkLimit returns nil if the user is allowed to create one more
|
||||||
|
// bookmark, or a PlanLimitError otherwise.
|
||||||
|
func EnforceBookmarkLimit(plan string, currentCount int) error {
|
||||||
|
limit := bookmarkLimitFor(plan)
|
||||||
|
if currentCount >= limit {
|
||||||
|
return &PlanLimitError{Resource: "bookmarks", Limit: limit, Plan: plan}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// EnforceCollectionLimit returns nil if the user is allowed to create one
|
||||||
|
// more collection of the given visibility.
|
||||||
|
//
|
||||||
|
// `isPublic=true` → checks against public-collection cap
|
||||||
|
// `isPublic=false` → checks against private-collection cap
|
||||||
|
// Premium users have no cap → always nil.
|
||||||
|
func EnforceCollectionLimit(plan string, isPublic bool, currentCount int) error {
|
||||||
|
if plan == PlanPremium {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
limit := freeCollectionPerVisibilityLimit
|
||||||
|
if currentCount >= limit {
|
||||||
|
resource := "private collections"
|
||||||
|
if isPublic {
|
||||||
|
resource = "public collections"
|
||||||
|
}
|
||||||
|
return &PlanLimitError{Resource: resource, Limit: limit, Plan: plan}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func bookmarkLimitFor(plan string) int {
|
||||||
|
if plan == PlanPremium {
|
||||||
|
return premiumBookmarkLimit
|
||||||
|
}
|
||||||
|
return freeBookmarkLimit
|
||||||
|
}
|
||||||
|
|
||||||
|
// BookmarkLimitFor returns the cap (for showing "X / Y used" in the UI).
|
||||||
|
func BookmarkLimitFor(plan string) int { return bookmarkLimitFor(plan) }
|
||||||
|
|
||||||
|
// CollectionLimitFor returns the per-visibility cap. -1 means unlimited.
|
||||||
|
func CollectionLimitFor(plan string) int {
|
||||||
|
if plan == PlanPremium {
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
return freeCollectionPerVisibilityLimit
|
||||||
|
}
|
||||||
@@ -0,0 +1,391 @@
|
|||||||
|
// Package service — social bits of the BFF.
|
||||||
|
//
|
||||||
|
// `social_service.go` bundles three lightweight services (Post, Bookmark,
|
||||||
|
// Collection) because each is small (10–40 LOC of logic) and they share the
|
||||||
|
// same R2 + UserRepo dependencies. Keeping them together keeps `server.go`'s
|
||||||
|
// init-wiring tidy without sprawling into 4 separate files.
|
||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/rand"
|
||||||
|
"encoding/hex"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"path"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/prabhupadasays/spsays-bff/internal/model"
|
||||||
|
"github.com/prabhupadasays/spsays-bff/internal/repository"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Sentinels surfaced to handlers.
|
||||||
|
var (
|
||||||
|
ErrForbidden = errors.New("forbidden")
|
||||||
|
ErrNotFound = errors.New("not found")
|
||||||
|
)
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// PostService
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
type PostService struct {
|
||||||
|
posts *repository.PostRepo
|
||||||
|
r2 *repository.R2
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewPostService(posts *repository.PostRepo, r2 *repository.R2) *PostService {
|
||||||
|
return &PostService{posts: posts, r2: r2}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create persists a new post. The caption is treated as opaque — we trust
|
||||||
|
// the frontend to send the exact text the user selected. Source URL must be
|
||||||
|
// a same-origin path (e.g. /books/bg/1/1?hl=…) — we strip the host if one
|
||||||
|
// was provided so links can't be hijacked.
|
||||||
|
func (s *PostService) Create(ctx context.Context, userID, caption, sourceURL, sourceTitle string, imageURLs []string) (*model.Post, error) {
|
||||||
|
caption = strings.TrimSpace(caption)
|
||||||
|
if caption == "" {
|
||||||
|
return nil, errors.New("caption is required")
|
||||||
|
}
|
||||||
|
if len(caption) > 4000 {
|
||||||
|
return nil, errors.New("caption too long (max 4000)")
|
||||||
|
}
|
||||||
|
sourceURL = stripHost(strings.TrimSpace(sourceURL))
|
||||||
|
if sourceURL == "" || !strings.HasPrefix(sourceURL, "/") {
|
||||||
|
return nil, errors.New("source_url must be an absolute path starting with /")
|
||||||
|
}
|
||||||
|
if len(imageURLs) > 5 {
|
||||||
|
return nil, errors.New("a post can hold up to 5 images")
|
||||||
|
}
|
||||||
|
return s.posts.Create(ctx, userID, caption, sourceURL, sourceTitle, imageURLs)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get returns one post (public).
|
||||||
|
func (s *PostService) Get(ctx context.Context, id string) (*model.Post, error) {
|
||||||
|
return s.posts.FindByID(ctx, id)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete checks ownership and removes the post.
|
||||||
|
func (s *PostService) Delete(ctx context.Context, postID, callerID string) error {
|
||||||
|
return s.posts.Delete(ctx, postID, callerID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReplaceImages swaps the image set on a post. The caller must own the
|
||||||
|
// post. Returns the fresh post payload so the client can reconcile its
|
||||||
|
// local state without a separate fetch.
|
||||||
|
func (s *PostService) ReplaceImages(ctx context.Context, postID, callerID string, imageURLs []string) (*model.Post, error) {
|
||||||
|
if err := s.posts.ReplaceImages(ctx, postID, callerID, imageURLs); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return s.posts.FindByID(ctx, postID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListByHandle is the public feed view.
|
||||||
|
func (s *PostService) ListByHandle(ctx context.Context, handle string, limit, offset int) ([]model.Post, int, error) {
|
||||||
|
if limit <= 0 || limit > 50 {
|
||||||
|
limit = 20
|
||||||
|
}
|
||||||
|
if offset < 0 {
|
||||||
|
offset = 0
|
||||||
|
}
|
||||||
|
return s.posts.ListByHandle(ctx, handle, limit, offset)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListMine is the owner's view of their own posts (drafts feature isn't a
|
||||||
|
// thing yet, so this is equivalent to ListByHandle minus the handle lookup).
|
||||||
|
func (s *PostService) ListMine(ctx context.Context, userID string, limit, offset int) ([]model.Post, int, error) {
|
||||||
|
if limit <= 0 || limit > 50 {
|
||||||
|
limit = 20
|
||||||
|
}
|
||||||
|
if offset < 0 {
|
||||||
|
offset = 0
|
||||||
|
}
|
||||||
|
return s.posts.ListByUser(ctx, userID, limit, offset)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ImagePresign returns a one-shot R2 PUT URL for a post image.
|
||||||
|
// The key is namespaced under post-images/<user_id>/<random>.<ext>. We
|
||||||
|
// don't tie it to a post_id at presign time because the post might not
|
||||||
|
// exist yet — the user uploads images first, then submits the post with
|
||||||
|
// the public URLs.
|
||||||
|
func (s *PostService) ImagePresign(ctx context.Context, userID, contentType string) (uploadURL, publicURL string, err error) {
|
||||||
|
return presignImage(ctx, s.r2, "post-images", userID, contentType)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// BookmarkService
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
type BookmarkService struct {
|
||||||
|
users *repository.UserRepo
|
||||||
|
bookmarks *repository.BookmarkRepo
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewBookmarkService(users *repository.UserRepo, bookmarks *repository.BookmarkRepo) *BookmarkService {
|
||||||
|
return &BookmarkService{users: users, bookmarks: bookmarks}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create enforces the plan cap and inserts a new bookmark.
|
||||||
|
//
|
||||||
|
// If the same URL is already bookmarked (UNIQUE constraint), we return the
|
||||||
|
// existing row as a 200 idempotent response instead of an error — clicking
|
||||||
|
// "Bookmark" twice on the same passage should feel like a no-op.
|
||||||
|
func (s *BookmarkService) Create(ctx context.Context, userID, sourceURL, sourceTitle string, excerpt, note *string) (*model.Bookmark, error) {
|
||||||
|
u, err := s.users.FindByID(ctx, userID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
current, err := s.bookmarks.CountByUser(ctx, userID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := EnforceBookmarkLimit(u.Plan, current); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
b, err := s.bookmarks.Create(ctx, userID, stripHost(sourceURL), sourceTitle, excerpt, note)
|
||||||
|
if err == nil {
|
||||||
|
return b, nil
|
||||||
|
}
|
||||||
|
if errors.Is(err, repository.ErrBookmarkExists) {
|
||||||
|
// Idempotent: return whatever is already there.
|
||||||
|
list, lerr := s.bookmarks.ListByUser(ctx, userID)
|
||||||
|
if lerr != nil {
|
||||||
|
return nil, lerr
|
||||||
|
}
|
||||||
|
for _, existing := range list {
|
||||||
|
if existing.SourceURL == stripHost(sourceURL) {
|
||||||
|
return &existing, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// List returns the caller's bookmarks.
|
||||||
|
func (s *BookmarkService) List(ctx context.Context, userID string) ([]model.Bookmark, error) {
|
||||||
|
return s.bookmarks.ListByUser(ctx, userID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete removes a bookmark belonging to the caller.
|
||||||
|
func (s *BookmarkService) Delete(ctx context.Context, id, userID string) error {
|
||||||
|
return s.bookmarks.Delete(ctx, id, userID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// CollectionService
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
type CollectionService struct {
|
||||||
|
users *repository.UserRepo
|
||||||
|
collections *repository.CollectionRepo
|
||||||
|
posts *repository.PostRepo
|
||||||
|
r2 *repository.R2
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewCollectionService(users *repository.UserRepo, collections *repository.CollectionRepo, posts *repository.PostRepo, r2 *repository.R2) *CollectionService {
|
||||||
|
return &CollectionService{users: users, collections: collections, posts: posts, r2: r2}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create enforces per-visibility plan caps and inserts.
|
||||||
|
func (s *CollectionService) Create(ctx context.Context, userID, title string, description, coverURL *string, isPublic bool) (*model.CollectionRecord, error) {
|
||||||
|
if title = strings.TrimSpace(title); title == "" {
|
||||||
|
return nil, errors.New("title is required")
|
||||||
|
}
|
||||||
|
if len(title) > 120 {
|
||||||
|
return nil, errors.New("title too long (max 120)")
|
||||||
|
}
|
||||||
|
u, err := s.users.FindByID(ctx, userID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
current, err := s.collections.CountByUserAndVisibility(ctx, userID, isPublic)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := EnforceCollectionLimit(u.Plan, isPublic, current); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return s.collections.Create(ctx, userID, title, description, coverURL, isPublic)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update — only the owner may edit. Visibility changes that would exceed
|
||||||
|
// the plan cap are blocked.
|
||||||
|
func (s *CollectionService) Update(ctx context.Context, id, userID string, title, description, coverURL *string, isPublic *bool) (*model.CollectionRecord, error) {
|
||||||
|
c, err := s.collections.FindByID(ctx, id)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if c.UserID != userID {
|
||||||
|
return nil, ErrForbidden
|
||||||
|
}
|
||||||
|
// If the caller is flipping visibility, make sure they wouldn't blow the cap.
|
||||||
|
if isPublic != nil && *isPublic != c.IsPublic {
|
||||||
|
u, err := s.users.FindByID(ctx, userID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
current, err := s.collections.CountByUserAndVisibility(ctx, userID, *isPublic)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := EnforceCollectionLimit(u.Plan, *isPublic, current); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return s.collections.Update(ctx, id, userID, title, description, coverURL, isPublic)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete — only the owner may delete.
|
||||||
|
func (s *CollectionService) Delete(ctx context.Context, id, userID string) error {
|
||||||
|
c, err := s.collections.FindByID(ctx, id)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if c.UserID != userID {
|
||||||
|
return ErrForbidden
|
||||||
|
}
|
||||||
|
return s.collections.Delete(ctx, id, userID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListMine returns the caller's collections (both public and private).
|
||||||
|
func (s *CollectionService) ListMine(ctx context.Context, userID string) ([]model.CollectionRecord, error) {
|
||||||
|
return s.collections.ListByUser(ctx, userID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// AddItem inserts a post into a collection. Caller must own the collection.
|
||||||
|
// We don't require ownership of the *post* — collections can curate other
|
||||||
|
// users' public posts in a future iteration; for v1 the toolbar always
|
||||||
|
// creates a fresh post owned by the caller before calling this.
|
||||||
|
func (s *CollectionService) AddItem(ctx context.Context, collectionID, postID, callerID string) error {
|
||||||
|
c, err := s.collections.FindByID(ctx, collectionID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if c.UserID != callerID {
|
||||||
|
return ErrForbidden
|
||||||
|
}
|
||||||
|
if _, err := s.posts.FindByID(ctx, postID); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return s.collections.AddItem(ctx, collectionID, postID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetItemOrder updates positions for the given post ids so the new order
|
||||||
|
// matches the input array. Owner-only.
|
||||||
|
func (s *CollectionService) SetItemOrder(ctx context.Context, collectionID, callerID string, postIDs []string) error {
|
||||||
|
c, err := s.collections.FindByID(ctx, collectionID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if c.UserID != callerID {
|
||||||
|
return ErrForbidden
|
||||||
|
}
|
||||||
|
return s.collections.SetItemOrder(ctx, collectionID, postIDs)
|
||||||
|
}
|
||||||
|
|
||||||
|
// CollectionsContaining returns the ids of the caller's collections
|
||||||
|
// that already include the given post. Used by the save-to-collection
|
||||||
|
// drawer to disable rows the post is already in.
|
||||||
|
func (s *CollectionService) CollectionsContaining(ctx context.Context, postID, callerID string) ([]string, error) {
|
||||||
|
return s.collections.CollectionsContaining(ctx, postID, callerID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// RemoveItem detaches a post from a collection (owner only).
|
||||||
|
func (s *CollectionService) RemoveItem(ctx context.Context, collectionID, postID, callerID string) error {
|
||||||
|
c, err := s.collections.FindByID(ctx, collectionID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if c.UserID != callerID {
|
||||||
|
return ErrForbidden
|
||||||
|
}
|
||||||
|
return s.collections.RemoveItem(ctx, collectionID, postID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get returns one collection. Visibility is enforced by the caller —
|
||||||
|
// `viewerID` is the calling user id ("" if anonymous). If the collection is
|
||||||
|
// private and the viewer isn't the owner, returns ErrForbidden.
|
||||||
|
func (s *CollectionService) Get(ctx context.Context, id, viewerID string) (*model.CollectionRecord, []model.Post, error) {
|
||||||
|
c, err := s.collections.FindByID(ctx, id)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
if !c.IsPublic && c.UserID != viewerID {
|
||||||
|
return nil, nil, ErrForbidden
|
||||||
|
}
|
||||||
|
posts, err := s.collections.PostsIn(ctx, id)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
return c, posts, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CoverPresign returns a one-shot R2 PUT URL for a collection cover image.
|
||||||
|
func (s *CollectionService) CoverPresign(ctx context.Context, userID, contentType string) (uploadURL, publicURL string, err error) {
|
||||||
|
return presignImage(ctx, s.r2, "collection-covers", userID, contentType)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// shared helpers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
// presignImage builds an R2 presign for a user-scoped media key under the
|
||||||
|
// given prefix. The random suffix invalidates browser caches when the image
|
||||||
|
// is replaced.
|
||||||
|
func presignImage(ctx context.Context, r2 *repository.R2, prefix, userID, contentType string) (uploadURL, publicURL string, err error) {
|
||||||
|
if r2 == nil {
|
||||||
|
return "", "", errors.New("media uploads are not configured")
|
||||||
|
}
|
||||||
|
ext, ok := imageExtensionFor(contentType)
|
||||||
|
if !ok {
|
||||||
|
return "", "", fmt.Errorf("unsupported content type: %s", contentType)
|
||||||
|
}
|
||||||
|
suffix := make([]byte, 8)
|
||||||
|
if _, err := rand.Read(suffix); err != nil {
|
||||||
|
return "", "", err
|
||||||
|
}
|
||||||
|
key := path.Join(prefix, userID, hex.EncodeToString(suffix)+ext)
|
||||||
|
return r2.PresignPut(ctx, key, contentType, 5*time.Minute)
|
||||||
|
}
|
||||||
|
|
||||||
|
func imageExtensionFor(ct string) (string, bool) {
|
||||||
|
switch ct {
|
||||||
|
case "image/png":
|
||||||
|
return ".png", true
|
||||||
|
case "image/jpeg":
|
||||||
|
return ".jpg", true
|
||||||
|
case "image/webp":
|
||||||
|
return ".webp", true
|
||||||
|
case "image/gif":
|
||||||
|
return ".gif", true
|
||||||
|
default:
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// stripHost reduces "https://prabhupadasays.org/books/bg/1/1?hl=x" to
|
||||||
|
// "/books/bg/1/1?hl=x" so we never store arbitrary external URLs.
|
||||||
|
// Anything that doesn't parse cleanly is returned unchanged (and rejected
|
||||||
|
// later by the prefix-check in the service).
|
||||||
|
func stripHost(u string) string {
|
||||||
|
u = strings.TrimSpace(u)
|
||||||
|
if u == "" {
|
||||||
|
return u
|
||||||
|
}
|
||||||
|
if strings.HasPrefix(u, "/") {
|
||||||
|
return u
|
||||||
|
}
|
||||||
|
for _, prefix := range []string{"http://", "https://"} {
|
||||||
|
if strings.HasPrefix(u, prefix) {
|
||||||
|
rest := u[len(prefix):]
|
||||||
|
if i := strings.IndexByte(rest, '/'); i >= 0 {
|
||||||
|
return rest[i:]
|
||||||
|
}
|
||||||
|
return "/"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return u
|
||||||
|
}
|
||||||
@@ -24,7 +24,7 @@ func NewUserService(users *repository.UserRepo, r2 *repository.R2) *UserService
|
|||||||
return &UserService{users: users, r2: r2}
|
return &UserService{users: users, r2: r2}
|
||||||
}
|
}
|
||||||
|
|
||||||
// UpdateProfile updates the user's name and/or avatar URL.
|
// UpdateProfile updates the user's name, avatar URL, and/or handle.
|
||||||
//
|
//
|
||||||
// `avatarURL` semantics:
|
// `avatarURL` semantics:
|
||||||
//
|
//
|
||||||
@@ -32,10 +32,10 @@ func NewUserService(users *repository.UserRepo, r2 *repository.R2) *UserService
|
|||||||
// "" → clear (remove avatar)
|
// "" → clear (remove avatar)
|
||||||
// "x" → set to "x"
|
// "x" → set to "x"
|
||||||
//
|
//
|
||||||
// The avatar URL is expected to point at our R2 bucket — we don't validate
|
// `handle` is normalized to the project's slug format before being passed
|
||||||
// the host because we don't want to break tests / local setups, but in
|
// to the DB so users can't sneak in spaces or punctuation. Empty after
|
||||||
// production you could add a check that it starts with R2_PUBLIC_URL.
|
// normalization → 400.
|
||||||
func (s *UserService) UpdateProfile(ctx context.Context, id string, name *string, avatarURL *string) (*model.User, error) {
|
func (s *UserService) UpdateProfile(ctx context.Context, id string, name, avatarURL, handle *string) (*model.User, error) {
|
||||||
if name != nil {
|
if name != nil {
|
||||||
trimmed := strings.TrimSpace(*name)
|
trimmed := strings.TrimSpace(*name)
|
||||||
if trimmed == "" {
|
if trimmed == "" {
|
||||||
@@ -46,7 +46,38 @@ func (s *UserService) UpdateProfile(ctx context.Context, id string, name *string
|
|||||||
}
|
}
|
||||||
name = &trimmed
|
name = &trimmed
|
||||||
}
|
}
|
||||||
return s.users.UpdateProfile(ctx, id, name, avatarURL)
|
if handle != nil {
|
||||||
|
clean := normalizeHandle(*handle)
|
||||||
|
if len(clean) < 3 {
|
||||||
|
return nil, errors.New("handle must be at least 3 characters (letters, digits, dashes)")
|
||||||
|
}
|
||||||
|
if len(clean) > 32 {
|
||||||
|
return nil, errors.New("handle too long (max 32)")
|
||||||
|
}
|
||||||
|
handle = &clean
|
||||||
|
}
|
||||||
|
return s.users.UpdateProfile(ctx, id, name, avatarURL, handle)
|
||||||
|
}
|
||||||
|
|
||||||
|
// normalizeHandle lowercases and strips anything that isn't [a-z0-9-]. Same
|
||||||
|
// rules as slugForHandle in auth_service, but exposed here because the
|
||||||
|
// profile-update path doesn't go through auth.
|
||||||
|
func normalizeHandle(raw string) string {
|
||||||
|
var b strings.Builder
|
||||||
|
for _, r := range strings.ToLower(strings.TrimSpace(raw)) {
|
||||||
|
switch {
|
||||||
|
case r >= 'a' && r <= 'z', r >= '0' && r <= '9':
|
||||||
|
b.WriteRune(r)
|
||||||
|
case r == '-', r == '.', r == '_':
|
||||||
|
b.WriteByte('-')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out := strings.Trim(b.String(), "-")
|
||||||
|
// Collapse runs of dashes.
|
||||||
|
for strings.Contains(out, "--") {
|
||||||
|
out = strings.ReplaceAll(out, "--", "-")
|
||||||
|
}
|
||||||
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
// AvatarPresign issues a one-shot presigned PUT URL the frontend uses to
|
// AvatarPresign issues a one-shot presigned PUT URL the frontend uses to
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
ALTER TABLE users DROP COLUMN IF EXISTS handle;
|
||||||
|
ALTER TABLE users DROP COLUMN IF EXISTS plan;
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
-- Adds the social-handle + subscription-plan columns to `users`.
|
||||||
|
--
|
||||||
|
-- handle — case-insensitive (citext) URL slug, e.g. /u/satya-das.
|
||||||
|
-- Auto-generated from the email-local part on signup; user can
|
||||||
|
-- change it later via PATCH /users/me. Unique across the table.
|
||||||
|
-- plan — 'free' (default) or 'premium'. Controls limits enforced at
|
||||||
|
-- the service layer (bookmarks count, collection counts).
|
||||||
|
-- No checkout flow yet — admin flips it with SQL.
|
||||||
|
ALTER TABLE users
|
||||||
|
ADD COLUMN IF NOT EXISTS handle CITEXT,
|
||||||
|
ADD COLUMN IF NOT EXISTS plan VARCHAR(16) NOT NULL DEFAULT 'free'
|
||||||
|
CHECK (plan IN ('free', 'premium'));
|
||||||
|
|
||||||
|
-- Backfill handles for any rows that already exist. The recipe mirrors the
|
||||||
|
-- generator the auth service uses: slugify the local-part of the email and
|
||||||
|
-- append a 6-char random suffix to make collisions vanishingly unlikely.
|
||||||
|
UPDATE users
|
||||||
|
SET handle = LOWER(REGEXP_REPLACE(SPLIT_PART(email::text, '@', 1), '[^a-z0-9]', '', 'g'))
|
||||||
|
|| '-' || substr(md5(id::text), 1, 6)
|
||||||
|
WHERE handle IS NULL;
|
||||||
|
|
||||||
|
ALTER TABLE users ALTER COLUMN handle SET NOT NULL;
|
||||||
|
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF NOT EXISTS (
|
||||||
|
SELECT 1 FROM pg_constraint WHERE conname = 'users_handle_key'
|
||||||
|
) THEN
|
||||||
|
ALTER TABLE users ADD CONSTRAINT users_handle_key UNIQUE (handle);
|
||||||
|
END IF;
|
||||||
|
END$$;
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
DROP TABLE IF EXISTS post_images;
|
||||||
|
DROP TABLE IF EXISTS posts;
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
-- Posts are public quotes pulled from the knowledgebase. Each carries the
|
||||||
|
-- selected text verbatim as its caption (immutable after create) plus a link
|
||||||
|
-- back to the source URL the user was reading. Optional images are stored in
|
||||||
|
-- the `post_images` table, one row per image, ordered by `position`.
|
||||||
|
--
|
||||||
|
-- Posts are always public — visibility is a per-user concern (the user's
|
||||||
|
-- /u/<handle> feed), not a per-post one. Private saves should use bookmarks.
|
||||||
|
CREATE TABLE IF NOT EXISTS posts (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
caption TEXT NOT NULL,
|
||||||
|
source_url TEXT NOT NULL,
|
||||||
|
source_title VARCHAR(200),
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS posts_user_created_idx
|
||||||
|
ON posts (user_id, created_at DESC);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS post_images (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
post_id UUID NOT NULL REFERENCES posts(id) ON DELETE CASCADE,
|
||||||
|
url TEXT NOT NULL,
|
||||||
|
position INT NOT NULL DEFAULT 0,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS post_images_post_idx
|
||||||
|
ON post_images (post_id, position);
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
DROP TABLE IF EXISTS bookmarks;
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
-- Bookmarks are always private — they're the user's "save my place" markers.
|
||||||
|
-- We don't store the full passage text, only the source URL + a short excerpt
|
||||||
|
-- so the bookmarks index can render meaningful previews without a separate
|
||||||
|
-- content fetch.
|
||||||
|
--
|
||||||
|
-- UNIQUE (user_id, source_url) means re-bookmarking the same passage is a
|
||||||
|
-- no-op rather than producing a duplicate; the service maps the unique
|
||||||
|
-- violation to a 200 idempotent response.
|
||||||
|
CREATE TABLE IF NOT EXISTS bookmarks (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
source_url TEXT NOT NULL,
|
||||||
|
source_title VARCHAR(200),
|
||||||
|
excerpt TEXT,
|
||||||
|
note TEXT,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
UNIQUE (user_id, source_url)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS bookmarks_user_created_idx
|
||||||
|
ON bookmarks (user_id, created_at DESC);
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
DROP TRIGGER IF EXISTS collection_items_count_trg ON collection_items;
|
||||||
|
DROP FUNCTION IF EXISTS sync_collection_item_count();
|
||||||
|
DROP TABLE IF EXISTS collection_items;
|
||||||
|
DROP TABLE IF EXISTS collections;
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
-- Collections group posts/quotes. They carry their own visibility flag —
|
||||||
|
-- a private collection holds posts that are themselves public on the user's
|
||||||
|
-- feed, but the collection page is only visible to the owner.
|
||||||
|
--
|
||||||
|
-- `item_count` is denormalized so listing pages don't need a join+count.
|
||||||
|
-- It's kept in sync by collection_items triggers below.
|
||||||
|
CREATE TABLE IF NOT EXISTS collections (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
title VARCHAR(120) NOT NULL,
|
||||||
|
description TEXT,
|
||||||
|
cover_url TEXT,
|
||||||
|
is_public BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
|
item_count INT NOT NULL DEFAULT 0,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS collections_user_created_idx
|
||||||
|
ON collections (user_id, created_at DESC);
|
||||||
|
|
||||||
|
-- Public-collection lookups by owner+visibility (for /u/<handle> page).
|
||||||
|
CREATE INDEX IF NOT EXISTS collections_user_public_idx
|
||||||
|
ON collections (user_id, is_public) WHERE is_public = TRUE;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS collection_items (
|
||||||
|
collection_id UUID NOT NULL REFERENCES collections(id) ON DELETE CASCADE,
|
||||||
|
post_id UUID NOT NULL REFERENCES posts(id) ON DELETE CASCADE,
|
||||||
|
position INT NOT NULL DEFAULT 0,
|
||||||
|
added_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
PRIMARY KEY (collection_id, post_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS collection_items_position_idx
|
||||||
|
ON collection_items (collection_id, position);
|
||||||
|
|
||||||
|
-- Keep item_count in sync.
|
||||||
|
CREATE OR REPLACE FUNCTION sync_collection_item_count() RETURNS TRIGGER AS $$
|
||||||
|
BEGIN
|
||||||
|
IF TG_OP = 'INSERT' THEN
|
||||||
|
UPDATE collections SET item_count = item_count + 1, updated_at = now()
|
||||||
|
WHERE id = NEW.collection_id;
|
||||||
|
ELSIF TG_OP = 'DELETE' THEN
|
||||||
|
UPDATE collections SET item_count = GREATEST(item_count - 1, 0), updated_at = now()
|
||||||
|
WHERE id = OLD.collection_id;
|
||||||
|
END IF;
|
||||||
|
RETURN NULL;
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql;
|
||||||
|
|
||||||
|
DROP TRIGGER IF EXISTS collection_items_count_trg ON collection_items;
|
||||||
|
CREATE TRIGGER collection_items_count_trg
|
||||||
|
AFTER INSERT OR DELETE ON collection_items
|
||||||
|
FOR EACH ROW EXECUTE FUNCTION sync_collection_item_count();
|
||||||
Reference in New Issue
Block a user