feat: post reshare enhancements
This commit is contained in:
+220
-141
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user