feat: post reshare enhancements
This commit is contained in:
@@ -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.
|
||||
|
||||
+17
-11
@@ -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.
|
||||
|
||||
+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
|
||||
|
||||
@@ -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},
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user