Skip to content
Merged
Show file tree
Hide file tree
Changes from 6 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 3 additions & 4 deletions .coderabbit.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -9,12 +9,11 @@ reviews:
high_level_summary_in_walkthrough: false
changed_files_summary: false
poem: false
finishing_touches:
docstrings:
enabled: false
auto_review:
enabled: true
base_branches:
- ".*"
drafts: false

checks:
docstring_coverage:
enabled: false
105 changes: 105 additions & 0 deletions src/Statistics/Controller/MessageOpenTrackController.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
<?php

declare(strict_types=1);

namespace PhpList\RestBundle\Statistics\Controller;

use Doctrine\ORM\EntityManagerInterface;
use OpenApi\Attributes as OA;
use PhpList\RestBundle\Common\Controller\BaseController;
use PhpList\Core\Domain\Analytics\Service\UserMessageService;
use PhpList\Core\Security\Authentication;
use PhpList\RestBundle\Common\Validator\RequestValidator;
use Psr\Log\LoggerInterface;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Attribute\MapQueryParameter;
use Symfony\Component\Routing\Attribute\Route;
use Throwable;

#[Route('/t', name: 'tracks_')]
class MessageOpenTrackController extends BaseController
{
public function __construct(
Authentication $authentication,
RequestValidator $validator,
private readonly UserMessageService $userMessageService,
private readonly EntityManagerInterface $entityManager,
private readonly LoggerInterface $logger,
) {
parent::__construct($authentication, $validator);
}

#[Route('/open.gif', name: 'user_message_open', methods: ['GET'])]
#[OA\Get(
path: '/api/v2/t/open.gif',
description: '1x1 tracking pixel endpoint that records a message view.'
. ' Requires `u` (subscriber UID) and `m` (message ID) as query parameters.',
summary: 'Track user message open',
tags: ['tracking'],
parameters: [
new OA\Parameter(
name: 'u',
description: 'Subscriber unique identifier (UID)',
in: 'query',
required: true,
schema: new OA\Schema(type: 'string')
),
new OA\Parameter(
name: 'm',
description: 'Message ID',
in: 'query',
required: true,
schema: new OA\Schema(type: 'integer')
),
],
responses: [
new OA\Response(response: 200, description: 'Transparent 1x1 GIF'),
]
)]
public function trackUserMessageView(
Request $request,
#[MapQueryParameter(name: 'u')] ?string $uid = null,
#[MapQueryParameter(name: 'm')] ?int $messageId = null,
): Response {
if (!$uid || !$messageId) {
return $this->returnPixelResponse();
}
Comment thread
TatevikGr marked this conversation as resolved.

$metadata = [
'HTTP_USER_AGENT' => $request->server->get('HTTP_USER_AGENT'),
'HTTP_REFERER' => $request->server->get('HTTP_REFERER'),
'client_ip' => $request->getClientIp(),
];

try {
$this->userMessageService->trackUserMessageView($uid, $messageId, $metadata);
$this->entityManager->flush();
} catch (Throwable $e) {
$this->logger->error(
'Failed to track user message view',
[
'exception' => $e,
'message_id' => $messageId,
'subscriber_uid' => $uid,
]
);
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

return $this->returnPixelResponse();
}

private function returnPixelResponse(): Response
{
return new Response(
content: base64_decode('R0lGODlhAQABAPAAAAAAAAAAACH5BAEAAAAALAAAAAABAAEAAAICRAEAOw=='),
status: 200,
headers: [
'Content-Type' => 'image/gif',
'Cache-Control' => 'no-store, no-cache, must-revalidate, max-age=0',
'Pragma' => 'no-cache',
'Expires' => '0',
]
);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
<?php

declare(strict_types=1);

namespace PhpList\RestBundle\Tests\Integration\Statistics\Controller;

use PhpList\RestBundle\Statistics\Controller\MessageOpenTrackController;
use PhpList\RestBundle\Tests\Integration\Common\AbstractTestController;
use PhpList\RestBundle\Tests\Integration\Identity\Fixtures\AdministratorFixture;
use PhpList\RestBundle\Tests\Integration\Messaging\Fixtures\MessageFixture;
use PhpList\RestBundle\Tests\Integration\Messaging\Fixtures\TemplateFixture;
use PhpList\RestBundle\Tests\Integration\Statistics\Fixtures\UserMessageFixture;
use PhpList\RestBundle\Tests\Integration\Subscription\Fixtures\SubscriberFixture;

class MessageOpenTrackControllerTest extends AbstractTestController
{
// from SubscriberFixture (id=1)
private const TEST_UID = '95feb7fe7e06e6c11ca8d0c48cb46e89';
// from MessageFixture
private const TEST_MESSAGE_ID = 1;

public function testControllerIsAvailableViaContainer(): void
{
self::assertInstanceOf(
MessageOpenTrackController::class,
self::getContainer()->get(MessageOpenTrackController::class)
);
}

public function testOpenGifReturnsTransparentGifWithNoCacheHeaders(): void
{
$this->loadFixtures([
AdministratorFixture::class,
TemplateFixture::class,
MessageFixture::class,
SubscriberFixture::class,
UserMessageFixture::class,
]);

self::getClient()->request(
'GET',
sprintf('/api/v2/t/open.gif?u=%s&m=%d', self::TEST_UID, self::TEST_MESSAGE_ID)
);

$response = self::getClient()->getResponse();

self::assertSame(200, $response->getStatusCode());
self::assertSame('image/gif', $response->headers->get('Content-Type'));
self::assertStringContainsString('no-store', (string) $response->headers->get('Cache-Control'));
self::assertSame('no-cache', $response->headers->get('Pragma'));
self::assertSame('0', $response->headers->get('Expires'));

$expectedGif = base64_decode('R0lGODlhAQABAPAAAAAAAAAAACH5BAEAAAAALAAAAAABAAEAAAICRAEAOw==');
self::assertSame($expectedGif, $response->getContent());
}

public function testOpenGifReturnsGifEvenIfUidUnknown(): void
{
// No fixtures needed for this; the controller should still return the GIF even if tracking no-ops
self::getClient()->request('GET', '/api/v2/t/open.gif?u=unknown-uid&m=999999');

$response = self::getClient()->getResponse();
self::assertSame(200, $response->getStatusCode());
self::assertSame('image/gif', $response->headers->get('Content-Type'));
}

public function testOpenGifMissingParametersReturns200Anyway(): void
{
self::getClient()->request('GET', '/api/v2/t/open.gif');
$status = self::getClient()->getResponse()->getStatusCode();

self::assertSame(200, $status);
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
43 changes: 43 additions & 0 deletions tests/Integration/Statistics/Fixtures/UserMessageFixture.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
<?php

declare(strict_types=1);

namespace PhpList\RestBundle\Tests\Integration\Statistics\Fixtures;

use Doctrine\Bundle\FixturesBundle\Fixture;
use Doctrine\Persistence\ObjectManager;
use PhpList\Core\Domain\Messaging\Model\Message;
use PhpList\Core\Domain\Messaging\Model\Message\UserMessageStatus;
use PhpList\Core\Domain\Messaging\Model\UserMessage;
use PhpList\Core\Domain\Subscription\Model\Subscriber;

/**
* Links an existing test Subscriber (id=1) with an existing test Message (id=1)
* via a UserMessage record in status "sent".
*/
class UserMessageFixture extends Fixture
{
public const SUBSCRIBER_ID = 1;
public const MESSAGE_ID = 1;

public function load(ObjectManager $manager): void
{
/** @var Subscriber|null $subscriber */
$subscriber = $manager->getRepository(Subscriber::class)->find(self::SUBSCRIBER_ID);
/** @var Message|null $message */
$message = $manager->getRepository(Message::class)->find(self::MESSAGE_ID);

// Doctrine may return null here when prerequisite fixtures are not loaded.
// PHPStan infers non-null from PHPDoc in some environments; suppress that false positive.
if ($subscriber === null || $message === null) {
// Pre-requisite fixtures aren't loaded; nothing to do.
return;
}

$userMessage = new UserMessage($subscriber, $message);
$userMessage->setStatus(UserMessageStatus::Sent);

$manager->persist($userMessage);
$manager->flush();
}
}
Loading