Files
osdb-1-claude-opusplan/backend/internal/repository/fruit_repo.go
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

384 lines
11 KiB
Go

package repository
import (
"context"
"errors"
"fmt"
"strings"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgconn"
"github.com/jackc/pgx/v5/pgxpool"
"osdb/internal/domain"
"osdb/internal/handler"
"osdb/internal/imaging"
)
type FruitRepo struct {
pool *pgxpool.Pool
}
func NewFruitRepo(pool *pgxpool.Pool) *FruitRepo {
return &FruitRepo{pool: pool}
}
func imageURL(fruitID, imageID int) string {
return fmt.Sprintf("/api/v1/fruits/%d/images/%d", fruitID, imageID)
}
func thumbnailURL(fruitID, imageID int) string {
return fmt.Sprintf("/api/v1/fruits/%d/images/%d/thumbnail", fruitID, imageID)
}
func mapPgError(err error) error {
var pgErr *pgconn.PgError
if errors.As(err, &pgErr) && pgErr.Code == "23505" {
return handler.ErrDuplicateOSDBNumber
}
return err
}
func escapeLike(s string) string {
s = strings.ReplaceAll(s, `\`, `\\`)
s = strings.ReplaceAll(s, `%`, `\%`)
s = strings.ReplaceAll(s, `_`, `\_`)
return s
}
func (r *FruitRepo) List(ctx context.Context, limit, offset int, name string, types []string) ([]domain.Fruit, int, error) {
escaped := escapeLike(name)
countRow := r.pool.QueryRow(ctx,
`SELECT COUNT(DISTINCT f.id)
FROM fruits f
LEFT JOIN fruit_synonyms fs ON fs.fruit_id = f.id
WHERE ($1 = '' OR f.name ILIKE '%' || $1 || '%' ESCAPE '\' OR fs.synonym ILIKE '%' || $1 || '%' ESCAPE '\')
AND ($2::fruit_type[] IS NULL OR f.fruit_type = ANY($2::fruit_type[]))`,
escaped, types)
var total int
if err := countRow.Scan(&total); err != nil {
return nil, 0, err
}
rows, err := r.pool.Query(ctx,
`SELECT DISTINCT f.id, f.name, f.osdb_number, f.comment, f.fruit_type, f.created_at, f.updated_at,
COALESCE((SELECT array_agg(fs2.synonym ORDER BY fs2.id) FROM fruit_synonyms fs2 WHERE fs2.fruit_id = f.id), '{}') AS synonyms,
COALESCE(
(SELECT '/api/v1/fruits/' || f.id || '/images/' || fi.id || '/thumbnail'
FROM fruit_images fi WHERE fi.fruit_id = f.id AND fi.image_type = 'fruit' ORDER BY fi.id LIMIT 1),
(SELECT '/api/v1/publications/' || pfi.publication_id || '/fruit-images/' || pfi.id || '/thumbnail'
FROM publication_fruit_images pfi WHERE pfi.fruit_id = f.id ORDER BY pfi.id LIMIT 1)
) AS thumbnail_url
FROM fruits f
LEFT JOIN fruit_synonyms fs ON fs.fruit_id = f.id
WHERE ($1 = '' OR f.name ILIKE '%' || $1 || '%' ESCAPE '\' OR fs.synonym ILIKE '%' || $1 || '%' ESCAPE '\')
AND ($2::fruit_type[] IS NULL OR f.fruit_type = ANY($2::fruit_type[]))
ORDER BY f.name, f.id LIMIT $3 OFFSET $4`,
escaped, types, limit, offset)
if err != nil {
return nil, 0, err
}
defer rows.Close()
fruits := []domain.Fruit{}
for rows.Next() {
var f domain.Fruit
if err := rows.Scan(&f.ID, &f.Name, &f.OSDBNumber, &f.Comment, &f.FruitType, &f.CreatedAt, &f.UpdatedAt,
&f.Synonyms, &f.ThumbnailURL); err != nil {
return nil, 0, err
}
if f.Synonyms == nil {
f.Synonyms = []string{}
}
f.Images = []domain.FruitImage{}
fruits = append(fruits, f)
}
if err := rows.Err(); err != nil {
return nil, 0, err
}
return fruits, total, nil
}
func (r *FruitRepo) Get(ctx context.Context, id int) (domain.Fruit, error) {
var f domain.Fruit
err := r.pool.QueryRow(ctx,
`SELECT id, name, osdb_number, comment, fruit_type, created_at, updated_at
FROM fruits WHERE id = $1`, id).
Scan(&f.ID, &f.Name, &f.OSDBNumber, &f.Comment, &f.FruitType, &f.CreatedAt, &f.UpdatedAt)
if errors.Is(err, pgx.ErrNoRows) {
return domain.Fruit{}, handler.ErrNotFound
}
if err != nil {
return domain.Fruit{}, err
}
// load synonyms
synRows, err := r.pool.Query(ctx, `SELECT synonym FROM fruit_synonyms WHERE fruit_id = $1 ORDER BY id`, id)
if err != nil {
return domain.Fruit{}, err
}
defer synRows.Close()
f.Synonyms = []string{}
for synRows.Next() {
var s string
if err := synRows.Scan(&s); err != nil {
return domain.Fruit{}, err
}
f.Synonyms = append(f.Synonyms, s)
}
if err := synRows.Err(); err != nil {
return domain.Fruit{}, err
}
// load image metadata (no binary)
imgRows, err := r.pool.Query(ctx,
`SELECT id, fruit_id, filename, image_type, title, created_at
FROM fruit_images WHERE fruit_id = $1 ORDER BY id`, id)
if err != nil {
return domain.Fruit{}, err
}
defer imgRows.Close()
f.Images = []domain.FruitImage{}
for imgRows.Next() {
var img domain.FruitImage
if err := imgRows.Scan(&img.ID, &img.FruitID, &img.Filename, &img.ImageType, &img.Title, &img.CreatedAt); err != nil {
return domain.Fruit{}, err
}
img.URL = imageURL(img.FruitID, img.ID)
f.Images = append(f.Images, img)
}
if err := imgRows.Err(); err != nil {
return domain.Fruit{}, err
}
return f, nil
}
func (r *FruitRepo) Create(ctx context.Context, dto domain.FruitWriteDTO) (domain.Fruit, error) {
tx, err := r.pool.Begin(ctx)
if err != nil {
return domain.Fruit{}, err
}
defer tx.Rollback(ctx) //nolint:errcheck
var f domain.Fruit
err = tx.QueryRow(ctx,
`INSERT INTO fruits (name, osdb_number, comment, fruit_type)
VALUES ($1, $2, $3, $4)
RETURNING id, name, osdb_number, comment, fruit_type, created_at, updated_at`,
dto.Name, dto.OSDBNumber, dto.Comment, dto.FruitType).
Scan(&f.ID, &f.Name, &f.OSDBNumber, &f.Comment, &f.FruitType, &f.CreatedAt, &f.UpdatedAt)
if err != nil {
return domain.Fruit{}, mapPgError(err)
}
syns := dto.Synonyms
if syns == nil {
syns = []string{}
}
for _, s := range syns {
if _, err := tx.Exec(ctx,
`INSERT INTO fruit_synonyms (fruit_id, synonym) VALUES ($1, $2)`, f.ID, s); err != nil {
return domain.Fruit{}, err
}
}
if err := tx.Commit(ctx); err != nil {
return domain.Fruit{}, err
}
f.Synonyms = syns
f.Images = []domain.FruitImage{}
return f, nil
}
func (r *FruitRepo) Update(ctx context.Context, id int, dto domain.FruitWriteDTO) (domain.Fruit, error) {
tx, err := r.pool.Begin(ctx)
if err != nil {
return domain.Fruit{}, err
}
defer tx.Rollback(ctx) //nolint:errcheck
var f domain.Fruit
err = tx.QueryRow(ctx,
`UPDATE fruits SET name=$1, osdb_number=$2, comment=$3, fruit_type=$4, updated_at=NOW()
WHERE id=$5
RETURNING id, name, osdb_number, comment, fruit_type, created_at, updated_at`,
dto.Name, dto.OSDBNumber, dto.Comment, dto.FruitType, id).
Scan(&f.ID, &f.Name, &f.OSDBNumber, &f.Comment, &f.FruitType, &f.CreatedAt, &f.UpdatedAt)
if errors.Is(err, pgx.ErrNoRows) {
return domain.Fruit{}, handler.ErrNotFound
}
if err != nil {
return domain.Fruit{}, mapPgError(err)
}
if _, err := tx.Exec(ctx, `DELETE FROM fruit_synonyms WHERE fruit_id=$1`, id); err != nil {
return domain.Fruit{}, err
}
syns := dto.Synonyms
if syns == nil {
syns = []string{}
}
for _, s := range syns {
if _, err := tx.Exec(ctx,
`INSERT INTO fruit_synonyms (fruit_id, synonym) VALUES ($1, $2)`, id, s); err != nil {
return domain.Fruit{}, err
}
}
if err := tx.Commit(ctx); err != nil {
return domain.Fruit{}, err
}
f.Synonyms = syns
f.Images = []domain.FruitImage{}
return f, nil
}
func (r *FruitRepo) Delete(ctx context.Context, id int) error {
tag, err := r.pool.Exec(ctx, `DELETE FROM fruits WHERE id=$1`, id)
if err != nil {
return err
}
if tag.RowsAffected() == 0 {
return handler.ErrNotFound
}
return nil
}
func (r *FruitRepo) ListImages(ctx context.Context, fruitID int) ([]domain.FruitImage, error) {
// verify fruit exists
var exists bool
if err := r.pool.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM fruits WHERE id=$1)`, fruitID).Scan(&exists); err != nil {
return nil, err
}
if !exists {
return nil, handler.ErrNotFound
}
rows, err := r.pool.Query(ctx,
`SELECT id, fruit_id, filename, image_type, title, created_at
FROM fruit_images WHERE fruit_id=$1 ORDER BY id`, fruitID)
if err != nil {
return nil, err
}
defer rows.Close()
images := []domain.FruitImage{}
for rows.Next() {
var img domain.FruitImage
if err := rows.Scan(&img.ID, &img.FruitID, &img.Filename, &img.ImageType, &img.Title, &img.CreatedAt); err != nil {
return nil, err
}
img.URL = imageURL(img.FruitID, img.ID)
images = append(images, img)
}
if err := rows.Err(); err != nil {
return nil, err
}
return images, nil
}
func (r *FruitRepo) AddImage(ctx context.Context, fruitID int, filename *string, data []byte, imageType string, title *string) (domain.FruitImage, error) {
var exists bool
if err := r.pool.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM fruits WHERE id=$1)`, fruitID).Scan(&exists); err != nil {
return domain.FruitImage{}, err
}
if !exists {
return domain.FruitImage{}, handler.ErrNotFound
}
thumbnailData, _ := imaging.CropToContent(data)
var img domain.FruitImage
err := r.pool.QueryRow(ctx,
`INSERT INTO fruit_images (fruit_id, filename, data, thumbnail_data, image_type, title)
VALUES ($1, $2, $3, $4, $5, $6)
RETURNING id, fruit_id, filename, image_type, title, created_at`,
fruitID, filename, data, thumbnailData, imageType, title).
Scan(&img.ID, &img.FruitID, &img.Filename, &img.ImageType, &img.Title, &img.CreatedAt)
if err != nil {
return domain.FruitImage{}, err
}
img.URL = imageURL(img.FruitID, img.ID)
return img, nil
}
func (r *FruitRepo) GetImageData(ctx context.Context, fruitID, imageID int) ([]byte, error) {
var data []byte
err := r.pool.QueryRow(ctx,
`SELECT data FROM fruit_images WHERE id=$1 AND fruit_id=$2`, imageID, fruitID).Scan(&data)
if errors.Is(err, pgx.ErrNoRows) {
return nil, handler.ErrNotFound
}
if err != nil {
return nil, err
}
return data, nil
}
// GetThumbnailData returns thumbnail_data if present, falls back to data.
func (r *FruitRepo) GetThumbnailData(ctx context.Context, fruitID, imageID int) ([]byte, error) {
var thumbnailData, data []byte
err := r.pool.QueryRow(ctx,
`SELECT thumbnail_data, data FROM fruit_images WHERE id=$1 AND fruit_id=$2`, imageID, fruitID).
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 *FruitRepo) DeleteImage(ctx context.Context, fruitID, imageID int) error {
tag, err := r.pool.Exec(ctx, `DELETE FROM fruit_images WHERE id=$1 AND fruit_id=$2`, imageID, fruitID)
if err != nil {
return err
}
if tag.RowsAffected() == 0 {
return handler.ErrNotFound
}
return nil
}
// BackfillThumbnails generates thumbnail_data for all fruit_images rows missing it.
// Returns count of rows processed.
func (r *FruitRepo) BackfillThumbnails(ctx context.Context) (int, error) {
rows, err := r.pool.Query(ctx, `SELECT id, fruit_id, data FROM fruit_images WHERE thumbnail_data IS NULL`)
if err != nil {
return 0, err
}
defer rows.Close()
type row struct {
id, fruitID int
data []byte
}
var pending []row
for rows.Next() {
var rw row
if err := rows.Scan(&rw.id, &rw.fruitID, &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 fruit_images SET thumbnail_data=$1 WHERE id=$2`, thumb, rw.id); err != nil {
return count, err
}
count++
}
return count, nil
}