<?php
namespace App\Service;
use App\Admin\Entity\StdPages;
use App\Admin\Entity\StdWebUsers;
use App\Admin\Entity\StdPagesPages;
use App\Entity\StdPagesTracking;
use App\Entity\StdTagTracking;
use App\Repository\StdPagesTrackingRepository;
use App\Utils\Functions;
use Doctrine\ORM\EntityManagerInterface;
use Psr\Log\LoggerInterface;
use Symfony\Component\HttpFoundation\Cookie;
use Symfony\Component\HttpFoundation\RequestStack;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Security\Core\Security;
use Symfony\Contracts\HttpClient\Exception\TransportExceptionInterface;
use Symfony\Contracts\HttpClient\HttpClientInterface;
use App\Admin\Enum\TrackingEvent;
class StdTrackingService
{
private EntityManagerInterface $em;
private RequestStack $requestStack;
private HttpClientInterface $httpClient;
private LoggerInterface $logger;
private StdPagesTrackingRepository $pageTrackingRepository;
private Security $security;
private bool $trackingEnabled;
private bool $preciseTrackingEnabled = false;
private bool $useGeoMapForTracking = false;
private bool $useApiForTracking = false;
private int $preciseTrackingHours = 1;
private int $minRefreshSamePageRate = 300;
private string $encryptionKey;
private ?Cookie $pendingTrackingCookie = null;
public function __construct(
EntityManagerInterface $em,
RequestStack $requestStack,
HttpClientInterface $httpClient,
LoggerInterface $logger,
StdPagesTrackingRepository $pageTrackingRepository,
Security $security
)
{
$this->em = $em;
$this->requestStack = $requestStack;
$this->httpClient = $httpClient;
$this->logger = $logger;
$this->pageTrackingRepository = $pageTrackingRepository;
$this->security = $security;
$this->encryptionKey = getenv('ANALYTICS_ENCRYPTION_KEY')
?: throw new \RuntimeException("Missing ANALYTICS_ENCRYPTION_KEY");
$this->trackingEnabled = Functions::getConfig("tracking_enable",'', $this->em);
$this->minRefreshSamePageRate = Functions::getConfig("tracking_same_page_min_interval_seconds",'', $this->em);
$this->preciseTrackingEnabled = Functions::getConfig("tracking_ask_precise_location",'', $this->em);
$this->preciseTrackingHours = Functions::getConfig("tracking_precise_location_hours",'', $this->em);
$this->useGeoMapForTracking = Functions::getConfig("use_geo_map_for_tracking",'', $this->em);
$this->useApiForTracking = Functions::getConfig("use_api_for_tracking",'', $this->em);
}
/**
* Registra visita/evento numa página.
* - Se $event vier null, assume TrackingEvent::OpenPage (compat).
* - Aplica anti-spam por (sessão+página+evento).
* - Reaproveita toda a lógica de geo/cidade/precise/tags.
*/
public function trackVisitOnPage(StdPages $page, ?TrackingEvent $event = null): void
{
if (!$this->trackingEnabled) {
return;
}
$request = $this->requestStack->getCurrentRequest();
// if (!$this->isTrackingCookieAccepted()) {
// return;
// }
if ($page->getContentType()->getMachineName() === "system") {
return;
}
$session = $request->getSession();
$sessionId = $session->getId();
$clientIp = $request->getClientIp();
$userAgent = $request->headers->get('User-Agent') ?? null;
if (empty($userAgent)) {
return;
}
$event ??= TrackingEvent::OpenPage;
$trackingId = $this->getOrCreateTrackingId();
$hashedTrackingId = Functions::encryptSha256($trackingId, $this->encryptionKey);
$fingerprint = Functions::encryptSha256($clientIp . $userAgent . $sessionId . $trackingId, $this->encryptionKey);
$sessionHash = Functions::encryptSha256($sessionId, $this->encryptionKey);
$hashedIp = Functions::encryptSha256($clientIp, $this->encryptionKey);
$now = new \DateTimeImmutable();
$lastEvent = $this->pageTrackingRepository->findOneBy(
[
'trackingId' => $hashedTrackingId,
'page' => $page,
'event' => $event->value,
],
['visitedAt' => 'DESC']
);
if ($lastEvent !== null) {
$timeSinceLast = $now->getTimestamp() - $lastEvent->getVisitedAt()->getTimestamp();
if ($timeSinceLast < $this->minRefreshSamePageRate) {
return;
}
}
$country = $session->get('country');
$city = $session->get('city');
$trackingPage = new StdPagesTracking();
$trackingPage->setPage($page);
$trackingPage->setHashedIp($hashedIp);
$trackingPage->setFingerprint($fingerprint);
$trackingPage->setSessionHash($sessionHash);
$trackingPage->setTrackingId($hashedTrackingId);
$trackingPage->setVisitedAt($now);
$trackingPage->setCountry($country);
$trackingPage->setCity($city);
$trackingPage->setEvent($event->value);
/** @var StdWebUsers|null $webUser */
$webUser = $this->security->getUser();
if ($webUser instanceof StdWebUsers) {
$trackingPage->setWebUser($webUser);
}
// precise location (se disponível)
$this->setPreciseLocationIfAvailable($trackingPage, $hashedIp);
// tags da página
if (count($page->getTags()) > 0) {
foreach ($trackingPage->getPage()->getTags() as $tag) {
$trackingTag = new StdTagTracking();
$trackingTag->setTag($tag);
$trackingTag->setTracking($trackingPage);
$this->em->persist($trackingTag);
}
}
$this->em->persist($trackingPage);
$this->em->flush();
}
public function addPreciseLocationToUser(string $clientIp, string $country, string $city): void
{
$lastTracking = $this->pageTrackingRepository->findRecentPreciseLocationWithinLastHoursOfIp($clientIp, $this->preciseTrackingHours);
if ($lastTracking === null) {
$tracking = $this->pageTrackingRepository->findByLastIpHash($clientIp);
if (!$tracking) {
throw new \RuntimeException('No tracking entry found for the ip.');
}
$tracking->setPreciseCountry($country);
$tracking->setPreciseCity($city);
$this->em->persist($tracking);
$this->em->flush();
}
}
public function getRecentPreciseLocationOfIp(string $ip, bool $isHashed): StdPagesTracking|bool|null
{
$hashedIp = $ip;
if(!$isHashed) {
$hashedIp = Functions::encryptSha256($ip, $this->encryptionKey);
}
if($this->preciseTrackingHours == 0) {
return null;
}
return $this->pageTrackingRepository->findRecentPreciseLocationWithinLastHoursOfIp($hashedIp, $this->preciseTrackingHours);
}
private function getGeoLocationFromIp(string $ip): ?array
{
try {
$response = $this->httpClient->request('GET', "https://ipwho.is/{$ip}");
$data = $response->toArray();
if (!($data['success'] ?? false)) {
return null;
}
return [$data['country'] ?? null, $data['city'] ?? null];
} catch (\Exception $e) {
return null;
} catch (TransportExceptionInterface $e) {
$this->logger->error("There's a issue in the GeoLocation Api: {$e->getMessage()}");
return null;
}
}
private function setPreciseLocationIfAvailable(StdPagesTracking $trackingPage, string $ip): void
{
if (
!$this->isTrackingEnabled() ||
!$this->isPreciseTrackingEnabled() &&
($this->isUseApiForTracking() ||
$this->isUseGeoMapForTracking()) ||
empty($this->getPreciseTrackingHours())
) {
return;
}
$location = $this->getRecentPreciseLocationOfIp($ip, true);
if ($location) {
$trackingPage->setPreciseCountry($location->getPreciseCountry());
$trackingPage->setPreciseCity($location->getPreciseCity());
}
}
public function getRecommendedPagesForUser(int $limit = 200): array
{
$request = $this->requestStack->getCurrentRequest();
if (!$request) {
return [];
}
/** @var StdWebUsers|null $webUser */
$webUser = $this->security->getUser();
$trackingId = $this->getOrCreateTrackingId();
$hashedTrackingId = Functions::encryptSha256($trackingId, $this->encryptionKey);
$conn = $this->em->getConnection();
// Single query to get visited page IDs, tag domain value IDs, and category IDs
$userCondition = $webUser
? 'pt.web_user_id = :userId'
: 'pt.tracking_id = :trackingId';
$dataQuery = "
SELECT
GROUP_CONCAT(DISTINCT pt.page_id) as visited_page_ids,
GROUP_CONCAT(DISTINCT tag.domain_value_id) as tag_domain_value_ids,
GROUP_CONCAT(DISTINCT cat_rel.relation_id) as category_ids
FROM std_pages_tracking pt
LEFT JOIN std_tag_tracking stt ON stt.tracking_id = pt.id
LEFT JOIN std_pages_tags tag ON tag.id = stt.tag_id
LEFT JOIN std_pages_pages cat_rel ON cat_rel.page_id = pt.page_id
LEFT JOIN std_content_types cat_ct ON cat_ct.id = cat_rel.content_type AND cat_ct.machine_name = 'pages_categories'
WHERE {$userCondition}
";
$params = $webUser
? ['userId' => $webUser->getId()]
: ['trackingId' => $hashedTrackingId];
$result = $conn->executeQuery($dataQuery, $params)->fetchAssociative();
if (!$result || empty($result['visited_page_ids'])) {
return [];
}
$visitedPageIds = array_filter(array_unique(explode(',', $result['visited_page_ids'])));
$tagDomainValueIds = $result['tag_domain_value_ids']
? array_filter(array_unique(explode(',', $result['tag_domain_value_ids'])))
: [];
$categoryIds = $result['category_ids']
? array_filter(array_unique(explode(',', $result['category_ids'])))
: [];
if (empty($tagDomainValueIds) && empty($categoryIds)) {
return [];
}
// Build optimized main query
$qb = $this->em->createQueryBuilder();
$qb->select('p')
->from(StdPages::class, 'p')
->where($qb->expr()->notIn('p.id', ':visited'))
->setParameter('visited', $visitedPageIds);
// Build score calculation and conditions
$scoreParts = [];
$orConditions = [];
if (!empty($tagDomainValueIds)) {
$qb->leftJoin('p.tags', 't')
->leftJoin('t.domainValue', 'dv');
$orConditions[] = $qb->expr()->in('dv.id', ':tagDomainValueIds');
$qb->setParameter('tagDomainValueIds', $tagDomainValueIds);
$scoreParts[] = '(CASE WHEN dv.id IN (:tagDomainValueIds) THEN 1 ELSE 0 END)';
}
if (!empty($categoryIds)) {
$qb->leftJoin('p.relations', 'rel')
->leftJoin('rel.contentType', 'relCt');
$orConditions[] = $qb->expr()->andX(
$qb->expr()->in('rel.relationId', ':catIds'),
$qb->expr()->eq('relCt.machineName', ':catMachine')
);
$qb->setParameter('catIds', $categoryIds);
$qb->setParameter('catMachine', 'pages_categories');
$scoreParts[] = '(CASE WHEN rel.relationId IN (:catIds) AND relCt.machineName = :catMachine THEN 1 ELSE 0 END)';
}
if (!empty($orConditions)) {
$qb->andWhere($qb->expr()->orX(...$orConditions));
}
if (!empty($scoreParts)) {
$qb->addSelect('(' . implode(' + ', $scoreParts) . ') AS HIDDEN score');
$qb->orderBy('score', 'DESC')
->addOrderBy('p.publishDate', 'DESC');
} else {
$qb->orderBy('p.publishDate', 'DESC');
}
$qb->groupBy('p.id')
->setMaxResults($limit);
return $qb->getQuery()->getResult();
}
public function isTrackingEnabled(): bool
{
return $this->trackingEnabled;
}
public function isPreciseTrackingEnabled(): bool
{
return $this->preciseTrackingEnabled && $this->trackingEnabled;
}
public function getPreciseTrackingHours(): int
{
return $this->preciseTrackingHours;
}
public function isUseGeoMapForTracking(): bool
{
return $this->useGeoMapForTracking;
}
public function setUseGeoMapForTracking(bool $useGeoMapForTracking): void
{
$this->useGeoMapForTracking = $useGeoMapForTracking;
}
public function isUseApiForTracking(): bool
{
return $this->useApiForTracking;
}
public function setUseApiForTracking(bool $useApiForTracking): void
{
$this->useApiForTracking = $useApiForTracking;
}
public function isTrackingCookieAccepted(): bool
{
$request = $this->requestStack->getCurrentRequest();
if (!$request) {
return false;
}
$analyticsCookieName = getenv('COOKIE_STD_ANALYTICS_NAME');
$analyticsConsent = $request->cookies->get($analyticsCookieName);
return $analyticsConsent === 'accepted';
}
/**
* Get or create persistent tracking ID (1 year cookie, independent of session)
* This survives session clears, cache clears, and browser restarts
*/
private function getOrCreateTrackingId(): string
{
$request = $this->requestStack->getCurrentRequest();
if (!$request) {
return uniqid('track_', true);
}
// Check if tracking ID cookie already exists
$trackingId = $request->cookies->get('_clw_tracking_id');
if (!$trackingId) {
$trackingId = bin2hex(random_bytes(16));
$this->pendingTrackingCookie = Cookie::create('_clw_tracking_id')
->withValue($trackingId)
->withExpires(time() + 31536000) // 1 year
->withPath('/')
->withSecure($request->isSecure())
->withHttpOnly(true)
->withSameSite(Cookie::SAMESITE_LAX);
}
return $trackingId;
}
/**
* Get the pending tracking cookie that needs to be set on the response
* Call this from a ResponseListener or Controller
*/
public function getPendingTrackingCookie(): ?Cookie
{
return $this->pendingTrackingCookie;
}
/**
* Clear the pending cookie after it's been set
*/
public function clearPendingTrackingCookie(): void
{
$this->pendingTrackingCookie = null;
}
}