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() }