Files
michaelschiemer/src/Application/Admin/PreSaveCampaignAdminController.php
Michael Schiemer fc3d7e6357 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.
2025-10-25 19:18:37 +02:00

601 lines
21 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Application\Admin;
use App\Domain\PreSave\PreSaveCampaign;
use App\Domain\PreSave\PreSaveCampaignRepository;
use App\Domain\PreSave\Services\PreSaveCampaignService;
use App\Domain\PreSave\ValueObjects\CampaignStatus;
use App\Domain\PreSave\ValueObjects\TrackUrl;
use App\Framework\Admin\AdminPageRenderer;
use App\Framework\Admin\Attributes\AdminResource;
use App\Framework\Admin\Factories\AdminFormFactory;
use App\Framework\Admin\Factories\AdminTableFactory;
use App\Framework\Admin\ValueObjects\AdminFormConfig;
use App\Framework\Admin\ValueObjects\AdminTableConfig;
use App\Framework\Attributes\Route;
use App\Framework\Core\ValueObjects\Timestamp;
use App\Framework\Exception\ErrorCode;
use App\Framework\Exception\FrameworkException;
use App\Framework\Http\HttpRequest;
use App\Framework\Http\Method;
use App\Framework\Router\Result\JsonResult;
use App\Framework\Router\Result\Redirect;
use App\Framework\Router\Result\ViewResult;
/**
* Pre-Save Campaign Admin Controller
*
* Admin interface for managing pre-save campaigns
*/
#[AdminResource(
name: 'presave-campaigns',
singularName: 'Pre-Save Campaign',
pluralName: 'Pre-Save Campaigns',
icon: 'music',
enableApi: true,
enableCrud: true
)]
final readonly class PreSaveCampaignAdminController
{
public function __construct(
private AdminPageRenderer $pageRenderer,
private AdminTableFactory $tableFactory,
private AdminFormFactory $formFactory,
private PreSaveCampaignRepository $repository,
private PreSaveCampaignService $service,
) {
}
/**
* List all campaigns
*/
#[Route('/admin/presave-campaigns', Method::GET)]
public function index(): ViewResult
{
$tableConfig = AdminTableConfig::create(
resource: 'presave-campaigns',
columns: [
'id' => [
'label' => 'ID',
'sortable' => true,
'class' => 'text-center',
],
'title' => [
'label' => 'Title',
'sortable' => true,
'searchable' => true,
],
'artist_name' => [
'label' => 'Artist',
'sortable' => true,
'searchable' => true,
],
'release_date' => [
'label' => 'Release Date',
'sortable' => true,
'formatter' => 'date',
],
'status' => [
'label' => 'Status',
'formatter' => 'status',
],
'created_at' => [
'label' => 'Created',
'sortable' => true,
'formatter' => 'date',
],
],
sortable: true,
searchable: true
);
$campaigns = $this->repository->findAll();
$campaignData = array_map(
fn ($campaign) => [
...$campaign->toArray(),
'release_date' => $campaign->releaseDate->format('Y-m-d H:i'),
'created_at' => $campaign->createdAt->format('Y-m-d H:i'),
],
$campaigns
);
$table = $this->tableFactory->create($tableConfig, $campaignData);
return $this->pageRenderer->renderIndex(
resource: 'presave-campaigns',
table: $table,
title: 'Pre-Save Campaigns',
actions: [
[
'url' => '/admin/presave-campaigns/create',
'label' => 'Create Campaign',
'icon' => 'plus',
],
]
);
}
/**
* Show campaign statistics
*/
#[Route('/admin/presave-campaigns/{id}', Method::GET)]
public function show(int $id): ViewResult
{
try {
$campaign = $this->repository->findById($id);
if ($campaign === null) {
throw FrameworkException::create(
ErrorCode::ENTITY_NOT_FOUND,
'Campaign not found'
)->withData(['campaign_id' => $id]);
}
$stats = $this->service->getCampaignStats($id);
return $this->pageRenderer->renderShow(
resource: 'presave-campaigns',
title: $campaign->title,
data: [
'campaign' => $campaign->toArray(),
'stats' => $stats,
]
);
} catch (FrameworkException $e) {
throw $e;
} catch (\Throwable $e) {
throw FrameworkException::create(
ErrorCode::DB_QUERY_FAILED,
'Failed to load campaign'
)->withContext($e->getMessage());
}
}
/**
* API endpoint for campaign list
*/
#[Route('/admin/api/presave-campaigns', Method::GET)]
public function apiList(HttpRequest $request): JsonResult
{
$campaigns = $this->repository->findAll();
$items = array_map(
fn ($campaign) => [
...$campaign->toArray(),
'release_date' => $campaign->releaseDate->format('Y-m-d H:i'),
'created_at' => $campaign->createdAt->format('Y-m-d H:i'),
],
$campaigns
);
return new JsonResult([
'success' => true,
'data' => $items,
'pagination' => [
'page' => 1,
'per_page' => count($items),
'total' => count($items),
'pages' => 1,
],
]);
}
/**
* API endpoint for campaign statistics
*/
#[Route('/admin/api/presave-campaigns/{id}/stats', Method::GET)]
public function apiStats(int $id): JsonResult
{
try {
$stats = $this->service->getCampaignStats($id);
return new JsonResult([
'success' => true,
'data' => $stats,
]);
} catch (\Exception $e) {
return new JsonResult([
'success' => false,
'error' => $e->getMessage(),
], 404);
}
}
/**
* Show create campaign form
*/
#[Route('/admin/presave-campaigns/create', Method::GET)]
public function create(): ViewResult
{
$formConfig = AdminFormConfig::create(
resource: 'presave-campaigns',
action: '/admin/presave-campaigns',
method: Method::POST,
fields: [
'title' => [
'type' => 'text',
'label' => 'Campaign Title',
'required' => true,
'placeholder' => 'e.g., New Album Release',
'help' => 'Name of the album or single',
],
'artist_name' => [
'type' => 'text',
'label' => 'Artist Name',
'required' => true,
'placeholder' => 'e.g., The Artist',
],
'cover_image_url' => [
'type' => 'text',
'label' => 'Cover Image URL',
'required' => true,
'placeholder' => 'https://...',
'help' => 'URL to album/single artwork',
],
'description' => [
'type' => 'textarea',
'label' => 'Description',
'placeholder' => 'Tell fans about this release...',
'help' => 'Optional description shown on the pre-save page',
],
'release_date' => [
'type' => 'text',
'label' => 'Release Date',
'required' => true,
'placeholder' => 'YYYY-MM-DD HH:MM',
'help' => 'When the track/album will be released',
],
'start_date' => [
'type' => 'text',
'label' => 'Campaign Start Date',
'placeholder' => 'YYYY-MM-DD HH:MM (optional)',
'help' => 'When to start accepting pre-saves (optional, defaults to now)',
],
'track_url_spotify' => [
'type' => 'text',
'label' => 'Spotify Track URL',
'placeholder' => 'https://open.spotify.com/track/...',
'help' => 'Spotify track link for pre-save',
],
'track_url_tidal' => [
'type' => 'text',
'label' => 'Tidal Track URL (optional)',
'placeholder' => 'https://tidal.com/browse/track/...',
],
'track_url_apple_music' => [
'type' => 'text',
'label' => 'Apple Music Track URL (optional)',
'placeholder' => 'https://music.apple.com/...',
],
]
);
$form = $this->formFactory->create($formConfig);
return $this->pageRenderer->renderForm(
resource: 'presave-campaigns',
form: $form,
title: 'Create Pre-Save Campaign',
backUrl: '/admin/presave-campaigns'
);
}
/**
* Store new campaign
*/
#[Route('/admin/presave-campaigns', Method::POST)]
public function store(HttpRequest $request): Redirect
{
$data = $request->parsedBody->toArray();
// Parse track URLs
$trackUrls = [];
if (! empty($data['track_url_spotify'])) {
$trackUrls[] = TrackUrl::fromUrl($data['track_url_spotify']);
}
if (! empty($data['track_url_tidal'])) {
$trackUrls[] = TrackUrl::fromUrl($data['track_url_tidal']);
}
if (! empty($data['track_url_apple_music'])) {
$trackUrls[] = TrackUrl::fromUrl($data['track_url_apple_music']);
}
try {
// Validate at least one track URL
if (empty($trackUrls)) {
throw FrameworkException::create(
ErrorCode::VAL_REQUIRED_FIELD_MISSING,
'At least one track URL is required'
);
}
// Create campaign entity
$campaign = PreSaveCampaign::create(
title: $data['title'],
artistName: $data['artist_name'],
coverImageUrl: $data['cover_image_url'],
releaseDate: Timestamp::fromDateTime(new \DateTimeImmutable($data['release_date'])),
trackUrls: $trackUrls,
description: ! empty($data['description']) ? $data['description'] : null,
startDate: ! empty($data['start_date']) ? Timestamp::fromDateTime(new \DateTimeImmutable($data['start_date'])) : null
);
// Save campaign
$savedCampaign = $this->repository->save($campaign);
return new Redirect('/admin/presave-campaigns/' . $savedCampaign->id);
} catch (FrameworkException $e) {
throw $e;
} catch (\Throwable $e) {
throw FrameworkException::create(
ErrorCode::DB_QUERY_FAILED,
'Failed to create campaign'
)->withContext($e->getMessage());
}
}
/**
* Show edit campaign form
*/
#[Route('/admin/presave-campaigns/{id}/edit', Method::GET)]
public function edit(int $id): ViewResult
{
$campaign = $this->repository->findById($id);
if ($campaign === null) {
throw FrameworkException::create(
ErrorCode::ENTITY_NOT_FOUND,
'Campaign not found'
)->withData(['campaign_id' => $id]);
}
// Extract track URLs by platform
$trackUrlData = [];
foreach ($campaign->trackUrls as $trackUrl) {
$trackUrlData['track_url_' . $trackUrl->platform->value] = $trackUrl->url;
}
$formConfig = AdminFormConfig::create(
resource: 'presave-campaigns',
action: '/admin/presave-campaigns/' . $id,
method: Method::POST,
fields: [
'_method' => [
'type' => 'hidden',
],
'title' => [
'type' => 'text',
'label' => 'Campaign Title',
'required' => true,
],
'artist_name' => [
'type' => 'text',
'label' => 'Artist Name',
'required' => true,
],
'cover_image_url' => [
'type' => 'text',
'label' => 'Cover Image URL',
'required' => true,
],
'description' => [
'type' => 'textarea',
'label' => 'Description',
],
'release_date' => [
'type' => 'text',
'label' => 'Release Date',
'required' => true,
],
'start_date' => [
'type' => 'text',
'label' => 'Campaign Start Date',
],
'track_url_spotify' => [
'type' => 'text',
'label' => 'Spotify Track URL',
],
'track_url_tidal' => [
'type' => 'text',
'label' => 'Tidal Track URL',
],
'track_url_apple_music' => [
'type' => 'text',
'label' => 'Apple Music Track URL',
],
'status' => [
'type' => 'select',
'label' => 'Status',
'required' => true,
'options' => [
CampaignStatus::DRAFT->value => 'Draft',
CampaignStatus::SCHEDULED->value => 'Scheduled',
CampaignStatus::ACTIVE->value => 'Active',
CampaignStatus::CANCELLED->value => 'Cancelled',
],
'help' => 'Campaign lifecycle status',
],
]
)->withData([
'_method' => 'PUT',
'title' => $campaign->title,
'artist_name' => $campaign->artistName,
'cover_image_url' => $campaign->coverImageUrl,
'description' => $campaign->description ?? '',
'release_date' => $campaign->releaseDate->format('Y-m-d H:i'),
'start_date' => $campaign->startDate?->format('Y-m-d H:i') ?? '',
'status' => $campaign->status->value,
...$trackUrlData,
]);
$form = $this->formFactory->create($formConfig);
return $this->pageRenderer->renderForm(
resource: 'presave-campaigns',
form: $form,
title: 'Edit Campaign: ' . $campaign->title,
backUrl: '/admin/presave-campaigns'
);
}
/**
* Update existing campaign
*/
#[Route('/admin/presave-campaigns/{id}', Method::POST)]
public function update(int $id, HttpRequest $request): Redirect
{
try {
$campaign = $this->repository->findById($id);
if ($campaign === null) {
throw FrameworkException::create(
ErrorCode::ENTITY_NOT_FOUND,
'Campaign not found'
)->withData(['campaign_id' => $id]);
}
$data = $request->parsedBody->toArray();
// Parse track URLs
$trackUrls = [];
if (! empty($data['track_url_spotify'])) {
$trackUrls[] = TrackUrl::fromUrl($data['track_url_spotify']);
}
if (! empty($data['track_url_tidal'])) {
$trackUrls[] = TrackUrl::fromUrl($data['track_url_tidal']);
}
if (! empty($data['track_url_apple_music'])) {
$trackUrls[] = TrackUrl::fromUrl($data['track_url_apple_music']);
}
// Validate at least one track URL
if (empty($trackUrls)) {
throw FrameworkException::create(
ErrorCode::VAL_REQUIRED_FIELD_MISSING,
'At least one track URL is required'
);
}
// Update campaign - create new instance with updated values
$updatedCampaign = new PreSaveCampaign(
id: $campaign->id,
title: $data['title'],
artistName: $data['artist_name'],
coverImageUrl: $data['cover_image_url'],
releaseDate: Timestamp::fromDateTime(new \DateTimeImmutable($data['release_date'])),
trackUrls: $trackUrls,
status: CampaignStatus::from($data['status']),
createdAt: $campaign->createdAt,
updatedAt: Timestamp::now(),
description: ! empty($data['description']) ? $data['description'] : null,
startDate: ! empty($data['start_date']) ? Timestamp::fromDateTime(new \DateTimeImmutable($data['start_date'])) : null
);
$this->repository->save($updatedCampaign);
return new Redirect('/admin/presave-campaigns/' . $id);
} catch (FrameworkException $e) {
throw $e;
} catch (\Throwable $e) {
throw FrameworkException::create(
ErrorCode::DB_QUERY_FAILED,
'Failed to update campaign'
)->withContext($e->getMessage());
}
}
/**
* Delete campaign
*/
#[Route('/admin/presave-campaigns/{id}/delete', Method::POST)]
public function destroy(int $id): Redirect
{
$this->repository->delete($id);
return new Redirect('/admin/presave-campaigns');
}
/**
* Publish campaign (custom action)
*/
#[Route('/admin/presave-campaigns/{id}/publish', Method::POST)]
public function publish(int $id): Redirect
{
try {
$campaign = $this->repository->findById($id);
if ($campaign === null) {
throw FrameworkException::create(
ErrorCode::ENTITY_NOT_FOUND,
'Campaign not found'
)->withData(['campaign_id' => $id]);
}
if ($campaign->status !== CampaignStatus::DRAFT) {
throw FrameworkException::create(
ErrorCode::VAL_BUSINESS_RULE_VIOLATION,
'Only draft campaigns can be published'
)->withData([
'campaign_id' => $id,
'current_status' => $campaign->status->value,
]);
}
$publishedCampaign = $campaign->publish();
$this->repository->save($publishedCampaign);
return new Redirect('/admin/presave-campaigns/' . $id);
} catch (FrameworkException $e) {
throw $e;
} catch (\Throwable $e) {
throw FrameworkException::create(
ErrorCode::DB_QUERY_FAILED,
'Failed to publish campaign'
)->withContext($e->getMessage());
}
}
/**
* Cancel campaign (custom action)
*/
#[Route('/admin/presave-campaigns/{id}/cancel', Method::POST)]
public function cancel(int $id): Redirect
{
try {
$campaign = $this->repository->findById($id);
if ($campaign === null) {
throw FrameworkException::create(
ErrorCode::ENTITY_NOT_FOUND,
'Campaign not found'
)->withData(['campaign_id' => $id]);
}
if (! in_array($campaign->status, [CampaignStatus::DRAFT, CampaignStatus::SCHEDULED, CampaignStatus::ACTIVE], true)) {
throw FrameworkException::create(
ErrorCode::VAL_BUSINESS_RULE_VIOLATION,
'Campaign cannot be cancelled in current status'
)->withData([
'campaign_id' => $id,
'current_status' => $campaign->status->value,
]);
}
$cancelledCampaign = $campaign->cancel();
$this->repository->save($cancelledCampaign);
return new Redirect('/admin/presave-campaigns/' . $id);
} catch (FrameworkException $e) {
throw $e;
} catch (\Throwable $e) {
throw FrameworkException::create(
ErrorCode::DB_QUERY_FAILED,
'Failed to cancel campaign'
)->withContext($e->getMessage());
}
}
}