Skip to content

Commit aacaf11

Browse files
authored
Feat: track message view (#167)
New Features Added 1x1 pixel message-open tracking endpoint to record when messages are viewed. Tests Added integration tests validating pixel responses, headers, behavior with missing/unknown parameters, and included test fixtures linking messages and subscribers. Chores Adjusted CI/config finishing touches related to docstring handling.
1 parent 1be4006 commit aacaf11

4 files changed

Lines changed: 224 additions & 4 deletions

File tree

.coderabbit.yaml

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -9,12 +9,11 @@ reviews:
99
high_level_summary_in_walkthrough: false
1010
changed_files_summary: false
1111
poem: false
12+
finishing_touches:
13+
docstrings:
14+
enabled: false
1215
auto_review:
1316
enabled: true
1417
base_branches:
1518
- ".*"
1619
drafts: false
17-
18-
checks:
19-
docstring_coverage:
20-
enabled: false
Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace PhpList\RestBundle\Statistics\Controller;
6+
7+
use Doctrine\ORM\EntityManagerInterface;
8+
use OpenApi\Attributes as OA;
9+
use PhpList\RestBundle\Common\Controller\BaseController;
10+
use PhpList\Core\Domain\Analytics\Service\UserMessageService;
11+
use PhpList\Core\Security\Authentication;
12+
use PhpList\RestBundle\Common\Validator\RequestValidator;
13+
use Psr\Log\LoggerInterface;
14+
use Symfony\Component\HttpFoundation\Request;
15+
use Symfony\Component\HttpFoundation\Response;
16+
use Symfony\Component\HttpKernel\Attribute\MapQueryParameter;
17+
use Symfony\Component\Routing\Attribute\Route;
18+
use Throwable;
19+
20+
#[Route('/t', name: 'tracks_')]
21+
class MessageOpenTrackController extends BaseController
22+
{
23+
public function __construct(
24+
Authentication $authentication,
25+
RequestValidator $validator,
26+
private readonly UserMessageService $userMessageService,
27+
private readonly EntityManagerInterface $entityManager,
28+
private readonly LoggerInterface $logger,
29+
) {
30+
parent::__construct($authentication, $validator);
31+
}
32+
33+
#[Route('/open.gif', name: 'user_message_open', methods: ['GET'])]
34+
#[OA\Get(
35+
path: '/api/v2/t/open.gif',
36+
description: '1x1 tracking pixel endpoint that records a message view.'
37+
. ' Requires `u` (subscriber UID) and `m` (message ID) as query parameters.',
38+
summary: 'Track user message open',
39+
tags: ['tracking'],
40+
parameters: [
41+
new OA\Parameter(
42+
name: 'u',
43+
description: 'Subscriber unique identifier (UID)',
44+
in: 'query',
45+
required: true,
46+
schema: new OA\Schema(type: 'string')
47+
),
48+
new OA\Parameter(
49+
name: 'm',
50+
description: 'Message ID',
51+
in: 'query',
52+
required: true,
53+
schema: new OA\Schema(type: 'integer')
54+
),
55+
],
56+
responses: [
57+
new OA\Response(response: 200, description: 'Transparent 1x1 GIF'),
58+
]
59+
)]
60+
public function trackUserMessageView(
61+
Request $request,
62+
#[MapQueryParameter(name: 'u')] ?string $uid = null,
63+
#[MapQueryParameter(name: 'm')] ?int $messageId = null,
64+
): Response {
65+
if ($uid === null || $messageId === null || $messageId <= 0) {
66+
return $this->returnPixelResponse();
67+
}
68+
69+
$metadata = [
70+
'HTTP_USER_AGENT' => $request->server->get('HTTP_USER_AGENT'),
71+
'HTTP_REFERER' => $request->server->get('HTTP_REFERER'),
72+
'client_ip' => $request->getClientIp(),
73+
];
74+
75+
try {
76+
$this->userMessageService->trackUserMessageView($uid, $messageId, $metadata);
77+
$this->entityManager->flush();
78+
} catch (Throwable $e) {
79+
$this->logger->error(
80+
'Failed to track user message view',
81+
[
82+
'exception' => $e,
83+
'message_id' => $messageId,
84+
]
85+
);
86+
}
87+
88+
return $this->returnPixelResponse();
89+
}
90+
91+
private function returnPixelResponse(): Response
92+
{
93+
return new Response(
94+
content: base64_decode('R0lGODlhAQABAPAAAAAAAAAAACH5BAEAAAAALAAAAAABAAEAAAICRAEAOw=='),
95+
status: 200,
96+
headers: [
97+
'Content-Type' => 'image/gif',
98+
'Cache-Control' => 'no-store, no-cache, must-revalidate, max-age=0',
99+
'Pragma' => 'no-cache',
100+
'Expires' => '0',
101+
]
102+
);
103+
}
104+
}
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace PhpList\RestBundle\Tests\Integration\Statistics\Controller;
6+
7+
use PhpList\RestBundle\Statistics\Controller\MessageOpenTrackController;
8+
use PhpList\RestBundle\Tests\Integration\Common\AbstractTestController;
9+
use PhpList\RestBundle\Tests\Integration\Identity\Fixtures\AdministratorFixture;
10+
use PhpList\RestBundle\Tests\Integration\Messaging\Fixtures\MessageFixture;
11+
use PhpList\RestBundle\Tests\Integration\Messaging\Fixtures\TemplateFixture;
12+
use PhpList\RestBundle\Tests\Integration\Statistics\Fixtures\UserMessageFixture;
13+
use PhpList\RestBundle\Tests\Integration\Subscription\Fixtures\SubscriberFixture;
14+
15+
class MessageOpenTrackControllerTest extends AbstractTestController
16+
{
17+
// from SubscriberFixture (id=1)
18+
private const TEST_UID = '95feb7fe7e06e6c11ca8d0c48cb46e89';
19+
// from MessageFixture
20+
private const TEST_MESSAGE_ID = 1;
21+
22+
public function testControllerIsAvailableViaContainer(): void
23+
{
24+
self::assertInstanceOf(
25+
MessageOpenTrackController::class,
26+
self::getContainer()->get(MessageOpenTrackController::class)
27+
);
28+
}
29+
30+
public function testOpenGifReturnsTransparentGifWithNoCacheHeaders(): void
31+
{
32+
$this->loadFixtures([
33+
AdministratorFixture::class,
34+
TemplateFixture::class,
35+
MessageFixture::class,
36+
SubscriberFixture::class,
37+
UserMessageFixture::class,
38+
]);
39+
40+
self::getClient()->request(
41+
'GET',
42+
sprintf('/api/v2/t/open.gif?u=%s&m=%d', self::TEST_UID, self::TEST_MESSAGE_ID)
43+
);
44+
45+
$response = self::getClient()->getResponse();
46+
47+
self::assertSame(200, $response->getStatusCode());
48+
self::assertSame('image/gif', $response->headers->get('Content-Type'));
49+
self::assertStringContainsString('no-store', (string) $response->headers->get('Cache-Control'));
50+
self::assertSame('no-cache', $response->headers->get('Pragma'));
51+
self::assertSame('0', $response->headers->get('Expires'));
52+
53+
$expectedGif = base64_decode('R0lGODlhAQABAPAAAAAAAAAAACH5BAEAAAAALAAAAAABAAEAAAICRAEAOw==');
54+
self::assertSame($expectedGif, $response->getContent());
55+
}
56+
57+
public function testOpenGifReturnsGifEvenIfUidUnknown(): void
58+
{
59+
// No fixtures needed for this; the controller should still return the GIF even if tracking no-ops
60+
self::getClient()->request('GET', '/api/v2/t/open.gif?u=unknown-uid&m=999999');
61+
62+
$response = self::getClient()->getResponse();
63+
self::assertSame(200, $response->getStatusCode());
64+
self::assertSame('image/gif', $response->headers->get('Content-Type'));
65+
}
66+
67+
public function testOpenGifMissingParametersReturns200Anyway(): void
68+
{
69+
self::getClient()->request('GET', '/api/v2/t/open.gif');
70+
$status = self::getClient()->getResponse()->getStatusCode();
71+
72+
self::assertSame(200, $status);
73+
}
74+
}
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace PhpList\RestBundle\Tests\Integration\Statistics\Fixtures;
6+
7+
use Doctrine\Bundle\FixturesBundle\Fixture;
8+
use Doctrine\Persistence\ObjectManager;
9+
use PhpList\Core\Domain\Messaging\Model\Message;
10+
use PhpList\Core\Domain\Messaging\Model\Message\UserMessageStatus;
11+
use PhpList\Core\Domain\Messaging\Model\UserMessage;
12+
use PhpList\Core\Domain\Subscription\Model\Subscriber;
13+
14+
/**
15+
* Links an existing test Subscriber (id=1) with an existing test Message (id=1)
16+
* via a UserMessage record in status "sent".
17+
*/
18+
class UserMessageFixture extends Fixture
19+
{
20+
public const SUBSCRIBER_ID = 1;
21+
public const MESSAGE_ID = 1;
22+
23+
public function load(ObjectManager $manager): void
24+
{
25+
/** @var Subscriber|null $subscriber */
26+
$subscriber = $manager->getRepository(Subscriber::class)->find(self::SUBSCRIBER_ID);
27+
/** @var Message|null $message */
28+
$message = $manager->getRepository(Message::class)->find(self::MESSAGE_ID);
29+
30+
// Doctrine may return null here when prerequisite fixtures are not loaded.
31+
// PHPStan infers non-null from PHPDoc in some environments; suppress that false positive.
32+
if ($subscriber === null || $message === null) {
33+
// Pre-requisite fixtures aren't loaded; nothing to do.
34+
return;
35+
}
36+
37+
$userMessage = new UserMessage($subscriber, $message);
38+
$userMessage->setStatus(UserMessageStatus::Sent);
39+
40+
$manager->persist($userMessage);
41+
$manager->flush();
42+
}
43+
}

0 commit comments

Comments
 (0)