140 lines
4.1 KiB
PHP
140 lines
4.1 KiB
PHP
<?php
|
|
|
|
namespace App\Domains\Analytics\Jobs;
|
|
|
|
use App\Domains\Analytics\Events\ClickRecorded;
|
|
use App\Domains\Analytics\Models\Click;
|
|
use App\Domains\Link\Models\Link;
|
|
use Illuminate\Bus\Queueable;
|
|
use Illuminate\Contracts\Queue\ShouldQueue;
|
|
use Illuminate\Foundation\Bus\Dispatchable;
|
|
use Illuminate\Queue\InteractsWithQueue;
|
|
use Illuminate\Support\Facades\Cache;
|
|
use Illuminate\Support\Facades\Redis;
|
|
|
|
class RecordClickJob implements ShouldQueue
|
|
{
|
|
use Dispatchable, InteractsWithQueue, Queueable;
|
|
|
|
/**
|
|
* @param array<string, array<int, string>> $headers
|
|
* @param array<string, string> $queryParams
|
|
*/
|
|
public function __construct(
|
|
public readonly int $linkId,
|
|
public readonly string $ip,
|
|
public readonly ?string $userAgent,
|
|
public readonly array $headers,
|
|
public readonly array $queryParams = [],
|
|
) {}
|
|
|
|
public function handle(): void
|
|
{
|
|
$link = Link::find($this->linkId);
|
|
if (! $link) {
|
|
return;
|
|
}
|
|
|
|
$ipHash = $this->hashIp($this->ip);
|
|
$device = $this->detectDevice($this->userAgent ?? '');
|
|
|
|
$click = Click::create([
|
|
'link_id' => $this->linkId,
|
|
'workspace_id' => $link->workspace_id,
|
|
'clicked_at' => now(),
|
|
'ip_hash' => $ipHash,
|
|
'device' => $device,
|
|
'os' => $this->detectOs($this->userAgent ?? ''),
|
|
'browser' => $this->detectBrowser($this->userAgent ?? ''),
|
|
'referrer_host' => $this->extractHost($this->headers['referer'][0] ?? null),
|
|
'utm_source' => $this->queryParams['utm_source'] ?? null,
|
|
'utm_medium' => $this->queryParams['utm_medium'] ?? null,
|
|
'utm_campaign' => $this->queryParams['utm_campaign'] ?? null,
|
|
'language' => substr($this->headers['accept-language'][0] ?? 'de', 0, 10),
|
|
]);
|
|
|
|
// Increment Redis counters
|
|
$date = now()->format('Y-m-d');
|
|
Redis::incr("clicks:{$this->linkId}:{$date}");
|
|
Redis::incr("clicks:{$link->workspace_id}:monthly:".now()->format('Ym'));
|
|
|
|
// Broadcast event for live dashboard
|
|
event(new ClickRecorded($click));
|
|
}
|
|
|
|
private function hashIp(string $ip): string
|
|
{
|
|
$salt = Cache::remember('ip_salt:'.today()->format('Y-m-d'), 86400, fn () => bin2hex(random_bytes(16)));
|
|
|
|
return hash('sha256', $ip.$salt);
|
|
}
|
|
|
|
private function detectDevice(string $ua): string
|
|
{
|
|
$ua = strtolower($ua);
|
|
if (str_contains($ua, 'bot') || str_contains($ua, 'crawler') || str_contains($ua, 'spider')) {
|
|
return 'bot';
|
|
}
|
|
if (preg_match('/mobile|iphone|android.*mobile|windows phone/i', $ua)) {
|
|
return 'mobile';
|
|
}
|
|
if (preg_match('/ipad|android(?!.*mobile)|tablet/i', $ua)) {
|
|
return 'tablet';
|
|
}
|
|
if (strlen($ua) === 0) {
|
|
return 'unknown';
|
|
}
|
|
|
|
return 'desktop';
|
|
}
|
|
|
|
private function detectOs(string $ua): ?string
|
|
{
|
|
if (stripos($ua, 'iPhone') !== false || stripos($ua, 'iPad') !== false) {
|
|
return 'ios';
|
|
}
|
|
if (stripos($ua, 'Android') !== false) {
|
|
return 'android';
|
|
}
|
|
if (stripos($ua, 'Windows') !== false) {
|
|
return 'windows';
|
|
}
|
|
if (stripos($ua, 'Mac OS') !== false) {
|
|
return 'macos';
|
|
}
|
|
if (stripos($ua, 'Linux') !== false) {
|
|
return 'linux';
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
private function detectBrowser(string $ua): ?string
|
|
{
|
|
if (stripos($ua, 'Edge') !== false || stripos($ua, 'Edg/') !== false) {
|
|
return 'edge';
|
|
}
|
|
if (stripos($ua, 'Chrome') !== false) {
|
|
return 'chrome';
|
|
}
|
|
if (stripos($ua, 'Firefox') !== false) {
|
|
return 'firefox';
|
|
}
|
|
if (stripos($ua, 'Safari') !== false) {
|
|
return 'safari';
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
private function extractHost(?string $url): ?string
|
|
{
|
|
if (! $url) {
|
|
return null;
|
|
}
|
|
$parsed = parse_url($url);
|
|
|
|
return $parsed['host'] ?? null;
|
|
}
|
|
}
|