veza/veza-backend-api/internal/handlers/marketplace.go
senke d5152d89a2
Some checks failed
Veza CI / Rust (Stream Server) (push) Successful in 5m28s
Security Scan / Secret Scanning (gitleaks) (push) Failing after 53s
Veza CI / Backend (Go) (push) Failing after 7m59s
Veza CI / Frontend (Web) (push) Failing after 17m43s
Veza CI / Notify on failure (push) Successful in 4s
E2E Playwright / e2e (full) (push) Failing after 20m55s
feat(stream): HLS default on + marketplace 30s pre-listen + FLAC tier checkbox (W4 Day 17)
Three pieces shipping under one banner since they're the day's
deliverables and share no review-time coupling :

1. HLS_STREAMING default flipped true
   - config.go : getEnvBool default true (was false). Operators wanting
     a lightweight dev / unit-test env explicitly set HLS_STREAMING=false
     to skip the transcoder pipeline.
   - .env.template : default flipped + comment explaining the opt-out.
   - Effect : every new track upload routes through the HLS transcoder
     by default ; ABR ladder served via /tracks/:id/master.m3u8.

2. Marketplace 30s pre-listen (creator opt-in)
   - migrations/989 : adds products.preview_enabled BOOLEAN NOT NULL
     DEFAULT FALSE + partial index on TRUE values. Default off so
     adoption is opt-in.
   - core/marketplace/models.go : PreviewEnabled field on Product.
   - handlers/marketplace.go : StreamProductPreview gains a fall-through.
     When no file-based ProductPreview exists AND the product is a
     track product AND preview_enabled=true, redirect to the underlying
     /tracks/:id/stream?preview=30. Header X-Preview-Cap-Seconds: 30
     surfaces the policy.
   - core/track/track_hls_handler.go : StreamTrack accepts ?preview=30
     and gates anonymous access via isMarketplacePreviewAllowed (raw
     SQL probe of products.preview_enabled to avoid the
     track→marketplace import cycle ; the reverse arrow already exists).
   - Trust model : 30s cap is enforced client-side (HTML5 audio
     currentTime). Industry standard for tease-to-buy ; not anti-rip.
     Documented in the migration + handler doc comment.

3. FLAC tier preview checkbox (Premium-gated, hidden by default)
   - upload-modal/constants.ts : optional flacAvailable on UploadFormData.
   - upload-modal/UploadModalMetadataForm.tsx : new optional props
     showFlacAvailable + flacAvailable + onFlacAvailableChange.
     Checkbox renders only when showFlacAvailable=true ; consumers
     pass that based on the user's role/subscription tier (deferred
     to caller wiring — Item G phase 4 will replace the role check
     with a real subscription-tier check).
   - Today the checkbox is a UI affordance only ; the actual lossless
     distribution path (ladder + storage class) is post-launch work.

Acceptance (Day 17) : new uploads serve HLS ABR by default ;
products.preview_enabled flag wires anonymous 30s pre-listen ;
checkbox visible to premium users on the upload form. All 4 tested
backend packages pass : handlers, core/track, core/marketplace, config.

W4 progress : Day 16 ✓ · Day 17 ✓ · Day 18 (faceted search)  ·
Day 19 (HAProxy sticky WS)  · Day 20 (k6 nightly) .

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 09:56:02 +02:00

974 lines
32 KiB
Go

package handlers
import (
"errors"
"net/http"
"os"
"path/filepath"
"strconv"
"strings"
"veza-backend-api/internal/core/marketplace"
"veza-backend-api/internal/response"
"veza-backend-api/internal/utils"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"go.uber.org/zap"
)
// MarketplaceHandler gère les opérations de la marketplace
type MarketplaceHandler struct {
service marketplace.MarketplaceService
commonHandler *CommonHandler
uploadDir string
}
// NewMarketplaceHandler crée une nouvelle instance de MarketplaceHandler
func NewMarketplaceHandler(service marketplace.MarketplaceService, logger *zap.Logger, uploadDir string) *MarketplaceHandler {
if uploadDir == "" {
uploadDir = "uploads"
}
return &MarketplaceHandler{
service: service,
commonHandler: NewCommonHandler(logger),
uploadDir: uploadDir,
}
}
// CreateProductRequest DTO pour la création de produit
// GO-013: Validation améliorée avec tags go-validator
// MOD-P1-001: Ajout tags validate pour validation systématique
// v0.401 M1: BPM, MusicalKey, Category
type CreateProductRequest struct {
Title string `json:"title" binding:"required,min=3,max=200" validate:"required,min=3,max=200"`
Description string `json:"description" binding:"max=2000" validate:"omitempty,max=2000"`
Price float64 `json:"price" binding:"required,min=0,gt=0" validate:"required,min=0,gt=0"`
ProductType string `json:"product_type" binding:"required,oneof=track pack service" validate:"required,oneof=track pack service"`
TrackID string `json:"track_id,omitempty" binding:"omitempty,uuid" validate:"omitempty,uuid"` // UUID string
LicenseType string `json:"license_type,omitempty" binding:"omitempty,oneof=standard exclusive commercial" validate:"omitempty,oneof=standard exclusive commercial"`
// v0.401 M1
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
// @Summary Create a new product
// @Description Create a product (Track, Pack, Service) for sale
// @Tags Marketplace
// @Accept json
// @Produce json
// @Security BearerAuth
// @Param product body CreateProductRequest true "Product info"
// @Success 201 {object} marketplace.Product
// @Success 201 {object} marketplace.Product
// @Failure 400 {object} response.APIResponse "Validation Error"
// @Failure 401 {object} response.APIResponse "Unauthorized"
// @Router /api/v1/marketplace/products [post]
func (h *MarketplaceHandler) CreateProduct(c *gin.Context) {
userID, ok := GetUserIDUUID(c)
if !ok {
return // Erreur déjà envoyée par GetUserIDUUID
}
var req CreateProductRequest
if appErr := h.commonHandler.BindAndValidateJSON(c, &req); appErr != nil {
RespondWithAppError(c, appErr)
return
}
product := &marketplace.Product{
SellerID: userID,
Title: req.Title,
Description: utils.SanitizeHTML(req.Description, 5000),
Price: req.Price,
ProductType: req.ProductType,
LicenseType: marketplace.LicenseType(req.LicenseType),
Status: marketplace.ProductStatusActive, // Direct active for MVP
BPM: req.BPM,
MusicalKey: req.MusicalKey,
Category: req.Category,
}
if req.TrackID != "" {
trackUUID, err := uuid.Parse(req.TrackID)
if err != nil {
response.BadRequest(c, "Invalid track_id format")
return
}
product.TrackID = &trackUUID
}
if err := h.service.CreateProduct(c.Request.Context(), product); err != nil {
if err == marketplace.ErrInvalidSeller {
response.Forbidden(c, "You do not own this track")
return
}
if err == marketplace.ErrTrackNotFound {
response.NotFound(c, "Track not found")
return
}
response.InternalServerError(c, "Failed to create product")
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)
}
// CreateOrderRequest DTO pour la création de commande
// MOD-P1-001: Ajout tags validate pour validation systématique
// v0.402 P2: promo_code optionnel
type CreateOrderRequest struct {
Items []struct {
ProductID string `json:"product_id" binding:"required" validate:"required,uuid"`
} `json:"items" binding:"required,min=1" validate:"required,min=1"`
PromoCode string `json:"promo_code,omitempty" binding:"omitempty,max=50" validate:"omitempty,max=50"`
}
// CreateOrder gère l'achat de produits
// @Summary Create a new order
// @Description Purchase products
// @Tags Marketplace
// @Accept json
// @Produce json
// @Security BearerAuth
// @Param order body CreateOrderRequest true "Order items"
// @Success 201 {object} marketplace.Order
// @Success 201 {object} marketplace.Order
// @Failure 400 {object} response.APIResponse "Validation Error"
// @Failure 401 {object} response.APIResponse "Unauthorized"
// @Router /api/v1/marketplace/orders [post]
func (h *MarketplaceHandler) CreateOrder(c *gin.Context) {
buyerID, ok := GetUserIDUUID(c)
if !ok {
return // Erreur déjà envoyée par GetUserIDUUID
}
var req CreateOrderRequest
if appErr := h.commonHandler.BindAndValidateJSON(c, &req); appErr != nil {
RespondWithAppError(c, appErr)
return
}
var items []marketplace.NewOrderItem
for _, item := range req.Items {
pid, err := uuid.Parse(item.ProductID)
if err != nil {
response.BadRequest(c, "Invalid product_id: "+item.ProductID)
return
}
items = append(items, marketplace.NewOrderItem{ProductID: pid})
}
promoCode := strings.TrimSpace(req.PromoCode)
resp, err := h.service.CreateOrder(c.Request.Context(), buyerID, items, promoCode)
if err != nil {
// MOD-P1-004: Détecter les erreurs de validation client et retourner 400 au lieu de 500
if errors.Is(err, marketplace.ErrPromoCodeInvalid) {
response.BadRequest(c, "Invalid or expired promo code")
return
}
errStr := err.Error()
if strings.Contains(errStr, "not found") || strings.Contains(errStr, "not active") {
// Erreurs de validation client (produit non trouvé, produit non actif)
response.BadRequest(c, err.Error())
return
}
if strings.Contains(errStr, "payment creation failed") {
response.BadRequest(c, "Payment service unavailable")
return
}
// Erreurs serveur (DB, IO, etc.) → 500
response.InternalServerError(c, "Failed to create order")
return
}
response.Created(c, resp)
}
// GetDownloadURL récupère l'URL de téléchargement pour un achat
// @Summary Get download URL
// @Description Get a secure download URL for a purchased product
// @Tags Marketplace
// @Accept json
// @Produce json
// @Security BearerAuth
// @Param product_id path string true "Product ID"
// @Success 200 {object} map[string]string
// @Failure 403 {object} response.APIResponse "No license"
// @Failure 404 {object} response.APIResponse "Not Found"
// @Router /api/v1/marketplace/download/{product_id} [get]
func (h *MarketplaceHandler) GetDownloadURL(c *gin.Context) {
userID, ok := GetUserIDUUID(c)
if !ok {
return // Erreur déjà envoyée par GetUserIDUUID
}
productIDStr := c.Param("product_id")
productID, err := uuid.Parse(productIDStr)
if err != nil {
response.BadRequest(c, "Invalid product_id")
return
}
url, err := h.service.GetDownloadURL(c.Request.Context(), userID, productID)
if err != nil {
if err == marketplace.ErrNoLicense {
response.Forbidden(c, "No valid license for this product")
return
}
if err == marketplace.ErrTrackNotFound {
response.NotFound(c, "Track file not found")
return
}
response.InternalServerError(c, "Failed to get download URL")
return
}
response.Success(c, gin.H{"url": url})
}
// UploadProductPreview handles audio preview upload for a product (v0.401 M1)
// POST /marketplace/products/:id/preview
func (h *MarketplaceHandler) UploadProductPreview(c *gin.Context) {
userID, ok := GetUserIDUUID(c)
if !ok {
return
}
productID, err := uuid.Parse(c.Param("id"))
if err != nil {
response.BadRequest(c, "Invalid product id")
return
}
file, err := c.FormFile("file")
if err != nil || file == nil {
response.BadRequest(c, "Missing or invalid file")
return
}
// SECURITY(REM-005): Validate file size (max 50MB for audio previews)
const maxPreviewSize = 50 << 20 // 50MB
if file.Size > maxPreviewSize {
response.BadRequest(c, "File too large. Maximum 50MB")
return
}
ext := strings.ToLower(filepath.Ext(file.Filename))
if ext != ".mp3" && ext != ".wav" && ext != ".m4a" && ext != ".ogg" {
response.BadRequest(c, "Invalid file type. Allowed: mp3, wav, m4a, ogg")
return
}
// SECURITY(REM-005): Sanitize filename to prevent path traversal — use UUID instead of user-supplied name.
safeFilename := uuid.New().String() + ext
previewDir := filepath.Join(h.uploadDir, "products", "previews", productID.String())
if err := os.MkdirAll(previewDir, 0755); err != nil {
response.InternalServerError(c, "Failed to create preview directory")
return
}
destPath := filepath.Join(previewDir, safeFilename)
if err := c.SaveUploadedFile(file, destPath); err != nil {
response.InternalServerError(c, "Failed to save preview")
return
}
relativePath := "products/previews/" + productID.String() + "/" + safeFilename
preview, err := h.service.AddProductPreview(c.Request.Context(), productID, userID, relativePath, nil)
if err != nil {
if err == marketplace.ErrProductNotFound {
response.NotFound(c, "Product not found")
return
}
if err == marketplace.ErrInvalidSeller {
response.Forbidden(c, "You do not own this product")
return
}
response.InternalServerError(c, "Failed to add preview")
return
}
response.Created(c, preview)
}
// CreateReviewRequest body for POST /products/:id/reviews (v0.403 R1)
type CreateReviewRequest struct {
Rating int `json:"rating" binding:"required,min=1,max=5" validate:"required,min=1,max=5"`
Comment string `json:"comment" binding:"max=2000" validate:"omitempty,max=2000"`
}
// CreateReview creates a review for a product (v0.403 R1). Buyer must have purchased the product.
func (h *MarketplaceHandler) CreateReview(c *gin.Context) {
userID, ok := GetUserIDUUID(c)
if !ok {
return
}
productID, err := uuid.Parse(c.Param("id"))
if err != nil {
response.BadRequest(c, "Invalid product id")
return
}
var req CreateReviewRequest
if appErr := h.commonHandler.BindAndValidateJSON(c, &req); appErr != nil {
RespondWithAppError(c, appErr)
return
}
review, err := h.service.CreateReview(c.Request.Context(), productID, userID, req.Rating, req.Comment)
if err != nil {
if err == marketplace.ErrProductNotFound {
response.NotFound(c, "Product not found")
return
}
if err == marketplace.ErrReviewNotPurchased {
response.Forbidden(c, "You must purchase the product before reviewing")
return
}
if err == marketplace.ErrReviewAlreadyExists {
response.Error(c, http.StatusConflict, "You have already reviewed this product")
return
}
response.InternalServerError(c, "Failed to create review")
return
}
response.Created(c, review)
}
// ListReviews returns paginated reviews for a product (v0.403 R1)
func (h *MarketplaceHandler) ListReviews(c *gin.Context) {
productID, err := uuid.Parse(c.Param("id"))
if err != nil {
response.BadRequest(c, "Invalid product id")
return
}
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "20"))
limit = clampLimit(limit) // SECURITY(MEDIUM-004)
offset, _ := strconv.Atoi(c.DefaultQuery("offset", "0"))
reviews, err := h.service.ListReviews(c.Request.Context(), productID, limit, offset)
if err != nil {
response.InternalServerError(c, "Failed to list reviews")
return
}
response.Success(c, gin.H{"reviews": reviews})
}
// GetProduct returns a single product by ID (v0.401 M1 - includes previews and images)
// GET /marketplace/products/:id
func (h *MarketplaceHandler) GetProduct(c *gin.Context) {
productID, err := uuid.Parse(c.Param("id"))
if err != nil {
response.BadRequest(c, "Invalid product id")
return
}
product, err := h.service.GetProduct(c.Request.Context(), productID)
if err != nil {
if err == marketplace.ErrProductNotFound {
response.NotFound(c, "Product not found")
return
}
response.InternalServerError(c, "Failed to get product")
return
}
response.Success(c, product)
}
// StreamProductPreview streams the first audio preview for a product (v0.401 M1)
// GET /marketplace/products/:id/preview
//
// v1.0.9 W4 Day 17 — when the product has no file-based preview but
// has product_type='track', track_id linked, AND preview_enabled=true,
// fall through to serving a 30-second slice of the underlying track.
// The 30s cap is enforced client-side (HTML5 audio currentTime) ; this
// is documented as the "tease-to-buy" trust model in the migration.
func (h *MarketplaceHandler) StreamProductPreview(c *gin.Context) {
productID, err := uuid.Parse(c.Param("id"))
if err != nil {
response.BadRequest(c, "Invalid product id")
return
}
product, err := h.service.GetProduct(c.Request.Context(), productID)
if err != nil {
response.NotFound(c, "Product not found")
return
}
// Path 1 : file-based preview already uploaded (legacy path).
if len(product.Previews) > 0 {
preview := product.Previews[0]
fullPath := filepath.Join(h.uploadDir, preview.FilePath)
if _, err := os.Stat(fullPath); os.IsNotExist(err) {
response.NotFound(c, "Preview file not found")
return
}
c.Header("Content-Disposition", "inline")
c.Header("X-Preview-Source", "file")
c.Header("X-Preview-Cap-Seconds", "0") // file is already trimmed at upload
c.File(fullPath)
return
}
// Path 2 (Day 17) : creator-opt-in 30s slice from the underlying
// track. Only honored when the product is a track product AND
// preview_enabled=true AND a track is linked.
if product.ProductType == "track" && product.PreviewEnabled && product.TrackID != nil {
// Redirect to the track stream endpoint with a query flag the
// frontend reads to enforce the 30s cap (audio.currentTime).
// Backend doesn't byte-cap : that requires bitrate-aware
// truncation which depends on the audio format. Industry
// standard (BandCamp / SoundCloud) uses the same client-cap
// model for this acceptance gate.
c.Header("X-Preview-Source", "track-30s")
c.Header("X-Preview-Cap-Seconds", "30")
c.Redirect(http.StatusFound,
"/api/v1/tracks/"+product.TrackID.String()+"/stream?preview=30")
return
}
response.NotFound(c, "Preview not available for this product")
}
// UpdateProductImagesRequest body for PUT /products/:id/images
type UpdateProductImagesRequest struct {
Images []struct {
URL string `json:"url" binding:"required" validate:"required,max=512"`
SortOrder int `json:"sort_order"`
} `json:"images" binding:"required" validate:"required"`
}
// UpdateProductImages updates product images (v0.401 M1)
// PUT /marketplace/products/:id/images
func (h *MarketplaceHandler) UpdateProductImages(c *gin.Context) {
userID, ok := GetUserIDUUID(c)
if !ok {
return
}
productID, err := uuid.Parse(c.Param("id"))
if err != nil {
response.BadRequest(c, "Invalid product id")
return
}
var req UpdateProductImagesRequest
if appErr := h.commonHandler.BindAndValidateJSON(c, &req); appErr != nil {
RespondWithAppError(c, appErr)
return
}
images := make([]marketplace.ProductImageInput, len(req.Images))
for i, img := range req.Images {
images[i] = marketplace.ProductImageInput{URL: img.URL, SortOrder: img.SortOrder}
}
result, err := h.service.UpdateProductImages(c.Request.Context(), productID, userID, images)
if err != nil {
if err == marketplace.ErrProductNotFound {
response.NotFound(c, "Product not found")
return
}
if err == marketplace.ErrInvalidSeller {
response.Forbidden(c, "You do not own this product")
return
}
response.InternalServerError(c, "Failed to update images")
return
}
response.Success(c, result)
}
// @Summary List products
// @Description List marketplace products with filters
// @Tags Marketplace
// @Accept json
// @Produce json
// @Param status query string false "Product status"
// @Param seller_id query string false "Seller ID"
// @Param q query string false "Search query"
// @Param type query string false "Product type (track, pack, service)"
// @Param min_price query number false "Minimum price"
// @Param max_price query number false "Maximum price"
// @Param page query int false "Page number"
// @Param limit query int false "Items per page"
// @Success 200 {array} marketplace.Product
// @Router /api/v1/marketplace/products [get]
func (h *MarketplaceHandler) ListProducts(c *gin.Context) {
filters := make(map[string]interface{})
if status := c.Query("status"); status != "" {
filters["status"] = status
}
if sellerID := c.Query("seller_id"); sellerID != "" {
if sellerID == "me" {
uid, ok := GetUserIDUUID(c)
if !ok {
response.Unauthorized(c, "Authentication required to filter by own products")
return
}
filters["seller_id"] = uid.String()
} else {
filters["seller_id"] = sellerID
}
}
if q := c.Query("q"); q != "" {
filters["search"] = q
}
if pType := c.Query("type"); pType != "" {
filters["product_type"] = pType
}
if bpmStr := c.Query("bpm"); bpmStr != "" {
if val, err := strconv.Atoi(bpmStr); err == nil && val > 0 {
filters["bpm"] = val
}
}
if musicalKey := c.Query("musical_key"); musicalKey != "" {
filters["musical_key"] = musicalKey
}
if category := c.Query("category"); category != "" {
filters["category"] = category
}
if minPriceStr := c.Query("min_price"); minPriceStr != "" {
if val, err := strconv.ParseFloat(minPriceStr, 64); err == nil {
filters["min_price"] = val
}
}
if maxPriceStr := c.Query("max_price"); maxPriceStr != "" {
if val, err := strconv.ParseFloat(maxPriceStr, 64); err == nil {
filters["max_price"] = val
}
}
if pageStr := c.Query("page"); pageStr != "" {
if val, err := strconv.Atoi(pageStr); err == nil && val > 0 {
// Calculate offset assuming default limit if not provided
// But limit parsing comes next, so let's store page and conform later or just pass page?
// Service expects offset. Let's wait for limit.
filters["page_number"] = val // Temporary storage
}
}
limit := 20
if limitStr := c.Query("limit"); limitStr != "" {
if val, err := strconv.Atoi(limitStr); err == nil && val > 0 {
limit = val
filters["limit"] = val
}
}
// Handle pagination offset
if pageVal, ok := filters["page_number"].(int); ok {
filters["offset"] = (pageVal - 1) * limit
delete(filters, "page_number") // cleanup
} else if offsetStr := c.Query("offset"); offsetStr != "" {
if val, err := strconv.Atoi(offsetStr); err == nil {
filters["offset"] = val
}
}
products, err := h.service.ListProducts(c.Request.Context(), filters)
if err != nil {
response.InternalServerError(c, "Failed to list products")
return
}
response.Success(c, products)
}
// UpdateProductRequest DTO pour la mise à jour de produit
// BE-API-037: Implement marketplace product update endpoint
// MOD-P1-001: Ajout tags validate pour validation systématique
// v0.401 M1: BPM, MusicalKey, Category
type UpdateProductRequest struct {
Title *string `json:"title,omitempty" binding:"omitempty,min=3,max=200" validate:"omitempty,min=3,max=200"`
Description *string `json:"description,omitempty" binding:"omitempty,max=2000" validate:"omitempty,max=2000"`
Price *float64 `json:"price,omitempty" binding:"omitempty,min=0,gt=0" validate:"omitempty,min=0,gt=0"`
Status *string `json:"status,omitempty" binding:"omitempty,oneof=draft active archived" validate:"omitempty,oneof=draft active archived"`
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
// BE-API-037: PUT /api/v1/marketplace/products/:id to update product details
// @Summary Update a product
// @Description Update product details (only seller can update)
// @Tags Marketplace
// @Accept json
// @Produce json
// @Security BearerAuth
// @Param id path string true "Product ID"
// @Param product body UpdateProductRequest true "Product updates"
// @Success 200 {object} marketplace.Product
// @Failure 400 {object} response.APIResponse "Validation Error"
// @Failure 401 {object} response.APIResponse "Unauthorized"
// @Failure 403 {object} response.APIResponse "Forbidden - Not product owner"
// @Failure 404 {object} response.APIResponse "Product not found"
// @Router /api/v1/marketplace/products/{id} [put]
func (h *MarketplaceHandler) UpdateProduct(c *gin.Context) {
userID, ok := GetUserIDUUID(c)
if !ok {
return // Erreur déjà envoyée par GetUserIDUUID
}
productIDStr := c.Param("id")
productID, err := uuid.Parse(productIDStr)
if err != nil {
response.BadRequest(c, "Invalid product id")
return
}
var req UpdateProductRequest
if appErr := h.commonHandler.BindAndValidateJSON(c, &req); appErr != nil {
RespondWithAppError(c, appErr)
return
}
// Build updates map
updates := make(map[string]interface{})
if req.Title != nil {
updates["title"] = *req.Title
}
if req.Description != nil {
updates["description"] = utils.SanitizeHTML(*req.Description, 5000)
}
if req.Price != nil {
updates["price"] = *req.Price
}
if req.Status != nil {
updates["status"] = *req.Status
}
if req.BPM != nil {
updates["bpm"] = *req.BPM
}
if req.MusicalKey != nil {
updates["musical_key"] = *req.MusicalKey
}
if req.Category != nil {
updates["category"] = *req.Category
}
// 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
}
product, err := h.service.UpdateProduct(c.Request.Context(), productID, userID, updates)
if err != nil {
if err == marketplace.ErrProductNotFound {
response.NotFound(c, "Product not found")
return
}
if err == marketplace.ErrInvalidSeller {
response.Forbidden(c, "You do not own this product")
return
}
response.InternalServerError(c, "Failed to update product")
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)
}
// ListOrders gère la récupération de la liste des commandes de l'utilisateur
// BE-API-038: GET /api/v1/marketplace/orders to list user's orders
// @Summary List user orders
// @Description Get all orders for the authenticated user
// @Tags Marketplace
// @Accept json
// @Produce json
// @Security BearerAuth
// @Success 200 {array} marketplace.Order
// @Failure 401 {object} response.APIResponse "Unauthorized"
// @Failure 500 {object} response.APIResponse "Internal Error"
// @Router /api/v1/marketplace/orders [get]
func (h *MarketplaceHandler) ListOrders(c *gin.Context) {
userID, ok := GetUserIDUUID(c)
if !ok {
return // Erreur déjà envoyée par GetUserIDUUID
}
orders, err := h.service.ListOrders(c.Request.Context(), userID)
if err != nil {
response.InternalServerError(c, "Failed to list orders")
return
}
response.Success(c, orders)
}
// GetOrder gère la récupération des détails d'une commande
// BE-API-039: GET /api/v1/marketplace/orders/:id to get order details
// @Summary Get order details
// @Description Get details of a specific order (only order owner can access)
// @Tags Marketplace
// @Accept json
// @Produce json
// @Security BearerAuth
// @Param id path string true "Order ID"
// @Success 200 {object} marketplace.Order
// @Failure 400 {object} response.APIResponse "Validation Error"
// @Failure 401 {object} response.APIResponse "Unauthorized"
// @Failure 403 {object} response.APIResponse "Forbidden - Not order owner"
// @Failure 404 {object} response.APIResponse "Order not found"
// @Router /api/v1/marketplace/orders/{id} [get]
func (h *MarketplaceHandler) GetOrder(c *gin.Context) {
userID, ok := GetUserIDUUID(c)
if !ok {
return // Erreur déjà envoyée par GetUserIDUUID
}
orderIDStr := c.Param("id")
orderID, err := uuid.Parse(orderIDStr)
if err != nil {
response.BadRequest(c, "Invalid order id")
return
}
order, err := h.service.GetOrder(c.Request.Context(), orderID, userID)
if err != nil {
if err == marketplace.ErrOrderNotFound {
response.NotFound(c, "Order not found")
return
}
response.InternalServerError(c, "Failed to get order")
return
}
response.Success(c, order)
}
// RefundOrderRequest body for POST /orders/:id/refund (v0.403 R2)
type RefundOrderRequest struct {
Reason string `json:"reason" binding:"max=500" validate:"omitempty,max=500"`
Details string `json:"details" binding:"max=2000" validate:"omitempty,max=2000"`
}
// RefundOrder initiates a refund for an order (v0.403 R2). Allowed: buyer, seller of products, or admin.
func (h *MarketplaceHandler) RefundOrder(c *gin.Context) {
userID, ok := GetUserIDUUID(c)
if !ok {
return
}
orderID, err := uuid.Parse(c.Param("id"))
if err != nil {
response.BadRequest(c, "Invalid order id")
return
}
var req RefundOrderRequest
_ = c.ShouldBindJSON(&req)
reason := req.Reason
if reason == "" {
reason = "Requested by customer"
}
refund, err := h.service.RefundOrder(c.Request.Context(), orderID, userID, reason)
if err != nil {
if err == marketplace.ErrOrderNotFound {
response.NotFound(c, "Order not found")
return
}
if err == marketplace.ErrOrderNotRefundable {
response.BadRequest(c, "Order cannot be refunded")
return
}
if err == marketplace.ErrRefundNotAvailable {
response.BadRequest(c, "Refunds are not available")
return
}
if err == marketplace.ErrRefundForbidden {
response.Forbidden(c, "You are not allowed to refund this order")
return
}
if err == marketplace.ErrRefundAlreadyRequested {
response.BadRequest(c, "A refund is already in progress for this order")
return
}
response.InternalServerError(c, "Failed to process refund")
return
}
// v1.0.6: refund is pending until the Hyperswitch webhook confirms.
// Surface the refund id + status so the UI can show "pending" and
// later re-query the order.
response.Success(c, gin.H{
"message": "Refund requested — you'll be notified when it's finalized.",
"refund": gin.H{
"id": refund.ID,
"status": refund.Status,
},
})
}
// GetOrderInvoice returns a PDF invoice for an order (v0.403 F1)
func (h *MarketplaceHandler) GetOrderInvoice(c *gin.Context) {
userID, ok := GetUserIDUUID(c)
if !ok {
return
}
orderID, err := uuid.Parse(c.Param("id"))
if err != nil {
response.BadRequest(c, "Invalid order id")
return
}
pdf, err := h.service.GenerateInvoice(c.Request.Context(), orderID, userID)
if err != nil {
if err == marketplace.ErrOrderNotFound {
response.NotFound(c, "Order not found")
return
}
response.InternalServerError(c, "Failed to generate invoice")
return
}
c.Header("Content-Type", "application/pdf")
c.Header("Content-Disposition", "attachment; filename=\"invoice-"+orderID.String()[:8]+".pdf\"")
c.Data(200, "application/pdf", pdf)
}
// 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)
if !ok {
return
}
revenue, salesCount, err := h.service.GetSellerStats(c.Request.Context(), userID)
if err != nil {
response.InternalServerError(c, "Failed to fetch seller stats")
return
}
response.Success(c, gin.H{
"revenue": revenue,
"sales": salesCount,
"sales_count": salesCount,
})
}
// GetSellStatsEvolution returns revenue/sales evolution by period (v0.401 M3)
func (h *MarketplaceHandler) GetSellStatsEvolution(c *gin.Context) {
userID, ok := GetUserIDUUID(c)
if !ok {
return
}
period := c.DefaultQuery("period", "day")
if period != "day" && period != "week" && period != "month" {
period = "day"
}
data, err := h.service.GetSellerStatsEvolution(c.Request.Context(), userID, period)
if err != nil {
response.InternalServerError(c, "Failed to fetch stats evolution")
return
}
response.Success(c, gin.H{"evolution": data})
}
// GetSellTopProducts returns top products by revenue (v0.401 M3)
func (h *MarketplaceHandler) GetSellTopProducts(c *gin.Context) {
userID, ok := GetUserIDUUID(c)
if !ok {
return
}
limit := 10
if l := c.Query("limit"); l != "" {
if v, err := strconv.Atoi(l); err == nil && v > 0 && v <= 50 {
limit = v
}
}
data, err := h.service.GetSellerTopProducts(c.Request.Context(), userID, limit)
if err != nil {
response.InternalServerError(c, "Failed to fetch top products")
return
}
response.Success(c, gin.H{"top_products": data})
}
// GetSellSales returns recent sales for the seller (v0.401 M3)
func (h *MarketplaceHandler) GetSellSales(c *gin.Context) {
userID, ok := GetUserIDUUID(c)
if !ok {
return
}
limit := 20
if l := c.Query("limit"); l != "" {
if v, err := strconv.Atoi(l); err == nil && v > 0 && v <= 100 {
limit = v
}
}
data, err := h.service.GetSellerSales(c.Request.Context(), userID, limit)
if err != nil {
response.InternalServerError(c, "Failed to fetch sales")
return
}
response.Success(c, gin.H{"sales": data})
}