From 7f5b0fee1177a895e6903562f91d5723da6141a8 Mon Sep 17 00:00:00 2001 From: Krispire Admin Date: Wed, 3 Jun 2026 14:41:15 +0530 Subject: [PATCH] feat: post reshare enhancements --- internal/handler/social.go | 13 +- internal/model/social.go | 28 +- internal/repository/post_repo.go | 361 +++++++++++------- internal/repository/stats_repo.go | 2 +- internal/service/social_service.go | 55 ++- .../0014_create_post_reshare_images.down.sql | 1 + .../0014_create_post_reshare_images.up.sql | 35 ++ .../0015_reshares_are_real_posts.down.sql | 12 + .../0015_reshares_are_real_posts.up.sql | 80 ++++ 9 files changed, 397 insertions(+), 190 deletions(-) create mode 100644 migrations/0014_create_post_reshare_images.down.sql create mode 100644 migrations/0014_create_post_reshare_images.up.sql create mode 100644 migrations/0015_reshares_are_real_posts.down.sql create mode 100644 migrations/0015_reshares_are_real_posts.up.sql diff --git a/internal/handler/social.go b/internal/handler/social.go index 718f011..2bb52cb 100644 --- a/internal/handler/social.go +++ b/internal/handler/social.go @@ -59,18 +59,19 @@ 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). +// Reshare materialises a reshare of the post identified by {id} as a +// new posts row owned by the caller. Idempotent — second call returns +// the same row (still 201). The body is the new (or existing) post so +// the frontend can drop it into its local feed without a refetch. 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 { + p, err := h.svc.Reshare(r.Context(), id, uid) + if err != nil { mapSocialErr(w, err) return } - w.WriteHeader(http.StatusNoContent) + response.JSON(w, http.StatusCreated, p) } // Unreshare removes a previously recorded reshare. Idempotent. diff --git a/internal/model/social.go b/internal/model/social.go index 9e21347..c60e101 100644 --- a/internal/model/social.go +++ b/internal/model/social.go @@ -5,6 +5,11 @@ 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. +// +// Reshares are real Post rows owned by the resharer, with +// ResharedFromPostID pointing to the canonical original. Likes, comments, +// and images all hang off the post's own id, so they diverge naturally +// across reshare boundaries — no parallel "per-resharer" tables. type Post struct { ID string `json:"id"` UserID string `json:"user_id"` @@ -20,19 +25,20 @@ type Post struct { 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). + // Reshare attribution. ResharedFromPostID is non-nil when this post + // is a reshare; the four `Original*` fields carry the canonical + // post's author info so the frontend can render a + // "Reshared from @handle" link without an extra round-trip. + ResharedFromPostID *string `json:"reshared_from_post_id,omitempty"` + OriginalAuthorName *string `json:"original_author_name,omitempty"` + OriginalAuthorHandle *string `json:"original_author_handle,omitempty"` + OriginalAuthorAvatar *string `json:"original_author_avatar_url,omitempty"` + + // ViewerHasReshared is true when the calling user has reshared the + // canonical version of this post. Hydrated by the service layer + // after the repo returns posts; always false for anonymous viewers. // Drives the post-card "Reshare ↔ Send" swap on the frontend. ViewerHasReshared bool `json:"viewer_has_reshared"` - - // ResharedAt is the timestamp at which the feed owner reshared this - // post. Non-nil only for rows that appear in a profile feed because - // the profile owner reshared them (i.e. the row comes from the - // post_reshares union branch of ListByUser / ListByHandle). - // Authored-by-feed-owner rows leave this nil. Drives the - // "↻ Reshared" badge in the post card. - ResharedAt *time.Time `json:"reshared_at,omitempty"` } // PostImage represents one attached image on a post. diff --git a/internal/repository/post_repo.go b/internal/repository/post_repo.go index 8452f84..824c508 100644 --- a/internal/repository/post_repo.go +++ b/internal/repository/post_repo.go @@ -13,10 +13,53 @@ import ( var ErrPostNotFound = errors.New("post not found") // PostRepo wraps Postgres access for posts + post_images. +// +// Reshare model: a reshare is a real `posts` row owned by the resharer +// with `reshared_from_post_id` pointing at the canonical original. Likes +// and comments (future) will hang off post.id, so they diverge between +// original and reshare for free. Backgrounds are per-row in post_images, +// same story. The only thing the original and its reshares share is the +// caption / source_url / source_title at the moment of reshare. type PostRepo struct{ pg *Postgres } func NewPostRepo(pg *Postgres) *PostRepo { return &PostRepo{pg: pg} } +// postSelectColumns is the canonical column list used by every reader so +// every Post payload carries the same shape — including reshare +// attribution. The LEFT JOINs to `original_post` + `original_user` are +// always present; for canonical (non-reshare) rows they just yield NULL. +const postSelectColumns = ` + 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, + p.reshared_from_post_id, + ou.name AS original_author_name, + ou.handle AS original_author_handle, + ou.avatar_url AS original_author_avatar +` + +const postSelectFrom = ` + FROM posts p + JOIN users u ON u.id = p.user_id + LEFT JOIN posts op ON op.id = p.reshared_from_post_id + LEFT JOIN users ou ON ou.id = op.user_id +` + +// scanPost decodes one row produced by `postSelectColumns` into the +// destination Post. Single source of truth for the column → struct +// mapping, used by every reader. +func scanPost(row interface { + Scan(dest ...any) error +}, p *model.Post) error { + return row.Scan( + &p.ID, &p.UserID, &p.Caption, &p.SourceURL, &p.SourceTitle, + &p.CreatedAt, &p.UpdatedAt, + &p.AuthorName, &p.AuthorHandle, &p.AuthorAvatarURL, + &p.ResharedFromPostID, + &p.OriginalAuthorName, &p.OriginalAuthorHandle, &p.OriginalAuthorAvatar, + ) +} + // 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) { @@ -57,12 +100,9 @@ func (r *PostRepo) Create(ctx context.Context, userID, caption, sourceURL, sourc 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. +// ReplaceImages atomically swaps a post's image set. Owner-only — every +// reshare is its own posts row now, so the resharer is the owner of +// their reshare and edits it just like any other post they made. func (r *PostRepo) ReplaceImages(ctx context.Context, postID, userID string, imageURLs []string) error { tx, err := r.pg.Pool.Begin(ctx) if err != nil { @@ -98,21 +138,12 @@ func (r *PostRepo) ReplaceImages(ctx context.Context, postID, userID string, ima return tx.Commit(ctx) } -// FindByID returns one post with its images and author info. +// FindByID returns one post with its images and author info (plus original +// author info if this row is a reshare). 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` + q := `SELECT ` + postSelectColumns + postSelectFrom + `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 err := scanPost(r.pg.Pool.QueryRow(ctx, q, id), p); err != nil { if errors.Is(err, pgx.ErrNoRows) { return nil, ErrPostNotFound } @@ -126,12 +157,8 @@ func (r *PostRepo) FindByID(ctx context.Context, id string) (*model.Post, error) return p, nil } -// ListByHandle returns the profile feed for a user — authored posts -// plus posts the user reshared — newest-first, paginated. Resolves -// handle → user_id and then delegates to ListByUser so both code paths -// produce the same shape (and so the union query lives in one place). -// Unknown handles return an empty page (not an error) — that matches -// the pre-reshare behaviour. +// ListByHandle returns the profile feed for a user. Unknown handle → +// empty page (matches pre-existing behaviour). func (r *PostRepo) ListByHandle(ctx context.Context, handle string, limit, offset int) ([]model.Post, int, error) { var userID string if err := r.pg.Pool.QueryRow(ctx, `SELECT id FROM users WHERE handle = $1`, handle).Scan(&userID); err != nil { @@ -143,56 +170,26 @@ func (r *PostRepo) ListByHandle(ctx context.Context, handle string, limit, offse return r.ListByUser(ctx, userID, limit, offset) } -// ListByUser returns the user's profile feed — authored posts plus -// posts they reshared. Each row carries a reshared_at timestamp that -// is NULL for authored rows and equal to post_reshares.created_at for -// reshared rows; rows are sorted by COALESCE(reshared_at, created_at) -// so fresh reshares of old posts surface at the top, matching the -// Twitter/X model. -// -// `total` is the union count so pagination spans the combined feed. +// ListByUser returns the user's profile feed — every post they own, +// authored or reshared. Reshares are interleaved naturally because +// they're stored as real rows; sort by created_at gets the right order +// without any UNION gymnastics. 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 - (SELECT COUNT(*) FROM posts WHERE user_id = $1) - + (SELECT COUNT(*) FROM post_reshares WHERE user_id = $1)`, - userID, - ).Scan(&total); err != nil { - return nil, 0, fmt.Errorf("count feed: %w", err) + total, err := r.CountByUser(ctx, userID) + if err != nil { + return nil, 0, err } - const q = ` - SELECT id, user_id, caption, source_url, source_title, - created_at, updated_at, author_name, author_handle, - author_avatar_url, reshared_at - FROM ( - SELECT p.id, p.user_id, p.caption, p.source_url, - COALESCE(p.source_title, '') AS source_title, - p.created_at, p.updated_at, - u.name AS author_name, u.handle AS author_handle, - u.avatar_url AS author_avatar_url, - NULL::timestamptz AS reshared_at - FROM posts p - JOIN users u ON u.id = p.user_id - WHERE p.user_id = $1 - UNION ALL - SELECT p.id, p.user_id, p.caption, p.source_url, - COALESCE(p.source_title, '') AS source_title, - p.created_at, p.updated_at, - u.name AS author_name, u.handle AS author_handle, - u.avatar_url AS author_avatar_url, - r.created_at AS reshared_at - FROM post_reshares r - JOIN posts p ON p.id = r.post_id - JOIN users u ON u.id = p.user_id - WHERE r.user_id = $1 - ) feed - ORDER BY COALESCE(reshared_at, created_at) DESC, id + q := `SELECT ` + postSelectColumns + postSelectFrom + ` + WHERE p.user_id = $1 + ORDER BY p.created_at DESC, p.id LIMIT $2 OFFSET $3` return r.collectPosts(ctx, total, q, userID, limit, offset) } -// Delete removes a post only if it belongs to the caller. +// Delete removes a post only if it belongs to the caller. Cascades: +// - the post's images (post_images.post_id FK) +// - any reshares of this post made by other users (posts.reshared_from_post_id FK) +// - those reshares' images (post_images.post_id FK on each reshare) 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) @@ -205,24 +202,167 @@ func (r *PostRepo) Delete(ctx context.Context, postID, userID string) error { return nil } -// CountByUser returns the profile-feed total — authored + reshared — -// which is what the profile header's "N posts" badge displays. Stays -// in sync with ListByUser's total so the badge never disagrees with -// the page contents below it. +// CountByUser returns the user's total post count (authored + reshared). +// Now just a single COUNT against posts.user_id. func (r *PostRepo) CountByUser(ctx context.Context, userID string) (int, error) { var n int - err := r.pg.Pool.QueryRow(ctx, ` - SELECT - (SELECT COUNT(*) FROM posts WHERE user_id = $1) - + (SELECT COUNT(*) FROM post_reshares WHERE user_id = $1)`, - userID, + 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 feed: %w", err) + return 0, fmt.Errorf("count posts: %w", err) } return n, nil } +// Reshare creates a new posts row owned by `resharerID` that points at +// the canonical original via `reshared_from_post_id`. Copies the +// original's caption / source_url / source_title and its current images +// so the resharer's view renders identically at the moment of reshare; +// after that the two diverge — edits to either side never touch the +// other. +// +// If the post being reshared is itself a reshare we point at *its* +// canonical (transitive ancestor), so every reshare always references +// the true original. +// +// Idempotent: a user already resharing a canonical post returns the +// existing reshare row instead of creating a second one. +// +// Service-layer rule (caller can't reshare their own canonical) is +// enforced above this method. +func (r *PostRepo) Reshare(ctx context.Context, originalPostID, resharerID 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) }() + + // Load the post being reshared. Walk one level back to canonical + // in case the caller is resharing someone else's reshare. + var ( + canonicalID string + caption string + sourceURL string + sourceTitle string + canonicalUser string + ) + if err := tx.QueryRow(ctx, ` + SELECT + COALESCE(p.reshared_from_post_id, p.id), + p.caption, p.source_url, COALESCE(p.source_title, ''), + COALESCE(op.user_id, p.user_id) + FROM posts p + LEFT JOIN posts op ON op.id = p.reshared_from_post_id + WHERE p.id = $1`, originalPostID, + ).Scan(&canonicalID, &caption, &sourceURL, &sourceTitle, &canonicalUser); err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return nil, ErrPostNotFound + } + return nil, fmt.Errorf("load canonical: %w", err) + } + if canonicalUser == resharerID { + return nil, errors.New("cannot reshare your own post") + } + + // Idempotency: existing reshare by this user of this canonical? + var existingID string + switch err := tx.QueryRow(ctx, ` + SELECT id FROM posts + WHERE user_id = $1 AND reshared_from_post_id = $2 + LIMIT 1`, resharerID, canonicalID, + ).Scan(&existingID); { + case err == nil: + // Already exists — commit the no-op tx and return the existing row. + if err := tx.Commit(ctx); err != nil { + return nil, fmt.Errorf("commit: %w", err) + } + return r.FindByID(ctx, existingID) + case errors.Is(err, pgx.ErrNoRows): + // Fresh reshare path below. + default: + return nil, fmt.Errorf("check existing reshare: %w", err) + } + + var newID string + if err := tx.QueryRow(ctx, ` + INSERT INTO posts (user_id, caption, source_url, source_title, reshared_from_post_id) + VALUES ($1, $2, $3, $4, $5) + RETURNING id`, + resharerID, caption, sourceURL, sourceTitle, canonicalID, + ).Scan(&newID); err != nil { + return nil, fmt.Errorf("insert reshare post: %w", err) + } + + // Copy the canonical's current images as the resharer's starting set. + if _, err := tx.Exec(ctx, ` + INSERT INTO post_images (post_id, url, position) + SELECT $1, url, position + FROM post_images + WHERE post_id = $2`, newID, canonicalID, + ); err != nil { + return nil, fmt.Errorf("copy images: %w", err) + } + + if err := tx.Commit(ctx); err != nil { + return nil, fmt.Errorf("commit: %w", err) + } + return r.FindByID(ctx, newID) +} + +// Unreshare removes the caller's reshare of a canonical post. The +// caller passes the canonical post id (what the UI knows); we look up +// the caller's reshare-row and delete it. Idempotent — no reshare to +// delete is a no-op. +func (r *PostRepo) Unreshare(ctx context.Context, canonicalPostID, resharerID string) error { + if _, err := r.pg.Pool.Exec(ctx, ` + DELETE FROM posts + WHERE user_id = $1 AND reshared_from_post_id = $2`, + resharerID, canonicalPostID, + ); err != nil { + return fmt.Errorf("unreshare: %w", err) + } + return nil +} + +// ReshareStatesFor returns the set of post IDs from the supplied list +// that the viewer has reshared. Used by feed renderers to flip the +// post-card "Reshare ↔ Send" toggle. +// +// `postIDs` may carry either canonical posts or reshare rows; the query +// resolves each id to its canonical first, then asks "does the viewer +// have a row whose reshared_from_post_id equals that canonical?". The +// returned map keys are the supplied ids — callers don't need to know +// the canonical relationship. +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 p.id + FROM posts p + WHERE p.id = ANY($2) + AND EXISTS ( + SELECT 1 FROM posts r + WHERE r.user_id = $1 + AND r.reshared_from_post_id = COALESCE(p.reshared_from_post_id, p.id) + )`, 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() +} + // --- internal helpers --- func (r *PostRepo) collectPosts(ctx context.Context, total int, query string, args ...any) ([]model.Post, int, error) { @@ -236,12 +376,7 @@ func (r *PostRepo) collectPosts(ctx context.Context, total int, query string, ar 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, - &p.ResharedAt, - ); err != nil { + if err := scanPost(rows, &p); err != nil { return nil, 0, fmt.Errorf("scan post: %w", err) } posts = append(posts, p) @@ -263,62 +398,6 @@ 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/repository/stats_repo.go b/internal/repository/stats_repo.go index 0d0d898..51532dc 100644 --- a/internal/repository/stats_repo.go +++ b/internal/repository/stats_repo.go @@ -54,7 +54,7 @@ func (r *StatsRepo) Snapshot(ctx context.Context) (*StatsSnapshot, error) { {`SELECT COUNT(*) FROM collections`, nil, &s.Collections}, {`SELECT COUNT(*) FROM collections WHERE is_public`, nil, &s.PublicCollections}, {`SELECT COUNT(*) FROM bookmarks`, nil, &s.Bookmarks}, - {`SELECT COUNT(*) FROM post_reshares`, nil, &s.Reshares}, + {`SELECT COUNT(*) FROM posts WHERE reshared_from_post_id IS NOT NULL`, nil, &s.Reshares}, {`SELECT COUNT(*) FROM feedback`, nil, &s.Feedback}, {`SELECT COUNT(*) FROM feedback WHERE created_at >= $1`, []any{sevenAgo}, &s.FeedbackLast7Days}, } diff --git a/internal/service/social_service.go b/internal/service/social_service.go index 8092661..0b5d7b2 100644 --- a/internal/service/social_service.go +++ b/internal/service/social_service.go @@ -75,39 +75,33 @@ func (s *PostService) Get(ctx context.Context, id, viewerID string) (*model.Post 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) +// Reshare materialises a reshare as a brand-new posts row owned by the +// caller, with reshared_from_post_id pointing to the canonical original. +// Idempotent — calling it twice returns the same reshare row. Resharing +// one's own post (canonical or chained) is rejected at the repo layer. +// +// Returns the new post so the frontend can drop it into local state +// without a round-trip. +func (s *PostService) Reshare(ctx context.Context, postID, callerID string) (*model.Post, error) { + return s.posts.Reshare(ctx, postID, callerID) } -// Unreshare removes a previously recorded reshare. Idempotent — no-ops -// when no row exists. +// Unreshare removes the caller's reshare of a canonical post by deleting +// their own reshare posts row. Idempotent — no-ops when nothing to delete. func (s *PostService) Unreshare(ctx context.Context, postID, callerID string) error { - return s.posts.RemoveReshare(ctx, postID, callerID) + return s.posts.Unreshare(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. +// batched lookup. Skips the query when the viewer is anonymous (the +// answer is uniformly false) or the slice is empty. 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 { + if p == nil { continue } ids = append(ids, p.ID) @@ -150,9 +144,9 @@ 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. +// ReplaceImages swaps the image set on a post. Owner-only — every +// reshare is its own posts row, owned by the resharer, so this works +// uniformly for canonical posts and reshares. 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 @@ -180,12 +174,11 @@ func (s *PostService) ListByHandle(ctx context.Context, handle, viewerID string, return posts, total, nil } -// ListMine is the owner's view of their own profile feed — authored -// posts plus reshares (the repo's ListByUser unions the two). The -// viewer here is by construction the feed owner, so every reshared -// row is one the viewer themselves reshared — we flip -// ViewerHasReshared inline rather than spending a hydration round-trip -// to learn what we already know. +// ListMine is the owner's view of their own profile feed. Every post +// they own — authored or reshared — comes from posts.user_id, so the +// viewer is by construction the owner. ViewerHasReshared is true for +// reshare rows (the user IS the resharer) and false for canonical +// rows (you don't "reshare your own post"). func (s *PostService) ListMine(ctx context.Context, userID string, limit, offset int) ([]model.Post, int, error) { if limit <= 0 || limit > 50 { limit = 20 @@ -198,7 +191,7 @@ func (s *PostService) ListMine(ctx context.Context, userID string, limit, offset return nil, 0, err } for i := range posts { - if posts[i].ResharedAt != nil { + if posts[i].ResharedFromPostID != nil { posts[i].ViewerHasReshared = true } } diff --git a/migrations/0014_create_post_reshare_images.down.sql b/migrations/0014_create_post_reshare_images.down.sql new file mode 100644 index 0000000..c43c1b0 --- /dev/null +++ b/migrations/0014_create_post_reshare_images.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS post_reshare_images; diff --git a/migrations/0014_create_post_reshare_images.up.sql b/migrations/0014_create_post_reshare_images.up.sql new file mode 100644 index 0000000..bacea38 --- /dev/null +++ b/migrations/0014_create_post_reshare_images.up.sql @@ -0,0 +1,35 @@ +-- Per-reshare background image overrides. +-- +-- Previously every viewer of a reshare saw the canonical post_images row — +-- so when either the original author or any resharer "Updated background" +-- the change leaked across all timelines that referenced the post. This +-- table separates "the post's background" from "this user's reshare of +-- the post's background": authoring stays in post_images, resharing +-- stores its own snapshot here. +-- +-- At reshare time we copy the post's current post_images into this table +-- so the resharer's feed renders identically to the author's. After that +-- moment the two are independent — the resharer's background lives only +-- here, the original author's lives only in post_images, and changes to +-- one never touch the other. +-- +-- Cascade: the FK targets the (post_id, user_id) primary key on +-- post_reshares. Every path that removes a reshare relationship (the +-- author deletes the post, the resharer un-reshares, either user is +-- deleted) drops the post_reshares row, which cascades the overrides +-- away. The user's stated rule — "should not be in sync except deletion" +-- — is preserved end-to-end without any extra application code. + +CREATE TABLE IF NOT EXISTS post_reshare_images ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + post_id UUID NOT NULL, + user_id UUID NOT NULL, + url TEXT NOT NULL, + position INT NOT NULL DEFAULT 0, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + FOREIGN KEY (post_id, user_id) + REFERENCES post_reshares (post_id, user_id) ON DELETE CASCADE +); + +CREATE INDEX IF NOT EXISTS idx_post_reshare_images_lookup + ON post_reshare_images (post_id, user_id, position); diff --git a/migrations/0015_reshares_are_real_posts.down.sql b/migrations/0015_reshares_are_real_posts.down.sql new file mode 100644 index 0000000..88d07d7 --- /dev/null +++ b/migrations/0015_reshares_are_real_posts.down.sql @@ -0,0 +1,12 @@ +-- Reverting this requires materialising the new reshared posts back into +-- the post_reshares + post_reshare_images join model and then dropping +-- the column. Documented but not automated — run manually if a rollback +-- is ever actually needed. +-- +-- Step 1: recreate the old tables. +-- Step 2: insert one row per reshare back into post_reshares. +-- Step 3: copy each reshare's post_images back into post_reshare_images. +-- Step 4: delete the materialised reshare rows from `posts`. +-- Step 5: drop the column. + +ALTER TABLE posts DROP COLUMN IF EXISTS reshared_from_post_id; diff --git a/migrations/0015_reshares_are_real_posts.up.sql b/migrations/0015_reshares_are_real_posts.up.sql new file mode 100644 index 0000000..5fa94bd --- /dev/null +++ b/migrations/0015_reshares_are_real_posts.up.sql @@ -0,0 +1,80 @@ +-- Reshares become real `posts` rows. +-- +-- Previously a reshare was a join-table relationship between (post, user) +-- that pointed at the original author's canonical row. Likes, comments, +-- and (most recently) background overrides all had to be plumbed through +-- a parallel "per-resharer" path to avoid leaking across timelines. The +-- next set of social features (likes, comments) would multiply that +-- complexity again. +-- +-- This migration collapses that model: every reshare is its own row in +-- `posts`, owned by the resharer, with a `reshared_from_post_id` pointer +-- back to the canonical original. Likes and comments will naturally hang +-- off post.id and diverge for free; backgrounds are already per-row in +-- post_images so the per-reshare override table goes away. +-- +-- Deletion still cascades: the FK uses ON DELETE CASCADE on +-- reshared_from_post_id, so an author deleting their post nukes every +-- reshare of it (including their post_images via the existing FK). + +-- 1. New self-referential column. Nullable: NULL on canonical posts, +-- set on reshares. +ALTER TABLE posts + ADD COLUMN IF NOT EXISTS reshared_from_post_id UUID + REFERENCES posts(id) ON DELETE CASCADE; + +CREATE INDEX IF NOT EXISTS idx_posts_reshared_from + ON posts (reshared_from_post_id) + WHERE reshared_from_post_id IS NOT NULL; + +-- Composite index used by ReshareStatesFor — given a viewer + a batch of +-- canonical post ids, "which of these has the viewer reshared?". +CREATE INDEX IF NOT EXISTS idx_posts_user_reshared_from + ON posts (user_id, reshared_from_post_id) + WHERE reshared_from_post_id IS NOT NULL; + +-- 2. Backfill: each row in post_reshares becomes a real `posts` row, +-- copying the canonical post's textual fields and the timestamp the +-- reshare originally happened. The new row is owned by the resharer. +-- +-- Skipped if post_reshares doesn't exist (clean install) — the +-- DO block uses to_regclass so a fresh database doesn't error. +DO $$ +BEGIN + IF to_regclass('public.post_reshares') IS NOT NULL THEN + INSERT INTO posts ( + id, user_id, caption, source_url, source_title, + created_at, updated_at, reshared_from_post_id + ) + SELECT + gen_random_uuid(), + r.user_id, + p.caption, + p.source_url, + p.source_title, + r.created_at, + r.created_at, + p.id + FROM post_reshares r + JOIN posts p ON p.id = r.post_id; + END IF; +END$$; + +-- 3. Carry the per-resharer image overrides over. Each override row maps +-- to the newly-inserted post via (reshared_from_post_id, user_id). +DO $$ +BEGIN + IF to_regclass('public.post_reshare_images') IS NOT NULL THEN + INSERT INTO post_images (post_id, url, position) + SELECT np.id, ri.url, ri.position + FROM post_reshare_images ri + JOIN posts np + ON np.reshared_from_post_id = ri.post_id + AND np.user_id = ri.user_id; + END IF; +END$$; + +-- 4. Old tables are no longer referenced anywhere — drop them so the +-- schema mirrors the new model. +DROP TABLE IF EXISTS post_reshare_images; +DROP TABLE IF EXISTS post_reshares;