feat(marketplace): add migration 098 product_licenses, ProductLicense model, GET /licenses/mine

This commit is contained in:
senke 2026-02-22 14:16:24 +01:00
parent 31a27e4724
commit 1fef428ce0
5 changed files with 181 additions and 4 deletions

View file

@ -68,6 +68,7 @@ func (r *APIRouter) setupMarketplaceRoutes(router *gin.RouterGroup) {
protected.GET("/orders/:id", marketHandler.GetOrder)
protected.POST("/orders", marketHandler.CreateOrder)
protected.GET("/download/:product_id", marketHandler.GetDownloadURL)
protected.GET("/licenses/mine", marketHandler.GetMyLicenses)
marketplaceExtHandler := handlers.NewMarketplaceExtHandler(marketService, r.logger)
protected.GET("/wishlist", marketplaceExtHandler.GetWishlist)

View file

@ -47,7 +47,8 @@ type Product struct {
// Relations
Previews []ProductPreview `gorm:"foreignKey:ProductID" json:"previews,omitempty"`
Images []ProductImage `gorm:"foreignKey:ProductID" json:"images,omitempty"`
Images []ProductImage `gorm:"foreignKey:ProductID" json:"images,omitempty"`
Licenses []ProductLicense `gorm:"foreignKey:ProductID" json:"licenses,omitempty"`
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"`
UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"`
@ -86,6 +87,23 @@ func (pi *ProductImage) BeforeCreate(tx *gorm.DB) (err error) {
return
}
// ProductLicense représente un type de licence proposé pour un produit (v0.401 M2)
type ProductLicense struct {
ID uuid.UUID `gorm:"type:uuid;primaryKey" json:"id"`
ProductID uuid.UUID `gorm:"type:uuid;not null" json:"product_id"`
LicenseType string `gorm:"column:license_type;not null;size:50" json:"license_type"` // streaming, personal, commercial, exclusive
PriceCents int `gorm:"column:price_cents;not null" json:"price_cents"`
TermsText string `gorm:"column:terms_text;type:text" json:"terms_text,omitempty"`
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"`
}
func (pl *ProductLicense) BeforeCreate(tx *gorm.DB) (err error) {
if pl.ID == uuid.Nil {
pl.ID = uuid.New()
}
return
}
func (p *Product) BeforeCreate(tx *gorm.DB) (err error) {
if p.ID == uuid.Nil {
p.ID = uuid.New()

View file

@ -72,6 +72,10 @@ type MarketplaceService interface {
// v0.401 M1: Product previews and images
AddProductPreview(ctx context.Context, productID uuid.UUID, sellerID uuid.UUID, filePath string, durationSec *int) (*ProductPreview, error)
UpdateProductImages(ctx context.Context, productID uuid.UUID, sellerID uuid.UUID, images []ProductImageInput) ([]ProductImage, error)
// v0.401 M2: Product licenses
GetProductLicenses(ctx context.Context, productID uuid.UUID) ([]ProductLicense, error)
SetProductLicenses(ctx context.Context, productID uuid.UUID, sellerID uuid.UUID, licenses []ProductLicenseInput) ([]ProductLicense, error)
}
// ProductImageInput represents input for adding/updating product images
@ -80,6 +84,13 @@ type ProductImageInput struct {
SortOrder int `json:"sort_order"`
}
// ProductLicenseInput represents input for product license (v0.401 M2)
type ProductLicenseInput struct {
LicenseType string `json:"license_type"` // streaming, personal, commercial, exclusive
PriceCents int `json:"price_cents"`
TermsText string `json:"terms_text,omitempty"`
}
// Service implémente MarketplaceService
type Service struct {
db *gorm.DB
@ -153,12 +164,12 @@ func (s *Service) CreateProduct(ctx context.Context, product *Product) error {
})
}
// GetProduct retrieves a product by ID with preloaded previews and images (v0.401 M1)
// GetProduct retrieves a product by ID with preloaded previews, images, licenses (v0.401 M1/M2)
func (s *Service) GetProduct(ctx context.Context, id uuid.UUID) (*Product, error) {
var product Product
if err := s.db.Preload("Previews").Preload("Images", func(db *gorm.DB) *gorm.DB {
return db.Order("product_images.sort_order ASC")
}).First(&product, "id = ?", id).Error; err != nil {
}).Preload("Licenses").First(&product, "id = ?", id).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, ErrProductNotFound
}
@ -664,3 +675,46 @@ func (s *Service) UpdateProductImages(ctx context.Context, productID uuid.UUID,
}
return result, nil
}
// GetProductLicenses returns all license types offered for a product (v0.401 M2)
func (s *Service) GetProductLicenses(ctx context.Context, productID uuid.UUID) ([]ProductLicense, error) {
var licenses []ProductLicense
if err := s.db.WithContext(ctx).Where("product_id = ?", productID).Find(&licenses).Error; err != nil {
return nil, err
}
return licenses, nil
}
// SetProductLicenses replaces all licenses for a product (v0.401 M2)
func (s *Service) SetProductLicenses(ctx context.Context, productID uuid.UUID, sellerID uuid.UUID, licenses []ProductLicenseInput) ([]ProductLicense, error) {
var product Product
if err := s.db.First(&product, "id = ?", productID).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, ErrProductNotFound
}
return nil, err
}
if product.SellerID != sellerID {
return nil, ErrInvalidSeller
}
if err := s.db.Where("product_id = ?", productID).Delete(&ProductLicense{}).Error; err != nil {
return nil, err
}
result := make([]ProductLicense, 0, len(licenses))
for _, in := range licenses {
if in.LicenseType == "" || in.PriceCents < 0 {
continue
}
pl := &ProductLicense{
ProductID: productID,
LicenseType: in.LicenseType,
PriceCents: in.PriceCents,
TermsText: in.TermsText,
}
if err := s.db.Create(pl).Error; err != nil {
return nil, err
}
result = append(result, *pl)
}
return result, nil
}

View file

@ -49,6 +49,12 @@ type CreateProductRequest struct {
BPM *int `json:"bpm,omitempty" binding:"omitempty,min=1,max=300" validate:"omitempty,min=1,max=300"`
MusicalKey string `json:"musical_key,omitempty" binding:"omitempty,max=10" validate:"omitempty,max=10"`
Category string `json:"category,omitempty" binding:"omitempty,oneof=sample beat preset pack" validate:"omitempty,oneof=sample beat preset pack"`
// v0.401 M2: Product licenses (streaming, personal, commercial, exclusive)
Licenses []struct {
LicenseType string `json:"license_type" binding:"required,oneof=streaming personal commercial exclusive"`
PriceCents int `json:"price_cents" binding:"required,min=0"`
TermsText string `json:"terms_text,omitempty"`
} `json:"licenses,omitempty"`
}
// CreateProduct gère la création d'un produit
@ -111,6 +117,26 @@ func (h *MarketplaceHandler) CreateProduct(c *gin.Context) {
return
}
// v0.401 M2: Create product licenses if provided
if len(req.Licenses) > 0 {
licInputs := make([]marketplace.ProductLicenseInput, 0, len(req.Licenses))
for _, l := range req.Licenses {
licInputs = append(licInputs, marketplace.ProductLicenseInput{
LicenseType: l.LicenseType,
PriceCents: l.PriceCents,
TermsText: l.TermsText,
})
}
if _, err := h.service.SetProductLicenses(c.Request.Context(), product.ID, userID, licInputs); err != nil {
response.InternalServerError(c, "Failed to create product licenses")
return
}
}
// Reload product with licenses for response
if p, err := h.service.GetProduct(c.Request.Context(), product.ID); err == nil {
product = p
}
response.Created(c, product)
}
@ -467,6 +493,12 @@ type UpdateProductRequest struct {
BPM *int `json:"bpm,omitempty" binding:"omitempty,min=1,max=300" validate:"omitempty,min=1,max=300"`
MusicalKey *string `json:"musical_key,omitempty" binding:"omitempty,max=10" validate:"omitempty,max=10"`
Category *string `json:"category,omitempty" binding:"omitempty,oneof=sample beat preset pack" validate:"omitempty,oneof=sample beat preset pack"`
// v0.401 M2: Product licenses
Licenses *[]struct {
LicenseType string `json:"license_type" binding:"required,oneof=streaming personal commercial exclusive"`
PriceCents int `json:"price_cents" binding:"required,min=0"`
TermsText string `json:"terms_text,omitempty"`
} `json:"licenses,omitempty"`
}
// UpdateProduct gère la mise à jour d'un produit
@ -528,7 +560,23 @@ func (h *MarketplaceHandler) UpdateProduct(c *gin.Context) {
updates["category"] = *req.Category
}
if len(updates) == 0 {
// v0.401 M2: Update product licenses if provided
if req.Licenses != nil {
licInputs := make([]marketplace.ProductLicenseInput, 0, len(*req.Licenses))
for _, l := range *req.Licenses {
licInputs = append(licInputs, marketplace.ProductLicenseInput{
LicenseType: l.LicenseType,
PriceCents: l.PriceCents,
TermsText: l.TermsText,
})
}
if _, err := h.service.SetProductLicenses(c.Request.Context(), productID, userID, licInputs); err != nil {
response.InternalServerError(c, "Failed to update product licenses")
return
}
}
if len(updates) == 0 && req.Licenses == nil {
response.BadRequest(c, "No fields to update")
return
}
@ -547,6 +595,13 @@ func (h *MarketplaceHandler) UpdateProduct(c *gin.Context) {
return
}
// Reload with licenses when they were updated
if req.Licenses != nil {
if p, err := h.service.GetProduct(c.Request.Context(), productID); err == nil {
product = p
}
}
response.Success(c, product)
}
@ -618,6 +673,36 @@ func (h *MarketplaceHandler) GetOrder(c *gin.Context) {
response.Success(c, order)
}
// GetMyLicenses returns all licenses purchased by the authenticated user (v0.401 M2)
func (h *MarketplaceHandler) GetMyLicenses(c *gin.Context) {
userID, ok := GetUserIDUUID(c)
if !ok {
return
}
licenses, err := h.service.GetUserLicenses(c.Request.Context(), userID)
if err != nil {
response.InternalServerError(c, "Failed to fetch licenses")
return
}
items := make([]gin.H, 0, len(licenses))
for _, lic := range licenses {
product, _ := h.service.GetProduct(c.Request.Context(), lic.ProductID)
order, _ := h.service.GetOrder(c.Request.Context(), lic.OrderID, userID)
downloadURL := ""
if url, err := h.service.GetDownloadURL(c.Request.Context(), userID, lic.ProductID); err == nil {
downloadURL = url
}
item := gin.H{
"license": lic,
"product": product,
"order": order,
"download_url": downloadURL,
}
items = append(items, item)
}
response.Success(c, gin.H{"licenses": items})
}
// GetSellStats returns seller stats (revenue, sales count) for the authenticated user
func (h *MarketplaceHandler) GetSellStats(c *gin.Context) {
userID, ok := GetUserIDUUID(c)

View file

@ -0,0 +1,19 @@
-- v0.401 M2: Product licenses (multiple license types per product with price and terms)
-- Types: streaming, personal, commercial, exclusive
DO $$
BEGIN
IF EXISTS (SELECT 1 FROM information_schema.tables WHERE table_schema = 'public' AND table_name = 'products') THEN
IF NOT EXISTS (SELECT 1 FROM information_schema.tables WHERE table_schema = 'public' AND table_name = 'product_licenses') THEN
CREATE TABLE product_licenses (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
product_id UUID NOT NULL REFERENCES products(id) ON DELETE CASCADE,
license_type VARCHAR(50) NOT NULL,
price_cents INTEGER NOT NULL,
terms_text TEXT,
created_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE INDEX idx_product_licenses_product_id ON product_licenses(product_id);
END IF;
END IF;
END $$;