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

@@ -10,7 +10,7 @@ use App\Framework\Core\ValueObjects\Hash;
use App\Framework\Database\Attributes\Column;
use App\Framework\Database\Attributes\Entity;
use App\Framework\Database\Attributes\Type;
use App\Framework\Filesystem\FilePath;
use App\Framework\Filesystem\ValueObjects\FilePath;
use App\Framework\Http\MimeType;
use App\Framework\Ulid\Ulid;

View File

@@ -4,6 +4,8 @@ declare(strict_types=1);
namespace App\Domain\Media;
use InvalidArgumentException;
enum ImageSize: string
{
case SMALL = 'small';
@@ -26,7 +28,7 @@ enum ImageSize: string
[ImageVariantType::HERO, self::LARGE] => 1920,
[ImageVariantType::HERO, self::XLARGE] => 2560,
default => throw new \InvalidArgumentException("Invalid combination: {$type->value} - {$this->value}"),
default => throw new InvalidArgumentException("Invalid combination: {$type->value} - {$this->value}"),
};
}

View File

@@ -7,6 +7,7 @@ namespace App\Domain\Media\Migrations;
use App\Framework\Database\ConnectionInterface;
use App\Framework\Database\Migration\Migration;
use App\Framework\Database\Migration\MigrationVersion;
use App\Framework\Database\Schema\Blueprint;
use App\Framework\Database\Schema\Schema;
final readonly class AddSizeToImageVariantsTable implements Migration
@@ -15,27 +16,27 @@ final readonly class AddSizeToImageVariantsTable implements Migration
{
$schema = new Schema($connection);
/*$schema->table('image_variants', function ($table) {
$schema->table('image_variants', function (Blueprint $table) {
$table->string('size', 25)->default('');
});*/
});
if (! $schema->hasColumn('image_variants', 'size')) {
$connection->execute("ALTER TABLE image_variants ADD COLUMN size VARCHAR(25) NOT NULL DEFAULT ''");
}
$schema->execute();
}
public function down(ConnectionInterface $connection): void
{
$schema = new Schema($connection);
if ($schema->hasColumn('image_variants', 'size')) {
$connection->execute("ALTER TABLE image_variants DROP COLUMN size");
}
$schema->table('image_variants', function (Blueprint $table) {
$table->dropColumn('size');
});
$schema->execute();
}
public function getVersion(): MigrationVersion
{
return MigrationVersion::fromTimestamp("2024_01_16_000005");
return MigrationVersion::fromTimestamp("2024_01_17_000004");
}
public function getDescription(): string

View File

@@ -7,53 +7,53 @@ namespace App\Domain\Media\Migrations;
use App\Framework\Database\ConnectionInterface;
use App\Framework\Database\Migration\Migration;
use App\Framework\Database\Migration\MigrationVersion;
use App\Framework\Database\Platform\SchemaBuilderFactory;
use App\Framework\Database\Platform\ValueObjects\TableOptions;
use App\Framework\Database\Schema\Blueprint;
use App\Framework\Database\Schema\ForeignKeyAction;
use App\Framework\Database\Schema\Schema;
final class CreateImageVariantsTable implements Migration
{
public function up(ConnectionInterface $connection): void
{
$schema = SchemaBuilderFactory::create($connection);
$schema = new Schema($connection);
$schema->createTable(
'image_variants',
[
$schema->id(),
$schema->string('image_id', 26)->notNull(),
$schema->string('variant_type', 50)->notNull(),
$schema->string('format', 25)->notNull(),
$schema->string('mime_type', 100)->notNull(),
$schema->integer('width')->notNull(),
$schema->integer('height')->notNull(),
$schema->bigInteger('file_size')->notNull(),
$schema->string('filename', 500)->notNull(),
$schema->string('path', 500)->notNull(),
$schema->timestamp('created_at')->notNull()->withDefault('CURRENT_TIMESTAMP'),
$schema->timestamp('updated_at')->withDefault(null),
],
TableOptions::default()
->withEngine('InnoDB')
->withCharset('utf8mb4')
->withCollation('utf8mb4_unicode_ci')
->withIfNotExists()
->withComment('Image variants table for different image sizes and formats')
);
$schema->createIfNotExists('image_variants', function (Blueprint $table) {
$table->bigIncrements('id');
$table->string('image_id', 26);
$table->string('variant_type', 50);
$table->string('format', 25);
$table->string('mime_type', 100);
$table->integer('width');
$table->integer('height');
$table->bigInteger('file_size');
$table->string('filename', 500);
$table->string('path', 500);
$table->timestamp('created_at')->useCurrent();
$table->timestamp('updated_at')->nullable();
// TODO: Add unique constraint, foreign key constraint, and index
// These will need to be implemented in a future enhancement of the SchemaBuilder
// For now, we'll add them manually:
$connection->query("ALTER TABLE image_variants ADD UNIQUE KEY uk_image_variants_combination (image_id, variant_type, format)");
$connection->query("ALTER TABLE image_variants ADD CONSTRAINT fk_image_variants_image_id FOREIGN KEY (image_id) REFERENCES images(ulid) ON DELETE CASCADE");
$connection->query("ALTER TABLE image_variants ADD INDEX idx_image_variants_lookup (image_id, variant_type)");
// Constraints and indexes
$table->unique(['image_id', 'variant_type', 'format'], 'uk_image_variants_combination');
$table->index(['image_id', 'variant_type'], 'idx_image_variants_lookup');
// Foreign key
$table->foreign('image_id')
->references('ulid')
->on('images')
->onDelete(ForeignKeyAction::CASCADE);
$table->engine('InnoDB');
$table->charset('utf8mb4');
$table->collation('utf8mb4_unicode_ci');
});
$schema->execute();
}
public function down(ConnectionInterface $connection): void
{
$schema = SchemaBuilderFactory::create($connection);
$this->dropExistingConstraints($connection);
$schema->dropTable('image_variants', true);
$schema = new Schema($connection);
$schema->dropIfExists('image_variants');
$schema->execute();
}
public function getVersion(): MigrationVersion

View File

@@ -7,38 +7,45 @@ namespace App\Domain\Media\Migrations;
use App\Framework\Database\ConnectionInterface;
use App\Framework\Database\Migration\Migration;
use App\Framework\Database\Migration\MigrationVersion;
use App\Framework\Database\Schema\Blueprint;
use App\Framework\Database\Schema\Schema;
final class CreateImagesTable implements Migration
{
public function up(ConnectionInterface $connection): void
{
$sql = <<<SQL
CREATE TABLE IF NOT EXISTS images (
ulid VARCHAR(26) NOT NULL,
filename VARCHAR(255) NOT NULL,
original_filename VARCHAR(255) NOT NULL,
mime_type VARCHAR(100) NOT NULL,
file_size BIGINT NOT NULL,
width INT UNSIGNED NOT NULL,
height INT UNSIGNED NOT NULL,
hash VARCHAR(255) NOT NULL,
path VARCHAR(500) NOT NULL,
alt_text TEXT NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NULL DEFAULT NULL,
$schema = new Schema($connection);
PRIMARY KEY (ulid),
UNIQUE KEY uk_images_hash (hash),
UNIQUE KEY uk_images_ulid (ulid)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
SQL;
$schema->createIfNotExists('images', function (Blueprint $table) {
$table->string('ulid', 26)->primary();
$table->string('filename', 255);
$table->string('original_filename', 255);
$table->string('mime_type', 100);
$table->bigInteger('file_size');
$table->integer('width');
$table->integer('height');
$table->string('hash', 255);
$table->string('path', 500);
$table->text('alt_text');
$table->timestamp('created_at')->useCurrent();
$table->timestamp('updated_at')->nullable();
$connection->query($sql);
$table->unique('hash', 'uk_images_hash');
$table->unique('ulid', 'uk_images_ulid');
$table->engine('InnoDB');
$table->charset('utf8mb4');
$table->collation('utf8mb4_unicode_ci');
});
$schema->execute();
}
public function down(ConnectionInterface $connection): void
{
$connection->execute("DROP TABLE IF EXISTS images");
$schema = new Schema($connection);
$schema->dropIfExists('images');
$schema->execute();
}
public function getVersion(): MigrationVersion

View File

@@ -7,23 +7,49 @@ namespace App\Domain\Media\Migrations;
use App\Framework\Database\ConnectionInterface;
use App\Framework\Database\Migration\Migration;
use App\Framework\Database\Migration\MigrationVersion;
use App\Framework\Database\Schema\Blueprint;
use App\Framework\Database\Schema\Schema;
final readonly class UpdateImageVariantsConstraint implements Migration
{
public function up(ConnectionInterface $connection): void
{
// Bestehenden Constraint entfernen
$connection->execute("ALTER TABLE image_variants DROP INDEX uk_image_variants_combination");
$schema = new Schema($connection);
// Neuen Constraint mit size-Spalte hinzufügen
$connection->execute("ALTER TABLE image_variants ADD UNIQUE KEY uk_image_variants_combination (image_id, variant_type, size, format)");
// Drop existing constraint
$schema->table('image_variants', function (Blueprint $table) {
$table->dropUnique('uk_image_variants_combination');
});
$schema->execute();
// Add new constraint with size column
$schema = new Schema($connection);
$schema->table('image_variants', function (Blueprint $table) {
$table->unique(['image_id', 'variant_type', 'size', 'format'], 'uk_image_variants_combination');
});
$schema->execute();
}
public function down(ConnectionInterface $connection): void
{
// Zurück zum ursprünglichen Constraint
$connection->execute("ALTER TABLE image_variants DROP INDEX uk_image_variants_combination");
$connection->execute("ALTER TABLE image_variants ADD UNIQUE KEY uk_image_variants_combination (image_id, variant_type, format)");
$schema = new Schema($connection);
// Drop current constraint
$schema->table('image_variants', function (Blueprint $table) {
$table->dropUnique('uk_image_variants_combination');
});
$schema->execute();
// Restore original constraint
$schema = new Schema($connection);
$schema->table('image_variants', function (Blueprint $table) {
$table->unique(['image_id', 'variant_type', 'format'], 'uk_image_variants_combination');
});
$schema->execute();
}
public function getVersion(): MigrationVersion

View File

@@ -16,6 +16,7 @@ final readonly class SaveImageFile
}
$fullPath = $image->path->join($image->filename)->toString();
return move_uploaded_file($tempFileName, $fullPath);
}
}

View File

@@ -5,8 +5,8 @@ declare(strict_types=1);
namespace App\Domain\PreSave\Jobs;
use App\Domain\PreSave\Services\PreSaveProcessor;
use App\Framework\Worker\Schedule;
use App\Framework\Worker\Every;
use App\Framework\Worker\Schedule;
/**
* Process Released Campaigns Job
@@ -18,7 +18,8 @@ final readonly class ProcessReleasedCampaignsJob
{
public function __construct(
private PreSaveProcessor $processor,
) {}
) {
}
/**
* @return array<string, mixed>

View File

@@ -7,8 +7,8 @@ namespace App\Domain\PreSave\Jobs;
use App\Domain\PreSave\PreSaveCampaignRepository;
use App\Domain\PreSave\Services\PreSaveProcessor;
use App\Domain\PreSave\ValueObjects\CampaignStatus;
use App\Framework\Worker\Schedule;
use App\Framework\Worker\Every;
use App\Framework\Worker\Schedule;
/**
* Retry Failed Registrations Job
@@ -21,7 +21,8 @@ final readonly class RetryFailedRegistrationsJob
public function __construct(
private PreSaveProcessor $processor,
private PreSaveCampaignRepository $campaignRepository,
) {}
) {
}
/**
* @return array<string, mixed>

View File

@@ -73,7 +73,7 @@ final readonly class PreSaveCampaign
*/
public function publish(): self
{
if (!$this->status->isEditable()) {
if (! $this->status->isEditable()) {
throw new \RuntimeException('Cannot publish campaign in current status: ' . $this->status->value);
}
@@ -203,7 +203,7 @@ final readonly class PreSaveCampaign
public function getDaysUntilRelease(): int
{
$now = Timestamp::now();
$diff = $this->releaseDate->getTimestamp() - $now->getTimestamp();
$diff = $this->releaseDate->toTimestamp() - $now->toTimestamp();
return (int) ceil($diff / 86400);
}
@@ -223,7 +223,7 @@ final readonly class PreSaveCampaign
'description' => $this->description,
'release_date' => $this->releaseDate->toTimestamp(),
'start_date' => $this->startDate?->toTimestamp(),
'track_urls' => array_map(fn($url) => $url->toArray(), $this->trackUrls),
'track_urls' => array_map(fn ($url) => $url->toArray(), $this->trackUrls),
'status' => $this->status->value,
'created_at' => $this->createdAt->toDateTime()->format('Y-m-d H:i:s'),
'updated_at' => $this->updatedAt->toDateTime()->format('Y-m-d H:i:s'),
@@ -248,7 +248,7 @@ final readonly class PreSaveCampaign
coverImageUrl: (string) $row['cover_image_url'],
releaseDate: Timestamp::fromFloat((float) $row['release_date']),
trackUrls: array_map(
fn(array $data) => TrackUrl::fromArray($data),
fn (array $data) => TrackUrl::fromArray($data),
$trackUrls
),
status: CampaignStatus::from($row['status']),

View File

@@ -5,20 +5,21 @@ declare(strict_types=1);
namespace App\Domain\PreSave;
use App\Domain\PreSave\ValueObjects\CampaignStatus;
use App\Framework\Core\ValueObjects\Timestamp;
use App\Framework\Database\ConnectionInterface;
use App\Framework\Database\ValueObjects\SqlQuery;
use App\Framework\Core\ValueObjects\Timestamp;
/**
* Pre-Save Campaign Repository
*/
final readonly class PreSaveCampaignRepository
final readonly class PreSaveCampaignRepository implements PreSaveCampaignRepositoryInterface
{
private const TABLE = 'presave_campaigns';
public function __construct(
private ConnectionInterface $connection,
) {}
) {
}
/**
* Find campaign by ID
@@ -56,7 +57,7 @@ final readonly class PreSaveCampaignRepository
$rows = $result->fetchAll();
return array_map(
fn(array $row) => PreSaveCampaign::fromArray($row),
fn (array $row) => PreSaveCampaign::fromArray($row),
$rows
);
}
@@ -84,7 +85,7 @@ final readonly class PreSaveCampaignRepository
$rows = $result->fetchAll();
return array_map(
fn(array $row) => PreSaveCampaign::fromArray($row),
fn (array $row) => PreSaveCampaign::fromArray($row),
$rows
);
}
@@ -105,7 +106,7 @@ final readonly class PreSaveCampaignRepository
$rows = $result->fetchAll();
return array_map(
fn(array $row) => PreSaveCampaign::fromArray($row),
fn (array $row) => PreSaveCampaign::fromArray($row),
$rows
);
}

View File

@@ -0,0 +1,52 @@
<?php
declare(strict_types=1);
namespace App\Domain\PreSave;
interface PreSaveCampaignRepositoryInterface
{
/**
* Find campaign by ID
*/
public function findById(int $id): ?PreSaveCampaign;
/**
* Find all campaigns
*
* @param array<string, mixed> $filters
* @return array<PreSaveCampaign>
*/
public function findAll(array $filters = []): array;
/**
* Find campaigns ready for release
*
* @return array<PreSaveCampaign>
*/
public function findReadyForRelease(): array;
/**
* Find campaigns ready for processing
*
* @return array<PreSaveCampaign>
*/
public function findReadyForProcessing(): array;
/**
* Save or update campaign
*/
public function save(PreSaveCampaign $campaign): PreSaveCampaign;
/**
* Delete campaign
*/
public function delete(int $id): bool;
/**
* Get campaign statistics
*
* @return array<string, int>
*/
public function getStatistics(int $campaignId): array;
}

View File

@@ -25,7 +25,8 @@ final readonly class PreSaveRegistration
public ?Timestamp $processedAt = null,
public ?string $errorMessage = null,
public int $retryCount = 0,
) {}
) {
}
/**
* Create new registration
@@ -104,7 +105,7 @@ final readonly class PreSaveRegistration
*/
public function resetForRetry(): self
{
if (!$this->status->canRetry()) {
if (! $this->status->canRetry()) {
throw new \RuntimeException('Cannot retry registration in status: ' . $this->status->value);
}

View File

@@ -12,13 +12,14 @@ use App\Framework\Database\ValueObjects\SqlQuery;
/**
* Pre-Save Registration Repository
*/
final readonly class PreSaveRegistrationRepository
final readonly class PreSaveRegistrationRepository implements PreSaveRegistrationRepositoryInterface
{
private const TABLE = 'presave_registrations';
public function __construct(
private ConnectionInterface $connection,
) {}
) {
}
/**
* Find registration by ID
@@ -52,6 +53,26 @@ final readonly class PreSaveRegistrationRepository
return $row ? PreSaveRegistration::fromArray($row) : null;
}
/**
* Find all registrations for user
*
* @return array<PreSaveRegistration>
*/
public function findByUserId(string $userId): array
{
$query = SqlQuery::select(table: self::TABLE)
->where('user_id = ?', [$userId])
->orderBy('registered_at DESC');
$result = $this->connection->query($query);
$rows = $result->fetchAll();
return array_map(
fn (array $row) => PreSaveRegistration::fromArray($row),
$rows
);
}
/**
* Find all registrations for campaign
*
@@ -59,16 +80,15 @@ final readonly class PreSaveRegistrationRepository
*/
public function findByCampaign(int $campaignId): array
{
$sql = "SELECT * FROM " . self::TABLE . "
WHERE campaign_id = ?
ORDER BY registered_at DESC";
$query = SqlQuery::select(self::TABLE)
->where('campaign_id = ?', [$campaignId])
->orderBy('registered_at DESC');
$query = SqlQuery::create($sql, [$campaignId]);
$result = $this->connection->query($query);
$rows = $result->fetchAll();
return array_map(
fn(array $row) => PreSaveRegistration::fromArray($row),
fn (array $row) => PreSaveRegistration::fromArray($row),
$rows
);
}
@@ -92,7 +112,7 @@ final readonly class PreSaveRegistrationRepository
$rows = $result->fetchAll();
return array_map(
fn(array $row) => PreSaveRegistration::fromArray($row),
fn (array $row) => PreSaveRegistration::fromArray($row),
$rows
);
}
@@ -119,7 +139,7 @@ final readonly class PreSaveRegistrationRepository
$rows = $result->fetchAll();
return array_map(
fn(array $row) => PreSaveRegistration::fromArray($row),
fn (array $row) => PreSaveRegistration::fromArray($row),
$rows
);
}

View File

@@ -0,0 +1,57 @@
<?php
declare(strict_types=1);
namespace App\Domain\PreSave;
use App\Domain\PreSave\ValueObjects\StreamingPlatform;
interface PreSaveRegistrationRepositoryInterface
{
/**
* Save or update registration
*/
public function save(PreSaveRegistration $registration): PreSaveRegistration;
/**
* Find registration for user and campaign
*/
public function findForUserAndCampaign(
string $userId,
int $campaignId,
StreamingPlatform $platform
): ?PreSaveRegistration;
/**
* Find all registrations for a user
*
* @return array<PreSaveRegistration>
*/
public function findByUserId(string $userId): array;
/**
* Find pending registrations for a campaign
*
* @return array<PreSaveRegistration>
*/
public function findPendingByCampaign(int $campaignId): array;
/**
* Find retryable failed registrations
*
* @return array<PreSaveRegistration>
*/
public function findRetryable(int $campaignId, int $maxRetries): array;
/**
* Get status counts for a campaign
*
* @return array<string, int>
*/
public function getStatusCounts(int $campaignId): array;
/**
* Delete registration
*/
public function delete(int $id): bool;
}

View File

@@ -4,14 +4,13 @@ declare(strict_types=1);
namespace App\Domain\PreSave\Services;
use App\Domain\PreSave\PreSaveCampaign;
use App\Domain\PreSaveCampaignRepository;
use App\Domain\PreSave\PreSaveCampaignRepositoryInterface;
use App\Domain\PreSave\PreSaveRegistration;
use App\Domain\PreSave\PreSaveRegistrationRepository;
use App\Domain\PreSave\PreSaveRegistrationRepositoryInterface;
use App\Domain\PreSave\ValueObjects\StreamingPlatform;
use App\Framework\OAuth\OAuthService;
use App\Framework\Exception\FrameworkException;
use App\Framework\Exception\ErrorCode;
use App\Framework\Exception\FrameworkException;
use App\Framework\OAuth\OAuthServiceInterface;
/**
* Pre-Save Campaign Service
@@ -21,10 +20,11 @@ use App\Framework\Exception\ErrorCode;
final readonly class PreSaveCampaignService
{
public function __construct(
private PreSaveCampaignRepository $campaignRepository,
private PreSaveRegistrationRepository $registrationRepository,
private OAuthService $oauthService,
) {}
private PreSaveCampaignRepositoryInterface $campaignRepository,
private PreSaveRegistrationRepositoryInterface $registrationRepository,
private OAuthServiceInterface $oauthService,
) {
}
/**
* Register user for campaign
@@ -43,7 +43,7 @@ final readonly class PreSaveCampaignService
)->withData(['campaign_id' => $campaignId]);
}
if (!$campaign->acceptsRegistrations()) {
if (! $campaign->acceptsRegistrations()) {
throw FrameworkException::create(
ErrorCode::VAL_BUSINESS_RULE_VIOLATION,
'Campaign is not accepting registrations'
@@ -53,7 +53,7 @@ final readonly class PreSaveCampaignService
]);
}
if (!$campaign->hasPlatform($platform)) {
if (! $campaign->hasPlatform($platform)) {
throw FrameworkException::create(
ErrorCode::VAL_BUSINESS_RULE_VIOLATION,
'Campaign does not support this platform'
@@ -76,7 +76,7 @@ final readonly class PreSaveCampaignService
}
// Verify user has OAuth token for platform
if (!$this->oauthService->hasProvider($userId, $platform->getOAuthProvider())) {
if (! $this->oauthService->hasProvider($userId, $platform->getOAuthProvider())) {
throw FrameworkException::create(
ErrorCode::AUTH_TOKEN_NOT_FOUND,
'User not connected to ' . $platform->getDisplayName()
@@ -110,7 +110,7 @@ final readonly class PreSaveCampaignService
}
// Can only cancel pending registrations
if (!$registration->status->shouldProcess()) {
if (! $registration->status->shouldProcess()) {
throw FrameworkException::create(
ErrorCode::VAL_BUSINESS_RULE_VIOLATION,
'Cannot cancel registration in current status'
@@ -172,11 +172,11 @@ final readonly class PreSaveCampaignService
): bool {
$campaign = $this->campaignRepository->findById($campaignId);
if ($campaign === null || !$campaign->acceptsRegistrations()) {
if ($campaign === null || ! $campaign->acceptsRegistrations()) {
return false;
}
if (!$campaign->hasPlatform($platform)) {
if (! $campaign->hasPlatform($platform)) {
return false;
}
@@ -184,7 +184,7 @@ final readonly class PreSaveCampaignService
return false;
}
if (!$this->oauthService->hasProvider($userId, $platform->getOAuthProvider())) {
if (! $this->oauthService->hasProvider($userId, $platform->getOAuthProvider())) {
return false;
}

View File

@@ -5,13 +5,15 @@ declare(strict_types=1);
namespace App\Domain\PreSave\Services;
use App\Domain\PreSave\PreSaveCampaign;
use App\Domain\PreSave\PreSaveCampaignRepository;
use App\Domain\PreSave\PreSaveCampaignRepositoryInterface;
use App\Domain\PreSave\PreSaveRegistration;
use App\Domain\PreSave\PreSaveRegistrationRepository;
use App\Domain\PreSave\PreSaveRegistrationRepositoryInterface;
use App\Domain\PreSave\ValueObjects\StreamingPlatform;
use App\Framework\OAuth\OAuthService;
use App\Framework\OAuth\Providers\SpotifyProvider;
use App\Framework\Logging\Logger;
use App\Framework\Logging\ValueObjects\LogContext;
use App\Framework\OAuth\OAuthServiceInterface;
use App\Framework\OAuth\Providers\SupportsPreSaves;
use App\Framework\OAuth\ValueObjects\OAuthToken;
/**
* Pre-Save Processor
@@ -21,12 +23,13 @@ use App\Framework\Logging\Logger;
final readonly class PreSaveProcessor
{
public function __construct(
private PreSaveCampaignRepository $campaignRepository,
private PreSaveRegistrationRepository $registrationRepository,
private OAuthService $oauthService,
private SpotifyProvider $spotifyProvider,
private PreSaveCampaignRepositoryInterface $campaignRepository,
private PreSaveRegistrationRepositoryInterface $registrationRepository,
private OAuthServiceInterface $oauthService,
private SupportsPreSaves $musicProvider,
private Logger $logger,
) {}
) {
}
/**
* Process all campaigns ready for release
@@ -114,13 +117,13 @@ final readonly class PreSaveProcessor
$this->processRegistration($campaign, $registration);
$successful++;
} catch (\Exception $e) {
$this->logger->error('Failed to process pre-save registration', [
$this->logger->error('Failed to process pre-save registration', LogContext::withData([
'registration_id' => $registration->id,
'campaign_id' => $campaign->id,
'user_id' => $registration->userId,
'platform' => $registration->platform->value,
'error' => $e->getMessage(),
]);
]));
$failed++;
}
@@ -149,6 +152,7 @@ final readonly class PreSaveProcessor
'Track URL not found for platform'
);
$this->registrationRepository->save($failedRegistration);
return;
}
@@ -161,7 +165,7 @@ final readonly class PreSaveProcessor
// Process based on platform
match ($registration->platform) {
StreamingPlatform::SPOTIFY => $this->processSpotifyRegistration(
StreamingPlatform::SPOTIFY => $this->processMusicPlatformRegistration(
$storedToken->token,
$trackUrl->trackId,
$registration
@@ -179,26 +183,26 @@ final readonly class PreSaveProcessor
}
/**
* Process Spotify registration
* Process music platform registration (Spotify, Apple Music, etc.)
*/
private function processSpotifyRegistration(
\App\Framework\OAuth\ValueObjects\OAuthToken $token,
private function processMusicPlatformRegistration(
OAuthToken $token,
string $trackId,
PreSaveRegistration $registration
): void {
// Add track to user's library
$this->spotifyProvider->addTracksToLibrary($token, [$trackId]);
$this->musicProvider->addTracksToLibrary($token, [$trackId]);
// Mark registration as completed
$completedRegistration = $registration->markAsCompleted();
$this->registrationRepository->save($completedRegistration);
$this->logger->info('Pre-save registration processed successfully', [
$this->logger->info('Pre-save registration processed successfully', LogContext::withData([
'registration_id' => $registration->id,
'user_id' => $registration->userId,
'platform' => 'spotify',
'platform' => $registration->platform->value,
'track_id' => $trackId,
]);
]));
}
/**

View File

@@ -62,7 +62,7 @@ enum StreamingPlatform: string
{
return array_filter(
self::cases(),
fn(self $platform) => $platform->isSupported()
fn (self $platform) => $platform->isSupported()
);
}
}

View File

@@ -26,7 +26,8 @@ final readonly class ClickEvent
public ?string $destinationService,
public bool $converted,
public Timestamp $clickedAt
) {}
) {
}
public static function create(
Clock $clock,
@@ -55,6 +56,7 @@ final readonly class ClickEvent
{
// Privacy-compliant: Hash IP with daily rotating salt
$salt = date('Y-m-d');
return hash('sha256', $ip->value . $salt);
}
@@ -92,8 +94,8 @@ final readonly class ClickEvent
'id' => $this->id->toString(),
'link_id' => $this->linkId->toString(),
'ip_hash' => $this->ipHash,
'country_code' => $this->countryCode->getCode(),
'country_name' => $this->countryCode->getName(),
'country_code' => (string) $this->countryCode,
'country_name' => $this->countryCode->getCountryName(),
'device_type' => $this->deviceType->value,
'user_agent' => $this->userAgentString,
'referer' => $this->referer,

View File

@@ -8,6 +8,8 @@ use App\Domain\SmartLink\ValueObjects\GeoRuleId;
use App\Domain\SmartLink\ValueObjects\SmartLinkId;
use App\Framework\Core\ValueObjects\CountryCode;
use App\Framework\Core\ValueObjects\Timestamp;
use App\Framework\DateTime\Clock;
use App\Framework\DateTime\SystemClock;
/**
* Geo-Routing Rule definiert länderspezifische Destination-Zuordnungen
@@ -21,16 +23,17 @@ final readonly class GeoRoutingRule
public string $destinationId,
public int $priority,
public Timestamp $createdAt
) {}
) {
}
public static function create(
SmartLinkId $linkId,
CountryCode $countryCode,
string $destinationId,
int $priority = 0,
?\App\Framework\DateTime\Clock $clock = null
?Clock $clock = null
): self {
$clock = $clock ?? new \App\Framework\DateTime\SystemClock();
$clock = $clock ?? new SystemClock();
return new self(
id: GeoRuleId::generate($clock),

View File

@@ -7,9 +7,8 @@ namespace App\Domain\SmartLink\Entities;
use App\Domain\SmartLink\Enums\ServiceType;
use App\Domain\SmartLink\ValueObjects\DestinationUrl;
use App\Domain\SmartLink\ValueObjects\SmartLinkId;
use App\Framework\Core\ValueObjects\CountryCode;
use App\Framework\Core\ValueObjects\Timestamp;
use App\Infrastructure\GeoIp\CountryCode;
final readonly class GeoRule
{
public function __construct(
@@ -19,7 +18,8 @@ final readonly class GeoRule
public ServiceType $serviceType,
public DestinationUrl $url,
public Timestamp $createdAt
) {}
) {
}
public static function create(
SmartLinkId $linkId,
@@ -47,8 +47,8 @@ final readonly class GeoRule
return [
'id' => $this->id,
'link_id' => $this->linkId->toString(),
'country_code' => $this->countryCode->getCode(),
'country_name' => $this->countryCode->getName(),
'country_code' => (string) $this->countryCode,
'country_name' => $this->countryCode->getCountryName(),
'service_type' => $this->serviceType->value,
'service_label' => $this->serviceType->getLabel(),
'url' => $this->url->toString(),

View File

@@ -19,7 +19,8 @@ final readonly class LinkDestination
public int $priority,
public bool $isDefault,
public Timestamp $createdAt
) {}
) {
}
public static function create(
SmartLinkId $linkId,

View File

@@ -26,7 +26,8 @@ final readonly class SmartLink
public LinkSettings $settings,
public Timestamp $createdAt,
public Timestamp $updatedAt
) {}
) {
}
public static function create(
Clock $clock,
@@ -52,7 +53,7 @@ final readonly class SmartLink
public function withStatus(LinkStatus $status): self
{
if (!$this->status->canTransitionTo($status)) {
if (! $this->status->canTransitionTo($status)) {
throw new \DomainException(
sprintf('Cannot transition from %s to %s', $this->status->value, $status->value)
);

View File

@@ -34,6 +34,7 @@ enum ServiceType: string
case TICKETMASTER = 'ticketmaster';
// Generic
case WEBSITE = 'website';
case CUSTOM = 'custom';
public function getLabel(): string
@@ -56,6 +57,7 @@ enum ServiceType: string
self::SHOPIFY => 'Shopify',
self::EVENTBRITE => 'Eventbrite',
self::TICKETMASTER => 'Ticketmaster',
self::WEBSITE => 'Website',
self::CUSTOM => 'Custom Link',
};
}
@@ -74,7 +76,7 @@ enum ServiceType: string
self::EVENTBRITE, self::TICKETMASTER => ServiceCategory::TICKETING,
self::CUSTOM => ServiceCategory::CUSTOM,
self::WEBSITE, self::CUSTOM => ServiceCategory::CUSTOM,
};
}
@@ -97,6 +99,7 @@ enum ServiceType: string
self::SHOPIFY => 'icon-shopify',
self::EVENTBRITE => 'icon-eventbrite',
self::TICKETMASTER => 'icon-ticketmaster',
self::WEBSITE => 'icon-globe',
self::CUSTOM => 'icon-link',
};
}

View File

@@ -55,4 +55,4 @@ final readonly class CreateAnalyticsAggregatesTable implements Migration
{
return 'CreateAnalyticsAggregatesTable';
}
}
}

View File

@@ -56,4 +56,4 @@ final readonly class CreateClickEventsTable implements Migration
{
return 'CreateClickEventsTable';
}
}
}

View File

@@ -54,4 +54,4 @@ final readonly class CreateSmartLinksTable implements Migration
{
return 'CreateSmartLinksTable';
}
}
}

View File

@@ -17,7 +17,8 @@ final readonly class DatabaseClickEventRepository implements ClickEventRepositor
{
public function __construct(
private ConnectionInterface $connection
) {}
) {
}
public function save(ClickEvent $event): void
{
@@ -54,7 +55,7 @@ final readonly class DatabaseClickEventRepository implements ClickEventRepositor
$result = $this->connection->query($query);
$rows = $result->fetchAll();
return array_map(fn(array $row) => $this->hydrate($row), $rows);
return array_map(fn (array $row) => $this->hydrate($row), $rows);
}
public function getRecentClicksByIpHash(string $ipHash, int $minutes = 60): array
@@ -66,7 +67,7 @@ final readonly class DatabaseClickEventRepository implements ClickEventRepositor
$result = $this->connection->query($query);
$rows = $result->fetchAll();
return array_map(fn(array $row) => $this->hydrate($row), $rows);
return array_map(fn (array $row) => $this->hydrate($row), $rows);
}
public function countByLinkId(SmartLinkId $linkId): int

View File

@@ -16,7 +16,8 @@ final readonly class DatabaseGeoRoutingRuleRepository implements GeoRoutingRuleR
{
public function __construct(
private ConnectionInterface $connection
) {}
) {
}
public function save(GeoRoutingRule $rule): void
{
@@ -64,7 +65,7 @@ final readonly class DatabaseGeoRoutingRuleRepository implements GeoRoutingRuleR
$query = SqlQuery::create($sql, [
$linkId->toString(),
$countryCode->toString()
$countryCode->toString(),
]);
$result = $this->connection->query($query);
@@ -87,7 +88,7 @@ final readonly class DatabaseGeoRoutingRuleRepository implements GeoRoutingRuleR
$result = $this->connection->query($query);
$rows = $result->fetchAll();
return array_map(fn(array $row) => $this->hydrate($row), $rows);
return array_map(fn (array $row) => $this->hydrate($row), $rows);
}
public function delete(GeoRuleId $id): void

View File

@@ -16,7 +16,8 @@ final readonly class DatabaseLinkDestinationRepository implements LinkDestinatio
{
public function __construct(
private ConnectionInterface $connection
) {}
) {
}
public function save(LinkDestination $destination): void
{
@@ -58,7 +59,7 @@ final readonly class DatabaseLinkDestinationRepository implements LinkDestinatio
[$linkId->toString()]
);
return array_map(fn(array $row) => $this->hydrate($row), $rows);
return array_map(fn (array $row) => $this->hydrate($row), $rows);
}
public function findDefaultByLinkId(SmartLinkId $linkId): ?LinkDestination

View File

@@ -5,9 +5,9 @@ declare(strict_types=1);
namespace App\Domain\SmartLink\Repositories;
use App\Domain\SmartLink\Entities\SmartLink;
use App\Domain\SmartLink\ValueObjects\LinkSettings;
use App\Domain\SmartLink\Enums\LinkStatus;
use App\Domain\SmartLink\Enums\LinkType;
use App\Domain\SmartLink\ValueObjects\LinkSettings;
use App\Domain\SmartLink\ValueObjects\LinkTitle;
use App\Domain\SmartLink\ValueObjects\ShortCode;
use App\Domain\SmartLink\ValueObjects\SmartLinkId;
@@ -19,7 +19,8 @@ final readonly class DatabaseSmartLinkRepository implements SmartLinkRepository
{
public function __construct(
private ConnectionInterface $connection
) {}
) {
}
public function save(SmartLink $link): void
{
@@ -82,7 +83,7 @@ final readonly class DatabaseSmartLinkRepository implements SmartLinkRepository
$result = $this->connection->query($query);
$rows = $result->fetchAll();
return array_map(fn(array $row) => $this->hydrate($row), $rows);
return array_map(fn (array $row) => $this->hydrate($row), $rows);
}
public function existsShortCode(ShortCode $shortCode): bool
@@ -109,7 +110,7 @@ final readonly class DatabaseSmartLinkRepository implements SmartLinkRepository
$result = $this->connection->query($query);
$rows = $result->fetchAll();
return array_map(fn(array $row) => $this->hydrate($row), $rows);
return array_map(fn (array $row) => $this->hydrate($row), $rows);
}
public function getTotalClicks(SmartLinkId $id): int

View File

@@ -11,8 +11,6 @@ use App\Domain\SmartLink\Repositories\ClickEventRepository;
use App\Domain\SmartLink\ValueObjects\SmartLinkId;
use App\Framework\DateTime\Clock;
use App\Framework\Http\HttpRequest;
use App\Framework\Http\IpAddress;
use App\Framework\Http\UserAgent;
use App\Infrastructure\GeoIp\GeoIp;
final readonly class ClickTrackingService
@@ -21,7 +19,8 @@ final readonly class ClickTrackingService
private ClickEventRepository $repository,
private GeoIp $geoIp,
private Clock $clock
) {}
) {
}
public function trackClick(
SmartLink $smartLink,

View File

@@ -6,15 +6,10 @@ namespace App\Domain\SmartLink\Services;
use App\Domain\SmartLink\Entities\LinkDestination;
use App\Domain\SmartLink\Entities\SmartLink;
use App\Domain\SmartLink\Enums\DeviceType;
use App\Domain\SmartLink\Repositories\GeoRoutingRuleRepository;
use App\Domain\SmartLink\Repositories\LinkDestinationRepository;
use App\Domain\SmartLink\ValueObjects\DestinationUrl;
use App\Domain\SmartLink\ValueObjects\ShortCode;
use App\Framework\Core\ValueObjects\CountryCode;
use App\Framework\Http\HttpRequest;
use App\Framework\Http\IpAddress;
use App\Framework\Http\UserAgent;
use App\Infrastructure\GeoIp\GeoIp;
final readonly class LinkRoutingEngine
@@ -23,14 +18,15 @@ final readonly class LinkRoutingEngine
private LinkDestinationRepository $destinationRepository,
private GeoRoutingRuleRepository $geoRoutingRuleRepository,
private GeoIp $geoIp
) {}
) {
}
public function resolveDestination(
SmartLink $smartLink,
HttpRequest $request
): LinkDestination {
// Check if link is accessible
if (!$smartLink->canBeAccessed()) {
if (! $smartLink->canBeAccessed()) {
throw new \DomainException('Link is not accessible');
}

View File

@@ -13,14 +13,15 @@ final readonly class ShortCodeGenerator
public function __construct(
private SmartLinkRepository $repository
) {}
) {
}
public function generateUnique(int $length = 6): ShortCode
{
for ($attempt = 0; $attempt < self::MAX_ATTEMPTS; $attempt++) {
$shortCode = ShortCode::generate($length);
if (!$this->repository->existsShortCode($shortCode)) {
if (! $this->repository->existsShortCode($shortCode)) {
return $shortCode;
}
}

View File

@@ -26,7 +26,8 @@ final readonly class SmartLinkService
private LinkDestinationRepository $destinationRepository,
private ShortCodeGenerator $shortCodeGenerator,
private Clock $clock
) {}
) {
}
public function createLink(
LinkType $type,

View File

@@ -12,7 +12,7 @@ final readonly class ClickId
private function __construct(
private string $value
) {
if (!Ulid::isValid($value)) {
if (! Ulid::isValid($value)) {
throw new \InvalidArgumentException('Invalid Click ID format');
}
}
@@ -20,6 +20,7 @@ final readonly class ClickId
public static function generate(Clock $clock): self
{
$ulid = new Ulid($clock);
return new self($ulid->__toString());
}

View File

@@ -19,12 +19,12 @@ final readonly class DestinationUrl
private function validate(): void
{
if (!filter_var($this->value, FILTER_VALIDATE_URL)) {
if (! filter_var($this->value, FILTER_VALIDATE_URL)) {
throw new \InvalidArgumentException('Invalid destination URL format');
}
$scheme = parse_url($this->value, PHP_URL_SCHEME);
if (!in_array($scheme, ['http', 'https'], true)) {
if (! in_array($scheme, ['http', 'https'], true)) {
throw new \InvalidArgumentException('Destination URL must use HTTP or HTTPS protocol');
}
}

View File

@@ -11,11 +11,13 @@ final readonly class GeoRuleId
{
private function __construct(
public string $value
) {}
) {
}
public static function generate(Clock $clock): self
{
$ulid = new Ulid($clock);
return new self($ulid->__toString());
}

View File

@@ -13,7 +13,8 @@ final readonly class LinkSettings
public ?string $customDomain,
public ?int $clickLimit,
public ?string $password
) {}
) {
}
public static function default(): self
{
@@ -82,7 +83,7 @@ final readonly class LinkSettings
public function verifyPassword(string $password): bool
{
if (!$this->isPasswordProtected()) {
if (! $this->isPasswordProtected()) {
return true;
}

View File

@@ -6,7 +6,7 @@ namespace App\Domain\SmartLink\ValueObjects;
final readonly class LinkTitle
{
private const MAX_LENGTH = 200;
private const int MAX_LENGTH = 200;
private function __construct(
private string $value

View File

@@ -6,9 +6,9 @@ namespace App\Domain\SmartLink\ValueObjects;
final readonly class ShortCode
{
private const MIN_LENGTH = 6;
private const MAX_LENGTH = 8;
private const ALLOWED_CHARS = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
private const int MIN_LENGTH = 6;
private const int MAX_LENGTH = 8;
private const string ALLOWED_CHARS = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
private function __construct(
private string $value
@@ -49,7 +49,7 @@ final readonly class ShortCode
);
}
if (!preg_match('/^[a-zA-Z0-9]+$/', $this->value)) {
if (! preg_match('/^[a-zA-Z0-9]+$/', $this->value)) {
throw new \InvalidArgumentException('ShortCode can only contain alphanumeric characters');
}
}

View File

@@ -12,7 +12,7 @@ final readonly class SmartLinkId
private function __construct(
private string $value
) {
if (!Ulid::isValid($value)) {
if (! Ulid::isValid($value)) {
throw new \InvalidArgumentException('Invalid SmartLink ID format');
}
}
@@ -20,6 +20,7 @@ final readonly class SmartLinkId
public static function generate(Clock $clock): self
{
$ulid = new Ulid($clock);
return new self($ulid->__toString());
}

View File

@@ -4,26 +4,30 @@ declare(strict_types=1);
namespace App\Domain\Vault\Migrations;
use App\Framework\Database\ConnectionInterface;
use App\Framework\Database\Migration\Migration;
use App\Framework\Database\Migration\MigrationVersion;
use App\Framework\Database\Schema\Blueprint;
use App\Framework\Database\Schema\Schema;
/**
* Create Vault tables for secure secrets storage
*/
final class CreateVaultTables extends Migration
final class CreateVaultTables implements Migration
{
public function up(Schema $schema): void
public function up(ConnectionInterface $connection): void
{
$schema = new Schema($connection);
// Main Vault Secrets Table
$schema->create('vault_secrets', function (Blueprint $table) {
$table->char('id', 36)->primary();
$schema->createIfNotExists('vault_secrets', function (Blueprint $table) {
$table->string('id', 36)->primary();
$table->string('secret_key', 255)->unique();
$table->text('encrypted_value');
$table->string('encryption_nonce', 255);
$table->integer('encryption_version')->default(1);
$table->timestamp('created_at')->useCurrent();
$table->timestamp('updated_at')->useCurrent()->onUpdateCurrent();
$table->timestamp('updated_at')->nullable();
$table->string('created_by', 255)->nullable();
$table->string('updated_by', 255)->nullable();
$table->integer('access_count')->default(0);
@@ -31,10 +35,15 @@ final class CreateVaultTables extends Migration
$table->index('secret_key');
$table->index('updated_at');
// Table options
$table->engine('InnoDB');
$table->charset('utf8mb4');
$table->collation('utf8mb4_unicode_ci');
});
// Vault Audit Log
$schema->create('vault_audit_log', function (Blueprint $table) {
$schema->createIfNotExists('vault_audit_log', function (Blueprint $table) {
$table->bigIncrements('id');
$table->string('secret_key', 255);
$table->enum('action', ['read', 'write', 'delete', 'rotate', 'export']);
@@ -49,10 +58,15 @@ final class CreateVaultTables extends Migration
$table->index('action');
$table->index('timestamp');
$table->index('user_id');
// Table options
$table->engine('InnoDB');
$table->charset('utf8mb4');
$table->collation('utf8mb4_unicode_ci');
});
// Encryption Key Versions
$schema->create('vault_encryption_keys', function (Blueprint $table) {
$schema->createIfNotExists('vault_encryption_keys', function (Blueprint $table) {
$table->increments('id');
$table->integer('version')->unique();
$table->string('key_hash', 255);
@@ -63,13 +77,32 @@ final class CreateVaultTables extends Migration
$table->index('version');
$table->index('is_active');
// Table options
$table->engine('InnoDB');
$table->charset('utf8mb4');
$table->collation('utf8mb4_unicode_ci');
});
$schema->execute();
}
public function down(Schema $schema): void
public function down(ConnectionInterface $connection): void
{
$schema = new Schema($connection);
$schema->dropIfExists('vault_encryption_keys');
$schema->dropIfExists('vault_audit_log');
$schema->dropIfExists('vault_secrets');
$schema->execute();
}
public function getVersion(): MigrationVersion
{
return MigrationVersion::fromTimestamp("2025_10_05_090000");
}
public function getDescription(): string
{
return "Create Vault tables for secure secrets storage";
}
}