- 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
348 lines
12 KiB
PHP
348 lines
12 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Application\Api;
|
|
|
|
use App\Framework\Attributes\Route;
|
|
use App\Framework\Core\Method;
|
|
use App\Framework\Http\HttpRequest;
|
|
use App\Framework\Http\JsonResult;
|
|
use App\Domain\PreSave\PreSaveCampaignRepository;
|
|
use App\Domain\PreSave\PreSaveRegistrationRepository;
|
|
use App\Domain\PreSave\PreSaveRegistration;
|
|
use App\Domain\PreSave\ValueObjects\StreamingPlatform;
|
|
use App\Framework\OAuth\Storage\OAuthTokenRepository;
|
|
use App\Framework\Exception\FrameworkException;
|
|
use App\Framework\Exception\ErrorCode;
|
|
|
|
/**
|
|
* Pre-Save Campaign API Controller
|
|
*/
|
|
final readonly class PreSaveApiController
|
|
{
|
|
public function __construct(
|
|
private PreSaveCampaignRepository $campaignRepository,
|
|
private PreSaveRegistrationRepository $registrationRepository,
|
|
private OAuthTokenRepository $tokenRepository
|
|
) {}
|
|
|
|
/**
|
|
* Get active campaigns
|
|
*/
|
|
#[Route(path: '/api/presave/campaigns', method: Method::GET)]
|
|
public function getCampaigns(HttpRequest $request): JsonResult
|
|
{
|
|
try {
|
|
$campaigns = $this->campaignRepository->findAll(['status' => 'active']);
|
|
|
|
return new JsonResult([
|
|
'success' => true,
|
|
'data' => array_map(fn($campaign) => [
|
|
'id' => $campaign->id,
|
|
'title' => $campaign->title,
|
|
'artist_name' => $campaign->artistName,
|
|
'cover_image_url' => $campaign->coverImageUrl,
|
|
'description' => $campaign->description,
|
|
'release_date' => $campaign->releaseDate->toTimestamp(),
|
|
'status' => $campaign->status->value,
|
|
'platforms' => array_map(fn($url) => $url->platform->value, $campaign->trackUrls)
|
|
], $campaigns)
|
|
]);
|
|
} catch (\Throwable $e) {
|
|
throw FrameworkException::create(
|
|
ErrorCode::DB_QUERY_FAILED,
|
|
'Failed to fetch campaigns'
|
|
)->withContext(
|
|
$e->getMessage()
|
|
);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get campaign by ID
|
|
*/
|
|
#[Route(path: '/api/presave/campaigns/{id}', method: Method::GET)]
|
|
public function getCampaign(HttpRequest $request): JsonResult
|
|
{
|
|
$campaignId = $request->routeParams->get('id');
|
|
|
|
// Validate ID is numeric
|
|
if (!is_numeric($campaignId)) {
|
|
throw FrameworkException::create(
|
|
ErrorCode::VAL_INVALID_VALUE,
|
|
'Campaign ID must be numeric'
|
|
)->withData(['provided' => $campaignId]);
|
|
}
|
|
|
|
try {
|
|
$campaign = $this->campaignRepository->findById((int) $campaignId);
|
|
|
|
if ($campaign === null) {
|
|
return new JsonResult([
|
|
'success' => false,
|
|
'message' => 'Campaign not found'
|
|
], 404);
|
|
}
|
|
|
|
return new JsonResult([
|
|
'success' => true,
|
|
'data' => [
|
|
'id' => $campaign->id,
|
|
'title' => $campaign->title,
|
|
'artist_name' => $campaign->artistName,
|
|
'cover_image_url' => $campaign->coverImageUrl,
|
|
'description' => $campaign->description,
|
|
'release_date' => $campaign->releaseDate->toTimestamp(),
|
|
'status' => $campaign->status->value,
|
|
'platforms' => array_map(fn($url) => $url->platform->value, $campaign->trackUrls),
|
|
'track_urls' => array_map(fn($url) => $url->toArray(), $campaign->trackUrls)
|
|
]
|
|
]);
|
|
} catch (\Throwable $e) {
|
|
throw FrameworkException::create(
|
|
ErrorCode::DB_QUERY_FAILED,
|
|
'Failed to fetch campaign'
|
|
)->withContext($e->getMessage());
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Register for pre-save campaign
|
|
*/
|
|
#[Route(path: '/api/presave/campaigns/{id}/register', method: Method::POST)]
|
|
public function register(HttpRequest $request): JsonResult
|
|
{
|
|
$campaignId = $request->routeParams->get('id');
|
|
|
|
// Validate ID is numeric
|
|
if (!is_numeric($campaignId)) {
|
|
throw FrameworkException::create(
|
|
ErrorCode::VAL_INVALID_VALUE,
|
|
'Campaign ID must be numeric'
|
|
)->withData(['provided' => $campaignId]);
|
|
}
|
|
|
|
$data = $request->parsedBody->toArray();
|
|
|
|
// Validate campaign exists
|
|
$campaign = $this->campaignRepository->findById((int) $campaignId);
|
|
if ($campaign === null) {
|
|
return new JsonResult([
|
|
'success' => false,
|
|
'message' => 'Campaign not found'
|
|
], 404);
|
|
}
|
|
|
|
// Validate required fields
|
|
if (empty($data['user_id'])) {
|
|
throw FrameworkException::create(
|
|
ErrorCode::VAL_REQUIRED_FIELD_MISSING,
|
|
'user_id is required'
|
|
);
|
|
}
|
|
|
|
if (empty($data['platform'])) {
|
|
throw FrameworkException::create(
|
|
ErrorCode::VAL_REQUIRED_FIELD_MISSING,
|
|
'platform is required'
|
|
);
|
|
}
|
|
|
|
// Validate platform
|
|
$platform = StreamingPlatform::tryFrom(strtolower($data['platform']));
|
|
if ($platform === null) {
|
|
throw FrameworkException::create(
|
|
ErrorCode::VAL_INVALID_VALUE,
|
|
"Invalid platform: {$data['platform']}"
|
|
)->withData([
|
|
'provided' => $data['platform'],
|
|
'allowed' => ['spotify', 'apple_music', 'tidal']
|
|
]);
|
|
}
|
|
|
|
// Check if user has OAuth token for this platform
|
|
$platformName = match ($platform) {
|
|
StreamingPlatform::SPOTIFY => 'spotify',
|
|
StreamingPlatform::APPLE_MUSIC => 'apple_music',
|
|
StreamingPlatform::TIDAL => 'tidal',
|
|
default => $platform->value
|
|
};
|
|
|
|
$token = $this->tokenRepository->findForUser($data['user_id'], $platformName);
|
|
if ($token === null) {
|
|
return new JsonResult([
|
|
'success' => false,
|
|
'message' => "User not connected to {$platformName}",
|
|
'oauth_required' => true,
|
|
'oauth_url' => "/oauth/{$platformName}/authorize?user_id={$data['user_id']}&redirect_url=/presave/{$campaignId}"
|
|
], 401);
|
|
}
|
|
|
|
// Check for duplicate registration
|
|
$existingRegistration = $this->registrationRepository->findForUserAndCampaign(
|
|
$data['user_id'],
|
|
$campaignId,
|
|
$platform
|
|
);
|
|
|
|
if ($existingRegistration !== null) {
|
|
return new JsonResult([
|
|
'success' => false,
|
|
'message' => 'You have already registered for this campaign',
|
|
'registration' => [
|
|
'id' => $existingRegistration->id,
|
|
'status' => $existingRegistration->status->value,
|
|
'platform' => $existingRegistration->platform->value,
|
|
'registered_at' => $existingRegistration->registeredAt->toTimestamp()
|
|
]
|
|
], 409);
|
|
}
|
|
|
|
// Create registration
|
|
$registration = PreSaveRegistration::create(
|
|
campaignId: (int) $campaignId,
|
|
userId: $data['user_id'],
|
|
platform: $platform
|
|
);
|
|
|
|
$savedRegistration = $this->registrationRepository->save($registration);
|
|
|
|
return new JsonResult([
|
|
'success' => true,
|
|
'message' => 'Successfully registered for pre-save campaign',
|
|
'data' => [
|
|
'registration_id' => $savedRegistration->id,
|
|
'campaign_id' => $campaignId,
|
|
'platform' => $platform->value,
|
|
'status' => $savedRegistration->status->value,
|
|
'registered_at' => $savedRegistration->registeredAt->toTimestamp()
|
|
]
|
|
], 201);
|
|
}
|
|
|
|
/**
|
|
* Check user's registration status for a campaign
|
|
*/
|
|
#[Route(path: '/api/presave/campaigns/{id}/status', method: Method::GET)]
|
|
public function getRegistrationStatus(HttpRequest $request): JsonResult
|
|
{
|
|
$campaignId = $request->routeParams->get('id');
|
|
$userId = $request->query->get('user_id');
|
|
|
|
if ($userId === null) {
|
|
throw FrameworkException::create(
|
|
ErrorCode::VAL_REQUIRED_FIELD_MISSING,
|
|
'user_id query parameter is required'
|
|
);
|
|
}
|
|
|
|
// Get all registrations for this user and campaign
|
|
$registrations = $this->registrationRepository->findByCampaign($campaignId);
|
|
$userRegistrations = array_filter(
|
|
$registrations,
|
|
fn($reg) => $reg->userId === $userId
|
|
);
|
|
|
|
if (empty($userRegistrations)) {
|
|
return new JsonResult([
|
|
'success' => true,
|
|
'registered' => false,
|
|
'registrations' => []
|
|
]);
|
|
}
|
|
|
|
return new JsonResult([
|
|
'success' => true,
|
|
'registered' => true,
|
|
'registrations' => array_map(fn($reg) => [
|
|
'id' => $reg->id,
|
|
'platform' => $reg->platform->value,
|
|
'status' => $reg->status->value,
|
|
'registered_at' => $reg->registeredAt->toTimestamp(),
|
|
'processed_at' => $reg->processedAt?->toTimestamp(),
|
|
'error_message' => $reg->errorMessage
|
|
], array_values($userRegistrations))
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* Get user's OAuth connection status
|
|
*/
|
|
#[Route(path: '/api/presave/oauth-status', method: Method::GET)]
|
|
public function getOAuthStatus(HttpRequest $request): JsonResult
|
|
{
|
|
$userId = $request->query->get('user_id');
|
|
|
|
if ($userId === null) {
|
|
throw FrameworkException::create(
|
|
ErrorCode::VAL_REQUIRED_FIELD_MISSING,
|
|
'user_id query parameter is required'
|
|
);
|
|
}
|
|
|
|
$connections = [];
|
|
$platforms = ['spotify', 'apple_music', 'tidal'];
|
|
|
|
foreach ($platforms as $platform) {
|
|
$token = $this->tokenRepository->findForUser($userId, $platform);
|
|
$connections[$platform] = [
|
|
'connected' => $token !== null,
|
|
'expires_at' => $token?->expiresAt,
|
|
'needs_refresh' => $token !== null && $token->expiresAt < time()
|
|
];
|
|
}
|
|
|
|
return new JsonResult([
|
|
'success' => true,
|
|
'user_id' => $userId,
|
|
'connections' => $connections
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* Get campaign statistics
|
|
*/
|
|
#[Route(path: '/api/presave/campaigns/{id}/stats', method: Method::GET)]
|
|
public function getStats(HttpRequest $request): JsonResult
|
|
{
|
|
$campaignId = $request->routeParams->get('id');
|
|
$campaign = $this->campaignRepository->findById($campaignId);
|
|
|
|
if ($campaign === null) {
|
|
return new JsonResult([
|
|
'success' => false,
|
|
'message' => 'Campaign not found'
|
|
], 404);
|
|
}
|
|
|
|
$registrations = $this->registrationRepository->findByCampaign($campaignId);
|
|
|
|
$stats = [
|
|
'total_registrations' => count($registrations),
|
|
'by_status' => [
|
|
'pending' => 0,
|
|
'processing' => 0,
|
|
'completed' => 0,
|
|
'failed' => 0
|
|
],
|
|
'by_platform' => [
|
|
'spotify' => 0,
|
|
'apple_music' => 0,
|
|
'tidal' => 0
|
|
]
|
|
];
|
|
|
|
foreach ($registrations as $registration) {
|
|
$stats['by_status'][strtolower($registration->status->value)]++;
|
|
$stats['by_platform'][strtolower($registration->platform->value)]++;
|
|
}
|
|
|
|
return new JsonResult([
|
|
'success' => true,
|
|
'campaign_id' => $campaignId,
|
|
'stats' => $stats
|
|
]);
|
|
}
|
|
}
|