chore: build
This commit is contained in:
@@ -17,12 +17,13 @@ func NewHandler(service *Service) *Handler {
|
||||
}
|
||||
|
||||
type mediaResponse struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
Filename string `json:"filename"`
|
||||
URL string `json:"url"`
|
||||
MimeType string `json:"mime_type"`
|
||||
SizeBytes int64 `json:"size_bytes"`
|
||||
AltText string `json:"alt_text"`
|
||||
ID uuid.UUID `json:"id"`
|
||||
Filename string `json:"filename"`
|
||||
URL string `json:"url"`
|
||||
MimeType string `json:"mime_type"`
|
||||
SizeBytes int64 `json:"size_bytes"`
|
||||
AltText string `json:"alt_text"`
|
||||
ProductID *uuid.UUID `json:"product_id,omitempty"`
|
||||
}
|
||||
|
||||
func toResponse(m *Media) mediaResponse {
|
||||
@@ -33,11 +34,49 @@ func toResponse(m *Media) mediaResponse {
|
||||
MimeType: m.MimeType,
|
||||
SizeBytes: m.SizeBytes,
|
||||
AltText: m.AltText,
|
||||
ProductID: m.ProductID,
|
||||
}
|
||||
}
|
||||
|
||||
// parseOptionalProductID reads the product_id query/form value, if any.
|
||||
// Absent means "media not tied to a product" (e.g. contact link icons),
|
||||
// distinct from an explicit product scope.
|
||||
func parseOptionalProductID(raw string) (*uuid.UUID, error) {
|
||||
if raw == "" {
|
||||
return nil, nil
|
||||
}
|
||||
id, err := uuid.Parse(raw)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &id, nil
|
||||
}
|
||||
|
||||
func (h *Handler) Get(c *gin.Context) {
|
||||
id, err := uuid.Parse(c.Param("id"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"})
|
||||
return
|
||||
}
|
||||
m, err := h.service.Get(c.Request.Context(), id)
|
||||
if err != nil {
|
||||
if errors.Is(err, ErrNotFound) {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "media not found"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load media"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, toResponse(m))
|
||||
}
|
||||
|
||||
func (h *Handler) List(c *gin.Context) {
|
||||
list, err := h.service.List(c.Request.Context())
|
||||
productID, err := parseOptionalProductID(c.Query("product_id"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid product_id"})
|
||||
return
|
||||
}
|
||||
list, err := h.service.List(c.Request.Context(), productID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list media"})
|
||||
return
|
||||
@@ -65,14 +104,21 @@ func (h *Handler) Upload(c *gin.Context) {
|
||||
|
||||
contentType := fileHeader.Header.Get("Content-Type")
|
||||
altText := c.PostForm("alt_text")
|
||||
productID, err := parseOptionalProductID(c.PostForm("product_id"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid product_id"})
|
||||
return
|
||||
}
|
||||
|
||||
m, err := h.service.Upload(c.Request.Context(), fileHeader.Filename, file, fileHeader.Size, contentType, altText)
|
||||
m, err := h.service.Upload(c.Request.Context(), fileHeader.Filename, file, fileHeader.Size, contentType, altText, productID)
|
||||
if err != nil {
|
||||
switch {
|
||||
case errors.Is(err, ErrFileTooLarge):
|
||||
c.JSON(http.StatusRequestEntityTooLarge, gin.H{"error": "file exceeds the maximum allowed size"})
|
||||
case errors.Is(err, ErrUnsupportedType):
|
||||
c.JSON(http.StatusUnsupportedMediaType, gin.H{"error": "unsupported file type"})
|
||||
case errors.Is(err, ErrProductNotFound):
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "product not found"})
|
||||
default:
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to upload file"})
|
||||
}
|
||||
|
||||
@@ -17,8 +17,12 @@ type Media struct {
|
||||
MimeType string `gorm:"not null"`
|
||||
SizeBytes int64 `gorm:"not null"`
|
||||
AltText string `gorm:"not null;default:''"`
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
// ProductID isolates each media file to the single product it was
|
||||
// uploaded for (nil for media not tied to any product, e.g. contact
|
||||
// link icons) -- there is no shared cross-product media library.
|
||||
ProductID *uuid.UUID `gorm:"type:uuid;index"`
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
func (Media) TableName() string { return "media" }
|
||||
|
||||
@@ -13,7 +13,7 @@ var ErrNotFound = errors.New("media not found")
|
||||
type Repository interface {
|
||||
Create(ctx context.Context, m *Media) error
|
||||
FindByID(ctx context.Context, id uuid.UUID) (*Media, error)
|
||||
List(ctx context.Context) ([]*Media, error)
|
||||
List(ctx context.Context, productID *uuid.UUID) ([]*Media, error)
|
||||
Update(ctx context.Context, m *Media) error
|
||||
Delete(ctx context.Context, id uuid.UUID) error
|
||||
}
|
||||
@@ -32,21 +32,38 @@ func (r *gormRepository) Create(ctx context.Context, m *Media) error {
|
||||
|
||||
func (r *gormRepository) FindByID(ctx context.Context, id uuid.UUID) (*Media, error) {
|
||||
var m Media
|
||||
err := r.db.WithContext(ctx).Where("id = ?", id).First(&m).Error
|
||||
|
||||
err := r.db.WithContext(ctx).
|
||||
Where("id = ?", id).
|
||||
First(&m).Error
|
||||
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &m, nil
|
||||
}
|
||||
|
||||
func (r *gormRepository) List(ctx context.Context) ([]*Media, error) {
|
||||
func (r *gormRepository) List(ctx context.Context, productID *uuid.UUID) ([]*Media, error) {
|
||||
var list []*Media
|
||||
if err := r.db.WithContext(ctx).Order("created_at desc").Find(&list).Error; err != nil {
|
||||
|
||||
q := r.db.WithContext(ctx).
|
||||
Order("created_at desc")
|
||||
|
||||
if productID != nil {
|
||||
q = q.Where("product_id = ?", *productID)
|
||||
} else {
|
||||
q = q.Where("product_id IS NULL")
|
||||
}
|
||||
|
||||
if err := q.Find(&list).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return list, nil
|
||||
}
|
||||
|
||||
@@ -55,12 +72,16 @@ func (r *gormRepository) Update(ctx context.Context, m *Media) error {
|
||||
}
|
||||
|
||||
func (r *gormRepository) Delete(ctx context.Context, id uuid.UUID) error {
|
||||
res := r.db.WithContext(ctx).Delete(&Media{}, "id = ?", id)
|
||||
res := r.db.WithContext(ctx).
|
||||
Delete(&Media{}, "id = ?", id)
|
||||
|
||||
if res.Error != nil {
|
||||
return res.Error
|
||||
}
|
||||
|
||||
if res.RowsAffected == 0 {
|
||||
return ErrNotFound
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -9,3 +9,10 @@ func RegisterAdminRoutes(rg *gin.RouterGroup, h *Handler, requireAdmin gin.Handl
|
||||
group.PUT("/:id", h.Update)
|
||||
group.DELETE("/:id", h.Delete)
|
||||
}
|
||||
|
||||
// RegisterPublicRoutes exposes read-only single-item lookup so the
|
||||
// storefront can resolve a product's primary_media_id / gallery media_ids
|
||||
// to a URL without needing admin access.
|
||||
func RegisterPublicRoutes(rg *gin.RouterGroup, h *Handler) {
|
||||
rg.GET("/media/:id", h.Get)
|
||||
}
|
||||
|
||||
@@ -7,13 +7,25 @@ import (
|
||||
"io"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"backend/internal/modules/categories"
|
||||
"backend/internal/modules/products"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrFileTooLarge = errors.New("file exceeds the maximum allowed size")
|
||||
ErrUnsupportedType = errors.New("unsupported file type")
|
||||
ErrProductNotFound = errors.New("product not found")
|
||||
)
|
||||
|
||||
// ProductFinder is the minimal slice of products.Service this module needs,
|
||||
// defined on the consumer side so media never imports the products
|
||||
// module's handler/repository/service internals -- only used to validate
|
||||
// that a product_id passed on upload actually exists.
|
||||
type ProductFinder interface {
|
||||
Get(ctx context.Context, id uuid.UUID) (*products.Product, error)
|
||||
}
|
||||
|
||||
// allowedTypes maps accepted MIME types to a safe file extension. Anything
|
||||
// not in this list is rejected -- uploads are never trusted to declare
|
||||
// their own extension.
|
||||
@@ -27,24 +39,38 @@ var allowedTypes = map[string]string{
|
||||
}
|
||||
|
||||
type Service struct {
|
||||
repo Repository
|
||||
storage Storage
|
||||
maxSizeByte int64
|
||||
repo Repository
|
||||
storage Storage
|
||||
maxSizeByte int64
|
||||
products ProductFinder
|
||||
categoriesRepo categories.Repository
|
||||
}
|
||||
|
||||
func NewService(repo Repository, storage Storage, maxSizeBytes int64) *Service {
|
||||
return &Service{repo: repo, storage: storage, maxSizeByte: maxSizeBytes}
|
||||
func NewService(
|
||||
repo Repository,
|
||||
storage Storage,
|
||||
products ProductFinder,
|
||||
categoriesRepo categories.Repository,
|
||||
maxSizeByte int64,
|
||||
) *Service {
|
||||
return &Service{
|
||||
repo: repo,
|
||||
storage: storage,
|
||||
products: products,
|
||||
categoriesRepo: categoriesRepo,
|
||||
maxSizeByte: maxSizeByte,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) List(ctx context.Context) ([]*Media, error) {
|
||||
return s.repo.List(ctx)
|
||||
func (s *Service) List(ctx context.Context, productID *uuid.UUID) ([]*Media, error) {
|
||||
return s.repo.List(ctx, productID)
|
||||
}
|
||||
|
||||
func (s *Service) Get(ctx context.Context, id uuid.UUID) (*Media, error) {
|
||||
return s.repo.FindByID(ctx, id)
|
||||
}
|
||||
|
||||
func (s *Service) Upload(ctx context.Context, filename string, reader io.Reader, size int64, contentType, altText string) (*Media, error) {
|
||||
func (s *Service) Upload(ctx context.Context, filename string, reader io.Reader, size int64, contentType, altText string, productID *uuid.UUID) (*Media, error) {
|
||||
if size > s.maxSizeByte {
|
||||
return nil, ErrFileTooLarge
|
||||
}
|
||||
@@ -52,6 +78,14 @@ func (s *Service) Upload(ctx context.Context, filename string, reader io.Reader,
|
||||
if !ok {
|
||||
return nil, ErrUnsupportedType
|
||||
}
|
||||
if productID != nil {
|
||||
if _, err := s.products.Get(ctx, *productID); err != nil {
|
||||
if errors.Is(err, products.ErrNotFound) {
|
||||
return nil, ErrProductNotFound
|
||||
}
|
||||
return nil, fmt.Errorf("check product: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
key := uuid.NewString() + ext
|
||||
url, err := s.storage.Save(ctx, key, reader, size, contentType)
|
||||
@@ -67,6 +101,7 @@ func (s *Service) Upload(ctx context.Context, filename string, reader io.Reader,
|
||||
MimeType: contentType,
|
||||
SizeBytes: size,
|
||||
AltText: altText,
|
||||
ProductID: productID,
|
||||
}
|
||||
if err := s.repo.Create(ctx, m); err != nil {
|
||||
_ = s.storage.Delete(ctx, key)
|
||||
@@ -97,3 +132,23 @@ func (s *Service) Delete(ctx context.Context, id uuid.UUID) error {
|
||||
}
|
||||
return s.repo.Delete(ctx, id)
|
||||
}
|
||||
|
||||
func (s *Service) BelongsToProduct(
|
||||
ctx context.Context,
|
||||
mediaID uuid.UUID,
|
||||
productID uuid.UUID,
|
||||
) (bool, error) {
|
||||
m, err := s.repo.FindByID(ctx, mediaID)
|
||||
if err != nil {
|
||||
if errors.Is(err, ErrNotFound) {
|
||||
return false, nil
|
||||
}
|
||||
return false, err
|
||||
}
|
||||
|
||||
if m.ProductID == nil {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
return *m.ProductID == productID, nil
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user