feat: post reshare enhancements

This commit is contained in:
Krispire Admin
2026-06-03 14:41:15 +05:30
parent 3f6b7b6636
commit 7f5b0fee11
9 changed files with 397 additions and 190 deletions
+7 -6
View File
@@ -59,18 +59,19 @@ func (h *PostHandler) Create(w http.ResponseWriter, r *http.Request) {
response.JSON(w, http.StatusCreated, p) response.JSON(w, http.StatusCreated, p)
} }
// Reshare records the caller as having reshared the post identified // Reshare materialises a reshare of the post identified by {id} as a
// by {id}. Idempotent. Returns 204 on success; conflict for the // new posts row owned by the caller. Idempotent — second call returns
// "reshare your own post" case surfaces as 400 via mapSocialErr's // the same row (still 201). The body is the new (or existing) post so
// fallthrough (errors.New wrapper in the service). // the frontend can drop it into its local feed without a refetch.
func (h *PostHandler) Reshare(w http.ResponseWriter, r *http.Request) { func (h *PostHandler) Reshare(w http.ResponseWriter, r *http.Request) {
uid := middleware.UserID(r.Context()) uid := middleware.UserID(r.Context())
id := chi.URLParam(r, "id") 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) mapSocialErr(w, err)
return return
} }
w.WriteHeader(http.StatusNoContent) response.JSON(w, http.StatusCreated, p)
} }
// Unreshare removes a previously recorded reshare. Idempotent. // Unreshare removes a previously recorded reshare. Idempotent.
+17 -11
View File
@@ -5,6 +5,11 @@ import "time"
// Post is a public quote on a user's feed. Caption is the verbatim selected // 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 // text from the knowledgebase; SourceURL links back to the page the user
// was reading. Images are loaded separately via PostImage rows. // 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 { type Post struct {
ID string `json:"id"` ID string `json:"id"`
UserID string `json:"user_id"` UserID string `json:"user_id"`
@@ -20,19 +25,20 @@ type Post struct {
AuthorHandle string `json:"author_handle,omitempty"` AuthorHandle string `json:"author_handle,omitempty"`
AuthorAvatarURL *string `json:"author_avatar_url,omitempty"` AuthorAvatarURL *string `json:"author_avatar_url,omitempty"`
// ViewerHasReshared is true when the calling user has reshared this // Reshare attribution. ResharedFromPostID is non-nil when this post
// post. Hydrated by the service layer after the repo returns posts; // is a reshare; the four `Original*` fields carry the canonical
// always false for anonymous viewers (no viewer = no reshare state). // 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. // Drives the post-card "Reshare ↔ Send" swap on the frontend.
ViewerHasReshared bool `json:"viewer_has_reshared"` 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. // PostImage represents one attached image on a post.
+220 -141
View File
@@ -13,10 +13,53 @@ import (
var ErrPostNotFound = errors.New("post not found") var ErrPostNotFound = errors.New("post not found")
// PostRepo wraps Postgres access for posts + post_images. // 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 } type PostRepo struct{ pg *Postgres }
func NewPostRepo(pg *Postgres) *PostRepo { return &PostRepo{pg: pg} } 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 // Create inserts a post + its images in a single transaction so we never
// end up with orphan images. // end up with orphan images.
func (r *PostRepo) Create(ctx context.Context, userID, caption, sourceURL, sourceTitle string, imageURLs []string) (*model.Post, error) { 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 return p, nil
} }
// ReplaceImages atomically swaps a post's image set. Ownership is // ReplaceImages atomically swaps a post's image set. Owner-only — every
// enforced inside the tx by locking the post row and checking user_id — // reshare is its own posts row now, so the resharer is the owner of
// non-owners get ErrPostNotFound (we don't distinguish "not yours" from // their reshare and edits it just like any other post they made.
// "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 { func (r *PostRepo) ReplaceImages(ctx context.Context, postID, userID string, imageURLs []string) error {
tx, err := r.pg.Pool.Begin(ctx) tx, err := r.pg.Pool.Begin(ctx)
if err != nil { if err != nil {
@@ -98,21 +138,12 @@ func (r *PostRepo) ReplaceImages(ctx context.Context, postID, userID string, ima
return tx.Commit(ctx) 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) { func (r *PostRepo) FindByID(ctx context.Context, id string) (*model.Post, error) {
const q = ` q := `SELECT ` + postSelectColumns + postSelectFrom + `WHERE p.id = $1`
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{} p := &model.Post{}
err := r.pg.Pool.QueryRow(ctx, q, id).Scan( if err := scanPost(r.pg.Pool.QueryRow(ctx, q, id), p); err != nil {
&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) { if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrPostNotFound return nil, ErrPostNotFound
} }
@@ -126,12 +157,8 @@ func (r *PostRepo) FindByID(ctx context.Context, id string) (*model.Post, error)
return p, nil return p, nil
} }
// ListByHandle returns the profile feed for a user — authored posts // ListByHandle returns the profile feed for a user. Unknown handle →
// plus posts the user reshared — newest-first, paginated. Resolves // empty page (matches pre-existing behaviour).
// 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.
func (r *PostRepo) ListByHandle(ctx context.Context, handle string, limit, offset int) ([]model.Post, int, error) { func (r *PostRepo) ListByHandle(ctx context.Context, handle string, limit, offset int) ([]model.Post, int, error) {
var userID string var userID string
if err := r.pg.Pool.QueryRow(ctx, `SELECT id FROM users WHERE handle = $1`, handle).Scan(&userID); err != nil { 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) return r.ListByUser(ctx, userID, limit, offset)
} }
// ListByUser returns the user's profile feed — authored posts plus // ListByUser returns the user's profile feed — every post they own,
// posts they reshared. Each row carries a reshared_at timestamp that // authored or reshared. Reshares are interleaved naturally because
// is NULL for authored rows and equal to post_reshares.created_at for // they're stored as real rows; sort by created_at gets the right order
// reshared rows; rows are sorted by COALESCE(reshared_at, created_at) // without any UNION gymnastics.
// 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.
func (r *PostRepo) ListByUser(ctx context.Context, userID string, limit, offset int) ([]model.Post, int, error) { func (r *PostRepo) ListByUser(ctx context.Context, userID string, limit, offset int) ([]model.Post, int, error) {
var total int total, err := r.CountByUser(ctx, userID)
if err := r.pg.Pool.QueryRow(ctx, ` if err != nil {
SELECT return nil, 0, err
(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)
} }
const q = ` q := `SELECT ` + postSelectColumns + postSelectFrom + `
SELECT id, user_id, caption, source_url, source_title, WHERE p.user_id = $1
created_at, updated_at, author_name, author_handle, ORDER BY p.created_at DESC, p.id
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
LIMIT $2 OFFSET $3` LIMIT $2 OFFSET $3`
return r.collectPosts(ctx, total, q, userID, limit, offset) 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 { func (r *PostRepo) Delete(ctx context.Context, postID, userID string) error {
tag, err := r.pg.Pool.Exec(ctx, tag, err := r.pg.Pool.Exec(ctx,
`DELETE FROM posts WHERE id = $1 AND user_id = $2`, postID, userID) `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 return nil
} }
// CountByUser returns the profile-feed total — authored + reshared — // CountByUser returns the user's total post count (authored + reshared).
// which is what the profile header's "N posts" badge displays. Stays // Now just a single COUNT against posts.user_id.
// in sync with ListByUser's total so the badge never disagrees with
// the page contents below it.
func (r *PostRepo) CountByUser(ctx context.Context, userID string) (int, error) { func (r *PostRepo) CountByUser(ctx context.Context, userID string) (int, error) {
var n int var n int
err := r.pg.Pool.QueryRow(ctx, ` err := r.pg.Pool.QueryRow(ctx,
SELECT `SELECT COUNT(*) FROM posts WHERE user_id = $1`, userID,
(SELECT COUNT(*) FROM posts WHERE user_id = $1)
+ (SELECT COUNT(*) FROM post_reshares WHERE user_id = $1)`,
userID,
).Scan(&n) ).Scan(&n)
if err != nil { if err != nil {
return 0, fmt.Errorf("count feed: %w", err) return 0, fmt.Errorf("count posts: %w", err)
} }
return n, nil 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 --- // --- internal helpers ---
func (r *PostRepo) collectPosts(ctx context.Context, total int, query string, args ...any) ([]model.Post, int, error) { 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{} ids := []string{}
for rows.Next() { for rows.Next() {
var p model.Post var p model.Post
if err := rows.Scan( if err := scanPost(rows, &p); err != nil {
&p.ID, &p.UserID, &p.Caption, &p.SourceURL, &p.SourceTitle,
&p.CreatedAt, &p.UpdatedAt,
&p.AuthorName, &p.AuthorHandle, &p.AuthorAvatarURL,
&p.ResharedAt,
); err != nil {
return nil, 0, fmt.Errorf("scan post: %w", err) return nil, 0, fmt.Errorf("scan post: %w", err)
} }
posts = append(posts, p) posts = append(posts, p)
@@ -263,62 +398,6 @@ func (r *PostRepo) collectPosts(ctx context.Context, total int, query string, ar
return posts, total, nil 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 // 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 // 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 // CollectionService needs to hydrate images onto posts that come back
+1 -1
View File
@@ -54,7 +54,7 @@ func (r *StatsRepo) Snapshot(ctx context.Context) (*StatsSnapshot, error) {
{`SELECT COUNT(*) FROM collections`, nil, &s.Collections}, {`SELECT COUNT(*) FROM collections`, nil, &s.Collections},
{`SELECT COUNT(*) FROM collections WHERE is_public`, nil, &s.PublicCollections}, {`SELECT COUNT(*) FROM collections WHERE is_public`, nil, &s.PublicCollections},
{`SELECT COUNT(*) FROM bookmarks`, nil, &s.Bookmarks}, {`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`, nil, &s.Feedback},
{`SELECT COUNT(*) FROM feedback WHERE created_at >= $1`, []any{sevenAgo}, &s.FeedbackLast7Days}, {`SELECT COUNT(*) FROM feedback WHERE created_at >= $1`, []any{sevenAgo}, &s.FeedbackLast7Days},
} }
+24 -31
View File
@@ -75,39 +75,33 @@ func (s *PostService) Get(ctx context.Context, id, viewerID string) (*model.Post
return p, nil return p, nil
} }
// Reshare records that the caller has reshared the given post. Idempotent // Reshare materialises a reshare as a brand-new posts row owned by the
// — calling it twice with the same (post, user) is a no-op. Resharing // caller, with reshared_from_post_id pointing to the canonical original.
// your own post is rejected: there's nothing to bring onto an account // Idempotent — calling it twice returns the same reshare row. Resharing
// that already authored the post. // one's own post (canonical or chained) is rejected at the repo layer.
func (s *PostService) Reshare(ctx context.Context, postID, callerID string) error { //
p, err := s.posts.FindByID(ctx, postID) // Returns the new post so the frontend can drop it into local state
if err != nil { // without a round-trip.
return err func (s *PostService) Reshare(ctx context.Context, postID, callerID string) (*model.Post, error) {
} return s.posts.Reshare(ctx, postID, callerID)
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 // Unreshare removes the caller's reshare of a canonical post by deleting
// when no row exists. // their own reshare posts row. Idempotent — no-ops when nothing to delete.
func (s *PostService) Unreshare(ctx context.Context, postID, callerID string) error { 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 // hydrateReshareStates fills in ViewerHasReshared on each post via one
// batched lookup against post_reshares. Skips the query when the viewer // batched lookup. Skips the query when the viewer is anonymous (the
// is anonymous (`viewerID == ""`) since the answer is uniformly false. // answer is uniformly false) or the slice is empty.
// 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 { func (s *PostService) hydrateReshareStates(ctx context.Context, viewerID string, posts []*model.Post) error {
if viewerID == "" || len(posts) == 0 { if viewerID == "" || len(posts) == 0 {
return nil return nil
} }
ids := make([]string, 0, len(posts)) ids := make([]string, 0, len(posts))
for _, p := range posts { for _, p := range posts {
if p == nil || p.UserID == viewerID { if p == nil {
continue continue
} }
ids = append(ids, p.ID) 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) return s.posts.Delete(ctx, postID, callerID)
} }
// ReplaceImages swaps the image set on a post. The caller must own the // ReplaceImages swaps the image set on a post. Owner-only — every
// post. Returns the fresh post payload so the client can reconcile its // reshare is its own posts row, owned by the resharer, so this works
// local state without a separate fetch. // uniformly for canonical posts and reshares.
func (s *PostService) ReplaceImages(ctx context.Context, postID, callerID string, imageURLs []string) (*model.Post, error) { 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 { if err := s.posts.ReplaceImages(ctx, postID, callerID, imageURLs); err != nil {
return nil, err return nil, err
@@ -180,12 +174,11 @@ func (s *PostService) ListByHandle(ctx context.Context, handle, viewerID string,
return posts, total, nil return posts, total, nil
} }
// ListMine is the owner's view of their own profile feed — authored // ListMine is the owner's view of their own profile feed. Every post
// posts plus reshares (the repo's ListByUser unions the two). The // they own — authored or reshared — comes from posts.user_id, so the
// viewer here is by construction the feed owner, so every reshared // viewer is by construction the owner. ViewerHasReshared is true for
// row is one the viewer themselves reshared — we flip // reshare rows (the user IS the resharer) and false for canonical
// ViewerHasReshared inline rather than spending a hydration round-trip // rows (you don't "reshare your own post").
// to learn what we already know.
func (s *PostService) ListMine(ctx context.Context, userID string, limit, offset int) ([]model.Post, int, error) { func (s *PostService) ListMine(ctx context.Context, userID string, limit, offset int) ([]model.Post, int, error) {
if limit <= 0 || limit > 50 { if limit <= 0 || limit > 50 {
limit = 20 limit = 20
@@ -198,7 +191,7 @@ func (s *PostService) ListMine(ctx context.Context, userID string, limit, offset
return nil, 0, err return nil, 0, err
} }
for i := range posts { for i := range posts {
if posts[i].ResharedAt != nil { if posts[i].ResharedFromPostID != nil {
posts[i].ViewerHasReshared = true posts[i].ViewerHasReshared = true
} }
} }
@@ -0,0 +1 @@
DROP TABLE IF EXISTS post_reshare_images;
@@ -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);
@@ -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;
@@ -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;