From df74fc7b509456caa4ac746655fc000cf9aaaca6 Mon Sep 17 00:00:00 2001 From: Tatevik Date: Wed, 1 Apr 2026 22:05:16 +0400 Subject: [PATCH 01/13] Version: dev --- composer.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/composer.json b/composer.json index 92598e8..477d302 100644 --- a/composer.json +++ b/composer.json @@ -42,7 +42,7 @@ }, "require": { "php": "^8.1", - "phplist/core": "dev-main", + "phplist/core": "dev-dev", "friendsofsymfony/rest-bundle": "*", "symfony/test-pack": "^1.0", "symfony/process": "^6.4", From 9bbad8b8f64b5edb0e6c399ed1b8ac22a25c712c Mon Sep 17 00:00:00 2001 From: Tatevik Date: Thu, 2 Apr 2026 11:29:53 +0400 Subject: [PATCH 02/13] Copy message + patch status endpoints --- .../Controller/CampaignController.php | 143 ++++++++++++++++++ 1 file changed, 143 insertions(+) diff --git a/src/Messaging/Controller/CampaignController.php b/src/Messaging/Controller/CampaignController.php index 1501524..b8fc594 100644 --- a/src/Messaging/Controller/CampaignController.php +++ b/src/Messaging/Controller/CampaignController.php @@ -8,6 +8,8 @@ use OpenApi\Attributes as OA; use PhpList\Core\Domain\Messaging\Message\SyncCampaignProcessorMessage; use PhpList\Core\Domain\Messaging\Model\Message; +use PhpList\Core\Domain\Messaging\Model\Message\MessageStatus; +use PhpList\Core\Domain\Messaging\Service\Manager\MessageManager; use PhpList\Core\Security\Authentication; use PhpList\RestBundle\Common\Controller\BaseController; use PhpList\RestBundle\Common\Validator\RequestValidator; @@ -36,6 +38,7 @@ public function __construct( private readonly CampaignService $campaignService, private readonly MessageBusInterface $messageBus, private readonly EntityManagerInterface $entityManager, + private readonly MessageManager $messageManager, ) { parent::__construct($authentication, $validator); } @@ -304,6 +307,146 @@ public function updateMessage( return $this->json(data:$message, status: Response::HTTP_OK); } + #[Route('/{messageId}/copy', name: 'copy_campaign', requirements: ['messageId' => '\d+'], methods: ['POST'])] + #[OA\Post( + path: '/api/v2/campaigns/{messageId}/copy', + description: '🚧 **Status: Beta** – This method is under development. Avoid using in production. ' . + 'Copies campaign/message by id into a draft message.', + summary: 'Copies campaign/message by id.', + tags: ['campaigns'], + parameters: [ + new OA\Parameter( + name: 'php-auth-pw', + description: 'Session key obtained from login', + in: 'header', + required: true, + schema: new OA\Schema(type: 'string') + ), + new OA\Parameter( + name: 'messageId', + description: 'message ID', + in: 'path', + required: true, + schema: new OA\Schema(type: 'string') + ) + ], + responses: [ + new OA\Response( + response: 201, + description: 'Success', + content: new OA\JsonContent(ref: '#/components/schemas/Message') + ), + new OA\Response( + response: 403, + description: 'Failure', + content: new OA\JsonContent(ref: '#/components/schemas/UnauthorizedResponse') + ) + ] + )] + public function copyMessage( + Request $request, + #[MapEntity(mapping: ['messageId' => 'id'])] ?Message $message = null + ): JsonResponse { + $authUser = $this->requireAuthentication($request); + if ($message === null) { + throw $this->createNotFoundException('Campaign not found.'); + } + + $message = $this->messageManager->copyAsDraftMessage($message, $authUser); + $this->entityManager->flush(); + + return $this->json($this->campaignService->getMessage($message), Response::HTTP_CREATED); + } + + #[Route('/{messageId}/status', name: 'update_status', requirements: ['messageId' => '\d+'], methods: ['PATCH'])] + #[OA\Patch( + path: '/api/v2/campaigns/{messageId}/status', + description: '🚧 **Status: Beta** – This method is under development. Avoid using in production. ' . + 'Updates campaign/message status by id.', + summary: 'Update campaign status by id.', + requestBody: new OA\RequestBody( + description: 'Update message status.', + required: true, + content: new OA\JsonContent( + required: ['status'], + properties: [ + new OA\Property( + property: 'status', + type: 'string', + enum: ['draft', 'scheduled', 'sent', 'canceled'], + example: 'draft' + ), + ], + type: 'object' + ) + ), + tags: ['campaigns'], + parameters: [ + new OA\Parameter( + name: 'php-auth-pw', + description: 'Session key obtained from login', + in: 'header', + required: true, + schema: new OA\Schema( + type: 'string' + ) + ), + new OA\Parameter( + name: 'messageId', + description: 'message ID', + in: 'path', + required: true, + schema: new OA\Schema(type: 'string') + ) + ], + responses: [ + new OA\Response( + response: 201, + description: 'Success', + content: new OA\JsonContent(ref: '#/components/schemas/Message') + ), + new OA\Response( + response: 403, + description: 'Failure', + content: new OA\JsonContent(ref: '#/components/schemas/UnauthorizedResponse') + ), + new OA\Response( + response: 404, + description: 'Failure', + content: new OA\JsonContent(ref: '#/components/schemas/NotFoundErrorResponse') + ), + new OA\Response( + response: 422, + description: 'Failure', + content: new OA\JsonContent(ref: '#/components/schemas/ValidationErrorResponse') + ), + ] + )] + public function updateMessageStatus( + Request $request, + #[MapEntity(mapping: ['messageId' => 'id'])] ?Message $message = null, + ): JsonResponse { + $this->requireAuthentication($request); + if ($message === null) { + throw $this->createNotFoundException('Message not found.'); + } + + if ($request->request->get('status') === null) { + return $this->json( + ['error' => 'Missing "status" value.'], + Response::HTTP_BAD_REQUEST + ); + } + + $message = $this->messageManager->updateStatus( + $message, + MessageStatus::from($request->request->get('status')) + ); + $this->entityManager->flush(); + + return $this->json($message, Response::HTTP_OK); + } + #[Route('/{messageId}', name: 'delete', requirements: ['messageId' => '\d+'], methods: ['DELETE'])] #[OA\Delete( path: '/api/v2/campaigns/{messageId}', From 9a574dcfbbec069c9cfde74eca798bc2859bb55c Mon Sep 17 00:00:00 2001 From: Tatevik Date: Thu, 2 Apr 2026 11:42:31 +0400 Subject: [PATCH 03/13] CampaignActionController --- .../Controller/CampaignActionController.php | 286 ++++++++++++++++++ .../Controller/CampaignController.php | 257 ---------------- .../Message/MessageMetadataRequest.php | 4 +- .../CampaignActionControllerTest.php | 77 +++++ .../Controller/CampaignControllerTest.php | 55 ---- 5 files changed, 366 insertions(+), 313 deletions(-) create mode 100644 src/Messaging/Controller/CampaignActionController.php create mode 100644 tests/Integration/Messaging/Controller/CampaignActionControllerTest.php diff --git a/src/Messaging/Controller/CampaignActionController.php b/src/Messaging/Controller/CampaignActionController.php new file mode 100644 index 0000000..bba577e --- /dev/null +++ b/src/Messaging/Controller/CampaignActionController.php @@ -0,0 +1,286 @@ + + */ +#[Route('/campaigns', name: 'campaign_')] +class CampaignActionController extends BaseController +{ + public function __construct( + Authentication $authentication, + RequestValidator $validator, + private readonly CampaignService $campaignService, + private readonly MessageBusInterface $messageBus, + private readonly EntityManagerInterface $entityManager, + private readonly MessageManager $messageManager, + private readonly MessageNormalizer $messageNormalizer + ) { + parent::__construct($authentication, $validator); + } + + #[Route('/{messageId}/copy', name: 'copy_campaign', requirements: ['messageId' => '\d+'], methods: ['POST'])] + #[OA\Post( + path: '/api/v2/campaigns/{messageId}/copy', + description: '🚧 **Status: Beta** – This method is under development. Avoid using in production. ' . + 'Copies campaign/message by id into a draft message.', + summary: 'Copies campaign/message by id.', + tags: ['campaigns'], + parameters: [ + new OA\Parameter( + name: 'php-auth-pw', + description: 'Session key obtained from login', + in: 'header', + required: true, + schema: new OA\Schema(type: 'string') + ), + new OA\Parameter( + name: 'messageId', + description: 'message ID', + in: 'path', + required: true, + schema: new OA\Schema(type: 'string') + ) + ], + responses: [ + new OA\Response( + response: 201, + description: 'Success', + content: new OA\JsonContent(ref: '#/components/schemas/Message') + ), + new OA\Response( + response: 403, + description: 'Failure', + content: new OA\JsonContent(ref: '#/components/schemas/UnauthorizedResponse') + ) + ] + )] + public function copyMessage( + Request $request, + #[MapEntity(mapping: ['messageId' => 'id'])] ?Message $message = null + ): JsonResponse { + $authUser = $this->requireAuthentication($request); + if ($message === null) { + throw $this->createNotFoundException('Campaign not found.'); + } + + $message = $this->messageManager->copyAsDraftMessage($message, $authUser); + $this->entityManager->flush(); + + return $this->json($this->campaignService->getMessage($message), Response::HTTP_CREATED); + } + + #[Route('/{messageId}/status', name: 'update_status', requirements: ['messageId' => '\d+'], methods: ['PATCH'])] + #[OA\Patch( + path: '/api/v2/campaigns/{messageId}/status', + description: '🚧 **Status: Beta** – This method is under development. Avoid using in production. ' . + 'Updates campaign/message status by id.', + summary: 'Update campaign status by id.', + requestBody: new OA\RequestBody( + description: 'Update message status.', + required: true, + content: new OA\JsonContent(ref: '#/components/schemas/MessageMetadataRequest') + ), + tags: ['campaigns'], + parameters: [ + new OA\Parameter( + name: 'php-auth-pw', + description: 'Session key obtained from login', + in: 'header', + required: true, + schema: new OA\Schema( + type: 'string' + ) + ), + new OA\Parameter( + name: 'messageId', + description: 'message ID', + in: 'path', + required: true, + schema: new OA\Schema(type: 'string') + ) + ], + responses: [ + new OA\Response( + response: 200, + description: 'Success', + content: new OA\JsonContent(ref: '#/components/schemas/Message') + ), + new OA\Response( + response: 403, + description: 'Failure', + content: new OA\JsonContent(ref: '#/components/schemas/UnauthorizedResponse') + ), + new OA\Response( + response: 404, + description: 'Failure', + content: new OA\JsonContent(ref: '#/components/schemas/NotFoundErrorResponse') + ), + new OA\Response( + response: 422, + description: 'Failure', + content: new OA\JsonContent(ref: '#/components/schemas/ValidationErrorResponse') + ), + ] + )] + public function updateMessageStatus( + Request $request, + #[MapEntity(mapping: ['messageId' => 'id'])] ?Message $message = null, + ): JsonResponse { + $this->requireAuthentication($request); + if ($message === null) { + throw $this->createNotFoundException('Message not found.'); + } + + /** @var MessageMetadataRequest $messageMetadataRequest */ + $messageMetadataRequest = $this->validator->validate($request, MessageMetadataRequest::class); + + $message = $this->messageManager->updateStatus( + $message, + MessageStatus::from($messageMetadataRequest->status), + ); + $this->entityManager->flush(); + + return $this->json($this->messageNormalizer->normalize($message), Response::HTTP_OK); + } + + #[Route('/{messageId}/send', name: 'send_campaign', requirements: ['messageId' => '\d+'], methods: ['POST'])] + #[OA\Post( + path: '/api/v2/campaigns/{messageId}/send', + description: '🚧 **Status: Beta** – This method is under development. Avoid using in production. ' . + 'Processes/sends campaign/message by id.', + summary: 'Processes/sends campaign/message by id.', + tags: ['campaigns'], + parameters: [ + new OA\Parameter( + name: 'php-auth-pw', + description: 'Session key obtained from login', + in: 'header', + required: true, + schema: new OA\Schema(type: 'string') + ), + new OA\Parameter( + name: 'messageId', + description: 'message ID', + in: 'path', + required: true, + schema: new OA\Schema(type: 'string') + ) + ], + responses: [ + new OA\Response( + response: 200, + description: 'Success', + content: new OA\JsonContent(ref: '#/components/schemas/Message') + ), + new OA\Response( + response: 403, + description: 'Failure', + content: new OA\JsonContent(ref: '#/components/schemas/UnauthorizedResponse') + ), + new OA\Response( + response: 404, + description: 'Failure', + content: new OA\JsonContent(ref: '#/components/schemas/NotFoundErrorResponse') + ), + ] + )] + public function sendMessage( + Request $request, + #[MapEntity(mapping: ['messageId' => 'id'])] ?Message $message = null + ): JsonResponse { + $this->requireAuthentication($request); + if ($message === null) { + throw $this->createNotFoundException('Campaign not found.'); + } + + $this->messageBus->dispatch(new SyncCampaignProcessorMessage($message->getId())); + + return $this->json($this->campaignService->getMessage($message), Response::HTTP_OK); + } + + #[Route('/{messageId}/resend', name: 'resend_campaign', requirements: ['messageId' => '\d+'], methods: ['POST'])] + #[OA\Post( + path: '/api/v2/campaigns/{messageId}/resend', + description: '🚧 **Status: Beta** – This method is under development. Avoid using in production. ' . + 'Processes/sends campaign/message by id to specified mailing lists.', + summary: 'Processes/sends campaign/message by id to lists.', + requestBody: new OA\RequestBody( + description: 'List ids to send this campaign to.', + required: true, + content: new OA\JsonContent(ref: '#/components/schemas/ResendMessageToListsRequest') + ), + tags: ['campaigns'], + parameters: [ + new OA\Parameter( + name: 'php-auth-pw', + description: 'Session key obtained from login', + in: 'header', + required: true, + schema: new OA\Schema(type: 'string') + ), + new OA\Parameter( + name: 'messageId', + description: 'message ID', + in: 'path', + required: true, + schema: new OA\Schema(type: 'string') + ) + ], + responses: [ + new OA\Response( + response: 200, + description: 'Success', + content: new OA\JsonContent(ref: '#/components/schemas/Message') + ), + new OA\Response( + response: 403, + description: 'Failure', + content: new OA\JsonContent(ref: '#/components/schemas/UnauthorizedResponse') + ) + ] + )] + public function resendMessageToLists( + Request $request, + #[MapEntity(mapping: ['messageId' => 'id'])] ?Message $message = null + ): JsonResponse { + $this->requireAuthentication($request); + if ($message === null) { + throw $this->createNotFoundException('Campaign not found.'); + } + + /** @var ResendMessageToListsRequest $resendToListsRequest */ + $resendToListsRequest = $this->validator->validate($request, ResendMessageToListsRequest::class); + + $this->messageBus->dispatch( + new SyncCampaignProcessorMessage($message->getId(), $resendToListsRequest->listIds) + ); + + return $this->json($this->campaignService->getMessage($message), Response::HTTP_OK); + } +} diff --git a/src/Messaging/Controller/CampaignController.php b/src/Messaging/Controller/CampaignController.php index b8fc594..5b7a1dc 100644 --- a/src/Messaging/Controller/CampaignController.php +++ b/src/Messaging/Controller/CampaignController.php @@ -6,22 +6,17 @@ use Doctrine\ORM\EntityManagerInterface; use OpenApi\Attributes as OA; -use PhpList\Core\Domain\Messaging\Message\SyncCampaignProcessorMessage; use PhpList\Core\Domain\Messaging\Model\Message; -use PhpList\Core\Domain\Messaging\Model\Message\MessageStatus; -use PhpList\Core\Domain\Messaging\Service\Manager\MessageManager; use PhpList\Core\Security\Authentication; use PhpList\RestBundle\Common\Controller\BaseController; use PhpList\RestBundle\Common\Validator\RequestValidator; use PhpList\RestBundle\Messaging\Request\CreateMessageRequest; -use PhpList\RestBundle\Messaging\Request\ResendMessageToListsRequest; use PhpList\RestBundle\Messaging\Request\UpdateMessageRequest; use PhpList\RestBundle\Messaging\Service\CampaignService; use Symfony\Bridge\Doctrine\Attribute\MapEntity; use Symfony\Component\HttpFoundation\JsonResponse; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; -use Symfony\Component\Messenger\MessageBusInterface; use Symfony\Component\Routing\Attribute\Route; /** @@ -36,9 +31,7 @@ public function __construct( Authentication $authentication, RequestValidator $validator, private readonly CampaignService $campaignService, - private readonly MessageBusInterface $messageBus, private readonly EntityManagerInterface $entityManager, - private readonly MessageManager $messageManager, ) { parent::__construct($authentication, $validator); } @@ -307,146 +300,6 @@ public function updateMessage( return $this->json(data:$message, status: Response::HTTP_OK); } - #[Route('/{messageId}/copy', name: 'copy_campaign', requirements: ['messageId' => '\d+'], methods: ['POST'])] - #[OA\Post( - path: '/api/v2/campaigns/{messageId}/copy', - description: '🚧 **Status: Beta** – This method is under development. Avoid using in production. ' . - 'Copies campaign/message by id into a draft message.', - summary: 'Copies campaign/message by id.', - tags: ['campaigns'], - parameters: [ - new OA\Parameter( - name: 'php-auth-pw', - description: 'Session key obtained from login', - in: 'header', - required: true, - schema: new OA\Schema(type: 'string') - ), - new OA\Parameter( - name: 'messageId', - description: 'message ID', - in: 'path', - required: true, - schema: new OA\Schema(type: 'string') - ) - ], - responses: [ - new OA\Response( - response: 201, - description: 'Success', - content: new OA\JsonContent(ref: '#/components/schemas/Message') - ), - new OA\Response( - response: 403, - description: 'Failure', - content: new OA\JsonContent(ref: '#/components/schemas/UnauthorizedResponse') - ) - ] - )] - public function copyMessage( - Request $request, - #[MapEntity(mapping: ['messageId' => 'id'])] ?Message $message = null - ): JsonResponse { - $authUser = $this->requireAuthentication($request); - if ($message === null) { - throw $this->createNotFoundException('Campaign not found.'); - } - - $message = $this->messageManager->copyAsDraftMessage($message, $authUser); - $this->entityManager->flush(); - - return $this->json($this->campaignService->getMessage($message), Response::HTTP_CREATED); - } - - #[Route('/{messageId}/status', name: 'update_status', requirements: ['messageId' => '\d+'], methods: ['PATCH'])] - #[OA\Patch( - path: '/api/v2/campaigns/{messageId}/status', - description: '🚧 **Status: Beta** – This method is under development. Avoid using in production. ' . - 'Updates campaign/message status by id.', - summary: 'Update campaign status by id.', - requestBody: new OA\RequestBody( - description: 'Update message status.', - required: true, - content: new OA\JsonContent( - required: ['status'], - properties: [ - new OA\Property( - property: 'status', - type: 'string', - enum: ['draft', 'scheduled', 'sent', 'canceled'], - example: 'draft' - ), - ], - type: 'object' - ) - ), - tags: ['campaigns'], - parameters: [ - new OA\Parameter( - name: 'php-auth-pw', - description: 'Session key obtained from login', - in: 'header', - required: true, - schema: new OA\Schema( - type: 'string' - ) - ), - new OA\Parameter( - name: 'messageId', - description: 'message ID', - in: 'path', - required: true, - schema: new OA\Schema(type: 'string') - ) - ], - responses: [ - new OA\Response( - response: 201, - description: 'Success', - content: new OA\JsonContent(ref: '#/components/schemas/Message') - ), - new OA\Response( - response: 403, - description: 'Failure', - content: new OA\JsonContent(ref: '#/components/schemas/UnauthorizedResponse') - ), - new OA\Response( - response: 404, - description: 'Failure', - content: new OA\JsonContent(ref: '#/components/schemas/NotFoundErrorResponse') - ), - new OA\Response( - response: 422, - description: 'Failure', - content: new OA\JsonContent(ref: '#/components/schemas/ValidationErrorResponse') - ), - ] - )] - public function updateMessageStatus( - Request $request, - #[MapEntity(mapping: ['messageId' => 'id'])] ?Message $message = null, - ): JsonResponse { - $this->requireAuthentication($request); - if ($message === null) { - throw $this->createNotFoundException('Message not found.'); - } - - if ($request->request->get('status') === null) { - return $this->json( - ['error' => 'Missing "status" value.'], - Response::HTTP_BAD_REQUEST - ); - } - - $message = $this->messageManager->updateStatus( - $message, - MessageStatus::from($request->request->get('status')) - ); - $this->entityManager->flush(); - - return $this->json($message, Response::HTTP_OK); - } - #[Route('/{messageId}', name: 'delete', requirements: ['messageId' => '\d+'], methods: ['DELETE'])] #[OA\Delete( path: '/api/v2/campaigns/{messageId}', @@ -499,114 +352,4 @@ public function deleteMessage( return $this->json(null, Response::HTTP_NO_CONTENT); } - - #[Route('/{messageId}/send', name: 'send_campaign', requirements: ['messageId' => '\d+'], methods: ['POST'])] - #[OA\Post( - path: '/api/v2/campaigns/{messageId}/send', - description: '🚧 **Status: Beta** – This method is under development. Avoid using in production. ' . - 'Processes/sends campaign/message by id.', - summary: 'Processes/sends campaign/message by id.', - tags: ['campaigns'], - parameters: [ - new OA\Parameter( - name: 'php-auth-pw', - description: 'Session key obtained from login', - in: 'header', - required: true, - schema: new OA\Schema(type: 'string') - ), - new OA\Parameter( - name: 'messageId', - description: 'message ID', - in: 'path', - required: true, - schema: new OA\Schema(type: 'string') - ) - ], - responses: [ - new OA\Response( - response: 200, - description: 'Success', - content: new OA\JsonContent(ref: '#/components/schemas/Message') - ), - new OA\Response( - response: 403, - description: 'Failure', - content: new OA\JsonContent(ref: '#/components/schemas/UnauthorizedResponse') - ) - ] - )] - public function sendMessage( - Request $request, - #[MapEntity(mapping: ['messageId' => 'id'])] ?Message $message = null - ): JsonResponse { - $this->requireAuthentication($request); - if ($message === null) { - throw $this->createNotFoundException('Campaign not found.'); - } - - $this->messageBus->dispatch(new SyncCampaignProcessorMessage($message->getId())); - - return $this->json($this->campaignService->getMessage($message), Response::HTTP_OK); - } - - #[Route('/{messageId}/resend', name: 'resend_campaign', requirements: ['messageId' => '\d+'], methods: ['POST'])] - #[OA\Post( - path: '/api/v2/campaigns/{messageId}/resend', - description: '🚧 **Status: Beta** – This method is under development. Avoid using in production. ' . - 'Processes/sends campaign/message by id to specified mailing lists.', - summary: 'Processes/sends campaign/message by id to lists.', - requestBody: new OA\RequestBody( - description: 'List ids to send this campaign to.', - required: true, - content: new OA\JsonContent(ref: '#/components/schemas/AdminAttributeDefinitionRequest') - ), - tags: ['campaigns'], - parameters: [ - new OA\Parameter( - name: 'php-auth-pw', - description: 'Session key obtained from login', - in: 'header', - required: true, - schema: new OA\Schema(type: 'string') - ), - new OA\Parameter( - name: 'messageId', - description: 'message ID', - in: 'path', - required: true, - schema: new OA\Schema(type: 'string') - ) - ], - responses: [ - new OA\Response( - response: 200, - description: 'Success', - content: new OA\JsonContent(ref: '#/components/schemas/Message') - ), - new OA\Response( - response: 403, - description: 'Failure', - content: new OA\JsonContent(ref: '#/components/schemas/UnauthorizedResponse') - ) - ] - )] - public function resendMessageToLists( - Request $request, - #[MapEntity(mapping: ['messageId' => 'id'])] ?Message $message = null - ): JsonResponse { - $this->requireAuthentication($request); - if ($message === null) { - throw $this->createNotFoundException('Campaign not found.'); - } - - /** @var ResendMessageToListsRequest $resendToListsRequest */ - $resendToListsRequest = $this->validator->validate($request, ResendMessageToListsRequest::class); - - $this->messageBus->dispatch( - new SyncCampaignProcessorMessage($message->getId(), $resendToListsRequest->listIds) - ); - - return $this->json($this->campaignService->getMessage($message), Response::HTTP_OK); - } } diff --git a/src/Messaging/Request/Message/MessageMetadataRequest.php b/src/Messaging/Request/Message/MessageMetadataRequest.php index 7679d84..4eb5045 100644 --- a/src/Messaging/Request/Message/MessageMetadataRequest.php +++ b/src/Messaging/Request/Message/MessageMetadataRequest.php @@ -7,16 +7,18 @@ use OpenApi\Attributes as OA; use PhpList\Core\Domain\Messaging\Model\Dto\Message\MessageMetadataDto; use PhpList\Core\Domain\Messaging\Model\Message\MessageStatus; +use PhpList\RestBundle\Common\Request\RequestInterface; use Symfony\Component\Validator\Constraints as Assert; #[OA\Schema( schema: 'MessageMetadataRequest', + required: ['status'], properties: [ new OA\Property(property: 'status', type: 'string', example: 'draft'), ], type: 'object' )] -class MessageMetadataRequest implements RequestDtoInterface +class MessageMetadataRequest implements RequestDtoInterface, RequestInterface { #[Assert\NotBlank] public string $status; diff --git a/tests/Integration/Messaging/Controller/CampaignActionControllerTest.php b/tests/Integration/Messaging/Controller/CampaignActionControllerTest.php new file mode 100644 index 0000000..f62d9d8 --- /dev/null +++ b/tests/Integration/Messaging/Controller/CampaignActionControllerTest.php @@ -0,0 +1,77 @@ +get(CampaignActionController::class) + ); + } + + public function testSendMessageWithoutSessionReturnsForbidden(): void + { + $this->loadFixtures([MessageFixture::class]); + self::getClient()->request('POST', '/api/v2/campaigns/1/send'); + $this->assertHttpForbidden(); + } + + public function testSendMessageWithValidSessionReturnsOkay(): void + { + $this->loadFixtures([AdministratorFixture::class, MessageFixture::class]); + + $this->authenticatedJsonRequest('POST', '/api/v2/campaigns/2/send'); + $this->assertHttpOkay(); + + $response = $this->getDecodedJsonResponseContent(); + self::assertSame(2, $response['id']); + } + + public function testSendMessageWithInvalidIdReturnsNotFound(): void + { + $this->authenticatedJsonRequest('POST', '/api/v2/campaigns/999/send'); + $this->assertHttpNotFound(); + } + + public function testResendMessageToListsWithoutSessionReturnsForbidden(): void + { + $this->loadFixtures([MessageFixture::class, SubscriberListFixture::class]); + + $this->jsonRequest('POST', '/api/v2/campaigns/2/resend', [], [], [], json_encode(['list_ids' => [1]])); + $this->assertHttpForbidden(); + } + + public function testResendMessageToListsWithValidSessionReturnsOkay(): void + { + $this->loadFixtures([MessageFixture::class, SubscriberListFixture::class]); + + $this->authenticatedJsonRequest('POST', '/api/v2/campaigns/2/resend', [], [], [], json_encode([ + 'list_ids' => [1], + ])); + $this->assertHttpOkay(); + + $response = $this->getDecodedJsonResponseContent(); + self::assertSame(2, $response['id']); + } + + public function testResendMessageToListsWithInvalidIdReturnsNotFound(): void + { + $this->loadFixtures([SubscriberListFixture::class]); + + $this->authenticatedJsonRequest('POST', '/api/v2/campaigns/999/resend', [], [], [], json_encode([ + 'list_ids' => [1], + ])); + $this->assertHttpNotFound(); + } +} diff --git a/tests/Integration/Messaging/Controller/CampaignControllerTest.php b/tests/Integration/Messaging/Controller/CampaignControllerTest.php index 4b71508..301dee3 100644 --- a/tests/Integration/Messaging/Controller/CampaignControllerTest.php +++ b/tests/Integration/Messaging/Controller/CampaignControllerTest.php @@ -9,7 +9,6 @@ use PhpList\RestBundle\Tests\Integration\Identity\Fixtures\AdministratorFixture; use PhpList\RestBundle\Tests\Integration\Identity\Fixtures\AdministratorTokenFixture; use PhpList\RestBundle\Tests\Integration\Messaging\Fixtures\MessageFixture; -use PhpList\RestBundle\Tests\Integration\Subscription\Fixtures\SubscriberListFixture; class CampaignControllerTest extends AbstractTestController { @@ -88,58 +87,4 @@ public function testDeleteCampaignReturnsNoContent(): void $this->authenticatedJsonRequest('DELETE', '/api/v2/campaigns/1'); $this->assertHttpNoContent(); } - public function testSendMessageWithoutSessionReturnsForbidden(): void - { - $this->loadFixtures([MessageFixture::class]); - self::getClient()->request('POST', '/api/v2/campaigns/1/send'); - $this->assertHttpForbidden(); - } - - public function testSendMessageWithValidSessionReturnsOkay(): void - { - $this->loadFixtures([AdministratorFixture::class, MessageFixture::class]); - - $this->authenticatedJsonRequest('POST', '/api/v2/campaigns/2/send'); - $this->assertHttpOkay(); - - $response = $this->getDecodedJsonResponseContent(); - self::assertSame(2, $response['id']); - } - - public function testSendMessageWithInvalidIdReturnsNotFound(): void - { - $this->authenticatedJsonRequest('POST', '/api/v2/campaigns/999/send'); - $this->assertHttpNotFound(); - } - - public function testResendMessageToListsWithoutSessionReturnsForbidden(): void - { - $this->loadFixtures([MessageFixture::class, SubscriberListFixture::class]); - - $this->jsonRequest('POST', '/api/v2/campaigns/2/resend', [], [], [], json_encode(['list_ids' => [1]])); - $this->assertHttpForbidden(); - } - - public function testResendMessageToListsWithValidSessionReturnsOkay(): void - { - $this->loadFixtures([MessageFixture::class, SubscriberListFixture::class]); - - $this->authenticatedJsonRequest('POST', '/api/v2/campaigns/2/resend', [], [], [], json_encode([ - 'list_ids' => [1], - ])); - $this->assertHttpOkay(); - - $response = $this->getDecodedJsonResponseContent(); - self::assertSame(2, $response['id']); - } - - public function testResendMessageToListsWithInvalidIdReturnsNotFound(): void - { - $this->loadFixtures([SubscriberListFixture::class]); - - $this->authenticatedJsonRequest('POST', '/api/v2/campaigns/999/resend', [], [], [], json_encode([ - 'list_ids' => [1], - ])); - $this->assertHttpNotFound(); - } } From 2aa402247968f1b6910176f6be8b972e579b88db Mon Sep 17 00:00:00 2001 From: Tatevik Date: Mon, 6 Apr 2026 11:20:29 +0400 Subject: [PATCH 04/13] Configure request object normalizer --- config/services/normalizers.yml | 6 ++ config/services/validators.yml | 3 +- .../EventListener/ExceptionListener.php | 63 +++++++++++++++++-- src/Common/SwaggerSchemasResponse.php | 36 ++++++++++- .../Request/CreateMessageRequest.php | 2 +- .../EventListener/ExceptionListenerTest.php | 20 ++++-- 6 files changed, 116 insertions(+), 14 deletions(-) diff --git a/config/services/normalizers.yml b/config/services/normalizers.yml index b617657..cf6fb72 100644 --- a/config/services/normalizers.yml +++ b/config/services/normalizers.yml @@ -15,5 +15,11 @@ services: $classMetadataFactory: '@?serializer.mapping.class_metadata_factory' $nameConverter: '@Symfony\Component\Serializer\NameConverter\CamelCaseToSnakeCaseNameConverter' + phplist.request_serializer: + class: Symfony\Component\Serializer\Serializer + arguments: + $normalizers: + - '@Symfony\Component\Serializer\Normalizer\ObjectNormalizer' + PhpList\RestBundle\: resource: '../../src/*/Serializer/*' diff --git a/config/services/validators.yml b/config/services/validators.yml index a4fa508..5fdbbe0 100644 --- a/config/services/validators.yml +++ b/config/services/validators.yml @@ -1,7 +1,7 @@ services: PhpList\RestBundle\Common\Validator\RequestValidator: arguments: - $serializer: '@Symfony\Component\Serializer\Normalizer\ObjectNormalizer' + $serializer: '@phplist.request_serializer' $validator: '@validator' PhpList\RestBundle\Identity\Validator\Constraint\UniqueEmailValidator: @@ -50,4 +50,3 @@ services: autowire: true autoconfigure: true tags: [ 'validator.constraint_validator' ] - diff --git a/src/Common/EventListener/ExceptionListener.php b/src/Common/EventListener/ExceptionListener.php index f272462..d490c24 100644 --- a/src/Common/EventListener/ExceptionListener.php +++ b/src/Common/EventListener/ExceptionListener.php @@ -15,7 +15,9 @@ use Symfony\Component\HttpKernel\Event\ExceptionEvent; use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException; use Symfony\Component\HttpKernel\Exception\HttpExceptionInterface; +use Symfony\Component\HttpKernel\Exception\UnprocessableEntityHttpException; use Symfony\Component\Security\Core\Exception\AccessDeniedException; +use Symfony\Component\Validator\Exception\ValidationFailedException; use Symfony\Component\Validator\Exception\ValidatorException; class ExceptionListener @@ -24,7 +26,6 @@ class ExceptionListener SubscriptionCreationException::class => null, AttributeDefinitionCreationException::class => null, AdminAttributeCreationException::class => null, - ValidatorException::class => 400, AccessDeniedException::class => 403, AccessDeniedHttpException::class => 403, AttachmentFileNotFoundException::class => 404, @@ -36,14 +37,34 @@ public function onKernelException(ExceptionEvent $event): void { $exception = $event->getThrowable(); + if ($exception instanceof ValidationFailedException + || $exception instanceof ValidatorException + || $exception instanceof UnprocessableEntityHttpException + ) { + $event->setResponse( + new JsonResponse([ + 'message' => 'Validation failed', + 'errors' => $this->parseFlatValidationMessage($exception->getMessage()), + ], 422) + ); + + return; + } + foreach (self::EXCEPTION_STATUS_MAP as $class => $statusCode) { if ($exception instanceof $class) { - $status = $statusCode ?? $exception->getStatusCode(); + $status = $statusCode ?? ( + method_exists($exception, 'getStatusCode') + ? $exception->getStatusCode() + : 400 + ); + $event->setResponse( new JsonResponse([ - 'message' => $exception->getMessage() + 'message' => $exception->getMessage(), ], $status) ); + return; } } @@ -51,18 +72,50 @@ public function onKernelException(ExceptionEvent $event): void if ($exception instanceof HttpExceptionInterface) { $event->setResponse( new JsonResponse([ - 'message' => $exception->getMessage() + 'message' => $exception->getMessage(), ], $exception->getStatusCode()) ); + return; } if ($exception instanceof Exception) { $event->setResponse( new JsonResponse([ - 'message' => $exception->getMessage() + 'message' => $exception->getMessage(), ], 500) ); } } + + /** + * @return array> + */ + private function parseFlatValidationMessage(string $message): array + { + $errors = []; + $lines = preg_split('/\r\n|\r|\n/', $message) ?: []; + + foreach ($lines as $line) { + $line = trim($line); + + if ($line === '') { + continue; + } + + $parts = explode(':', $line, 2); + + if (count($parts) !== 2) { + $errors['_global'][] = $line; + continue; + } + + $field = trim($parts[0]); + $errorMessage = trim($parts[1]); + + $errors[$field][] = $errorMessage; + } + + return $errors; + } } diff --git a/src/Common/SwaggerSchemasResponse.php b/src/Common/SwaggerSchemasResponse.php index 8c650f9..6f4ad0f 100644 --- a/src/Common/SwaggerSchemasResponse.php +++ b/src/Common/SwaggerSchemasResponse.php @@ -6,8 +6,22 @@ use OpenApi\Attributes as OA; +#[OA\Schema( + schema: 'ErrorDetails', + type: 'object', + example: [ + 'format.formatOptions[0]' => ['The value you selected is not a valid choice.'], + 'schedule.repeatUntil' => ['This value is not a valid datetime.'], + 'schedule.requeueUntil' => ['This value is not a valid datetime.'], + ], + additionalProperties: new OA\AdditionalProperties( + type: 'array', + items: new OA\Items(type: 'string') + ) +)] #[OA\Schema( schema: 'UnauthorizedResponse', + required: ['message'], properties: [ new OA\Property( property: 'message', @@ -19,17 +33,23 @@ )] #[OA\Schema( schema: 'ValidationErrorResponse', + required: ['message', 'errors'], properties: [ new OA\Property( property: 'message', type: 'string', - example: 'Some fields are invalid' + example: 'Validation failed' + ), + new OA\Property( + property: 'errors', + ref: '#/components/schemas/ErrorDetails' ) ], type: 'object' )] #[OA\Schema( schema: 'BadRequestResponse', + required: ['message'], properties: [ new OA\Property( property: 'message', @@ -41,6 +61,7 @@ )] #[OA\Schema( schema: 'AlreadyExistsResponse', + required: ['message'], properties: [ new OA\Property( property: 'message', @@ -62,7 +83,18 @@ ], type: 'object' )] - +#[OA\Schema( + schema: 'GenericErrorResponse', + required: ['message'], + properties: [ + new OA\Property( + property: 'message', + type: 'string', + example: 'An unexpected error occurred.' + ) + ], + type: 'object' +)] #[OA\Schema( schema: 'CursorPagination', properties: [ diff --git a/src/Messaging/Request/CreateMessageRequest.php b/src/Messaging/Request/CreateMessageRequest.php index 4d07968..d81fabb 100644 --- a/src/Messaging/Request/CreateMessageRequest.php +++ b/src/Messaging/Request/CreateMessageRequest.php @@ -38,7 +38,7 @@ class CreateMessageRequest implements RequestInterface public MessageOptionsRequest $options; #[TemplateExists] - public ?int $templateId; + public ?int $templateId = null; public function getDto(): MessageDtoInterface { diff --git a/tests/Integration/Common/EventListener/ExceptionListenerTest.php b/tests/Integration/Common/EventListener/ExceptionListenerTest.php index 9b6946d..d2fb76b 100644 --- a/tests/Integration/Common/EventListener/ExceptionListenerTest.php +++ b/tests/Integration/Common/EventListener/ExceptionListenerTest.php @@ -34,7 +34,10 @@ public function testAccessDeniedExceptionHandled(): void $this->assertInstanceOf(JsonResponse::class, $response); $this->assertEquals(403, $response->getStatusCode()); - $this->assertEquals(['message' => 'Forbidden'], json_decode($response->getContent(), true)); + $this->assertEquals( + ['message' => 'Forbidden'], + json_decode($response->getContent(), true) + ); } public function testHttpExceptionHandled(): void @@ -47,7 +50,10 @@ public function testHttpExceptionHandled(): void $this->assertInstanceOf(JsonResponse::class, $response); $this->assertEquals(404, $response->getStatusCode()); - $this->assertEquals(['message' => 'Not found'], json_decode($response->getContent(), true)); + $this->assertEquals( + ['message' => 'Not found'], + json_decode($response->getContent(), true) + ); } public function testSubscriptionCreationExceptionHandled(): void @@ -61,7 +67,10 @@ public function testSubscriptionCreationExceptionHandled(): void $this->assertInstanceOf(JsonResponse::class, $response); $this->assertEquals(409, $response->getStatusCode()); - $this->assertEquals(['message' => 'Subscription error'], json_decode($response->getContent(), true)); + $this->assertEquals( + ['message' => 'Subscription error'], + json_decode($response->getContent(), true) + ); } public function testGenericExceptionHandled(): void @@ -74,6 +83,9 @@ public function testGenericExceptionHandled(): void $this->assertInstanceOf(JsonResponse::class, $response); $this->assertEquals(500, $response->getStatusCode()); - $this->assertEquals(['message' => 'Something went wrong'], json_decode($response->getContent(), true)); + $this->assertEquals( + ['message' => 'Something went wrong'], + json_decode($response->getContent(), true) + ); } } From 890acdb38145d101daa5fe05595e40ba6d256192 Mon Sep 17 00:00:00 2001 From: Tatevik Date: Mon, 6 Apr 2026 14:49:09 +0400 Subject: [PATCH 05/13] Datetime form at + test fix --- .../Serializer/AdministratorTokenNormalizer.php | 3 ++- .../Serializer/ListMessageNormalizer.php | 4 ++-- src/Messaging/Serializer/MessageNormalizer.php | 13 +++++++------ .../Serializer/SubscriberHistoryNormalizer.php | 2 +- .../Serializer/SubscriberListNormalizer.php | 2 +- .../Serializer/SubscriberNormalizer.php | 4 ++-- .../Serializer/SubscriberOnlyNormalizer.php | 2 +- .../Serializer/SubscriptionNormalizer.php | 3 ++- .../Serializer/UserBlacklistNormalizer.php | 2 +- .../Controller/PasswordResetControllerTest.php | 6 ++++-- .../Identity/Controller/SessionControllerTest.php | 15 +++++++++++++-- 11 files changed, 36 insertions(+), 20 deletions(-) diff --git a/src/Identity/Serializer/AdministratorTokenNormalizer.php b/src/Identity/Serializer/AdministratorTokenNormalizer.php index 7f6df4d..a04e8a7 100644 --- a/src/Identity/Serializer/AdministratorTokenNormalizer.php +++ b/src/Identity/Serializer/AdministratorTokenNormalizer.php @@ -4,6 +4,7 @@ namespace PhpList\RestBundle\Identity\Serializer; +use DateTimeInterface; use PhpList\Core\Domain\Identity\Model\AdministratorToken; use Symfony\Component\Serializer\Normalizer\NormalizerInterface; @@ -21,7 +22,7 @@ public function normalize($object, string $format = null, array $context = []): return [ 'id' => $object->getId(), 'key' => $object->getKey(), - 'expiry_date' => $object->getExpiry()->format('Y-m-d\TH:i:sP'), + 'expiry_date' => $object->getExpiry()->format(DateTimeInterface::ATOM), ]; } diff --git a/src/Messaging/Serializer/ListMessageNormalizer.php b/src/Messaging/Serializer/ListMessageNormalizer.php index 514ead2..5b9d1c2 100644 --- a/src/Messaging/Serializer/ListMessageNormalizer.php +++ b/src/Messaging/Serializer/ListMessageNormalizer.php @@ -51,8 +51,8 @@ public function normalize($object, string $format = null, array $context = []): 'id' => $object->getId(), 'message' => $this->messageNormalizer->normalize($object->getMessage()), 'subscriber_list' => $this->subscriberListNormalizer->normalize($object->getList()), - 'created_at' => $object->getEntered()->format('Y-m-d\TH:i:sP'), - 'updated_at' => $object->getUpdatedAt()->format('Y-m-d\TH:i:sP'), + 'created_at' => $object->getEntered()->format(\DateTimeInterface::ATOM), + 'updated_at' => $object->getUpdatedAt()->format(\DateTimeInterface::ATOM), ]; } diff --git a/src/Messaging/Serializer/MessageNormalizer.php b/src/Messaging/Serializer/MessageNormalizer.php index 4b39bfb..916ee73 100644 --- a/src/Messaging/Serializer/MessageNormalizer.php +++ b/src/Messaging/Serializer/MessageNormalizer.php @@ -4,6 +4,7 @@ namespace PhpList\RestBundle\Messaging\Serializer; +use DateTimeInterface; use OpenApi\Attributes as OA; use PhpList\Core\Domain\Messaging\Model\Message; use Symfony\Component\Serializer\Normalizer\NormalizerInterface; @@ -117,16 +118,16 @@ public function normalize($object, string $format = null, array $context = []): 'processed' => $object->getMetadata()->getProcessed(), 'views' => $object->getMetadata()->getViews(), 'bounce_count' => $object->getMetadata()->getBounceCount(), - 'entered' => $object->getMetadata()->getEntered()?->format('Y-m-d\TH:i:sP'), - 'sent' => $object->getMetadata()->getSent()?->format('Y-m-d\TH:i:sP'), - 'send_start' => $object->getMetadata()->getSendStart()?->format('Y-m-d\TH:i:sP'), + 'entered' => $object->getMetadata()->getEntered()?->format(DateTimeInterface::ATOM), + 'sent' => $object->getMetadata()->getSent()?->format(DateTimeInterface::ATOM), + 'send_start' => $object->getMetadata()->getSendStart()?->format(DateTimeInterface::ATOM), ], 'message_schedule' => [ 'repeat_interval' => $object->getSchedule()->getRepeatInterval(), - 'repeat_until' => $object->getSchedule()->getRepeatUntil()?->format('Y-m-d\TH:i:sP'), + 'repeat_until' => $object->getSchedule()->getRepeatUntil()?->format(DateTimeInterface::ATOM), 'requeue_interval' => $object->getSchedule()->getRequeueInterval(), - 'requeue_until' => $object->getSchedule()->getRequeueUntil()?->format('Y-m-d\TH:i:sP'), - 'embargo' => $object->getSchedule()->getEmbargo()?->format('Y-m-d\TH:i:sP'), + 'requeue_until' => $object->getSchedule()->getRequeueUntil()?->format(DateTimeInterface::ATOM), + 'embargo' => $object->getSchedule()->getEmbargo()?->format(DateTimeInterface::ATOM), ], 'message_options' => [ 'from_field' => $object->getOptions()->getFromField(), diff --git a/src/Subscription/Serializer/SubscriberHistoryNormalizer.php b/src/Subscription/Serializer/SubscriberHistoryNormalizer.php index 2ab5b43..9189a5e 100644 --- a/src/Subscription/Serializer/SubscriberHistoryNormalizer.php +++ b/src/Subscription/Serializer/SubscriberHistoryNormalizer.php @@ -39,7 +39,7 @@ public function normalize($object, string $format = null, array $context = []): return [ 'id' => $object->getId(), 'ip' => $object->getIp(), - 'created_at' => $object->getCreatedAt()->format('Y-m-d\TH:i:sP'), + 'created_at' => $object->getCreatedAt()->format(\DateTimeInterface::ATOM), 'summary' => $object->getSummary(), 'detail' => $object->getDetail(), 'system_info' => $object->getSystemInfo(), diff --git a/src/Subscription/Serializer/SubscriberListNormalizer.php b/src/Subscription/Serializer/SubscriberListNormalizer.php index 00a8ff1..976fcf2 100644 --- a/src/Subscription/Serializer/SubscriberListNormalizer.php +++ b/src/Subscription/Serializer/SubscriberListNormalizer.php @@ -42,7 +42,7 @@ public function normalize($object, string $format = null, array $context = []): return [ 'id' => $object->getId(), 'name' => $object->getName(), - 'created_at' => $object->getCreatedAt()->format('Y-m-d\TH:i:sP'), + 'created_at' => $object->getCreatedAt()->format(\DateTimeInterface::ATOM), 'description' => $object->getDescription(), 'list_position' => $object->getListPosition(), 'subject_prefix' => $object->getSubjectPrefix(), diff --git a/src/Subscription/Serializer/SubscriberNormalizer.php b/src/Subscription/Serializer/SubscriberNormalizer.php index be40871..9ce8155 100644 --- a/src/Subscription/Serializer/SubscriberNormalizer.php +++ b/src/Subscription/Serializer/SubscriberNormalizer.php @@ -67,8 +67,8 @@ public function normalize($object, string $format = null, array $context = []): return [ 'id' => $object->getId(), 'email' => $object->getEmail(), - 'created_at' => $object->getCreatedAt()?->format('Y-m-d\TH:i:sP'), - 'updated_at' => $object->getUpdatedAt()?->format('Y-m-d\TH:i:sP'), + 'created_at' => $object->getCreatedAt()?->format(\DateTimeInterface::ATOM), + 'updated_at' => $object->getUpdatedAt()?->format(\DateTimeInterface::ATOM), 'confirmed' => $object->isConfirmed(), 'blacklisted' => $object->isBlacklisted(), 'bounce_count' => $object->getBounceCount(), diff --git a/src/Subscription/Serializer/SubscriberOnlyNormalizer.php b/src/Subscription/Serializer/SubscriberOnlyNormalizer.php index 2227f3e..0b93d8a 100644 --- a/src/Subscription/Serializer/SubscriberOnlyNormalizer.php +++ b/src/Subscription/Serializer/SubscriberOnlyNormalizer.php @@ -42,7 +42,7 @@ public function normalize($object, string $format = null, array $context = []): return [ 'id' => $object->getId(), 'email' => $object->getEmail(), - 'created_at' => $object->getCreatedAt()->format('Y-m-d\TH:i:sP'), + 'created_at' => $object->getCreatedAt()->format(\DateTimeInterface::ATOM), 'confirmed' => $object->isConfirmed(), 'blacklisted' => $object->isBlacklisted(), 'bounce_count' => $object->getBounceCount(), diff --git a/src/Subscription/Serializer/SubscriptionNormalizer.php b/src/Subscription/Serializer/SubscriptionNormalizer.php index d403729..eac2fef 100644 --- a/src/Subscription/Serializer/SubscriptionNormalizer.php +++ b/src/Subscription/Serializer/SubscriptionNormalizer.php @@ -4,6 +4,7 @@ namespace PhpList\RestBundle\Subscription\Serializer; +use DateTimeInterface; use OpenApi\Attributes as OA; use PhpList\Core\Domain\Subscription\Model\Subscription; use Symfony\Component\Serializer\Normalizer\NormalizerInterface; @@ -47,7 +48,7 @@ public function normalize($object, string $format = null, array $context = []): return [ 'subscriber' => $this->subscriberNormalizer->normalize($object->getSubscriber()), 'subscriber_list' => $this->subscriberListNormalizer->normalize($object->getSubscriberList()), - 'subscription_date' => $object->getCreatedAt()->format('Y-m-d\TH:i:sP'), + 'subscription_date' => $object->getCreatedAt()->format(DateTimeInterface::ATOM), ]; } diff --git a/src/Subscription/Serializer/UserBlacklistNormalizer.php b/src/Subscription/Serializer/UserBlacklistNormalizer.php index f8ff01c..466debe 100644 --- a/src/Subscription/Serializer/UserBlacklistNormalizer.php +++ b/src/Subscription/Serializer/UserBlacklistNormalizer.php @@ -27,7 +27,7 @@ public function normalize($object, string $format = null, array $context = []): return [ 'email' => $object->getEmail(), - 'added' => $object->getAdded()?->format('Y-m-d\TH:i:sP'), + 'added' => $object->getAdded()?->format(\DateTimeInterface::ATOM), 'reason' => $reason, ]; } diff --git a/tests/Integration/Identity/Controller/PasswordResetControllerTest.php b/tests/Integration/Identity/Controller/PasswordResetControllerTest.php index 08ed439..53f2cd7 100644 --- a/tests/Integration/Identity/Controller/PasswordResetControllerTest.php +++ b/tests/Integration/Identity/Controller/PasswordResetControllerTest.php @@ -32,7 +32,8 @@ public function testRequestPasswordResetWithInvalidEmailReturnsError422(): void $this->assertHttpUnprocessableEntity(); $data = $this->getDecodedJsonResponseContent(); - $this->assertStringContainsString('This value is not a valid email address', $data['message']); + $this->assertStringContainsString('Validation failed', $data['message']); + $this->assertStringContainsString('This value is not a valid email address', $data['errors']['email'][0]); } public function testRequestPasswordResetWithNonExistentEmailReturnsError404(): void @@ -97,6 +98,7 @@ public function testResetPasswordWithShortPasswordReturnsError422(): void $this->assertHttpUnprocessableEntity(); $data = $this->getDecodedJsonResponseContent(); - $this->assertStringContainsString('This value is too short', $data['message']); + $this->assertStringContainsString('Validation failed', $data['message']); + $this->assertStringContainsString('This value is too short', $data['errors']['newPassword'][0]); } } diff --git a/tests/Integration/Identity/Controller/SessionControllerTest.php b/tests/Integration/Identity/Controller/SessionControllerTest.php index 2003593..cb0cd45 100644 --- a/tests/Integration/Identity/Controller/SessionControllerTest.php +++ b/tests/Integration/Identity/Controller/SessionControllerTest.php @@ -65,7 +65,15 @@ public function testPostSessionsWithValidEmptyJsonWithOtherTypeReturnsError422() $this->assertHttpUnprocessableEntity(); $this->assertJsonResponseContentEquals( [ - 'message' => "loginName: This value should not be blank.\npassword: This value should not be blank.", + 'message' => 'Validation failed', + 'errors' => [ + 'loginName' => [ + 'This value should not be blank.', + ], + 'password' => [ + 'This value should not be blank.', + ], + ], ] ); } @@ -91,7 +99,10 @@ public function testPostSessionsWithValidIncompleteJsonReturnsError400(string $j $this->assertHttpUnprocessableEntity(); $data = $this->getDecodedJsonResponseContent(); - $this->assertStringContainsString('This value should not be blank', $data['message']); + $this->assertStringContainsString( + 'Validation failed', + $data['message'] + ); } public function testPostSessionsWithInvalidCredentialsReturnsNotAuthorized() From 20507a9b1f99b34c082792f848dcfc68e45b541b Mon Sep 17 00:00:00 2001 From: Tatevik Date: Mon, 6 Apr 2026 15:18:37 +0400 Subject: [PATCH 06/13] Fix: message format options --- .../Request/Message/MessageFormatRequest.php | 15 +-------------- .../Request/Message/MessageScheduleRequest.php | 4 ++-- src/Messaging/Serializer/MessageNormalizer.php | 6 ++++++ 3 files changed, 9 insertions(+), 16 deletions(-) diff --git a/src/Messaging/Request/Message/MessageFormatRequest.php b/src/Messaging/Request/Message/MessageFormatRequest.php index 4876706..bb3b373 100644 --- a/src/Messaging/Request/Message/MessageFormatRequest.php +++ b/src/Messaging/Request/Message/MessageFormatRequest.php @@ -10,7 +10,7 @@ #[OA\Schema( schema: 'MessageFormatRequest', - required: ['html_formated', 'send_format', 'format_options'], + required: ['html_formated', 'send_format'], properties: [ new OA\Property(property: 'html_formated', type: 'boolean', example: true), new OA\Property( @@ -19,12 +19,6 @@ enum: ['html', 'text', 'invite'], example: 'html' ), - new OA\Property( - property: 'format_options', - type: 'array', - items: new OA\Items(type: 'string', enum: ['text', 'html', 'pdf']), - example: ['html'] - ), ], type: 'object' )] @@ -36,18 +30,11 @@ class MessageFormatRequest implements RequestDtoInterface #[Assert\Choice(['html', 'text', 'invite'])] public string $sendFormat; - #[Assert\All([ - new Assert\Type('string'), - new Assert\Choice(['text', 'html', 'pdf']), - ])] - public array $formatOptions; - public function getDto(): MessageFormatDto { return new MessageFormatDto( htmlFormated: $this->htmlFormated, sendFormat: $this->sendFormat, - formatOptions: $this->formatOptions, ); } } diff --git a/src/Messaging/Request/Message/MessageScheduleRequest.php b/src/Messaging/Request/Message/MessageScheduleRequest.php index 1108226..a3cd216 100644 --- a/src/Messaging/Request/Message/MessageScheduleRequest.php +++ b/src/Messaging/Request/Message/MessageScheduleRequest.php @@ -34,12 +34,12 @@ class MessageScheduleRequest implements RequestDtoInterface { public ?int $repeatInterval = null; - #[Assert\DateTime] + #[Assert\DateTime(format: "Y-m-d\TH:i:s.uP")] public ?string $repeatUntil = null; public ?int $requeueInterval = null; - #[Assert\DateTime] + #[Assert\DateTime(format: "Y-m-d\TH:i:s.uP")] public ?string $requeueUntil = null; #[Assert\NotBlank] diff --git a/src/Messaging/Serializer/MessageNormalizer.php b/src/Messaging/Serializer/MessageNormalizer.php index 916ee73..e636bb6 100644 --- a/src/Messaging/Serializer/MessageNormalizer.php +++ b/src/Messaging/Serializer/MessageNormalizer.php @@ -36,6 +36,9 @@ new OA\Property(property: 'send_format', type: 'string', example: 'text', nullable: true), new OA\Property(property: 'as_text', type: 'integer', example: 12), new OA\Property(property: 'as_html', type: 'integer', example: 12), + new OA\Property(property: 'as_text_and_html', type: 'integer', example: 12), + new OA\Property(property: 'as_pdf', type: 'integer', example: 12), + new OA\Property(property: 'as_text_and_pdf', type: 'integer', example: 12), ], type: 'object' ), @@ -112,6 +115,9 @@ public function normalize($object, string $format = null, array $context = []): 'send_format' => $object->getFormat()->getSendFormat(), 'as_text' => $object->getFormat()->getAsText(), 'as_html' => $object->getFormat()->getAsHtml(), + 'as_text_and_html' => $object->getFormat()->getAsTextAndHtml(), + 'as_pdf' => $object->getFormat()->getAsPdf(), + 'as_text_and_pdf' => $object->getFormat()->getAsTextAndHtml(), ], 'message_metadata' => [ 'status' => $object->getMetadata()->getStatus()->value, From 6264e4700095daadf7692e32523b9cfe6232455a Mon Sep 17 00:00:00 2001 From: Tatevik Date: Mon, 6 Apr 2026 18:20:03 +0400 Subject: [PATCH 07/13] Fix: manager configs --- config/services/managers.yml | 6 +++--- src/Messaging/Request/Message/MessageMetadataRequest.php | 8 +++++++- 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/config/services/managers.yml b/config/services/managers.yml index 9925399..34b4a2a 100644 --- a/config/services/managers.yml +++ b/config/services/managers.yml @@ -8,7 +8,7 @@ services: autowire: true autoconfigure: true - PhpList\Core\Domain\Identity\Service\SessionManager: + PhpList\Core\Domain\Identity\Service\Manager\SessionManager: autowire: true autoconfigure: true @@ -36,7 +36,7 @@ services: autowire: true autoconfigure: true - PhpList\Core\Domain\Identity\Service\AdministratorManager: + PhpList\Core\Domain\Identity\Service\Manager\AdministratorManager: autowire: true autoconfigure: true @@ -56,6 +56,6 @@ services: autowire: true autoconfigure: true - PhpList\Core\Domain\Identity\Service\PasswordManager: + PhpList\Core\Domain\Identity\Service\Manager\PasswordManager: autowire: true autoconfigure: true diff --git a/src/Messaging/Request/Message/MessageMetadataRequest.php b/src/Messaging/Request/Message/MessageMetadataRequest.php index 4eb5045..2ae5ba3 100644 --- a/src/Messaging/Request/Message/MessageMetadataRequest.php +++ b/src/Messaging/Request/Message/MessageMetadataRequest.php @@ -14,13 +14,19 @@ schema: 'MessageMetadataRequest', required: ['status'], properties: [ - new OA\Property(property: 'status', type: 'string', example: 'draft'), + new OA\Property( + property: 'status', + type: 'string', + enum: ['draft', 'sent', 'prepared', 'submitted', 'suspended', 'requeued'], + example: 'draft' + ), ], type: 'object' )] class MessageMetadataRequest implements RequestDtoInterface, RequestInterface { #[Assert\NotBlank] + #[Assert\Choice(['draft', 'sent', 'prepared', 'submitted', 'suspended', 'requeued'])] public string $status; /** From bdeddeb1906903f7d948607b7fc14d49f3bdedd8 Mon Sep 17 00:00:00 2001 From: Tatevik Date: Mon, 6 Apr 2026 18:21:10 +0400 Subject: [PATCH 08/13] Add: testSendMessage endpoint --- config/services/messenger_handlers.yml | 7 +- .../Controller/CampaignActionController.php | 76 ++++++++++++++++++- .../Message/MessageScheduleRequest.php | 4 +- .../TestSendMessageToSubscribersRequest.php | 44 +++++++++++ 4 files changed, 127 insertions(+), 4 deletions(-) create mode 100644 src/Messaging/Request/TestSendMessageToSubscribersRequest.php diff --git a/config/services/messenger_handlers.yml b/config/services/messenger_handlers.yml index 9073d52..6b5a0ee 100644 --- a/config/services/messenger_handlers.yml +++ b/config/services/messenger_handlers.yml @@ -1,5 +1,10 @@ services: - PhpList\Core\Domain\Messaging\MessageHandler\CampaignProcessorMessageHandler: + PhpList\Core\Domain\Messaging\MessageHandler\CampaignProcessor\CampaignProcessorMessageHandler: + autowire: true + autoconfigure: true + public: false + + PhpList\Core\Domain\Messaging\MessageHandler\CampaignProcessor\TestCampaignProcessorMessageHandler: autowire: true autoconfigure: true public: false diff --git a/src/Messaging/Controller/CampaignActionController.php b/src/Messaging/Controller/CampaignActionController.php index bba577e..5515341 100644 --- a/src/Messaging/Controller/CampaignActionController.php +++ b/src/Messaging/Controller/CampaignActionController.php @@ -6,7 +6,8 @@ use Doctrine\ORM\EntityManagerInterface; use OpenApi\Attributes as OA; -use PhpList\Core\Domain\Messaging\Message\SyncCampaignProcessorMessage; +use PhpList\Core\Domain\Messaging\Message\CampaignProcessor\SyncCampaignProcessorMessage; +use PhpList\Core\Domain\Messaging\Message\CampaignProcessor\TestCampaignProcessorMessage; use PhpList\Core\Domain\Messaging\Model\Message; use PhpList\Core\Domain\Messaging\Model\Message\MessageStatus; use PhpList\Core\Domain\Messaging\Service\Manager\MessageManager; @@ -15,6 +16,7 @@ use PhpList\RestBundle\Common\Validator\RequestValidator; use PhpList\RestBundle\Messaging\Request\Message\MessageMetadataRequest; use PhpList\RestBundle\Messaging\Request\ResendMessageToListsRequest; +use PhpList\RestBundle\Messaging\Request\TestSendMessageToSubscribersRequest; use PhpList\RestBundle\Messaging\Serializer\MessageNormalizer; use PhpList\RestBundle\Messaging\Service\CampaignService; use Symfony\Bridge\Doctrine\Attribute\MapEntity; @@ -28,6 +30,7 @@ * This controller provides REST API to manage campaign actions. * * @author Tatevik Grigoryan + * @SuppressWarnings("PHPMD.CouplingBetweenObjects") */ #[Route('/campaigns', name: 'campaign_')] class CampaignActionController extends BaseController @@ -283,4 +286,75 @@ public function resendMessageToLists( return $this->json($this->campaignService->getMessage($message), Response::HTTP_OK); } + + #[Route( + '/{messageId}/test-send', + name: 'test_send_campaign', + requirements: ['messageId' => '\d+'], + methods: ['POST'] + )] + #[OA\Post( + path: '/api/v2/campaigns/{messageId}/test-send', + description: '🚧 **Status: Beta** – This method is under development. Avoid using in production. ' . + 'Processes/sends campaign/message by id to specified subscribers.', + summary: 'Processes/sends campaign/message by id to specified subscribers.', + requestBody: new OA\RequestBody( + description: 'Subscribers email to send this campaign to.', + required: true, + content: new OA\JsonContent(ref: '#/components/schemas/ResendMessageToListsRequest') + ), + tags: ['campaigns'], + parameters: [ + new OA\Parameter( + name: 'php-auth-pw', + description: 'Session key obtained from login', + in: 'header', + required: true, + schema: new OA\Schema(type: 'string') + ), + new OA\Parameter( + name: 'messageId', + description: 'message ID', + in: 'path', + required: true, + schema: new OA\Schema(type: 'string') + ) + ], + responses: [ + new OA\Response( + response: 200, + description: 'Success', + content: new OA\JsonContent(ref: '#/components/schemas/Message') + ), + new OA\Response( + response: 403, + description: 'Failure', + content: new OA\JsonContent(ref: '#/components/schemas/UnauthorizedResponse') + ), + new OA\Response( + response: 404, + description: 'Failure', + content: new OA\JsonContent(ref: '#/components/schemas/NotFoundErrorResponse') + ), + ] + )] + public function testSendMessage( + Request $request, + #[MapEntity(mapping: ['messageId' => 'id'])] ?Message $message = null + ): JsonResponse { + $this->requireAuthentication($request); + if ($message === null) { + throw $this->createNotFoundException('Campaign not found.'); + } + + /** @var TestSendMessageToSubscribersRequest $testSendRequest */ + $testSendRequest = $this->validator->validate($request, TestSendMessageToSubscribersRequest::class); + + $this->messageBus->dispatch(new TestCampaignProcessorMessage( + messageId: $message->getId(), + subscriberEmails: $testSendRequest->emails + )); + + return $this->json($this->campaignService->getMessage($message), Response::HTTP_OK); + } } diff --git a/src/Messaging/Request/Message/MessageScheduleRequest.php b/src/Messaging/Request/Message/MessageScheduleRequest.php index a3cd216..996017a 100644 --- a/src/Messaging/Request/Message/MessageScheduleRequest.php +++ b/src/Messaging/Request/Message/MessageScheduleRequest.php @@ -34,12 +34,12 @@ class MessageScheduleRequest implements RequestDtoInterface { public ?int $repeatInterval = null; - #[Assert\DateTime(format: "Y-m-d\TH:i:s.uP")] + #[Assert\DateTime(format: 'Y-m-d\TH:i:s.uP')] public ?string $repeatUntil = null; public ?int $requeueInterval = null; - #[Assert\DateTime(format: "Y-m-d\TH:i:s.uP")] + #[Assert\DateTime(format: 'Y-m-d\TH:i:s.uP')] public ?string $requeueUntil = null; #[Assert\NotBlank] diff --git a/src/Messaging/Request/TestSendMessageToSubscribersRequest.php b/src/Messaging/Request/TestSendMessageToSubscribersRequest.php new file mode 100644 index 0000000..5907bfc --- /dev/null +++ b/src/Messaging/Request/TestSendMessageToSubscribersRequest.php @@ -0,0 +1,44 @@ + $this->emails, + ]; + } +} From 6922cdf78c12632f9bf1700d3b60a3930f404973 Mon Sep 17 00:00:00 2001 From: Tatevik Date: Mon, 6 Apr 2026 19:19:43 +0400 Subject: [PATCH 09/13] Client docs workflow --- .github/workflows/client-docs.yml | 36 +++++++++++++++++++++++++------ 1 file changed, 30 insertions(+), 6 deletions(-) diff --git a/.github/workflows/client-docs.yml b/.github/workflows/client-docs.yml index 5a8204b..296e1fa 100644 --- a/.github/workflows/client-docs.yml +++ b/.github/workflows/client-docs.yml @@ -3,13 +3,24 @@ name: Update phplist-api-client OpenAPI on: push: branches: - - main + - '**' pull_request: jobs: generate-openapi: runs-on: ubuntu-22.04 + outputs: + source_branch: ${{ steps.branch.outputs.source_branch }} steps: + - name: Determine source branch + id: branch + run: | + if [ "${{ github.event_name }}" = "pull_request" ]; then + echo "source_branch=${{ github.head_ref }}" >> "$GITHUB_OUTPUT" + else + echo "source_branch=${{ github.ref_name }}" >> "$GITHUB_OUTPUT" + fi + - name: Checkout Source Repository uses: actions/checkout@v3 @@ -42,6 +53,8 @@ jobs: update-phplist-api-client: runs-on: ubuntu-22.04 needs: generate-openapi + env: + TARGET_BRANCH: ${{ needs.generate-openapi.outputs.source_branch }} steps: - name: Checkout phplist-api-client Repository uses: actions/checkout@v3 @@ -50,6 +63,17 @@ jobs: token: ${{ secrets.PUSH_API_CLIENT }} fetch-depth: 0 + - name: Prepare target branch + run: | + git fetch origin + + if git ls-remote --exit-code --heads origin "$TARGET_BRANCH" >/dev/null 2>&1; then + git checkout "$TARGET_BRANCH" + git pull --rebase origin "$TARGET_BRANCH" + else + git checkout -b "$TARGET_BRANCH" + fi + - name: Download Generated OpenAPI JSON uses: actions/download-artifact@v4 with: @@ -63,13 +87,13 @@ jobs: if [ -f openapi.json ]; then diff openapi.json new-openapi/latest-restapi.json > openapi-diff.txt || true if [ -s openapi-diff.txt ]; then - echo "diff=true" >> $GITHUB_OUTPUT + echo "diff=true" >> "$GITHUB_OUTPUT" else - echo "diff=false" >> $GITHUB_OUTPUT + echo "diff=false" >> "$GITHUB_OUTPUT" fi else echo "No previous openapi.json, will add." - echo "diff=true" >> $GITHUB_OUTPUT + echo "diff=true" >> "$GITHUB_OUTPUT" fi - name: Update and Commit OpenAPI File @@ -79,8 +103,8 @@ jobs: git config user.name "github-actions" git config user.email "github-actions@phplist-api-client.workflow" git add openapi.json - git commit -m "Update openapi.json from REST API workflow `date`" - git push + git commit -m "Update openapi.json from REST API workflow $(date -u +"%Y-%m-%dT%H:%M:%SZ")" + git push origin HEAD:"$TARGET_BRANCH" - name: Skip Commit if No Changes if: steps.diff.outputs.diff == 'false' From a238db418282cc5d4d46e8ef999fe71b96aeda21 Mon Sep 17 00:00:00 2001 From: Tatevik Date: Tue, 7 Apr 2026 18:03:28 +0400 Subject: [PATCH 10/13] Remove text_message from campaign request --- src/Messaging/Request/Message/MessageContentRequest.php | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/src/Messaging/Request/Message/MessageContentRequest.php b/src/Messaging/Request/Message/MessageContentRequest.php index a8d759c..56dd274 100644 --- a/src/Messaging/Request/Message/MessageContentRequest.php +++ b/src/Messaging/Request/Message/MessageContentRequest.php @@ -10,11 +10,10 @@ #[OA\Schema( schema: 'MessageContentRequest', - required: ['subject', 'text', 'text_message', 'footer'], + required: ['subject', 'text', 'footer'], properties: [ new OA\Property(property: 'subject', type: 'string', example: 'Campaign Subject'), new OA\Property(property: 'text', type: 'string', example: 'Full text content'), - new OA\Property(property: 'text_message', type: 'string', example: 'Short text message'), new OA\Property(property: 'footer', type: 'string', example: 'Unsubscribe link here'), ], type: 'object' @@ -27,9 +26,6 @@ class MessageContentRequest implements RequestDtoInterface #[Assert\NotBlank] public string $text; - #[Assert\NotBlank] - public string $textMessage; - #[Assert\NotBlank] public string $footer; @@ -38,7 +34,6 @@ public function getDto(): MessageContentDto return new MessageContentDto( subject: $this->subject, text: $this->text, - textMessage: $this->textMessage, footer: $this->footer, ); } From df1894660d3d0e1875f9216e522dfc507d13dc3f Mon Sep 17 00:00:00 2001 From: Tatevik Date: Wed, 8 Apr 2026 12:46:54 +0400 Subject: [PATCH 11/13] Remove html_formated from campaign request --- .github/workflows/client-docs.yml | 7 +++++++ .../Controller/CampaignActionController.php | 14 ++++++++++++-- .../Request/Message/MessageFormatRequest.php | 7 +------ 3 files changed, 20 insertions(+), 8 deletions(-) diff --git a/.github/workflows/client-docs.yml b/.github/workflows/client-docs.yml index 296e1fa..b961c1e 100644 --- a/.github/workflows/client-docs.yml +++ b/.github/workflows/client-docs.yml @@ -99,11 +99,18 @@ jobs: - name: Update and Commit OpenAPI File if: steps.diff.outputs.diff == 'true' run: | + set -euo pipefail cp new-openapi/latest-restapi.json openapi.json git config user.name "github-actions" git config user.email "github-actions@phplist-api-client.workflow" git add openapi.json + if git diff --cached --quiet; then + echo "No changes to commit" + exit 0 + fi git commit -m "Update openapi.json from REST API workflow $(date -u +"%Y-%m-%dT%H:%M:%SZ")" + git fetch origin "$TARGET_BRANCH" + git rebase "origin/$TARGET_BRANCH" git push origin HEAD:"$TARGET_BRANCH" - name: Skip Commit if No Changes diff --git a/src/Messaging/Controller/CampaignActionController.php b/src/Messaging/Controller/CampaignActionController.php index 5515341..326f832 100644 --- a/src/Messaging/Controller/CampaignActionController.php +++ b/src/Messaging/Controller/CampaignActionController.php @@ -80,7 +80,12 @@ public function __construct( response: 403, description: 'Failure', content: new OA\JsonContent(ref: '#/components/schemas/UnauthorizedResponse') - ) + ), + new OA\Response( + response: 404, + description: 'Failure', + content: new OA\JsonContent(ref: '#/components/schemas/NotFoundErrorResponse') + ), ] )] public function copyMessage( @@ -265,7 +270,12 @@ public function sendMessage( response: 403, description: 'Failure', content: new OA\JsonContent(ref: '#/components/schemas/UnauthorizedResponse') - ) + ), + new OA\Response( + response: 404, + description: 'Failure', + content: new OA\JsonContent(ref: '#/components/schemas/NotFoundErrorResponse') + ), ] )] public function resendMessageToLists( diff --git a/src/Messaging/Request/Message/MessageFormatRequest.php b/src/Messaging/Request/Message/MessageFormatRequest.php index bb3b373..90ae287 100644 --- a/src/Messaging/Request/Message/MessageFormatRequest.php +++ b/src/Messaging/Request/Message/MessageFormatRequest.php @@ -10,9 +10,8 @@ #[OA\Schema( schema: 'MessageFormatRequest', - required: ['html_formated', 'send_format'], + required: ['send_format'], properties: [ - new OA\Property(property: 'html_formated', type: 'boolean', example: true), new OA\Property( property: 'send_format', type: 'string', @@ -24,16 +23,12 @@ enum: ['html', 'text', 'invite'], )] class MessageFormatRequest implements RequestDtoInterface { - #[Assert\Type('bool')] - public bool $htmlFormated; - #[Assert\Choice(['html', 'text', 'invite'])] public string $sendFormat; public function getDto(): MessageFormatDto { return new MessageFormatDto( - htmlFormated: $this->htmlFormated, sendFormat: $this->sendFormat, ); } From 4467266e5d708c603f78b9d2bd4db52cd8a83c82 Mon Sep 17 00:00:00 2001 From: Tatevik Date: Wed, 8 Apr 2026 15:54:44 +0400 Subject: [PATCH 12/13] Add: validateNoClickTrackLinks --- .../Request/Message/MessageContentRequest.php | 23 ++++++++ .../Request/MessageContentRequestTest.php | 58 +++++++++++++++++++ 2 files changed, 81 insertions(+) create mode 100644 tests/Unit/Messaging/Request/MessageContentRequestTest.php diff --git a/src/Messaging/Request/Message/MessageContentRequest.php b/src/Messaging/Request/Message/MessageContentRequest.php index 56dd274..01bff9b 100644 --- a/src/Messaging/Request/Message/MessageContentRequest.php +++ b/src/Messaging/Request/Message/MessageContentRequest.php @@ -7,6 +7,7 @@ use OpenApi\Attributes as OA; use PhpList\Core\Domain\Messaging\Model\Dto\Message\MessageContentDto; use Symfony\Component\Validator\Constraints as Assert; +use Symfony\Component\Validator\Context\ExecutionContextInterface; #[OA\Schema( schema: 'MessageContentRequest', @@ -20,6 +21,9 @@ )] class MessageContentRequest implements RequestDtoInterface { + private const CLICKTRACK_MESSAGE = 'You should not paste the results of a test message back into the editor. ' + . 'This will break the click-track statistics, and overload the server.'; + #[Assert\NotBlank] public string $subject; @@ -29,6 +33,25 @@ class MessageContentRequest implements RequestDtoInterface #[Assert\NotBlank] public string $footer; + #[Assert\Callback('validateNoClickTrackLinks')] + public function validateNoClickTrackLinks(ExecutionContextInterface $context): void + { + if (!isset($this->text)) { + return; + } + + $hasClickTrackLinks = preg_match('/lt\.php\?id=[\w%]{22}/', $this->text) === 1 + || preg_match('/lt\.php\?id=[\w%]{16}/', $this->text) === 1 + || preg_match('#/lt/[\w%]{22}#', $this->text) === 1 + || preg_match('#/lt/[\w%]{16}#', $this->text) === 1; + + if ($hasClickTrackLinks) { + $context->buildViolation(self::CLICKTRACK_MESSAGE) + ->atPath('text') + ->addViolation(); + } + } + public function getDto(): MessageContentDto { return new MessageContentDto( diff --git a/tests/Unit/Messaging/Request/MessageContentRequestTest.php b/tests/Unit/Messaging/Request/MessageContentRequestTest.php new file mode 100644 index 0000000..520e3dd --- /dev/null +++ b/tests/Unit/Messaging/Request/MessageContentRequestTest.php @@ -0,0 +1,58 @@ +text = 'Hello, this is a normal message body.'; + + $context = $this->createMock(ExecutionContextInterface::class); + $context->expects($this->never())->method('buildViolation'); + + $request->validateNoClickTrackLinks($context); + } + + public function testValidateNoClickTrackLinksWithLtPhpPatternAddsViolation(): void + { + $request = new MessageContentRequest(); + $request->text = 'See this link: https://example.com/lt.php?id=abcdefghijklmnop'; + + $builder = $this->createMock(ConstraintViolationBuilderInterface::class); + $builder->expects($this->once())->method('atPath')->with('text')->willReturnSelf(); + $builder->expects($this->once())->method('addViolation'); + + $context = $this->createMock(ExecutionContextInterface::class); + $context->expects($this->once()) + ->method('buildViolation') + ->willReturn($builder); + + $request->validateNoClickTrackLinks($context); + } + + public function testValidateNoClickTrackLinksWithLinkMapPatternAddsViolation(): void + { + $request = new MessageContentRequest(); + $request->text = 'Mapped link: https://example.com/lt/abcdefghijklmnopqrstuv'; + + $builder = $this->createMock(ConstraintViolationBuilderInterface::class); + $builder->expects($this->once())->method('atPath')->with('text')->willReturnSelf(); + $builder->expects($this->once())->method('addViolation'); + + $context = $this->createMock(ExecutionContextInterface::class); + $context->expects($this->once()) + ->method('buildViolation') + ->willReturn($builder); + + $request->validateNoClickTrackLinks($context); + } +} From c678b302c358f997701de9e220ec2f031b85df5a Mon Sep 17 00:00:00 2001 From: Tatevik Date: Thu, 9 Apr 2026 13:45:50 +0400 Subject: [PATCH 13/13] Filter campaigns by subject Fix: flush entity manager after updates --- .../Controller/CampaignController.php | 13 ++++++++--- .../Controller/ListMessageController.php | 23 +++++++------------ src/Messaging/Service/CampaignService.php | 4 +++- 3 files changed, 21 insertions(+), 19 deletions(-) diff --git a/src/Messaging/Controller/CampaignController.php b/src/Messaging/Controller/CampaignController.php index 5b7a1dc..1769aa7 100644 --- a/src/Messaging/Controller/CampaignController.php +++ b/src/Messaging/Controller/CampaignController.php @@ -66,7 +66,14 @@ public function __construct( in: 'query', required: false, schema: new OA\Schema(type: 'integer', default: 25, maximum: 100, minimum: 1) - ) + ), + new OA\Parameter( + name: 'subject', + description: 'Filter campaigns by subject (partial match)', + in: 'query', + required: false, + schema: new OA\Schema(type: 'string', maxLength: 50) + ), ], responses: [ new OA\Response( @@ -96,7 +103,7 @@ public function getMessages(Request $request): JsonResponse $authUser = $this->requireAuthentication($request); return $this->json( - $this->campaignService->getMessages($request, $authUser), + $this->campaignService->getMessages(request: $request, administrator: $authUser), Response::HTTP_OK ); } @@ -148,10 +155,10 @@ public function getMessage( Request $request, #[MapEntity(mapping: ['messageId' => 'id'])] ?Message $message = null ): JsonResponse { + $this->requireAuthentication($request); if ($message === null) { throw $this->createNotFoundException('Campaign not found.'); } - $this->requireAuthentication($request); return $this->json($this->campaignService->getMessage($message), Response::HTTP_OK); } diff --git a/src/Messaging/Controller/ListMessageController.php b/src/Messaging/Controller/ListMessageController.php index f67c8fa..fe0e80a 100644 --- a/src/Messaging/Controller/ListMessageController.php +++ b/src/Messaging/Controller/ListMessageController.php @@ -27,25 +27,16 @@ #[Route('/list-messages', name: 'list_message_')] class ListMessageController extends BaseController { - private ListMessageManager $listMessageManager; - private ListMessageNormalizer $listMessageNormalizer; - private SubscriberListNormalizer $subscriberListNormalizer; - private MessageNormalizer $messageNormalizer; - public function __construct( - Authentication $authentication, - RequestValidator $validator, - ListMessageManager $listMessageManager, - ListMessageNormalizer $listMessageNormalizer, - SubscriberListNormalizer $subscriberListNormalizer, - MessageNormalizer $messageNormalizer, + protected Authentication $authentication, + protected RequestValidator $validator, + private readonly ListMessageManager $listMessageManager, + private readonly ListMessageNormalizer $listMessageNormalizer, + private readonly SubscriberListNormalizer $subscriberListNormalizer, + private readonly MessageNormalizer $messageNormalizer, private readonly EntityManagerInterface $entityManager, ) { parent::__construct($authentication, $validator); - $this->listMessageManager = $listMessageManager; - $this->listMessageNormalizer = $listMessageNormalizer; - $this->subscriberListNormalizer = $subscriberListNormalizer; - $this->messageNormalizer = $messageNormalizer; } #[Route( @@ -339,6 +330,7 @@ public function disassociateMessageFromList( } $this->listMessageManager->removeAssociation($message, $subscriberList); + $this->entityManager->flush(); return $this->json(null, Response::HTTP_NO_CONTENT); } @@ -398,6 +390,7 @@ public function removeAllListAssociationsForMessage( } $this->listMessageManager->removeAllListAssociationsForMessage($message); + $this->entityManager->flush(); return $this->json(null, Response::HTTP_NO_CONTENT); } diff --git a/src/Messaging/Service/CampaignService.php b/src/Messaging/Service/CampaignService.php index 90ec7d2..5f50124 100644 --- a/src/Messaging/Service/CampaignService.php +++ b/src/Messaging/Service/CampaignService.php @@ -30,7 +30,9 @@ public function __construct( public function getMessages(Request $request, Administrator $administrator): array { - $filter = (new MessageFilter())->setOwner($administrator); + $filter = (new MessageFilter()) + ->setOwner($administrator) + ->setSubject($request->query->get('subject')); return $this->paginatedProvider->getPaginatedList( request: $request,