diff --git a/veza-backend-api/internal/api/router.go b/veza-backend-api/internal/api/router.go index 2b56d9343..f2ed74a43 100644 --- a/veza-backend-api/internal/api/router.go +++ b/veza-backend-api/internal/api/router.go @@ -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 diff --git a/veza-backend-api/internal/api/routes_distribution.go b/veza-backend-api/internal/api/routes_distribution.go new file mode 100644 index 000000000..1c328b964 --- /dev/null +++ b/veza-backend-api/internal/api/routes_distribution.go @@ -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) +} diff --git a/veza-backend-api/internal/core/distribution/models.go b/veza-backend-api/internal/core/distribution/models.go new file mode 100644 index 000000000..0d72f7875 --- /dev/null +++ b/veza-backend-api/internal/core/distribution/models.go @@ -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 +} diff --git a/veza-backend-api/internal/core/distribution/service.go b/veza-backend-api/internal/core/distribution/service.go new file mode 100644 index 000000000..0084f8625 --- /dev/null +++ b/veza-backend-api/internal/core/distribution/service.go @@ -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"` +} diff --git a/veza-backend-api/internal/core/distribution/service_test.go b/veza-backend-api/internal/core/distribution/service_test.go new file mode 100644 index 000000000..bc04851f2 --- /dev/null +++ b/veza-backend-api/internal/core/distribution/service_test.go @@ -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) + } +} diff --git a/veza-backend-api/internal/handlers/distribution_handler.go b/veza-backend-api/internal/handlers/distribution_handler.go new file mode 100644 index 000000000..c9da83454 --- /dev/null +++ b/veza-backend-api/internal/handlers/distribution_handler.go @@ -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, + }) +} diff --git a/veza-backend-api/internal/handlers/distribution_handler_test.go b/veza-backend-api/internal/handlers/distribution_handler_test.go new file mode 100644 index 000000000..0d48975c2 --- /dev/null +++ b/veza-backend-api/internal/handlers/distribution_handler_test.go @@ -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) + } +}