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

@@ -18,7 +18,8 @@ final readonly class CampaignService
{
public function __construct(
private CampaignRepository $repository
) {}
) {
}
public function findBySlug(string $slug): ?Campaign
{

View File

@@ -60,10 +60,10 @@ final readonly class InMemoryCampaignRepository implements CampaignRepository
'preview_url' => null,
'spotify_id' => 'spotify_track_3',
'apple_music_id' => 'apple_track_3',
]
],
],
'status' => 'active',
])
]),
];
}

View File

@@ -4,10 +4,10 @@ declare(strict_types=1);
namespace App\Application\Campaign;
use App\Application\Campaign\Services\SpotifyCampaignService;
use App\Framework\Attributes\Route;
use App\Framework\Http\Method;
use App\Framework\Http\Session\Session;
use App\Application\Campaign\Services\SpotifyCampaignService;
use App\Framework\Router\Result\Redirect;
/**
@@ -20,7 +20,8 @@ final readonly class PreSaveCampaign
public function __construct(
private CampaignService $campaignService,
private SpotifyCampaignService $spotifyCampaignService
) {}
) {
}
#[Route(path: '/campaign/{slug}/presave/{platform}', method: Method::GET)]
public function __invoke(string $slug, string $platform, Session $session): Redirect
@@ -28,7 +29,7 @@ final readonly class PreSaveCampaign
// Fetch campaign
$campaign = $this->campaignService->findBySlug($slug);
if (!$campaign) {
if (! $campaign) {
return Redirect::to('/404')
->withFlash('error', 'Campaign not found');
}
@@ -58,7 +59,7 @@ final readonly class PreSaveCampaign
*/
private function handleSpotifyPreSave(Session $session, object $campaign, string $slug): Redirect
{
if (!$campaign->spotify_enabled || !$campaign->spotify_uri) {
if (! $campaign->spotify_enabled || ! $campaign->spotify_uri) {
return Redirect::to("/campaign/{$slug}")
->withFlash('error', 'Spotify is not enabled for this campaign');
}
@@ -82,7 +83,7 @@ final readonly class PreSaveCampaign
*/
private function handleAppleMusicPreSave(object $campaign, string $slug): Redirect
{
if (!$campaign->apple_music_enabled || !$campaign->apple_music_id) {
if (! $campaign->apple_music_enabled || ! $campaign->apple_music_id) {
return Redirect::to("/campaign/{$slug}")
->withFlash('error', 'Apple Music is not enabled for this campaign');
}
@@ -94,7 +95,7 @@ final readonly class PreSaveCampaign
->withFlash('success', 'Redirecting to Apple Music...')
->withSession([
'presave_platform' => 'Apple Music',
'presave_url' => $appleUrl
'presave_url' => $appleUrl,
]);
}
}

View File

@@ -4,10 +4,10 @@ declare(strict_types=1);
namespace App\Application\Campaign\Services;
use App\Framework\OAuth\Providers\SpotifyProvider;
use App\Framework\OAuth\ValueObjects\OAuthToken;
use App\Application\Campaign\ValueObjects\Campaign;
use App\Framework\Attributes\Singleton;
use App\Framework\OAuth\Providers\SpotifyProvider;
use App\Framework\OAuth\ValueObjects\OAuthToken;
/**
* Spotify Campaign Integration Service
@@ -19,7 +19,8 @@ final readonly class SpotifyCampaignService
{
public function __construct(
private SpotifyProvider $spotifyProvider
) {}
) {
}
/**
* Get Spotify authorization URL for campaign pre-save
@@ -50,14 +51,14 @@ final readonly class SpotifyCampaignService
*/
public function addCampaignToLibrary(OAuthToken $token, Campaign $campaign): bool
{
if (!$campaign->spotify_enabled || empty($campaign->tracks)) {
if (! $campaign->spotify_enabled || empty($campaign->tracks)) {
return false;
}
// Extract Spotify track IDs from campaign tracks
$trackIds = array_filter(
array_map(
fn($track) => $this->extractSpotifyId($track['spotify_uri'] ?? null),
fn ($track) => $this->extractSpotifyId($track['spotify_uri'] ?? null),
$campaign->tracks
)
);
@@ -78,13 +79,13 @@ final readonly class SpotifyCampaignService
*/
public function checkCampaignInLibrary(OAuthToken $token, Campaign $campaign): array
{
if (!$campaign->spotify_enabled || empty($campaign->tracks)) {
if (! $campaign->spotify_enabled || empty($campaign->tracks)) {
return [];
}
$trackIds = array_filter(
array_map(
fn($track) => $this->extractSpotifyId($track['spotify_uri'] ?? null),
fn ($track) => $this->extractSpotifyId($track['spotify_uri'] ?? null),
$campaign->tracks
)
);
@@ -124,12 +125,14 @@ final readonly class SpotifyCampaignService
// Handle spotify:track:xxxx format
if (str_starts_with($uri, 'spotify:')) {
$parts = explode(':', $uri);
return $parts[2] ?? null;
}
// Handle URL format: https://open.spotify.com/track/xxxx
if (str_contains($uri, 'open.spotify.com')) {
$parts = explode('/', $uri);
return end($parts);
}

View File

@@ -6,9 +6,9 @@ namespace App\Application\Campaign;
use App\Framework\Attributes\Route;
use App\Framework\Http\Method;
use App\Framework\Router\Result\ViewResult;
use App\Framework\Http\Session\Session;
use App\Framework\Meta\MetaData;
use App\Framework\Router\Result\ViewResult;
/**
* Show Campaign Landing Page
@@ -19,7 +19,8 @@ final readonly class ShowCampaign
{
public function __construct(
private CampaignService $campaignService
) {}
) {
}
#[Route(path: '/campaign/{slug}', method: Method::GET)]
public function __invoke(string $slug, Session $session): ViewResult
@@ -27,7 +28,7 @@ final readonly class ShowCampaign
// Fetch campaign data
$campaign = $this->campaignService->findBySlug($slug);
if (!$campaign) {
if (! $campaign) {
throw new \RuntimeException("Campaign not found: {$slug}");
}
@@ -50,7 +51,7 @@ final readonly class ShowCampaign
'spotify_enabled' => (bool) $campaign->spotify_enabled,
'apple_music_enabled' => (bool) $campaign->apple_music_enabled,
'tracks' => $campaign->tracks ? array_map(
fn($track) => [
fn ($track) => [
'position' => $track->position,
'title' => $track->title,
'duration' => $track->duration ? $this->formatDuration($track->duration) : null,

View File

@@ -18,7 +18,8 @@ final readonly class ShowSuccess
{
public function __construct(
private CampaignService $campaignService
) {}
) {
}
#[Route(path: '/campaign/{slug}/success', method: Method::GET)]
public function __invoke(string $slug, Request $request): ViewResult
@@ -27,7 +28,7 @@ final readonly class ShowSuccess
// Fetch campaign
$campaign = $this->campaignService->findBySlug($slug);
if (!$campaign) {
if (! $campaign) {
throw new \RuntimeException("Campaign not found: {$slug}");
}

View File

@@ -4,13 +4,12 @@ declare(strict_types=1);
namespace App\Application\Campaign;
use App\Application\Campaign\Services\SpotifyCampaignService;
use App\Framework\Attributes\Route;
use App\Framework\Http\Method;
use App\Framework\Http\Request;
use App\Framework\Http\Responses\Redirect;
use App\Framework\OAuth\Providers\SpotifyProvider;
use App\Application\Campaign\Services\SpotifyCampaignService;
use App\Application\Campaign\CampaignService;
/**
* Spotify OAuth Callback Handler
@@ -23,7 +22,8 @@ final readonly class SpotifyCallbackHandler
private SpotifyProvider $spotifyProvider,
private SpotifyCampaignService $spotifyCampaignService,
private CampaignService $campaignService
) {}
) {
}
#[Route(path: '/campaign/spotify/callback', method: Method::GET)]
public function __invoke(Request $request): Redirect
@@ -41,14 +41,14 @@ final readonly class SpotifyCallbackHandler
// Validate state (CSRF protection)
$expectedState = $request->session->get('spotify_oauth_state');
if (!$state || $state !== $expectedState) {
if (! $state || $state !== $expectedState) {
return Redirect::to('/')
->withFlash('error', 'Invalid OAuth state. Please try again.');
}
// Get campaign slug from session
$campaignSlug = $request->session->get('spotify_campaign_slug');
if (!$campaignSlug) {
if (! $campaignSlug) {
return Redirect::to('/')
->withFlash('error', 'Campaign session expired. Please try again.');
}
@@ -59,7 +59,7 @@ final readonly class SpotifyCallbackHandler
// Get campaign data
$campaign = $this->campaignService->findBySlug($campaignSlug);
if (!$campaign) {
if (! $campaign) {
return Redirect::to('/')
->withFlash('error', 'Campaign not found');
}
@@ -67,7 +67,7 @@ final readonly class SpotifyCallbackHandler
// Add campaign tracks to user's Spotify library
$success = $this->spotifyCampaignService->addCampaignToLibrary($token, $campaign);
if (!$success) {
if (! $success) {
return Redirect::to("/campaign/{$campaignSlug}")
->withFlash('error', 'Failed to add tracks to your library');
}
@@ -113,6 +113,7 @@ final readonly class SpotifyCallbackHandler
if (str_starts_with($uri, 'spotify:')) {
$parts = explode(':', $uri);
return $parts[2] ?? null;
}

View File

@@ -18,7 +18,8 @@ final readonly class SubscribeCampaign
{
public function __construct(
private CampaignService $campaignService
) {}
) {
}
#[Route(path: '/campaign/{slug}/subscribe', method: Method::POST)]
public function __invoke(string $slug, Request $request): Redirect
@@ -27,7 +28,7 @@ final readonly class SubscribeCampaign
// Fetch campaign
$campaign = $this->campaignService->findBySlug($slug);
if (!$campaign) {
if (! $campaign) {
return Redirect::to('/404')
->withFlash('error', 'Campaign not found');
}
@@ -37,7 +38,7 @@ final readonly class SubscribeCampaign
$name = $request->parsedBody->get('name');
$newsletter = $request->parsedBody->get('newsletter') === '1';
if (!$email || !filter_var($email, FILTER_VALIDATE_EMAIL)) {
if (! $email || ! filter_var($email, FILTER_VALIDATE_EMAIL)) {
return Redirect::back()
->withFlash('error', 'Please provide a valid email address')
->withInput($request->parsedBody->toArray());

View File

@@ -32,7 +32,8 @@ final readonly class Campaign
public string $status = 'active',
public ?\DateTimeImmutable $created_at = null,
public ?\DateTimeImmutable $updated_at = null,
) {}
) {
}
public static function fromArray(array $data): self
{
@@ -53,7 +54,7 @@ final readonly class Campaign
spotify_uri: $data['spotify_uri'] ?? null,
apple_music_id: $data['apple_music_id'] ?? null,
tracks: isset($data['tracks']) ? array_map(
fn($track) => CampaignTrack::fromArray($track),
fn ($track) => CampaignTrack::fromArray($track),
$data['tracks']
) : null,
status: $data['status'] ?? 'active',
@@ -95,7 +96,7 @@ final readonly class Campaign
public function hasReleased(): bool
{
if (!$this->release_date) {
if (! $this->release_date) {
return false;
}

View File

@@ -19,7 +19,8 @@ final readonly class CampaignTrack
public ?string $preview_url,
public ?string $spotify_id = null,
public ?string $apple_music_id = null,
) {}
) {
}
public static function fromArray(array $data): self
{