package repository import ( "context" "errors" "fmt" "github.com/jackc/pgx/v5" "github.com/prabhupadasays/spsays-bff/internal/model" ) var ErrCollectionNotFound = errors.New("collection not found") type CollectionRepo struct{ pg *Postgres } func NewCollectionRepo(pg *Postgres) *CollectionRepo { return &CollectionRepo{pg: pg} } // Create inserts a new collection owned by userID. func (r *CollectionRepo) Create(ctx context.Context, userID, title string, description, coverURL *string, isPublic bool) (*model.CollectionRecord, error) { c := &model.CollectionRecord{} err := r.pg.Pool.QueryRow(ctx, ` INSERT INTO collections (user_id, title, description, cover_url, is_public) VALUES ($1, $2, $3, $4, $5) RETURNING id, user_id, title, description, cover_url, is_public, item_count, created_at, updated_at`, userID, title, description, coverURL, isPublic, ).Scan(&c.ID, &c.UserID, &c.Title, &c.Description, &c.CoverURL, &c.IsPublic, &c.ItemCount, &c.CreatedAt, &c.UpdatedAt) if err != nil { return nil, fmt.Errorf("create collection: %w", err) } return c, nil } // FindByID returns one collection by id (no owner check — caller decides // what to do with the visibility flag). func (r *CollectionRepo) FindByID(ctx context.Context, id string) (*model.CollectionRecord, error) { c := &model.CollectionRecord{} err := r.pg.Pool.QueryRow(ctx, ` SELECT id, user_id, title, description, cover_url, is_public, item_count, created_at, updated_at FROM collections WHERE id = $1`, id, ).Scan(&c.ID, &c.UserID, &c.Title, &c.Description, &c.CoverURL, &c.IsPublic, &c.ItemCount, &c.CreatedAt, &c.UpdatedAt) if err != nil { if errors.Is(err, pgx.ErrNoRows) { return nil, ErrCollectionNotFound } return nil, fmt.Errorf("find collection: %w", err) } return c, nil } // ListByUser returns all collections for the caller (own listing). func (r *CollectionRepo) ListByUser(ctx context.Context, userID string) ([]model.CollectionRecord, error) { return r.list(ctx, `WHERE user_id = $1 ORDER BY updated_at DESC`, userID) } // ListPublicByUser returns only the public collections for /u/. func (r *CollectionRepo) ListPublicByUser(ctx context.Context, userID string) ([]model.CollectionRecord, error) { return r.list(ctx, `WHERE user_id = $1 AND is_public = TRUE ORDER BY updated_at DESC`, userID) } // CountByUserAndVisibility — used by the plan-limit enforcer // (free: max 5 public, 5 private). func (r *CollectionRepo) CountByUserAndVisibility(ctx context.Context, userID string, isPublic bool) (int, error) { var n int err := r.pg.Pool.QueryRow(ctx, ` SELECT COUNT(*) FROM collections WHERE user_id = $1 AND is_public = $2`, userID, isPublic, ).Scan(&n) if err != nil { return 0, fmt.Errorf("count collections: %w", err) } return n, nil } // Update modifies title/description/cover/visibility. Nil pointers mean // "leave unchanged"; isPublic is unconditional (use a pointer if needed). func (r *CollectionRepo) Update(ctx context.Context, id, userID string, title, description, coverURL *string, isPublic *bool) (*model.CollectionRecord, error) { const q = ` UPDATE collections SET title = COALESCE($3, title), description = COALESCE($4, description), cover_url = CASE WHEN $5::boolean THEN $6 ELSE cover_url END, is_public = COALESCE($7, is_public), updated_at = now() WHERE id = $1 AND user_id = $2 RETURNING id, user_id, title, description, cover_url, is_public, item_count, created_at, updated_at` updateCover := coverURL != nil var coverVal *string if updateCover && *coverURL != "" { coverVal = coverURL } c := &model.CollectionRecord{} err := r.pg.Pool.QueryRow(ctx, q, id, userID, title, description, updateCover, coverVal, isPublic). Scan(&c.ID, &c.UserID, &c.Title, &c.Description, &c.CoverURL, &c.IsPublic, &c.ItemCount, &c.CreatedAt, &c.UpdatedAt) if err != nil { if errors.Is(err, pgx.ErrNoRows) { return nil, ErrCollectionNotFound } return nil, fmt.Errorf("update collection: %w", err) } return c, nil } // Delete removes a collection (cascades to collection_items via FK). func (r *CollectionRepo) Delete(ctx context.Context, id, userID string) error { tag, err := r.pg.Pool.Exec(ctx, `DELETE FROM collections WHERE id = $1 AND user_id = $2`, id, userID) if err != nil { return fmt.Errorf("delete collection: %w", err) } if tag.RowsAffected() == 0 { return ErrCollectionNotFound } return nil } // AddItem inserts a post into a collection. Idempotent — ON CONFLICT. func (r *CollectionRepo) AddItem(ctx context.Context, collectionID, postID string) error { _, err := r.pg.Pool.Exec(ctx, ` INSERT INTO collection_items (collection_id, post_id, position) VALUES ($1, $2, COALESCE( (SELECT MAX(position) + 1 FROM collection_items WHERE collection_id = $1), 0 )) ON CONFLICT (collection_id, post_id) DO NOTHING`, collectionID, postID, ) if err != nil { return fmt.Errorf("add collection item: %w", err) } return nil } // SetItemOrder rewrites positions for the given posts so the new order // matches the input array. Runs in a single transaction so concurrent // reads see either the old order or the new one — never a half-applied // state. Posts not in the input list are left untouched (they keep // whatever position they had); callers should pass the full membership // when they want a clean ordering. func (r *CollectionRepo) SetItemOrder(ctx context.Context, collectionID string, postIDs []string) error { if len(postIDs) == 0 { return nil } tx, err := r.pg.Pool.Begin(ctx) if err != nil { return fmt.Errorf("begin tx: %w", err) } defer func() { _ = tx.Rollback(ctx) }() for i, pid := range postIDs { if _, err := tx.Exec(ctx, ` UPDATE collection_items SET position = $1 WHERE collection_id = $2 AND post_id = $3`, i, collectionID, pid, ); err != nil { return fmt.Errorf("set position %d: %w", i, err) } } return tx.Commit(ctx) } // RemoveItem deletes a post from a collection. func (r *CollectionRepo) RemoveItem(ctx context.Context, collectionID, postID string) error { _, err := r.pg.Pool.Exec(ctx, ` DELETE FROM collection_items WHERE collection_id = $1 AND post_id = $2`, collectionID, postID, ) if err != nil { return fmt.Errorf("remove collection item: %w", err) } return nil } // CollectionsContaining returns the ids of collections owned by // `userID` that already include `postID`. The drawer uses this to grey // out rows the caller can't add the post to a second time. func (r *CollectionRepo) CollectionsContaining(ctx context.Context, postID, userID string) ([]string, error) { rows, err := r.pg.Pool.Query(ctx, ` SELECT ci.collection_id FROM collection_items ci JOIN collections c ON c.id = ci.collection_id WHERE ci.post_id = $1 AND c.user_id = $2`, postID, userID, ) if err != nil { return nil, fmt.Errorf("collections containing post: %w", err) } defer rows.Close() ids := []string{} for rows.Next() { var id string if err := rows.Scan(&id); err != nil { return nil, fmt.Errorf("scan collection id: %w", err) } ids = append(ids, id) } return ids, rows.Err() } // PostsIn returns the posts inside a collection, ordered by collection // position then added_at. Images are NOT hydrated — call // PostRepo.ImagesFor on the returned ids if your caller renders the // post visually. The CollectionService.Get path already does this. func (r *CollectionRepo) PostsIn(ctx context.Context, collectionID string) ([]model.Post, error) { rows, err := r.pg.Pool.Query(ctx, ` 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, p.is_hidden FROM collection_items ci JOIN posts p ON p.id = ci.post_id JOIN users u ON u.id = p.user_id WHERE ci.collection_id = $1 AND p.deleted_at IS NULL ORDER BY ci.position, ci.added_at`, collectionID, ) if err != nil { return nil, fmt.Errorf("query collection 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.IsHidden, ); err != nil { return nil, fmt.Errorf("scan post: %w", err) } posts = append(posts, p) ids = append(ids, p.ID) } if err := rows.Err(); err != nil { return nil, fmt.Errorf("iterate collection posts: %w", err) } return posts, nil } // --- internal helpers --- func (r *CollectionRepo) list(ctx context.Context, where string, args ...any) ([]model.CollectionRecord, error) { rows, err := r.pg.Pool.Query(ctx, ` SELECT id, user_id, title, description, cover_url, is_public, item_count, created_at, updated_at FROM collections `+where, args..., ) if err != nil { return nil, fmt.Errorf("query collections: %w", err) } defer rows.Close() out := []model.CollectionRecord{} for rows.Next() { var c model.CollectionRecord if err := rows.Scan(&c.ID, &c.UserID, &c.Title, &c.Description, &c.CoverURL, &c.IsPublic, &c.ItemCount, &c.CreatedAt, &c.UpdatedAt); err != nil { return nil, fmt.Errorf("scan collection: %w", err) } out = append(out, c) } return out, rows.Err() }