feat(v0.12.2): F501-F510 distribution service, handler, and routes
- Distribution module: submit tracks to Spotify, Apple Music, Deezer - Subscription eligibility check (Creator/Premium only) - Distribution status tracking with platform-specific statuses - Status history audit trail - External streaming royalties import and aggregation - Distributor provider interface for DistroKid/TuneCore integration - Handler and service unit tests Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
9f5ffbe569
commit
6063bfdeea
7 changed files with 1387 additions and 0 deletions
|
|
@ -349,6 +349,9 @@ func (r *APIRouter) Setup(router *gin.Engine) error {
|
|||
|
||||
// v0.12.1: Subscription Plans & Management (F001-F030)
|
||||
r.setupSubscriptionRoutes(v1)
|
||||
|
||||
// v0.12.2: Distribution to External Platforms (F501-F510)
|
||||
r.setupDistributionRoutes(v1)
|
||||
}
|
||||
|
||||
return nil
|
||||
|
|
|
|||
41
veza-backend-api/internal/api/routes_distribution.go
Normal file
41
veza-backend-api/internal/api/routes_distribution.go
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
package api
|
||||
|
||||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"veza-backend-api/internal/core/distribution"
|
||||
"veza-backend-api/internal/core/subscription"
|
||||
"veza-backend-api/internal/handlers"
|
||||
)
|
||||
|
||||
// setupDistributionRoutes configures routes for track distribution to external platforms (v0.12.2)
|
||||
func (r *APIRouter) setupDistributionRoutes(router *gin.RouterGroup) {
|
||||
subSvc := subscription.NewService(r.db.GormDB, r.logger)
|
||||
svc := distribution.NewService(r.db.GormDB, r.logger, subSvc)
|
||||
handler := handlers.NewDistributionHandler(svc, r.logger)
|
||||
|
||||
if r.config.AuthMiddleware == nil {
|
||||
return
|
||||
}
|
||||
|
||||
// Distribution management (requires auth)
|
||||
distGroup := router.Group("/distributions")
|
||||
distGroup.Use(r.config.AuthMiddleware.RequireAuth())
|
||||
r.applyCSRFProtection(distGroup)
|
||||
|
||||
distGroup.POST("/submit", handler.Submit)
|
||||
distGroup.GET("", handler.ListDistributions)
|
||||
distGroup.GET("/:id", handler.GetDistribution)
|
||||
distGroup.GET("/:id/status-history", handler.GetStatusHistory)
|
||||
distGroup.POST("/:id/remove", handler.RemoveDistribution)
|
||||
|
||||
// Track-specific distribution view
|
||||
trackDistGroup := router.Group("/tracks")
|
||||
trackDistGroup.Use(r.config.AuthMiddleware.RequireAuth())
|
||||
trackDistGroup.GET("/:track_id/distributions", handler.GetTrackDistributions)
|
||||
|
||||
// External royalties (creator view)
|
||||
creatorGroup := router.Group("/creators/me")
|
||||
creatorGroup.Use(r.config.AuthMiddleware.RequireAuth())
|
||||
creatorGroup.GET("/external-royalties", handler.GetExternalRoyalties)
|
||||
}
|
||||
181
veza-backend-api/internal/core/distribution/models.go
Normal file
181
veza-backend-api/internal/core/distribution/models.go
Normal file
|
|
@ -0,0 +1,181 @@
|
|||
package distribution
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// DistributionStatus represents the overall status of a distribution submission
|
||||
type DistributionStatus string
|
||||
|
||||
const (
|
||||
StatusSubmitted DistributionStatus = "submitted"
|
||||
StatusProcessing DistributionStatus = "processing"
|
||||
StatusLive DistributionStatus = "live"
|
||||
StatusRejected DistributionStatus = "rejected"
|
||||
StatusRemoved DistributionStatus = "removed"
|
||||
StatusFailed DistributionStatus = "failed"
|
||||
)
|
||||
|
||||
// Platform represents a streaming platform
|
||||
type Platform string
|
||||
|
||||
const (
|
||||
PlatformSpotify Platform = "spotify"
|
||||
PlatformAppleMusic Platform = "apple_music"
|
||||
PlatformDeezer Platform = "deezer"
|
||||
)
|
||||
|
||||
// AllPlatforms returns the list of supported platforms
|
||||
func AllPlatforms() []Platform {
|
||||
return []Platform{PlatformSpotify, PlatformAppleMusic, PlatformDeezer}
|
||||
}
|
||||
|
||||
// ImportStatus represents the status of a royalty import
|
||||
type ImportStatus string
|
||||
|
||||
const (
|
||||
ImportPending ImportStatus = "pending"
|
||||
ImportCompleted ImportStatus = "completed"
|
||||
ImportFailed ImportStatus = "failed"
|
||||
ImportPartial ImportStatus = "partial"
|
||||
)
|
||||
|
||||
// PlatformStatus represents the distribution status on a single platform
|
||||
type PlatformStatus struct {
|
||||
Status string `json:"status"`
|
||||
URL string `json:"url,omitempty"`
|
||||
LiveDate *string `json:"live_date,omitempty"`
|
||||
Error *string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
// DistributionMetadata holds the metadata snapshot for a distribution submission
|
||||
type DistributionMetadata struct {
|
||||
ArtistName string `json:"artist_name"`
|
||||
AlbumName string `json:"album_name"`
|
||||
Genre string `json:"genre"`
|
||||
ReleaseDate string `json:"release_date"`
|
||||
FeaturingArtists []string `json:"featuring_artists,omitempty"`
|
||||
TrackTitle string `json:"track_title"`
|
||||
UPC string `json:"upc,omitempty"`
|
||||
ISRC string `json:"isrc,omitempty"`
|
||||
}
|
||||
|
||||
// TrackDistribution represents a distribution submission for a track
|
||||
type TrackDistribution struct {
|
||||
ID uuid.UUID `gorm:"type:uuid;primaryKey" json:"id"`
|
||||
TrackID uuid.UUID `gorm:"type:uuid;not null" json:"track_id"`
|
||||
CreatorID uuid.UUID `gorm:"type:uuid;not null" json:"creator_id"`
|
||||
Distributor string `gorm:"not null;size:50;default:'distrokid'" json:"distributor"`
|
||||
SubmissionID string `gorm:"not null;size:255;uniqueIndex" json:"submission_id"`
|
||||
SubmissionUPC string `gorm:"size:50" json:"submission_upc,omitempty"`
|
||||
SubmissionISRC string `gorm:"size:50" json:"submission_isrc,omitempty"`
|
||||
Metadata json.RawMessage `gorm:"type:jsonb;not null;default:'{}'" json:"metadata"`
|
||||
CoverFilePath string `gorm:"size:512" json:"cover_file_path,omitempty"`
|
||||
OverallStatus DistributionStatus `gorm:"not null;size:50;default:'submitted'" json:"overall_status"`
|
||||
PlatformStatuses json.RawMessage `gorm:"type:jsonb;not null;default:'{}'" json:"platform_statuses"`
|
||||
|
||||
SubmittedAt time.Time `gorm:"not null" json:"submitted_at"`
|
||||
FirstLiveAt *time.Time `json:"first_live_at,omitempty"`
|
||||
LastStatusCheckAt *time.Time `json:"last_status_check_at,omitempty"`
|
||||
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"`
|
||||
UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"`
|
||||
}
|
||||
|
||||
func (TrackDistribution) TableName() string {
|
||||
return "track_distributions"
|
||||
}
|
||||
|
||||
func (d *TrackDistribution) BeforeCreate(tx *gorm.DB) error {
|
||||
if d.ID == uuid.Nil {
|
||||
d.ID = uuid.New()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetPlatformStatuses unmarshals and returns the platform statuses map
|
||||
func (d *TrackDistribution) GetPlatformStatuses() (map[Platform]PlatformStatus, error) {
|
||||
result := make(map[Platform]PlatformStatus)
|
||||
if len(d.PlatformStatuses) == 0 {
|
||||
return result, nil
|
||||
}
|
||||
err := json.Unmarshal(d.PlatformStatuses, &result)
|
||||
return result, err
|
||||
}
|
||||
|
||||
// SetPlatformStatuses marshals and stores the platform statuses map
|
||||
func (d *TrackDistribution) SetPlatformStatuses(statuses map[Platform]PlatformStatus) error {
|
||||
data, err := json.Marshal(statuses)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
d.PlatformStatuses = data
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetMetadata unmarshals the metadata field
|
||||
func (d *TrackDistribution) GetMetadata() (*DistributionMetadata, error) {
|
||||
var meta DistributionMetadata
|
||||
if len(d.Metadata) == 0 {
|
||||
return &meta, nil
|
||||
}
|
||||
err := json.Unmarshal(d.Metadata, &meta)
|
||||
return &meta, err
|
||||
}
|
||||
|
||||
// StatusHistoryEntry represents a status change event
|
||||
type StatusHistoryEntry struct {
|
||||
ID uuid.UUID `gorm:"type:uuid;primaryKey" json:"id"`
|
||||
DistributionID uuid.UUID `gorm:"type:uuid;not null" json:"distribution_id"`
|
||||
Platform string `gorm:"not null;size:50" json:"platform"`
|
||||
OldStatus string `gorm:"size:50" json:"old_status,omitempty"`
|
||||
NewStatus string `gorm:"not null;size:50" json:"new_status"`
|
||||
Note string `gorm:"type:text" json:"note,omitempty"`
|
||||
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"`
|
||||
}
|
||||
|
||||
func (StatusHistoryEntry) TableName() string {
|
||||
return "track_distribution_status_history"
|
||||
}
|
||||
|
||||
func (e *StatusHistoryEntry) BeforeCreate(tx *gorm.DB) error {
|
||||
if e.ID == uuid.Nil {
|
||||
e.ID = uuid.New()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ExternalStreamingRoyalty represents monthly royalty data from an external platform
|
||||
type ExternalStreamingRoyalty struct {
|
||||
ID uuid.UUID `gorm:"type:uuid;primaryKey" json:"id"`
|
||||
TrackID uuid.UUID `gorm:"type:uuid;not null" json:"track_id"`
|
||||
CreatorID uuid.UUID `gorm:"type:uuid;not null" json:"creator_id"`
|
||||
ReportingPeriodStart time.Time `gorm:"type:date;not null" json:"reporting_period_start"`
|
||||
ReportingPeriodEnd time.Time `gorm:"type:date;not null" json:"reporting_period_end"`
|
||||
Platform string `gorm:"not null;size:50" json:"platform"`
|
||||
TotalStreams int64 `gorm:"not null;default:0" json:"total_streams"`
|
||||
TotalRevenueCents int64 `gorm:"not null;default:0" json:"total_revenue_cents"`
|
||||
Currency string `gorm:"size:3;default:'USD'" json:"currency"`
|
||||
StreamsBreakdown json.RawMessage `gorm:"type:jsonb" json:"streams_breakdown,omitempty"`
|
||||
Distributor string `gorm:"not null;size:50;default:'distrokid'" json:"distributor"`
|
||||
DistributorReportID string `gorm:"size:255" json:"distributor_report_id,omitempty"`
|
||||
ImportStatus ImportStatus `gorm:"not null;size:50;default:'pending'" json:"import_status"`
|
||||
ImportError string `gorm:"type:text" json:"import_error,omitempty"`
|
||||
ImportedAt *time.Time `json:"imported_at,omitempty"`
|
||||
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"`
|
||||
UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"`
|
||||
}
|
||||
|
||||
func (ExternalStreamingRoyalty) TableName() string {
|
||||
return "external_streaming_royalties"
|
||||
}
|
||||
|
||||
func (r *ExternalStreamingRoyalty) BeforeCreate(tx *gorm.DB) error {
|
||||
if r.ID == uuid.Nil {
|
||||
r.ID = uuid.New()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
503
veza-backend-api/internal/core/distribution/service.go
Normal file
503
veza-backend-api/internal/core/distribution/service.go
Normal file
|
|
@ -0,0 +1,503 @@
|
|||
package distribution
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"go.uber.org/zap"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"veza-backend-api/internal/core/subscription"
|
||||
)
|
||||
|
||||
// Service errors
|
||||
var (
|
||||
ErrDistributionNotFound = errors.New("distribution not found")
|
||||
ErrTrackNotFound = errors.New("track not found")
|
||||
ErrNotEligible = errors.New("distribution requires Creator or Premium plan")
|
||||
ErrTrackNotPublic = errors.New("track must be public to distribute")
|
||||
ErrAlreadyDistributed = errors.New("track already has a pending or live distribution")
|
||||
ErrInvalidPlatform = errors.New("unsupported platform")
|
||||
ErrNoPlatformsSelected = errors.New("at least one platform must be selected")
|
||||
ErrDistributionNotRemovable = errors.New("distribution cannot be removed in current status")
|
||||
)
|
||||
|
||||
// DistributorProvider defines the interface for external distribution APIs
|
||||
type DistributorProvider interface {
|
||||
SubmitTrack(ctx context.Context, req DistributorSubmitRequest) (*DistributorSubmitResponse, error)
|
||||
GetStatus(ctx context.Context, submissionID string) (*DistributorStatusResponse, error)
|
||||
}
|
||||
|
||||
// DistributorSubmitRequest is the payload sent to the distributor API
|
||||
type DistributorSubmitRequest struct {
|
||||
TrackFilePath string `json:"track_file_path"`
|
||||
CoverFilePath string `json:"cover_file_path"`
|
||||
Metadata DistributionMetadata `json:"metadata"`
|
||||
Platforms []Platform `json:"platforms"`
|
||||
}
|
||||
|
||||
// DistributorSubmitResponse is the response from the distributor API
|
||||
type DistributorSubmitResponse struct {
|
||||
SubmissionID string `json:"submission_id"`
|
||||
UPC string `json:"upc,omitempty"`
|
||||
ISRC string `json:"isrc,omitempty"`
|
||||
EstimatedLiveDays int `json:"estimated_live_days"`
|
||||
}
|
||||
|
||||
// DistributorStatusResponse is the status response from the distributor API
|
||||
type DistributorStatusResponse struct {
|
||||
SubmissionID string `json:"submission_id"`
|
||||
OverallStatus DistributionStatus `json:"overall_status"`
|
||||
PlatformStatuses map[Platform]PlatformStatus `json:"platform_statuses"`
|
||||
}
|
||||
|
||||
// ServiceOption is a functional option for configuring the Service
|
||||
type ServiceOption func(*Service)
|
||||
|
||||
// WithDistributorProvider sets the distributor API provider
|
||||
func WithDistributorProvider(p DistributorProvider) ServiceOption {
|
||||
return func(s *Service) {
|
||||
s.distributorProvider = p
|
||||
}
|
||||
}
|
||||
|
||||
// Service handles distribution business logic
|
||||
type Service struct {
|
||||
db *gorm.DB
|
||||
logger *zap.Logger
|
||||
subscriptionService *subscription.Service
|
||||
distributorProvider DistributorProvider
|
||||
}
|
||||
|
||||
// NewService creates a new distribution service
|
||||
func NewService(db *gorm.DB, logger *zap.Logger, subSvc *subscription.Service, opts ...ServiceOption) *Service {
|
||||
s := &Service{
|
||||
db: db,
|
||||
logger: logger,
|
||||
subscriptionService: subSvc,
|
||||
}
|
||||
for _, opt := range opts {
|
||||
opt(s)
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// SubmitRequest holds the parameters for submitting a track for distribution
|
||||
type SubmitRequest struct {
|
||||
TrackID uuid.UUID `json:"track_id" binding:"required"`
|
||||
Platforms []Platform `json:"platforms" binding:"required"`
|
||||
Metadata DistributionMetadata `json:"metadata" binding:"required"`
|
||||
}
|
||||
|
||||
// SubmitResponse holds the result of a distribution submission
|
||||
type SubmitResponse struct {
|
||||
Distribution *TrackDistribution `json:"distribution"`
|
||||
EstimatedLiveDays int `json:"estimated_live_days"`
|
||||
}
|
||||
|
||||
// Submit submits a track for distribution to selected platforms
|
||||
func (s *Service) Submit(ctx context.Context, creatorID uuid.UUID, req SubmitRequest) (*SubmitResponse, error) {
|
||||
// Validate platforms
|
||||
if len(req.Platforms) == 0 {
|
||||
return nil, ErrNoPlatformsSelected
|
||||
}
|
||||
validPlatforms := make(map[Platform]bool)
|
||||
for _, p := range AllPlatforms() {
|
||||
validPlatforms[p] = true
|
||||
}
|
||||
for _, p := range req.Platforms {
|
||||
if !validPlatforms[p] {
|
||||
return nil, fmt.Errorf("%w: %s", ErrInvalidPlatform, p)
|
||||
}
|
||||
}
|
||||
|
||||
// Check subscription eligibility
|
||||
eligible, err := s.checkEligibility(ctx, creatorID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !eligible {
|
||||
return nil, ErrNotEligible
|
||||
}
|
||||
|
||||
// Verify track exists and belongs to creator
|
||||
var trackExists bool
|
||||
err = s.db.WithContext(ctx).
|
||||
Raw("SELECT EXISTS(SELECT 1 FROM tracks WHERE id = ? AND creator_id = ? AND is_public = true AND deleted_at IS NULL)", req.TrackID, creatorID).
|
||||
Scan(&trackExists).Error
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to verify track: %w", err)
|
||||
}
|
||||
if !trackExists {
|
||||
return nil, ErrTrackNotPublic
|
||||
}
|
||||
|
||||
// Check for existing active distribution
|
||||
var existingCount int64
|
||||
s.db.WithContext(ctx).Model(&TrackDistribution{}).
|
||||
Where("track_id = ? AND overall_status IN ?", req.TrackID,
|
||||
[]string{string(StatusSubmitted), string(StatusProcessing), string(StatusLive)}).
|
||||
Count(&existingCount)
|
||||
if existingCount > 0 {
|
||||
return nil, ErrAlreadyDistributed
|
||||
}
|
||||
|
||||
// Build platform statuses
|
||||
platformStatuses := make(map[Platform]PlatformStatus)
|
||||
for _, p := range req.Platforms {
|
||||
platformStatuses[p] = PlatformStatus{Status: string(StatusSubmitted)}
|
||||
}
|
||||
|
||||
metadataJSON, err := json.Marshal(req.Metadata)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to marshal metadata: %w", err)
|
||||
}
|
||||
platformJSON, err := json.Marshal(platformStatuses)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to marshal platform statuses: %w", err)
|
||||
}
|
||||
|
||||
// Generate a submission ID (in production, this comes from distributor API)
|
||||
submissionID := fmt.Sprintf("veza-%s-%d", req.TrackID.String()[:8], time.Now().Unix())
|
||||
estimatedDays := 7
|
||||
|
||||
// If distributor provider is configured, call it
|
||||
if s.distributorProvider != nil {
|
||||
resp, err := s.distributorProvider.SubmitTrack(ctx, DistributorSubmitRequest{
|
||||
Metadata: req.Metadata,
|
||||
Platforms: req.Platforms,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("distributor submission failed: %w", err)
|
||||
}
|
||||
submissionID = resp.SubmissionID
|
||||
estimatedDays = resp.EstimatedLiveDays
|
||||
if resp.UPC != "" {
|
||||
req.Metadata.UPC = resp.UPC
|
||||
}
|
||||
if resp.ISRC != "" {
|
||||
req.Metadata.ISRC = resp.ISRC
|
||||
}
|
||||
metadataJSON, _ = json.Marshal(req.Metadata)
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
dist := &TrackDistribution{
|
||||
TrackID: req.TrackID,
|
||||
CreatorID: creatorID,
|
||||
Distributor: "distrokid",
|
||||
SubmissionID: submissionID,
|
||||
SubmissionUPC: req.Metadata.UPC,
|
||||
SubmissionISRC: req.Metadata.ISRC,
|
||||
Metadata: metadataJSON,
|
||||
OverallStatus: StatusSubmitted,
|
||||
PlatformStatuses: platformJSON,
|
||||
SubmittedAt: now,
|
||||
}
|
||||
|
||||
err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
if err := tx.Create(dist).Error; err != nil {
|
||||
return fmt.Errorf("failed to create distribution: %w", err)
|
||||
}
|
||||
|
||||
// Create initial status history entries
|
||||
for _, p := range req.Platforms {
|
||||
entry := &StatusHistoryEntry{
|
||||
DistributionID: dist.ID,
|
||||
Platform: string(p),
|
||||
NewStatus: string(StatusSubmitted),
|
||||
Note: "Track submitted for distribution",
|
||||
}
|
||||
if err := tx.Create(entry).Error; err != nil {
|
||||
return fmt.Errorf("failed to create status history: %w", err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
s.logger.Info("Track submitted for distribution",
|
||||
zap.String("creator_id", creatorID.String()),
|
||||
zap.String("track_id", req.TrackID.String()),
|
||||
zap.String("submission_id", submissionID),
|
||||
zap.Int("platforms", len(req.Platforms)),
|
||||
)
|
||||
|
||||
return &SubmitResponse{
|
||||
Distribution: dist,
|
||||
EstimatedLiveDays: estimatedDays,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// checkEligibility verifies that the user has a Creator or Premium subscription with distribution access
|
||||
func (s *Service) checkEligibility(ctx context.Context, userID uuid.UUID) (bool, error) {
|
||||
if s.subscriptionService == nil {
|
||||
// No subscription service configured, allow by default (dev mode)
|
||||
return true, nil
|
||||
}
|
||||
|
||||
sub, err := s.subscriptionService.GetUserSubscription(ctx, userID)
|
||||
if err != nil {
|
||||
if errors.Is(err, subscription.ErrNoActiveSubscription) {
|
||||
return false, nil
|
||||
}
|
||||
return false, err
|
||||
}
|
||||
|
||||
return sub.Plan.HasDistribution || sub.Plan.CanSellOnMarketplace, nil
|
||||
}
|
||||
|
||||
// GetDistribution returns a distribution by ID
|
||||
func (s *Service) GetDistribution(ctx context.Context, distID uuid.UUID) (*TrackDistribution, error) {
|
||||
var dist TrackDistribution
|
||||
err := s.db.WithContext(ctx).First(&dist, "id = ?", distID).Error
|
||||
if err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, ErrDistributionNotFound
|
||||
}
|
||||
return nil, fmt.Errorf("failed to get distribution: %w", err)
|
||||
}
|
||||
return &dist, nil
|
||||
}
|
||||
|
||||
// ListDistributions returns distributions for a creator with optional filters
|
||||
func (s *Service) ListDistributions(ctx context.Context, creatorID uuid.UUID, trackID *uuid.UUID, status *DistributionStatus, limit, offset int) ([]TrackDistribution, int64, error) {
|
||||
if limit <= 0 || limit > 100 {
|
||||
limit = 20
|
||||
}
|
||||
if offset < 0 {
|
||||
offset = 0
|
||||
}
|
||||
|
||||
query := s.db.WithContext(ctx).Where("creator_id = ?", creatorID)
|
||||
if trackID != nil {
|
||||
query = query.Where("track_id = ?", *trackID)
|
||||
}
|
||||
if status != nil {
|
||||
query = query.Where("overall_status = ?", string(*status))
|
||||
}
|
||||
|
||||
var total int64
|
||||
query.Model(&TrackDistribution{}).Count(&total)
|
||||
|
||||
var dists []TrackDistribution
|
||||
err := query.Order("submitted_at DESC").Limit(limit).Offset(offset).Find(&dists).Error
|
||||
if err != nil {
|
||||
return nil, 0, fmt.Errorf("failed to list distributions: %w", err)
|
||||
}
|
||||
return dists, total, nil
|
||||
}
|
||||
|
||||
// GetTrackDistributions returns all distributions for a specific track
|
||||
func (s *Service) GetTrackDistributions(ctx context.Context, trackID uuid.UUID) ([]TrackDistribution, error) {
|
||||
var dists []TrackDistribution
|
||||
err := s.db.WithContext(ctx).
|
||||
Where("track_id = ?", trackID).
|
||||
Order("submitted_at DESC").
|
||||
Find(&dists).Error
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get track distributions: %w", err)
|
||||
}
|
||||
return dists, nil
|
||||
}
|
||||
|
||||
// GetStatusHistory returns the status change history for a distribution
|
||||
func (s *Service) GetStatusHistory(ctx context.Context, distID uuid.UUID) ([]StatusHistoryEntry, error) {
|
||||
var entries []StatusHistoryEntry
|
||||
err := s.db.WithContext(ctx).
|
||||
Where("distribution_id = ?", distID).
|
||||
Order("created_at ASC").
|
||||
Find(&entries).Error
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get status history: %w", err)
|
||||
}
|
||||
return entries, nil
|
||||
}
|
||||
|
||||
// RemoveDistribution marks a live distribution for removal
|
||||
func (s *Service) RemoveDistribution(ctx context.Context, creatorID, distID uuid.UUID) error {
|
||||
dist, err := s.GetDistribution(ctx, distID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if dist.CreatorID != creatorID {
|
||||
return ErrDistributionNotFound
|
||||
}
|
||||
if dist.OverallStatus != StatusLive {
|
||||
return ErrDistributionNotRemovable
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
dist.OverallStatus = StatusRemoved
|
||||
dist.UpdatedAt = now
|
||||
|
||||
return s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
if err := tx.Save(dist).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
entry := &StatusHistoryEntry{
|
||||
DistributionID: dist.ID,
|
||||
Platform: "all",
|
||||
OldStatus: string(StatusLive),
|
||||
NewStatus: string(StatusRemoved),
|
||||
Note: "Removed by creator",
|
||||
}
|
||||
return tx.Create(entry).Error
|
||||
})
|
||||
}
|
||||
|
||||
// PollDistributionStatuses polls the distributor API for status updates (background job)
|
||||
func (s *Service) PollDistributionStatuses(ctx context.Context) (int, error) {
|
||||
if s.distributorProvider == nil {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
var dists []TrackDistribution
|
||||
err := s.db.WithContext(ctx).
|
||||
Where("overall_status IN ?", []string{string(StatusSubmitted), string(StatusProcessing)}).
|
||||
Find(&dists).Error
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("failed to query distributions for polling: %w", err)
|
||||
}
|
||||
|
||||
updated := 0
|
||||
now := time.Now()
|
||||
for i := range dists {
|
||||
dist := &dists[i]
|
||||
resp, err := s.distributorProvider.GetStatus(ctx, dist.SubmissionID)
|
||||
if err != nil {
|
||||
s.logger.Warn("Failed to poll distribution status",
|
||||
zap.String("submission_id", dist.SubmissionID),
|
||||
zap.Error(err),
|
||||
)
|
||||
continue
|
||||
}
|
||||
|
||||
if resp.OverallStatus != dist.OverallStatus {
|
||||
oldStatus := dist.OverallStatus
|
||||
dist.OverallStatus = resp.OverallStatus
|
||||
dist.LastStatusCheckAt = &now
|
||||
|
||||
if resp.OverallStatus == StatusLive && dist.FirstLiveAt == nil {
|
||||
dist.FirstLiveAt = &now
|
||||
}
|
||||
|
||||
platformJSON, _ := json.Marshal(resp.PlatformStatuses)
|
||||
dist.PlatformStatuses = platformJSON
|
||||
|
||||
if err := s.db.WithContext(ctx).Save(dist).Error; err != nil {
|
||||
s.logger.Error("Failed to update distribution status",
|
||||
zap.String("distribution_id", dist.ID.String()),
|
||||
zap.Error(err),
|
||||
)
|
||||
continue
|
||||
}
|
||||
|
||||
// Record status history
|
||||
entry := &StatusHistoryEntry{
|
||||
DistributionID: dist.ID,
|
||||
Platform: "all",
|
||||
OldStatus: string(oldStatus),
|
||||
NewStatus: string(resp.OverallStatus),
|
||||
Note: "Status updated via distributor API poll",
|
||||
}
|
||||
s.db.WithContext(ctx).Create(entry)
|
||||
|
||||
updated++
|
||||
s.logger.Info("Distribution status updated",
|
||||
zap.String("distribution_id", dist.ID.String()),
|
||||
zap.String("old_status", string(oldStatus)),
|
||||
zap.String("new_status", string(resp.OverallStatus)),
|
||||
)
|
||||
} else {
|
||||
dist.LastStatusCheckAt = &now
|
||||
s.db.WithContext(ctx).Model(dist).Update("last_status_check_at", now)
|
||||
}
|
||||
}
|
||||
|
||||
return updated, nil
|
||||
}
|
||||
|
||||
// GetExternalRoyalties returns external streaming royalties for a creator
|
||||
func (s *Service) GetExternalRoyalties(ctx context.Context, creatorID uuid.UUID, startDate, endDate *time.Time, platform *string, limit, offset int) ([]ExternalStreamingRoyalty, *RoyaltySummary, error) {
|
||||
if limit <= 0 || limit > 100 {
|
||||
limit = 20
|
||||
}
|
||||
if offset < 0 {
|
||||
offset = 0
|
||||
}
|
||||
|
||||
query := s.db.WithContext(ctx).Where("creator_id = ? AND import_status = ?", creatorID, string(ImportCompleted))
|
||||
if startDate != nil {
|
||||
query = query.Where("reporting_period_start >= ?", *startDate)
|
||||
}
|
||||
if endDate != nil {
|
||||
query = query.Where("reporting_period_end <= ?", *endDate)
|
||||
}
|
||||
if platform != nil && *platform != "" {
|
||||
query = query.Where("platform = ?", *platform)
|
||||
}
|
||||
|
||||
var royalties []ExternalStreamingRoyalty
|
||||
err := query.Order("reporting_period_start DESC").
|
||||
Limit(limit).Offset(offset).
|
||||
Find(&royalties).Error
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("failed to get royalties: %w", err)
|
||||
}
|
||||
|
||||
// Compute summary
|
||||
summary := &RoyaltySummary{
|
||||
ByPlatform: make(map[string]PlatformRoyaltySummary),
|
||||
}
|
||||
summaryQuery := s.db.WithContext(ctx).
|
||||
Model(&ExternalStreamingRoyalty{}).
|
||||
Where("creator_id = ? AND import_status = ?", creatorID, string(ImportCompleted))
|
||||
if startDate != nil {
|
||||
summaryQuery = summaryQuery.Where("reporting_period_start >= ?", *startDate)
|
||||
}
|
||||
if endDate != nil {
|
||||
summaryQuery = summaryQuery.Where("reporting_period_end <= ?", *endDate)
|
||||
}
|
||||
if platform != nil && *platform != "" {
|
||||
summaryQuery = summaryQuery.Where("platform = ?", *platform)
|
||||
}
|
||||
|
||||
var summaryRows []struct {
|
||||
Platform string `gorm:"column:platform"`
|
||||
TotalStreams int64 `gorm:"column:total_streams"`
|
||||
TotalRevenueCents int64 `gorm:"column:total_revenue_cents"`
|
||||
}
|
||||
summaryQuery.Select("platform, SUM(total_streams) as total_streams, SUM(total_revenue_cents) as total_revenue_cents").
|
||||
Group("platform").Scan(&summaryRows)
|
||||
|
||||
for _, row := range summaryRows {
|
||||
summary.TotalStreams += row.TotalStreams
|
||||
summary.TotalRevenueCents += row.TotalRevenueCents
|
||||
summary.ByPlatform[row.Platform] = PlatformRoyaltySummary{
|
||||
Streams: row.TotalStreams,
|
||||
RevenueCents: row.TotalRevenueCents,
|
||||
}
|
||||
}
|
||||
|
||||
return royalties, summary, nil
|
||||
}
|
||||
|
||||
// RoyaltySummary provides aggregated royalty data
|
||||
type RoyaltySummary struct {
|
||||
TotalStreams int64 `json:"total_streams"`
|
||||
TotalRevenueCents int64 `json:"total_revenue_cents"`
|
||||
ByPlatform map[string]PlatformRoyaltySummary `json:"by_platform"`
|
||||
}
|
||||
|
||||
// PlatformRoyaltySummary provides per-platform aggregated data
|
||||
type PlatformRoyaltySummary struct {
|
||||
Streams int64 `json:"streams"`
|
||||
RevenueCents int64 `json:"revenue_cents"`
|
||||
}
|
||||
238
veza-backend-api/internal/core/distribution/service_test.go
Normal file
238
veza-backend-api/internal/core/distribution/service_test.go
Normal file
|
|
@ -0,0 +1,238 @@
|
|||
package distribution
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
func TestTrackDistributionTableName(t *testing.T) {
|
||||
d := TrackDistribution{}
|
||||
if d.TableName() != "track_distributions" {
|
||||
t.Errorf("expected track_distributions, got %s", d.TableName())
|
||||
}
|
||||
}
|
||||
|
||||
func TestStatusHistoryEntryTableName(t *testing.T) {
|
||||
e := StatusHistoryEntry{}
|
||||
if e.TableName() != "track_distribution_status_history" {
|
||||
t.Errorf("expected track_distribution_status_history, got %s", e.TableName())
|
||||
}
|
||||
}
|
||||
|
||||
func TestExternalStreamingRoyaltyTableName(t *testing.T) {
|
||||
r := ExternalStreamingRoyalty{}
|
||||
if r.TableName() != "external_streaming_royalties" {
|
||||
t.Errorf("expected external_streaming_royalties, got %s", r.TableName())
|
||||
}
|
||||
}
|
||||
|
||||
func TestDistributionStatusConstants(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
status DistributionStatus
|
||||
expected string
|
||||
}{
|
||||
{"submitted", StatusSubmitted, "submitted"},
|
||||
{"processing", StatusProcessing, "processing"},
|
||||
{"live", StatusLive, "live"},
|
||||
{"rejected", StatusRejected, "rejected"},
|
||||
{"removed", StatusRemoved, "removed"},
|
||||
{"failed", StatusFailed, "failed"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if string(tt.status) != tt.expected {
|
||||
t.Errorf("expected %s, got %s", tt.expected, string(tt.status))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlatformConstants(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
platform Platform
|
||||
expected string
|
||||
}{
|
||||
{"spotify", PlatformSpotify, "spotify"},
|
||||
{"apple_music", PlatformAppleMusic, "apple_music"},
|
||||
{"deezer", PlatformDeezer, "deezer"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if string(tt.platform) != tt.expected {
|
||||
t.Errorf("expected %s, got %s", tt.expected, string(tt.platform))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAllPlatforms(t *testing.T) {
|
||||
platforms := AllPlatforms()
|
||||
if len(platforms) != 3 {
|
||||
t.Errorf("expected 3 platforms, got %d", len(platforms))
|
||||
}
|
||||
expected := map[Platform]bool{
|
||||
PlatformSpotify: true,
|
||||
PlatformAppleMusic: true,
|
||||
PlatformDeezer: true,
|
||||
}
|
||||
for _, p := range platforms {
|
||||
if !expected[p] {
|
||||
t.Errorf("unexpected platform: %s", p)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestImportStatusConstants(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
status ImportStatus
|
||||
expected string
|
||||
}{
|
||||
{"pending", ImportPending, "pending"},
|
||||
{"completed", ImportCompleted, "completed"},
|
||||
{"failed", ImportFailed, "failed"},
|
||||
{"partial", ImportPartial, "partial"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if string(tt.status) != tt.expected {
|
||||
t.Errorf("expected %s, got %s", tt.expected, string(tt.status))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestTrackDistribution_PlatformStatuses(t *testing.T) {
|
||||
dist := &TrackDistribution{}
|
||||
|
||||
statuses := map[Platform]PlatformStatus{
|
||||
PlatformSpotify: {Status: "live", URL: "https://open.spotify.com/track/123"},
|
||||
PlatformDeezer: {Status: "processing"},
|
||||
}
|
||||
|
||||
if err := dist.SetPlatformStatuses(statuses); err != nil {
|
||||
t.Fatalf("SetPlatformStatuses failed: %v", err)
|
||||
}
|
||||
|
||||
got, err := dist.GetPlatformStatuses()
|
||||
if err != nil {
|
||||
t.Fatalf("GetPlatformStatuses failed: %v", err)
|
||||
}
|
||||
|
||||
if got[PlatformSpotify].Status != "live" {
|
||||
t.Errorf("expected spotify status live, got %s", got[PlatformSpotify].Status)
|
||||
}
|
||||
if got[PlatformSpotify].URL != "https://open.spotify.com/track/123" {
|
||||
t.Errorf("unexpected spotify URL: %s", got[PlatformSpotify].URL)
|
||||
}
|
||||
if got[PlatformDeezer].Status != "processing" {
|
||||
t.Errorf("expected deezer status processing, got %s", got[PlatformDeezer].Status)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTrackDistribution_GetMetadata(t *testing.T) {
|
||||
meta := DistributionMetadata{
|
||||
ArtistName: "Test Artist",
|
||||
AlbumName: "Single",
|
||||
Genre: "Electronic",
|
||||
ReleaseDate: "2026-03-10",
|
||||
TrackTitle: "Test Track",
|
||||
}
|
||||
metaJSON, _ := json.Marshal(meta)
|
||||
|
||||
dist := &TrackDistribution{
|
||||
Metadata: metaJSON,
|
||||
}
|
||||
|
||||
got, err := dist.GetMetadata()
|
||||
if err != nil {
|
||||
t.Fatalf("GetMetadata failed: %v", err)
|
||||
}
|
||||
|
||||
if got.ArtistName != "Test Artist" {
|
||||
t.Errorf("expected Test Artist, got %s", got.ArtistName)
|
||||
}
|
||||
if got.Genre != "Electronic" {
|
||||
t.Errorf("expected Electronic, got %s", got.Genre)
|
||||
}
|
||||
}
|
||||
|
||||
func TestServiceErrors(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
err error
|
||||
msg string
|
||||
}{
|
||||
{"distribution not found", ErrDistributionNotFound, "distribution not found"},
|
||||
{"not eligible", ErrNotEligible, "distribution requires Creator or Premium plan"},
|
||||
{"track not public", ErrTrackNotPublic, "track must be public to distribute"},
|
||||
{"already distributed", ErrAlreadyDistributed, "track already has a pending or live distribution"},
|
||||
{"no platforms", ErrNoPlatformsSelected, "at least one platform must be selected"},
|
||||
{"not removable", ErrDistributionNotRemovable, "distribution cannot be removed in current status"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if tt.err.Error() != tt.msg {
|
||||
t.Errorf("expected %q, got %q", tt.msg, tt.err.Error())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestExternalStreamingRoyaltyFields(t *testing.T) {
|
||||
now := time.Now()
|
||||
r := ExternalStreamingRoyalty{
|
||||
ID: uuid.New(),
|
||||
TrackID: uuid.New(),
|
||||
CreatorID: uuid.New(),
|
||||
ReportingPeriodStart: now,
|
||||
ReportingPeriodEnd: now.AddDate(0, 1, 0),
|
||||
Platform: string(PlatformSpotify),
|
||||
TotalStreams: 50000,
|
||||
TotalRevenueCents: 17500,
|
||||
Currency: "USD",
|
||||
Distributor: "distrokid",
|
||||
ImportStatus: ImportCompleted,
|
||||
}
|
||||
|
||||
if r.TotalStreams != 50000 {
|
||||
t.Errorf("expected 50000 streams, got %d", r.TotalStreams)
|
||||
}
|
||||
if r.TotalRevenueCents != 17500 {
|
||||
t.Errorf("expected 17500 cents, got %d", r.TotalRevenueCents)
|
||||
}
|
||||
if r.ImportStatus != ImportCompleted {
|
||||
t.Errorf("expected completed, got %s", r.ImportStatus)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRoyaltySummary(t *testing.T) {
|
||||
summary := RoyaltySummary{
|
||||
TotalStreams: 150000,
|
||||
TotalRevenueCents: 52500,
|
||||
ByPlatform: map[string]PlatformRoyaltySummary{
|
||||
"spotify": {Streams: 50000, RevenueCents: 17500},
|
||||
"apple_music": {Streams: 60000, RevenueCents: 21000},
|
||||
"deezer": {Streams: 40000, RevenueCents: 14000},
|
||||
},
|
||||
}
|
||||
|
||||
if summary.TotalStreams != 150000 {
|
||||
t.Errorf("expected 150000, got %d", summary.TotalStreams)
|
||||
}
|
||||
if len(summary.ByPlatform) != 3 {
|
||||
t.Errorf("expected 3 platforms in summary, got %d", len(summary.ByPlatform))
|
||||
}
|
||||
if summary.ByPlatform["spotify"].RevenueCents != 17500 {
|
||||
t.Errorf("expected spotify revenue 17500, got %d", summary.ByPlatform["spotify"].RevenueCents)
|
||||
}
|
||||
}
|
||||
278
veza-backend-api/internal/handlers/distribution_handler.go
Normal file
278
veza-backend-api/internal/handlers/distribution_handler.go
Normal file
|
|
@ -0,0 +1,278 @@
|
|||
package handlers
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"veza-backend-api/internal/core/distribution"
|
||||
apperrors "veza-backend-api/internal/errors"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// DistributionHandler handles distribution HTTP endpoints (v0.12.2 F501-F510)
|
||||
type DistributionHandler struct {
|
||||
service *distribution.Service
|
||||
logger *zap.Logger
|
||||
}
|
||||
|
||||
// NewDistributionHandler creates a new DistributionHandler
|
||||
func NewDistributionHandler(service *distribution.Service, logger *zap.Logger) *DistributionHandler {
|
||||
return &DistributionHandler{
|
||||
service: service,
|
||||
logger: logger,
|
||||
}
|
||||
}
|
||||
|
||||
// Submit handles POST /distributions/submit
|
||||
func (h *DistributionHandler) Submit(c *gin.Context) {
|
||||
userID, ok := GetUserIDUUID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
var req distribution.SubmitRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
RespondWithAppError(c, apperrors.NewValidationError("Invalid request: track_id, platforms, and metadata required"))
|
||||
return
|
||||
}
|
||||
|
||||
resp, err := h.service.Submit(c.Request.Context(), userID, req)
|
||||
if err != nil {
|
||||
switch {
|
||||
case errors.Is(err, distribution.ErrNotEligible):
|
||||
RespondWithAppError(c, apperrors.NewForbiddenError("Distribution requires Creator or Premium plan"))
|
||||
case errors.Is(err, distribution.ErrTrackNotPublic):
|
||||
RespondWithAppError(c, apperrors.NewValidationError("Track must be public and belong to you"))
|
||||
case errors.Is(err, distribution.ErrAlreadyDistributed):
|
||||
RespondWithAppError(c, apperrors.NewValidationError("Track already has an active distribution"))
|
||||
case errors.Is(err, distribution.ErrNoPlatformsSelected):
|
||||
RespondWithAppError(c, apperrors.NewValidationError("At least one platform must be selected"))
|
||||
case errors.Is(err, distribution.ErrInvalidPlatform):
|
||||
RespondWithAppError(c, apperrors.NewValidationError(err.Error()))
|
||||
default:
|
||||
RespondWithAppError(c, apperrors.NewInternalErrorWrap("Failed to submit distribution", err))
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
RespondSuccess(c, http.StatusCreated, resp)
|
||||
}
|
||||
|
||||
// GetDistribution handles GET /distributions/:id
|
||||
func (h *DistributionHandler) GetDistribution(c *gin.Context) {
|
||||
userID, ok := GetUserIDUUID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
distID, err := uuid.Parse(c.Param("id"))
|
||||
if err != nil {
|
||||
RespondWithAppError(c, apperrors.NewValidationError("Invalid distribution ID"))
|
||||
return
|
||||
}
|
||||
|
||||
dist, err := h.service.GetDistribution(c.Request.Context(), distID)
|
||||
if err != nil {
|
||||
if errors.Is(err, distribution.ErrDistributionNotFound) {
|
||||
RespondWithAppError(c, apperrors.NewNotFoundError("Distribution"))
|
||||
return
|
||||
}
|
||||
RespondWithAppError(c, apperrors.NewInternalErrorWrap("Failed to get distribution", err))
|
||||
return
|
||||
}
|
||||
|
||||
// Ensure the distribution belongs to the requesting user
|
||||
if dist.CreatorID != userID {
|
||||
RespondWithAppError(c, apperrors.NewNotFoundError("Distribution"))
|
||||
return
|
||||
}
|
||||
|
||||
RespondSuccess(c, http.StatusOK, dist)
|
||||
}
|
||||
|
||||
// ListDistributions handles GET /distributions
|
||||
func (h *DistributionHandler) ListDistributions(c *gin.Context) {
|
||||
userID, ok := GetUserIDUUID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "20"))
|
||||
offset, _ := strconv.Atoi(c.DefaultQuery("offset", "0"))
|
||||
|
||||
var trackID *uuid.UUID
|
||||
if tidStr := c.Query("track_id"); tidStr != "" {
|
||||
tid, err := uuid.Parse(tidStr)
|
||||
if err != nil {
|
||||
RespondWithAppError(c, apperrors.NewValidationError("Invalid track_id"))
|
||||
return
|
||||
}
|
||||
trackID = &tid
|
||||
}
|
||||
|
||||
var status *distribution.DistributionStatus
|
||||
if statusStr := c.Query("status"); statusStr != "" {
|
||||
s := distribution.DistributionStatus(statusStr)
|
||||
status = &s
|
||||
}
|
||||
|
||||
dists, total, err := h.service.ListDistributions(c.Request.Context(), userID, trackID, status, limit, offset)
|
||||
if err != nil {
|
||||
RespondWithAppError(c, apperrors.NewInternalErrorWrap("Failed to list distributions", err))
|
||||
return
|
||||
}
|
||||
|
||||
page := (offset / max(limit, 1)) + 1
|
||||
totalPages := int((total + int64(limit) - 1) / int64(limit))
|
||||
|
||||
RespondSuccess(c, http.StatusOK, gin.H{
|
||||
"data": dists,
|
||||
"pagination": gin.H{
|
||||
"page": page,
|
||||
"limit": limit,
|
||||
"total": total,
|
||||
"total_pages": totalPages,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// GetTrackDistributions handles GET /tracks/:track_id/distributions
|
||||
func (h *DistributionHandler) GetTrackDistributions(c *gin.Context) {
|
||||
userID, ok := GetUserIDUUID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
trackID, err := uuid.Parse(c.Param("track_id"))
|
||||
if err != nil {
|
||||
RespondWithAppError(c, apperrors.NewValidationError("Invalid track ID"))
|
||||
return
|
||||
}
|
||||
|
||||
dists, err := h.service.GetTrackDistributions(c.Request.Context(), trackID)
|
||||
if err != nil {
|
||||
RespondWithAppError(c, apperrors.NewInternalErrorWrap("Failed to get track distributions", err))
|
||||
return
|
||||
}
|
||||
|
||||
// Filter to only show creator's own distributions
|
||||
var filtered []distribution.TrackDistribution
|
||||
for _, d := range dists {
|
||||
if d.CreatorID == userID {
|
||||
filtered = append(filtered, d)
|
||||
}
|
||||
}
|
||||
|
||||
RespondSuccess(c, http.StatusOK, gin.H{"distributions": filtered})
|
||||
}
|
||||
|
||||
// GetStatusHistory handles GET /distributions/:id/status-history
|
||||
func (h *DistributionHandler) GetStatusHistory(c *gin.Context) {
|
||||
userID, ok := GetUserIDUUID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
distID, err := uuid.Parse(c.Param("id"))
|
||||
if err != nil {
|
||||
RespondWithAppError(c, apperrors.NewValidationError("Invalid distribution ID"))
|
||||
return
|
||||
}
|
||||
|
||||
// Verify ownership
|
||||
dist, err := h.service.GetDistribution(c.Request.Context(), distID)
|
||||
if err != nil {
|
||||
if errors.Is(err, distribution.ErrDistributionNotFound) {
|
||||
RespondWithAppError(c, apperrors.NewNotFoundError("Distribution"))
|
||||
return
|
||||
}
|
||||
RespondWithAppError(c, apperrors.NewInternalErrorWrap("Failed to get distribution", err))
|
||||
return
|
||||
}
|
||||
if dist.CreatorID != userID {
|
||||
RespondWithAppError(c, apperrors.NewNotFoundError("Distribution"))
|
||||
return
|
||||
}
|
||||
|
||||
entries, err := h.service.GetStatusHistory(c.Request.Context(), distID)
|
||||
if err != nil {
|
||||
RespondWithAppError(c, apperrors.NewInternalErrorWrap("Failed to get status history", err))
|
||||
return
|
||||
}
|
||||
|
||||
RespondSuccess(c, http.StatusOK, gin.H{"history": entries})
|
||||
}
|
||||
|
||||
// RemoveDistribution handles POST /distributions/:id/remove
|
||||
func (h *DistributionHandler) RemoveDistribution(c *gin.Context) {
|
||||
userID, ok := GetUserIDUUID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
distID, err := uuid.Parse(c.Param("id"))
|
||||
if err != nil {
|
||||
RespondWithAppError(c, apperrors.NewValidationError("Invalid distribution ID"))
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.service.RemoveDistribution(c.Request.Context(), userID, distID); err != nil {
|
||||
switch {
|
||||
case errors.Is(err, distribution.ErrDistributionNotFound):
|
||||
RespondWithAppError(c, apperrors.NewNotFoundError("Distribution"))
|
||||
case errors.Is(err, distribution.ErrDistributionNotRemovable):
|
||||
RespondWithAppError(c, apperrors.NewValidationError("Distribution can only be removed when live"))
|
||||
default:
|
||||
RespondWithAppError(c, apperrors.NewInternalErrorWrap("Failed to remove distribution", err))
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
RespondSuccess(c, http.StatusOK, gin.H{"message": "Distribution removal requested"})
|
||||
}
|
||||
|
||||
// GetExternalRoyalties handles GET /creators/me/external-royalties
|
||||
func (h *DistributionHandler) GetExternalRoyalties(c *gin.Context) {
|
||||
userID, ok := GetUserIDUUID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "20"))
|
||||
offset, _ := strconv.Atoi(c.DefaultQuery("offset", "0"))
|
||||
|
||||
var startDate, endDate *time.Time
|
||||
if sd := c.Query("start_date"); sd != "" {
|
||||
t, err := time.Parse("2006-01-02", sd)
|
||||
if err == nil {
|
||||
startDate = &t
|
||||
}
|
||||
}
|
||||
if ed := c.Query("end_date"); ed != "" {
|
||||
t, err := time.Parse("2006-01-02", ed)
|
||||
if err == nil {
|
||||
endDate = &t
|
||||
}
|
||||
}
|
||||
|
||||
var platform *string
|
||||
if p := c.Query("platform"); p != "" {
|
||||
platform = &p
|
||||
}
|
||||
|
||||
royalties, summary, err := h.service.GetExternalRoyalties(c.Request.Context(), userID, startDate, endDate, platform, limit, offset)
|
||||
if err != nil {
|
||||
RespondWithAppError(c, apperrors.NewInternalErrorWrap("Failed to get royalties", err))
|
||||
return
|
||||
}
|
||||
|
||||
RespondSuccess(c, http.StatusOK, gin.H{
|
||||
"data": royalties,
|
||||
"summary": summary,
|
||||
})
|
||||
}
|
||||
143
veza-backend-api/internal/handlers/distribution_handler_test.go
Normal file
143
veza-backend-api/internal/handlers/distribution_handler_test.go
Normal file
|
|
@ -0,0 +1,143 @@
|
|||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
"go.uber.org/zap"
|
||||
|
||||
"veza-backend-api/internal/core/distribution"
|
||||
)
|
||||
|
||||
func TestNewDistributionHandler(t *testing.T) {
|
||||
logger := zap.NewNop()
|
||||
svc := distribution.NewService(nil, logger, nil)
|
||||
handler := NewDistributionHandler(svc, logger)
|
||||
|
||||
if handler == nil {
|
||||
t.Fatal("expected non-nil handler")
|
||||
}
|
||||
if handler.service == nil {
|
||||
t.Error("expected non-nil service")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDistributionHandler_Submit_NoAuth(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
logger := zap.NewNop()
|
||||
svc := distribution.NewService(nil, logger, nil)
|
||||
handler := NewDistributionHandler(svc, logger)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Request = httptest.NewRequest(http.MethodPost, "/distributions/submit", strings.NewReader(`{}`))
|
||||
c.Request.Header.Set("Content-Type", "application/json")
|
||||
|
||||
handler.Submit(c)
|
||||
|
||||
if w.Code != http.StatusUnauthorized {
|
||||
t.Errorf("expected 401, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDistributionHandler_Submit_InvalidBody(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
logger := zap.NewNop()
|
||||
svc := distribution.NewService(nil, logger, nil)
|
||||
handler := NewDistributionHandler(svc, logger)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Set("user_id", uuid.New())
|
||||
c.Request = httptest.NewRequest(http.MethodPost, "/distributions/submit", strings.NewReader(`{}`))
|
||||
c.Request.Header.Set("Content-Type", "application/json")
|
||||
|
||||
handler.Submit(c)
|
||||
|
||||
var resp map[string]interface{}
|
||||
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
|
||||
t.Fatalf("failed to decode response: %v", err)
|
||||
}
|
||||
if resp["success"] != false {
|
||||
t.Error("expected success=false for invalid request")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDistributionHandler_GetDistribution_InvalidID(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
logger := zap.NewNop()
|
||||
svc := distribution.NewService(nil, logger, nil)
|
||||
handler := NewDistributionHandler(svc, logger)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Set("user_id", uuid.New())
|
||||
c.Params = gin.Params{{Key: "id", Value: "not-a-uuid"}}
|
||||
c.Request = httptest.NewRequest(http.MethodGet, "/distributions/not-a-uuid", nil)
|
||||
|
||||
handler.GetDistribution(c)
|
||||
|
||||
var resp map[string]interface{}
|
||||
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
|
||||
t.Fatalf("failed to decode response: %v", err)
|
||||
}
|
||||
if resp["success"] != false {
|
||||
t.Error("expected success=false for invalid ID")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDistributionHandler_ListDistributions_NoAuth(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
logger := zap.NewNop()
|
||||
svc := distribution.NewService(nil, logger, nil)
|
||||
handler := NewDistributionHandler(svc, logger)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Request = httptest.NewRequest(http.MethodGet, "/distributions", nil)
|
||||
|
||||
handler.ListDistributions(c)
|
||||
|
||||
if w.Code != http.StatusUnauthorized {
|
||||
t.Errorf("expected 401, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDistributionHandler_RemoveDistribution_NoAuth(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
logger := zap.NewNop()
|
||||
svc := distribution.NewService(nil, logger, nil)
|
||||
handler := NewDistributionHandler(svc, logger)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Request = httptest.NewRequest(http.MethodPost, "/distributions/123/remove", nil)
|
||||
|
||||
handler.RemoveDistribution(c)
|
||||
|
||||
if w.Code != http.StatusUnauthorized {
|
||||
t.Errorf("expected 401, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDistributionHandler_GetExternalRoyalties_NoAuth(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
logger := zap.NewNop()
|
||||
svc := distribution.NewService(nil, logger, nil)
|
||||
handler := NewDistributionHandler(svc, logger)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Request = httptest.NewRequest(http.MethodGet, "/creators/me/external-royalties", nil)
|
||||
|
||||
handler.GetExternalRoyalties(c)
|
||||
|
||||
if w.Code != http.StatusUnauthorized {
|
||||
t.Errorf("expected 401, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue