Files
spsays-bff/internal/repository/post_repo.go
T
2026-06-16 14:47:45 +05:30

683 lines
23 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,
p.is_hidden
`
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,
&p.IsHidden,
)
}
// 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 AND p.deleted_at IS NULL`
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)
}
// Hidden posts are dropped from the mentions timeline too (the mentioned
// user is not the author, so there is no owner exception here).
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 AND p.is_hidden = FALSE AND p.deleted_at IS NULL`,
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 AND p.is_hidden = FALSE AND p.deleted_at IS NULL
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.
//
// Hidden posts are excluded from this timeline for everyone, including the
// owner — they live in the owner's separate "Hidden" list (ListHiddenByUser).
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 AND p.is_hidden = FALSE AND p.deleted_at IS NULL
ORDER BY p.created_at DESC, p.id
LIMIT $2 OFFSET $3`
return r.collectPosts(ctx, total, q, userID, limit, offset)
}
// ListHiddenByUser returns the owner's hidden posts, newest first — the
// /u/me "Hidden" tab. Owner-only (the caller enforces auth + scoping).
func (r *PostRepo) ListHiddenByUser(ctx context.Context, userID string, limit, offset int) ([]model.Post, int, error) {
total, err := r.CountHiddenByUser(ctx, userID)
if err != nil {
return nil, 0, err
}
q := `SELECT ` + postSelectColumns + postSelectFrom + `
WHERE p.user_id = $1 AND p.is_hidden = TRUE AND p.deleted_at IS NULL
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.is_hidden = FALSE AND p.deleted_at IS NULL
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
}
// SetHidden flips a post's hidden flag. Owner-only: the WHERE clause scopes
// to user_id, so a non-owner (or unknown id) affects no rows → ErrPostNotFound.
func (r *PostRepo) SetHidden(ctx context.Context, postID, userID string, hidden bool) error {
tag, err := r.pg.Pool.Exec(ctx,
`UPDATE posts SET is_hidden = $3, updated_at = now() WHERE id = $1 AND user_id = $2`,
postID, userID, hidden)
if err != nil {
return fmt.Errorf("set post hidden: %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.
// CountByUser counts the user's visible (non-hidden) posts — matches the
// timeline shown on their profile.
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 AND is_hidden = FALSE AND deleted_at IS NULL`, userID,
).Scan(&n)
if err != nil {
return 0, fmt.Errorf("count posts: %w", err)
}
return n, nil
}
// CountHiddenByUser counts the owner's hidden posts (drives the "Hidden" tab's
// visibility + badge).
func (r *PostRepo) CountHiddenByUser(ctx context.Context, userID string) (int, error) {
var n int
err := r.pg.Pool.QueryRow(ctx,
`SELECT COUNT(*) FROM posts WHERE user_id = $1 AND is_hidden = TRUE AND deleted_at IS NULL`, userID,
).Scan(&n)
if err != nil {
return 0, fmt.Errorf("count hidden 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()
}