369 lines
14 KiB
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;
|
|
}
|
|
} |