This commit is contained in:
CFOU
2026-09-14 20:50:19 +02:00
commit 3091fb4020
135 changed files with 10262 additions and 0 deletions
@@ -0,0 +1,303 @@
package products
import (
"errors"
"net/http"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
)
type Handler struct {
service *Service
}
func NewHandler(service *Service) *Handler {
return &Handler{service: service}
}
type galleryItemResponse struct {
MediaID uuid.UUID `json:"media_id"`
Position int `json:"position"`
}
type productResponse struct {
ID uuid.UUID `json:"id"`
CategoryID *uuid.UUID `json:"category_id,omitempty"`
Name string `json:"name"`
Slug string `json:"slug"`
ShortDescription string `json:"short_description"`
Description string `json:"description"`
IsActive bool `json:"is_active"`
IsFeatured bool `json:"is_featured"`
PrimaryMediaID *uuid.UUID `json:"primary_media_id,omitempty"`
Position int `json:"position"`
}
func toResponse(p *Product) productResponse {
return productResponse{
ID: p.ID,
CategoryID: p.CategoryID,
Name: p.Name,
Slug: p.Slug,
ShortDescription: p.ShortDescription,
Description: p.Description,
IsActive: p.IsActive,
IsFeatured: p.IsFeatured,
PrimaryMediaID: p.PrimaryMediaID,
Position: p.Position,
}
}
func parseCategoryFilter(c *gin.Context) *uuid.UUID {
raw := c.Query("category_id")
if raw == "" {
return nil
}
id, err := uuid.Parse(raw)
if err != nil {
return nil
}
return &id
}
func (h *Handler) ListAdmin(c *gin.Context) {
list, err := h.service.List(c.Request.Context(), false, parseCategoryFilter(c))
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list products"})
return
}
c.JSON(http.StatusOK, gin.H{"products": toResponseList(list)})
}
func (h *Handler) ListPublic(c *gin.Context) {
list, err := h.service.List(c.Request.Context(), true, parseCategoryFilter(c))
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list products"})
return
}
c.JSON(http.StatusOK, gin.H{"products": toResponseList(list)})
}
func toResponseList(list []*Product) []productResponse {
resp := make([]productResponse, 0, len(list))
for _, p := range list {
resp = append(resp, toResponse(p))
}
return resp
}
func (h *Handler) GetAdmin(c *gin.Context) {
id, err := uuid.Parse(c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"})
return
}
p, err := h.service.Get(c.Request.Context(), id)
if err != nil {
h.respondGetError(c, err)
return
}
c.JSON(http.StatusOK, toResponse(p))
}
func (h *Handler) GetPublicBySlug(c *gin.Context) {
p, err := h.service.GetBySlug(c.Request.Context(), c.Param("slug"))
if err != nil {
h.respondGetError(c, err)
return
}
if !p.IsActive {
c.JSON(http.StatusNotFound, gin.H{"error": "product not found"})
return
}
c.JSON(http.StatusOK, toResponse(p))
}
func (h *Handler) respondGetError(c *gin.Context, err error) {
if errors.Is(err, ErrNotFound) {
c.JSON(http.StatusNotFound, gin.H{"error": "product not found"})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load product"})
}
type upsertRequest struct {
CategoryID *uuid.UUID `json:"category_id"`
Name string `json:"name" binding:"required"`
Slug string `json:"slug" binding:"required"`
ShortDescription string `json:"short_description"`
Description string `json:"description"`
IsActive bool `json:"is_active"`
IsFeatured bool `json:"is_featured"`
PrimaryMediaID *uuid.UUID `json:"primary_media_id"`
Position int `json:"position"`
}
func (req upsertRequest) toInput() UpsertInput {
return UpsertInput{
CategoryID: req.CategoryID,
Name: req.Name,
Slug: req.Slug,
ShortDescription: req.ShortDescription,
Description: req.Description,
IsActive: req.IsActive,
IsFeatured: req.IsFeatured,
PrimaryMediaID: req.PrimaryMediaID,
Position: req.Position,
}
}
func (h *Handler) Create(c *gin.Context) {
var req upsertRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "validation error", "details": err.Error()})
return
}
p, err := h.service.Create(c.Request.Context(), req.toInput())
if err != nil {
if errors.Is(err, ErrSlugTaken) {
c.JSON(http.StatusConflict, gin.H{"error": "slug already in use"})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create product"})
return
}
c.JSON(http.StatusCreated, toResponse(p))
}
func (h *Handler) Update(c *gin.Context) {
id, err := uuid.Parse(c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"})
return
}
var req upsertRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "validation error", "details": err.Error()})
return
}
p, err := h.service.Update(c.Request.Context(), id, req.toInput())
if err != nil {
switch {
case errors.Is(err, ErrNotFound):
c.JSON(http.StatusNotFound, gin.H{"error": "product not found"})
case errors.Is(err, ErrSlugTaken):
c.JSON(http.StatusConflict, gin.H{"error": "slug already in use"})
default:
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update product"})
}
return
}
c.JSON(http.StatusOK, toResponse(p))
}
type updatePositionRequest struct {
Position int `json:"position"`
}
// UpdatePosition lets the admin reorder the catalog display order (e.g.
// move up/move down in the admin list) without resending the whole
// product payload.
func (h *Handler) UpdatePosition(c *gin.Context) {
id, err := uuid.Parse(c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"})
return
}
var req updatePositionRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "validation error", "details": err.Error()})
return
}
p, err := h.service.UpdatePosition(c.Request.Context(), id, req.Position)
if err != nil {
if errors.Is(err, ErrNotFound) {
c.JSON(http.StatusNotFound, gin.H{"error": "product not found"})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update position"})
return
}
c.JSON(http.StatusOK, toResponse(p))
}
func (h *Handler) Delete(c *gin.Context) {
id, err := uuid.Parse(c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"})
return
}
if err := h.service.Delete(c.Request.Context(), id); err != nil {
if errors.Is(err, ErrNotFound) {
c.JSON(http.StatusNotFound, gin.H{"error": "product not found"})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete product"})
return
}
c.Status(http.StatusNoContent)
}
type addGalleryRequest struct {
MediaID uuid.UUID `json:"media_id" binding:"required"`
Position int `json:"position"`
}
func (h *Handler) AddGalleryItem(c *gin.Context) {
productID, err := uuid.Parse(c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"})
return
}
var req addGalleryRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "validation error", "details": err.Error()})
return
}
if err := h.service.AddGalleryItem(c.Request.Context(), productID, req.MediaID, req.Position); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to attach media"})
return
}
c.Status(http.StatusCreated)
}
func (h *Handler) RemoveGalleryItem(c *gin.Context) {
productID, err := uuid.Parse(c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"})
return
}
mediaID, err := uuid.Parse(c.Param("mediaId"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid media id"})
return
}
if err := h.service.RemoveGalleryItem(c.Request.Context(), productID, mediaID); err != nil {
if errors.Is(err, ErrNotFound) {
c.JSON(http.StatusNotFound, gin.H{"error": "gallery item not found"})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to detach media"})
return
}
c.Status(http.StatusNoContent)
}
func (h *Handler) ListGallery(c *gin.Context) {
productID, err := uuid.Parse(c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"})
return
}
items, err := h.service.ListGallery(c.Request.Context(), productID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list gallery"})
return
}
resp := make([]galleryItemResponse, 0, len(items))
for _, item := range items {
resp = append(resp, galleryItemResponse{MediaID: item.MediaID, Position: item.Position})
}
c.JSON(http.StatusOK, gin.H{"gallery": resp})
}
@@ -0,0 +1,41 @@
// Package products owns the product catalog entity itself (identity,
// description, category, media). Quantity-based pricing lives in the
// pricing module, which references products by ID -- keeping "what a
// product is" separate from "how it's priced", per spec section 13/16.
package products
import (
"time"
"github.com/google/uuid"
)
type Product struct {
ID uuid.UUID `gorm:"type:uuid;primaryKey"`
CategoryID *uuid.UUID `gorm:"type:uuid"`
Name string `gorm:"not null"`
Slug string `gorm:"uniqueIndex;not null"`
ShortDescription string `gorm:"not null;default:''"`
Description string `gorm:"not null;default:''"`
IsActive bool `gorm:"not null;default:true"`
IsFeatured bool `gorm:"not null;default:false"`
PrimaryMediaID *uuid.UUID `gorm:"type:uuid"`
Position int `gorm:"not null;default:0"`
CreatedAt time.Time
UpdatedAt time.Time
}
func (Product) TableName() string { return "products" }
// GalleryItem attaches a media item to a product's gallery, ordered by
// Position. A product's primary image (PrimaryMediaID) is separate from
// the gallery so it can be picked from unrelated media too.
type GalleryItem struct {
ID uuid.UUID `gorm:"type:uuid;primaryKey"`
ProductID uuid.UUID `gorm:"type:uuid;not null;index"`
MediaID uuid.UUID `gorm:"type:uuid;not null"`
Position int `gorm:"not null;default:0"`
CreatedAt time.Time
}
func (GalleryItem) TableName() string { return "product_media" }
@@ -0,0 +1,132 @@
package products
import (
"context"
"errors"
"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgconn"
"gorm.io/gorm"
)
var (
ErrNotFound = errors.New("product not found")
ErrSlugTaken = errors.New("slug already in use")
)
type Repository interface {
Create(ctx context.Context, p *Product) error
FindByID(ctx context.Context, id uuid.UUID) (*Product, error)
FindBySlug(ctx context.Context, slug string) (*Product, error)
List(ctx context.Context, activeOnly bool, categoryID *uuid.UUID) ([]*Product, error)
Update(ctx context.Context, p *Product) error
Delete(ctx context.Context, id uuid.UUID) error
AddGalleryItem(ctx context.Context, item *GalleryItem) error
RemoveGalleryItem(ctx context.Context, productID, mediaID uuid.UUID) error
ListGallery(ctx context.Context, productID uuid.UUID) ([]*GalleryItem, error)
}
type gormRepository struct {
db *gorm.DB
}
func NewRepository(db *gorm.DB) Repository {
return &gormRepository{db: db}
}
func (r *gormRepository) Create(ctx context.Context, p *Product) error {
if err := r.db.WithContext(ctx).Create(p).Error; err != nil {
if isUniqueViolation(err) {
return ErrSlugTaken
}
return err
}
return nil
}
func (r *gormRepository) FindByID(ctx context.Context, id uuid.UUID) (*Product, error) {
var p Product
err := r.db.WithContext(ctx).Where("id = ?", id).First(&p).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, ErrNotFound
}
if err != nil {
return nil, err
}
return &p, nil
}
func (r *gormRepository) FindBySlug(ctx context.Context, slug string) (*Product, error) {
var p Product
err := r.db.WithContext(ctx).Where("slug = ?", slug).First(&p).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, ErrNotFound
}
if err != nil {
return nil, err
}
return &p, nil
}
func (r *gormRepository) List(ctx context.Context, activeOnly bool, categoryID *uuid.UUID) ([]*Product, error) {
q := r.db.WithContext(ctx).Order("position asc, created_at desc")
if activeOnly {
q = q.Where("is_active = ?", true)
}
if categoryID != nil {
q = q.Where("category_id = ?", *categoryID)
}
var list []*Product
if err := q.Find(&list).Error; err != nil {
return nil, err
}
return list, nil
}
func (r *gormRepository) Update(ctx context.Context, p *Product) error {
err := r.db.WithContext(ctx).Save(p).Error
if isUniqueViolation(err) {
return ErrSlugTaken
}
return err
}
func (r *gormRepository) Delete(ctx context.Context, id uuid.UUID) error {
res := r.db.WithContext(ctx).Delete(&Product{}, "id = ?", id)
if res.Error != nil {
return res.Error
}
if res.RowsAffected == 0 {
return ErrNotFound
}
return nil
}
func (r *gormRepository) AddGalleryItem(ctx context.Context, item *GalleryItem) error {
return r.db.WithContext(ctx).Create(item).Error
}
func (r *gormRepository) RemoveGalleryItem(ctx context.Context, productID, mediaID uuid.UUID) error {
res := r.db.WithContext(ctx).Delete(&GalleryItem{}, "product_id = ? AND media_id = ?", productID, mediaID)
if res.Error != nil {
return res.Error
}
if res.RowsAffected == 0 {
return ErrNotFound
}
return nil
}
func (r *gormRepository) ListGallery(ctx context.Context, productID uuid.UUID) ([]*GalleryItem, error) {
var list []*GalleryItem
if err := r.db.WithContext(ctx).Where("product_id = ?", productID).Order("position asc").Find(&list).Error; err != nil {
return nil, err
}
return list, nil
}
func isUniqueViolation(err error) bool {
var pgErr *pgconn.PgError
return errors.As(err, &pgErr) && pgErr.Code == "23505"
}
@@ -0,0 +1,24 @@
package products
import "github.com/gin-gonic/gin"
func RegisterAdminRoutes(rg *gin.RouterGroup, h *Handler, requireAdmin gin.HandlerFunc) {
group := rg.Group("/admin/products", requireAdmin)
group.GET("", h.ListAdmin)
group.POST("", h.Create)
group.GET("/:id", h.GetAdmin)
group.PUT("/:id", h.Update)
group.PATCH("/:id/position", h.UpdatePosition)
group.DELETE("/:id", h.Delete)
group.GET("/:id/gallery", h.ListGallery)
group.POST("/:id/gallery", h.AddGalleryItem)
group.DELETE("/:id/gallery/:mediaId", h.RemoveGalleryItem)
}
// RegisterPublicRoutes exposes the read-only, active-only product catalog
// used by the storefront (site vitrine / boutique).
func RegisterPublicRoutes(rg *gin.RouterGroup, h *Handler) {
rg.GET("/products", h.ListPublic)
rg.GET("/products/:slug", h.GetPublicBySlug)
}
@@ -0,0 +1,113 @@
package products
import (
"context"
"github.com/google/uuid"
)
type Service struct {
repo Repository
}
func NewService(repo Repository) *Service {
return &Service{repo: repo}
}
func (s *Service) List(ctx context.Context, activeOnly bool, categoryID *uuid.UUID) ([]*Product, error) {
return s.repo.List(ctx, activeOnly, categoryID)
}
func (s *Service) Get(ctx context.Context, id uuid.UUID) (*Product, error) {
return s.repo.FindByID(ctx, id)
}
func (s *Service) GetBySlug(ctx context.Context, slug string) (*Product, error) {
return s.repo.FindBySlug(ctx, slug)
}
type UpsertInput struct {
CategoryID *uuid.UUID
Name string
Slug string
ShortDescription string
Description string
IsActive bool
IsFeatured bool
PrimaryMediaID *uuid.UUID
Position int
}
func (s *Service) Create(ctx context.Context, in UpsertInput) (*Product, error) {
p := &Product{
ID: uuid.New(),
CategoryID: in.CategoryID,
Name: in.Name,
Slug: in.Slug,
ShortDescription: in.ShortDescription,
Description: in.Description,
IsActive: in.IsActive,
IsFeatured: in.IsFeatured,
PrimaryMediaID: in.PrimaryMediaID,
Position: in.Position,
}
if err := s.repo.Create(ctx, p); err != nil {
return nil, err
}
return p, nil
}
func (s *Service) Update(ctx context.Context, id uuid.UUID, in UpsertInput) (*Product, error) {
p, err := s.repo.FindByID(ctx, id)
if err != nil {
return nil, err
}
p.CategoryID = in.CategoryID
p.Name = in.Name
p.Slug = in.Slug
p.ShortDescription = in.ShortDescription
p.Description = in.Description
p.IsActive = in.IsActive
p.IsFeatured = in.IsFeatured
p.PrimaryMediaID = in.PrimaryMediaID
p.Position = in.Position
if err := s.repo.Update(ctx, p); err != nil {
return nil, err
}
return p, nil
}
func (s *Service) Delete(ctx context.Context, id uuid.UUID) error {
return s.repo.Delete(ctx, id)
}
// UpdatePosition lets the admin reorder the catalog display order without
// resending the full product payload.
func (s *Service) UpdatePosition(ctx context.Context, id uuid.UUID, position int) (*Product, error) {
p, err := s.repo.FindByID(ctx, id)
if err != nil {
return nil, err
}
p.Position = position
if err := s.repo.Update(ctx, p); err != nil {
return nil, err
}
return p, nil
}
func (s *Service) AddGalleryItem(ctx context.Context, productID, mediaID uuid.UUID, position int) error {
return s.repo.AddGalleryItem(ctx, &GalleryItem{
ID: uuid.New(),
ProductID: productID,
MediaID: mediaID,
Position: position,
})
}
func (s *Service) RemoveGalleryItem(ctx context.Context, productID, mediaID uuid.UUID) error {
return s.repo.RemoveGalleryItem(ctx, productID, mediaID)
}
func (s *Service) ListGallery(ctx context.Context, productID uuid.UUID) ([]*GalleryItem, error) {
return s.repo.ListGallery(ctx, productID)
}