feat(Production): Complete production deployment infrastructure

- Add comprehensive health check system with multiple endpoints
- Add Prometheus metrics endpoint
- Add production logging configurations (5 strategies)
- Add complete deployment documentation suite:
  * QUICKSTART.md - 30-minute deployment guide
  * DEPLOYMENT_CHECKLIST.md - Printable verification checklist
  * DEPLOYMENT_WORKFLOW.md - Complete deployment lifecycle
  * PRODUCTION_DEPLOYMENT.md - Comprehensive technical reference
  * production-logging.md - Logging configuration guide
  * ANSIBLE_DEPLOYMENT.md - Infrastructure as Code automation
  * README.md - Navigation hub
  * DEPLOYMENT_SUMMARY.md - Executive summary
- Add deployment scripts and automation
- Add DEPLOYMENT_PLAN.md - Concrete plan for immediate deployment
- Update README with production-ready features

All production infrastructure is now complete and ready for deployment.
This commit is contained in:
2025-10-25 19:18:37 +02:00
parent caa85db796
commit fc3d7e6357
83016 changed files with 378904 additions and 20919 deletions

View File

@@ -5,8 +5,8 @@ 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;
use App\Framework\Worker\Schedule;
/**
* Process Released Campaigns Job
@@ -18,7 +18,8 @@ final readonly class ProcessReleasedCampaignsJob
{
public function __construct(
private PreSaveProcessor $processor,
) {}
) {
}
/**
* @return array<string, mixed>

View File

@@ -7,8 +7,8 @@ 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;
use App\Framework\Worker\Schedule;
/**
* Retry Failed Registrations Job
@@ -21,7 +21,8 @@ final readonly class RetryFailedRegistrationsJob
public function __construct(
private PreSaveProcessor $processor,
private PreSaveCampaignRepository $campaignRepository,
) {}
) {
}
/**
* @return array<string, mixed>

View File

@@ -73,7 +73,7 @@ final readonly class PreSaveCampaign
*/
public function publish(): self
{
if (!$this->status->isEditable()) {
if (! $this->status->isEditable()) {
throw new \RuntimeException('Cannot publish campaign in current status: ' . $this->status->value);
}
@@ -203,7 +203,7 @@ final readonly class PreSaveCampaign
public function getDaysUntilRelease(): int
{
$now = Timestamp::now();
$diff = $this->releaseDate->getTimestamp() - $now->getTimestamp();
$diff = $this->releaseDate->toTimestamp() - $now->toTimestamp();
return (int) ceil($diff / 86400);
}
@@ -223,7 +223,7 @@ final readonly class PreSaveCampaign
'description' => $this->description,
'release_date' => $this->releaseDate->toTimestamp(),
'start_date' => $this->startDate?->toTimestamp(),
'track_urls' => array_map(fn($url) => $url->toArray(), $this->trackUrls),
'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'),
@@ -248,7 +248,7 @@ final readonly class PreSaveCampaign
coverImageUrl: (string) $row['cover_image_url'],
releaseDate: Timestamp::fromFloat((float) $row['release_date']),
trackUrls: array_map(
fn(array $data) => TrackUrl::fromArray($data),
fn (array $data) => TrackUrl::fromArray($data),
$trackUrls
),
status: CampaignStatus::from($row['status']),

View File

@@ -5,20 +5,21 @@ declare(strict_types=1);
namespace App\Domain\PreSave;
use App\Domain\PreSave\ValueObjects\CampaignStatus;
use App\Framework\Core\ValueObjects\Timestamp;
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
final readonly class PreSaveCampaignRepository implements PreSaveCampaignRepositoryInterface
{
private const TABLE = 'presave_campaigns';
public function __construct(
private ConnectionInterface $connection,
) {}
) {
}
/**
* Find campaign by ID
@@ -56,7 +57,7 @@ final readonly class PreSaveCampaignRepository
$rows = $result->fetchAll();
return array_map(
fn(array $row) => PreSaveCampaign::fromArray($row),
fn (array $row) => PreSaveCampaign::fromArray($row),
$rows
);
}
@@ -84,7 +85,7 @@ final readonly class PreSaveCampaignRepository
$rows = $result->fetchAll();
return array_map(
fn(array $row) => PreSaveCampaign::fromArray($row),
fn (array $row) => PreSaveCampaign::fromArray($row),
$rows
);
}
@@ -105,7 +106,7 @@ final readonly class PreSaveCampaignRepository
$rows = $result->fetchAll();
return array_map(
fn(array $row) => PreSaveCampaign::fromArray($row),
fn (array $row) => PreSaveCampaign::fromArray($row),
$rows
);
}

View File

@@ -0,0 +1,52 @@
<?php
declare(strict_types=1);
namespace App\Domain\PreSave;
interface PreSaveCampaignRepositoryInterface
{
/**
* Find campaign by ID
*/
public function findById(int $id): ?PreSaveCampaign;
/**
* Find all campaigns
*
* @param array<string, mixed> $filters
* @return array<PreSaveCampaign>
*/
public function findAll(array $filters = []): array;
/**
* Find campaigns ready for release
*
* @return array<PreSaveCampaign>
*/
public function findReadyForRelease(): array;
/**
* Find campaigns ready for processing
*
* @return array<PreSaveCampaign>
*/
public function findReadyForProcessing(): array;
/**
* Save or update campaign
*/
public function save(PreSaveCampaign $campaign): PreSaveCampaign;
/**
* Delete campaign
*/
public function delete(int $id): bool;
/**
* Get campaign statistics
*
* @return array<string, int>
*/
public function getStatistics(int $campaignId): array;
}

View File

@@ -25,7 +25,8 @@ final readonly class PreSaveRegistration
public ?Timestamp $processedAt = null,
public ?string $errorMessage = null,
public int $retryCount = 0,
) {}
) {
}
/**
* Create new registration
@@ -104,7 +105,7 @@ final readonly class PreSaveRegistration
*/
public function resetForRetry(): self
{
if (!$this->status->canRetry()) {
if (! $this->status->canRetry()) {
throw new \RuntimeException('Cannot retry registration in status: ' . $this->status->value);
}

View File

@@ -12,13 +12,14 @@ use App\Framework\Database\ValueObjects\SqlQuery;
/**
* Pre-Save Registration Repository
*/
final readonly class PreSaveRegistrationRepository
final readonly class PreSaveRegistrationRepository implements PreSaveRegistrationRepositoryInterface
{
private const TABLE = 'presave_registrations';
public function __construct(
private ConnectionInterface $connection,
) {}
) {
}
/**
* Find registration by ID
@@ -52,6 +53,26 @@ final readonly class PreSaveRegistrationRepository
return $row ? PreSaveRegistration::fromArray($row) : null;
}
/**
* Find all registrations for user
*
* @return array<PreSaveRegistration>
*/
public function findByUserId(string $userId): array
{
$query = SqlQuery::select(table: self::TABLE)
->where('user_id = ?', [$userId])
->orderBy('registered_at DESC');
$result = $this->connection->query($query);
$rows = $result->fetchAll();
return array_map(
fn (array $row) => PreSaveRegistration::fromArray($row),
$rows
);
}
/**
* Find all registrations for campaign
*
@@ -59,16 +80,15 @@ final readonly class PreSaveRegistrationRepository
*/
public function findByCampaign(int $campaignId): array
{
$sql = "SELECT * FROM " . self::TABLE . "
WHERE campaign_id = ?
ORDER BY registered_at DESC";
$query = SqlQuery::select(self::TABLE)
->where('campaign_id = ?', [$campaignId])
->orderBy('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),
fn (array $row) => PreSaveRegistration::fromArray($row),
$rows
);
}
@@ -92,7 +112,7 @@ final readonly class PreSaveRegistrationRepository
$rows = $result->fetchAll();
return array_map(
fn(array $row) => PreSaveRegistration::fromArray($row),
fn (array $row) => PreSaveRegistration::fromArray($row),
$rows
);
}
@@ -119,7 +139,7 @@ final readonly class PreSaveRegistrationRepository
$rows = $result->fetchAll();
return array_map(
fn(array $row) => PreSaveRegistration::fromArray($row),
fn (array $row) => PreSaveRegistration::fromArray($row),
$rows
);
}

View File

@@ -0,0 +1,57 @@
<?php
declare(strict_types=1);
namespace App\Domain\PreSave;
use App\Domain\PreSave\ValueObjects\StreamingPlatform;
interface PreSaveRegistrationRepositoryInterface
{
/**
* Save or update registration
*/
public function save(PreSaveRegistration $registration): PreSaveRegistration;
/**
* Find registration for user and campaign
*/
public function findForUserAndCampaign(
string $userId,
int $campaignId,
StreamingPlatform $platform
): ?PreSaveRegistration;
/**
* Find all registrations for a user
*
* @return array<PreSaveRegistration>
*/
public function findByUserId(string $userId): array;
/**
* Find pending registrations for a campaign
*
* @return array<PreSaveRegistration>
*/
public function findPendingByCampaign(int $campaignId): array;
/**
* Find retryable failed registrations
*
* @return array<PreSaveRegistration>
*/
public function findRetryable(int $campaignId, int $maxRetries): array;
/**
* Get status counts for a campaign
*
* @return array<string, int>
*/
public function getStatusCounts(int $campaignId): array;
/**
* Delete registration
*/
public function delete(int $id): bool;
}

View File

@@ -4,14 +4,13 @@ declare(strict_types=1);
namespace App\Domain\PreSave\Services;
use App\Domain\PreSave\PreSaveCampaign;
use App\Domain\PreSaveCampaignRepository;
use App\Domain\PreSave\PreSaveCampaignRepositoryInterface;
use App\Domain\PreSave\PreSaveRegistration;
use App\Domain\PreSave\PreSaveRegistrationRepository;
use App\Domain\PreSave\PreSaveRegistrationRepositoryInterface;
use App\Domain\PreSave\ValueObjects\StreamingPlatform;
use App\Framework\OAuth\OAuthService;
use App\Framework\Exception\FrameworkException;
use App\Framework\Exception\ErrorCode;
use App\Framework\Exception\FrameworkException;
use App\Framework\OAuth\OAuthServiceInterface;
/**
* Pre-Save Campaign Service
@@ -21,10 +20,11 @@ use App\Framework\Exception\ErrorCode;
final readonly class PreSaveCampaignService
{
public function __construct(
private PreSaveCampaignRepository $campaignRepository,
private PreSaveRegistrationRepository $registrationRepository,
private OAuthService $oauthService,
) {}
private PreSaveCampaignRepositoryInterface $campaignRepository,
private PreSaveRegistrationRepositoryInterface $registrationRepository,
private OAuthServiceInterface $oauthService,
) {
}
/**
* Register user for campaign
@@ -43,7 +43,7 @@ final readonly class PreSaveCampaignService
)->withData(['campaign_id' => $campaignId]);
}
if (!$campaign->acceptsRegistrations()) {
if (! $campaign->acceptsRegistrations()) {
throw FrameworkException::create(
ErrorCode::VAL_BUSINESS_RULE_VIOLATION,
'Campaign is not accepting registrations'
@@ -53,7 +53,7 @@ final readonly class PreSaveCampaignService
]);
}
if (!$campaign->hasPlatform($platform)) {
if (! $campaign->hasPlatform($platform)) {
throw FrameworkException::create(
ErrorCode::VAL_BUSINESS_RULE_VIOLATION,
'Campaign does not support this platform'
@@ -76,7 +76,7 @@ final readonly class PreSaveCampaignService
}
// Verify user has OAuth token for platform
if (!$this->oauthService->hasProvider($userId, $platform->getOAuthProvider())) {
if (! $this->oauthService->hasProvider($userId, $platform->getOAuthProvider())) {
throw FrameworkException::create(
ErrorCode::AUTH_TOKEN_NOT_FOUND,
'User not connected to ' . $platform->getDisplayName()
@@ -110,7 +110,7 @@ final readonly class PreSaveCampaignService
}
// Can only cancel pending registrations
if (!$registration->status->shouldProcess()) {
if (! $registration->status->shouldProcess()) {
throw FrameworkException::create(
ErrorCode::VAL_BUSINESS_RULE_VIOLATION,
'Cannot cancel registration in current status'
@@ -172,11 +172,11 @@ final readonly class PreSaveCampaignService
): bool {
$campaign = $this->campaignRepository->findById($campaignId);
if ($campaign === null || !$campaign->acceptsRegistrations()) {
if ($campaign === null || ! $campaign->acceptsRegistrations()) {
return false;
}
if (!$campaign->hasPlatform($platform)) {
if (! $campaign->hasPlatform($platform)) {
return false;
}
@@ -184,7 +184,7 @@ final readonly class PreSaveCampaignService
return false;
}
if (!$this->oauthService->hasProvider($userId, $platform->getOAuthProvider())) {
if (! $this->oauthService->hasProvider($userId, $platform->getOAuthProvider())) {
return false;
}

View File

@@ -5,13 +5,15 @@ declare(strict_types=1);
namespace App\Domain\PreSave\Services;
use App\Domain\PreSave\PreSaveCampaign;
use App\Domain\PreSave\PreSaveCampaignRepository;
use App\Domain\PreSave\PreSaveCampaignRepositoryInterface;
use App\Domain\PreSave\PreSaveRegistration;
use App\Domain\PreSave\PreSaveRegistrationRepository;
use App\Domain\PreSave\PreSaveRegistrationRepositoryInterface;
use App\Domain\PreSave\ValueObjects\StreamingPlatform;
use App\Framework\OAuth\OAuthService;
use App\Framework\OAuth\Providers\SpotifyProvider;
use App\Framework\Logging\Logger;
use App\Framework\Logging\ValueObjects\LogContext;
use App\Framework\OAuth\OAuthServiceInterface;
use App\Framework\OAuth\Providers\SupportsPreSaves;
use App\Framework\OAuth\ValueObjects\OAuthToken;
/**
* Pre-Save Processor
@@ -21,12 +23,13 @@ use App\Framework\Logging\Logger;
final readonly class PreSaveProcessor
{
public function __construct(
private PreSaveCampaignRepository $campaignRepository,
private PreSaveRegistrationRepository $registrationRepository,
private OAuthService $oauthService,
private SpotifyProvider $spotifyProvider,
private PreSaveCampaignRepositoryInterface $campaignRepository,
private PreSaveRegistrationRepositoryInterface $registrationRepository,
private OAuthServiceInterface $oauthService,
private SupportsPreSaves $musicProvider,
private Logger $logger,
) {}
) {
}
/**
* Process all campaigns ready for release
@@ -114,13 +117,13 @@ final readonly class PreSaveProcessor
$this->processRegistration($campaign, $registration);
$successful++;
} catch (\Exception $e) {
$this->logger->error('Failed to process pre-save registration', [
$this->logger->error('Failed to process pre-save registration', LogContext::withData([
'registration_id' => $registration->id,
'campaign_id' => $campaign->id,
'user_id' => $registration->userId,
'platform' => $registration->platform->value,
'error' => $e->getMessage(),
]);
]));
$failed++;
}
@@ -149,6 +152,7 @@ final readonly class PreSaveProcessor
'Track URL not found for platform'
);
$this->registrationRepository->save($failedRegistration);
return;
}
@@ -161,7 +165,7 @@ final readonly class PreSaveProcessor
// Process based on platform
match ($registration->platform) {
StreamingPlatform::SPOTIFY => $this->processSpotifyRegistration(
StreamingPlatform::SPOTIFY => $this->processMusicPlatformRegistration(
$storedToken->token,
$trackUrl->trackId,
$registration
@@ -179,26 +183,26 @@ final readonly class PreSaveProcessor
}
/**
* Process Spotify registration
* Process music platform registration (Spotify, Apple Music, etc.)
*/
private function processSpotifyRegistration(
\App\Framework\OAuth\ValueObjects\OAuthToken $token,
private function processMusicPlatformRegistration(
OAuthToken $token,
string $trackId,
PreSaveRegistration $registration
): void {
// Add track to user's library
$this->spotifyProvider->addTracksToLibrary($token, [$trackId]);
$this->musicProvider->addTracksToLibrary($token, [$trackId]);
// Mark registration as completed
$completedRegistration = $registration->markAsCompleted();
$this->registrationRepository->save($completedRegistration);
$this->logger->info('Pre-save registration processed successfully', [
$this->logger->info('Pre-save registration processed successfully', LogContext::withData([
'registration_id' => $registration->id,
'user_id' => $registration->userId,
'platform' => 'spotify',
'platform' => $registration->platform->value,
'track_id' => $trackId,
]);
]));
}
/**

View File

@@ -62,7 +62,7 @@ enum StreamingPlatform: string
{
return array_filter(
self::cases(),
fn(self $platform) => $platform->isSupported()
fn (self $platform) => $platform->isSupported()
);
}
}