feat: add user centric features

This commit is contained in:
Krispire Admin
2026-05-30 23:43:27 +05:30
parent 97422c1ef5
commit aecdd1030a
21 changed files with 2037 additions and 69 deletions
+508
View File
@@ -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
}
+9 -2
View File
@@ -2,10 +2,12 @@ package handler
import (
"encoding/json"
"errors"
"net/http"
"strings"
"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/response"
)
@@ -26,9 +28,10 @@ func NewUserHandler(svc *service.UserService) *UserHandler {
type updateProfileReq struct {
Name *string `json:"name"`
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) {
uid := middleware.UserID(r.Context())
@@ -38,8 +41,12 @@ func (h *UserHandler) UpdateProfile(w http.ResponseWriter, r *http.Request) {
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 errors.Is(err, repository.ErrHandleTaken) {
response.Error(w, http.StatusConflict, "that handle is already in use")
return
}
response.Error(w, http.StatusBadRequest, err.Error())
return
}
+68
View File
@@ -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
View File
@@ -13,9 +13,11 @@ import "time"
type User struct {
ID string `json:"id"`
Email string `json:"email"`
Handle string `json:"handle"`
Name string `json:"name"`
AvatarURL *string `json:"avatar_url"`
Role string `json:"role"`
Plan string `json:"plan"` // "free" | "premium"
EmailVerifiedAt *time.Time `json:"email_verified_at"`
PasswordHash string `json:"-"`
TokenVersion int `json:"-"`
@@ -40,41 +42,6 @@ const (
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.
type ShortURL struct {
ID string `json:"id"`
+96
View File
@@ -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
}
+263
View File
@@ -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()
}
+257
View File
@@ -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()
}
+37 -17
View File
@@ -17,6 +17,9 @@ var ErrUserNotFound = errors.New("user not found")
// ErrEmailTaken is returned by Create when the email already exists.
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.
type UserRepo struct {
pg *Postgres
@@ -26,21 +29,25 @@ func NewUserRepo(pg *Postgres) *UserRepo {
return &UserRepo{pg: pg}
}
// Create inserts a new user. Returns ErrEmailTaken on unique-violation.
// `email` is lowercased before insert; the column is CITEXT so case doesn't
// matter for lookups, but lower-casing keeps stored values tidy.
func (r *UserRepo) Create(ctx context.Context, email, passwordHash, name string) (*model.User, error) {
// Create inserts a new user. Returns ErrEmailTaken or ErrHandleTaken on
// unique-violation. The caller is responsible for picking a unique handle —
// the auth service does this with a retry loop.
func (r *UserRepo) Create(ctx context.Context, email, passwordHash, name, handle string) (*model.User, error) {
const q = `
INSERT INTO users (email, password_hash, name)
VALUES ($1, $2, $3)
RETURNING id, email, password_hash, name, avatar_url, role,
INSERT INTO users (email, password_hash, name, handle)
VALUES ($1, $2, $3, $4)
RETURNING id, email, handle, password_hash, name, avatar_url, role, plan,
email_verified_at, token_version, created_at, updated_at`
u := &model.User{}
err := r.pg.Pool.QueryRow(ctx, q, strings.ToLower(strings.TrimSpace(email)), passwordHash, name).
Scan(&u.ID, &u.Email, &u.PasswordHash, &u.Name, &u.AvatarURL, &u.Role,
err := r.pg.Pool.QueryRow(ctx, q,
strings.ToLower(strings.TrimSpace(email)), passwordHash, name, handle,
).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 isUniqueViolation(err, "users_handle_key") {
return nil, ErrHandleTaken
}
if isUniqueViolation(err, "users_email_key") {
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)
}
// 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) {
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
FROM users ` + where + ` LIMIT 1`
u := &model.User{}
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)
if err != nil {
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
}
// UpdateProfile updates the user's name and/or avatar. Pass nil to leave a field unchanged.
// Returns the updated user.
func (r *UserRepo) UpdateProfile(ctx context.Context, id string, name *string, avatarURL *string) (*model.User, error) {
// UpdateProfile updates the user's name, avatar, and/or handle. Pass nil to
// leave a field unchanged. Returns the updated user.
//
// `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 = `
UPDATE users
SET name = COALESCE($2, name),
avatar_url = CASE WHEN $3::boolean THEN $4 ELSE avatar_url END,
handle = COALESCE($5, handle),
updated_at = now()
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`
// $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{}
err := r.pg.Pool.QueryRow(ctx, q, id, name, updateAvatar, avatarVal).
Scan(&u.ID, &u.Email, &u.PasswordHash, &u.Name, &u.AvatarURL, &u.Role,
err := r.pg.Pool.QueryRow(ctx, q, id, name, updateAvatar, avatarVal, handle).
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 errors.Is(err, pgx.ErrNoRows) {
return nil, ErrUserNotFound
}
if isUniqueViolation(err, "users_handle_key") {
return nil, ErrHandleTaken
}
return nil, fmt.Errorf("update profile: %w", err)
}
return u, nil
+77 -4
View File
@@ -46,6 +46,10 @@ type Server struct {
shortURLH *handler.ShortURLHandler
authH *handler.AuthHandler
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)
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)
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}
s.authIssuer = issuer
s.userRepo = userRepo
s.authH = handler.NewAuthHandler(authSvc, cookieOpts)
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.
@@ -471,13 +487,70 @@ func (s *Server) setupRouter() {
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) {
if s.postH == nil {
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)
})
r.Route("/mentions", func(r chi.Router) {
// TODO: register mention handlers
})
// --- 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.
// The handler short-circuits to 503 if Postgres or Redis are unavailable.
r.Route("/share", func(r chi.Router) {
+57 -2
View File
@@ -32,6 +32,7 @@ var (
ErrInvalidOrExpiredLink = errors.New("link is invalid or has expired")
ErrEmailAlreadyVerified = errors.New("email already verified")
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.
@@ -91,13 +92,30 @@ func (s *AuthService) SignUp(ctx context.Context, email, password, name string)
return nil, fmt.Errorf("hash password: %w", err)
}
u, err := s.users.Create(ctx, email, string(hash), strings.TrimSpace(name))
if err != nil {
// Generate a handle and retry up to 8 times if it collides with an
// 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) {
return nil, ErrEmailAlreadyTaken
}
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 {
// Log and keep going — signup itself succeeded.
@@ -265,6 +283,43 @@ func newToken() (raw, hash string, err error) {
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 {
sum := sha256.Sum256([]byte(raw))
return hex.EncodeToString(sum[:])
+87
View File
@@ -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
}
+391
View File
@@ -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
}
+37 -6
View File
@@ -24,7 +24,7 @@ func NewUserService(users *repository.UserRepo, r2 *repository.R2) *UserService
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:
//
@@ -32,10 +32,10 @@ func NewUserService(users *repository.UserRepo, r2 *repository.R2) *UserService
// "" → clear (remove avatar)
// "x" → set to "x"
//
// The avatar URL is expected to point at our R2 bucket — we don't validate
// the host because we don't want to break tests / local setups, but in
// production you could add a check that it starts with R2_PUBLIC_URL.
func (s *UserService) UpdateProfile(ctx context.Context, id string, name *string, avatarURL *string) (*model.User, error) {
// `handle` is normalized to the project's slug format before being passed
// to the DB so users can't sneak in spaces or punctuation. Empty after
// normalization → 400.
func (s *UserService) UpdateProfile(ctx context.Context, id string, name, avatarURL, handle *string) (*model.User, error) {
if name != nil {
trimmed := strings.TrimSpace(*name)
if trimmed == "" {
@@ -46,7 +46,38 @@ func (s *UserService) UpdateProfile(ctx context.Context, id string, name *string
}
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
@@ -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$$;
+2
View File
@@ -0,0 +1,2 @@
DROP TABLE IF EXISTS post_images;
DROP TABLE IF EXISTS posts;
+30
View File
@@ -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;
+21
View File
@@ -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;
+54
View File
@@ -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();