631 lines
21 KiB
Go
631 lines
21 KiB
Go
package repository
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"time"
|
|
|
|
"github.com/jackc/pgx/v5"
|
|
"github.com/prabhupadasays/spsays-bff/internal/model"
|
|
)
|
|
|
|
// ErrPostNotFound is returned by lookup methods when no row matches.
|
|
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) {
|
|
tx, err := r.pg.Pool.Begin(ctx)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("begin tx: %w", err)
|
|
}
|
|
defer func() { _ = tx.Rollback(ctx) }()
|
|
|
|
p := &model.Post{}
|
|
err = tx.QueryRow(ctx, `
|
|
INSERT INTO posts (user_id, caption, source_url, source_title)
|
|
VALUES ($1, $2, $3, $4)
|
|
RETURNING id, user_id, caption, source_url, COALESCE(source_title, ''), created_at, updated_at`,
|
|
userID, caption, sourceURL, sourceTitle,
|
|
).Scan(&p.ID, &p.UserID, &p.Caption, &p.SourceURL, &p.SourceTitle, &p.CreatedAt, &p.UpdatedAt)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("insert post: %w", err)
|
|
}
|
|
|
|
for i, url := range imageURLs {
|
|
var img model.PostImage
|
|
err = tx.QueryRow(ctx, `
|
|
INSERT INTO post_images (post_id, url, position)
|
|
VALUES ($1, $2, $3)
|
|
RETURNING id, url, position`,
|
|
p.ID, url, i,
|
|
).Scan(&img.ID, &img.URL, &img.Position)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("insert post image %d: %w", i, err)
|
|
}
|
|
p.Images = append(p.Images, img)
|
|
}
|
|
|
|
if err := tx.Commit(ctx); err != nil {
|
|
return nil, fmt.Errorf("commit: %w", err)
|
|
}
|
|
return p, nil
|
|
}
|
|
|
|
// 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 {
|
|
return fmt.Errorf("begin tx: %w", err)
|
|
}
|
|
defer func() { _ = tx.Rollback(ctx) }()
|
|
|
|
var owner string
|
|
if err := tx.QueryRow(ctx, `SELECT user_id FROM posts WHERE id = $1 FOR UPDATE`, postID).Scan(&owner); err != nil {
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
|
return ErrPostNotFound
|
|
}
|
|
return fmt.Errorf("lock post: %w", err)
|
|
}
|
|
if owner != userID {
|
|
return ErrPostNotFound
|
|
}
|
|
|
|
if _, err := tx.Exec(ctx, `DELETE FROM post_images WHERE post_id = $1`, postID); err != nil {
|
|
return fmt.Errorf("delete images: %w", err)
|
|
}
|
|
for i, url := range imageURLs {
|
|
if _, err := tx.Exec(ctx, `
|
|
INSERT INTO post_images (post_id, url, position)
|
|
VALUES ($1, $2, $3)`, postID, url, i,
|
|
); err != nil {
|
|
return fmt.Errorf("insert image %d: %w", i, err)
|
|
}
|
|
}
|
|
if _, err := tx.Exec(ctx, `UPDATE posts SET updated_at = now() WHERE id = $1`, postID); err != nil {
|
|
return fmt.Errorf("touch post: %w", err)
|
|
}
|
|
return tx.Commit(ctx)
|
|
}
|
|
|
|
// 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) {
|
|
q := `SELECT ` + postSelectColumns + postSelectFrom + `WHERE p.id = $1`
|
|
p := &model.Post{}
|
|
if err := scanPost(r.pg.Pool.QueryRow(ctx, q, id), p); err != nil {
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
|
return nil, ErrPostNotFound
|
|
}
|
|
return nil, fmt.Errorf("find post: %w", err)
|
|
}
|
|
imgs, err := r.ImagesFor(ctx, []string{p.ID})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
p.Images = imgs[p.ID]
|
|
return p, nil
|
|
}
|
|
|
|
// 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 {
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
|
return []model.Post{}, 0, nil
|
|
}
|
|
return nil, 0, fmt.Errorf("lookup user by handle: %w", err)
|
|
}
|
|
return r.ListByUser(ctx, userID, limit, offset)
|
|
}
|
|
|
|
// ListMentioningByHandle returns posts that tag the given user (handle),
|
|
// newest first — the "Mentions" profile tab. Same shape as the profile feed.
|
|
func (r *PostRepo) ListMentioningByHandle(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 {
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
|
return []model.Post{}, 0, nil
|
|
}
|
|
return nil, 0, fmt.Errorf("lookup user by handle: %w", err)
|
|
}
|
|
|
|
var total int
|
|
if err := r.pg.Pool.QueryRow(ctx,
|
|
`SELECT count(*) FROM posts p JOIN post_mentions pm ON pm.post_id = p.id WHERE pm.user_id = $1`,
|
|
userID,
|
|
).Scan(&total); err != nil {
|
|
return nil, 0, fmt.Errorf("count mentioning posts: %w", err)
|
|
}
|
|
q := `SELECT ` + postSelectColumns + postSelectFrom + `
|
|
JOIN post_mentions pm ON pm.post_id = p.id
|
|
WHERE pm.user_id = $1
|
|
ORDER BY p.created_at DESC, p.id
|
|
LIMIT $2 OFFSET $3`
|
|
return r.collectPosts(ctx, total, q, userID, limit, offset)
|
|
}
|
|
|
|
// 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) {
|
|
total, err := r.CountByUser(ctx, userID)
|
|
if err != nil {
|
|
return nil, 0, err
|
|
}
|
|
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)
|
|
}
|
|
|
|
// FeedForViewer returns one page of the global home feed for `viewerID`,
|
|
// newest-first within a two-tier priority:
|
|
//
|
|
// tier 0 — posts by people the viewer follows that they haven't liked yet
|
|
// tier 1 — everything else (followed-and-liked, followers', strangers')
|
|
//
|
|
// The viewer's own posts, anything involving a block (either direction), and
|
|
// any id in `exclude` (the already-served set) are filtered out. The tier is
|
|
// computed in ORDER BY via correlated EXISTS, so no extra columns leak into
|
|
// scanPost. No total is computed (infinite scroll infers "more" from a full
|
|
// page).
|
|
//
|
|
// With a non-empty `exclude` the caller drives pagination purely by exclusion
|
|
// (pass offset 0): since the tier depends on viewer state — liking a post
|
|
// moves it between tiers — an OFFSET would drift and repeat/skip rows, so the
|
|
// seen-set is the correct cursor. Without Redis, `exclude` is empty and OFFSET
|
|
// paginates (the frontend de-dups the rare drift). At larger scale this moves
|
|
// to a precomputed fan-out / keyset cursor.
|
|
func (r *PostRepo) FeedForViewer(ctx context.Context, viewerID string, limit, offset int, exclude []string) ([]model.Post, error) {
|
|
// nil would encode as SQL NULL, and `id <> ALL(NULL)` is NULL (excludes
|
|
// every row); a non-nil empty array means "exclude nothing".
|
|
if exclude == nil {
|
|
exclude = []string{}
|
|
}
|
|
q := `SELECT ` + postSelectColumns + postSelectFrom + `
|
|
WHERE p.user_id <> $1
|
|
AND p.id <> ALL($4)
|
|
AND NOT EXISTS (
|
|
SELECT 1 FROM user_blocks b
|
|
WHERE (b.blocker_id = $1 AND b.blocked_id = p.user_id)
|
|
OR (b.blocker_id = p.user_id AND b.blocked_id = $1)
|
|
)
|
|
ORDER BY
|
|
(CASE
|
|
WHEN EXISTS (
|
|
SELECT 1 FROM follows f
|
|
WHERE f.follower_id = $1 AND f.following_id = p.user_id
|
|
AND f.status = 'accepted'
|
|
)
|
|
AND NOT EXISTS (
|
|
SELECT 1 FROM post_likes pl
|
|
WHERE pl.post_id = p.id AND pl.user_id = $1
|
|
)
|
|
THEN 0 ELSE 1
|
|
END) ASC,
|
|
p.created_at DESC, p.id DESC
|
|
LIMIT $2 OFFSET $3`
|
|
posts, _, err := r.collectPosts(ctx, 0, q, viewerID, limit, offset, exclude)
|
|
return posts, err
|
|
}
|
|
|
|
// HydrateViewerRelationships fills ViewerFollowsAuthor / AuthorFollowsViewer
|
|
// across a page of feed posts in two batched queries (one per direction).
|
|
// No-op for anonymous viewers.
|
|
func (r *PostRepo) HydrateViewerRelationships(ctx context.Context, viewerID string, posts []model.Post) error {
|
|
if viewerID == "" || len(posts) == 0 {
|
|
return nil
|
|
}
|
|
seen := map[string]bool{}
|
|
authorIDs := make([]string, 0, len(posts))
|
|
for i := range posts {
|
|
id := posts[i].UserID
|
|
if id != "" && id != viewerID && !seen[id] {
|
|
seen[id] = true
|
|
authorIDs = append(authorIDs, id)
|
|
}
|
|
}
|
|
if len(authorIDs) == 0 {
|
|
return nil
|
|
}
|
|
|
|
following, err := r.followEdges(ctx,
|
|
`SELECT following_id FROM follows
|
|
WHERE follower_id = $1 AND status = 'accepted' AND following_id = ANY($2)`,
|
|
viewerID, authorIDs)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
followers, err := r.followEdges(ctx,
|
|
`SELECT follower_id FROM follows
|
|
WHERE following_id = $1 AND status = 'accepted' AND follower_id = ANY($2)`,
|
|
viewerID, authorIDs)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
for i := range posts {
|
|
posts[i].ViewerFollowsAuthor = following[posts[i].UserID]
|
|
posts[i].AuthorFollowsViewer = followers[posts[i].UserID]
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// followEdges runs a one-column follow query and collects the ids into a set.
|
|
func (r *PostRepo) followEdges(ctx context.Context, query, viewerID string, ids []string) (map[string]bool, error) {
|
|
rows, err := r.pg.Pool.Query(ctx, query, viewerID, ids)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("hydrate follow edges: %w", err)
|
|
}
|
|
defer rows.Close()
|
|
out := map[string]bool{}
|
|
for rows.Next() {
|
|
var id string
|
|
if err := rows.Scan(&id); err != nil {
|
|
return nil, fmt.Errorf("scan follow edge: %w", err)
|
|
}
|
|
out[id] = true
|
|
}
|
|
return out, rows.Err()
|
|
}
|
|
|
|
// 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)
|
|
if err != nil {
|
|
return fmt.Errorf("delete post: %w", err)
|
|
}
|
|
if tag.RowsAffected() == 0 {
|
|
return ErrPostNotFound
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// 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 COUNT(*) FROM posts WHERE user_id = $1`, userID,
|
|
).Scan(&n)
|
|
if err != nil {
|
|
return 0, fmt.Errorf("count posts: %w", err)
|
|
}
|
|
return n, nil
|
|
}
|
|
|
|
// PostedDates returns the set of UTC calendar days, on or after `since`,
|
|
// on which the user created at least one post. Used by the sādhana streak
|
|
// to detect the "posted a quote today" discipline (and to score past days
|
|
// of the streak) without loading whole posts. Keys are "YYYY-MM-DD".
|
|
func (r *PostRepo) PostedDates(ctx context.Context, userID string, since time.Time) (map[string]bool, error) {
|
|
rows, err := r.pg.Pool.Query(ctx, `
|
|
SELECT DISTINCT (created_at AT TIME ZONE 'UTC')::date AS d
|
|
FROM posts
|
|
WHERE user_id = $1 AND created_at >= $2`,
|
|
userID, since,
|
|
)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("posted dates: %w", err)
|
|
}
|
|
defer rows.Close()
|
|
|
|
out := make(map[string]bool)
|
|
for rows.Next() {
|
|
var d time.Time
|
|
if err := rows.Scan(&d); err != nil {
|
|
return nil, fmt.Errorf("scan posted date: %w", err)
|
|
}
|
|
out[d.Format("2006-01-02")] = true
|
|
}
|
|
return out, rows.Err()
|
|
}
|
|
|
|
// PostCountsByDate returns, for each calendar day on or after `since`, how
|
|
// many posts the user created that day — bucketed in the given IANA timezone
|
|
// (`tz`) so the day boundary matches the user's local midnight. Keys are
|
|
// "YYYY-MM-DD"; days with no posts are absent (so a missing key reads as
|
|
// zero). The sādhana page uses this for the "posted a quote today" discipline
|
|
// and to annotate each day's fold with the number of quotes shared.
|
|
func (r *PostRepo) PostCountsByDate(ctx context.Context, userID string, since time.Time, tz string) (map[string]int, error) {
|
|
if tz == "" {
|
|
tz = "UTC"
|
|
}
|
|
rows, err := r.pg.Pool.Query(ctx, `
|
|
SELECT (created_at AT TIME ZONE $3::text)::date AS d, count(*) AS n
|
|
FROM posts
|
|
WHERE user_id = $1 AND created_at >= $2
|
|
GROUP BY d`,
|
|
userID, since, tz,
|
|
)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("post counts by date: %w", err)
|
|
}
|
|
defer rows.Close()
|
|
|
|
out := make(map[string]int)
|
|
for rows.Next() {
|
|
var (
|
|
d time.Time
|
|
n int
|
|
)
|
|
if err := rows.Scan(&d, &n); err != nil {
|
|
return nil, fmt.Errorf("scan post count: %w", err)
|
|
}
|
|
out[d.Format("2006-01-02")] = n
|
|
}
|
|
return out, rows.Err()
|
|
}
|
|
|
|
// 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) {
|
|
rows, err := r.pg.Pool.Query(ctx, query, args...)
|
|
if err != nil {
|
|
return nil, 0, fmt.Errorf("query posts: %w", err)
|
|
}
|
|
defer rows.Close()
|
|
|
|
posts := []model.Post{}
|
|
ids := []string{}
|
|
for rows.Next() {
|
|
var p model.Post
|
|
if err := scanPost(rows, &p); err != nil {
|
|
return nil, 0, fmt.Errorf("scan post: %w", err)
|
|
}
|
|
posts = append(posts, p)
|
|
ids = append(ids, p.ID)
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return nil, 0, fmt.Errorf("iterate posts: %w", err)
|
|
}
|
|
|
|
if len(ids) > 0 {
|
|
imgs, err := r.ImagesFor(ctx, ids)
|
|
if err != nil {
|
|
return nil, 0, err
|
|
}
|
|
for i := range posts {
|
|
posts[i].Images = imgs[posts[i].ID]
|
|
}
|
|
}
|
|
return posts, total, nil
|
|
}
|
|
|
|
// 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
|
|
// from CollectionRepo.PostsIn (which only joins posts/users — not
|
|
// post_images — to keep that query focused).
|
|
func (r *PostRepo) ImagesFor(ctx context.Context, postIDs []string) (map[string][]model.PostImage, error) {
|
|
out := make(map[string][]model.PostImage, len(postIDs))
|
|
if len(postIDs) == 0 {
|
|
return out, nil
|
|
}
|
|
rows, err := r.pg.Pool.Query(ctx, `
|
|
SELECT post_id, id, url, position
|
|
FROM post_images
|
|
WHERE post_id = ANY($1)
|
|
ORDER BY post_id, position`, postIDs,
|
|
)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("query post images: %w", err)
|
|
}
|
|
defer rows.Close()
|
|
for rows.Next() {
|
|
var postID string
|
|
var img model.PostImage
|
|
if err := rows.Scan(&postID, &img.ID, &img.URL, &img.Position); err != nil {
|
|
return nil, fmt.Errorf("scan post image: %w", err)
|
|
}
|
|
out[postID] = append(out[postID], img)
|
|
}
|
|
return out, rows.Err()
|
|
}
|