From aecdd1030a3c26dd4a0c8710f2b328ad5df227c6 Mon Sep 17 00:00:00 2001 From: Krispire Admin Date: Sat, 30 May 2026 23:43:27 +0530 Subject: [PATCH] feat: add user centric features --- internal/handler/social.go | 508 ++++++++++++++++++ internal/handler/user.go | 11 +- internal/model/social.go | 68 +++ internal/model/user.go | 37 +- internal/repository/bookmark_repo.go | 96 ++++ internal/repository/collection_repo.go | 263 +++++++++ internal/repository/post_repo.go | 257 +++++++++ internal/repository/user_repo.go | 56 +- internal/server/server.go | 83 ++- internal/service/auth_service.go | 61 ++- internal/service/plan.go | 87 +++ internal/service/social_service.go | 391 ++++++++++++++ internal/service/user_service.go | 43 +- .../0005_add_user_handle_and_plan.down.sql | 2 + .../0005_add_user_handle_and_plan.up.sql | 31 ++ migrations/0006_create_posts.down.sql | 2 + migrations/0006_create_posts.up.sql | 30 ++ migrations/0007_create_bookmarks.down.sql | 1 + migrations/0007_create_bookmarks.up.sql | 21 + migrations/0008_create_collections.down.sql | 4 + migrations/0008_create_collections.up.sql | 54 ++ 21 files changed, 2037 insertions(+), 69 deletions(-) create mode 100644 internal/handler/social.go create mode 100644 internal/model/social.go create mode 100644 internal/repository/bookmark_repo.go create mode 100644 internal/repository/collection_repo.go create mode 100644 internal/repository/post_repo.go create mode 100644 internal/service/plan.go create mode 100644 internal/service/social_service.go create mode 100644 migrations/0005_add_user_handle_and_plan.down.sql create mode 100644 migrations/0005_add_user_handle_and_plan.up.sql create mode 100644 migrations/0006_create_posts.down.sql create mode 100644 migrations/0006_create_posts.up.sql create mode 100644 migrations/0007_create_bookmarks.down.sql create mode 100644 migrations/0007_create_bookmarks.up.sql create mode 100644 migrations/0008_create_collections.down.sql create mode 100644 migrations/0008_create_collections.up.sql diff --git a/internal/handler/social.go b/internal/handler/social.go new file mode 100644 index 0000000..dd63817 --- /dev/null +++ b/internal/handler/social.go @@ -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 +} diff --git a/internal/handler/user.go b/internal/handler/user.go index 9692ece..e3927ca 100644 --- a/internal/handler/user.go +++ b/internal/handler/user.go @@ -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 } diff --git a/internal/model/social.go b/internal/model/social.go new file mode 100644 index 0000000..636ffe4 --- /dev/null +++ b/internal/model/social.go @@ -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 +} diff --git a/internal/model/user.go b/internal/model/user.go index 035961e..3acbccd 100644 --- a/internal/model/user.go +++ b/internal/model/user.go @@ -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"` diff --git a/internal/repository/bookmark_repo.go b/internal/repository/bookmark_repo.go new file mode 100644 index 0000000..53b2353 --- /dev/null +++ b/internal/repository/bookmark_repo.go @@ -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 +} diff --git a/internal/repository/collection_repo.go b/internal/repository/collection_repo.go new file mode 100644 index 0000000..4a4f018 --- /dev/null +++ b/internal/repository/collection_repo.go @@ -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/. +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() +} diff --git a/internal/repository/post_repo.go b/internal/repository/post_repo.go new file mode 100644 index 0000000..52bcd2c --- /dev/null +++ b/internal/repository/post_repo.go @@ -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() +} diff --git a/internal/repository/user_repo.go b/internal/repository/user_repo.go index 653b58c..0874dd3 100644 --- a/internal/repository/user_repo.go +++ b/internal/repository/user_repo.go @@ -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, - &u.EmailVerifiedAt, &u.TokenVersion, &u.CreatedAt, &u.UpdatedAt) + 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 diff --git a/internal/server/server.go b/internal/server/server.go index 0c03608..c8aa548 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -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 - }) - r.Route("/mentions", func(r chi.Router) { - // TODO: register mention 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) + }) }) + // --- 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) { diff --git a/internal/service/auth_service.go b/internal/service/auth_service.go index 134f791..e05eb5b 100644 --- a/internal/service/auth_service.go +++ b/internal/service/auth_service.go @@ -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,12 +92,29 @@ 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 } - 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 { @@ -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[:]) diff --git a/internal/service/plan.go b/internal/service/plan.go new file mode 100644 index 0000000..55f8653 --- /dev/null +++ b/internal/service/plan.go @@ -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 +} diff --git a/internal/service/social_service.go b/internal/service/social_service.go new file mode 100644 index 0000000..13507cd --- /dev/null +++ b/internal/service/social_service.go @@ -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//.. 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 +} diff --git a/internal/service/user_service.go b/internal/service/user_service.go index 4bf23f9..f4afdb2 100644 --- a/internal/service/user_service.go +++ b/internal/service/user_service.go @@ -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 diff --git a/migrations/0005_add_user_handle_and_plan.down.sql b/migrations/0005_add_user_handle_and_plan.down.sql new file mode 100644 index 0000000..91cef84 --- /dev/null +++ b/migrations/0005_add_user_handle_and_plan.down.sql @@ -0,0 +1,2 @@ +ALTER TABLE users DROP COLUMN IF EXISTS handle; +ALTER TABLE users DROP COLUMN IF EXISTS plan; diff --git a/migrations/0005_add_user_handle_and_plan.up.sql b/migrations/0005_add_user_handle_and_plan.up.sql new file mode 100644 index 0000000..82c98e0 --- /dev/null +++ b/migrations/0005_add_user_handle_and_plan.up.sql @@ -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$$; diff --git a/migrations/0006_create_posts.down.sql b/migrations/0006_create_posts.down.sql new file mode 100644 index 0000000..a9737bd --- /dev/null +++ b/migrations/0006_create_posts.down.sql @@ -0,0 +1,2 @@ +DROP TABLE IF EXISTS post_images; +DROP TABLE IF EXISTS posts; diff --git a/migrations/0006_create_posts.up.sql b/migrations/0006_create_posts.up.sql new file mode 100644 index 0000000..db32f79 --- /dev/null +++ b/migrations/0006_create_posts.up.sql @@ -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/ 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); diff --git a/migrations/0007_create_bookmarks.down.sql b/migrations/0007_create_bookmarks.down.sql new file mode 100644 index 0000000..66f2182 --- /dev/null +++ b/migrations/0007_create_bookmarks.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS bookmarks; diff --git a/migrations/0007_create_bookmarks.up.sql b/migrations/0007_create_bookmarks.up.sql new file mode 100644 index 0000000..6f6aaa8 --- /dev/null +++ b/migrations/0007_create_bookmarks.up.sql @@ -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); diff --git a/migrations/0008_create_collections.down.sql b/migrations/0008_create_collections.down.sql new file mode 100644 index 0000000..3517fef --- /dev/null +++ b/migrations/0008_create_collections.down.sql @@ -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; diff --git a/migrations/0008_create_collections.up.sql b/migrations/0008_create_collections.up.sql new file mode 100644 index 0000000..6ac213e --- /dev/null +++ b/migrations/0008_create_collections.up.sql @@ -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/ 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();