feat(analytics): click recording job with IP hashing, device detection, Redis counters
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>main
parent
ebdcd141bb
commit
66e559173c
|
|
@ -0,0 +1,36 @@
|
|||
<?php
|
||||
|
||||
namespace App\Domains\Analytics\Events;
|
||||
|
||||
use App\Domains\Analytics\Models\Click;
|
||||
use Illuminate\Broadcasting\Channel;
|
||||
use Illuminate\Broadcasting\InteractsWithSockets;
|
||||
use Illuminate\Contracts\Broadcasting\ShouldBroadcast;
|
||||
use Illuminate\Foundation\Events\Dispatchable;
|
||||
|
||||
class ClickRecorded implements ShouldBroadcast
|
||||
{
|
||||
use Dispatchable, InteractsWithSockets;
|
||||
|
||||
public function __construct(public readonly Click $click) {}
|
||||
|
||||
public function broadcastOn(): Channel
|
||||
{
|
||||
return new Channel("workspace.{$this->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(),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,108 @@
|
|||
<?php
|
||||
|
||||
namespace App\Domains\Analytics\Jobs;
|
||||
|
||||
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;
|
||||
|
||||
public function __construct(
|
||||
public readonly int $linkId,
|
||||
public readonly string $ip,
|
||||
public readonly ?string $userAgent,
|
||||
public readonly array $headers,
|
||||
) {}
|
||||
|
||||
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] ?? $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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,27 @@
|
|||
<?php
|
||||
|
||||
use App\Domains\Analytics\Jobs\RecordClickJob;
|
||||
use App\Domains\Analytics\Models\Click;
|
||||
use App\Domains\Link\Models\Link;
|
||||
|
||||
it('records a click to the database', function () {
|
||||
$link = Link::factory()->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);
|
||||
});
|
||||
Loading…
Reference in New Issue