From 603f05058dcbbd98bb49a6840f77f884d89288c3 Mon Sep 17 00:00:00 2001 From: Tatevik Date: Thu, 12 Feb 2026 11:55:53 +0400 Subject: [PATCH 1/4] UserMessageService --- config/services/services.yml | 4 + .../Analytics/Model/UserMessageView.php | 5 + .../Analytics/Service/UserMessageService.php | 56 ++++++ .../Placeholder/UserTrackValueResolver.php | 2 +- .../CampaignProcessorMessageHandler.php | 2 +- src/Domain/Messaging/Model/UserMessage.php | 10 + .../Repository/UserMessageRepository.php | 2 +- .../Messaging/Service/ForwardingGuard.php | 2 +- .../Service/UserMessageServiceTest.php | 188 ++++++++++++++++++ .../UserTrackValueResolverTest.php | 4 +- .../Messaging/Service/ForwardingGuardTest.php | 6 +- 11 files changed, 272 insertions(+), 9 deletions(-) create mode 100644 src/Domain/Analytics/Service/UserMessageService.php create mode 100644 tests/Unit/Domain/Analytics/Service/UserMessageServiceTest.php diff --git a/config/services/services.yml b/config/services/services.yml index 5e7db66b..53a5f84d 100644 --- a/config/services/services.yml +++ b/config/services/services.yml @@ -53,6 +53,10 @@ services: autoconfigure: true public: true + PhpList\Core\Domain\Analytics\Service\UserMessageService: + autowire: true + autoconfigure: true + PhpList\Core\Domain\Messaging\Service\SendRateLimiter: autowire: true autoconfigure: true diff --git a/src/Domain/Analytics/Model/UserMessageView.php b/src/Domain/Analytics/Model/UserMessageView.php index 846c8e97..b391d3f3 100644 --- a/src/Domain/Analytics/Model/UserMessageView.php +++ b/src/Domain/Analytics/Model/UserMessageView.php @@ -85,6 +85,11 @@ public function setViewed(?DateTime $viewed): self return $this; } + public function setViewedNow(): self + { + return $this->setViewed(new DateTime()); + } + public function setIp(?string $ip): self { $this->ip = $ip; diff --git a/src/Domain/Analytics/Service/UserMessageService.php b/src/Domain/Analytics/Service/UserMessageService.php new file mode 100644 index 00000000..878845e8 --- /dev/null +++ b/src/Domain/Analytics/Service/UserMessageService.php @@ -0,0 +1,56 @@ +subscriberRepository->findOneByUniqueId($uid); + $message = $this->messageRepository->find($messageId); + + if ($subscriber === null || $message === null) { + return; + } + + $userMessage = $this->userMessageRepository->findByUserAndMessage($subscriber, $message); + if ($userMessage === null) { + return; + } + + $userMessage->setViewedNow(); + $message->incrementViews(); + + $data = []; + foreach (['HTTP_USER_AGENT', 'HTTP_REFERER'] as $key) { + if (isset($metadata[$key])) { + $data[$key] = htmlspecialchars(strip_tags($metadata[$key])); + } + } + + $userMessageView = new UserMessageView(); + $userMessageView->setUserId($subscriber->getId()); + $userMessageView->setMessageId($messageId); + $userMessageView->setViewedNow(); + $userMessageView->setIp($metadata['client_ip'] ?? null); + $userMessageView->setData(serialize($data)); + + $this->entityManager->persist($userMessageView); + } +} diff --git a/src/Domain/Configuration/Service/Placeholder/UserTrackValueResolver.php b/src/Domain/Configuration/Service/Placeholder/UserTrackValueResolver.php index 4983fc28..4bcfb96a 100644 --- a/src/Domain/Configuration/Service/Placeholder/UserTrackValueResolver.php +++ b/src/Domain/Configuration/Service/Placeholder/UserTrackValueResolver.php @@ -33,7 +33,7 @@ public function __invoke(PlaceholderContext $ctx): string return ''; + $expected = ''; // Normalize double quotes for comparison $this->assertSame($expected, $result); } @@ -86,7 +86,7 @@ public function testHtmlFallsBackToRestApiDomainWhenConfigMissing(): void $result = $resolver($ctx); - $expected = ''; + $expected = ''; $this->assertSame($expected, $result); } } diff --git a/tests/Unit/Domain/Messaging/Service/ForwardingGuardTest.php b/tests/Unit/Domain/Messaging/Service/ForwardingGuardTest.php index 8ac266f8..b1ee10b9 100644 --- a/tests/Unit/Domain/Messaging/Service/ForwardingGuardTest.php +++ b/tests/Unit/Domain/Messaging/Service/ForwardingGuardTest.php @@ -46,7 +46,7 @@ public function testAssertCanForwardReturnsSubscriber(): void $subscriber = new Subscriber(); $this->subscriberRepo->method('findOneByUniqueId')->with($uid)->willReturn($subscriber); - $this->userMessageRepo->method('findOneByUserAndMessage')->willReturn( + $this->userMessageRepo->method('findByUserAndMessage')->willReturn( $this->createMock(UserMessage::class) ); $this->forwardRepo->method('getCountByUserSince')->willReturn(1); @@ -82,7 +82,7 @@ public function testAssertCanForwardThrowsWhenMessageNotReceived(): void ); $this->subscriberRepo->method('findOneByUniqueId')->willReturn(new Subscriber()); - $this->userMessageRepo->method('findOneByUserAndMessage')->willReturn(null); + $this->userMessageRepo->method('findByUserAndMessage')->willReturn(null); $this->expectException(MessageNotReceivedException::class); $guard->assertCanForward('uid', $this->createMock(Message::class)); @@ -99,7 +99,7 @@ public function testAssertCanForwardThrowsWhenLimitExceeded(): void ); $this->subscriberRepo->method('findOneByUniqueId')->willReturn(new Subscriber()); - $this->userMessageRepo->method('findOneByUserAndMessage')->willReturn($this->createMock(UserMessage::class)); + $this->userMessageRepo->method('findByUserAndMessage')->willReturn($this->createMock(UserMessage::class)); $this->forwardRepo->method('getCountByUserSince')->willReturn(2); $this->expectException(ForwardLimitExceededException::class); From 47855679adf8a0af97c4091039f4fba6d27eadf9 Mon Sep 17 00:00:00 2001 From: Tatevik Date: Thu, 12 Feb 2026 12:51:51 +0400 Subject: [PATCH 2/4] rest_api_base_url --- config/parameters.yml.dist | 4 ++-- .../Service/Placeholder/UserTrackValueResolver.php | 4 ++-- .../Messaging/Service/Builder/HttpReceivedStampBuilder.php | 4 ++-- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/config/parameters.yml.dist b/config/parameters.yml.dist index 628f1e45..6de7d2ef 100644 --- a/config/parameters.yml.dist +++ b/config/parameters.yml.dist @@ -33,8 +33,8 @@ parameters: env(APP_POWERED_BY_PHPLIST): '0' app.preference_page_show_private_lists: '%%env(PREFERENCEPAGE_SHOW_PRIVATE_LISTS)%%' env(PREFERENCEPAGE_SHOW_PRIVATE_LISTS): '0' - app.rest_api_domain: '%%env(REST_API_DOMAIN)%%' - env(REST_API_DOMAIN): 'example.com' + app.rest_api_base_url: '%%env(REST_API_BASE_URL)%%' + env(REST_API_BASE_URL): 'https://example.com/api/v2' # Email configuration app.mailer_from: '%%env(MAILER_FROM)%%' diff --git a/src/Domain/Configuration/Service/Placeholder/UserTrackValueResolver.php b/src/Domain/Configuration/Service/Placeholder/UserTrackValueResolver.php index 4bcfb96a..e893a46c 100644 --- a/src/Domain/Configuration/Service/Placeholder/UserTrackValueResolver.php +++ b/src/Domain/Configuration/Service/Placeholder/UserTrackValueResolver.php @@ -13,7 +13,7 @@ final class UserTrackValueResolver implements PlaceholderValueResolverInterface { public function __construct( private readonly ConfigProvider $config, - #[Autowire('%rest_api_domain%')] private readonly string $restApiDomain, + #[Autowire('%rest_api_base_url%')] private readonly string $restApiBaseUrl, ) { } @@ -24,7 +24,7 @@ public function name(): string public function __invoke(PlaceholderContext $ctx): string { - $base = $this->config->getValue(ConfigOption::Domain) ?? $this->restApiDomain; + $base = $this->config->getValue(ConfigOption::Domain) ?? $this->restApiBaseUrl; if ($ctx->isText() || empty($base)) { return ''; diff --git a/src/Domain/Messaging/Service/Builder/HttpReceivedStampBuilder.php b/src/Domain/Messaging/Service/Builder/HttpReceivedStampBuilder.php index 8c62a2af..fe06efca 100644 --- a/src/Domain/Messaging/Service/Builder/HttpReceivedStampBuilder.php +++ b/src/Domain/Messaging/Service/Builder/HttpReceivedStampBuilder.php @@ -12,7 +12,7 @@ class HttpReceivedStampBuilder { public function __construct( private readonly RequestStack $requestStack, - #[Autowire('%app.rest_api_domain%')] private readonly string $hostname, + #[Autowire('%app.rest_api_base_url%')] private readonly string $restApiBaseUrl, ) { } @@ -40,7 +40,7 @@ public function buildStamp(): ?string $requestTime = $request->server->get('REQUEST_TIME') ?? time(); $date = (new DateTimeImmutable('@' . $requestTime))->format(\DATE_RFC2822); - return sprintf('from %s by %s with HTTP; %s', $from, $this->hostname, $date); + return sprintf('from %s by %s with HTTP; %s', $from, $this->restApiBaseUrl, $date); } private function getHostByAddr(string $ipAddress): ?string From 7c07bd2fe4f7de9a2ac1b8c19695c2c16d2ad35c Mon Sep 17 00:00:00 2001 From: Tatevik Date: Thu, 12 Feb 2026 13:37:40 +0400 Subject: [PATCH 3/4] incrementViews --- src/Domain/Analytics/Service/UserMessageService.php | 5 +++-- src/Domain/Messaging/Model/Message/MessageMetadata.php | 7 +++++++ src/Domain/Messaging/Repository/MessageRepository.php | 10 ++++++++++ 3 files changed, 20 insertions(+), 2 deletions(-) diff --git a/src/Domain/Analytics/Service/UserMessageService.php b/src/Domain/Analytics/Service/UserMessageService.php index 878845e8..7a70ca37 100644 --- a/src/Domain/Analytics/Service/UserMessageService.php +++ b/src/Domain/Analytics/Service/UserMessageService.php @@ -6,6 +6,7 @@ use Doctrine\ORM\EntityManagerInterface; use PhpList\Core\Domain\Analytics\Model\UserMessageView; +use PhpList\Core\Domain\Messaging\Model\Message\MessageStatus; use PhpList\Core\Domain\Messaging\Repository\MessageRepository; use PhpList\Core\Domain\Messaging\Repository\UserMessageRepository; use PhpList\Core\Domain\Subscription\Repository\SubscriberRepository; @@ -23,7 +24,7 @@ public function __construct( public function trackUserMessageView(string $uid, int $messageId, array $metadata): void { $subscriber = $this->subscriberRepository->findOneByUniqueId($uid); - $message = $this->messageRepository->find($messageId); + $message = $this->messageRepository->findById($messageId); if ($subscriber === null || $message === null) { return; @@ -35,7 +36,7 @@ public function trackUserMessageView(string $uid, int $messageId, array $metadat } $userMessage->setViewedNow(); - $message->incrementViews(); + $message->getMetadata()->incrementViews(); $data = []; foreach (['HTTP_USER_AGENT', 'HTTP_REFERER'] as $key) { diff --git a/src/Domain/Messaging/Model/Message/MessageMetadata.php b/src/Domain/Messaging/Model/Message/MessageMetadata.php index 0d817c2e..f0662c90 100644 --- a/src/Domain/Messaging/Model/Message/MessageMetadata.php +++ b/src/Domain/Messaging/Model/Message/MessageMetadata.php @@ -81,6 +81,13 @@ public function setViews(int $viewed): self return $this; } + public function incrementViews(): self + { + $this->viewed += 1; + + return $this; + } + public function getViews(): int { return $this->viewed; diff --git a/src/Domain/Messaging/Repository/MessageRepository.php b/src/Domain/Messaging/Repository/MessageRepository.php index a2db5ae3..883e56d5 100644 --- a/src/Domain/Messaging/Repository/MessageRepository.php +++ b/src/Domain/Messaging/Repository/MessageRepository.php @@ -27,6 +27,7 @@ public function findCampaignsWithoutUuid(): array ->getResult(); } + /** @return Message[] */ public function getByOwnerId(int $ownerId): array { return $this->createQueryBuilder('m') @@ -36,6 +37,15 @@ public function getByOwnerId(int $ownerId): array ->getResult(); } + public function findById(int $id): ?Message + { + return $this->createQueryBuilder('m') + ->where('m.id = :id') + ->setParameter('id', $id) + ->getQuery() + ->getOneOrNullResult(); + } + /** @return Message[] */ public function getFilteredAfterId(int $lastId, int $limit, ?FilterRequestInterface $filter = null): array { From 3103bee3578b7ddb8962a733931b70967b6e3881 Mon Sep 17 00:00:00 2001 From: Tatevik Date: Thu, 12 Feb 2026 13:53:34 +0400 Subject: [PATCH 4/4] After review 0 --- .../Analytics/Service/UserMessageService.php | 1 - .../Placeholder/UserTrackValueResolver.php | 2 +- .../Service/UserMessageServiceTest.php | 26 +++++++++++-------- 3 files changed, 16 insertions(+), 13 deletions(-) diff --git a/src/Domain/Analytics/Service/UserMessageService.php b/src/Domain/Analytics/Service/UserMessageService.php index 7a70ca37..c5a1cc5b 100644 --- a/src/Domain/Analytics/Service/UserMessageService.php +++ b/src/Domain/Analytics/Service/UserMessageService.php @@ -6,7 +6,6 @@ use Doctrine\ORM\EntityManagerInterface; use PhpList\Core\Domain\Analytics\Model\UserMessageView; -use PhpList\Core\Domain\Messaging\Model\Message\MessageStatus; use PhpList\Core\Domain\Messaging\Repository\MessageRepository; use PhpList\Core\Domain\Messaging\Repository\UserMessageRepository; use PhpList\Core\Domain\Subscription\Repository\SubscriberRepository; diff --git a/src/Domain/Configuration/Service/Placeholder/UserTrackValueResolver.php b/src/Domain/Configuration/Service/Placeholder/UserTrackValueResolver.php index e893a46c..8a5b626d 100644 --- a/src/Domain/Configuration/Service/Placeholder/UserTrackValueResolver.php +++ b/src/Domain/Configuration/Service/Placeholder/UserTrackValueResolver.php @@ -13,7 +13,7 @@ final class UserTrackValueResolver implements PlaceholderValueResolverInterface { public function __construct( private readonly ConfigProvider $config, - #[Autowire('%rest_api_base_url%')] private readonly string $restApiBaseUrl, + #[Autowire('%app.rest_api_base_url%')] private readonly string $restApiBaseUrl, ) { } diff --git a/tests/Unit/Domain/Analytics/Service/UserMessageServiceTest.php b/tests/Unit/Domain/Analytics/Service/UserMessageServiceTest.php index 61d14724..47da1520 100644 --- a/tests/Unit/Domain/Analytics/Service/UserMessageServiceTest.php +++ b/tests/Unit/Domain/Analytics/Service/UserMessageServiceTest.php @@ -8,6 +8,7 @@ use PhpList\Core\Domain\Analytics\Model\UserMessageView; use PhpList\Core\Domain\Analytics\Service\UserMessageService; use PhpList\Core\Domain\Messaging\Model\Message; +use PhpList\Core\Domain\Messaging\Model\Message\MessageMetadata; use PhpList\Core\Domain\Messaging\Model\UserMessage; use PhpList\Core\Domain\Messaging\Repository\MessageRepository; use PhpList\Core\Domain\Messaging\Repository\UserMessageRepository; @@ -50,7 +51,7 @@ public function testReturnsEarlyWhenSubscriberNotFound(): void // Service fetches message regardless, then returns early because subscriber is null $this->messageRepository ->expects(self::once()) - ->method('find') + ->method('findById') ->with(123) ->willReturn($this->getMockBuilder(Message::class)->disableOriginalConstructor()->getMock()); $this->userMessageRepository->expects(self::never())->method('findByUserAndMessage'); @@ -68,7 +69,7 @@ public function testReturnsEarlyWhenMessageNotFound(): void $this->messageRepository ->expects(self::once()) - ->method('find') + ->method('findById') ->with(123) ->willReturn(null); @@ -83,11 +84,12 @@ public function testReturnsEarlyWhenUserMessageNotFound(): void $subscriber = $this->createMock(Subscriber::class); $message = $this->getMockBuilder(Message::class) ->disableOriginalConstructor() - ->addMethods(['incrementViews']) ->getMock(); + $metadata = $this->createMock(MessageMetadata::class); $this->subscriberRepository->method('findOneByUniqueId')->willReturn($subscriber); - $this->messageRepository->method('find')->willReturn($message); + $this->messageRepository->method('findById')->willReturn($message); + $message->method('getMetadata')->willReturn($metadata); $this->userMessageRepository ->expects(self::once()) @@ -95,7 +97,7 @@ public function testReturnsEarlyWhenUserMessageNotFound(): void ->with($subscriber, $message) ->willReturn(null); - $message->expects(self::never())->method('incrementViews'); + $metadata->expects(self::never())->method('incrementViews'); $this->entityManager->expects(self::never())->method('persist'); $this->subject->trackUserMessageView('sub-uid', 321, []); @@ -111,17 +113,18 @@ public function testHappyPathPersistsUserMessageViewAndMarksViewed(): void $message = $this->getMockBuilder(Message::class) ->disableOriginalConstructor() - ->addMethods(['incrementViews']) ->getMock(); + $metadataObj = $this->createMock(MessageMetadata::class); $userMessage = $this->createMock(UserMessage::class); $this->subscriberRepository->method('findOneByUniqueId')->willReturn($subscriber); - $this->messageRepository->method('find')->willReturn($message); + $this->messageRepository->method('findById')->with($messageId)->willReturn($message); + $message->method('getMetadata')->willReturn($metadataObj); $this->userMessageRepository->method('findByUserAndMessage')->willReturn($userMessage); $userMessage->expects(self::once())->method('setViewedNow'); - $message->expects(self::once())->method('incrementViews'); + $metadataObj->expects(self::once())->method('incrementViews'); $metadata = [ 'client_ip' => '203.0.113.10', @@ -156,16 +159,17 @@ public function testHandlesMissingOptionalMetadataGracefully(): void $message = $this->getMockBuilder(Message::class) ->disableOriginalConstructor() - ->addMethods(['incrementViews']) ->getMock(); + $metadataObj = $this->createMock(MessageMetadata::class); $userMessage = $this->createMock(UserMessage::class); $this->subscriberRepository->method('findOneByUniqueId')->willReturn($subscriber); - $this->messageRepository->method('find')->willReturn($message); + $this->messageRepository->method('findById')->willReturn($message); + $message->method('getMetadata')->willReturn($metadataObj); $this->userMessageRepository->method('findByUserAndMessage')->willReturn($userMessage); - $message->expects(self::once())->method('incrementViews'); + $metadataObj->expects(self::once())->method('incrementViews'); $userMessage->expects(self::once())->method('setViewedNow'); // No HTTP_* keys and no client_ip