Files
michaelschiemer/src/Domain/PreSave
..

Pre-Save Campaign System

A complete pre-save campaign system for music releases, integrated with streaming platforms like Spotify, Tidal, and Apple Music.

What is a Pre-Save Campaign?

A pre-save campaign allows fans to register their interest in an upcoming music release before it's officially available. When the release date arrives, the track/album is automatically added to their library on their chosen streaming platform.

This is a common marketing tool in the music industry to:

  • Build hype for upcoming releases
  • Guarantee first-day streams
  • Increase algorithmic visibility on streaming platforms
  • Track fan engagement and reach

System Architecture

┌─────────────────┐    ┌─────────────────┐    ┌─────────────────┐
│  Admin Creates  │───▶│  Fans Register  │───▶│ Release Day     │
│    Campaign     │    │  via OAuth      │    │  Processing     │
└─────────────────┘    └─────────────────┘    └─────────────────┘
        │                       │                       │
   Admin Interface        Public Landing Page    Background Jobs
   CRUD Operations        OAuth Integration      Auto-Add to Library
   Campaign Management    User Registration      Status Tracking

Core Components

1. Value Objects (ValueObjects/)

StreamingPlatform - Supported streaming platforms

  • SPOTIFY, TIDAL, APPLE_MUSIC, DEEZER, YOUTUBE_MUSIC
  • Methods: getDisplayName(), getOAuthProvider(), isSupported()

CampaignStatus - Campaign lifecycle states

  • DRAFT → SCHEDULED → ACTIVE → RELEASED → COMPLETED
  • Methods: acceptsRegistrations(), shouldProcess(), isEditable()

RegistrationStatus - User registration states

  • PENDING → COMPLETED / FAILED / REVOKED
  • Methods: canRetry(), isFinal()

TrackUrl - Platform-specific track URL with ID extraction

  • Automatic platform detection from URL
  • Track ID extraction for API calls
  • Methods: fromUrl(), getPlatformId()

2. Entities

PreSaveCampaign - Campaign entity

PreSaveCampaign::create(
    title: 'New Album',
    artistName: 'The Artist',
    coverImageUrl: 'https://...',
    releaseDate: Timestamp::fromString('2024-12-01 00:00'),
    trackUrls: [TrackUrl::fromUrl('https://open.spotify.com/track/...')],
    description: 'Optional description',
    startDate: Timestamp::now()
);

Lifecycle Methods:

  • publish() - Publish draft campaign (sets status to SCHEDULED or ACTIVE)
  • markAsReleased() - Mark as released when release date passes
  • markAsCompleted() - Mark as completed after processing
  • cancel() - Cancel campaign at any time

PreSaveRegistration - User registration

PreSaveRegistration::create(
    campaignId: 1,
    userId: 'spotify_user_123',
    platform: StreamingPlatform::SPOTIFY
);

Status Methods:

  • markAsCompleted() - Mark as successfully processed
  • markAsFailed() - Mark as failed with error message
  • resetForRetry() - Reset for retry attempt

3. Repositories

PreSaveCampaignRepository

  • findById(int $id): ?PreSaveCampaign
  • findAll(): array
  • save(PreSaveCampaign $campaign): PreSaveCampaign
  • delete(int $id): void

PreSaveRegistrationRepository

  • findByCampaign(int $campaignId): array
  • findByUserAndCampaign(string $userId, int $campaignId, StreamingPlatform $platform): ?PreSaveRegistration
  • findPendingForCampaign(int $campaignId): array
  • findFailedRegistrations(int $maxRetries = 3): array
  • save(PreSaveRegistration $registration): PreSaveRegistration
  • delete(int $id): void

4. Services

PreSaveCampaignService - Business logic layer

public function getCampaignStats(int $campaignId): array
{
    return [
        'total_registrations' => 150,
        'by_platform' => [
            'spotify' => 100,
            'tidal' => 30,
            'apple_music' => 20
        ],
        'by_status' => [
            'pending' => 120,
            'completed' => 25,
            'failed' => 5
        ]
    ];
}

PreSaveProcessor - Release day processing

Core Methods:

  • processReleasedCampaigns() - Check for campaigns that should be released
  • processPendingRegistrations() - Process all pending registrations
  • retryFailedRegistrations(int $campaignId, int $maxRetries) - Retry failed registrations

Processing Flow:

// 1. Mark campaigns as released
$releaseResults = $processor->processReleasedCampaigns();

// 2. Process pending registrations
$processingResults = $processor->processPendingRegistrations();

// 3. Retry failed registrations (separate job)
$retryResults = $processor->retryFailedRegistrations($campaignId, 3);

5. Background Jobs

ProcessReleasedCampaignsJob - Runs every hour

#[Schedule(interval: Duration::fromMinutes(60))]
public function handle(): array
{
    // Step 1: Mark campaigns as released
    $releaseResults = $this->processor->processReleasedCampaigns();

    // Step 2: Process pending registrations
    $processingResults = $this->processor->processPendingRegistrations();

    return [
        'release_phase' => $releaseResults,
        'processing_phase' => $processingResults
    ];
}

RetryFailedRegistrationsJob - Runs every 6 hours

#[Schedule(interval: Duration::fromHours(6))]
public function handle(): array
{
    $campaigns = $this->campaignRepository->findAll([
        'status' => CampaignStatus::RELEASED->value
    ]);

    foreach ($campaigns as $campaign) {
        $result = $this->processor->retryFailedRegistrations($campaign->id, 3);
        // Process retry results
    }
}

6. OAuth Integration

The system integrates with the OAuth module for authentication:

// User clicks "Pre-Save on Spotify"
$authUrl = $this->oauthService->getAuthorizationUrl(
    'spotify',
    '/presave/123/oauth/callback/spotify'
);

// After OAuth authorization
$token = $this->oauthService->handleCallback('spotify', $code, $callbackUrl);

// Token is stored and auto-refreshed
$storedToken = $this->oauthService->getTokenForUser($userId, 'spotify');

// Use token to add tracks
$provider->addTracksToLibrary($storedToken->token, [$trackId]);

Admin Interface

Campaign Management

Admin Routes:

  • GET /admin/presave-campaigns - List all campaigns
  • GET /admin/presave-campaigns/create - Create campaign form
  • POST /admin/presave-campaigns - Store new campaign
  • GET /admin/presave-campaigns/{id} - View campaign stats
  • GET /admin/presave-campaigns/{id}/edit - Edit campaign form
  • POST /admin/presave-campaigns/{id} - Update campaign
  • POST /admin/presave-campaigns/{id}/delete - Delete campaign
  • POST /admin/presave-campaigns/{id}/publish - Publish draft campaign
  • POST /admin/presave-campaigns/{id}/cancel - Cancel campaign

Admin Features:

  • Create campaigns with multiple platform URLs
  • Edit campaign details (title, artist, cover image, description)
  • Set release date and optional campaign start date
  • Manage campaign status (draft, scheduled, active, cancelled)
  • View campaign statistics and registrations
  • Publish campaigns to make them public
  • Cancel campaigns at any time

Campaign Form Fields

- title: Campaign title (required)
- artist_name: Artist name (required)
- cover_image_url: Album/single artwork URL (required)
- description: Campaign description (optional)
- release_date: When track will be released (required)
- start_date: When to start accepting pre-saves (optional)
- track_url_spotify: Spotify track URL
- track_url_tidal: Tidal track URL (optional)
- track_url_apple_music: Apple Music track URL (optional)

Public Interface

End-User Flow

Public Routes:

  • GET /presave/{id} - Campaign landing page
  • POST /presave/{id}/register/{platform} - Register for pre-save
  • GET /presave/{id}/oauth/callback/{platform} - OAuth callback
  • POST /presave/{id}/cancel/{platform} - Cancel registration
  • GET /presave/{id}/status - Get registration status (JSON)

User Journey:

  1. Visit campaign landing page: /presave/123
  2. Click "Pre-Save on Spotify"
  3. Redirect to Spotify OAuth authorization
  4. Callback stores token and creates registration
  5. Redirect back to landing page with success message
  6. On release day, background job adds track to library
  7. User sees "✓ Added to Library" status

Campaign Landing Page Features

  • Album artwork display
  • Track/album title and artist
  • Release date badge
  • Optional campaign description
  • Platform selection buttons (Spotify, Tidal, etc.)
  • Registration status display
  • Success/cancellation messages
  • Auto-refresh status for pending registrations
  • Mobile-responsive design

Database Schema

presave_campaigns Table

CREATE TABLE presave_campaigns (
    id                  INTEGER PRIMARY KEY AUTOINCREMENT,
    title               VARCHAR(255) NOT NULL,
    artist_name         VARCHAR(255) NOT NULL,
    cover_image_url     TEXT NOT NULL,
    description         TEXT,
    release_date        INTEGER NOT NULL,
    start_date          INTEGER,
    track_urls          TEXT NOT NULL,  -- JSON array of TrackUrl objects
    status              VARCHAR(20) NOT NULL DEFAULT 'draft',
    created_at          INTEGER NOT NULL,
    updated_at          INTEGER NOT NULL
);

CREATE INDEX idx_presave_campaigns_status ON presave_campaigns(status);
CREATE INDEX idx_presave_campaigns_release_date ON presave_campaigns(release_date);

presave_registrations Table

CREATE TABLE presave_registrations (
    id                  INTEGER PRIMARY KEY AUTOINCREMENT,
    campaign_id         INTEGER NOT NULL,
    user_id             VARCHAR(255) NOT NULL,
    platform            VARCHAR(50) NOT NULL,
    status              VARCHAR(20) NOT NULL DEFAULT 'pending',
    registered_at       INTEGER NOT NULL,
    processed_at        INTEGER,
    error_message       TEXT,
    retry_count         INTEGER NOT NULL DEFAULT 0,

    UNIQUE(campaign_id, user_id, platform),
    FOREIGN KEY (campaign_id) REFERENCES presave_campaigns(id) ON DELETE CASCADE
);

CREATE INDEX idx_presave_registrations_campaign_id ON presave_registrations(campaign_id);
CREATE INDEX idx_presave_registrations_user_id ON presave_registrations(user_id);
CREATE INDEX idx_presave_registrations_status ON presave_registrations(status);
CREATE INDEX idx_presave_registrations_platform ON presave_registrations(platform);

Campaign Lifecycle

Status Flow

DRAFT
  │
  ├─ publish() → SCHEDULED (if release_date in future)
  ├─ publish() → ACTIVE (if release_date has passed)
  └─ cancel() → CANCELLED

SCHEDULED
  │
  ├─ (on start_date) → ACTIVE
  ├─ (on release_date) → RELEASED
  └─ cancel() → CANCELLED

ACTIVE
  │
  ├─ (on release_date) → RELEASED
  └─ cancel() → CANCELLED

RELEASED
  │
  └─ (after processing) → COMPLETED

COMPLETED / CANCELLED
  │
  └─ [FINAL STATE]

Registration Flow

PENDING
  │
  ├─ (on success) → COMPLETED
  ├─ (on error) → FAILED
  └─ (user action) → REVOKED

FAILED
  │
  ├─ (retry success) → COMPLETED
  └─ (max retries) → [STAYS FAILED]

COMPLETED / REVOKED
  │
  └─ [FINAL STATE]

Platform Integration

Currently Supported

Spotify

  • OAuth 2.0 authorization
  • Pre-save implementation via addTracksToLibrary()
  • Track ID extraction from Spotify URLs
  • Library status checking

Planned Support

Tidal 🔄 (In Development) Apple Music 🔄 (In Development) Deezer 📋 (Planned) YouTube Music 📋 (Planned)

Adding New Platform

  1. Update StreamingPlatform enum:
case NEW_PLATFORM = 'new_platform';

public function isSupported(): bool
{
    return match ($this) {
        self::NEW_PLATFORM => true,  // Enable support
        // ...
    };
}
  1. Implement OAuth provider in /src/Framework/OAuth/Providers/:
final readonly class NewPlatformProvider implements OAuthProvider
{
    public function addTracksToLibrary(OAuthToken $token, array $trackIds): bool
    {
        // Platform-specific implementation
    }
}
  1. Update TrackUrl::fromUrl() to support new URL format:
if (str_contains($url, 'newplatform.com/track/')) {
    preg_match('/track\/([a-zA-Z0-9]+)/', $url, $matches);
    return new self(StreamingPlatform::NEW_PLATFORM, $url, $matches[1]);
}

Error Handling

Failed Registrations

When a registration fails:

  1. Status set to FAILED
  2. Error message stored in error_message field
  3. Retry count incremented
  4. RetryFailedRegistrationsJob attempts retry (max 3 attempts)
  5. After max retries, registration stays FAILED

Common Failure Scenarios

  • Token Expired: Auto-refresh handled by OAuthService
  • API Rate Limit: Retry with exponential backoff
  • Invalid Track ID: Permanent failure, needs manual correction
  • Platform API Down: Retry until success or max attempts

Performance Considerations

Background Processing

  • Campaigns checked hourly for release
  • Registrations processed in batches
  • Failed registrations retried every 6 hours
  • Auto-refresh tokens prevent auth failures

Scalability

  • Unique constraint prevents duplicate registrations
  • Indexes on frequently queried fields (status, campaign_id, user_id)
  • CASCADE delete for data integrity
  • Efficient batch processing in workers

Testing

Unit Tests

./vendor/bin/pest tests/Domain/PreSave

Integration Tests

# Test complete flow
./vendor/bin/pest tests/Feature/PreSaveFlowTest.php

Manual Testing

  1. Create campaign in admin: /admin/presave-campaigns/create
  2. Publish campaign: /admin/presave-campaigns/{id}/publish
  3. Visit public page: /presave/{id}
  4. Register via OAuth
  5. Run worker manually:
php console.php schedule:run ProcessReleasedCampaignsJob
  1. Check registration status

Security Considerations

OAuth Security

  • Tokens stored encrypted in database
  • Auto-refresh prevents token expiration
  • State parameter prevents CSRF attacks
  • Secure callback URL validation

Input Validation

  • Track URLs validated and sanitized
  • Campaign dates validated
  • Platform enum ensures only supported platforms
  • Unique constraint prevents duplicate registrations

Data Privacy

  • User IDs from OAuth providers
  • No personally identifiable information stored
  • Registrations deleted on campaign deletion (CASCADE)
  • Secure token storage and handling

Framework Compliance

Value Objects

  • All domain concepts as Value Objects
  • No primitive obsession (no raw arrays or strings)
  • Immutable with transformation methods

Readonly Classes

  • All entities and VOs are readonly
  • No mutable state
  • New instances for changes

No Inheritance

  • Composition over inheritance
  • No extends used anywhere
  • Interface-based contracts

Explicit DI

  • Constructor injection only
  • No global state or service locators
  • Clear dependency graph

Monitoring & Metrics

Key Metrics

  • Total campaigns created
  • Active campaigns
  • Total registrations
  • Registrations by platform
  • Success/failure rates
  • Processing times

Health Checks

// Check system health
GET /admin/api/presave-campaigns/health

// Response
{
    "status": "healthy",
    "campaigns": {
        "total": 10,
        "active": 3,
        "scheduled": 5
    },
    "registrations": {
        "total": 1500,
        "pending": 200,
        "completed": 1250,
        "failed": 50
    },
    "workers": {
        "last_run": "2024-01-20 15:30:00",
        "status": "running"
    }
}

Future Enhancements

Planned Features

  • Email notifications for successful pre-saves
  • SMS notifications for release day
  • Social sharing for campaigns
  • Campaign analytics dashboard
  • A/B testing for landing pages
  • Multi-track album pre-saves
  • Playlist pre-saves
  • Fan engagement metrics
  • Export campaign data

Platform Expansion

  • Complete Tidal integration
  • Complete Apple Music integration
  • Add Deezer support
  • Add YouTube Music support
  • Add SoundCloud support
  • Add Bandcamp support

Troubleshooting

Registration Not Processing

Symptom: Registrations stay in PENDING status

Solutions:

  1. Check if scheduled job is running: php console.php schedule:list
  2. Check campaign status: Must be RELEASED
  3. Verify OAuth token is valid: Auto-refresh should handle this
  4. Check error logs for API failures

OAuth Flow Failing

Symptom: Authorization redirect fails or callback errors

Solutions:

  1. Verify OAuth credentials in environment
  2. Check callback URL matches registered URL
  3. Ensure HTTPS is enabled (required for OAuth)
  4. Check provider API status

Platform API Rate Limits

Symptom: Multiple FAILED registrations with rate limit errors

Solutions:

  1. Reduce processing batch size
  2. Increase delay between API calls
  3. Implement exponential backoff
  4. Contact platform for rate limit increase

Support

For issues or questions:

  • Check logs: storage/logs/
  • Review error messages in admin interface
  • Check background job status
  • Contact development team

License

Part of Custom PHP Framework - Internal Use Only