352 lines
12 KiB
Go
352 lines
12 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 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.
|
|
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)
|
|
}
|
|
|
|
// 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.
|
|
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)
|
|
}
|
|
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
|
|
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 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.
|
|
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,
|
|
).Scan(&n)
|
|
if err != nil {
|
|
return 0, fmt.Errorf("count feed: %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,
|
|
&p.ResharedAt,
|
|
); 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
|
|
}
|
|
|
|
// 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
|
|
// 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()
|
|
}
|