Files
SkinbaseNova/app/Services/Academy/AcademyAnalyticsService.php

369 lines
14 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Services\Academy;
use App\Models\AcademyEvent;
use App\Models\AcademySearchLog;
use App\Models\User;
use App\Support\AcademyAnalytics\AcademyAnalyticsContentType;
use App\Support\AcademyAnalytics\AcademyAnalyticsEventType;
use Illuminate\Http\Request;
use Illuminate\Support\Carbon;
use Illuminate\Support\Str;
final class AcademyAnalyticsService
{
/**
* @var list<string>
*/
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<string, mixed> $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<string, mixed> $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<string, mixed> $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;
}
}