Skip to content

Commit d7e3fcb

Browse files
committed
Add endpoints to resume and retrieve stuck campaigns in CampaignController and CampaignService
1 parent 72e848a commit d7e3fcb

4 files changed

Lines changed: 286 additions & 0 deletions

File tree

src/Messaging/Controller/CampaignActionController.php

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66

77
use Doctrine\ORM\EntityManagerInterface;
88
use OpenApi\Attributes as OA;
9+
use PhpList\Core\Domain\Messaging\Message\CampaignProcessor\CampaignProcessorMessage;
910
use PhpList\Core\Domain\Messaging\Message\CampaignProcessor\SyncCampaignProcessorMessage;
1011
use PhpList\Core\Domain\Messaging\Message\CampaignProcessor\TestCampaignProcessorMessage;
1112
use PhpList\Core\Domain\Messaging\Model\Message;
@@ -103,6 +104,68 @@ public function copyMessage(
103104
return $this->json($this->campaignService->getMessage($message), Response::HTTP_CREATED);
104105
}
105106

107+
#[Route('/{messageId}/resume', name: 'resume_campaign', requirements: ['messageId' => '\d+'], methods: ['POST'])]
108+
#[OA\Post(
109+
path: '/api/v2/campaigns/{messageId}/resume',
110+
description: '🚧 **Status: Beta** – This method is under development. Avoid using in production. ' .
111+
'Resumes a campaign/message that is stuck in Prepared/InProcess status past the stuck-campaign ' .
112+
'threshold, re-dispatching it for processing.',
113+
summary: 'Resumes a campaign stuck in processing.',
114+
tags: ['campaigns'],
115+
parameters: [
116+
new OA\Parameter(
117+
name: 'php-auth-pw',
118+
description: 'Session key obtained from login',
119+
in: 'header',
120+
required: true,
121+
schema: new OA\Schema(type: 'string')
122+
),
123+
new OA\Parameter(
124+
name: 'messageId',
125+
description: 'message ID',
126+
in: 'path',
127+
required: true,
128+
schema: new OA\Schema(type: 'string')
129+
)
130+
],
131+
responses: [
132+
new OA\Response(
133+
response: 200,
134+
description: 'Success',
135+
content: new OA\JsonContent(ref: '#/components/schemas/Message')
136+
),
137+
new OA\Response(
138+
response: 403,
139+
description: 'Failure',
140+
content: new OA\JsonContent(ref: '#/components/schemas/UnauthorizedResponse')
141+
),
142+
new OA\Response(
143+
response: 404,
144+
description: 'Failure',
145+
content: new OA\JsonContent(ref: '#/components/schemas/NotFoundErrorResponse')
146+
),
147+
new OA\Response(
148+
response: 409,
149+
description: 'Failure - campaign is not currently stuck in processing',
150+
content: new OA\JsonContent(ref: '#/components/schemas/UnauthorizedResponse')
151+
),
152+
]
153+
)]
154+
public function resumeStuckCampaign(
155+
Request $request,
156+
#[MapEntity(mapping: ['messageId' => 'id'])] ?Message $message = null
157+
): JsonResponse {
158+
$authUser = $this->requireAuthentication($request);
159+
if ($message === null) {
160+
throw $this->createNotFoundException('Campaign not found.');
161+
}
162+
163+
$this->campaignService->resumeStuckCampaign($authUser, $message);
164+
$this->messageBus->dispatch(new CampaignProcessorMessage($message->getId()));
165+
166+
return $this->json($this->campaignService->getMessage($message), Response::HTTP_OK);
167+
}
168+
106169
#[Route('/{messageId}/status', name: 'update_status', requirements: ['messageId' => '\d+'], methods: ['PATCH'])]
107170
#[OA\Patch(
108171
path: '/api/v2/campaigns/{messageId}/status',

src/Messaging/Controller/CampaignController.php

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -123,6 +123,59 @@ public function getMessages(Request $request): JsonResponse
123123
);
124124
}
125125

126+
#[Route('/stuck', name: 'get_stuck', methods: ['GET'])]
127+
#[OA\Get(
128+
path: '/api/v2/campaigns/stuck',
129+
description: '🚧 **Status: Beta** – This method is under development. Avoid using in production. ' .
130+
'Returns campaigns stuck in Prepared/InProcess status past the stuck-campaign threshold, ' .
131+
'for admin review. No automatic action is taken on these campaigns.',
132+
summary: 'Gets a list of campaigns stuck in processing.',
133+
tags: ['campaigns'],
134+
parameters: [
135+
new OA\Parameter(
136+
name: 'php-auth-pw',
137+
description: 'Session key obtained from login',
138+
in: 'header',
139+
required: true,
140+
schema: new OA\Schema(type: 'string')
141+
),
142+
],
143+
responses: [
144+
new OA\Response(
145+
response: 200,
146+
description: 'Success',
147+
content: new OA\JsonContent(
148+
type: 'array',
149+
items: new OA\Items(
150+
properties: [
151+
new OA\Property(property: 'id', type: 'integer'),
152+
new OA\Property(property: 'subject', type: 'string'),
153+
new OA\Property(property: 'status', type: 'string', example: 'inprocess'),
154+
new OA\Property(
155+
property: 'updated_at',
156+
type: 'string',
157+
format: 'date-time'
158+
),
159+
new OA\Property(property: 'stuck_seconds', type: 'integer', example: 1845),
160+
],
161+
type: 'object'
162+
)
163+
)
164+
),
165+
new OA\Response(
166+
response: 403,
167+
description: 'Failure',
168+
content: new OA\JsonContent(ref: '#/components/schemas/UnauthorizedResponse')
169+
)
170+
]
171+
)]
172+
public function getStuckCampaigns(Request $request): JsonResponse
173+
{
174+
$this->requireAuthentication($request);
175+
176+
return $this->json($this->campaignService->getStuckCampaigns(), Response::HTTP_OK);
177+
}
178+
126179
#[Route('/{messageId}', name: 'get_one', requirements: ['messageId' => '\d+'], methods: ['GET'])]
127180
#[OA\Get(
128181
path: '/api/v2/campaigns/{messageId}',

src/Messaging/Service/CampaignService.php

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@
44

55
namespace PhpList\RestBundle\Messaging\Service;
66

7+
use DateTimeImmutable;
8+
use DateTimeInterface;
79
use Doctrine\ORM\EntityManagerInterface;
810
use PhpList\Core\Domain\Identity\Model\Administrator;
911
use PhpList\Core\Domain\Identity\Model\PrivilegeFlag;
@@ -14,8 +16,10 @@
1416
use PhpList\RestBundle\Messaging\Request\CreateMessageRequest;
1517
use PhpList\RestBundle\Messaging\Request\UpdateMessageRequest;
1618
use PhpList\RestBundle\Messaging\Serializer\MessageNormalizer;
19+
use Symfony\Component\DependencyInjection\Attribute\Autowire;
1720
use Symfony\Component\HttpFoundation\Request;
1821
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
22+
use Symfony\Component\HttpKernel\Exception\ConflictHttpException;
1923
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
2024

2125
class CampaignService
@@ -25,6 +29,7 @@ public function __construct(
2529
private readonly PaginatedDataProvider $paginatedProvider,
2630
private readonly MessageNormalizer $normalizer,
2731
private readonly EntityManagerInterface $entityManager,
32+
#[Autowire('%messaging.stuck_campaign_threshold%')] private readonly int $stuckCampaignThresholdSeconds = 1800,
2833
) {
2934
}
3035

@@ -106,4 +111,55 @@ public function deleteMessage(Administrator $administrator, Message $message = n
106111
$this->messageManager->delete($message);
107112
$this->entityManager->flush();
108113
}
114+
115+
/**
116+
* Lists campaigns whose processing appears stalled: still in Prepared/InProcess status
117+
* with no update for longer than the stuck-campaign threshold. This is a monitoring view
118+
* only, no automatic action is taken - an admin decides whether to resume each one.
119+
*/
120+
public function getStuckCampaigns(): array
121+
{
122+
$stuckMessages = $this->messageManager->getStuckCampaigns($this->getStaleBefore());
123+
$now = new DateTimeImmutable();
124+
125+
return array_map(
126+
fn (Message $message) => $this->toStuckCampaignArray($message, $now),
127+
$stuckMessages
128+
);
129+
}
130+
131+
public function resumeStuckCampaign(Administrator $administrator, Message $message = null): void
132+
{
133+
if (!$administrator->getPrivileges()->has(PrivilegeFlag::Campaigns)) {
134+
throw new AccessDeniedHttpException('You are not allowed to update campaigns.');
135+
}
136+
137+
if (!$message) {
138+
throw new NotFoundHttpException('Campaign not found.');
139+
}
140+
141+
$stuckIds = array_map(
142+
static fn (Message $stuckMessage) => $stuckMessage->getId(),
143+
$this->messageManager->getStuckCampaigns($this->getStaleBefore())
144+
);
145+
if (!in_array($message->getId(), $stuckIds, true)) {
146+
throw new ConflictHttpException('Campaign is not currently stuck in processing.');
147+
}
148+
}
149+
150+
private function getStaleBefore(): DateTimeImmutable
151+
{
152+
return new DateTimeImmutable(sprintf('-%d seconds', $this->stuckCampaignThresholdSeconds));
153+
}
154+
155+
private function toStuckCampaignArray(Message $message, DateTimeImmutable $now): array
156+
{
157+
return [
158+
'id' => $message->getId(),
159+
'subject' => $message->getContent()->getSubject(),
160+
'status' => $message->getMetadata()->getStatus()->value,
161+
'updated_at' => $message->getUpdatedAt()->format(DateTimeInterface::ATOM),
162+
'stuck_seconds' => $now->getTimestamp() - $message->getUpdatedAt()->getTimestamp(),
163+
];
164+
}
109165
}

tests/Unit/Messaging/Service/CampaignServiceTest.php

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@
44

55
namespace PhpList\RestBundle\Tests\Unit\Messaging\Service;
66

7+
use DateTime;
8+
use DateTimeImmutable;
79
use Doctrine\ORM\EntityManagerInterface;
810
use PhpList\Core\Domain\Identity\Model\Administrator;
911
use PhpList\Core\Domain\Identity\Model\PrivilegeFlag;
@@ -12,6 +14,9 @@
1214
use PhpList\Core\Domain\Messaging\Model\Message;
1315
use PhpList\Core\Domain\Messaging\Model\Dto\CreateMessageDto;
1416
use PhpList\Core\Domain\Messaging\Model\Dto\UpdateMessageDto;
17+
use PhpList\Core\Domain\Messaging\Model\Message\MessageContent;
18+
use PhpList\Core\Domain\Messaging\Model\Message\MessageMetadata;
19+
use PhpList\Core\Domain\Messaging\Model\Message\MessageStatus;
1520
use PhpList\Core\Domain\Messaging\Service\Manager\MessageManager;
1621
use PhpList\RestBundle\Common\Service\Provider\PaginatedDataProvider;
1722
use PhpList\RestBundle\Messaging\Request\CreateMessageRequest;
@@ -22,6 +27,7 @@
2227
use PHPUnit\Framework\TestCase;
2328
use Symfony\Component\HttpFoundation\Request;
2429
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
30+
use Symfony\Component\HttpKernel\Exception\ConflictHttpException;
2531
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
2632

2733
class CampaignServiceTest extends TestCase
@@ -42,6 +48,7 @@ protected function setUp(): void
4248
paginatedProvider: $this->paginatedProvider,
4349
normalizer: $this->normalizer,
4450
entityManager: $this->createMock(EntityManagerInterface::class),
51+
stuckCampaignThresholdSeconds: 1800,
4552
);
4653
}
4754

@@ -342,4 +349,111 @@ public function testDeleteMessageCallsMessageManagerDelete(): void
342349

343350
$this->campaignService->deleteMessage($administrator, $message);
344351
}
352+
353+
public function testGetStuckCampaignsReturnsMappedArray(): void
354+
{
355+
$message = $this->createMock(Message::class);
356+
$content = $this->createMock(MessageContent::class);
357+
$metadata = $this->createMock(MessageMetadata::class);
358+
$updatedAt = new DateTime('-45 minutes');
359+
360+
$content->method('getSubject')->willReturn('Stuck Campaign');
361+
$metadata->method('getStatus')->willReturn(MessageStatus::InProcess);
362+
$message->method('getId')->willReturn(7);
363+
$message->method('getContent')->willReturn($content);
364+
$message->method('getMetadata')->willReturn($metadata);
365+
$message->method('getUpdatedAt')->willReturn($updatedAt);
366+
367+
$this->messageManager->expects($this->once())
368+
->method('getStuckCampaigns')
369+
->with($this->isInstanceOf(DateTimeImmutable::class))
370+
->willReturn([$message]);
371+
372+
$result = $this->campaignService->getStuckCampaigns();
373+
374+
$this->assertCount(1, $result);
375+
$this->assertSame(7, $result[0]['id']);
376+
$this->assertSame('Stuck Campaign', $result[0]['subject']);
377+
$this->assertSame('inprocess', $result[0]['status']);
378+
$this->assertGreaterThanOrEqual(45 * 60, $result[0]['stuck_seconds']);
379+
}
380+
381+
public function testResumeStuckCampaignThrowsExceptionWhenAdministratorLacksPrivileges(): void
382+
{
383+
$privileges = $this->createMock(Privileges::class);
384+
$administrator = $this->createMock(Administrator::class);
385+
$message = $this->createMock(Message::class);
386+
387+
$administrator->expects($this->once())
388+
->method('getPrivileges')
389+
->willReturn($privileges);
390+
391+
$privileges->expects($this->once())
392+
->method('has')
393+
->with(PrivilegeFlag::Campaigns)
394+
->willReturn(false);
395+
396+
$this->expectException(AccessDeniedHttpException::class);
397+
398+
$this->campaignService->resumeStuckCampaign($administrator, $message);
399+
}
400+
401+
public function testResumeStuckCampaignThrowsExceptionWhenMessageIsNull(): void
402+
{
403+
$privileges = $this->createMock(Privileges::class);
404+
$administrator = $this->createMock(Administrator::class);
405+
406+
$administrator->expects($this->once())
407+
->method('getPrivileges')
408+
->willReturn($privileges);
409+
410+
$privileges->expects($this->once())
411+
->method('has')
412+
->with(PrivilegeFlag::Campaigns)
413+
->willReturn(true);
414+
415+
$this->expectException(NotFoundHttpException::class);
416+
417+
$this->campaignService->resumeStuckCampaign($administrator, null);
418+
}
419+
420+
public function testResumeStuckCampaignThrowsConflictWhenCampaignIsNotStuck(): void
421+
{
422+
$privileges = $this->createMock(Privileges::class);
423+
$administrator = $this->createMock(Administrator::class);
424+
$message = $this->createMock(Message::class);
425+
$otherStuckMessage = $this->createMock(Message::class);
426+
427+
$administrator->method('getPrivileges')->willReturn($privileges);
428+
$privileges->method('has')->with(PrivilegeFlag::Campaigns)->willReturn(true);
429+
430+
$message->method('getId')->willReturn(1);
431+
$otherStuckMessage->method('getId')->willReturn(2);
432+
433+
$this->messageManager->expects($this->once())
434+
->method('getStuckCampaigns')
435+
->willReturn([$otherStuckMessage]);
436+
437+
$this->expectException(ConflictHttpException::class);
438+
439+
$this->campaignService->resumeStuckCampaign($administrator, $message);
440+
}
441+
442+
public function testResumeStuckCampaignSucceedsWhenCampaignIsStuck(): void
443+
{
444+
$privileges = $this->createMock(Privileges::class);
445+
$administrator = $this->createMock(Administrator::class);
446+
$message = $this->createMock(Message::class);
447+
448+
$administrator->method('getPrivileges')->willReturn($privileges);
449+
$privileges->method('has')->with(PrivilegeFlag::Campaigns)->willReturn(true);
450+
451+
$message->method('getId')->willReturn(1);
452+
453+
$this->messageManager->expects($this->once())
454+
->method('getStuckCampaigns')
455+
->willReturn([$message]);
456+
457+
$this->campaignService->resumeStuckCampaign($administrator, $message);
458+
}
345459
}

0 commit comments

Comments
 (0)