From 09698870087250478a5bc0acd34eb28b77933d06 Mon Sep 17 00:00:00 2001 From: Krispire Admin Date: Sun, 31 May 2026 08:05:02 +0530 Subject: [PATCH] feat: share post to other apps --- internal/handler/social.go | 57 ++++++++- internal/middleware/auth.go | 33 +++++ internal/model/social.go | 6 + internal/repository/post_repo.go | 56 +++++++++ internal/server/server.go | 30 ++++- internal/service/social_service.go | 118 ++++++++++++++++-- migrations/0010_create_post_reshares.down.sql | 2 + migrations/0010_create_post_reshares.up.sql | 25 ++++ 8 files changed, 313 insertions(+), 14 deletions(-) create mode 100644 migrations/0010_create_post_reshares.down.sql create mode 100644 migrations/0010_create_post_reshares.up.sql diff --git a/internal/handler/social.go b/internal/handler/social.go index dd63817..718f011 100644 --- a/internal/handler/social.go +++ b/internal/handler/social.go @@ -59,9 +59,36 @@ func (h *PostHandler) Create(w http.ResponseWriter, r *http.Request) { response.JSON(w, http.StatusCreated, p) } +// Reshare records the caller as having reshared the post identified +// by {id}. Idempotent. Returns 204 on success; conflict for the +// "reshare your own post" case surfaces as 400 via mapSocialErr's +// fallthrough (errors.New wrapper in the service). +func (h *PostHandler) Reshare(w http.ResponseWriter, r *http.Request) { + uid := middleware.UserID(r.Context()) + id := chi.URLParam(r, "id") + if err := h.svc.Reshare(r.Context(), id, uid); err != nil { + mapSocialErr(w, err) + return + } + w.WriteHeader(http.StatusNoContent) +} + +// Unreshare removes a previously recorded reshare. Idempotent. +func (h *PostHandler) Unreshare(w http.ResponseWriter, r *http.Request) { + uid := middleware.UserID(r.Context()) + id := chi.URLParam(r, "id") + if err := h.svc.Unreshare(r.Context(), id, uid); err != nil { + mapSocialErr(w, err) + return + } + w.WriteHeader(http.StatusNoContent) +} + func (h *PostHandler) Get(w http.ResponseWriter, r *http.Request) { id := chi.URLParam(r, "id") - p, err := h.svc.Get(r.Context(), id) + // viewerID is "" for anonymous viewers — the service treats that as + // "no reshare hydration" so the public-cache shape is unchanged. + p, err := h.svc.Get(r.Context(), id, middleware.UserID(r.Context())) if err != nil { mapSocialErr(w, err) return @@ -420,7 +447,10 @@ func (h *ProfileHandler) Get(w http.ResponseWriter, r *http.Request) { } // Feed returns posts for the public feed. Same payload as ListMine but -// looked up by handle so it's reachable anonymously. +// looked up by handle so it's reachable anonymously. When an +// authenticated viewer hits this route (via OptionalAuth) each post +// carries `viewer_has_reshared` so the action footer renders the right +// button. Anonymous viewers get `false` for every post at zero cost. func (h *ProfileHandler) Feed(w http.ResponseWriter, r *http.Request) { handle := chi.URLParam(r, "handle") limit, offset := parseLimitOffset(r) @@ -429,6 +459,29 @@ func (h *ProfileHandler) Feed(w http.ResponseWriter, r *http.Request) { mapSocialErr(w, err) return } + viewerID := middleware.UserID(r.Context()) + if viewerID != "" && len(posts) > 0 { + ids := make([]string, 0, len(posts)) + for i := range posts { + // Skip own posts — can't reshare yourself, and the swap + // already triggers on isOwner. + if posts[i].UserID != viewerID { + ids = append(ids, posts[i].ID) + } + } + if len(ids) > 0 { + states, err := h.posts.ReshareStatesFor(r.Context(), viewerID, ids) + if err != nil { + mapSocialErr(w, err) + return + } + for i := range posts { + if states[posts[i].ID] { + posts[i].ViewerHasReshared = true + } + } + } + } response.JSONWithMeta(w, http.StatusOK, posts, paginationMeta(total, limit, offset)) } diff --git a/internal/middleware/auth.go b/internal/middleware/auth.go index ff840c4..6197d7e 100644 --- a/internal/middleware/auth.go +++ b/internal/middleware/auth.go @@ -61,6 +61,39 @@ func RequireAuth(issuer *auth.Issuer, users *repository.UserRepo) func(http.Hand } } +// OptionalAuth is the soft-auth sibling of RequireAuth. Same logic, but +// invalid / missing sessions don't reject the request — they just skip +// the context population. Use it on routes that are reachable +// anonymously but want to personalise the response when the viewer +// happens to be signed in (e.g. `viewer_has_reshared` on a public +// post). +func OptionalAuth(issuer *auth.Issuer, users *repository.UserRepo) func(http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + raw := auth.Read(r) + if raw == "" { + next.ServeHTTP(w, r) + return + } + claims, err := issuer.Parse(raw) + if err != nil { + next.ServeHTTP(w, r) + return + } + u, err := users.FindByID(r.Context(), claims.UserID) + if err != nil || u.TokenVersion != claims.TokenVersion { + // Stale or invalid session — proceed anonymously rather + // than failing the request. RequireAuth would 401 here. + next.ServeHTTP(w, r) + return + } + ctx := context.WithValue(r.Context(), userIDKey, claims.UserID) + ctx = context.WithValue(ctx, tokenVersionKey, claims.TokenVersion) + next.ServeHTTP(w, r.WithContext(ctx)) + }) + } +} + // UserID returns the authenticated user's id from the request context. // Returns "" if RequireAuth has not run. func UserID(ctx context.Context) string { diff --git a/internal/model/social.go b/internal/model/social.go index 636ffe4..aae40a3 100644 --- a/internal/model/social.go +++ b/internal/model/social.go @@ -19,6 +19,12 @@ type Post struct { AuthorName string `json:"author_name,omitempty"` AuthorHandle string `json:"author_handle,omitempty"` AuthorAvatarURL *string `json:"author_avatar_url,omitempty"` + + // ViewerHasReshared is true when the calling user has reshared this + // post. Hydrated by the service layer after the repo returns posts; + // always false for anonymous viewers (no viewer = no reshare state). + // Drives the post-card "Reshare ↔ Send" swap on the frontend. + ViewerHasReshared bool `json:"viewer_has_reshared"` } // PostImage represents one attached image on a post. diff --git a/internal/repository/post_repo.go b/internal/repository/post_repo.go index e30fafe..66bd526 100644 --- a/internal/repository/post_repo.go +++ b/internal/repository/post_repo.go @@ -228,6 +228,62 @@ func (r *PostRepo) collectPosts(ctx context.Context, total int, query string, ar return posts, total, nil } +// AddReshare records that `viewerID` reshared `postID`. Idempotent — +// repeated calls with the same pair are no-ops (the PRIMARY KEY makes +// the second insert a conflict, which we explicitly ignore). Returns +// nil on success regardless of whether a row was actually inserted. +func (r *PostRepo) AddReshare(ctx context.Context, postID, viewerID string) error { + const q = ` + INSERT INTO post_reshares (post_id, user_id) + VALUES ($1, $2) + ON CONFLICT (post_id, user_id) DO NOTHING` + if _, err := r.pg.Pool.Exec(ctx, q, postID, viewerID); err != nil { + return fmt.Errorf("add reshare: %w", err) + } + return nil +} + +// RemoveReshare deletes the reshare relationship. Idempotent — deleting +// a row that doesn't exist returns nil rather than ErrPostNotFound, +// since "I'm not subscribed" is the desired end state either way. +func (r *PostRepo) RemoveReshare(ctx context.Context, postID, viewerID string) error { + const q = `DELETE FROM post_reshares WHERE post_id = $1 AND user_id = $2` + if _, err := r.pg.Pool.Exec(ctx, q, postID, viewerID); err != nil { + return fmt.Errorf("remove reshare: %w", err) + } + return nil +} + +// ReshareStatesFor returns a set of post IDs that `viewerID` has +// reshared, filtered to the supplied `postIDs`. Same shape as +// ImagesFor — one batched query so a feed render doesn't N+1 the +// reshare table. Returns an empty map when `viewerID` is "" (anonymous +// viewer can't have reshared anything). +func (r *PostRepo) ReshareStatesFor(ctx context.Context, viewerID string, postIDs []string) (map[string]bool, error) { + out := make(map[string]bool, len(postIDs)) + if viewerID == "" || len(postIDs) == 0 { + return out, nil + } + rows, err := r.pg.Pool.Query(ctx, ` + SELECT post_id + FROM post_reshares + WHERE user_id = $1 AND post_id = ANY($2)`, + viewerID, postIDs, + ) + if err != nil { + return nil, fmt.Errorf("query reshare states: %w", err) + } + defer rows.Close() + for rows.Next() { + var id string + if err := rows.Scan(&id); err != nil { + return nil, fmt.Errorf("scan reshare state: %w", err) + } + out[id] = true + } + return out, rows.Err() +} + // 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. Exported because the // CollectionService needs to hydrate images onto posts that come back diff --git a/internal/server/server.go b/internal/server/server.go index ce0f19e..081b135 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -500,9 +500,15 @@ func (s *Server) setupRouter() { r.HandleFunc("/*", s.authUnavailable) return } - // Public — read individual posts without auth. - r.Get("/{id}", s.postH.Get) - // Protected — create / delete / image presign / "my posts". + // Public read — anonymous-OK but populates viewer_id when a + // session cookie is present, so the response can include + // per-viewer fields like `viewer_has_reshared`. + r.Group(func(r chi.Router) { + r.Use(middleware.OptionalAuth(s.authIssuer, s.userRepo)) + r.Get("/{id}", s.postH.Get) + }) + // Protected — create / delete / image presign / "my posts" / + // reshare actions. r.Group(func(r chi.Router) { r.Use(middleware.RequireAuth(s.authIssuer, s.userRepo)) r.Post("/", s.postH.Create) @@ -510,6 +516,11 @@ func (s *Server) setupRouter() { r.Put("/{id}/images", s.postH.ReplaceImages) r.Get("/mine", s.postH.ListMine) r.Post("/image-presign", s.postH.ImagePresign) + // Reshare is auth-required: anonymous users can't claim + // to have reshared anything. The action is idempotent so + // double-clicks don't 4xx. + r.Post("/{id}/reshare", s.postH.Reshare) + r.Delete("/{id}/reshare", s.postH.Unreshare) }) }) @@ -528,12 +539,18 @@ func (s *Server) setupRouter() { // --- Social: collections --- // The single-collection GET is the only route that may be hit // anonymously (public collections); everything else needs auth. + // OptionalAuth on the GET lets us populate `viewer_has_reshared` + // on each post in the collection's carousel when a viewer is + // signed in, without rejecting anonymous traffic. 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.OptionalAuth(s.authIssuer, s.userRepo)) + 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) @@ -549,11 +566,16 @@ func (s *Server) setupRouter() { }) // --- Public profile + feed --- + // OptionalAuth populates viewer_id when a session cookie is + // present, so the feed can carry `viewer_has_reshared` on each + // post. Without a cookie the routes still respond — they just + // return the anonymous shape (all-false). r.Route("/u", func(r chi.Router) { if s.profileH == nil { r.HandleFunc("/*", s.authUnavailable) return } + r.Use(middleware.OptionalAuth(s.authIssuer, s.userRepo)) r.Get("/{handle}", s.profileH.Get) r.Get("/{handle}/feed", s.profileH.Feed) }) diff --git a/internal/service/social_service.go b/internal/service/social_service.go index fde2175..3e9d10a 100644 --- a/internal/service/social_service.go +++ b/internal/service/social_service.go @@ -61,9 +61,88 @@ func (s *PostService) Create(ctx context.Context, userID, caption, sourceURL, so 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) +// Get returns one post (public). `viewerID` is "" for anonymous callers; +// when set, the returned post carries the viewer's reshare state so the +// frontend can render the right action button without a second request. +func (s *PostService) Get(ctx context.Context, id, viewerID string) (*model.Post, error) { + p, err := s.posts.FindByID(ctx, id) + if err != nil { + return nil, err + } + if err := s.hydrateReshareStates(ctx, viewerID, []*model.Post{p}); err != nil { + return nil, err + } + return p, nil +} + +// Reshare records that the caller has reshared the given post. Idempotent +// — calling it twice with the same (post, user) is a no-op. Resharing +// your own post is rejected: there's nothing to bring onto an account +// that already authored the post. +func (s *PostService) Reshare(ctx context.Context, postID, callerID string) error { + p, err := s.posts.FindByID(ctx, postID) + if err != nil { + return err + } + if p.UserID == callerID { + return errors.New("cannot reshare your own post") + } + return s.posts.AddReshare(ctx, postID, callerID) +} + +// Unreshare removes a previously recorded reshare. Idempotent — no-ops +// when no row exists. +func (s *PostService) Unreshare(ctx context.Context, postID, callerID string) error { + return s.posts.RemoveReshare(ctx, postID, callerID) +} + +// hydrateReshareStates fills in ViewerHasReshared on each post via one +// batched lookup against post_reshares. Skips the query when the viewer +// is anonymous (`viewerID == ""`) since the answer is uniformly false. +// Also fast-paths posts authored by the viewer — they can't be reshared +// by themselves, so we leave the flag at its zero value. +func (s *PostService) hydrateReshareStates(ctx context.Context, viewerID string, posts []*model.Post) error { + if viewerID == "" || len(posts) == 0 { + return nil + } + ids := make([]string, 0, len(posts)) + for _, p := range posts { + if p == nil || p.UserID == viewerID { + continue + } + ids = append(ids, p.ID) + } + if len(ids) == 0 { + return nil + } + states, err := s.posts.ReshareStatesFor(ctx, viewerID, ids) + if err != nil { + return err + } + for _, p := range posts { + if p == nil { + continue + } + if states[p.ID] { + p.ViewerHasReshared = true + } + } + return nil +} + +// hydrateReshareStatesSlice is the value-slice variant of +// hydrateReshareStates. Repository list methods return []model.Post +// (not pointers) — we wrap each element so the helper can mutate in +// place via a stable pointer. +func (s *PostService) hydrateReshareStatesSlice(ctx context.Context, viewerID string, posts []model.Post) error { + if len(posts) == 0 { + return nil + } + ptrs := make([]*model.Post, len(posts)) + for i := range posts { + ptrs[i] = &posts[i] + } + return s.hydrateReshareStates(ctx, viewerID, ptrs) } // Delete checks ownership and removes the post. @@ -81,19 +160,28 @@ func (s *PostService) ReplaceImages(ctx context.Context, postID, callerID string 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) { +// ListByHandle is the public feed view. `viewerID` is "" for anonymous +// callers; when set, each returned post carries the viewer's reshare +// state. +func (s *PostService) ListByHandle(ctx context.Context, handle, viewerID 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) + posts, total, err := s.posts.ListByHandle(ctx, handle, limit, offset) + if err != nil { + return nil, 0, err + } + if err := s.hydrateReshareStatesSlice(ctx, viewerID, posts); err != nil { + return nil, 0, err + } + return posts, total, nil } -// 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). +// ListMine is the owner's view of their own posts. Reshare hydration +// is skipped — the caller can't reshare their own posts. func (s *PostService) ListMine(ctx context.Context, userID string, limit, offset int) ([]model.Post, int, error) { if limit <= 0 || limit > 50 { limit = 20 @@ -336,6 +424,20 @@ func (s *CollectionService) Get(ctx context.Context, id, viewerID string) (*mode for i := range posts { posts[i].Images = imgs[posts[i].ID] } + // Same batched approach for reshare states so the action footer + // on each card swaps Reshare↔Send correctly. Anonymous viewers + // (viewerID == "") get all-false at zero cost. + if viewerID != "" { + states, err := s.posts.ReshareStatesFor(ctx, viewerID, ids) + if err != nil { + return nil, nil, err + } + for i := range posts { + if states[posts[i].ID] { + posts[i].ViewerHasReshared = true + } + } + } } return c, posts, nil } diff --git a/migrations/0010_create_post_reshares.down.sql b/migrations/0010_create_post_reshares.down.sql new file mode 100644 index 0000000..77ceff8 --- /dev/null +++ b/migrations/0010_create_post_reshares.down.sql @@ -0,0 +1,2 @@ +DROP INDEX IF EXISTS idx_post_reshares_user_created; +DROP TABLE IF EXISTS post_reshares; diff --git a/migrations/0010_create_post_reshares.up.sql b/migrations/0010_create_post_reshares.up.sql new file mode 100644 index 0000000..840ba20 --- /dev/null +++ b/migrations/0010_create_post_reshares.up.sql @@ -0,0 +1,25 @@ +-- Tracks which posts a user has reshared. Pure join table — no +-- denormalised counters, no duplicate post rows. The act of resharing +-- is a relationship between (post, user); the post itself stays in +-- one canonical row owned by its original author. +-- +-- This powers the post-card swap: posts a user has authored OR +-- reshared show the "Send" (external share) button instead of +-- "Reshare", because there's nothing left to bring onto the account. +-- +-- Composite PK on (post_id, user_id) makes the relationship unique +-- and gives us a covered lookup for the "did this viewer reshare this +-- post?" check used in batched feed hydration. +-- +-- Index on (user_id, created_at DESC) keeps "what has this user +-- reshared, newest first?" cheap for the day we surface that on the +-- profile feed. +CREATE TABLE IF NOT EXISTS post_reshares ( + post_id UUID NOT NULL REFERENCES posts(id) ON DELETE CASCADE, + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + PRIMARY KEY (post_id, user_id) +); + +CREATE INDEX IF NOT EXISTS idx_post_reshares_user_created + ON post_reshares (user_id, created_at DESC);