feat: share post to other apps

This commit is contained in:
Krispire Admin
2026-05-31 08:05:02 +05:30
parent 05b61cef09
commit 0969887008
8 changed files with 313 additions and 14 deletions
+55 -2
View File
@@ -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))
}
+33
View File
@@ -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 {
+6
View File
@@ -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.
+56
View File
@@ -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
+26 -4
View File
@@ -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)
})
+110 -8
View File
@@ -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
}