package repository import ( "context" "errors" "fmt" "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgxpool" "osdb/internal/domain" "osdb/internal/handler" ) type PublicationRepo struct { pool *pgxpool.Pool } func NewPublicationRepo(pool *pgxpool.Pool) *PublicationRepo { return &PublicationRepo{pool: pool} } func pubImageURL(pubID int) string { return fmt.Sprintf("/api/v1/publications/%d/image", pubID) } func descURL(pubID, descID int) string { return fmt.Sprintf("/api/v1/publications/%d/descriptions/%d", pubID, descID) } func pubFruitImgURL(pubID, imgID int) string { return fmt.Sprintf("/api/v1/publications/%d/fruit-images/%d", pubID, imgID) } func mapPubPgError(err error) error { return mapPgError(err) } func mapPgErrorToPub(err error) error { e := mapPgError(err) if errors.Is(e, handler.ErrDuplicateOSDBNumber) { return handler.ErrDuplicateOSDBPubID } return e } func mapPgErrorToDesc(err error) error { e := mapPgError(err) if errors.Is(e, handler.ErrDuplicateOSDBNumber) { return handler.ErrDuplicatePubDescription } return e } func (r *PublicationRepo) List(ctx context.Context) ([]domain.Publication, error) { rows, err := r.pool.Query(ctx, `SELECT id, title, author, osdb_pub_id, CASE WHEN image_data IS NOT NULL THEN true ELSE false END AS has_image, created_at, updated_at FROM publications ORDER BY id`) if err != nil { return nil, err } defer rows.Close() pubs := []domain.Publication{} for rows.Next() { var p domain.Publication var hasImage bool if err := rows.Scan(&p.ID, &p.Title, &p.Author, &p.OSDBPubID, &hasImage, &p.CreatedAt, &p.UpdatedAt); err != nil { return nil, err } if hasImage { url := pubImageURL(p.ID) p.ImageURL = &url } pubs = append(pubs, p) } return pubs, rows.Err() } func (r *PublicationRepo) Get(ctx context.Context, id int) (domain.Publication, error) { var p domain.Publication var hasImage bool err := r.pool.QueryRow(ctx, `SELECT id, title, author, osdb_pub_id, CASE WHEN image_data IS NOT NULL THEN true ELSE false END, created_at, updated_at FROM publications WHERE id=$1`, id). Scan(&p.ID, &p.Title, &p.Author, &p.OSDBPubID, &hasImage, &p.CreatedAt, &p.UpdatedAt) if errors.Is(err, pgx.ErrNoRows) { return domain.Publication{}, handler.ErrNotFound } if err != nil { return domain.Publication{}, err } if hasImage { url := pubImageURL(p.ID) p.ImageURL = &url } return p, nil } func (r *PublicationRepo) Create(ctx context.Context, dto domain.PublicationWriteDTO) (domain.Publication, error) { var p domain.Publication err := r.pool.QueryRow(ctx, `INSERT INTO publications (title, author, osdb_pub_id) VALUES ($1, $2, $3) RETURNING id, title, author, osdb_pub_id, created_at, updated_at`, dto.Title, dto.Author, dto.OSDBPubID). Scan(&p.ID, &p.Title, &p.Author, &p.OSDBPubID, &p.CreatedAt, &p.UpdatedAt) if err != nil { return domain.Publication{}, mapPgErrorToPub(err) } return p, nil } func (r *PublicationRepo) Update(ctx context.Context, id int, dto domain.PublicationWriteDTO) (domain.Publication, error) { var p domain.Publication var hasImage bool err := r.pool.QueryRow(ctx, `UPDATE publications SET title=$1, author=$2, osdb_pub_id=$3, updated_at=NOW() WHERE id=$4 RETURNING id, title, author, osdb_pub_id, CASE WHEN image_data IS NOT NULL THEN true ELSE false END, created_at, updated_at`, dto.Title, dto.Author, dto.OSDBPubID, id). Scan(&p.ID, &p.Title, &p.Author, &p.OSDBPubID, &hasImage, &p.CreatedAt, &p.UpdatedAt) if errors.Is(err, pgx.ErrNoRows) { return domain.Publication{}, handler.ErrNotFound } if err != nil { return domain.Publication{}, mapPgErrorToPub(err) } if hasImage { url := pubImageURL(p.ID) p.ImageURL = &url } return p, nil } func (r *PublicationRepo) Delete(ctx context.Context, id int) error { tag, err := r.pool.Exec(ctx, `DELETE FROM publications WHERE id=$1`, id) if err != nil { return err } if tag.RowsAffected() == 0 { return handler.ErrNotFound } return nil } func (r *PublicationRepo) SetImage(ctx context.Context, pubID int, data []byte) error { tag, err := r.pool.Exec(ctx, `UPDATE publications SET image_data=$1, updated_at=NOW() WHERE id=$2`, data, pubID) if err != nil { return err } if tag.RowsAffected() == 0 { return handler.ErrNotFound } return nil } func (r *PublicationRepo) GetImageData(ctx context.Context, pubID int) ([]byte, error) { var data []byte err := r.pool.QueryRow(ctx, `SELECT image_data FROM publications WHERE id=$1`, pubID).Scan(&data) if errors.Is(err, pgx.ErrNoRows) { return nil, handler.ErrNotFound } if err != nil { return nil, err } if data == nil { return nil, handler.ErrNotFound } return data, nil } func (r *PublicationRepo) DeleteImage(ctx context.Context, pubID int) error { var data []byte err := r.pool.QueryRow(ctx, `UPDATE publications SET image_data=NULL, updated_at=NOW() WHERE id=$1 RETURNING image_data`, pubID). Scan(&data) if errors.Is(err, pgx.ErrNoRows) { return handler.ErrNotFound } return err } func (r *PublicationRepo) ListFruits(ctx context.Context, pubID int) ([]domain.PublicationFruit, error) { if err := r.requirePublication(ctx, pubID); err != nil { return nil, err } rows, err := r.pool.Query(ctx, `SELECT f.id, f.name, f.osdb_number FROM fruits f JOIN publication_fruits pf ON pf.fruit_id = f.id WHERE pf.publication_id=$1 ORDER BY f.name`, pubID) if err != nil { return nil, err } defer rows.Close() fruits := []domain.PublicationFruit{} for rows.Next() { var pf domain.PublicationFruit if err := rows.Scan(&pf.FruitID, &pf.Name, &pf.OSDBNumber); err != nil { return nil, err } fruits = append(fruits, pf) } return fruits, rows.Err() } func (r *PublicationRepo) LinkFruit(ctx context.Context, pubID, fruitID int) error { if err := r.requirePublication(ctx, pubID); err != nil { return err } _, err := r.pool.Exec(ctx, `INSERT INTO publication_fruits (publication_id, fruit_id) VALUES ($1, $2) ON CONFLICT DO NOTHING`, pubID, fruitID) return err } func (r *PublicationRepo) UnlinkFruit(ctx context.Context, pubID, fruitID int) error { tag, err := r.pool.Exec(ctx, `DELETE FROM publication_fruits WHERE publication_id=$1 AND fruit_id=$2`, pubID, fruitID) if err != nil { return err } if tag.RowsAffected() == 0 { return handler.ErrNotFound } return nil } func (r *PublicationRepo) ListDescriptions(ctx context.Context, pubID int) ([]domain.PublicationDescription, error) { if err := r.requirePublication(ctx, pubID); err != nil { return nil, err } rows, err := r.pool.Query(ctx, `SELECT pd.id, pd.publication_id, pd.fruit_id, f.name, p.title, pd.created_at FROM publication_descriptions pd JOIN fruits f ON f.id = pd.fruit_id JOIN publications p ON p.id = pd.publication_id WHERE pd.publication_id=$1 ORDER BY pd.id`, pubID) if err != nil { return nil, err } defer rows.Close() descs := []domain.PublicationDescription{} for rows.Next() { var d domain.PublicationDescription if err := rows.Scan(&d.ID, &d.PublicationID, &d.FruitID, &d.FruitName, &d.PublicationTitle, &d.CreatedAt); err != nil { return nil, err } d.URL = descURL(d.PublicationID, d.ID) descs = append(descs, d) } return descs, rows.Err() } func (r *PublicationRepo) AddDescription(ctx context.Context, pubID, fruitID int, data []byte) (domain.PublicationDescription, error) { if err := r.requirePublication(ctx, pubID); err != nil { return domain.PublicationDescription{}, err } var d domain.PublicationDescription err := r.pool.QueryRow(ctx, `INSERT INTO publication_descriptions (publication_id, fruit_id, pdf_data) VALUES ($1, $2, $3) RETURNING id, publication_id, fruit_id, created_at`, pubID, fruitID, data). Scan(&d.ID, &d.PublicationID, &d.FruitID, &d.CreatedAt) if err != nil { return domain.PublicationDescription{}, mapPgErrorToDesc(err) } d.URL = descURL(d.PublicationID, d.ID) return d, nil } func (r *PublicationRepo) GetDescriptionData(ctx context.Context, pubID, descID int) ([]byte, error) { var data []byte err := r.pool.QueryRow(ctx, `SELECT pdf_data FROM publication_descriptions WHERE id=$1 AND publication_id=$2`, descID, pubID).Scan(&data) if errors.Is(err, pgx.ErrNoRows) { return nil, handler.ErrNotFound } return data, err } func (r *PublicationRepo) DeleteDescription(ctx context.Context, pubID, descID int) error { tag, err := r.pool.Exec(ctx, `DELETE FROM publication_descriptions WHERE id=$1 AND publication_id=$2`, descID, pubID) if err != nil { return err } if tag.RowsAffected() == 0 { return handler.ErrNotFound } return nil } func (r *PublicationRepo) ListFruitImages(ctx context.Context, pubID int) ([]domain.PublicationFruitImage, error) { if err := r.requirePublication(ctx, pubID); err != nil { return nil, err } rows, err := r.pool.Query(ctx, `SELECT pfi.id, pfi.publication_id, p.title, p.author, pfi.fruit_id, pfi.filename, pfi.image_type, pfi.created_at FROM publication_fruit_images pfi JOIN publications p ON p.id = pfi.publication_id WHERE pfi.publication_id=$1 ORDER BY pfi.id`, pubID) if err != nil { return nil, err } defer rows.Close() imgs := []domain.PublicationFruitImage{} for rows.Next() { var img domain.PublicationFruitImage if err := rows.Scan(&img.ID, &img.PublicationID, &img.PublicationTitle, &img.PublicationAuthor, &img.FruitID, &img.Filename, &img.ImageType, &img.CreatedAt); err != nil { return nil, err } img.URL = pubFruitImgURL(img.PublicationID, img.ID) imgs = append(imgs, img) } return imgs, rows.Err() } func (r *PublicationRepo) AddFruitImage(ctx context.Context, pubID, fruitID int, filename *string, data []byte) (domain.PublicationFruitImage, error) { if err := r.requirePublication(ctx, pubID); err != nil { return domain.PublicationFruitImage{}, err } var img domain.PublicationFruitImage err := r.pool.QueryRow(ctx, `INSERT INTO publication_fruit_images (publication_id, fruit_id, filename, data, image_type) VALUES ($1, $2, $3, $4, 'fruit') RETURNING id, publication_id, fruit_id, filename, image_type, created_at`, pubID, fruitID, filename, data). Scan(&img.ID, &img.PublicationID, &img.FruitID, &img.Filename, &img.ImageType, &img.CreatedAt) if err != nil { return domain.PublicationFruitImage{}, err } img.URL = pubFruitImgURL(img.PublicationID, img.ID) return img, nil } func (r *PublicationRepo) GetFruitImageData(ctx context.Context, pubID, imgID int) ([]byte, error) { var data []byte err := r.pool.QueryRow(ctx, `SELECT data FROM publication_fruit_images WHERE id=$1 AND publication_id=$2`, imgID, pubID).Scan(&data) if errors.Is(err, pgx.ErrNoRows) { return nil, handler.ErrNotFound } return data, err } func (r *PublicationRepo) DeleteFruitImage(ctx context.Context, pubID, imgID int) error { tag, err := r.pool.Exec(ctx, `DELETE FROM publication_fruit_images WHERE id=$1 AND publication_id=$2`, imgID, pubID) if err != nil { return err } if tag.RowsAffected() == 0 { return handler.ErrNotFound } return nil } func (r *PublicationRepo) GetFruitDescriptions(ctx context.Context, fruitID int) ([]domain.PublicationDescription, error) { rows, err := r.pool.Query(ctx, `SELECT pd.id, pd.publication_id, pd.fruit_id, f.name, p.title, pd.created_at FROM publication_descriptions pd JOIN fruits f ON f.id = pd.fruit_id JOIN publications p ON p.id = pd.publication_id WHERE pd.fruit_id=$1 ORDER BY pd.id`, fruitID) if err != nil { return nil, err } defer rows.Close() descs := []domain.PublicationDescription{} for rows.Next() { var d domain.PublicationDescription if err := rows.Scan(&d.ID, &d.PublicationID, &d.FruitID, &d.FruitName, &d.PublicationTitle, &d.CreatedAt); err != nil { return nil, err } d.URL = descURL(d.PublicationID, d.ID) descs = append(descs, d) } return descs, rows.Err() } func (r *PublicationRepo) GetFruitPublicationImages(ctx context.Context, fruitID int) ([]domain.PublicationFruitImage, error) { rows, err := r.pool.Query(ctx, `SELECT pfi.id, pfi.publication_id, p.title, p.author, pfi.fruit_id, pfi.filename, pfi.image_type, pfi.created_at FROM publication_fruit_images pfi JOIN publications p ON p.id = pfi.publication_id WHERE pfi.fruit_id=$1 ORDER BY pfi.id`, fruitID) if err != nil { return nil, err } defer rows.Close() imgs := []domain.PublicationFruitImage{} for rows.Next() { var img domain.PublicationFruitImage if err := rows.Scan(&img.ID, &img.PublicationID, &img.PublicationTitle, &img.PublicationAuthor, &img.FruitID, &img.Filename, &img.ImageType, &img.CreatedAt); err != nil { return nil, err } img.URL = pubFruitImgURL(img.PublicationID, img.ID) imgs = append(imgs, img) } return imgs, rows.Err() } func (r *PublicationRepo) requirePublication(ctx context.Context, pubID int) error { var exists bool err := r.pool.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM publications WHERE id=$1)`, pubID).Scan(&exists) if err != nil { return err } if !exists { return handler.ErrNotFound } return nil }