265 lines
9.1 KiB
Go
265 lines
9.1 KiB
Go
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/<handle>.
|
|
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
|
|
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()
|
|
}
|