diff --git a/app/Domains/Analytics/Events/ClickRecorded.php b/app/Domains/Analytics/Events/ClickRecorded.php new file mode 100644 index 0000000..c9a9c8e --- /dev/null +++ b/app/Domains/Analytics/Events/ClickRecorded.php @@ -0,0 +1,36 @@ +click->workspace_id}.analytics"); + } + + public function broadcastAs(): string + { + return 'click.recorded'; + } + + public function broadcastWith(): array + { + return [ + 'link_id' => $this->click->link_id, + 'country' => $this->click->country, + 'device' => $this->click->device, + 'clicked_at' => $this->click->clicked_at?->toISOString(), + ]; + } +} diff --git a/app/Domains/Analytics/Jobs/RecordClickJob.php b/app/Domains/Analytics/Jobs/RecordClickJob.php new file mode 100644 index 0000000..560fcfe --- /dev/null +++ b/app/Domains/Analytics/Jobs/RecordClickJob.php @@ -0,0 +1,108 @@ +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] ?? $this->headers['Referer'][0] ?? null), + 'utm_source' => $this->headers['utm_source'][0] ?? null, + 'utm_medium' => $this->headers['utm_medium'][0] ?? null, + 'utm_campaign' => $this->headers['utm_campaign'][0] ?? 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 \App\Domains\Analytics\Events\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, 'Windows') !== false) return 'windows'; + if (stripos($ua, 'Mac OS') !== false) return 'macos'; + if (stripos($ua, 'iPhone') !== false || stripos($ua, 'iPad') !== false) return 'ios'; + if (stripos($ua, 'Android') !== false) return 'android'; + 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 && stripos($ua, 'Chrome') === false) return 'safari'; + return null; + } + + private function extractHost(?string $url): ?string + { + if (! $url) return null; + $parsed = parse_url($url); + return $parsed['host'] ?? null; + } +} diff --git a/app/Http/Controllers/RedirectController.php b/app/Http/Controllers/RedirectController.php index b05880a..dfd6225 100644 --- a/app/Http/Controllers/RedirectController.php +++ b/app/Http/Controllers/RedirectController.php @@ -42,12 +42,12 @@ class RedirectController extends Controller } // 4. RecordClickJob dispatched in Task 7 - // \App\Domains\Analytics\Jobs\RecordClickJob::dispatch( - // (int) $data['link_id'], - // $request->ip(), - // $request->userAgent(), - // $request->headers->all() - // )->onQueue('clicks'); + \App\Domains\Analytics\Jobs\RecordClickJob::dispatch( + (int) $data['link_id'], + $request->ip(), + $request->userAgent() ?? '', + $request->headers->all() + )->onQueue('clicks'); // 5. 302 redirect return redirect($data['target'], 302); diff --git a/tests/Feature/Analytics/RecordClickTest.php b/tests/Feature/Analytics/RecordClickTest.php new file mode 100644 index 0000000..eaf04b9 --- /dev/null +++ b/tests/Feature/Analytics/RecordClickTest.php @@ -0,0 +1,27 @@ +create(); + + RecordClickJob::dispatchSync($link->id, '1.2.3.4', 'Mozilla/5.0 (iPhone; CPU iPhone OS 15_0)', []); + + $this->assertDatabaseCount('clicks', 1); + $this->assertDatabaseHas('clicks', [ + 'link_id' => $link->id, + 'device' => 'mobile', + ]); +}); + +it('hashes IP address before storage', function () { + $link = Link::factory()->create(); + + RecordClickJob::dispatchSync($link->id, '1.2.3.4', 'test-agent', []); + + $click = Click::first(); + expect($click->ip_hash)->not->toBe('1.2.3.4') + ->and(strlen($click->ip_hash))->toBe(64); +});