Files
spsays-bff/internal/repository/post_repo.go
T

261 lines
8.3 KiB
Go

package repository
import (
"context"
"errors"
"fmt"
"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.
type PostRepo struct{ pg *Postgres }
func NewPostRepo(pg *Postgres) *PostRepo { return &PostRepo{pg: pg} }
// 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. 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.
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.
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`
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 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 public feed for a user, newest-first, paginated.
func (r *PostRepo) ListByHandle(ctx context.Context, handle string, limit, offset int) ([]model.Post, int, error) {
var total int
if err := r.pg.Pool.QueryRow(ctx, `
SELECT COUNT(*) FROM posts p
JOIN users u ON u.id = p.user_id
WHERE u.handle = $1`, handle,
).Scan(&total); err != nil {
return nil, 0, fmt.Errorf("count posts: %w", err)
}
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 u.handle = $1
ORDER BY p.created_at DESC
LIMIT $2 OFFSET $3`
return r.collectPosts(ctx, total, q, handle, limit, offset)
}
// ListByUser returns the user's own posts (private listing — same shape).
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 COUNT(*) FROM posts WHERE user_id = $1`, userID).Scan(&total); err != nil {
return nil, 0, fmt.Errorf("count posts: %w", err)
}
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.user_id = $1
ORDER BY p.created_at DESC
LIMIT $2 OFFSET $3`
return r.collectPosts(ctx, total, q, userID, limit, offset)
}
// Delete removes a post only if it belongs to the caller.
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 is used by services that need to know how many posts a user
// has — currently the public-profile endpoint only.
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 user posts: %w", err)
}
return n, nil
}
// --- 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 := rows.Scan(
&p.ID, &p.UserID, &p.Caption, &p.SourceURL, &p.SourceTitle,
&p.CreatedAt, &p.UpdatedAt,
&p.AuthorName, &p.AuthorHandle, &p.AuthorAvatarURL,
); 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()
}