veza/veza-backend-api/internal/handlers/marketplace.go

929 lines
30 KiB
Go
Raw Normal View History

2025-12-03 19:29:37 +00:00
package handlers
import (
"errors"
"net/http"
"os"
"path/filepath"
"strconv"
2025-12-16 18:34:08 +00:00
"strings"
2025-12-16 16:23:49 +00:00
"veza-backend-api/internal/core/marketplace"
"veza-backend-api/internal/response"
"veza-backend-api/internal/utils"
2025-12-16 16:23:49 +00:00
2025-12-03 19:29:37 +00:00
"github.com/gin-gonic/gin"
"github.com/google/uuid"
P0: stabilisation backend/chat/stream + nouvelle base migrations v1 Backend Go: - Remplacement complet des anciennes migrations par la base V1 alignée sur ORIGIN. - Durcissement global du parsing JSON (BindAndValidateJSON + RespondWithAppError). - Sécurisation de config.go, CORS, statuts de santé et monitoring. - Implémentation des transactions P0 (RBAC, duplication de playlists, social toggles). - Ajout d’un job worker structuré (emails, analytics, thumbnails) + tests associés. - Nouvelle doc backend : AUDIT_CONFIG, BACKEND_CONFIG, AUTH_PASSWORD_RESET, JOB_WORKER_*. Chat server (Rust): - Refonte du pipeline JWT + sécurité, audit et rate limiting avancé. - Implémentation complète du cycle de message (read receipts, delivered, edit/delete, typing). - Nettoyage des panics, gestion d’erreurs robuste, logs structurés. - Migrations chat alignées sur le schéma UUID et nouvelles features. Stream server (Rust): - Refonte du moteur de streaming (encoding pipeline + HLS) et des modules core. - Transactions P0 pour les jobs et segments, garanties d’atomicité. - Documentation détaillée de la pipeline (AUDIT_STREAM_*, DESIGN_STREAM_PIPELINE, TRANSACTIONS_P0_IMPLEMENTATION). Documentation & audits: - TRIAGE.md et AUDIT_STABILITY.md à jour avec l’état réel des 3 services. - Cartographie complète des migrations et des transactions (DB_MIGRATIONS_*, DB_TRANSACTION_PLAN, AUDIT_DB_TRANSACTIONS, TRANSACTION_TESTS_PHASE3). - Scripts de reset et de cleanup pour la lab DB et la V1. Ce commit fige l’ensemble du travail de stabilisation P0 (UUID, backend, chat et stream) avant les phases suivantes (Coherence Guardian, WS hardening, etc.).
2025-12-06 10:14:38 +00:00
"go.uber.org/zap"
2025-12-03 19:29:37 +00:00
)
// MarketplaceHandler gère les opérations de la marketplace
type MarketplaceHandler struct {
P0: stabilisation backend/chat/stream + nouvelle base migrations v1 Backend Go: - Remplacement complet des anciennes migrations par la base V1 alignée sur ORIGIN. - Durcissement global du parsing JSON (BindAndValidateJSON + RespondWithAppError). - Sécurisation de config.go, CORS, statuts de santé et monitoring. - Implémentation des transactions P0 (RBAC, duplication de playlists, social toggles). - Ajout d’un job worker structuré (emails, analytics, thumbnails) + tests associés. - Nouvelle doc backend : AUDIT_CONFIG, BACKEND_CONFIG, AUTH_PASSWORD_RESET, JOB_WORKER_*. Chat server (Rust): - Refonte du pipeline JWT + sécurité, audit et rate limiting avancé. - Implémentation complète du cycle de message (read receipts, delivered, edit/delete, typing). - Nettoyage des panics, gestion d’erreurs robuste, logs structurés. - Migrations chat alignées sur le schéma UUID et nouvelles features. Stream server (Rust): - Refonte du moteur de streaming (encoding pipeline + HLS) et des modules core. - Transactions P0 pour les jobs et segments, garanties d’atomicité. - Documentation détaillée de la pipeline (AUDIT_STREAM_*, DESIGN_STREAM_PIPELINE, TRANSACTIONS_P0_IMPLEMENTATION). Documentation & audits: - TRIAGE.md et AUDIT_STABILITY.md à jour avec l’état réel des 3 services. - Cartographie complète des migrations et des transactions (DB_MIGRATIONS_*, DB_TRANSACTION_PLAN, AUDIT_DB_TRANSACTIONS, TRANSACTION_TESTS_PHASE3). - Scripts de reset et de cleanup pour la lab DB et la V1. Ce commit fige l’ensemble du travail de stabilisation P0 (UUID, backend, chat et stream) avant les phases suivantes (Coherence Guardian, WS hardening, etc.).
2025-12-06 10:14:38 +00:00
service marketplace.MarketplaceService
commonHandler *CommonHandler
uploadDir string
2025-12-03 19:29:37 +00:00
}
// NewMarketplaceHandler crée une nouvelle instance de MarketplaceHandler
func NewMarketplaceHandler(service marketplace.MarketplaceService, logger *zap.Logger, uploadDir string) *MarketplaceHandler {
if uploadDir == "" {
uploadDir = "uploads"
}
P0: stabilisation backend/chat/stream + nouvelle base migrations v1 Backend Go: - Remplacement complet des anciennes migrations par la base V1 alignée sur ORIGIN. - Durcissement global du parsing JSON (BindAndValidateJSON + RespondWithAppError). - Sécurisation de config.go, CORS, statuts de santé et monitoring. - Implémentation des transactions P0 (RBAC, duplication de playlists, social toggles). - Ajout d’un job worker structuré (emails, analytics, thumbnails) + tests associés. - Nouvelle doc backend : AUDIT_CONFIG, BACKEND_CONFIG, AUTH_PASSWORD_RESET, JOB_WORKER_*. Chat server (Rust): - Refonte du pipeline JWT + sécurité, audit et rate limiting avancé. - Implémentation complète du cycle de message (read receipts, delivered, edit/delete, typing). - Nettoyage des panics, gestion d’erreurs robuste, logs structurés. - Migrations chat alignées sur le schéma UUID et nouvelles features. Stream server (Rust): - Refonte du moteur de streaming (encoding pipeline + HLS) et des modules core. - Transactions P0 pour les jobs et segments, garanties d’atomicité. - Documentation détaillée de la pipeline (AUDIT_STREAM_*, DESIGN_STREAM_PIPELINE, TRANSACTIONS_P0_IMPLEMENTATION). Documentation & audits: - TRIAGE.md et AUDIT_STABILITY.md à jour avec l’état réel des 3 services. - Cartographie complète des migrations et des transactions (DB_MIGRATIONS_*, DB_TRANSACTION_PLAN, AUDIT_DB_TRANSACTIONS, TRANSACTION_TESTS_PHASE3). - Scripts de reset et de cleanup pour la lab DB et la V1. Ce commit fige l’ensemble du travail de stabilisation P0 (UUID, backend, chat et stream) avant les phases suivantes (Coherence Guardian, WS hardening, etc.).
2025-12-06 10:14:38 +00:00
return &MarketplaceHandler{
service: service,
commonHandler: NewCommonHandler(logger),
uploadDir: uploadDir,
P0: stabilisation backend/chat/stream + nouvelle base migrations v1 Backend Go: - Remplacement complet des anciennes migrations par la base V1 alignée sur ORIGIN. - Durcissement global du parsing JSON (BindAndValidateJSON + RespondWithAppError). - Sécurisation de config.go, CORS, statuts de santé et monitoring. - Implémentation des transactions P0 (RBAC, duplication de playlists, social toggles). - Ajout d’un job worker structuré (emails, analytics, thumbnails) + tests associés. - Nouvelle doc backend : AUDIT_CONFIG, BACKEND_CONFIG, AUTH_PASSWORD_RESET, JOB_WORKER_*. Chat server (Rust): - Refonte du pipeline JWT + sécurité, audit et rate limiting avancé. - Implémentation complète du cycle de message (read receipts, delivered, edit/delete, typing). - Nettoyage des panics, gestion d’erreurs robuste, logs structurés. - Migrations chat alignées sur le schéma UUID et nouvelles features. Stream server (Rust): - Refonte du moteur de streaming (encoding pipeline + HLS) et des modules core. - Transactions P0 pour les jobs et segments, garanties d’atomicité. - Documentation détaillée de la pipeline (AUDIT_STREAM_*, DESIGN_STREAM_PIPELINE, TRANSACTIONS_P0_IMPLEMENTATION). Documentation & audits: - TRIAGE.md et AUDIT_STABILITY.md à jour avec l’état réel des 3 services. - Cartographie complète des migrations et des transactions (DB_MIGRATIONS_*, DB_TRANSACTION_PLAN, AUDIT_DB_TRANSACTIONS, TRANSACTION_TESTS_PHASE3). - Scripts de reset et de cleanup pour la lab DB et la V1. Ce commit fige l’ensemble du travail de stabilisation P0 (UUID, backend, chat et stream) avant les phases suivantes (Coherence Guardian, WS hardening, etc.).
2025-12-06 10:14:38 +00:00
}
2025-12-03 19:29:37 +00:00
}
// CreateProductRequest DTO pour la création de produit
// GO-013: Validation améliorée avec tags go-validator
2025-12-16 18:34:08 +00:00
// MOD-P1-001: Ajout tags validate pour validation systématique
// v0.401 M1: BPM, MusicalKey, Category
2025-12-03 19:29:37 +00:00
type CreateProductRequest struct {
2025-12-16 18:34:08 +00:00
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"`
2025-12-03 19:29:37 +00:00
}
// 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"
2025-12-03 19:29:37 +00:00
// @Router /api/v1/marketplace/products [post]
func (h *MarketplaceHandler) CreateProduct(c *gin.Context) {
2025-12-16 16:23:49 +00:00
userID, ok := GetUserIDUUID(c)
if !ok {
return // Erreur déjà envoyée par GetUserIDUUID
}
2025-12-03 19:29:37 +00:00
var req CreateProductRequest
P0: stabilisation backend/chat/stream + nouvelle base migrations v1 Backend Go: - Remplacement complet des anciennes migrations par la base V1 alignée sur ORIGIN. - Durcissement global du parsing JSON (BindAndValidateJSON + RespondWithAppError). - Sécurisation de config.go, CORS, statuts de santé et monitoring. - Implémentation des transactions P0 (RBAC, duplication de playlists, social toggles). - Ajout d’un job worker structuré (emails, analytics, thumbnails) + tests associés. - Nouvelle doc backend : AUDIT_CONFIG, BACKEND_CONFIG, AUTH_PASSWORD_RESET, JOB_WORKER_*. Chat server (Rust): - Refonte du pipeline JWT + sécurité, audit et rate limiting avancé. - Implémentation complète du cycle de message (read receipts, delivered, edit/delete, typing). - Nettoyage des panics, gestion d’erreurs robuste, logs structurés. - Migrations chat alignées sur le schéma UUID et nouvelles features. Stream server (Rust): - Refonte du moteur de streaming (encoding pipeline + HLS) et des modules core. - Transactions P0 pour les jobs et segments, garanties d’atomicité. - Documentation détaillée de la pipeline (AUDIT_STREAM_*, DESIGN_STREAM_PIPELINE, TRANSACTIONS_P0_IMPLEMENTATION). Documentation & audits: - TRIAGE.md et AUDIT_STABILITY.md à jour avec l’état réel des 3 services. - Cartographie complète des migrations et des transactions (DB_MIGRATIONS_*, DB_TRANSACTION_PLAN, AUDIT_DB_TRANSACTIONS, TRANSACTION_TESTS_PHASE3). - Scripts de reset et de cleanup pour la lab DB et la V1. Ce commit fige l’ensemble du travail de stabilisation P0 (UUID, backend, chat et stream) avant les phases suivantes (Coherence Guardian, WS hardening, etc.).
2025-12-06 10:14:38 +00:00
if appErr := h.commonHandler.BindAndValidateJSON(c, &req); appErr != nil {
RespondWithAppError(c, appErr)
2025-12-03 19:29:37 +00:00
return
}
product := &marketplace.Product{
SellerID: userID,
Title: req.Title,
Description: utils.SanitizeHTML(req.Description, 5000),
2025-12-03 19:29:37 +00:00
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,
2025-12-03 19:29:37 +00:00
}
if req.TrackID != "" {
trackUUID, err := uuid.Parse(req.TrackID)
if err != nil {
response.BadRequest(c, "Invalid track_id format")
2025-12-03 19:29:37 +00:00
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")
2025-12-03 19:29:37 +00:00
return
}
if err == marketplace.ErrTrackNotFound {
response.NotFound(c, "Track not found")
2025-12-03 19:29:37 +00:00
return
}
response.InternalServerError(c, "Failed to create product")
2025-12-03 19:29:37 +00:00
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)
2025-12-03 19:29:37 +00:00
}
// CreateOrderRequest DTO pour la création de commande
2025-12-16 18:34:08 +00:00
// MOD-P1-001: Ajout tags validate pour validation systématique
// v0.402 P2: promo_code optionnel
2025-12-03 19:29:37 +00:00
type CreateOrderRequest struct {
Items []struct {
2025-12-16 18:34:08 +00:00
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"`
2025-12-03 19:29:37 +00:00
}
// 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"
2025-12-03 19:29:37 +00:00
// @Router /api/v1/marketplace/orders [post]
func (h *MarketplaceHandler) CreateOrder(c *gin.Context) {
2025-12-16 16:23:49 +00:00
buyerID, ok := GetUserIDUUID(c)
if !ok {
return // Erreur déjà envoyée par GetUserIDUUID
}
2025-12-03 19:29:37 +00:00
var req CreateOrderRequest
P0: stabilisation backend/chat/stream + nouvelle base migrations v1 Backend Go: - Remplacement complet des anciennes migrations par la base V1 alignée sur ORIGIN. - Durcissement global du parsing JSON (BindAndValidateJSON + RespondWithAppError). - Sécurisation de config.go, CORS, statuts de santé et monitoring. - Implémentation des transactions P0 (RBAC, duplication de playlists, social toggles). - Ajout d’un job worker structuré (emails, analytics, thumbnails) + tests associés. - Nouvelle doc backend : AUDIT_CONFIG, BACKEND_CONFIG, AUTH_PASSWORD_RESET, JOB_WORKER_*. Chat server (Rust): - Refonte du pipeline JWT + sécurité, audit et rate limiting avancé. - Implémentation complète du cycle de message (read receipts, delivered, edit/delete, typing). - Nettoyage des panics, gestion d’erreurs robuste, logs structurés. - Migrations chat alignées sur le schéma UUID et nouvelles features. Stream server (Rust): - Refonte du moteur de streaming (encoding pipeline + HLS) et des modules core. - Transactions P0 pour les jobs et segments, garanties d’atomicité. - Documentation détaillée de la pipeline (AUDIT_STREAM_*, DESIGN_STREAM_PIPELINE, TRANSACTIONS_P0_IMPLEMENTATION). Documentation & audits: - TRIAGE.md et AUDIT_STABILITY.md à jour avec l’état réel des 3 services. - Cartographie complète des migrations et des transactions (DB_MIGRATIONS_*, DB_TRANSACTION_PLAN, AUDIT_DB_TRANSACTIONS, TRANSACTION_TESTS_PHASE3). - Scripts de reset et de cleanup pour la lab DB et la V1. Ce commit fige l’ensemble du travail de stabilisation P0 (UUID, backend, chat et stream) avant les phases suivantes (Coherence Guardian, WS hardening, etc.).
2025-12-06 10:14:38 +00:00
if appErr := h.commonHandler.BindAndValidateJSON(c, &req); appErr != nil {
RespondWithAppError(c, appErr)
2025-12-03 19:29:37 +00:00
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)
2025-12-03 19:29:37 +00:00
return
}
items = append(items, marketplace.NewOrderItem{ProductID: pid})
}
promoCode := strings.TrimSpace(req.PromoCode)
resp, err := h.service.CreateOrder(c.Request.Context(), buyerID, items, promoCode)
2025-12-03 19:29:37 +00:00
if err != nil {
2025-12-16 18:34:08 +00:00
// 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
}
2025-12-16 18:34:08 +00:00
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
}
2025-12-16 18:34:08 +00:00
// Erreurs serveur (DB, IO, etc.) → 500
response.InternalServerError(c, "Failed to create order")
2025-12-03 19:29:37 +00:00
return
}
response.Created(c, resp)
2025-12-03 19:29:37 +00:00
}
// 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"
2025-12-03 19:29:37 +00:00
// @Router /api/v1/marketplace/download/{product_id} [get]
func (h *MarketplaceHandler) GetDownloadURL(c *gin.Context) {
2025-12-16 16:23:49 +00:00
userID, ok := GetUserIDUUID(c)
if !ok {
return // Erreur déjà envoyée par GetUserIDUUID
}
2025-12-03 19:29:37 +00:00
productIDStr := c.Param("product_id")
2025-12-03 19:29:37 +00:00
productID, err := uuid.Parse(productIDStr)
if err != nil {
response.BadRequest(c, "Invalid product_id")
2025-12-03 19:29:37 +00:00
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")
2025-12-03 19:29:37 +00:00
return
}
if err == marketplace.ErrTrackNotFound {
response.NotFound(c, "Track file not found")
2025-12-03 19:29:37 +00:00
return
}
response.InternalServerError(c, "Failed to get download URL")
2025-12-03 19:29:37 +00:00
return
}
response.Success(c, gin.H{"url": url})
2025-12-03 19:29:37 +00:00
}
// 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
}
fix(v0.12.6): apply all pentest remediations — 36 findings across 36 files CRITICAL fixes: - Race condition (TOCTOU) in payout/refund with SELECT FOR UPDATE (CRITICAL-001/002) - IDOR on analytics endpoint — ownership check enforced (CRITICAL-003) - CSWSH on all WebSocket endpoints — origin whitelist (CRITICAL-004) - Mass assignment on user self-update — strip privileged fields (CRITICAL-005) HIGH fixes: - Path traversal in marketplace upload — UUID filenames (HIGH-001) - IP spoofing — use Gin trusted proxy c.ClientIP() (HIGH-002) - Popularity metrics (followers, likes) set to json:"-" (HIGH-003) - bcrypt cost hardened to 12 everywhere (HIGH-004) - Refresh token lock made mandatory (HIGH-005) - Stream token replay prevention with access_count (HIGH-006) - Subscription trial race condition fixed (HIGH-007) - License download expiration check (HIGH-008) - Webhook amount validation (HIGH-009) - pprof endpoint removed from production (HIGH-010) MEDIUM fixes: - WebSocket message size limit 64KB (MEDIUM-010) - HSTS header in nginx production (MEDIUM-001) - CORS origin restricted in nginx-rtmp (MEDIUM-002) - Docker alpine pinned to 3.21 (MEDIUM-003/004) - Redis authentication enforced (MEDIUM-005) - GDPR account deletion expanded (MEDIUM-006) - .gitignore hardened (MEDIUM-007) LOW/INFO fixes: - GitHub Actions SHA pinning on all workflows (LOW-001) - .env.example security documentation (INFO-001) - Production CORS set to HTTPS (LOW-002) All tests pass. Go and Rust compile clean. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 23:44:46 +00:00
// 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
}
fix(v0.12.6): apply all pentest remediations — 36 findings across 36 files CRITICAL fixes: - Race condition (TOCTOU) in payout/refund with SELECT FOR UPDATE (CRITICAL-001/002) - IDOR on analytics endpoint — ownership check enforced (CRITICAL-003) - CSWSH on all WebSocket endpoints — origin whitelist (CRITICAL-004) - Mass assignment on user self-update — strip privileged fields (CRITICAL-005) HIGH fixes: - Path traversal in marketplace upload — UUID filenames (HIGH-001) - IP spoofing — use Gin trusted proxy c.ClientIP() (HIGH-002) - Popularity metrics (followers, likes) set to json:"-" (HIGH-003) - bcrypt cost hardened to 12 everywhere (HIGH-004) - Refresh token lock made mandatory (HIGH-005) - Stream token replay prevention with access_count (HIGH-006) - Subscription trial race condition fixed (HIGH-007) - License download expiration check (HIGH-008) - Webhook amount validation (HIGH-009) - pprof endpoint removed from production (HIGH-010) MEDIUM fixes: - WebSocket message size limit 64KB (MEDIUM-010) - HSTS header in nginx production (MEDIUM-001) - CORS origin restricted in nginx-rtmp (MEDIUM-002) - Docker alpine pinned to 3.21 (MEDIUM-003/004) - Redis authentication enforced (MEDIUM-005) - GDPR account deletion expanded (MEDIUM-006) - .gitignore hardened (MEDIUM-007) LOW/INFO fixes: - GitHub Actions SHA pinning on all workflows (LOW-001) - .env.example security documentation (INFO-001) - Production CORS set to HTTPS (LOW-002) All tests pass. Go and Rust compile clean. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 23:44:46 +00:00
// 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
}
fix(v0.12.6): apply all pentest remediations — 36 findings across 36 files CRITICAL fixes: - Race condition (TOCTOU) in payout/refund with SELECT FOR UPDATE (CRITICAL-001/002) - IDOR on analytics endpoint — ownership check enforced (CRITICAL-003) - CSWSH on all WebSocket endpoints — origin whitelist (CRITICAL-004) - Mass assignment on user self-update — strip privileged fields (CRITICAL-005) HIGH fixes: - Path traversal in marketplace upload — UUID filenames (HIGH-001) - IP spoofing — use Gin trusted proxy c.ClientIP() (HIGH-002) - Popularity metrics (followers, likes) set to json:"-" (HIGH-003) - bcrypt cost hardened to 12 everywhere (HIGH-004) - Refresh token lock made mandatory (HIGH-005) - Stream token replay prevention with access_count (HIGH-006) - Subscription trial race condition fixed (HIGH-007) - License download expiration check (HIGH-008) - Webhook amount validation (HIGH-009) - pprof endpoint removed from production (HIGH-010) MEDIUM fixes: - WebSocket message size limit 64KB (MEDIUM-010) - HSTS header in nginx production (MEDIUM-001) - CORS origin restricted in nginx-rtmp (MEDIUM-002) - Docker alpine pinned to 3.21 (MEDIUM-003/004) - Redis authentication enforced (MEDIUM-005) - GDPR account deletion expanded (MEDIUM-006) - .gitignore hardened (MEDIUM-007) LOW/INFO fixes: - GitHub Actions SHA pinning on all workflows (LOW-001) - .env.example security documentation (INFO-001) - Production CORS set to HTTPS (LOW-002) All tests pass. Go and Rust compile clean. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 23:44:46 +00:00
destPath := filepath.Join(previewDir, safeFilename)
if err := c.SaveUploadedFile(file, destPath); err != nil {
response.InternalServerError(c, "Failed to save preview")
return
}
fix(v0.12.6): apply all pentest remediations — 36 findings across 36 files CRITICAL fixes: - Race condition (TOCTOU) in payout/refund with SELECT FOR UPDATE (CRITICAL-001/002) - IDOR on analytics endpoint — ownership check enforced (CRITICAL-003) - CSWSH on all WebSocket endpoints — origin whitelist (CRITICAL-004) - Mass assignment on user self-update — strip privileged fields (CRITICAL-005) HIGH fixes: - Path traversal in marketplace upload — UUID filenames (HIGH-001) - IP spoofing — use Gin trusted proxy c.ClientIP() (HIGH-002) - Popularity metrics (followers, likes) set to json:"-" (HIGH-003) - bcrypt cost hardened to 12 everywhere (HIGH-004) - Refresh token lock made mandatory (HIGH-005) - Stream token replay prevention with access_count (HIGH-006) - Subscription trial race condition fixed (HIGH-007) - License download expiration check (HIGH-008) - Webhook amount validation (HIGH-009) - pprof endpoint removed from production (HIGH-010) MEDIUM fixes: - WebSocket message size limit 64KB (MEDIUM-010) - HSTS header in nginx production (MEDIUM-001) - CORS origin restricted in nginx-rtmp (MEDIUM-002) - Docker alpine pinned to 3.21 (MEDIUM-003/004) - Redis authentication enforced (MEDIUM-005) - GDPR account deletion expanded (MEDIUM-006) - .gitignore hardened (MEDIUM-007) LOW/INFO fixes: - GitHub Actions SHA pinning on all workflows (LOW-001) - .env.example security documentation (INFO-001) - Production CORS set to HTTPS (LOW-002) All tests pass. Go and Rust compile clean. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 23:44:46 +00:00
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
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 || len(product.Previews) == 0 {
response.NotFound(c, "Preview not found")
return
}
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.File(fullPath)
}
// 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)
}
2025-12-03 19:29:37 +00:00
// @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"
2025-12-03 19:29:37 +00:00
// @Success 200 {array} marketplace.Product
// @Router /api/v1/marketplace/products [get]
func (h *MarketplaceHandler) ListProducts(c *gin.Context) {
filters := make(map[string]interface{})
2025-12-03 19:29:37 +00:00
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
}
2025-12-03 19:29:37 +00:00
}
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
}
}
2025-12-03 19:29:37 +00:00
products, err := h.service.ListProducts(c.Request.Context(), filters)
if err != nil {
response.InternalServerError(c, "Failed to list products")
2025-12-03 19:29:37 +00:00
return
}
response.Success(c, products)
2025-12-03 19:29:37 +00:00
}
// 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"
}
if err := h.service.RefundOrder(c.Request.Context(), orderID, userID, reason); 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
}
response.InternalServerError(c, "Failed to process refund")
return
}
response.Success(c, gin.H{"message": "Refund processed"})
}
// 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,
2026-03-05 22:03:43 +00:00
"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})
}