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:
senke 2026-03-10 19:54:26 +01:00
parent 9f5ffbe569
commit 6063bfdeea
7 changed files with 1387 additions and 0 deletions

View file

@ -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

View 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)
}

View 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
}

View 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"`
}

View 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)
}
}

View 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,
})
}

View 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)
}
}