Files
osdb-1-claude-opusplan/backend/internal/repository/publication_repo.go
T
juliaandClaude Sonnet 4.6 649c9687ac feat: fruit overview with thumbnails and card layout (story #07)
- Auto-crop thumbnails on image upload (Go stdlib, no external deps):
  find non-background bounding box, pad 5px, scale to ≤300px, encode JPEG
- Add thumbnail_data BYTEA column to fruit_images and publication_fruit_images
- New GET /api/v1/fruits/:id/images/:imageId/thumbnail endpoint (fallback to full data)
- New GET /api/v1/publications/:id/fruit-images/:imgId/thumbnail endpoint
- New POST /api/v1/admin/backfill-thumbnails to retroactively generate thumbnails
- ListFruits response now includes synonyms and thumbnail_url per fruit
- Replace FruitList table with responsive FruitCard grid (thumbnail + name + OSDB ID + type + synonyms)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-19 09:02:14 +02:00

489 lines
14 KiB
Go

package repository
import (
"context"
"errors"
"fmt"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
"osdb/internal/domain"
"osdb/internal/handler"
"osdb/internal/imaging"
)
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
}
thumbnailData, _ := imaging.CropToContent(data)
var img domain.PublicationFruitImage
err := r.pool.QueryRow(ctx,
`INSERT INTO publication_fruit_images (publication_id, fruit_id, filename, data, thumbnail_data, image_type)
VALUES ($1, $2, $3, $4, $5, 'fruit')
RETURNING id, publication_id, fruit_id, filename, image_type, created_at`,
pubID, fruitID, filename, data, thumbnailData).
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
}
// GetFruitImageThumbnailData returns thumbnail_data if present, falls back to data.
func (r *PublicationRepo) GetFruitImageThumbnailData(ctx context.Context, pubID, imgID int) ([]byte, error) {
var thumbnailData, data []byte
err := r.pool.QueryRow(ctx,
`SELECT thumbnail_data, data FROM publication_fruit_images WHERE id=$1 AND publication_id=$2`,
imgID, pubID).Scan(&thumbnailData, &data)
if errors.Is(err, pgx.ErrNoRows) {
return nil, handler.ErrNotFound
}
if err != nil {
return nil, err
}
if len(thumbnailData) > 0 {
return thumbnailData, nil
}
return data, nil
}
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
}
// BackfillThumbnails generates thumbnail_data for all publication_fruit_images rows missing it.
// Returns count of rows processed.
func (r *PublicationRepo) BackfillThumbnails(ctx context.Context) (int, error) {
rows, err := r.pool.Query(ctx,
`SELECT id, data FROM publication_fruit_images WHERE thumbnail_data IS NULL`)
if err != nil {
return 0, err
}
defer rows.Close()
type row struct {
id int
data []byte
}
var pending []row
for rows.Next() {
var rw row
if err := rows.Scan(&rw.id, &rw.data); err != nil {
return 0, err
}
pending = append(pending, rw)
}
if err := rows.Err(); err != nil {
return 0, err
}
count := 0
for _, rw := range pending {
thumb, _ := imaging.CropToContent(rw.data)
if _, err := r.pool.Exec(ctx,
`UPDATE publication_fruit_images SET thumbnail_data=$1 WHERE id=$2`, thumb, rw.id); err != nil {
return count, err
}
count++
}
return count, nil
}