Files
osdb-1-claude-opusplan/backend/internal/handler/publication_handler.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

451 lines
13 KiB
Go

package handler
import (
"context"
"errors"
"io"
"net/http"
"strconv"
"strings"
"github.com/labstack/echo/v4"
"osdb/internal/domain"
)
var (
ErrDuplicateOSDBPubID = errors.New("duplicate osdb_pub_id")
ErrDuplicatePubDescription = errors.New("duplicate publication description for this fruit")
)
// PublicationRepository is the consumer-defined interface the handler depends on.
type PublicationRepository interface {
List(ctx context.Context) ([]domain.Publication, error)
Get(ctx context.Context, id int) (domain.Publication, error)
Create(ctx context.Context, dto domain.PublicationWriteDTO) (domain.Publication, error)
Update(ctx context.Context, id int, dto domain.PublicationWriteDTO) (domain.Publication, error)
Delete(ctx context.Context, id int) error
SetImage(ctx context.Context, pubID int, data []byte) error
GetImageData(ctx context.Context, pubID int) ([]byte, error)
DeleteImage(ctx context.Context, pubID int) error
ListFruits(ctx context.Context, pubID int) ([]domain.PublicationFruit, error)
LinkFruit(ctx context.Context, pubID, fruitID int) error
UnlinkFruit(ctx context.Context, pubID, fruitID int) error
ListDescriptions(ctx context.Context, pubID int) ([]domain.PublicationDescription, error)
AddDescription(ctx context.Context, pubID, fruitID int, data []byte) (domain.PublicationDescription, error)
GetDescriptionData(ctx context.Context, pubID, descID int) ([]byte, error)
DeleteDescription(ctx context.Context, pubID, descID int) error
ListFruitImages(ctx context.Context, pubID int) ([]domain.PublicationFruitImage, error)
AddFruitImage(ctx context.Context, pubID, fruitID int, filename *string, data []byte) (domain.PublicationFruitImage, error)
GetFruitImageData(ctx context.Context, pubID, imgID int) ([]byte, error)
GetFruitImageThumbnailData(ctx context.Context, pubID, imgID int) ([]byte, error)
DeleteFruitImage(ctx context.Context, pubID, imgID int) error
GetFruitDescriptions(ctx context.Context, fruitID int) ([]domain.PublicationDescription, error)
GetFruitPublicationImages(ctx context.Context, fruitID int) ([]domain.PublicationFruitImage, error)
}
type PublicationHandler struct {
repo PublicationRepository
}
func NewPublicationHandler(repo PublicationRepository) *PublicationHandler {
return &PublicationHandler{repo: repo}
}
func validatePublication(dto domain.PublicationWriteDTO) []string {
var errs []string
if strings.TrimSpace(dto.Title) == "" {
errs = append(errs, "title is required")
}
if strings.TrimSpace(dto.OSDBPubID) == "" {
errs = append(errs, "osdb_pub_id is required")
}
return errs
}
func mapPubRepoError(c echo.Context, err error) error {
switch {
case errors.Is(err, ErrNotFound):
return c.JSON(http.StatusNotFound, map[string]string{"error": "not found"})
case errors.Is(err, ErrDuplicateOSDBPubID):
return c.JSON(http.StatusConflict, map[string]string{"error": "osdb_pub_id already exists"})
case errors.Is(err, ErrDuplicatePubDescription):
return c.JSON(http.StatusConflict, map[string]string{"error": "description already exists for this fruit in this publication"})
default:
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "internal server error"})
}
}
func (h *PublicationHandler) List(c echo.Context) error {
pubs, err := h.repo.List(c.Request().Context())
if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "internal server error"})
}
if pubs == nil {
pubs = []domain.Publication{}
}
return c.JSON(http.StatusOK, pubs)
}
func (h *PublicationHandler) Get(c echo.Context) error {
id, err := parseID(c, "id")
if err != nil {
return err
}
pub, err := h.repo.Get(c.Request().Context(), id)
if err != nil {
return mapPubRepoError(c, err)
}
return c.JSON(http.StatusOK, pub)
}
func (h *PublicationHandler) Create(c echo.Context) error {
var dto domain.PublicationWriteDTO
if err := c.Bind(&dto); err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid request body"})
}
if errs := validatePublication(dto); len(errs) > 0 {
return c.JSON(http.StatusUnprocessableEntity, map[string]interface{}{"errors": errs})
}
pub, err := h.repo.Create(c.Request().Context(), dto)
if err != nil {
return mapPubRepoError(c, err)
}
return c.JSON(http.StatusCreated, pub)
}
func (h *PublicationHandler) Update(c echo.Context) error {
id, err := parseID(c, "id")
if err != nil {
return err
}
var dto domain.PublicationWriteDTO
if err := c.Bind(&dto); err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid request body"})
}
if errs := validatePublication(dto); len(errs) > 0 {
return c.JSON(http.StatusUnprocessableEntity, map[string]interface{}{"errors": errs})
}
pub, err := h.repo.Update(c.Request().Context(), id, dto)
if err != nil {
return mapPubRepoError(c, err)
}
return c.JSON(http.StatusOK, pub)
}
func (h *PublicationHandler) Delete(c echo.Context) error {
id, err := parseID(c, "id")
if err != nil {
return err
}
if err := h.repo.Delete(c.Request().Context(), id); err != nil {
return mapPubRepoError(c, err)
}
return c.NoContent(http.StatusNoContent)
}
func (h *PublicationHandler) UploadCoverImage(c echo.Context) error {
pubID, err := parseID(c, "id")
if err != nil {
return err
}
file, err := c.FormFile("image")
if err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "image file is required"})
}
src, err := file.Open()
if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "internal server error"})
}
defer src.Close()
data, err := io.ReadAll(src)
if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "internal server error"})
}
if err := h.repo.SetImage(c.Request().Context(), pubID, data); err != nil {
return mapPubRepoError(c, err)
}
return c.JSON(http.StatusCreated, map[string]string{"message": "image uploaded"})
}
func (h *PublicationHandler) ServeCoverImage(c echo.Context) error {
pubID, err := parseID(c, "id")
if err != nil {
return err
}
data, err := h.repo.GetImageData(c.Request().Context(), pubID)
if err != nil {
return mapPubRepoError(c, err)
}
contentType := http.DetectContentType(data)
return c.Blob(http.StatusOK, contentType, data)
}
func (h *PublicationHandler) DeleteCoverImage(c echo.Context) error {
pubID, err := parseID(c, "id")
if err != nil {
return err
}
if err := h.repo.DeleteImage(c.Request().Context(), pubID); err != nil {
return mapPubRepoError(c, err)
}
return c.NoContent(http.StatusNoContent)
}
func (h *PublicationHandler) ListFruits(c echo.Context) error {
pubID, err := parseID(c, "id")
if err != nil {
return err
}
fruits, err := h.repo.ListFruits(c.Request().Context(), pubID)
if err != nil {
return mapPubRepoError(c, err)
}
if fruits == nil {
fruits = []domain.PublicationFruit{}
}
return c.JSON(http.StatusOK, fruits)
}
func (h *PublicationHandler) LinkFruit(c echo.Context) error {
pubID, err := parseID(c, "id")
if err != nil {
return err
}
var body struct {
FruitID int `json:"fruit_id"`
}
if err := c.Bind(&body); err != nil || body.FruitID == 0 {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "fruit_id is required"})
}
if err := h.repo.LinkFruit(c.Request().Context(), pubID, body.FruitID); err != nil {
return mapPubRepoError(c, err)
}
return c.JSON(http.StatusCreated, map[string]int{"fruit_id": body.FruitID})
}
func (h *PublicationHandler) UnlinkFruit(c echo.Context) error {
pubID, err := parseID(c, "id")
if err != nil {
return err
}
fruitID, err := parseID(c, "fruitId")
if err != nil {
return err
}
if err := h.repo.UnlinkFruit(c.Request().Context(), pubID, fruitID); err != nil {
return mapPubRepoError(c, err)
}
return c.NoContent(http.StatusNoContent)
}
func (h *PublicationHandler) ListDescriptions(c echo.Context) error {
pubID, err := parseID(c, "id")
if err != nil {
return err
}
descs, err := h.repo.ListDescriptions(c.Request().Context(), pubID)
if err != nil {
return mapPubRepoError(c, err)
}
if descs == nil {
descs = []domain.PublicationDescription{}
}
return c.JSON(http.StatusOK, descs)
}
func (h *PublicationHandler) UploadDescription(c echo.Context) error {
pubID, err := parseID(c, "id")
if err != nil {
return err
}
fruitIDStr := c.FormValue("fruit_id")
fruitID, convErr := strconv.Atoi(fruitIDStr)
if convErr != nil || fruitID == 0 {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "fruit_id is required"})
}
file, err := c.FormFile("pdf")
if err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "pdf file is required"})
}
src, err := file.Open()
if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "internal server error"})
}
defer src.Close()
data, err := io.ReadAll(src)
if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "internal server error"})
}
desc, err := h.repo.AddDescription(c.Request().Context(), pubID, fruitID, data)
if err != nil {
return mapPubRepoError(c, err)
}
return c.JSON(http.StatusCreated, desc)
}
func (h *PublicationHandler) ServeDescription(c echo.Context) error {
pubID, err := parseID(c, "id")
if err != nil {
return err
}
descID, err := parseID(c, "descId")
if err != nil {
return err
}
data, err := h.repo.GetDescriptionData(c.Request().Context(), pubID, descID)
if err != nil {
return mapPubRepoError(c, err)
}
return c.Blob(http.StatusOK, "application/pdf", data)
}
func (h *PublicationHandler) DeleteDescription(c echo.Context) error {
pubID, err := parseID(c, "id")
if err != nil {
return err
}
descID, err := parseID(c, "descId")
if err != nil {
return err
}
if err := h.repo.DeleteDescription(c.Request().Context(), pubID, descID); err != nil {
return mapPubRepoError(c, err)
}
return c.NoContent(http.StatusNoContent)
}
func (h *PublicationHandler) ListFruitImages(c echo.Context) error {
pubID, err := parseID(c, "id")
if err != nil {
return err
}
imgs, err := h.repo.ListFruitImages(c.Request().Context(), pubID)
if err != nil {
return mapPubRepoError(c, err)
}
if imgs == nil {
imgs = []domain.PublicationFruitImage{}
}
return c.JSON(http.StatusOK, imgs)
}
func (h *PublicationHandler) UploadFruitImage(c echo.Context) error {
pubID, err := parseID(c, "id")
if err != nil {
return err
}
fruitIDStr := c.FormValue("fruit_id")
fruitID, convErr := strconv.Atoi(fruitIDStr)
if convErr != nil || fruitID == 0 {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "fruit_id is required"})
}
file, err := c.FormFile("image")
if err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "image file is required"})
}
src, err := file.Open()
if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "internal server error"})
}
defer src.Close()
data, err := io.ReadAll(src)
if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "internal server error"})
}
var filename *string
if file.Filename != "" {
s := file.Filename
filename = &s
}
img, err := h.repo.AddFruitImage(c.Request().Context(), pubID, fruitID, filename, data)
if err != nil {
return mapPubRepoError(c, err)
}
return c.JSON(http.StatusCreated, img)
}
func (h *PublicationHandler) ServeFruitImage(c echo.Context) error {
pubID, err := parseID(c, "id")
if err != nil {
return err
}
imgID, err := parseID(c, "imgId")
if err != nil {
return err
}
data, err := h.repo.GetFruitImageData(c.Request().Context(), pubID, imgID)
if err != nil {
return mapPubRepoError(c, err)
}
contentType := http.DetectContentType(data)
return c.Blob(http.StatusOK, contentType, data)
}
func (h *PublicationHandler) ServeFruitImageThumbnail(c echo.Context) error {
pubID, err := parseID(c, "id")
if err != nil {
return err
}
imgID, err := parseID(c, "imgId")
if err != nil {
return err
}
data, err := h.repo.GetFruitImageThumbnailData(c.Request().Context(), pubID, imgID)
if err != nil {
return mapPubRepoError(c, err)
}
contentType := http.DetectContentType(data)
return c.Blob(http.StatusOK, contentType, data)
}
func (h *PublicationHandler) DeleteFruitImage(c echo.Context) error {
pubID, err := parseID(c, "id")
if err != nil {
return err
}
imgID, err := parseID(c, "imgId")
if err != nil {
return err
}
if err := h.repo.DeleteFruitImage(c.Request().Context(), pubID, imgID); err != nil {
return mapPubRepoError(c, err)
}
return c.NoContent(http.StatusNoContent)
}
func (h *PublicationHandler) GetFruitDescriptions(c echo.Context) error {
fruitID, err := parseID(c, "id")
if err != nil {
return err
}
descs, err := h.repo.GetFruitDescriptions(c.Request().Context(), fruitID)
if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "internal server error"})
}
if descs == nil {
descs = []domain.PublicationDescription{}
}
return c.JSON(http.StatusOK, descs)
}
func (h *PublicationHandler) GetFruitPublicationImages(c echo.Context) error {
fruitID, err := parseID(c, "id")
if err != nil {
return err
}
imgs, err := h.repo.GetFruitPublicationImages(c.Request().Context(), fruitID)
if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "internal server error"})
}
if imgs == nil {
imgs = []domain.PublicationFruitImage{}
}
return c.JSON(http.StatusOK, imgs)
}