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,105 @@
<?php
declare(strict_types=1);
namespace App\Domain\SmartLink\Entities;
use App\Domain\SmartLink\Enums\DeviceType;
use App\Domain\SmartLink\ValueObjects\ClickId;
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\Http\IpAddress;
use App\Framework\UserAgent\UserAgent;
final readonly class ClickEvent
{
public function __construct(
public ClickId $id,
public SmartLinkId $linkId,
public string $ipHash,
public CountryCode $countryCode,
public DeviceType $deviceType,
public string $userAgentString,
public ?string $referer,
public ?string $destinationService,
public bool $converted,
public Timestamp $clickedAt
) {}
public static function create(
Clock $clock,
SmartLinkId $linkId,
IpAddress $ip,
CountryCode $countryCode,
UserAgent $userAgent,
?string $referer = null,
?string $destinationService = null
): self {
return new self(
id: ClickId::generate($clock),
linkId: $linkId,
ipHash: self::hashIp($ip),
countryCode: $countryCode,
deviceType: DeviceType::fromUserAgent($userAgent->toString()),
userAgentString: $userAgent->toString(),
referer: $referer,
destinationService: $destinationService,
converted: false,
clickedAt: Timestamp::now()
);
}
private static function hashIp(IpAddress $ip): string
{
// Privacy-compliant: Hash IP with daily rotating salt
$salt = date('Y-m-d');
return hash('sha256', $ip->value . $salt);
}
public function withConversion(): self
{
return new self(
id: $this->id,
linkId: $this->linkId,
ipHash: $this->ipHash,
countryCode: $this->countryCode,
deviceType: $this->deviceType,
userAgentString: $this->userAgentString,
referer: $this->referer,
destinationService: $this->destinationService,
converted: true,
clickedAt: $this->clickedAt
);
}
public function isUnique(array $recentClicks): bool
{
foreach ($recentClicks as $click) {
if ($click->ipHash === $this->ipHash &&
$click->clickedAt->isWithinMinutes($this->clickedAt, 60)) {
return false;
}
}
return true;
}
public function toArray(): array
{
return [
'id' => $this->id->toString(),
'link_id' => $this->linkId->toString(),
'ip_hash' => $this->ipHash,
'country_code' => $this->countryCode->getCode(),
'country_name' => $this->countryCode->getName(),
'device_type' => $this->deviceType->value,
'user_agent' => $this->userAgentString,
'referer' => $this->referer,
'destination_service' => $this->destinationService,
'converted' => $this->converted,
'clicked_at' => $this->clickedAt->format('Y-m-d H:i:s'),
];
}
}

View File

@@ -0,0 +1,44 @@
<?php
declare(strict_types=1);
namespace App\Domain\SmartLink\Entities;
use App\Domain\SmartLink\ValueObjects\GeoRuleId;
use App\Domain\SmartLink\ValueObjects\SmartLinkId;
use App\Framework\Core\ValueObjects\CountryCode;
use App\Framework\Core\ValueObjects\Timestamp;
/**
* Geo-Routing Rule definiert länderspezifische Destination-Zuordnungen
*/
final readonly class GeoRoutingRule
{
public function __construct(
public GeoRuleId $id,
public SmartLinkId $linkId,
public CountryCode $countryCode,
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
): self {
$clock = $clock ?? new \App\Framework\DateTime\SystemClock();
return new self(
id: GeoRuleId::generate($clock),
linkId: $linkId,
countryCode: $countryCode,
destinationId: $destinationId,
priority: $priority,
createdAt: Timestamp::now()
);
}
}

View File

@@ -0,0 +1,58 @@
<?php
declare(strict_types=1);
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\Timestamp;
use App\Infrastructure\GeoIp\CountryCode;
final readonly class GeoRule
{
public function __construct(
public string $id,
public SmartLinkId $linkId,
public CountryCode $countryCode,
public ServiceType $serviceType,
public DestinationUrl $url,
public Timestamp $createdAt
) {}
public static function create(
SmartLinkId $linkId,
CountryCode $countryCode,
ServiceType $serviceType,
DestinationUrl $url
): self {
return new self(
id: uniqid('geo_', true),
linkId: $linkId,
countryCode: $countryCode,
serviceType: $serviceType,
url: $url,
createdAt: Timestamp::now()
);
}
public function matchesCountry(CountryCode $country): bool
{
return $this->countryCode->equals($country);
}
public function toArray(): array
{
return [
'id' => $this->id,
'link_id' => $this->linkId->toString(),
'country_code' => $this->countryCode->getCode(),
'country_name' => $this->countryCode->getName(),
'service_type' => $this->serviceType->value,
'service_label' => $this->serviceType->getLabel(),
'url' => $this->url->toString(),
'created_at' => $this->createdAt->format('Y-m-d H:i:s'),
];
}
}

View File

@@ -0,0 +1,81 @@
<?php
declare(strict_types=1);
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\Timestamp;
final readonly class LinkDestination
{
public function __construct(
public string $id,
public SmartLinkId $linkId,
public ServiceType $serviceType,
public DestinationUrl $url,
public int $priority,
public bool $isDefault,
public Timestamp $createdAt
) {}
public static function create(
SmartLinkId $linkId,
ServiceType $serviceType,
DestinationUrl $url,
int $priority = 0,
bool $isDefault = false
): self {
return new self(
id: uniqid('dest_', true),
linkId: $linkId,
serviceType: $serviceType,
url: $url,
priority: $priority,
isDefault: $isDefault,
createdAt: Timestamp::now()
);
}
public function withPriority(int $priority): self
{
return new self(
id: $this->id,
linkId: $this->linkId,
serviceType: $this->serviceType,
url: $this->url,
priority: $priority,
isDefault: $this->isDefault,
createdAt: $this->createdAt
);
}
public function asDefault(): self
{
return new self(
id: $this->id,
linkId: $this->linkId,
serviceType: $this->serviceType,
url: $this->url,
priority: $this->priority,
isDefault: true,
createdAt: $this->createdAt
);
}
public function toArray(): array
{
return [
'id' => $this->id,
'link_id' => $this->linkId->toString(),
'service_type' => $this->serviceType->value,
'service_label' => $this->serviceType->getLabel(),
'url' => $this->url->toString(),
'priority' => $this->priority,
'is_default' => $this->isDefault,
'created_at' => $this->createdAt->format('Y-m-d H:i:s'),
];
}
}

View File

@@ -0,0 +1,121 @@
<?php
declare(strict_types=1);
namespace App\Domain\SmartLink\Entities;
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;
use App\Framework\Core\ValueObjects\Timestamp;
use App\Framework\DateTime\Clock;
final readonly class SmartLink
{
public function __construct(
public SmartLinkId $id,
public ShortCode $shortCode,
public LinkType $type,
public LinkTitle $title,
public ?string $coverImageUrl,
public LinkStatus $status,
public ?string $userId,
public LinkSettings $settings,
public Timestamp $createdAt,
public Timestamp $updatedAt
) {}
public static function create(
Clock $clock,
ShortCode $shortCode,
LinkType $type,
LinkTitle $title,
?string $userId = null,
?string $coverImageUrl = null
): self {
return new self(
id: SmartLinkId::generate($clock),
shortCode: $shortCode,
type: $type,
title: $title,
coverImageUrl: $coverImageUrl,
status: LinkStatus::DRAFT,
userId: $userId,
settings: LinkSettings::default(),
createdAt: Timestamp::now(),
updatedAt: Timestamp::now()
);
}
public function withStatus(LinkStatus $status): self
{
if (!$this->status->canTransitionTo($status)) {
throw new \DomainException(
sprintf('Cannot transition from %s to %s', $this->status->value, $status->value)
);
}
return new self(
id: $this->id,
shortCode: $this->shortCode,
type: $this->type,
title: $this->title,
coverImageUrl: $this->coverImageUrl,
status: $status,
userId: $this->userId,
settings: $this->settings,
createdAt: $this->createdAt,
updatedAt: Timestamp::now()
);
}
public function withTitle(LinkTitle $title): self
{
return new self(
id: $this->id,
shortCode: $this->shortCode,
type: $this->type,
title: $title,
coverImageUrl: $this->coverImageUrl,
status: $this->status,
userId: $this->userId,
settings: $this->settings,
createdAt: $this->createdAt,
updatedAt: Timestamp::now()
);
}
public function withSettings(LinkSettings $settings): self
{
return new self(
id: $this->id,
shortCode: $this->shortCode,
type: $this->type,
title: $this->title,
coverImageUrl: $this->coverImageUrl,
status: $this->status,
userId: $this->userId,
settings: $settings,
createdAt: $this->createdAt,
updatedAt: Timestamp::now()
);
}
public function isActive(): bool
{
return $this->status === LinkStatus::ACTIVE;
}
public function canBeAccessed(): bool
{
return $this->status->isPublic();
}
public function isOwnedBy(?string $userId): bool
{
return $this->userId === $userId;
}
}

View File

@@ -0,0 +1,47 @@
<?php
declare(strict_types=1);
namespace App\Domain\SmartLink\Enums;
enum DeviceType: string
{
case MOBILE = 'mobile';
case TABLET = 'tablet';
case DESKTOP = 'desktop';
case UNKNOWN = 'unknown';
public static function fromUserAgent(string $userAgent): self
{
$userAgent = strtolower($userAgent);
if (preg_match('/mobile|android|iphone|ipod|blackberry|iemobile|opera mini/i', $userAgent)) {
return self::MOBILE;
}
if (preg_match('/tablet|ipad|playbook|silk/i', $userAgent)) {
return self::TABLET;
}
if (preg_match('/windows|macintosh|linux|x11/i', $userAgent)) {
return self::DESKTOP;
}
return self::UNKNOWN;
}
public function getLabel(): string
{
return match ($this) {
self::MOBILE => 'Mobile',
self::TABLET => 'Tablet',
self::DESKTOP => 'Desktop',
self::UNKNOWN => 'Unknown',
};
}
public function isMobile(): bool
{
return $this === self::MOBILE || $this === self::TABLET;
}
}

View File

@@ -0,0 +1,71 @@
<?php
declare(strict_types=1);
namespace App\Domain\SmartLink\Enums;
enum LinkStatus: string
{
case DRAFT = 'draft';
case ACTIVE = 'active';
case SCHEDULED = 'scheduled';
case PAUSED = 'paused';
case EXPIRED = 'expired';
case ARCHIVED = 'archived';
public function getLabel(): string
{
return match ($this) {
self::DRAFT => 'Draft',
self::ACTIVE => 'Active',
self::SCHEDULED => 'Scheduled',
self::PAUSED => 'Paused',
self::EXPIRED => 'Expired',
self::ARCHIVED => 'Archived',
};
}
public function isPublic(): bool
{
return match ($this) {
self::ACTIVE, self::SCHEDULED => true,
default => false,
};
}
public function canBeEdited(): bool
{
return match ($this) {
self::DRAFT, self::PAUSED, self::SCHEDULED => true,
default => false,
};
}
public function canTransitionTo(self $newStatus): bool
{
return match ([$this, $newStatus]) {
// From DRAFT
[self::DRAFT, self::ACTIVE] => true,
[self::DRAFT, self::SCHEDULED] => true,
[self::DRAFT, self::ARCHIVED] => true,
// From ACTIVE
[self::ACTIVE, self::PAUSED] => true,
[self::ACTIVE, self::EXPIRED] => true,
[self::ACTIVE, self::ARCHIVED] => true,
// From SCHEDULED
[self::SCHEDULED, self::ACTIVE] => true,
[self::SCHEDULED, self::ARCHIVED] => true,
// From PAUSED
[self::PAUSED, self::ACTIVE] => true,
[self::PAUSED, self::ARCHIVED] => true,
// From EXPIRED
[self::EXPIRED, self::ARCHIVED] => true,
default => false,
};
}
}

View File

@@ -0,0 +1,57 @@
<?php
declare(strict_types=1);
namespace App\Domain\SmartLink\Enums;
enum LinkType: string
{
case PRE_RELEASE = 'pre_release';
case RELEASE = 'release';
case BIO_LINK = 'bio_link';
case EVENT = 'event';
case CONTENT = 'content';
case REWARD = 'reward';
public function getLabel(): string
{
return match ($this) {
self::PRE_RELEASE => 'Pre-Release / Pre-Save',
self::RELEASE => 'Release / Album',
self::BIO_LINK => 'Bio / Landing Page',
self::EVENT => 'Event / Tickets',
self::CONTENT => 'Content / Playlist',
self::REWARD => 'Reward / Contest',
};
}
public function getDescription(): string
{
return match ($this) {
self::PRE_RELEASE => 'Pre-save campaign for upcoming releases',
self::RELEASE => 'Album or single release link',
self::BIO_LINK => 'Artist bio or creator landing page',
self::EVENT => 'Concert, tour or event tickets',
self::CONTENT => 'Video, playlist or content promotion',
self::REWARD => 'Contest, giveaway or reward campaign',
};
}
public function requiresReleaseDate(): bool
{
return $this === self::PRE_RELEASE;
}
public function supportsMultipleSections(): bool
{
return $this === self::BIO_LINK;
}
public function isTimebound(): bool
{
return match ($this) {
self::PRE_RELEASE, self::EVENT, self::REWARD => true,
default => false,
};
}
}

View File

@@ -0,0 +1,27 @@
<?php
declare(strict_types=1);
namespace App\Domain\SmartLink\Enums;
enum ServiceCategory: string
{
case MUSIC_STREAMING = 'music_streaming';
case VIDEO = 'video';
case SOCIAL_MEDIA = 'social_media';
case ECOMMERCE = 'ecommerce';
case TICKETING = 'ticketing';
case CUSTOM = 'custom';
public function getLabel(): string
{
return match ($this) {
self::MUSIC_STREAMING => 'Music Streaming',
self::VIDEO => 'Video',
self::SOCIAL_MEDIA => 'Social Media',
self::ECOMMERCE => 'E-Commerce',
self::TICKETING => 'Ticketing',
self::CUSTOM => 'Custom',
};
}
}

View File

@@ -0,0 +1,119 @@
<?php
declare(strict_types=1);
namespace App\Domain\SmartLink\Enums;
enum ServiceType: string
{
// Music Streaming
case SPOTIFY = 'spotify';
case APPLE_MUSIC = 'apple_music';
case YOUTUBE_MUSIC = 'youtube_music';
case DEEZER = 'deezer';
case TIDAL = 'tidal';
case AMAZON_MUSIC = 'amazon_music';
case SOUNDCLOUD = 'soundcloud';
// Video
case YOUTUBE = 'youtube';
case VIMEO = 'vimeo';
// Social Media
case INSTAGRAM = 'instagram';
case TIKTOK = 'tiktok';
case FACEBOOK = 'facebook';
case TWITTER = 'twitter';
// E-Commerce
case BANDCAMP = 'bandcamp';
case SHOPIFY = 'shopify';
// Ticketing
case EVENTBRITE = 'eventbrite';
case TICKETMASTER = 'ticketmaster';
// Generic
case CUSTOM = 'custom';
public function getLabel(): string
{
return match ($this) {
self::SPOTIFY => 'Spotify',
self::APPLE_MUSIC => 'Apple Music',
self::YOUTUBE_MUSIC => 'YouTube Music',
self::DEEZER => 'Deezer',
self::TIDAL => 'Tidal',
self::AMAZON_MUSIC => 'Amazon Music',
self::SOUNDCLOUD => 'SoundCloud',
self::YOUTUBE => 'YouTube',
self::VIMEO => 'Vimeo',
self::INSTAGRAM => 'Instagram',
self::TIKTOK => 'TikTok',
self::FACEBOOK => 'Facebook',
self::TWITTER => 'Twitter',
self::BANDCAMP => 'Bandcamp',
self::SHOPIFY => 'Shopify',
self::EVENTBRITE => 'Eventbrite',
self::TICKETMASTER => 'Ticketmaster',
self::CUSTOM => 'Custom Link',
};
}
public function getCategory(): ServiceCategory
{
return match ($this) {
self::SPOTIFY, self::APPLE_MUSIC, self::YOUTUBE_MUSIC, self::DEEZER,
self::TIDAL, self::AMAZON_MUSIC, self::SOUNDCLOUD => ServiceCategory::MUSIC_STREAMING,
self::YOUTUBE, self::VIMEO => ServiceCategory::VIDEO,
self::INSTAGRAM, self::TIKTOK, self::FACEBOOK, self::TWITTER => ServiceCategory::SOCIAL_MEDIA,
self::BANDCAMP, self::SHOPIFY => ServiceCategory::ECOMMERCE,
self::EVENTBRITE, self::TICKETMASTER => ServiceCategory::TICKETING,
self::CUSTOM => ServiceCategory::CUSTOM,
};
}
public function getIconClass(): string
{
return match ($this) {
self::SPOTIFY => 'icon-spotify',
self::APPLE_MUSIC => 'icon-apple',
self::YOUTUBE, self::YOUTUBE_MUSIC => 'icon-youtube',
self::DEEZER => 'icon-deezer',
self::TIDAL => 'icon-tidal',
self::AMAZON_MUSIC => 'icon-amazon',
self::SOUNDCLOUD => 'icon-soundcloud',
self::VIMEO => 'icon-vimeo',
self::INSTAGRAM => 'icon-instagram',
self::TIKTOK => 'icon-tiktok',
self::FACEBOOK => 'icon-facebook',
self::TWITTER => 'icon-twitter',
self::BANDCAMP => 'icon-bandcamp',
self::SHOPIFY => 'icon-shopify',
self::EVENTBRITE => 'icon-eventbrite',
self::TICKETMASTER => 'icon-ticketmaster',
self::CUSTOM => 'icon-link',
};
}
public function supportsPreSave(): bool
{
return match ($this) {
self::SPOTIFY, self::APPLE_MUSIC, self::DEEZER => true,
default => false,
};
}
public function requiresAuthentication(): bool
{
return match ($this) {
self::SPOTIFY, self::APPLE_MUSIC => true,
default => false,
};
}
}

View File

@@ -0,0 +1,20 @@
<?php
declare(strict_types=1);
namespace App\Domain\SmartLink\Exceptions;
use App\Domain\SmartLink\ValueObjects\ShortCode;
use App\Framework\Exception\ErrorCode;
use App\Framework\Exception\FrameworkException;
final class ShortCodeAlreadyExistsException extends FrameworkException
{
public static function forCode(ShortCode $shortCode): self
{
return self::create(
ErrorCode::VAL_BUSINESS_RULE_VIOLATION,
sprintf('ShortCode "%s" is already in use', $shortCode->toString())
)->withData(['short_code' => $shortCode->toString()]);
}
}

View File

@@ -0,0 +1,29 @@
<?php
declare(strict_types=1);
namespace App\Domain\SmartLink\Exceptions;
use App\Domain\SmartLink\ValueObjects\ShortCode;
use App\Domain\SmartLink\ValueObjects\SmartLinkId;
use App\Framework\Exception\ErrorCode;
use App\Framework\Exception\FrameworkException;
final class SmartLinkNotFoundException extends FrameworkException
{
public static function forId(SmartLinkId $id): self
{
return self::create(
ErrorCode::ENTITY_NOT_FOUND,
sprintf('SmartLink with ID "%s" not found', $id->toString())
)->withData(['link_id' => $id->toString()]);
}
public static function forShortCode(ShortCode $shortCode): self
{
return self::create(
ErrorCode::ENTITY_NOT_FOUND,
sprintf('SmartLink with code "%s" not found', $shortCode->toString())
)->withData(['short_code' => $shortCode->toString()]);
}
}

View File

@@ -0,0 +1,22 @@
<?php
declare(strict_types=1);
namespace App\Domain\SmartLink\Initializers;
use App\Domain\SmartLink\Repositories\DatabaseGeoRoutingRuleRepository;
use App\Domain\SmartLink\Repositories\GeoRoutingRuleRepository;
use App\Framework\Database\ConnectionInterface;
use App\Framework\DI\Container;
use App\Framework\DI\Initializer;
final readonly class GeoRoutingRuleRepositoryInitializer
{
#[Initializer]
public function initialize(Container $container): GeoRoutingRuleRepository
{
return new DatabaseGeoRoutingRuleRepository(
$container->get(ConnectionInterface::class)
);
}
}

View File

@@ -0,0 +1,58 @@
<?php
declare(strict_types=1);
namespace App\Domain\SmartLink\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\ForeignKeyAction;
use App\Framework\Database\Schema\Schema;
final readonly class CreateAnalyticsAggregatesTable implements Migration
{
public function up(ConnectionInterface $connection): void
{
$schema = new Schema($connection);
$schema->create('analytics_aggregates', function (Blueprint $table) {
$table->id(); // AUTO_INCREMENT primary key
$table->string('link_id', 26);
$table->date('date');
$table->integer('total_clicks')->default(0);
$table->integer('unique_clicks')->default(0);
$table->integer('conversions')->default(0);
$table->json('country_breakdown'); // {"US": 150, "DE": 80, ...}
$table->json('device_breakdown'); // {"mobile": 180, "desktop": 50}
$table->json('service_breakdown'); // {"spotify": 120, "apple_music": 110}
$table->timestamp('created_at');
$table->timestamp('updated_at');
$table->unique(['link_id', 'date']); // One row per link per date
$table->foreign('link_id')->references('id')->on('smart_links')->onDelete(ForeignKeyAction::CASCADE);
$table->index('date');
});
$schema->execute();
}
public function down(ConnectionInterface $connection): void
{
$schema = new Schema($connection);
$schema->dropIfExists('analytics_aggregates');
$schema->execute();
}
public function getVersion(): MigrationVersion
{
return MigrationVersion::fromTimestamp('2025_01_01_000005');
}
public function getDescription(): string
{
return 'CreateAnalyticsAggregatesTable';
}
}

View File

@@ -0,0 +1,59 @@
<?php
declare(strict_types=1);
namespace App\Domain\SmartLink\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\ForeignKeyAction;
use App\Framework\Database\Schema\Schema;
final readonly class CreateClickEventsTable implements Migration
{
public function up(ConnectionInterface $connection): void
{
$schema = new Schema($connection);
$schema->create('click_events', function (Blueprint $table) {
$table->string('id', 26)->primary(); // ULID
$table->string('link_id', 26);
$table->string('ip_hash', 64); // SHA-256 hash
$table->string('country_code', 2);
$table->string('device_type', 20); // enum: DeviceType
$table->text('user_agent');
$table->text('referer')->nullable();
$table->string('destination_service', 30)->nullable();
$table->boolean('converted')->default(false);
$table->timestamp('clicked_at');
$table->foreign('link_id')->references('id')->on('smart_links')->onDelete(ForeignKeyAction::CASCADE);
$table->index('link_id');
$table->index('clicked_at');
$table->index(['link_id', 'clicked_at']);
$table->index('ip_hash'); // For unique visitor tracking
});
$schema->execute();
}
public function down(ConnectionInterface $connection): void
{
$schema = new Schema($connection);
$schema->dropIfExists('click_events');
$schema->execute();
}
public function getVersion(): MigrationVersion
{
return MigrationVersion::fromTimestamp('2025_01_01_000004');
}
public function getDescription(): string
{
return 'CreateClickEventsTable';
}
}

View File

@@ -0,0 +1,54 @@
<?php
declare(strict_types=1);
namespace App\Domain\SmartLink\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 CreateGeoRoutingRulesTable implements Migration
{
public function up(ConnectionInterface $connection): void
{
$schema = new Schema($connection);
$schema->create('geo_routing_rules', function (Blueprint $table) {
$table->string('id', 26)->primary();
$table->string('link_id', 26);
$table->string('country_code', 2);
$table->string('destination_id', 26);
$table->integer('priority')->default(0);
$table->timestamp('created_at');
// Indexes (Foreign Keys werden später hinzugefügt wenn nötig)
$table->index('link_id');
$table->index('destination_id');
$table->index(['link_id', 'country_code']);
$table->index('priority');
});
$schema->execute();
}
public function down(ConnectionInterface $connection): void
{
$schema = new Schema($connection);
$schema->dropIfExists('geo_routing_rules');
$schema->execute();
}
public function getVersion(): MigrationVersion
{
return MigrationVersion::fromTimestamp('2025_01_04_150000');
}
public function getDescription(): string
{
return 'CreateGeoRoutingRulesTable';
}
}

View File

@@ -0,0 +1,53 @@
<?php
declare(strict_types=1);
namespace App\Domain\SmartLink\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\ForeignKeyAction;
use App\Framework\Database\Schema\Schema;
final readonly class CreateGeoRulesTable implements Migration
{
public function up(ConnectionInterface $connection): void
{
$schema = new Schema($connection);
$schema->create('geo_rules', function (Blueprint $table) {
$table->string('id', 50)->primary();
$table->string('link_id', 26);
$table->string('country_code', 2);
$table->string('service_type', 30); // enum: ServiceType
$table->text('url');
$table->timestamp('created_at');
$table->foreign('link_id')->references('id')->on('smart_links')->onDelete(ForeignKeyAction::CASCADE);
$table->index('link_id');
$table->index('country_code');
$table->unique(['link_id', 'country_code']); // One rule per country per link
});
$schema->execute();
}
public function down(ConnectionInterface $connection): void
{
$schema = new Schema($connection);
$schema->dropIfExists('geo_rules');
$schema->execute();
}
public function getVersion(): MigrationVersion
{
return MigrationVersion::fromTimestamp('2025_01_01_000003');
}
public function getDescription(): string
{
return 'Create geo_rules table for SmartLink geolocation routing';
}
}

View File

@@ -0,0 +1,54 @@
<?php
declare(strict_types=1);
namespace App\Domain\SmartLink\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\ForeignKeyAction;
use App\Framework\Database\Schema\Schema;
final readonly class CreateLinkDestinationsTable implements Migration
{
public function up(ConnectionInterface $connection): void
{
$schema = new Schema($connection);
$schema->create('link_destinations', function (Blueprint $table) {
$table->string('id', 50)->primary();
$table->string('link_id', 26);
$table->string('service_type', 30); // enum: ServiceType
$table->text('url');
$table->integer('priority')->default(0);
$table->boolean('is_default')->default(false);
$table->timestamp('created_at');
$table->foreign('link_id')->references('id')->on('smart_links')->onDelete(ForeignKeyAction::CASCADE);
$table->index('link_id');
$table->index('service_type');
});
$schema->execute();
}
public function down(ConnectionInterface $connection): void
{
$schema = new Schema($connection);
$schema->dropIfExists('link_destinations');
$schema->execute();
}
public function getVersion(): MigrationVersion
{
return MigrationVersion::fromTimestamp('2025_01_01_000002');
}
public function getDescription(): string
{
return 'Create link_destinations table for SmartLink service routing';
}
}

View File

@@ -0,0 +1,57 @@
<?php
declare(strict_types=1);
namespace App\Domain\SmartLink\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 CreateSmartLinksTable implements Migration
{
public function up(ConnectionInterface $connection): void
{
$schema = new Schema($connection);
$schema->create('smart_links', function (Blueprint $table) {
$table->string('id', 26)->primary(); // ULID
$table->string('short_code', 8)->unique();
$table->string('type', 20); // enum: LinkType
$table->string('title', 200);
$table->text('cover_image_url')->nullable();
$table->string('status', 20)->default('draft'); // enum: LinkStatus
$table->string('user_id', 26)->nullable();
$table->json('settings'); // LinkSettings object
$table->timestamp('created_at');
$table->timestamp('updated_at');
$table->index('short_code');
$table->index('user_id');
$table->index('status');
$table->index('created_at');
});
$schema->execute();
}
public function down(ConnectionInterface $connection): void
{
$schema = new Schema($connection);
$schema->dropIfExists('smart_links');
$schema->execute();
}
public function getVersion(): MigrationVersion
{
return MigrationVersion::fromTimestamp('2025_01_01_000001');
}
public function getDescription(): string
{
return 'CreateSmartLinksTable';
}
}

View File

@@ -0,0 +1,24 @@
<?php
declare(strict_types=1);
namespace App\Domain\SmartLink\Repositories;
use App\Domain\SmartLink\Entities\ClickEvent;
use App\Domain\SmartLink\ValueObjects\SmartLinkId;
use App\Framework\Core\ValueObjects\Timestamp;
interface ClickEventRepository
{
public function save(ClickEvent $event): void;
public function findByLinkId(SmartLinkId $linkId, ?Timestamp $since = null): array;
public function getRecentClicksByIpHash(string $ipHash, int $minutes = 60): array;
public function countByLinkId(SmartLinkId $linkId): int;
public function countUniqueByLinkId(SmartLinkId $linkId): int;
public function getConversionsByLinkId(SmartLinkId $linkId): int;
}

View File

@@ -0,0 +1,117 @@
<?php
declare(strict_types=1);
namespace App\Domain\SmartLink\Repositories;
use App\Domain\SmartLink\Entities\ClickEvent;
use App\Domain\SmartLink\Enums\DeviceType;
use App\Domain\SmartLink\ValueObjects\ClickId;
use App\Domain\SmartLink\ValueObjects\SmartLinkId;
use App\Framework\Core\ValueObjects\CountryCode;
use App\Framework\Core\ValueObjects\Timestamp;
use App\Framework\Database\ConnectionInterface;
use App\Framework\Database\ValueObjects\SqlQuery;
final readonly class DatabaseClickEventRepository implements ClickEventRepository
{
public function __construct(
private ConnectionInterface $connection
) {}
public function save(ClickEvent $event): void
{
$sql = 'INSERT INTO click_events
(id, link_id, ip_hash, country_code, device_type, user_agent, referer, destination_service, converted, clicked_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)';
$query = SqlQuery::create($sql, [
$event->id->toString(),
$event->linkId->toString(),
$event->ipHash,
$event->countryCode->toString(),
$event->deviceType->value,
$event->userAgentString,
$event->referer,
$event->destinationService,
$event->converted ? 1 : 0,
$event->clickedAt->format('Y-m-d H:i:s'),
]);
$this->connection->execute($query);
}
public function findByLinkId(SmartLinkId $linkId, ?Timestamp $since = null): array
{
if ($since !== null) {
$sql = 'SELECT * FROM click_events WHERE link_id = ? AND clicked_at >= ? ORDER BY clicked_at DESC';
$query = SqlQuery::create($sql, [$linkId->toString(), $since->format('Y-m-d H:i:s')]);
} else {
$sql = 'SELECT * FROM click_events WHERE link_id = ? ORDER BY clicked_at DESC';
$query = SqlQuery::create($sql, [$linkId->toString()]);
}
$result = $this->connection->query($query);
$rows = $result->fetchAll();
return array_map(fn(array $row) => $this->hydrate($row), $rows);
}
public function getRecentClicksByIpHash(string $ipHash, int $minutes = 60): array
{
$since = Timestamp::now()->subtractMinutes($minutes);
$sql = 'SELECT * FROM click_events WHERE ip_hash = ? AND clicked_at >= ?';
$query = SqlQuery::create($sql, [$ipHash, $since->format('Y-m-d H:i:s')]);
$result = $this->connection->query($query);
$rows = $result->fetchAll();
return array_map(fn(array $row) => $this->hydrate($row), $rows);
}
public function countByLinkId(SmartLinkId $linkId): int
{
$sql = 'SELECT COUNT(*) as count FROM click_events WHERE link_id = ?';
$query = SqlQuery::create($sql, [$linkId->toString()]);
$result = $this->connection->query($query);
$row = $result->fetch();
return (int) ($row['count'] ?? 0);
}
public function countUniqueByLinkId(SmartLinkId $linkId): int
{
$sql = 'SELECT COUNT(DISTINCT ip_hash) as count FROM click_events WHERE link_id = ?';
$query = SqlQuery::create($sql, [$linkId->toString()]);
$result = $this->connection->query($query);
$row = $result->fetch();
return (int) ($row['count'] ?? 0);
}
public function getConversionsByLinkId(SmartLinkId $linkId): int
{
$sql = 'SELECT COUNT(*) as count FROM click_events WHERE link_id = ? AND converted = 1';
$query = SqlQuery::create($sql, [$linkId->toString()]);
$result = $this->connection->query($query);
$row = $result->fetch();
return (int) ($row['count'] ?? 0);
}
private function hydrate(array $row): ClickEvent
{
return new ClickEvent(
id: ClickId::fromString($row['id']),
linkId: SmartLinkId::fromString($row['link_id']),
ipHash: $row['ip_hash'],
countryCode: CountryCode::fromString($row['country_code']),
deviceType: DeviceType::from($row['device_type']),
userAgentString: $row['user_agent'],
referer: $row['referer'],
destinationService: $row['destination_service'],
converted: (bool) $row['converted'],
clickedAt: Timestamp::fromDateTime(new \DateTimeImmutable($row['clicked_at']))
);
}
}

View File

@@ -0,0 +1,120 @@
<?php
declare(strict_types=1);
namespace App\Domain\SmartLink\Repositories;
use App\Domain\SmartLink\Entities\GeoRoutingRule;
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\Database\ConnectionInterface;
use App\Framework\Database\ValueObjects\SqlQuery;
final readonly class DatabaseGeoRoutingRuleRepository implements GeoRoutingRuleRepository
{
public function __construct(
private ConnectionInterface $connection
) {}
public function save(GeoRoutingRule $rule): void
{
$sql = 'INSERT INTO geo_routing_rules
(id, link_id, country_code, destination_id, priority, created_at)
VALUES (?, ?, ?, ?, ?, ?)
ON DUPLICATE KEY UPDATE
country_code = VALUES(country_code),
destination_id = VALUES(destination_id),
priority = VALUES(priority)';
$query = SqlQuery::create($sql, [
$rule->id->toString(),
$rule->linkId->toString(),
$rule->countryCode->toString(),
$rule->destinationId,
$rule->priority,
$rule->createdAt->format('Y-m-d H:i:s'),
]);
$this->connection->execute($query);
}
public function findById(GeoRuleId $id): ?GeoRoutingRule
{
$sql = 'SELECT * FROM geo_routing_rules WHERE id = ?';
$query = SqlQuery::create($sql, [$id->toString()]);
$result = $this->connection->query($query);
$row = $result->fetch();
if ($row === false) {
return null;
}
return $this->hydrate($row);
}
public function findByLinkAndCountry(SmartLinkId $linkId, CountryCode $countryCode): ?GeoRoutingRule
{
$sql = 'SELECT * FROM geo_routing_rules
WHERE link_id = ? AND country_code = ?
ORDER BY priority DESC
LIMIT 1';
$query = SqlQuery::create($sql, [
$linkId->toString(),
$countryCode->toString()
]);
$result = $this->connection->query($query);
$row = $result->fetch();
if ($row === false) {
return null;
}
return $this->hydrate($row);
}
public function findByLinkId(SmartLinkId $linkId): array
{
$sql = 'SELECT * FROM geo_routing_rules
WHERE link_id = ?
ORDER BY priority DESC, country_code ASC';
$query = SqlQuery::create($sql, [$linkId->toString()]);
$result = $this->connection->query($query);
$rows = $result->fetchAll();
return array_map(fn(array $row) => $this->hydrate($row), $rows);
}
public function delete(GeoRuleId $id): void
{
$sql = 'DELETE FROM geo_routing_rules WHERE id = ?';
$query = SqlQuery::create($sql, [$id->toString()]);
$this->connection->execute($query);
}
public function deleteByLinkId(SmartLinkId $linkId): void
{
$sql = 'DELETE FROM geo_routing_rules WHERE link_id = ?';
$query = SqlQuery::create($sql, [$linkId->toString()]);
$this->connection->execute($query);
}
private function hydrate(array $row): GeoRoutingRule
{
return new GeoRoutingRule(
id: GeoRuleId::fromString($row['id']),
linkId: SmartLinkId::fromString($row['link_id']),
countryCode: CountryCode::fromString($row['country_code']),
destinationId: $row['destination_id'],
priority: (int) $row['priority'],
createdAt: Timestamp::fromDateTime(new \DateTimeImmutable($row['created_at']))
);
}
}

View File

@@ -0,0 +1,100 @@
<?php
declare(strict_types=1);
namespace App\Domain\SmartLink\Repositories;
use App\Domain\SmartLink\Entities\LinkDestination;
use App\Domain\SmartLink\Enums\ServiceType;
use App\Domain\SmartLink\ValueObjects\DestinationUrl;
use App\Domain\SmartLink\ValueObjects\SmartLinkId;
use App\Framework\Core\ValueObjects\Timestamp;
use App\Framework\Database\ConnectionInterface;
use App\Framework\Database\ValueObjects\SqlQuery;
final readonly class DatabaseLinkDestinationRepository implements LinkDestinationRepository
{
public function __construct(
private ConnectionInterface $connection
) {}
public function save(LinkDestination $destination): void
{
$sql = 'INSERT INTO link_destinations (id, link_id, service_type, url, priority, is_default, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?)
ON DUPLICATE KEY UPDATE
service_type = VALUES(service_type),
url = VALUES(url),
priority = VALUES(priority),
is_default = VALUES(is_default)';
$query = SqlQuery::create($sql, [
$destination->id,
$destination->linkId->toString(),
$destination->serviceType->value,
$destination->url->toString(),
$destination->priority,
$destination->isDefault,
$destination->createdAt->format('Y-m-d H:i:s'),
]);
$this->connection->execute($query);
}
public function findById(string $id): ?LinkDestination
{
$sql = 'SELECT * FROM link_destinations WHERE id = ? LIMIT 1';
$query = SqlQuery::create($sql, [$id]);
$result = $this->connection->query($query);
$row = $result->fetch();
return $row ? $this->hydrate($row) : null;
}
public function findByLinkId(SmartLinkId $linkId): array
{
$rows = $this->connection->fetchAll(
'SELECT * FROM link_destinations WHERE link_id = ? ORDER BY priority DESC, created_at ASC',
[$linkId->toString()]
);
return array_map(fn(array $row) => $this->hydrate($row), $rows);
}
public function findDefaultByLinkId(SmartLinkId $linkId): ?LinkDestination
{
$sql = 'SELECT * FROM link_destinations WHERE link_id = ? AND is_default = 1 LIMIT 1';
$query = SqlQuery::create($sql, [$linkId->toString()]);
$result = $this->connection->query($query);
$row = $result->fetch();
return $row ? $this->hydrate($row) : null;
}
public function deleteByLinkId(SmartLinkId $linkId): void
{
$sql = 'DELETE FROM link_destinations WHERE link_id = ?';
$query = SqlQuery::create($sql, [$linkId->toString()]);
$this->connection->execute($query);
}
public function deleteById(string $id): void
{
$sql = 'DELETE FROM link_destinations WHERE id = ?';
$query = SqlQuery::create($sql, [$id]);
$this->connection->execute($query);
}
private function hydrate(array $row): LinkDestination
{
return new LinkDestination(
id: $row['id'],
linkId: SmartLinkId::fromString($row['link_id']),
serviceType: ServiceType::from($row['service_type']),
url: DestinationUrl::fromString($row['url']),
priority: (int) $row['priority'],
isDefault: (bool) $row['is_default'],
createdAt: Timestamp::fromDateTime(new \DateTimeImmutable($row['created_at']))
);
}
}

View File

@@ -0,0 +1,149 @@
<?php
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\LinkTitle;
use App\Domain\SmartLink\ValueObjects\ShortCode;
use App\Domain\SmartLink\ValueObjects\SmartLinkId;
use App\Framework\Core\ValueObjects\Timestamp;
use App\Framework\Database\ConnectionInterface;
use App\Framework\Database\ValueObjects\SqlQuery;
final readonly class DatabaseSmartLinkRepository implements SmartLinkRepository
{
public function __construct(
private ConnectionInterface $connection
) {}
public function save(SmartLink $link): void
{
$sql = 'INSERT INTO smart_links (id, short_code, type, title, cover_image_url, status, user_id, settings, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON DUPLICATE KEY UPDATE
type = VALUES(type),
title = VALUES(title),
cover_image_url = VALUES(cover_image_url),
status = VALUES(status),
settings = VALUES(settings),
updated_at = VALUES(updated_at)';
$query = SqlQuery::create($sql, [
$link->id->toString(),
$link->shortCode->toString(),
$link->type->value,
$link->title->toString(),
$link->coverImageUrl,
$link->status->value,
$link->userId,
json_encode($link->settings->toArray()),
$link->createdAt->format('Y-m-d H:i:s'),
$link->updatedAt->format('Y-m-d H:i:s'),
]);
$this->connection->execute($query);
}
public function findById(SmartLinkId $id): ?SmartLink
{
$sql = 'SELECT * FROM smart_links WHERE id = ?';
$query = SqlQuery::create($sql, [$id->toString()]);
$result = $this->connection->query($query);
$row = $result->fetch();
return $row ? $this->hydrate($row) : null;
}
public function findByShortCode(ShortCode $shortCode): ?SmartLink
{
$sql = 'SELECT * FROM smart_links WHERE short_code = ?';
$query = SqlQuery::create($sql, [$shortCode->toString()]);
$result = $this->connection->query($query);
$row = $result->fetch();
return $row ? $this->hydrate($row) : null;
}
public function findByUserId(string $userId, ?LinkStatus $status = null): array
{
if ($status !== null) {
$sql = 'SELECT * FROM smart_links WHERE user_id = ? AND status = ? ORDER BY created_at DESC';
$query = SqlQuery::create($sql, [$userId, $status->value]);
} else {
$sql = 'SELECT * FROM smart_links WHERE user_id = ? ORDER BY created_at DESC';
$query = SqlQuery::create($sql, [$userId]);
}
$result = $this->connection->query($query);
$rows = $result->fetchAll();
return array_map(fn(array $row) => $this->hydrate($row), $rows);
}
public function existsShortCode(ShortCode $shortCode): bool
{
$sql = 'SELECT COUNT(*) as count FROM smart_links WHERE short_code = ?';
$query = SqlQuery::create($sql, [$shortCode->toString()]);
$result = $this->connection->query($query);
$count = $result->fetch();
return ($count['count'] ?? 0) > 0;
}
public function delete(SmartLinkId $id): void
{
$sql = 'DELETE FROM smart_links WHERE id = ?';
$query = SqlQuery::create($sql, [$id->toString()]);
$this->connection->execute($query);
}
public function findActiveLinks(): array
{
$sql = 'SELECT * FROM smart_links WHERE status = ? ORDER BY created_at DESC';
$query = SqlQuery::create($sql, [LinkStatus::ACTIVE->value]);
$result = $this->connection->query($query);
$rows = $result->fetchAll();
return array_map(fn(array $row) => $this->hydrate($row), $rows);
}
public function getTotalClicks(SmartLinkId $id): int
{
$sql = 'SELECT COUNT(*) as count FROM click_events WHERE link_id = ?';
$query = SqlQuery::create($sql, [$id->toString()]);
$result = $this->connection->query($query);
$row = $result->fetch();
return (int) ($row['count'] ?? 0);
}
private function hydrate(array $row): SmartLink
{
$settings = json_decode($row['settings'], true);
return new SmartLink(
id: SmartLinkId::fromString($row['id']),
shortCode: ShortCode::fromString($row['short_code']),
type: LinkType::from($row['type']),
title: LinkTitle::fromString($row['title']),
coverImageUrl: $row['cover_image_url'],
status: LinkStatus::from($row['status']),
userId: $row['user_id'],
settings: new LinkSettings(
trackClicks: (bool) $settings['track_clicks'],
enableGeoRouting: (bool) $settings['enable_geo_routing'],
showPreview: (bool) $settings['show_preview'],
customDomain: $settings['custom_domain'] ?? null,
clickLimit: $settings['click_limit'] ?? null,
password: $settings['password'] ?? null
),
createdAt: Timestamp::fromDateTime(new \DateTimeImmutable($row['created_at'])),
updatedAt: Timestamp::fromDateTime(new \DateTimeImmutable($row['updated_at']))
);
}
}

View File

@@ -0,0 +1,31 @@
<?php
declare(strict_types=1);
namespace App\Domain\SmartLink\Repositories;
use App\Domain\SmartLink\Entities\GeoRoutingRule;
use App\Domain\SmartLink\ValueObjects\GeoRuleId;
use App\Domain\SmartLink\ValueObjects\SmartLinkId;
use App\Framework\Core\ValueObjects\CountryCode;
interface GeoRoutingRuleRepository
{
public function save(GeoRoutingRule $rule): void;
public function findById(GeoRuleId $id): ?GeoRoutingRule;
/**
* Findet Geo-Routing-Regel für Link + Land, sortiert nach Priorität
*/
public function findByLinkAndCountry(SmartLinkId $linkId, CountryCode $countryCode): ?GeoRoutingRule;
/**
* Findet alle Geo-Routing-Regeln für einen Link
*/
public function findByLinkId(SmartLinkId $linkId): array;
public function delete(GeoRuleId $id): void;
public function deleteByLinkId(SmartLinkId $linkId): void;
}

View File

@@ -0,0 +1,23 @@
<?php
declare(strict_types=1);
namespace App\Domain\SmartLink\Repositories;
use App\Domain\SmartLink\Entities\LinkDestination;
use App\Domain\SmartLink\ValueObjects\SmartLinkId;
interface LinkDestinationRepository
{
public function save(LinkDestination $destination): void;
public function findById(string $id): ?LinkDestination;
public function findByLinkId(SmartLinkId $linkId): array;
public function findDefaultByLinkId(SmartLinkId $linkId): ?LinkDestination;
public function deleteByLinkId(SmartLinkId $linkId): void;
public function deleteById(string $id): void;
}

View File

@@ -0,0 +1,29 @@
<?php
declare(strict_types=1);
namespace App\Domain\SmartLink\Repositories;
use App\Domain\SmartLink\Entities\SmartLink;
use App\Domain\SmartLink\Enums\LinkStatus;
use App\Domain\SmartLink\ValueObjects\ShortCode;
use App\Domain\SmartLink\ValueObjects\SmartLinkId;
interface SmartLinkRepository
{
public function save(SmartLink $link): void;
public function findById(SmartLinkId $id): ?SmartLink;
public function findByShortCode(ShortCode $shortCode): ?SmartLink;
public function findByUserId(string $userId, ?LinkStatus $status = null): array;
public function existsShortCode(ShortCode $shortCode): bool;
public function delete(SmartLinkId $id): void;
public function findActiveLinks(): array;
public function getTotalClicks(SmartLinkId $id): int;
}

View File

@@ -0,0 +1,66 @@
<?php
declare(strict_types=1);
namespace App\Domain\SmartLink\Services;
use App\Domain\SmartLink\Entities\ClickEvent;
use App\Domain\SmartLink\Entities\SmartLink;
use App\Domain\SmartLink\Enums\ServiceType;
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
{
public function __construct(
private ClickEventRepository $repository,
private GeoIp $geoIp,
private Clock $clock
) {}
public function trackClick(
SmartLink $smartLink,
HttpRequest $request,
ServiceType $destinationService
): ClickEvent {
$ip = $request->server->getClientIp();
$userAgent = $request->server->getUserAgent();
$referer = $request->server->getReferer();
$countryCode = $this->geoIp->getCountryCode($ip);
$event = ClickEvent::create(
clock: $this->clock,
linkId: $smartLink->id,
ip: $ip,
countryCode: $countryCode,
userAgent: $userAgent,
referer: $referer,
destinationService: $destinationService->value
);
$this->repository->save($event);
return $event;
}
public function isUniqueClick(ClickEvent $event): bool
{
$recentClicks = $this->repository->getRecentClicksByIpHash($event->ipHash, 60);
return $event->isUnique($recentClicks);
}
public function getLinkStats(SmartLinkId $linkId): array
{
return [
'total_clicks' => $this->repository->countByLinkId($linkId),
'unique_clicks' => $this->repository->countUniqueByLinkId($linkId),
'conversions' => $this->repository->getConversionsByLinkId($linkId),
];
}
}

View File

@@ -0,0 +1,95 @@
<?php
declare(strict_types=1);
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
{
public function __construct(
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()) {
throw new \DomainException('Link is not accessible');
}
// Check click limit
if ($smartLink->settings->hasClickLimitReached($this->getCurrentClicks($smartLink))) {
throw new \DomainException('Link has reached click limit');
}
// Apply routing strategies in order
if ($smartLink->settings->enableGeoRouting) {
$ip = $request->server->getClientIp();
$country = $this->geoIp->getCountryCode($ip);
$geoDestination = $this->applyGeoRouting($smartLink, $country);
if ($geoDestination !== null) {
return $geoDestination;
}
}
// Fallback to default destination
return $this->getDefaultDestination($smartLink);
}
private function applyGeoRouting(SmartLink $link, CountryCode $country): ?LinkDestination
{
// Find geo-routing rule for this link and country
$geoRule = $this->geoRoutingRuleRepository->findByLinkAndCountry($link->id, $country);
if ($geoRule === null) {
return null;
}
// Find the destination referenced by the geo rule
$destination = $this->destinationRepository->findById($geoRule->destinationId);
return $destination;
}
private function getDefaultDestination(SmartLink $link): LinkDestination
{
$defaultDestination = $this->destinationRepository->findDefaultByLinkId($link->id);
if ($defaultDestination !== null) {
return $defaultDestination;
}
// Get first destination by priority
$destinations = $this->destinationRepository->findByLinkId($link->id);
if (empty($destinations)) {
throw new \DomainException('No destinations configured for this link');
}
return $destinations[0];
}
private function getCurrentClicks(SmartLink $link): int
{
// TODO: Implement click counting
return 0;
}
}

View File

@@ -0,0 +1,31 @@
<?php
declare(strict_types=1);
namespace App\Domain\SmartLink\Services;
use App\Domain\SmartLink\Repositories\SmartLinkRepository;
use App\Domain\SmartLink\ValueObjects\ShortCode;
final readonly class ShortCodeGenerator
{
private const MAX_ATTEMPTS = 10;
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)) {
return $shortCode;
}
}
// If we couldn't generate a unique code after MAX_ATTEMPTS, try with longer length
return $this->generateUnique($length + 1);
}
}

View File

@@ -0,0 +1,149 @@
<?php
declare(strict_types=1);
namespace App\Domain\SmartLink\Services;
use App\Domain\SmartLink\Entities\LinkDestination;
use App\Domain\SmartLink\Entities\SmartLink;
use App\Domain\SmartLink\Enums\LinkStatus;
use App\Domain\SmartLink\Enums\LinkType;
use App\Domain\SmartLink\Enums\ServiceType;
use App\Domain\SmartLink\Exceptions\ShortCodeAlreadyExistsException;
use App\Domain\SmartLink\Exceptions\SmartLinkNotFoundException;
use App\Domain\SmartLink\Repositories\LinkDestinationRepository;
use App\Domain\SmartLink\Repositories\SmartLinkRepository;
use App\Domain\SmartLink\ValueObjects\DestinationUrl;
use App\Domain\SmartLink\ValueObjects\LinkTitle;
use App\Domain\SmartLink\ValueObjects\ShortCode;
use App\Domain\SmartLink\ValueObjects\SmartLinkId;
use App\Framework\DateTime\Clock;
final readonly class SmartLinkService
{
public function __construct(
private SmartLinkRepository $linkRepository,
private LinkDestinationRepository $destinationRepository,
private ShortCodeGenerator $shortCodeGenerator,
private Clock $clock
) {}
public function createLink(
LinkType $type,
LinkTitle $title,
?string $userId = null,
?string $coverImageUrl = null,
?ShortCode $customShortCode = null
): SmartLink {
$shortCode = $customShortCode ?? $this->shortCodeGenerator->generateUnique();
if ($this->linkRepository->existsShortCode($shortCode)) {
throw ShortCodeAlreadyExistsException::forCode($shortCode);
}
$link = SmartLink::create(
clock: $this->clock,
shortCode: $shortCode,
type: $type,
title: $title,
userId: $userId,
coverImageUrl: $coverImageUrl
);
$this->linkRepository->save($link);
return $link;
}
public function findById(SmartLinkId $id): SmartLink
{
$link = $this->linkRepository->findById($id);
if ($link === null) {
throw SmartLinkNotFoundException::forId($id);
}
return $link;
}
public function findByShortCode(ShortCode $shortCode): SmartLink
{
$link = $this->linkRepository->findByShortCode($shortCode);
if ($link === null) {
throw SmartLinkNotFoundException::forShortCode($shortCode);
}
return $link;
}
public function getLinkByShortCode(ShortCode $shortCode): SmartLink
{
return $this->findByShortCode($shortCode);
}
public function updateTitle(SmartLinkId $id, LinkTitle $title): SmartLink
{
$link = $this->findById($id);
$updatedLink = $link->withTitle($title);
$this->linkRepository->save($updatedLink);
return $updatedLink;
}
public function publishLink(SmartLinkId $id): SmartLink
{
$link = $this->findById($id);
$publishedLink = $link->withStatus(LinkStatus::ACTIVE);
$this->linkRepository->save($publishedLink);
return $publishedLink;
}
public function pauseLink(SmartLinkId $id): SmartLink
{
$link = $this->findById($id);
$pausedLink = $link->withStatus(LinkStatus::PAUSED);
$this->linkRepository->save($pausedLink);
return $pausedLink;
}
public function addDestination(
SmartLinkId $linkId,
ServiceType $serviceType,
DestinationUrl $url,
int $priority = 0,
bool $isDefault = false
): LinkDestination {
$destination = LinkDestination::create(
linkId: $linkId,
serviceType: $serviceType,
url: $url,
priority: $priority,
isDefault: $isDefault
);
$this->destinationRepository->save($destination);
return $destination;
}
public function getDestinations(SmartLinkId $linkId): array
{
return $this->destinationRepository->findByLinkId($linkId);
}
public function deleteLink(SmartLinkId $id): void
{
$this->linkRepository->delete($id);
}
public function getUserLinks(string $userId, ?LinkStatus $status = null): array
{
return $this->linkRepository->findByUserId($userId, $status);
}
}

View File

@@ -0,0 +1,38 @@
<?php
declare(strict_types=1);
namespace App\Domain\SmartLink;
use App\Domain\SmartLink\Repositories\ClickEventRepository;
use App\Domain\SmartLink\Repositories\DatabaseClickEventRepository;
use App\Domain\SmartLink\Repositories\DatabaseLinkDestinationRepository;
use App\Domain\SmartLink\Repositories\DatabaseSmartLinkRepository;
use App\Domain\SmartLink\Repositories\LinkDestinationRepository;
use App\Domain\SmartLink\Repositories\SmartLinkRepository;
use App\Framework\Database\ConnectionInterface;
use App\Framework\DI\Initializer;
final readonly class SmartLinkServiceInitializer
{
#[Initializer]
public function initializeSmartLinkRepository(
ConnectionInterface $connection
): SmartLinkRepository {
return new DatabaseSmartLinkRepository($connection);
}
#[Initializer]
public function initializeLinkDestinationRepository(
ConnectionInterface $connection
): LinkDestinationRepository {
return new DatabaseLinkDestinationRepository($connection);
}
#[Initializer]
public function initializeClickEventRepository(
ConnectionInterface $connection
): ClickEventRepository {
return new DatabaseClickEventRepository($connection);
}
}

View File

@@ -0,0 +1,45 @@
<?php
declare(strict_types=1);
namespace App\Domain\SmartLink\ValueObjects;
use App\Framework\DateTime\Clock;
use App\Framework\Ulid\Ulid;
final readonly class ClickId
{
private function __construct(
private string $value
) {
if (!Ulid::isValid($value)) {
throw new \InvalidArgumentException('Invalid Click ID format');
}
}
public static function generate(Clock $clock): self
{
$ulid = new Ulid($clock);
return new self($ulid->__toString());
}
public static function fromString(string $value): self
{
return new self($value);
}
public function toString(): string
{
return $this->value;
}
public function equals(self $other): bool
{
return $this->value === $other->value;
}
public function __toString(): string
{
return $this->value;
}
}

View File

@@ -0,0 +1,56 @@
<?php
declare(strict_types=1);
namespace App\Domain\SmartLink\ValueObjects;
final readonly class DestinationUrl
{
private function __construct(
private string $value
) {
$this->validate();
}
public static function fromString(string $url): self
{
return new self($url);
}
private function validate(): void
{
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)) {
throw new \InvalidArgumentException('Destination URL must use HTTP or HTTPS protocol');
}
}
public function toString(): string
{
return $this->value;
}
public function getHost(): string
{
return parse_url($this->value, PHP_URL_HOST) ?? '';
}
public function isSecure(): bool
{
return parse_url($this->value, PHP_URL_SCHEME) === 'https';
}
public function equals(self $other): bool
{
return $this->value === $other->value;
}
public function __toString(): string
{
return $this->value;
}
}

View File

@@ -0,0 +1,36 @@
<?php
declare(strict_types=1);
namespace App\Domain\SmartLink\ValueObjects;
use App\Framework\DateTime\Clock;
use App\Framework\Ulid\Ulid;
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());
}
public static function fromString(string $id): self
{
return new self($id);
}
public function toString(): string
{
return $this->value;
}
public function equals(self $other): bool
{
return $this->value === $other->value;
}
}

View File

@@ -0,0 +1,112 @@
<?php
declare(strict_types=1);
namespace App\Domain\SmartLink\ValueObjects;
final readonly class LinkSettings
{
public function __construct(
public bool $trackClicks,
public bool $enableGeoRouting,
public bool $showPreview,
public ?string $customDomain,
public ?int $clickLimit,
public ?string $password
) {}
public static function default(): self
{
return new self(
trackClicks: true,
enableGeoRouting: true,
showPreview: true,
customDomain: null,
clickLimit: null,
password: null
);
}
public function withClickTracking(bool $enabled): self
{
return new self(
trackClicks: $enabled,
enableGeoRouting: $this->enableGeoRouting,
showPreview: $this->showPreview,
customDomain: $this->customDomain,
clickLimit: $this->clickLimit,
password: $this->password
);
}
public function withGeoRouting(bool $enabled): self
{
return new self(
trackClicks: $this->trackClicks,
enableGeoRouting: $enabled,
showPreview: $this->showPreview,
customDomain: $this->customDomain,
clickLimit: $this->clickLimit,
password: $this->password
);
}
public function withClickLimit(?int $limit): self
{
return new self(
trackClicks: $this->trackClicks,
enableGeoRouting: $this->enableGeoRouting,
showPreview: $this->showPreview,
customDomain: $this->customDomain,
clickLimit: $limit,
password: $this->password
);
}
public function withPassword(?string $password): self
{
return new self(
trackClicks: $this->trackClicks,
enableGeoRouting: $this->enableGeoRouting,
showPreview: $this->showPreview,
customDomain: $this->customDomain,
clickLimit: $this->clickLimit,
password: $password ? password_hash($password, PASSWORD_BCRYPT) : null
);
}
public function isPasswordProtected(): bool
{
return $this->password !== null;
}
public function verifyPassword(string $password): bool
{
if (!$this->isPasswordProtected()) {
return true;
}
return password_verify($password, $this->password);
}
public function hasClickLimitReached(int $currentClicks): bool
{
if ($this->clickLimit === null) {
return false;
}
return $currentClicks >= $this->clickLimit;
}
public function toArray(): array
{
return [
'track_clicks' => $this->trackClicks,
'enable_geo_routing' => $this->enableGeoRouting,
'show_preview' => $this->showPreview,
'custom_domain' => $this->customDomain,
'click_limit' => $this->clickLimit,
'has_password' => $this->isPasswordProtected(),
];
}
}

View File

@@ -0,0 +1,53 @@
<?php
declare(strict_types=1);
namespace App\Domain\SmartLink\ValueObjects;
final readonly class LinkTitle
{
private const MAX_LENGTH = 200;
private function __construct(
private string $value
) {
$this->validate();
}
public static function fromString(string $value): self
{
return new self(trim($value));
}
private function validate(): void
{
if (empty($this->value)) {
throw new \InvalidArgumentException('Link title cannot be empty');
}
if (strlen($this->value) > self::MAX_LENGTH) {
throw new \InvalidArgumentException(
sprintf('Link title cannot exceed %d characters', self::MAX_LENGTH)
);
}
}
public function toString(): string
{
return $this->value;
}
public function truncate(int $length): self
{
if (strlen($this->value) <= $length) {
return $this;
}
return new self(substr($this->value, 0, $length) . '...');
}
public function __toString(): string
{
return $this->value;
}
}

View File

@@ -0,0 +1,71 @@
<?php
declare(strict_types=1);
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 function __construct(
private string $value
) {
$this->validate();
}
public static function generate(int $length = 6): self
{
if ($length < self::MIN_LENGTH || $length > self::MAX_LENGTH) {
throw new \InvalidArgumentException(
sprintf('ShortCode length must be between %d and %d', self::MIN_LENGTH, self::MAX_LENGTH)
);
}
$code = '';
$maxIndex = strlen(self::ALLOWED_CHARS) - 1;
for ($i = 0; $i < $length; $i++) {
$code .= self::ALLOWED_CHARS[random_int(0, $maxIndex)];
}
return new self($code);
}
public static function fromString(string $value): self
{
return new self($value);
}
private function validate(): void
{
$length = strlen($this->value);
if ($length < self::MIN_LENGTH || $length > self::MAX_LENGTH) {
throw new \InvalidArgumentException(
sprintf('ShortCode must be between %d and %d characters', self::MIN_LENGTH, self::MAX_LENGTH)
);
}
if (!preg_match('/^[a-zA-Z0-9]+$/', $this->value)) {
throw new \InvalidArgumentException('ShortCode can only contain alphanumeric characters');
}
}
public function toString(): string
{
return $this->value;
}
public function equals(self $other): bool
{
return strtolower($this->value) === strtolower($other->value);
}
public function __toString(): string
{
return $this->value;
}
}

View File

@@ -0,0 +1,45 @@
<?php
declare(strict_types=1);
namespace App\Domain\SmartLink\ValueObjects;
use App\Framework\DateTime\Clock;
use App\Framework\Ulid\Ulid;
final readonly class SmartLinkId
{
private function __construct(
private string $value
) {
if (!Ulid::isValid($value)) {
throw new \InvalidArgumentException('Invalid SmartLink ID format');
}
}
public static function generate(Clock $clock): self
{
$ulid = new Ulid($clock);
return new self($ulid->__toString());
}
public static function fromString(string $value): self
{
return new self($value);
}
public function toString(): string
{
return $this->value;
}
public function equals(self $other): bool
{
return $this->value === $other->value;
}
public function __toString(): string
{
return $this->value;
}
}