feat: share post to other apps
This commit is contained in:
@@ -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))
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user