diff --git a/veza-backend-api/internal/api/routes_marketplace.go b/veza-backend-api/internal/api/routes_marketplace.go index 317b31ffb..46b6ff67d 100644 --- a/veza-backend-api/internal/api/routes_marketplace.go +++ b/veza-backend-api/internal/api/routes_marketplace.go @@ -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) diff --git a/veza-backend-api/internal/core/marketplace/models.go b/veza-backend-api/internal/core/marketplace/models.go index 5244f8816..f5603de6e 100644 --- a/veza-backend-api/internal/core/marketplace/models.go +++ b/veza-backend-api/internal/core/marketplace/models.go @@ -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() diff --git a/veza-backend-api/internal/core/marketplace/service.go b/veza-backend-api/internal/core/marketplace/service.go index db8fbeed7..d7553a26f 100644 --- a/veza-backend-api/internal/core/marketplace/service.go +++ b/veza-backend-api/internal/core/marketplace/service.go @@ -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 +} diff --git a/veza-backend-api/internal/handlers/marketplace.go b/veza-backend-api/internal/handlers/marketplace.go index d28737bf5..547fe47ac 100644 --- a/veza-backend-api/internal/handlers/marketplace.go +++ b/veza-backend-api/internal/handlers/marketplace.go @@ -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) diff --git a/veza-backend-api/migrations/098_product_licenses.sql b/veza-backend-api/migrations/098_product_licenses.sql new file mode 100644 index 000000000..164f0890c --- /dev/null +++ b/veza-backend-api/migrations/098_product_licenses.sql @@ -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 $$;