- 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.
601 lines
21 KiB
PHP
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());
|
|
}
|
|
}
|
|
}
|