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

@@ -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());
}