feat: CI/CD pipeline setup complete - Ansible playbooks updated, secrets configured, workflow ready

This commit is contained in:
2025-10-31 01:39:24 +01:00
parent 55c04e4fd0
commit e26eb2aa12
601 changed files with 44184 additions and 32477 deletions

View File

@@ -8,7 +8,9 @@ use App\Domain\PreSave\PreSaveCampaignRepositoryInterface;
use App\Domain\PreSave\PreSaveRegistration;
use App\Domain\PreSave\PreSaveRegistrationRepositoryInterface;
use App\Domain\PreSave\ValueObjects\StreamingPlatform;
use App\Framework\Exception\ErrorCode;
use App\Framework\Exception\Core\AuthErrorCode;
use App\Framework\Exception\Core\DatabaseErrorCode;
use App\Framework\Exception\Core\ValidationErrorCode;
use App\Framework\Exception\FrameworkException;
use App\Framework\OAuth\OAuthServiceInterface;
@@ -38,14 +40,14 @@ final readonly class PreSaveCampaignService
if ($campaign === null) {
throw FrameworkException::create(
ErrorCode::ENTITY_NOT_FOUND,
DatabaseErrorCode::ENTITY_NOT_FOUND,
'Campaign not found'
)->withData(['campaign_id' => $campaignId]);
}
if (! $campaign->acceptsRegistrations()) {
throw FrameworkException::create(
ErrorCode::VAL_BUSINESS_RULE_VIOLATION,
ValidationErrorCode::BUSINESS_RULE_VIOLATION,
'Campaign is not accepting registrations'
)->withData([
'campaign_id' => $campaignId,
@@ -55,7 +57,7 @@ final readonly class PreSaveCampaignService
if (! $campaign->hasPlatform($platform)) {
throw FrameworkException::create(
ErrorCode::VAL_BUSINESS_RULE_VIOLATION,
ValidationErrorCode::BUSINESS_RULE_VIOLATION,
'Campaign does not support this platform'
)->withData([
'campaign_id' => $campaignId,
@@ -66,7 +68,7 @@ final readonly class PreSaveCampaignService
// Check if already registered
if ($this->registrationRepository->hasRegistered($userId, $campaignId, $platform)) {
throw FrameworkException::create(
ErrorCode::VAL_BUSINESS_RULE_VIOLATION,
ValidationErrorCode::BUSINESS_RULE_VIOLATION,
'User already registered for this campaign'
)->withData([
'user_id' => $userId,
@@ -78,7 +80,7 @@ final readonly class PreSaveCampaignService
// Verify user has OAuth token for platform
if (! $this->oauthService->hasProvider($userId, $platform->getOAuthProvider())) {
throw FrameworkException::create(
ErrorCode::AUTH_TOKEN_NOT_FOUND,
AuthErrorCode::UNAUTHORIZED,
'User not connected to ' . $platform->getDisplayName()
)->withData([
'user_id' => $userId,
@@ -112,7 +114,7 @@ final readonly class PreSaveCampaignService
// Can only cancel pending registrations
if (! $registration->status->shouldProcess()) {
throw FrameworkException::create(
ErrorCode::VAL_BUSINESS_RULE_VIOLATION,
ValidationErrorCode::BUSINESS_RULE_VIOLATION,
'Cannot cancel registration in current status'
)->withData(['status' => $registration->status->value]);
}
@@ -131,7 +133,7 @@ final readonly class PreSaveCampaignService
if ($campaign === null) {
throw FrameworkException::create(
ErrorCode::ENTITY_NOT_FOUND,
DatabaseErrorCode::ENTITY_NOT_FOUND,
'Campaign not found'
)->withData(['campaign_id' => $campaignId]);
}
@@ -140,7 +142,7 @@ final readonly class PreSaveCampaignService
$statusCounts = $this->registrationRepository->getStatusCounts($campaignId);
return [
'campaign' => $campaign->toArray(),
'campaign' => $campaign,
'total_registrations' => $stats['total_registrations'],
'completed' => $stats['completed'],
'pending' => $stats['pending'],

View File

@@ -0,0 +1,60 @@
<?php
declare(strict_types=1);
namespace App\Domain\SmartLink\DI;
use App\Domain\SmartLink\Repositories\ClickEventRepository;
use App\Domain\SmartLink\Repositories\SmartLinkRepository;
use App\Domain\SmartLink\Services\ClickStatisticsService;
use App\Domain\SmartLink\Services\ClickTrackingService;
use App\Domain\SmartLink\Services\SmartLinkService;
use App\Framework\Database\ConnectionInterface;
use App\Framework\DI\Container;
use App\Framework\DI\Initializer;
use App\Infrastructure\SmartLink\Repositories\DatabaseClickEventRepository;
/**
* SmartLink Domain Service Registration
*
* Registers all SmartLink domain services in the DI container.
*/
final readonly class SmartLinkServiceInitializer
{
#[Initializer]
public function __invoke(Container $container): void
{
// Repository Bindings
$container->singleton(
ClickEventRepository::class,
fn(Container $c) => new DatabaseClickEventRepository(
$c->get(ConnectionInterface::class)
)
);
// SmartLink Management Service
$container->singleton(
SmartLinkService::class,
fn(Container $c) => new SmartLinkService(
$c->get(SmartLinkRepository::class)
)
);
// Click Tracking Service
$container->singleton(
ClickTrackingService::class,
fn(Container $c) => new ClickTrackingService(
$c->get(ClickEventRepository::class),
$c->get(SmartLinkRepository::class)
)
);
// Click Statistics Service (NEW - for Analytics Dashboard)
$container->singleton(
ClickStatisticsService::class,
fn(Container $c) => new ClickStatisticsService(
$c->get(ClickEventRepository::class)
)
);
}
}

View File

@@ -6,7 +6,7 @@ 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\Core\DatabaseErrorCode;
use App\Framework\Exception\FrameworkException;
final class SmartLinkNotFoundException extends FrameworkException
@@ -14,7 +14,7 @@ final class SmartLinkNotFoundException extends FrameworkException
public static function forId(SmartLinkId $id): self
{
return self::create(
ErrorCode::ENTITY_NOT_FOUND,
DatabaseErrorCode::ENTITY_NOT_FOUND,
sprintf('SmartLink with ID "%s" not found', $id->toString())
)->withData(['link_id' => $id->toString()]);
}
@@ -22,7 +22,7 @@ final class SmartLinkNotFoundException extends FrameworkException
public static function forShortCode(ShortCode $shortCode): self
{
return self::create(
ErrorCode::ENTITY_NOT_FOUND,
DatabaseErrorCode::ENTITY_NOT_FOUND,
sprintf('SmartLink with code "%s" not found', $shortCode->toString())
)->withData(['short_code' => $shortCode->toString()]);
}

View File

@@ -17,35 +17,27 @@ final readonly class CreateClickEventsTable implements Migration
{
$schema = new Schema($connection);
$schema->create('click_events', function (Blueprint $table) {
$schema->create('smartlink_click_events', function (Blueprint $table) {
$table->string('id', 26)->primary(); // ULID
$table->string('link_id', 26);
$table->string('smartlink_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->string('country_code', 2)->nullable();
$table->string('device_type', 20)->nullable(); // enum: DeviceType
$table->text('user_agent')->nullable();
$table->text('referer')->nullable();
$table->string('destination_service', 30)->nullable();
$table->boolean('converted')->default(false);
$table->boolean('is_conversion')->default(false);
$table->timestamp('clicked_at');
$table->foreign('link_id')->references('id')->on('smart_links')->onDelete(ForeignKeyAction::CASCADE);
$table->index('link_id');
$table->foreign('smartlink_id')->references('id')->on('smart_links')->onDelete(ForeignKeyAction::CASCADE);
$table->index('smartlink_id');
$table->index('clicked_at');
$table->index(['link_id', 'clicked_at']);
$table->index(['smartlink_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
{

View File

@@ -21,4 +21,49 @@ interface ClickEventRepository
public function countUniqueByLinkId(SmartLinkId $linkId): int;
public function getConversionsByLinkId(SmartLinkId $linkId): int;
// Analytics Aggregation Methods
/**
* Get click time-series data grouped by hour
*
* @return array<array{hour: string, clicks: int, unique_clicks: int}>
*/
public function getClickTimeSeriesByHour(Timestamp $since): array;
/**
* Get geographic distribution of clicks
*
* @return array<array{country_code: string, click_count: int}>
*/
public function getGeographicDistribution(?Timestamp $since = null): array;
/**
* Get device type distribution
*
* @return array<array{device_type: string, click_count: int}>
*/
public function getDeviceTypeDistribution(?Timestamp $since = null): array;
/**
* Get top performing links by click count
*
* @return array<array{link_id: string, click_count: int, unique_click_count: int}>
*/
public function getTopLinksByClicks(int $limit, ?Timestamp $since = null): array;
/**
* Count total clicks globally
*/
public function countTotal(?Timestamp $since = null): int;
/**
* Count total unique clicks globally
*/
public function countUniqueTotal(?Timestamp $since = null): int;
/**
* Count total conversions globally
*/
public function countConversionsTotal(?Timestamp $since = null): int;
}

View File

@@ -1,118 +0,0 @@
<?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,232 @@
<?php
declare(strict_types=1);
namespace App\Domain\SmartLink\Services;
use App\Domain\SmartLink\Repositories\ClickEventRepository;
use App\Domain\SmartLink\ValueObjects\SmartLinkId;
use App\Framework\Core\ValueObjects\Timestamp;
/**
* Click Statistics Aggregation Service
*
* Provides comprehensive analytics aggregations for SmartLink clicks including:
* - Time-series click data
* - Geographic distribution
* - Device/Browser statistics
* - Top-performing links
* - Conversion metrics
*/
final readonly class ClickStatisticsService
{
public function __construct(
private ClickEventRepository $clickEventRepository
) {
}
/**
* Get aggregated click statistics for a time period
*
* @return array{total_clicks: int, unique_clicks: int, conversions: int, conversion_rate: float}
*/
public function getOverviewStats(?Timestamp $since = null): array
{
$totalClicks = $this->clickEventRepository->countTotal($since);
$uniqueClicks = $this->clickEventRepository->countUniqueTotal($since);
$conversions = $this->clickEventRepository->countConversionsTotal($since);
$conversionRate = $totalClicks > 0
? ($conversions / $totalClicks) * 100
: 0.0;
return [
'total_clicks' => $totalClicks,
'unique_clicks' => $uniqueClicks,
'conversions' => $conversions,
'conversion_rate' => round($conversionRate, 2),
];
}
/**
* Get click time-series data for chart visualization
*
* @param int $hours Number of hours to include (default: 24)
* @return array<array{timestamp: string, clicks: int, unique_clicks: int}>
*/
public function getClickTimeSeries(int $hours = 24): array
{
$since = Timestamp::now()->subtractHours($hours);
$rawData = $this->clickEventRepository->getClickTimeSeriesByHour($since);
// Transform for frontend consumption
return array_map(function (array $row) {
return [
'timestamp' => $row['hour'],
'clicks' => (int) $row['clicks'],
'unique_clicks' => (int) $row['unique_clicks'],
];
}, $rawData);
}
/**
* Get geographic distribution of clicks
*
* @return array<array{country_code: string, country_name: string, click_count: int, percentage: float}>
*/
public function getGeographicDistribution(?Timestamp $since = null): array
{
$rawData = $this->clickEventRepository->getGeographicDistribution($since);
$totalClicks = array_sum(array_column($rawData, 'click_count'));
return array_map(function (array $row) use ($totalClicks) {
$percentage = $totalClicks > 0
? ((int) $row['click_count'] / $totalClicks) * 100
: 0.0;
return [
'country_code' => $row['country_code'],
'country_name' => $this->getCountryName($row['country_code']),
'click_count' => (int) $row['click_count'],
'percentage' => round($percentage, 2),
];
}, $rawData);
}
/**
* Get device type distribution
*
* @return array{mobile: int, desktop: int, tablet: int, mobile_percentage: float, desktop_percentage: float, tablet_percentage: float}
*/
public function getDeviceDistribution(?Timestamp $since = null): array
{
$rawData = $this->clickEventRepository->getDeviceTypeDistribution($since);
$mobile = 0;
$desktop = 0;
$tablet = 0;
foreach ($rawData as $row) {
$count = (int) $row['click_count'];
match ($row['device_type']) {
'mobile' => $mobile = $count,
'desktop' => $desktop = $count,
'tablet' => $tablet = $count,
default => null,
};
}
$total = $mobile + $desktop + $tablet;
return [
'mobile' => $mobile,
'desktop' => $desktop,
'tablet' => $tablet,
'mobile_percentage' => $total > 0 ? round(($mobile / $total) * 100, 2) : 0.0,
'desktop_percentage' => $total > 0 ? round(($desktop / $total) * 100, 2) : 0.0,
'tablet_percentage' => $total > 0 ? round(($tablet / $total) * 100, 2) : 0.0,
];
}
/**
* Get top performing links by click count
*
* @param int $limit Number of links to return (default: 10)
* @return array<array{link_id: string, clicks: int, unique_clicks: int}>
*/
public function getTopPerformingLinks(int $limit = 10, ?Timestamp $since = null): array
{
$rawData = $this->clickEventRepository->getTopLinksByClicks($limit, $since);
// Note: For now, we return the aggregated data without joining to smart_links table
// Frontend will need to fetch link details separately or we can implement a JOIN query
return array_map(function (array $row) {
return [
'link_id' => $row['link_id'],
'clicks' => (int) $row['click_count'],
'unique_clicks' => (int) $row['unique_click_count'],
];
}, $rawData);
}
/**
* Get browser distribution statistics
*
* @return array<array{browser: string, click_count: int, percentage: float}>
*/
public function getBrowserDistribution(?Timestamp $since = null): array
{
// TODO: Implement browser parsing from user_agent field
// This requires user-agent parsing library or regex patterns
return [];
}
/**
* Get real-time click statistics (last 60 minutes)
*
* @return array{last_minute: int, last_5_minutes: int, last_15_minutes: int, last_60_minutes: int}
*/
public function getRealTimeStats(): array
{
$now = Timestamp::now();
return [
'last_minute' => $this->clickEventRepository->countTotal($now->subtractMinutes(1)),
'last_5_minutes' => $this->clickEventRepository->countTotal($now->subtractMinutes(5)),
'last_15_minutes' => $this->clickEventRepository->countTotal($now->subtractMinutes(15)),
'last_60_minutes' => $this->clickEventRepository->countTotal($now->subtractMinutes(60)),
];
}
/**
* Helper: Get country name from country code
*/
private function getCountryName(string $countryCode): string
{
// Simple country code mapping - could be replaced with a proper library
$countries = [
'DE' => 'Germany',
'US' => 'United States',
'GB' => 'United Kingdom',
'FR' => 'France',
'ES' => 'Spain',
'IT' => 'Italy',
'NL' => 'Netherlands',
'AT' => 'Austria',
'CH' => 'Switzerland',
'BE' => 'Belgium',
];
return $countries[$countryCode] ?? $countryCode;
}
/**
* Get click statistics for a specific link
*
* @return array{total_clicks: int, unique_clicks: int, conversions: int, conversion_rate: float, avg_clicks_per_day: float}
*/
public function getLinkStatistics(SmartLinkId $linkId, ?Timestamp $since = null): array
{
$totalClicks = $this->clickEventRepository->countByLinkId($linkId);
$uniqueClicks = $this->clickEventRepository->countUniqueByLinkId($linkId);
$conversions = $this->clickEventRepository->getConversionsByLinkId($linkId);
$conversionRate = $totalClicks > 0
? ($conversions / $totalClicks) * 100
: 0.0;
// Calculate average clicks per day
$daysSince = $since ? $since->diffInDays(Timestamp::now()) : 30; // Default 30 days
$avgClicksPerDay = $daysSince > 0
? $totalClicks / $daysSince
: 0.0;
return [
'total_clicks' => $totalClicks,
'unique_clicks' => $uniqueClicks,
'conversions' => $conversions,
'conversion_rate' => round($conversionRate, 2),
'avg_clicks_per_day' => round($avgClicksPerDay, 2),
];
}
}

View File

@@ -140,6 +140,10 @@ final readonly class SmartLinkService
public function deleteLink(SmartLinkId $id): void
{
// Delete all destinations first (to maintain referential integrity)
$this->destinationRepository->deleteByLinkId($id);
// Delete the link itself
$this->linkRepository->delete($id);
}

View File

@@ -1,38 +0,0 @@
<?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);
}
}