docs: consolidate documentation into organized structure

- Move 12 markdown files from root to docs/ subdirectories
- Organize documentation by category:
  • docs/troubleshooting/ (1 file)  - Technical troubleshooting guides
  • docs/deployment/      (4 files) - Deployment and security documentation
  • docs/guides/          (3 files) - Feature-specific guides
  • docs/planning/        (4 files) - Planning and improvement proposals

Root directory cleanup:
- Reduced from 16 to 4 markdown files in root
- Only essential project files remain:
  • CLAUDE.md (AI instructions)
  • README.md (Main project readme)
  • CLEANUP_PLAN.md (Current cleanup plan)
  • SRC_STRUCTURE_IMPROVEMENTS.md (Structure improvements)

This improves:
 Documentation discoverability
 Logical organization by purpose
 Clean root directory
 Better maintainability
This commit is contained in:
2025-10-05 11:05:04 +02:00
parent 887847dde6
commit 5050c7d73a
36686 changed files with 196456 additions and 12398919 deletions

View File

@@ -0,0 +1,41 @@
<?php
declare(strict_types=1);
namespace App\Domain\PreSave\Jobs;
use App\Domain\PreSave\Services\PreSaveProcessor;
use App\Framework\Worker\Schedule;
use App\Framework\Worker\Every;
/**
* Process Released Campaigns Job
*
* Runs every hour to check for campaigns that should be released
*/
#[Schedule(at: new Every(hours: 1))]
final readonly class ProcessReleasedCampaignsJob
{
public function __construct(
private PreSaveProcessor $processor,
) {}
/**
* @return array<string, mixed>
*/
public function handle(): array
{
// Step 1: Mark campaigns as released if release date has passed
$releaseResults = $this->processor->processReleasedCampaigns();
// Step 2: Process pending registrations for released campaigns
$processingResults = $this->processor->processPendingRegistrations();
return [
'job' => 'process_released_campaigns',
'timestamp' => time(),
'release_phase' => $releaseResults,
'processing_phase' => $processingResults,
];
}
}

View File

@@ -0,0 +1,65 @@
<?php
declare(strict_types=1);
namespace App\Domain\PreSave\Jobs;
use App\Domain\PreSave\PreSaveCampaignRepository;
use App\Domain\PreSave\Services\PreSaveProcessor;
use App\Domain\PreSave\ValueObjects\CampaignStatus;
use App\Framework\Worker\Schedule;
use App\Framework\Worker\Every;
/**
* Retry Failed Registrations Job
*
* Runs every 6 hours to retry failed pre-save registrations
*/
#[Schedule(at: new Every(hours: 6))]
final readonly class RetryFailedRegistrationsJob
{
public function __construct(
private PreSaveProcessor $processor,
private PreSaveCampaignRepository $campaignRepository,
) {}
/**
* @return array<string, mixed>
*/
public function handle(): array
{
// Find campaigns that are released but not completed
$campaigns = $this->campaignRepository->findAll([
'status' => CampaignStatus::RELEASED->value,
]);
$totalProcessed = 0;
$totalSuccessful = 0;
$results = [];
foreach ($campaigns as $campaign) {
$result = $this->processor->retryFailedRegistrations($campaign->id, 3);
$totalProcessed += $result['processed'];
$totalSuccessful += $result['successful'];
if ($result['processed'] > 0) {
$results[] = [
'campaign_id' => $campaign->id,
'title' => $campaign->title,
...$result,
];
}
}
return [
'job' => 'retry_failed_registrations',
'timestamp' => time(),
'campaigns_checked' => count($campaigns),
'total_processed' => $totalProcessed,
'total_successful' => $totalSuccessful,
'total_failed' => $totalProcessed - $totalSuccessful,
'results' => $results,
];
}
}

View File

@@ -0,0 +1,58 @@
<?php
declare(strict_types=1);
namespace App\Domain\PreSave\Migrations;
use App\Framework\Database\ConnectionInterface;
use App\Framework\Database\Migration\Migration;
use App\Framework\Database\Migration\MigrationVersion;
use App\Framework\Database\Schema\Blueprint;
use App\Framework\Database\Schema\Schema;
/**
* Create Pre-Save Campaigns Table
*/
final class CreatePresaveCampaignsTable implements Migration
{
public function up(ConnectionInterface $connection): void
{
$schema = new Schema($connection);
$schema->createIfNotExists('presave_campaigns', function (Blueprint $table) {
$table->id();
$table->string('title', 255);
$table->string('artist_name', 255);
$table->text('cover_image_url');
$table->text('description')->nullable();
$table->bigInteger('release_date');
$table->bigInteger('start_date')->nullable();
$table->text('track_urls'); // JSON
$table->string('status', 20)->default('draft');
$table->timestamps();
// Indexes
$table->index(['status']);
$table->index(['release_date']);
});
$schema->execute();
}
public function down(ConnectionInterface $connection): void
{
$schema = new Schema($connection);
$schema->dropIfExists('presave_campaigns');
$schema->execute();
}
public function getVersion(): MigrationVersion
{
return MigrationVersion::fromTimestamp("2025_01_10_150000");
}
public function getDescription(): string
{
return "Create pre-save campaigns table";
}
}

View File

@@ -0,0 +1,69 @@
<?php
declare(strict_types=1);
namespace App\Domain\PreSave\Migrations;
use App\Framework\Database\ConnectionInterface;
use App\Framework\Database\Migration\Migration;
use App\Framework\Database\Migration\MigrationVersion;
use App\Framework\Database\Schema\Blueprint;
use App\Framework\Database\Schema\ForeignKeyAction;
use App\Framework\Database\Schema\Schema;
/**
* Create Pre-Save Registrations Table
*/
final class CreatePresaveRegistrationsTable implements Migration
{
public function up(ConnectionInterface $connection): void
{
$schema = new Schema($connection);
$schema->createIfNotExists('presave_registrations', function (Blueprint $table) {
$table->id();
$table->bigInteger('campaign_id'); // Match the signed BIGINT type of presave_campaigns.id
$table->string('user_id', 255);
$table->string('platform', 50);
$table->string('status', 20)->default('pending');
$table->bigInteger('registered_at');
$table->bigInteger('processed_at')->nullable();
$table->text('error_message')->nullable();
$table->unsignedInteger('retry_count')->default(0);
// Unique constraint
$table->unique(['campaign_id', 'user_id', 'platform']);
// Foreign key
$table->foreign('campaign_id')
->references('id')
->on('presave_campaigns')
->onDelete(ForeignKeyAction::CASCADE);
// Indexes
$table->index(['campaign_id']);
$table->index(['user_id']);
$table->index(['status']);
$table->index(['platform']);
});
$schema->execute();
}
public function down(ConnectionInterface $connection): void
{
$schema = new Schema($connection);
$schema->dropIfExists('presave_registrations');
$schema->execute();
}
public function getVersion(): MigrationVersion
{
return MigrationVersion::fromTimestamp("2025_01_10_150001");
}
public function getDescription(): string
{
return "Create pre-save registrations table";
}
}

View File

@@ -0,0 +1,261 @@
<?php
declare(strict_types=1);
namespace App\Domain\PreSave;
use App\Domain\PreSave\ValueObjects\CampaignStatus;
use App\Domain\PreSave\ValueObjects\StreamingPlatform;
use App\Domain\PreSave\ValueObjects\TrackUrl;
use App\Framework\Core\ValueObjects\Timestamp;
/**
* Pre-Save Campaign Entity
*
* Represents a music pre-save campaign for upcoming releases
*/
final readonly class PreSaveCampaign
{
/**
* @param array<TrackUrl> $trackUrls
*/
public function __construct(
public ?int $id,
public string $title,
public string $artistName,
public string $coverImageUrl,
public Timestamp $releaseDate,
public array $trackUrls,
public CampaignStatus $status,
public Timestamp $createdAt,
public Timestamp $updatedAt,
public ?string $description = null,
public ?Timestamp $startDate = null,
) {
if (empty($this->trackUrls)) {
throw new \InvalidArgumentException('Campaign must have at least one track URL');
}
}
/**
* Create new campaign
*
* @param array<TrackUrl> $trackUrls
*/
public static function create(
string $title,
string $artistName,
string $coverImageUrl,
Timestamp $releaseDate,
array $trackUrls,
?string $description = null,
?Timestamp $startDate = null,
): self {
$now = Timestamp::now();
return new self(
id: null,
title: $title,
artistName: $artistName,
coverImageUrl: $coverImageUrl,
releaseDate: $releaseDate,
trackUrls: $trackUrls,
status: CampaignStatus::DRAFT,
createdAt: $now,
updatedAt: $now,
description: $description,
startDate: $startDate,
);
}
/**
* Publish campaign (make it active)
*/
public function publish(): self
{
if (!$this->status->isEditable()) {
throw new \RuntimeException('Cannot publish campaign in current status: ' . $this->status->value);
}
$now = Timestamp::now();
$newStatus = $now->isBefore($this->releaseDate) ? CampaignStatus::SCHEDULED : CampaignStatus::ACTIVE;
return new self(
id: $this->id,
title: $this->title,
artistName: $this->artistName,
coverImageUrl: $this->coverImageUrl,
releaseDate: $this->releaseDate,
trackUrls: $this->trackUrls,
status: $newStatus,
createdAt: $this->createdAt,
updatedAt: Timestamp::now(),
description: $this->description,
startDate: $this->startDate ?? $now,
);
}
/**
* Mark as released (ready for processing)
*/
public function markAsReleased(): self
{
return new self(
id: $this->id,
title: $this->title,
artistName: $this->artistName,
coverImageUrl: $this->coverImageUrl,
releaseDate: $this->releaseDate,
trackUrls: $this->trackUrls,
status: CampaignStatus::RELEASED,
createdAt: $this->createdAt,
updatedAt: Timestamp::now(),
description: $this->description,
startDate: $this->startDate,
);
}
/**
* Mark as completed
*/
public function markAsCompleted(): self
{
return new self(
id: $this->id,
title: $this->title,
artistName: $this->artistName,
coverImageUrl: $this->coverImageUrl,
releaseDate: $this->releaseDate,
trackUrls: $this->trackUrls,
status: CampaignStatus::COMPLETED,
createdAt: $this->createdAt,
updatedAt: Timestamp::now(),
description: $this->description,
startDate: $this->startDate,
);
}
/**
* Cancel campaign
*/
public function cancel(): self
{
if ($this->status === CampaignStatus::COMPLETED) {
throw new \RuntimeException('Cannot cancel completed campaign');
}
return new self(
id: $this->id,
title: $this->title,
artistName: $this->artistName,
coverImageUrl: $this->coverImageUrl,
releaseDate: $this->releaseDate,
trackUrls: $this->trackUrls,
status: CampaignStatus::CANCELLED,
createdAt: $this->createdAt,
updatedAt: Timestamp::now(),
description: $this->description,
startDate: $this->startDate,
);
}
/**
* Get track URL for specific platform
*/
public function getTrackUrl(StreamingPlatform $platform): ?TrackUrl
{
foreach ($this->trackUrls as $trackUrl) {
if ($trackUrl->platform === $platform) {
return $trackUrl;
}
}
return null;
}
/**
* Check if campaign has track for platform
*/
public function hasPlatform(StreamingPlatform $platform): bool
{
return $this->getTrackUrl($platform) !== null;
}
/**
* Check if campaign is currently accepting registrations
*/
public function acceptsRegistrations(): bool
{
return $this->status->acceptsRegistrations();
}
/**
* Check if release date has passed
*/
public function hasReleased(): bool
{
return $this->releaseDate->isBefore(Timestamp::now());
}
/**
* Get days until release
*/
public function getDaysUntilRelease(): int
{
$now = Timestamp::now();
$diff = $this->releaseDate->getTimestamp() - $now->getTimestamp();
return (int) ceil($diff / 86400);
}
/**
* Convert to array for storage/API
*
* @return array<string, mixed>
*/
public function toArray(): array
{
return [
'id' => $this->id,
'title' => $this->title,
'artist_name' => $this->artistName,
'cover_image_url' => $this->coverImageUrl,
'description' => $this->description,
'release_date' => $this->releaseDate->toTimestamp(),
'start_date' => $this->startDate?->toTimestamp(),
'track_urls' => array_map(fn($url) => $url->toArray(), $this->trackUrls),
'status' => $this->status->value,
'created_at' => $this->createdAt->toDateTime()->format('Y-m-d H:i:s'),
'updated_at' => $this->updatedAt->toDateTime()->format('Y-m-d H:i:s'),
];
}
/**
* Create from database row
*
* @param array<string, mixed> $row
*/
public static function fromArray(array $row): self
{
$trackUrls = is_string($row['track_urls'])
? json_decode($row['track_urls'], true)
: $row['track_urls'];
return new self(
id: isset($row['id']) ? (int) $row['id'] : null,
title: (string) $row['title'],
artistName: (string) $row['artist_name'],
coverImageUrl: (string) $row['cover_image_url'],
releaseDate: Timestamp::fromFloat((float) $row['release_date']),
trackUrls: array_map(
fn(array $data) => TrackUrl::fromArray($data),
$trackUrls
),
status: CampaignStatus::from($row['status']),
createdAt: Timestamp::fromFloat((float) strtotime($row['created_at'])),
updatedAt: Timestamp::fromFloat((float) strtotime($row['updated_at'])),
description: $row['description'] ?? null,
startDate: isset($row['start_date']) ? Timestamp::fromFloat((float) $row['start_date']) : null,
);
}
}

View File

@@ -0,0 +1,243 @@
<?php
declare(strict_types=1);
namespace App\Domain\PreSave;
use App\Domain\PreSave\ValueObjects\CampaignStatus;
use App\Framework\Database\ConnectionInterface;
use App\Framework\Database\ValueObjects\SqlQuery;
use App\Framework\Core\ValueObjects\Timestamp;
/**
* Pre-Save Campaign Repository
*/
final readonly class PreSaveCampaignRepository
{
private const TABLE = 'presave_campaigns';
public function __construct(
private ConnectionInterface $connection,
) {}
/**
* Find campaign by ID
*/
public function findById(int $id): ?PreSaveCampaign
{
$sql = "SELECT * FROM " . self::TABLE . " WHERE id = ? LIMIT 1";
$query = SqlQuery::create($sql, [$id]);
$result = $this->connection->query($query);
$row = $result->fetchOne();
return $row ? PreSaveCampaign::fromArray($row) : null;
}
/**
* Find all campaigns
*
* @param array<string, mixed> $filters
* @return array<PreSaveCampaign>
*/
public function findAll(array $filters = []): array
{
$sql = "SELECT * FROM " . self::TABLE;
$params = [];
if (isset($filters['status'])) {
$sql .= " WHERE status = ?";
$params[] = $filters['status'];
}
$sql .= " ORDER BY release_date DESC";
$query = SqlQuery::create($sql, $params);
$result = $this->connection->query($query);
$rows = $result->fetchAll();
return array_map(
fn(array $row) => PreSaveCampaign::fromArray($row),
$rows
);
}
/**
* Find campaigns that should be released (release date passed, status = scheduled/active)
*
* @return array<PreSaveCampaign>
*/
public function findReadyForRelease(): array
{
$now = Timestamp::now()->toTimestamp();
$sql = "SELECT * FROM " . self::TABLE . "
WHERE release_date <= ?
AND status IN (?, ?)
ORDER BY release_date ASC";
$query = SqlQuery::create($sql, [
$now,
CampaignStatus::SCHEDULED->value,
CampaignStatus::ACTIVE->value,
]);
$result = $this->connection->query($query);
$rows = $result->fetchAll();
return array_map(
fn(array $row) => PreSaveCampaign::fromArray($row),
$rows
);
}
/**
* Find campaigns ready for processing (status = released)
*
* @return array<PreSaveCampaign>
*/
public function findReadyForProcessing(): array
{
$sql = "SELECT * FROM " . self::TABLE . "
WHERE status = ?
ORDER BY release_date ASC";
$query = SqlQuery::create($sql, [CampaignStatus::RELEASED->value]);
$result = $this->connection->query($query);
$rows = $result->fetchAll();
return array_map(
fn(array $row) => PreSaveCampaign::fromArray($row),
$rows
);
}
/**
* Save or update campaign
*/
public function save(PreSaveCampaign $campaign): PreSaveCampaign
{
if ($campaign->id === null) {
return $this->insert($campaign);
}
return $this->update($campaign);
}
/**
* Delete campaign
*/
public function delete(int $id): bool
{
$sql = "DELETE FROM " . self::TABLE . " WHERE id = ?";
$query = SqlQuery::create($sql, [$id]);
$this->connection->execute($query);
return true;
}
/**
* Get campaign statistics
*
* @return array<string, int>
*/
public function getStatistics(int $campaignId): array
{
$sql = "SELECT
COUNT(*) as total_registrations,
SUM(CASE WHEN status = 'completed' THEN 1 ELSE 0 END) as completed,
SUM(CASE WHEN status = 'pending' THEN 1 ELSE 0 END) as pending,
SUM(CASE WHEN status = 'failed' THEN 1 ELSE 0 END) as failed
FROM presave_registrations
WHERE campaign_id = ?";
$query = SqlQuery::create($sql, [$campaignId]);
$result = $this->connection->query($query);
$row = $result->fetchOne();
return [
'total_registrations' => (int) ($row['total_registrations'] ?? 0),
'completed' => (int) ($row['completed'] ?? 0),
'pending' => (int) ($row['pending'] ?? 0),
'failed' => (int) ($row['failed'] ?? 0),
];
}
/**
* Insert new campaign
*/
private function insert(PreSaveCampaign $campaign): PreSaveCampaign
{
$data = $campaign->toArray();
$sql = "INSERT INTO " . self::TABLE . "
(title, artist_name, cover_image_url, description, release_date, start_date, track_urls, status, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)";
$query = SqlQuery::create($sql, [
$data['title'],
$data['artist_name'],
$data['cover_image_url'],
$data['description'],
$data['release_date'],
$data['start_date'],
json_encode($data['track_urls']),
$data['status'],
$data['created_at'],
$data['updated_at'],
]);
$this->connection->execute($query);
$id = (int) $this->connection->lastInsertId();
return new PreSaveCampaign(
id: $id,
title: $campaign->title,
artistName: $campaign->artistName,
coverImageUrl: $campaign->coverImageUrl,
releaseDate: $campaign->releaseDate,
trackUrls: $campaign->trackUrls,
status: $campaign->status,
createdAt: $campaign->createdAt,
updatedAt: $campaign->updatedAt,
description: $campaign->description,
startDate: $campaign->startDate,
);
}
/**
* Update existing campaign
*/
private function update(PreSaveCampaign $campaign): PreSaveCampaign
{
$data = $campaign->toArray();
$sql = "UPDATE " . self::TABLE . "
SET title = ?,
artist_name = ?,
cover_image_url = ?,
description = ?,
release_date = ?,
start_date = ?,
track_urls = ?,
status = ?,
updated_at = ?
WHERE id = ?";
$query = SqlQuery::create($sql, [
$data['title'],
$data['artist_name'],
$data['cover_image_url'],
$data['description'],
$data['release_date'],
$data['start_date'],
json_encode($data['track_urls']),
$data['status'],
$data['updated_at'],
$campaign->id,
]);
$this->connection->execute($query);
return $campaign;
}
}

View File

@@ -0,0 +1,179 @@
<?php
declare(strict_types=1);
namespace App\Domain\PreSave;
use App\Domain\PreSave\ValueObjects\RegistrationStatus;
use App\Domain\PreSave\ValueObjects\StreamingPlatform;
use App\Framework\Core\ValueObjects\Timestamp;
/**
* Pre-Save Registration Entity
*
* Represents a user's registration for a pre-save campaign
*/
final readonly class PreSaveRegistration
{
public function __construct(
public ?int $id,
public int $campaignId,
public string $userId,
public StreamingPlatform $platform,
public RegistrationStatus $status,
public Timestamp $registeredAt,
public ?Timestamp $processedAt = null,
public ?string $errorMessage = null,
public int $retryCount = 0,
) {}
/**
* Create new registration
*/
public static function create(
int $campaignId,
string $userId,
StreamingPlatform $platform,
): self {
return new self(
id: null,
campaignId: $campaignId,
userId: $userId,
platform: $platform,
status: RegistrationStatus::PENDING,
registeredAt: Timestamp::now(),
);
}
/**
* Mark as completed
*/
public function markAsCompleted(): self
{
return new self(
id: $this->id,
campaignId: $this->campaignId,
userId: $this->userId,
platform: $this->platform,
status: RegistrationStatus::COMPLETED,
registeredAt: $this->registeredAt,
processedAt: Timestamp::now(),
errorMessage: null,
retryCount: $this->retryCount,
);
}
/**
* Mark as failed with error message
*/
public function markAsFailed(string $errorMessage): self
{
return new self(
id: $this->id,
campaignId: $this->campaignId,
userId: $this->userId,
platform: $this->platform,
status: RegistrationStatus::FAILED,
registeredAt: $this->registeredAt,
processedAt: Timestamp::now(),
errorMessage: $errorMessage,
retryCount: $this->retryCount + 1,
);
}
/**
* Mark as revoked (user disconnected OAuth)
*/
public function markAsRevoked(): self
{
return new self(
id: $this->id,
campaignId: $this->campaignId,
userId: $this->userId,
platform: $this->platform,
status: RegistrationStatus::REVOKED,
registeredAt: $this->registeredAt,
processedAt: Timestamp::now(),
errorMessage: 'OAuth token revoked by user',
retryCount: $this->retryCount,
);
}
/**
* Reset for retry
*/
public function resetForRetry(): self
{
if (!$this->status->canRetry()) {
throw new \RuntimeException('Cannot retry registration in status: ' . $this->status->value);
}
return new self(
id: $this->id,
campaignId: $this->campaignId,
userId: $this->userId,
platform: $this->platform,
status: RegistrationStatus::PENDING,
registeredAt: $this->registeredAt,
processedAt: null,
errorMessage: null,
retryCount: $this->retryCount,
);
}
/**
* Check if should be processed
*/
public function shouldProcess(): bool
{
return $this->status->shouldProcess();
}
/**
* Check if max retries exceeded
*/
public function hasExceededMaxRetries(int $maxRetries = 3): bool
{
return $this->retryCount >= $maxRetries;
}
/**
* Convert to array for storage/API
*
* @return array<string, mixed>
*/
public function toArray(): array
{
return [
'id' => $this->id,
'campaign_id' => $this->campaignId,
'user_id' => $this->userId,
'platform' => $this->platform->value,
'status' => $this->status->value,
'registered_at' => $this->registeredAt->toTimestamp(),
'processed_at' => $this->processedAt?->toTimestamp(),
'error_message' => $this->errorMessage,
'retry_count' => $this->retryCount,
];
}
/**
* Create from database row
*
* @param array<string, mixed> $row
*/
public static function fromArray(array $row): self
{
return new self(
id: isset($row['id']) ? (int) $row['id'] : null,
campaignId: (int) $row['campaign_id'],
userId: (string) $row['user_id'],
platform: StreamingPlatform::from($row['platform']),
status: RegistrationStatus::from($row['status']),
registeredAt: Timestamp::fromFloat((float) $row['registered_at']),
processedAt: isset($row['processed_at']) ? Timestamp::fromFloat((float) $row['processed_at']) : null,
errorMessage: $row['error_message'] ?? null,
retryCount: (int) ($row['retry_count'] ?? 0),
);
}
}

View File

@@ -0,0 +1,263 @@
<?php
declare(strict_types=1);
namespace App\Domain\PreSave;
use App\Domain\PreSave\ValueObjects\RegistrationStatus;
use App\Domain\PreSave\ValueObjects\StreamingPlatform;
use App\Framework\Database\ConnectionInterface;
use App\Framework\Database\ValueObjects\SqlQuery;
/**
* Pre-Save Registration Repository
*/
final readonly class PreSaveRegistrationRepository
{
private const TABLE = 'presave_registrations';
public function __construct(
private ConnectionInterface $connection,
) {}
/**
* Find registration by ID
*/
public function findById(int $id): ?PreSaveRegistration
{
$sql = "SELECT * FROM " . self::TABLE . " WHERE id = ? LIMIT 1";
$query = SqlQuery::create($sql, [$id]);
$result = $this->connection->query($query);
$row = $result->fetchOne();
return $row ? PreSaveRegistration::fromArray($row) : null;
}
/**
* Find registration for user and campaign
*/
public function findForUserAndCampaign(
string $userId,
int $campaignId,
StreamingPlatform $platform
): ?PreSaveRegistration {
$sql = "SELECT * FROM " . self::TABLE . "
WHERE user_id = ? AND campaign_id = ? AND platform = ?
LIMIT 1";
$query = SqlQuery::create($sql, [$userId, $campaignId, $platform->value]);
$result = $this->connection->query($query);
$row = $result->fetchOne();
return $row ? PreSaveRegistration::fromArray($row) : null;
}
/**
* Find all registrations for campaign
*
* @return array<PreSaveRegistration>
*/
public function findByCampaign(int $campaignId): array
{
$sql = "SELECT * FROM " . self::TABLE . "
WHERE campaign_id = ?
ORDER BY registered_at DESC";
$query = SqlQuery::create($sql, [$campaignId]);
$result = $this->connection->query($query);
$rows = $result->fetchAll();
return array_map(
fn(array $row) => PreSaveRegistration::fromArray($row),
$rows
);
}
/**
* Find pending registrations for campaign
*
* @return array<PreSaveRegistration>
*/
public function findPendingByCampaign(int $campaignId): array
{
$sql = "SELECT * FROM " . self::TABLE . "
WHERE campaign_id = ? AND status = ?
ORDER BY registered_at ASC";
$query = SqlQuery::create($sql, [
$campaignId,
RegistrationStatus::PENDING->value,
]);
$result = $this->connection->query($query);
$rows = $result->fetchAll();
return array_map(
fn(array $row) => PreSaveRegistration::fromArray($row),
$rows
);
}
/**
* Find failed registrations that can be retried
*
* @return array<PreSaveRegistration>
*/
public function findRetryable(int $campaignId, int $maxRetries = 3): array
{
$sql = "SELECT * FROM " . self::TABLE . "
WHERE campaign_id = ?
AND status = ?
AND retry_count < ?
ORDER BY registered_at ASC";
$query = SqlQuery::create($sql, [
$campaignId,
RegistrationStatus::FAILED->value,
$maxRetries,
]);
$result = $this->connection->query($query);
$rows = $result->fetchAll();
return array_map(
fn(array $row) => PreSaveRegistration::fromArray($row),
$rows
);
}
/**
* Check if user already registered for campaign
*/
public function hasRegistered(
string $userId,
int $campaignId,
StreamingPlatform $platform
): bool {
return $this->findForUserAndCampaign($userId, $campaignId, $platform) !== null;
}
/**
* Save or update registration
*/
public function save(PreSaveRegistration $registration): PreSaveRegistration
{
if ($registration->id === null) {
return $this->insert($registration);
}
return $this->update($registration);
}
/**
* Delete registration
*/
public function delete(int $id): bool
{
$sql = "DELETE FROM " . self::TABLE . " WHERE id = ?";
$query = SqlQuery::create($sql, [$id]);
$this->connection->execute($query);
return true;
}
/**
* Delete all registrations for campaign
*/
public function deleteByCampaign(int $campaignId): int
{
$sql = "DELETE FROM " . self::TABLE . " WHERE campaign_id = ?";
$query = SqlQuery::create($sql, [$campaignId]);
$this->connection->execute($query);
return $this->connection->rowCount();
}
/**
* Get registration count by status
*
* @return array<string, int>
*/
public function getStatusCounts(int $campaignId): array
{
$sql = "SELECT status, COUNT(*) as count
FROM " . self::TABLE . "
WHERE campaign_id = ?
GROUP BY status";
$query = SqlQuery::create($sql, [$campaignId]);
$result = $this->connection->query($query);
$rows = $result->fetchAll();
$counts = [];
foreach ($rows as $row) {
$counts[$row['status']] = (int) $row['count'];
}
return $counts;
}
/**
* Insert new registration
*/
private function insert(PreSaveRegistration $registration): PreSaveRegistration
{
$data = $registration->toArray();
$sql = "INSERT INTO " . self::TABLE . "
(campaign_id, user_id, platform, status, registered_at, processed_at, error_message, retry_count)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)";
$query = SqlQuery::create($sql, [
$data['campaign_id'],
$data['user_id'],
$data['platform'],
$data['status'],
$data['registered_at'],
$data['processed_at'],
$data['error_message'],
$data['retry_count'],
]);
$this->connection->execute($query);
$id = (int) $this->connection->lastInsertId();
return new PreSaveRegistration(
id: $id,
campaignId: $registration->campaignId,
userId: $registration->userId,
platform: $registration->platform,
status: $registration->status,
registeredAt: $registration->registeredAt,
processedAt: $registration->processedAt,
errorMessage: $registration->errorMessage,
retryCount: $registration->retryCount,
);
}
/**
* Update existing registration
*/
private function update(PreSaveRegistration $registration): PreSaveRegistration
{
$data = $registration->toArray();
$sql = "UPDATE " . self::TABLE . "
SET status = ?,
processed_at = ?,
error_message = ?,
retry_count = ?
WHERE id = ?";
$query = SqlQuery::create($sql, [
$data['status'],
$data['processed_at'],
$data['error_message'],
$data['retry_count'],
$registration->id,
]);
$this->connection->execute($query);
return $registration;
}
}

View File

@@ -0,0 +1,25 @@
<?php
declare(strict_types=1);
namespace App\Domain\PreSave;
use App\Framework\Database\EntityManager;
use App\Framework\DI\Initializer;
final readonly class PreSaveRepositoryInitializer
{
#[Initializer]
public function initializeCampaignRepository(
EntityManager $entityManager
): PreSaveCampaignRepository {
return new PreSaveCampaignRepository($entityManager);
}
#[Initializer]
public function initializeRegistrationRepository(
EntityManager $entityManager
): PreSaveRegistrationRepository {
return new PreSaveRegistrationRepository($entityManager);
}
}

View File

@@ -0,0 +1,629 @@
# 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
```php
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
```php
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
```php
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:**
```php
// 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
```php
#[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
```php
#[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:
```php
// 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
```php
- 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
```sql
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
```sql
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:
```php
case NEW_PLATFORM = 'new_platform';
public function isSupported(): bool
{
return match ($this) {
self::NEW_PLATFORM => true, // Enable support
// ...
};
}
```
2. Implement OAuth provider in `/src/Framework/OAuth/Providers/`:
```php
final readonly class NewPlatformProvider implements OAuthProvider
{
public function addTracksToLibrary(OAuthToken $token, array $trackIds): bool
{
// Platform-specific implementation
}
}
```
3. Update `TrackUrl::fromUrl()` to support new URL format:
```php
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
```bash
./vendor/bin/pest tests/Domain/PreSave
```
### Integration Tests
```bash
# 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:
```bash
php console.php schedule:run ProcessReleasedCampaignsJob
```
6. 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
```php
// 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

View File

@@ -0,0 +1,193 @@
<?php
declare(strict_types=1);
namespace App\Domain\PreSave\Services;
use App\Domain\PreSave\PreSaveCampaign;
use App\Domain\PreSaveCampaignRepository;
use App\Domain\PreSave\PreSaveRegistration;
use App\Domain\PreSave\PreSaveRegistrationRepository;
use App\Domain\PreSave\ValueObjects\StreamingPlatform;
use App\Framework\OAuth\OAuthService;
use App\Framework\Exception\FrameworkException;
use App\Framework\Exception\ErrorCode;
/**
* Pre-Save Campaign Service
*
* Business logic for campaign management and registration
*/
final readonly class PreSaveCampaignService
{
public function __construct(
private PreSaveCampaignRepository $campaignRepository,
private PreSaveRegistrationRepository $registrationRepository,
private OAuthService $oauthService,
) {}
/**
* Register user for campaign
*/
public function registerUser(
string $userId,
int $campaignId,
StreamingPlatform $platform
): PreSaveRegistration {
$campaign = $this->campaignRepository->findById($campaignId);
if ($campaign === null) {
throw FrameworkException::create(
ErrorCode::ENTITY_NOT_FOUND,
'Campaign not found'
)->withData(['campaign_id' => $campaignId]);
}
if (!$campaign->acceptsRegistrations()) {
throw FrameworkException::create(
ErrorCode::VAL_BUSINESS_RULE_VIOLATION,
'Campaign is not accepting registrations'
)->withData([
'campaign_id' => $campaignId,
'status' => $campaign->status->value,
]);
}
if (!$campaign->hasPlatform($platform)) {
throw FrameworkException::create(
ErrorCode::VAL_BUSINESS_RULE_VIOLATION,
'Campaign does not support this platform'
)->withData([
'campaign_id' => $campaignId,
'platform' => $platform->value,
]);
}
// Check if already registered
if ($this->registrationRepository->hasRegistered($userId, $campaignId, $platform)) {
throw FrameworkException::create(
ErrorCode::VAL_BUSINESS_RULE_VIOLATION,
'User already registered for this campaign'
)->withData([
'user_id' => $userId,
'campaign_id' => $campaignId,
'platform' => $platform->value,
]);
}
// Verify user has OAuth token for platform
if (!$this->oauthService->hasProvider($userId, $platform->getOAuthProvider())) {
throw FrameworkException::create(
ErrorCode::AUTH_TOKEN_NOT_FOUND,
'User not connected to ' . $platform->getDisplayName()
)->withData([
'user_id' => $userId,
'platform' => $platform->value,
]);
}
$registration = PreSaveRegistration::create($campaignId, $userId, $platform);
return $this->registrationRepository->save($registration);
}
/**
* Cancel user registration
*/
public function cancelRegistration(
string $userId,
int $campaignId,
StreamingPlatform $platform
): bool {
$registration = $this->registrationRepository->findForUserAndCampaign(
$userId,
$campaignId,
$platform
);
if ($registration === null) {
return false;
}
// Can only cancel pending registrations
if (!$registration->status->shouldProcess()) {
throw FrameworkException::create(
ErrorCode::VAL_BUSINESS_RULE_VIOLATION,
'Cannot cancel registration in current status'
)->withData(['status' => $registration->status->value]);
}
return $this->registrationRepository->delete($registration->id);
}
/**
* Get campaign statistics
*
* @return array<string, mixed>
*/
public function getCampaignStats(int $campaignId): array
{
$campaign = $this->campaignRepository->findById($campaignId);
if ($campaign === null) {
throw FrameworkException::create(
ErrorCode::ENTITY_NOT_FOUND,
'Campaign not found'
)->withData(['campaign_id' => $campaignId]);
}
$stats = $this->campaignRepository->getStatistics($campaignId);
$statusCounts = $this->registrationRepository->getStatusCounts($campaignId);
return [
'campaign' => $campaign->toArray(),
'total_registrations' => $stats['total_registrations'],
'completed' => $stats['completed'],
'pending' => $stats['pending'],
'failed' => $stats['failed'],
'status_breakdown' => $statusCounts,
'days_until_release' => $campaign->getDaysUntilRelease(),
];
}
/**
* Get user's registrations
*
* @return array<PreSaveRegistration>
*/
public function getUserRegistrations(string $userId): array
{
// This would need a dedicated query method in repository
// For now, simplified implementation
return [];
}
/**
* Check if user can register for campaign
*/
public function canUserRegister(
string $userId,
int $campaignId,
StreamingPlatform $platform
): bool {
$campaign = $this->campaignRepository->findById($campaignId);
if ($campaign === null || !$campaign->acceptsRegistrations()) {
return false;
}
if (!$campaign->hasPlatform($platform)) {
return false;
}
if ($this->registrationRepository->hasRegistered($userId, $campaignId, $platform)) {
return false;
}
if (!$this->oauthService->hasProvider($userId, $platform->getOAuthProvider())) {
return false;
}
return true;
}
}

View File

@@ -0,0 +1,242 @@
<?php
declare(strict_types=1);
namespace App\Domain\PreSave\Services;
use App\Domain\PreSave\PreSaveCampaign;
use App\Domain\PreSave\PreSaveCampaignRepository;
use App\Domain\PreSave\PreSaveRegistration;
use App\Domain\PreSave\PreSaveRegistrationRepository;
use App\Domain\PreSave\ValueObjects\StreamingPlatform;
use App\Framework\OAuth\OAuthService;
use App\Framework\OAuth\Providers\SpotifyProvider;
use App\Framework\Logging\Logger;
/**
* Pre-Save Processor
*
* Processes pre-save registrations on release day
*/
final readonly class PreSaveProcessor
{
public function __construct(
private PreSaveCampaignRepository $campaignRepository,
private PreSaveRegistrationRepository $registrationRepository,
private OAuthService $oauthService,
private SpotifyProvider $spotifyProvider,
private Logger $logger,
) {}
/**
* Process all campaigns ready for release
*
* @return array<string, mixed>
*/
public function processReleasedCampaigns(): array
{
// Find campaigns that should be released
$campaigns = $this->campaignRepository->findReadyForRelease();
$results = [];
foreach ($campaigns as $campaign) {
// Mark campaign as released
$releasedCampaign = $campaign->markAsReleased();
$this->campaignRepository->save($releasedCampaign);
$results[$campaign->id] = [
'campaign_id' => $campaign->id,
'title' => $campaign->title,
'marked_as_released' => true,
];
}
return [
'campaigns_marked_for_release' => count($campaigns),
'results' => $results,
];
}
/**
* Process all pending registrations for released campaigns
*
* @return array<string, mixed>
*/
public function processPendingRegistrations(): array
{
$campaigns = $this->campaignRepository->findReadyForProcessing();
$totalProcessed = 0;
$totalSuccessful = 0;
$totalFailed = 0;
foreach ($campaigns as $campaign) {
$result = $this->processCampaignRegistrations($campaign);
$totalProcessed += $result['processed'];
$totalSuccessful += $result['successful'];
$totalFailed += $result['failed'];
// Check if all registrations are processed
$pending = $this->registrationRepository->findPendingByCampaign($campaign->id);
if (empty($pending)) {
// Mark campaign as completed
$completedCampaign = $campaign->markAsCompleted();
$this->campaignRepository->save($completedCampaign);
}
}
return [
'campaigns_processed' => count($campaigns),
'total_processed' => $totalProcessed,
'total_successful' => $totalSuccessful,
'total_failed' => $totalFailed,
];
}
/**
* Process registrations for a specific campaign
*
* @return array<string, int>
*/
public function processCampaignRegistrations(PreSaveCampaign $campaign): array
{
$registrations = $this->registrationRepository->findPendingByCampaign($campaign->id);
$processed = 0;
$successful = 0;
$failed = 0;
foreach ($registrations as $registration) {
try {
$this->processRegistration($campaign, $registration);
$successful++;
} catch (\Exception $e) {
$this->logger->error('Failed to process pre-save registration', [
'registration_id' => $registration->id,
'campaign_id' => $campaign->id,
'user_id' => $registration->userId,
'platform' => $registration->platform->value,
'error' => $e->getMessage(),
]);
$failed++;
}
$processed++;
}
return [
'processed' => $processed,
'successful' => $successful,
'failed' => $failed,
];
}
/**
* Process a single registration
*/
private function processRegistration(
PreSaveCampaign $campaign,
PreSaveRegistration $registration
): void {
// Get track URL for platform
$trackUrl = $campaign->getTrackUrl($registration->platform);
if ($trackUrl === null) {
$failedRegistration = $registration->markAsFailed(
'Track URL not found for platform'
);
$this->registrationRepository->save($failedRegistration);
return;
}
try {
// Get OAuth token for user (auto-refreshes if expired)
$storedToken = $this->oauthService->getTokenForUser(
$registration->userId,
$registration->platform->getOAuthProvider()
);
// Process based on platform
match ($registration->platform) {
StreamingPlatform::SPOTIFY => $this->processSpotifyRegistration(
$storedToken->token,
$trackUrl->trackId,
$registration
),
default => throw new \RuntimeException(
'Platform not yet supported: ' . $registration->platform->value
),
};
} catch (\Exception $e) {
$failedRegistration = $registration->markAsFailed($e->getMessage());
$this->registrationRepository->save($failedRegistration);
throw $e;
}
}
/**
* Process Spotify registration
*/
private function processSpotifyRegistration(
\App\Framework\OAuth\ValueObjects\OAuthToken $token,
string $trackId,
PreSaveRegistration $registration
): void {
// Add track to user's library
$this->spotifyProvider->addTracksToLibrary($token, [$trackId]);
// Mark registration as completed
$completedRegistration = $registration->markAsCompleted();
$this->registrationRepository->save($completedRegistration);
$this->logger->info('Pre-save registration processed successfully', [
'registration_id' => $registration->id,
'user_id' => $registration->userId,
'platform' => 'spotify',
'track_id' => $trackId,
]);
}
/**
* Retry failed registrations
*
* @return array<string, int>
*/
public function retryFailedRegistrations(int $campaignId, int $maxRetries = 3): array
{
$campaign = $this->campaignRepository->findById($campaignId);
if ($campaign === null) {
throw new \InvalidArgumentException('Campaign not found');
}
$retryable = $this->registrationRepository->findRetryable($campaignId, $maxRetries);
$processed = 0;
$successful = 0;
foreach ($retryable as $registration) {
$resetRegistration = $registration->resetForRetry();
$this->registrationRepository->save($resetRegistration);
try {
$this->processRegistration($campaign, $resetRegistration);
$successful++;
} catch (\Exception $e) {
// Already logged in processRegistration
}
$processed++;
}
return [
'processed' => $processed,
'successful' => $successful,
'failed' => $processed - $successful,
];
}
}

View File

@@ -0,0 +1,83 @@
<?php
declare(strict_types=1);
namespace App\Domain\PreSave\ValueObjects;
/**
* Campaign Status Enum
*
* Lifecycle states of a pre-save campaign
*/
enum CampaignStatus: string
{
case DRAFT = 'draft';
case SCHEDULED = 'scheduled';
case ACTIVE = 'active';
case RELEASED = 'released';
case COMPLETED = 'completed';
case CANCELLED = 'cancelled';
/**
* Check if campaign accepts new registrations
*/
public function acceptsRegistrations(): bool
{
return match ($this) {
self::SCHEDULED, self::ACTIVE => true,
default => false,
};
}
/**
* Check if campaign should be processed on release day
*/
public function shouldProcess(): bool
{
return match ($this) {
self::RELEASED => true,
default => false,
};
}
/**
* Check if campaign is editable
*/
public function isEditable(): bool
{
return match ($this) {
self::DRAFT, self::SCHEDULED => true,
default => false,
};
}
/**
* Get badge color for UI
*/
public function getBadgeColor(): string
{
return match ($this) {
self::DRAFT => 'gray',
self::SCHEDULED => 'blue',
self::ACTIVE => 'green',
self::RELEASED => 'purple',
self::COMPLETED => 'teal',
self::CANCELLED => 'red',
};
}
/**
* Get display label
*/
public function getLabel(): string
{
return match ($this) {
self::DRAFT => 'Draft',
self::SCHEDULED => 'Scheduled',
self::ACTIVE => 'Active',
self::RELEASED => 'Released',
self::COMPLETED => 'Completed',
self::CANCELLED => 'Cancelled',
};
}
}

View File

@@ -0,0 +1,71 @@
<?php
declare(strict_types=1);
namespace App\Domain\PreSave\ValueObjects;
/**
* Registration Status Enum
*
* Status of individual pre-save registrations
*/
enum RegistrationStatus: string
{
case PENDING = 'pending';
case COMPLETED = 'completed';
case FAILED = 'failed';
case REVOKED = 'revoked';
/**
* Check if registration should be processed
*/
public function shouldProcess(): bool
{
return $this === self::PENDING;
}
/**
* Check if registration is final (cannot be retried)
*/
public function isFinal(): bool
{
return match ($this) {
self::COMPLETED, self::REVOKED => true,
default => false,
};
}
/**
* Check if registration can be retried
*/
public function canRetry(): bool
{
return $this === self::FAILED;
}
/**
* Get badge color for UI
*/
public function getBadgeColor(): string
{
return match ($this) {
self::PENDING => 'yellow',
self::COMPLETED => 'green',
self::FAILED => 'red',
self::REVOKED => 'gray',
};
}
/**
* Get display label
*/
public function getLabel(): string
{
return match ($this) {
self::PENDING => 'Pending',
self::COMPLETED => 'Completed',
self::FAILED => 'Failed',
self::REVOKED => 'Revoked',
};
}
}

View File

@@ -0,0 +1,68 @@
<?php
declare(strict_types=1);
namespace App\Domain\PreSave\ValueObjects;
/**
* Streaming Platform Enum
*
* Supported platforms for pre-save campaigns
*/
enum StreamingPlatform: string
{
case SPOTIFY = 'spotify';
case TIDAL = 'tidal';
case APPLE_MUSIC = 'apple_music';
case DEEZER = 'deezer';
case YOUTUBE_MUSIC = 'youtube_music';
/**
* Get display name
*/
public function getDisplayName(): string
{
return match ($this) {
self::SPOTIFY => 'Spotify',
self::TIDAL => 'Tidal',
self::APPLE_MUSIC => 'Apple Music',
self::DEEZER => 'Deezer',
self::YOUTUBE_MUSIC => 'YouTube Music',
};
}
/**
* Get OAuth provider name (matches OAuthService providers)
*/
public function getOAuthProvider(): string
{
return $this->value;
}
/**
* Check if platform is currently supported
*/
public function isSupported(): bool
{
return match ($this) {
self::SPOTIFY => true,
self::TIDAL => false, // TODO: Implement
self::APPLE_MUSIC => false, // TODO: Implement
self::DEEZER => false, // TODO: Implement
self::YOUTUBE_MUSIC => false, // TODO: Implement
};
}
/**
* Get all supported platforms
*
* @return array<self>
*/
public static function supported(): array
{
return array_filter(
self::cases(),
fn(self $platform) => $platform->isSupported()
);
}
}

View File

@@ -0,0 +1,115 @@
<?php
declare(strict_types=1);
namespace App\Domain\PreSave\ValueObjects;
/**
* Track URL Value Object
*
* Represents a streaming platform track URL with ID extraction
*/
final readonly class TrackUrl
{
public function __construct(
public StreamingPlatform $platform,
public string $url,
public string $trackId,
) {
if (empty($url)) {
throw new \InvalidArgumentException('Track URL cannot be empty');
}
if (empty($trackId)) {
throw new \InvalidArgumentException('Track ID cannot be empty');
}
}
/**
* Create from URL with automatic platform detection and ID extraction
*/
public static function fromUrl(string $url): self
{
if (empty($url)) {
throw new \InvalidArgumentException('URL cannot be empty');
}
// Spotify
if (str_contains($url, 'spotify.com/track/')) {
preg_match('/track\/([a-zA-Z0-9]+)/', $url, $matches);
$trackId = $matches[1] ?? throw new \InvalidArgumentException('Invalid Spotify track URL');
return new self(StreamingPlatform::SPOTIFY, $url, $trackId);
}
// Tidal
if (str_contains($url, 'tidal.com/track/')) {
preg_match('/track\/(\d+)/', $url, $matches);
$trackId = $matches[1] ?? throw new \InvalidArgumentException('Invalid Tidal track URL');
return new self(StreamingPlatform::TIDAL, $url, $trackId);
}
// Apple Music
if (str_contains($url, 'music.apple.com')) {
preg_match('/\/id(\d+)/', $url, $matches);
$trackId = $matches[1] ?? throw new \InvalidArgumentException('Invalid Apple Music track URL');
return new self(StreamingPlatform::APPLE_MUSIC, $url, $trackId);
}
throw new \InvalidArgumentException('Unsupported streaming platform URL');
}
/**
* Create for specific platform with ID
*/
public static function create(StreamingPlatform $platform, string $trackId): self
{
$url = match ($platform) {
StreamingPlatform::SPOTIFY => "https://open.spotify.com/track/{$trackId}",
StreamingPlatform::TIDAL => "https://tidal.com/track/{$trackId}",
StreamingPlatform::APPLE_MUSIC => "https://music.apple.com/track/id{$trackId}",
StreamingPlatform::DEEZER => "https://www.deezer.com/track/{$trackId}",
StreamingPlatform::YOUTUBE_MUSIC => "https://music.youtube.com/watch?v={$trackId}",
};
return new self($platform, $url, $trackId);
}
/**
* Get shareable URL
*/
public function getShareableUrl(): string
{
return $this->url;
}
/**
* Convert to array
*
* @return array<string, string>
*/
public function toArray(): array
{
return [
'platform' => $this->platform->value,
'url' => $this->url,
'track_id' => $this->trackId,
];
}
/**
* Create from stored array
*
* @param array<string, string> $data
*/
public static function fromArray(array $data): self
{
return new self(
platform: StreamingPlatform::from($data['platform']),
url: $data['url'],
trackId: $data['track_id'],
);
}
}