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,600 @@
<?php
declare(strict_types=1);
namespace App\Application\Admin;
use App\Domain\PreSave\PreSaveCampaignRepository;
use App\Domain\PreSave\Services\PreSaveCampaignService;
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\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;
use App\Domain\PreSave\PreSaveCampaign;
use App\Domain\PreSave\ValueObjects\CampaignStatus;
use App\Domain\PreSave\ValueObjects\StreamingPlatform;
use App\Domain\PreSave\ValueObjects\TrackUrl;
use App\Framework\Core\ValueObjects\Timestamp;
use App\Framework\Exception\FrameworkException;
use App\Framework\Exception\ErrorCode;
/**
* 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());
}
}
}