src/Service/StdTrackingService.php line 124

Open in your IDE?
  1. <?php
  2. namespace App\Service;
  3. use App\Admin\Entity\StdPages;
  4. use App\Admin\Entity\StdWebUsers;
  5. use App\Admin\Entity\StdPagesPages;
  6. use App\Entity\StdPagesTracking;
  7. use App\Entity\StdTagTracking;
  8. use App\Repository\StdPagesTrackingRepository;
  9. use App\Utils\Functions;
  10. use Doctrine\ORM\EntityManagerInterface;
  11. use Psr\Log\LoggerInterface;
  12. use Symfony\Component\HttpFoundation\Cookie;
  13. use Symfony\Component\HttpFoundation\RequestStack;
  14. use Symfony\Component\HttpFoundation\Response;
  15. use Symfony\Component\Security\Core\Security;
  16. use Symfony\Contracts\HttpClient\Exception\TransportExceptionInterface;
  17. use Symfony\Contracts\HttpClient\HttpClientInterface;
  18. use App\Admin\Enum\TrackingEvent;
  19. class StdTrackingService
  20. {
  21.     private EntityManagerInterface $em;
  22.     private RequestStack $requestStack;
  23.     private HttpClientInterface $httpClient;
  24.     private LoggerInterface $logger;
  25.     private StdPagesTrackingRepository $pageTrackingRepository;
  26.     private Security $security;
  27.     private bool $trackingEnabled;
  28.     private bool $preciseTrackingEnabled false;
  29.     private bool $useGeoMapForTracking false;
  30.     private bool $useApiForTracking false;
  31.     private int $preciseTrackingHours 1;
  32.     private int $minRefreshSamePageRate 300;
  33.     private string $encryptionKey;
  34.     private ?Cookie $pendingTrackingCookie null;
  35.     public function __construct(
  36.         EntityManagerInterface $em,
  37.         RequestStack $requestStack,
  38.         HttpClientInterface $httpClient,
  39.         LoggerInterface $logger,
  40.         StdPagesTrackingRepository $pageTrackingRepository,
  41.         Security $security
  42.     )
  43.     {
  44.         $this->em $em;
  45.         $this->requestStack $requestStack;
  46.         $this->httpClient $httpClient;
  47.         $this->logger $logger;
  48.         $this->pageTrackingRepository $pageTrackingRepository;
  49.         $this->security $security;
  50.         $this->encryptionKey getenv('ANALYTICS_ENCRYPTION_KEY')
  51.             ?: throw new \RuntimeException("Missing ANALYTICS_ENCRYPTION_KEY");
  52.         $this->trackingEnabled Functions::getConfig("tracking_enable",''$this->em);
  53.         $this->minRefreshSamePageRate Functions::getConfig("tracking_same_page_min_interval_seconds",''$this->em);
  54.         $this->preciseTrackingEnabled Functions::getConfig("tracking_ask_precise_location",''$this->em);
  55.         $this->preciseTrackingHours Functions::getConfig("tracking_precise_location_hours",''$this->em);
  56.         $this->useGeoMapForTracking Functions::getConfig("use_geo_map_for_tracking",''$this->em);
  57.         $this->useApiForTracking Functions::getConfig("use_api_for_tracking",''$this->em);
  58.     }
  59.      /**
  60.      * Registra visita/evento numa página.
  61.      * - Se $event vier null, assume TrackingEvent::OpenPage (compat).
  62.      * - Aplica anti-spam por (sessão+página+evento).
  63.      * - Reaproveita toda a lógica de geo/cidade/precise/tags.
  64.      */
  65.     public function trackVisitOnPage(StdPages $page, ?TrackingEvent $event null): void
  66.     {
  67.         if (!$this->trackingEnabled) {
  68.             return;
  69.         }
  70.         $request $this->requestStack->getCurrentRequest();
  71. //        if (!$this->isTrackingCookieAccepted()) {
  72. //            return;
  73. //        }
  74.         if ($page->getContentType()->getMachineName() === "system") {
  75.             return;
  76.         }
  77.         $session   $request->getSession();
  78.         $sessionId $session->getId();
  79.         $clientIp  $request->getClientIp();
  80.         $userAgent $request->headers->get('User-Agent') ?? null;
  81.         if (empty($userAgent)) {
  82.             return;
  83.         }
  84.         $event ??= TrackingEvent::OpenPage;
  85.         $trackingId $this->getOrCreateTrackingId();
  86.         $hashedTrackingId Functions::encryptSha256($trackingId$this->encryptionKey);
  87.         $fingerprint Functions::encryptSha256($clientIp $userAgent $sessionId $trackingId$this->encryptionKey);
  88.         $sessionHash Functions::encryptSha256($sessionId$this->encryptionKey);
  89.         $hashedIp    Functions::encryptSha256($clientIp$this->encryptionKey);
  90.         $now = new \DateTimeImmutable();
  91.         $lastEvent $this->pageTrackingRepository->findOneBy(
  92.             [
  93.                 'trackingId' => $hashedTrackingId,
  94.                 'page'        => $page,
  95.                 'event'       => $event->value,
  96.             ],
  97.             ['visitedAt' => 'DESC']
  98.         );
  99.         if ($lastEvent !== null) {
  100.             $timeSinceLast $now->getTimestamp() - $lastEvent->getVisitedAt()->getTimestamp();
  101.             if ($timeSinceLast $this->minRefreshSamePageRate) {
  102.                 return;
  103.             }
  104.         }
  105.         $country $session->get('country');
  106.         $city $session->get('city');
  107.         $trackingPage = new StdPagesTracking();
  108.         $trackingPage->setPage($page);
  109.         $trackingPage->setHashedIp($hashedIp);
  110.         $trackingPage->setFingerprint($fingerprint);
  111.         $trackingPage->setSessionHash($sessionHash);
  112.         $trackingPage->setTrackingId($hashedTrackingId);
  113.         $trackingPage->setVisitedAt($now);
  114.         $trackingPage->setCountry($country);
  115.         $trackingPage->setCity($city);
  116.         $trackingPage->setEvent($event->value);
  117.         /** @var StdWebUsers|null $webUser */
  118.         $webUser $this->security->getUser();
  119.         if ($webUser instanceof StdWebUsers) {
  120.             $trackingPage->setWebUser($webUser);
  121.         }
  122.         // precise location (se disponível)
  123.         $this->setPreciseLocationIfAvailable($trackingPage$hashedIp);
  124.         // tags da página
  125.         if (count($page->getTags()) > 0) {
  126.             foreach ($trackingPage->getPage()->getTags() as $tag) {
  127.                 $trackingTag = new StdTagTracking();
  128.                 $trackingTag->setTag($tag);
  129.                 $trackingTag->setTracking($trackingPage);
  130.                 $this->em->persist($trackingTag);
  131.             }
  132.         }
  133.         $this->em->persist($trackingPage);
  134.         $this->em->flush();
  135.     }
  136.     public function addPreciseLocationToUser(string $clientIpstring $countrystring $city): void
  137.     {
  138.         $lastTracking $this->pageTrackingRepository->findRecentPreciseLocationWithinLastHoursOfIp($clientIp$this->preciseTrackingHours);
  139.         if ($lastTracking === null) {
  140.             $tracking $this->pageTrackingRepository->findByLastIpHash($clientIp);
  141.             if (!$tracking) {
  142.                 throw new \RuntimeException('No tracking entry found for the ip.');
  143.             }
  144.             $tracking->setPreciseCountry($country);
  145.             $tracking->setPreciseCity($city);
  146.             $this->em->persist($tracking);
  147.             $this->em->flush();
  148.         }
  149.     }
  150.     public function getRecentPreciseLocationOfIp(string $ipbool $isHashed): StdPagesTracking|bool|null
  151.     {
  152.         $hashedIp $ip;
  153.         if(!$isHashed) {
  154.             $hashedIp Functions::encryptSha256($ip$this->encryptionKey);
  155.         }
  156.         if($this->preciseTrackingHours == 0) {
  157.             return null;
  158.         }
  159.         return $this->pageTrackingRepository->findRecentPreciseLocationWithinLastHoursOfIp($hashedIp$this->preciseTrackingHours);
  160.     }
  161.     private function getGeoLocationFromIp(string $ip): ?array
  162.     {
  163.         try {
  164.             $response $this->httpClient->request('GET'"https://ipwho.is/{$ip}");
  165.             $data $response->toArray();
  166.             if (!($data['success'] ?? false)) {
  167.                 return null;
  168.             }
  169.             return [$data['country'] ?? null$data['city'] ?? null];
  170.         } catch (\Exception $e) {
  171.             return null;
  172.         } catch (TransportExceptionInterface $e) {
  173.             $this->logger->error("There's a issue in the GeoLocation Api: {$e->getMessage()}");
  174.             return null;
  175.         }
  176.     }
  177.     private function setPreciseLocationIfAvailable(StdPagesTracking $trackingPagestring $ip): void
  178.     {
  179.         if (
  180.             !$this->isTrackingEnabled() ||
  181.             !$this->isPreciseTrackingEnabled() &&
  182.             ($this->isUseApiForTracking() ||
  183.             $this->isUseGeoMapForTracking()) ||
  184.             empty($this->getPreciseTrackingHours())
  185.         ) {
  186.             return;
  187.         }
  188.         $location $this->getRecentPreciseLocationOfIp($iptrue);
  189.         if ($location) {
  190.             $trackingPage->setPreciseCountry($location->getPreciseCountry());
  191.             $trackingPage->setPreciseCity($location->getPreciseCity());
  192.         }
  193.     }
  194.     public function getRecommendedPagesForUser(int $limit 200): array
  195.     {
  196.         $request $this->requestStack->getCurrentRequest();
  197.         if (!$request) {
  198.             return [];
  199.         }
  200.         /** @var StdWebUsers|null $webUser */
  201.         $webUser $this->security->getUser();
  202.         
  203.         $trackingId $this->getOrCreateTrackingId();
  204.         $hashedTrackingId Functions::encryptSha256($trackingId$this->encryptionKey);
  205.         $conn $this->em->getConnection();
  206.         // Single query to get visited page IDs, tag domain value IDs, and category IDs
  207.         $userCondition $webUser 
  208.             'pt.web_user_id = :userId' 
  209.             'pt.tracking_id = :trackingId';
  210.         
  211.         $dataQuery "
  212.             SELECT 
  213.                 GROUP_CONCAT(DISTINCT pt.page_id) as visited_page_ids,
  214.                 GROUP_CONCAT(DISTINCT tag.domain_value_id) as tag_domain_value_ids,
  215.                 GROUP_CONCAT(DISTINCT cat_rel.relation_id) as category_ids
  216.             FROM std_pages_tracking pt
  217.             LEFT JOIN std_tag_tracking stt ON stt.tracking_id = pt.id
  218.             LEFT JOIN std_pages_tags tag ON tag.id = stt.tag_id
  219.             LEFT JOIN std_pages_pages cat_rel ON cat_rel.page_id = pt.page_id
  220.             LEFT JOIN std_content_types cat_ct ON cat_ct.id = cat_rel.content_type AND cat_ct.machine_name = 'pages_categories'
  221.             WHERE {$userCondition}
  222.         ";
  223.         
  224.         $params $webUser 
  225.             ? ['userId' => $webUser->getId()] 
  226.             : ['trackingId' => $hashedTrackingId];
  227.         
  228.         $result $conn->executeQuery($dataQuery$params)->fetchAssociative();
  229.         
  230.         if (!$result || empty($result['visited_page_ids'])) {
  231.             return [];
  232.         }
  233.         $visitedPageIds array_filter(array_unique(explode(','$result['visited_page_ids'])));
  234.         $tagDomainValueIds $result['tag_domain_value_ids'
  235.             ? array_filter(array_unique(explode(','$result['tag_domain_value_ids']))) 
  236.             : [];
  237.         $categoryIds $result['category_ids'
  238.             ? array_filter(array_unique(explode(','$result['category_ids']))) 
  239.             : [];
  240.         if (empty($tagDomainValueIds) && empty($categoryIds)) {
  241.             return [];
  242.         }
  243.         // Build optimized main query
  244.         $qb $this->em->createQueryBuilder();
  245.         $qb->select('p')
  246.             ->from(StdPages::class, 'p')
  247.             ->where($qb->expr()->notIn('p.id'':visited'))
  248.             ->setParameter('visited'$visitedPageIds);
  249.         // Build score calculation and conditions
  250.         $scoreParts = [];
  251.         $orConditions = [];
  252.         if (!empty($tagDomainValueIds)) {
  253.             $qb->leftJoin('p.tags''t')
  254.                ->leftJoin('t.domainValue''dv');
  255.             $orConditions[] = $qb->expr()->in('dv.id'':tagDomainValueIds');
  256.             $qb->setParameter('tagDomainValueIds'$tagDomainValueIds);
  257.             $scoreParts[] = '(CASE WHEN dv.id IN (:tagDomainValueIds) THEN 1 ELSE 0 END)';
  258.         }
  259.         if (!empty($categoryIds)) {
  260.             $qb->leftJoin('p.relations''rel')
  261.                ->leftJoin('rel.contentType''relCt');
  262.             $orConditions[] = $qb->expr()->andX(
  263.                 $qb->expr()->in('rel.relationId'':catIds'),
  264.                 $qb->expr()->eq('relCt.machineName'':catMachine')
  265.             );
  266.             $qb->setParameter('catIds'$categoryIds);
  267.             $qb->setParameter('catMachine''pages_categories');
  268.             $scoreParts[] = '(CASE WHEN rel.relationId IN (:catIds) AND relCt.machineName = :catMachine THEN 1 ELSE 0 END)';
  269.         }
  270.         if (!empty($orConditions)) {
  271.             $qb->andWhere($qb->expr()->orX(...$orConditions));
  272.         }
  273.         if (!empty($scoreParts)) {
  274.             $qb->addSelect('(' implode(' + '$scoreParts) . ') AS HIDDEN score');
  275.             $qb->orderBy('score''DESC')
  276.                ->addOrderBy('p.publishDate''DESC');
  277.         } else {
  278.             $qb->orderBy('p.publishDate''DESC');
  279.         }
  280.         $qb->groupBy('p.id')
  281.            ->setMaxResults($limit);
  282.         return $qb->getQuery()->getResult();
  283.     }
  284.     public function isTrackingEnabled(): bool
  285.     {
  286.         return $this->trackingEnabled;
  287.     }
  288.     public function isPreciseTrackingEnabled(): bool
  289.     {
  290.         return $this->preciseTrackingEnabled && $this->trackingEnabled;
  291.     }
  292.     public function getPreciseTrackingHours(): int
  293.     {
  294.         return $this->preciseTrackingHours;
  295.     }
  296.     public function isUseGeoMapForTracking(): bool
  297.     {
  298.         return $this->useGeoMapForTracking;
  299.     }
  300.     public function setUseGeoMapForTracking(bool $useGeoMapForTracking): void
  301.     {
  302.         $this->useGeoMapForTracking $useGeoMapForTracking;
  303.     }
  304.     public function isUseApiForTracking(): bool
  305.     {
  306.         return $this->useApiForTracking;
  307.     }
  308.     public function setUseApiForTracking(bool $useApiForTracking): void
  309.     {
  310.         $this->useApiForTracking $useApiForTracking;
  311.     }
  312.     public function isTrackingCookieAccepted(): bool
  313.     {
  314.         $request $this->requestStack->getCurrentRequest();
  315.         if (!$request) {
  316.             return false;
  317.         }
  318.         $analyticsCookieName getenv('COOKIE_STD_ANALYTICS_NAME');
  319.         $analyticsConsent $request->cookies->get($analyticsCookieName);
  320.         return $analyticsConsent === 'accepted';
  321.     }
  322.     /**
  323.      * Get or create persistent tracking ID (1 year cookie, independent of session)
  324.      * This survives session clears, cache clears, and browser restarts
  325.      */
  326.     private function getOrCreateTrackingId(): string
  327.     {
  328.         $request $this->requestStack->getCurrentRequest();
  329.         if (!$request) {
  330.             return uniqid('track_'true);
  331.         }
  332.         // Check if tracking ID cookie already exists
  333.         $trackingId $request->cookies->get('_clw_tracking_id');
  334.         if (!$trackingId) {
  335.             $trackingId bin2hex(random_bytes(16));
  336.             $this->pendingTrackingCookie Cookie::create('_clw_tracking_id')
  337.                 ->withValue($trackingId)
  338.                 ->withExpires(time() + 31536000// 1 year
  339.                 ->withPath('/')
  340.                 ->withSecure($request->isSecure())
  341.                 ->withHttpOnly(true)
  342.                 ->withSameSite(Cookie::SAMESITE_LAX);
  343.         }
  344.         return $trackingId;
  345.     }
  346.     /**
  347.      * Get the pending tracking cookie that needs to be set on the response
  348.      * Call this from a ResponseListener or Controller
  349.      */
  350.     public function getPendingTrackingCookie(): ?Cookie
  351.     {
  352.         return $this->pendingTrackingCookie;
  353.     }
  354.     /**
  355.      * Clear the pending cookie after it's been set
  356.      */
  357.     public function clearPendingTrackingCookie(): void
  358.     {
  359.         $this->pendingTrackingCookie null;
  360.     }
  361. }