- 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>
451 lines
13 KiB
Go
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)
|
|
}
|