*/ private const KNOWN_BOT_PATTERNS = [ 'googlebot', 'bingbot', 'ahrefsbot', 'semrushbot', 'dotbot', 'barkrowler', 'claudebot', 'gptbot', 'amazonbot', 'mj12bot', 'petalbot', 'yandexbot', 'bytespider', 'crawler', 'spider', 'headless', 'preview', ]; public function __construct(private readonly AcademyAnalyticsContentResolver $contentResolver) { } /** * @param array $payload */ public function track(array $payload, ?User $user = null, ?Request $request = null): AcademyEvent { $request ??= request(); $user ??= $request?->user(); $eventType = trim((string) ($payload['event_type'] ?? '')); $contentType = $this->normalizeNullableString($payload['content_type'] ?? null); $contentId = filled($payload['content_id'] ?? null) ? (int) $payload['content_id'] : null; $metadata = is_array($payload['metadata'] ?? null) ? $payload['metadata'] : []; $rawOccurredAt = $payload['occurred_at'] ?? null; $occurredAt = $rawOccurredAt instanceof Carbon ? $rawOccurredAt : Carbon::parse((string) ($rawOccurredAt ?? now()->toISOString())); $userAgent = strtolower(trim((string) ($request?->userAgent() ?? ''))); $isBot = $this->looksLikeBot($userAgent); $isCrawler = $isBot || str_contains($userAgent, 'crawl'); $isAdmin = $user ? ($user->hasStaffAccess() || $user->isModerator()) : false; $isSubscriber = $user ? ($user->hasAcademyProAccess() || $user->hasAcademyCreatorAccess()) : false; $visitorId = $this->resolveVisitorId($payload, $request, $user); $event = AcademyEvent::query()->create([ 'event_type' => $eventType, 'content_type' => $contentType, 'content_id' => $contentId, 'user_id' => $user?->id, 'visitor_id' => $visitorId, 'session_id' => $this->normalizeNullableString($payload['session_id'] ?? ($request?->hasSession() ? $request->session()->getId() : null)), 'url' => $this->normalizeNullableString($payload['url'] ?? $request?->fullUrl()), 'route_name' => $this->normalizeNullableString($payload['route_name'] ?? $request?->route()?->getName()), 'referrer' => $this->normalizeNullableString($payload['referrer'] ?? $request?->headers->get('referer')), 'utm_source' => $this->normalizeNullableString($payload['utm_source'] ?? $request?->query('utm_source')), 'utm_medium' => $this->normalizeNullableString($payload['utm_medium'] ?? $request?->query('utm_medium')), 'utm_campaign' => $this->normalizeNullableString($payload['utm_campaign'] ?? $request?->query('utm_campaign')), 'device_type' => $this->deviceTypeFromUserAgent($userAgent), 'browser' => $this->browserFromUserAgent($userAgent), 'platform' => $this->platformFromUserAgent($userAgent), 'country_code' => $this->countryCodeFromRequest($request), 'is_logged_in' => $user !== null, 'is_subscriber' => $isSubscriber, 'is_admin' => $isAdmin, 'is_bot' => $isBot, 'is_crawler' => $isCrawler, 'is_suspicious' => $isBot || $this->looksSuspicious($request, $userAgent), 'metadata' => $metadata === [] ? null : $metadata, 'occurred_at' => $occurredAt, ]); if ($eventType === AcademyAnalyticsEventType::SEARCH_RESULT_CLICK) { $this->syncSearchResultClickAttribution($event, $metadata, $request, $user); } return $event; } public function trackContentView(string $contentType, ?int $contentId, Request $request): void { $this->track([ 'event_type' => AcademyAnalyticsEventType::PAGE_VIEW, 'content_type' => $contentType, 'content_id' => $contentId, 'metadata' => ['source' => 'academy_page'], ], $request->user(), $request); $specificEvent = match ($contentType) { AcademyAnalyticsContentType::PROMPT => AcademyAnalyticsEventType::CONTENT_VIEW, AcademyAnalyticsContentType::LESSON => AcademyAnalyticsEventType::LESSON_VIEW, AcademyAnalyticsContentType::COURSE => AcademyAnalyticsEventType::COURSE_VIEW, AcademyAnalyticsContentType::PROMPT_PACK => AcademyAnalyticsEventType::PROMPT_PACK_VIEW, AcademyAnalyticsContentType::CHALLENGE => AcademyAnalyticsEventType::CHALLENGE_VIEW, default => AcademyAnalyticsEventType::CONTENT_VIEW, }; $this->track([ 'event_type' => $specificEvent, 'content_type' => $contentType, 'content_id' => $contentId, ], $request->user(), $request); } public function trackPromptCopy(int $promptId, string $copyType, Request $request): void { $eventType = trim(strtolower($copyType)) === 'negative' ? AcademyAnalyticsEventType::PROMPT_NEGATIVE_COPY : AcademyAnalyticsEventType::PROMPT_COPY; $this->track([ 'event_type' => $eventType, 'content_type' => AcademyAnalyticsContentType::PROMPT, 'content_id' => $promptId, 'metadata' => [ 'copy_type' => $copyType, 'source' => 'prompt_detail', ], ], $request->user(), $request); } public function trackUpgradeClick(?string $source, ?string $contentType, ?int $contentId, Request $request): void { $this->track([ 'event_type' => AcademyAnalyticsEventType::UPGRADE_CLICK, 'content_type' => $contentType ?: AcademyAnalyticsContentType::UPGRADE, 'content_id' => $contentId, 'metadata' => array_filter([ 'source' => $this->normalizeNullableString($source), ]), ], $request->user(), $request); } /** * @param array $filters */ public function trackSearch(string $query, int $resultsCount, array $filters = [], ?Request $request = null): AcademySearchLog { $request ??= request(); $user = $request?->user(); $normalizedQuery = $this->normalizeSearchQuery($query); $isBot = $this->looksLikeBot(strtolower(trim((string) ($request?->userAgent() ?? '')))); $log = AcademySearchLog::query()->create([ 'user_id' => $user?->id, 'visitor_id' => $this->resolveVisitorId([], $request, $user), 'query' => trim($query), 'normalized_query' => $normalizedQuery, 'results_count' => max(0, $resultsCount), 'filters' => $filters === [] ? null : $filters, 'is_logged_in' => $user !== null, 'is_subscriber' => $user ? ($user->hasAcademyCreatorAccess() || $user->hasAcademyProAccess()) : false, 'is_bot' => $isBot, ]); $this->track([ 'event_type' => AcademyAnalyticsEventType::SEARCH, 'content_type' => AcademyAnalyticsContentType::SEARCH, 'metadata' => [ 'query' => $normalizedQuery, 'results_count' => $resultsCount, 'filters' => $filters, ], ], $user, $request); if ($resultsCount === 0) { $this->track([ 'event_type' => AcademyAnalyticsEventType::ZERO_SEARCH_RESULTS, 'content_type' => AcademyAnalyticsContentType::SEARCH, 'metadata' => [ 'query' => $normalizedQuery, 'filters' => $filters, ], ], $user, $request); } return $log; } public function normalizeSearchQuery(string $query): string { $value = strtolower(trim($query)); $value = preg_replace('/\s+/', ' ', $value) ?? $value; $value = preg_replace('/[^a-z0-9\s\-_]+/', '', $value) ?? $value; return trim($value); } /** * @param array $metadata */ private function syncSearchResultClickAttribution(AcademyEvent $event, array $metadata, ?Request $request, ?User $user): AcademySearchLog { $query = trim((string) ($metadata['query'] ?? '')); $normalizedQuery = $this->normalizeSearchQuery((string) ($metadata['normalized_query'] ?? $query)); $resultsCount = max(0, (int) ($metadata['results_count'] ?? 0)); $filters = is_array($metadata['filters'] ?? null) ? $metadata['filters'] : []; $visitorId = $this->normalizeNullableString($event->visitor_id) ?? $this->resolveVisitorId([], $request, $user); $recentThreshold = ($event->occurred_at ?? now())->copy()->subMinutes(30); $searchLog = AcademySearchLog::query() ->where('normalized_query', $normalizedQuery) ->where('created_at', '>=', $recentThreshold) ->whereNull('clicked_content_id') ->where(function ($builder) use ($user, $visitorId): void { if ($user?->id !== null) { $builder->orWhere('user_id', $user->id); } if ($visitorId !== null) { $builder->orWhere('visitor_id', $visitorId); } }) ->latest('id') ->first(); if ($searchLog instanceof AcademySearchLog) { $searchLog->forceFill([ 'clicked_content_type' => $event->content_type, 'clicked_content_id' => $event->content_id, ])->save(); return $searchLog; } return AcademySearchLog::query()->create([ 'user_id' => $user?->id, 'visitor_id' => $visitorId, 'query' => $query, 'normalized_query' => $normalizedQuery, 'results_count' => $resultsCount, 'clicked_content_type' => $event->content_type, 'clicked_content_id' => $event->content_id, 'filters' => $filters === [] ? null : $filters, 'is_logged_in' => $user !== null, 'is_subscriber' => (bool) $event->is_subscriber, 'is_bot' => (bool) $event->is_bot, ]); } private function resolveVisitorId(array $payload, ?Request $request, ?User $user): ?string { $payloadVisitorId = $this->normalizeNullableString($payload['visitor_id'] ?? null); if ($payloadVisitorId !== null) { return $payloadVisitorId; } $cookieVisitorId = $this->normalizeNullableString($request?->cookie('academy_visitor_id')); if ($cookieVisitorId !== null) { return $cookieVisitorId; } if ($user) { return sprintf('user:%d', $user->id); } return (string) Str::uuid(); } private function looksLikeBot(string $userAgent): bool { if ($userAgent === '') { return false; } foreach (self::KNOWN_BOT_PATTERNS as $pattern) { if (str_contains($userAgent, $pattern)) { return true; } } return false; } private function looksSuspicious(?Request $request, string $userAgent): bool { if ($request === null) { return false; } return $this->looksLikeBot($userAgent) || str_contains(strtolower((string) $request->headers->get('accept', '')), '*/*') || $request->headers->get('sec-fetch-site') === null; } private function deviceTypeFromUserAgent(string $userAgent): string { if ($userAgent === '') { return 'unknown'; } if (str_contains($userAgent, 'tablet') || str_contains($userAgent, 'ipad')) { return 'tablet'; } if (str_contains($userAgent, 'mobile') || str_contains($userAgent, 'android')) { return 'mobile'; } return 'desktop'; } private function browserFromUserAgent(string $userAgent): ?string { if ($userAgent === '') { return null; } return match (true) { str_contains($userAgent, 'edg/') => 'Edge', str_contains($userAgent, 'chrome/') => 'Chrome', str_contains($userAgent, 'firefox/') => 'Firefox', str_contains($userAgent, 'safari/') && ! str_contains($userAgent, 'chrome/') => 'Safari', default => 'Other', }; } private function platformFromUserAgent(string $userAgent): ?string { if ($userAgent === '') { return null; } return match (true) { str_contains($userAgent, 'windows') => 'Windows', str_contains($userAgent, 'mac os') || str_contains($userAgent, 'macintosh') => 'macOS', str_contains($userAgent, 'android') => 'Android', str_contains($userAgent, 'iphone') || str_contains($userAgent, 'ipad') || str_contains($userAgent, 'ios') => 'iOS', str_contains($userAgent, 'linux') => 'Linux', default => 'Other', }; } private function countryCodeFromRequest(?Request $request): ?string { $country = strtoupper(trim((string) ($request?->headers->get('cf-ipcountry') ?? $request?->headers->get('x-country-code') ?? ''))); return $country !== '' && strlen($country) <= 8 ? $country : null; } private function normalizeNullableString(mixed $value): ?string { $normalized = trim((string) $value); return $normalized === '' ? null : $normalized; } }