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) } // 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) } // 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 UTC calendar day on or after `since`, // how many posts the user created that day. Keys are "YYYY-MM-DD"; days // with no posts are absent (so a missing key reads as zero). The sādhana // vine uses this both 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) (map[string]int, error) { rows, err := r.pg.Pool.Query(ctx, ` SELECT (created_at AT TIME ZONE 'UTC')::date AS d, count(*) AS n FROM posts WHERE user_id = $1 AND created_at >= $2 GROUP BY d`, userID, since, ) 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() }