feat: publication management with cover images, PDFs, and fruit images (story #04)
Full CRUD for publications; link fruits to publications; upload per-fruit PDF descriptions and fruit images stored as BYTEA; fruit detail page shows publication descriptions and images. ImageOverlayDrawer for cover thumbnail full-size view. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,432 @@
|
||||
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)
|
||||
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) 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)
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user