feat: create ClickUp task after confirming accommodation booking
This commit is contained in:
@@ -0,0 +1,104 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\ClickUp;
|
||||
|
||||
use App\ClickUp\Exception\ClickUpException;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Symfony\Contracts\HttpClient\Exception\ExceptionInterface;
|
||||
use Symfony\Contracts\HttpClient\HttpClientInterface;
|
||||
|
||||
class ApiClient
|
||||
{
|
||||
private const string DEFAULT_BASE_URL = 'https://api.clickup.com/api/v2';
|
||||
|
||||
// Creating a task from a template regularly takes several seconds on ClickUp's side, so these
|
||||
// ceilings sit well above the ones the BpnConnect client uses. They exist to stop a hung
|
||||
// upstream from pinning a worker forever, not to enforce a latency budget.
|
||||
private const int IDLE_TIMEOUT_SECONDS = 30;
|
||||
private const int MAX_DURATION_SECONDS = 120;
|
||||
|
||||
public function __construct(
|
||||
private readonly HttpClientInterface $httpClient,
|
||||
private readonly LoggerInterface $logger,
|
||||
private readonly ?string $apiToken = null,
|
||||
private readonly ?string $listId = null,
|
||||
private readonly ?string $templateId = null,
|
||||
private readonly ?string $baseUrl = null,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Environments without ClickUp credentials (dev, test, staging) must not fail — callers check
|
||||
* this instead of letting assertConfigured() blow up.
|
||||
*/
|
||||
public function isConfigured(): bool
|
||||
{
|
||||
return null !== $this->apiToken && '' !== $this->apiToken
|
||||
&& null !== $this->listId && '' !== $this->listId
|
||||
&& null !== $this->templateId && '' !== $this->templateId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a task from the configured template and returns the new task id.
|
||||
*/
|
||||
public function createTaskFromTemplate(string $name): string
|
||||
{
|
||||
$data = $this->request('POST', sprintf('/list/%s/taskTemplate/%s', $this->listId, $this->templateId), [
|
||||
'name' => $name,
|
||||
]);
|
||||
|
||||
$id = $data['id'] ?? null;
|
||||
if (!is_string($id) || '' === $id) {
|
||||
throw new ClickUpException('ClickUp did not return a task id for the created task.');
|
||||
}
|
||||
|
||||
return $id;
|
||||
}
|
||||
|
||||
public function updateTaskStatus(string $taskId, string $status): void
|
||||
{
|
||||
$this->request('PUT', '/task/'.$taskId, ['status' => $status]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $payload
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function request(string $method, string $path, array $payload): array
|
||||
{
|
||||
$this->assertConfigured();
|
||||
|
||||
try {
|
||||
$response = $this->httpClient->request($method, ($this->baseUrl ?? self::DEFAULT_BASE_URL).$path, [
|
||||
'headers' => [
|
||||
// ClickUp deviates from the usual scheme: the raw token goes into the
|
||||
// Authorization header, without a "Bearer " prefix.
|
||||
'Authorization' => $this->apiToken,
|
||||
],
|
||||
'json' => $payload,
|
||||
'timeout' => self::IDLE_TIMEOUT_SECONDS,
|
||||
'max_duration' => self::MAX_DURATION_SECONDS,
|
||||
]);
|
||||
|
||||
return $response->toArray();
|
||||
} catch (ExceptionInterface $e) {
|
||||
$this->logger->error('ClickUp request failed', [
|
||||
'method' => $method,
|
||||
'path' => $path,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
|
||||
throw new ClickUpException('ClickUp request failed: '.$e->getMessage(), 0, $e);
|
||||
}
|
||||
}
|
||||
|
||||
private function assertConfigured(): void
|
||||
{
|
||||
if (!$this->isConfigured()) {
|
||||
throw new ClickUpException('ClickUp client is not configured (missing CLICKUP_API_TOKEN, CLICKUP_LIST_ID or CLICKUP_TEMPLATE_ID).');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\ClickUp\Exception;
|
||||
|
||||
class ClickUpException extends \RuntimeException
|
||||
{
|
||||
}
|
||||
@@ -5,7 +5,7 @@ declare(strict_types=1);
|
||||
namespace App\Command;
|
||||
|
||||
use App\Exception\NewsletterProviderException;
|
||||
use App\Service\MailjetApiClient;
|
||||
use App\Mailjet\ApiClient;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Symfony\Component\Console\Attribute\AsCommand;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
@@ -31,7 +31,7 @@ final class MailjetNewsletterWebhookCommand extends Command
|
||||
private const int WEBHOOK_VERSION = 2;
|
||||
|
||||
public function __construct(
|
||||
private readonly MailjetApiClient $mailjetApiClient,
|
||||
private readonly ApiClient $mailjetApiClient,
|
||||
#[Autowire(env: 'APP_BASE_URL')]
|
||||
private readonly string $defaultBaseUrl,
|
||||
#[Autowire(env: 'MAILJET_WEBHOOK_BASIC_PASSWORD')]
|
||||
|
||||
@@ -6,11 +6,13 @@ namespace App\Controller\Admin\AccommodationBooking;
|
||||
|
||||
use App\Entity\Groups\AccommodationBooking;
|
||||
use App\Htmx\HxRedirectResponse;
|
||||
use App\Message\CreateClickUpBookingTaskMessage;
|
||||
use App\Service\AccommodationBookingService;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\Messenger\MessageBusInterface;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
use Symfony\Component\Security\Http\Attribute\IsGranted;
|
||||
|
||||
@@ -19,6 +21,7 @@ class ConfirmController extends AbstractController
|
||||
{
|
||||
public function __construct(
|
||||
private readonly AccommodationBookingService $bookingService,
|
||||
private readonly MessageBusInterface $messageBus,
|
||||
private readonly LoggerInterface $logger,
|
||||
) {
|
||||
}
|
||||
@@ -53,6 +56,9 @@ class ConfirmController extends AbstractController
|
||||
'id' => $booking->getId(),
|
||||
]);
|
||||
|
||||
// Creating the ClickUp task takes seconds, so it must not hang off this request.
|
||||
$this->messageBus->dispatch(new CreateClickUpBookingTaskMessage((int) $booking->getId()));
|
||||
|
||||
return new HxRedirectResponse($this->generateUrl('app_admin_accommodationbooking_show', ['id' => $booking->getId()]));
|
||||
}
|
||||
|
||||
|
||||
@@ -2,13 +2,13 @@
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Service;
|
||||
namespace App\Mailjet;
|
||||
|
||||
use App\Exception\NewsletterProviderException;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Symfony\Contracts\HttpClient\HttpClientInterface;
|
||||
|
||||
class MailjetApiClient
|
||||
class ApiClient
|
||||
{
|
||||
private const string DEFAULT_BASE_URL = 'https://api.mailjet.com/v3/REST';
|
||||
private const int NAME_MAX_LENGTH = 255;
|
||||
@@ -0,0 +1,13 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Message;
|
||||
|
||||
final class CreateClickUpBookingTaskMessage
|
||||
{
|
||||
public function __construct(
|
||||
public readonly int $bookingId,
|
||||
) {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\MessageHandler;
|
||||
|
||||
use App\ClickUp\ApiClient;
|
||||
use App\ClickUp\Exception\ClickUpException;
|
||||
use App\Message\CreateClickUpBookingTaskMessage;
|
||||
use App\Repository\Groups\AccommodationBookingRepository;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
|
||||
|
||||
#[AsMessageHandler]
|
||||
final class CreateClickUpBookingTaskHandler
|
||||
{
|
||||
/** The status every freshly created group-operations task starts in. */
|
||||
public const string TASK_STATUS = 'GRO';
|
||||
|
||||
public function __construct(
|
||||
private readonly AccommodationBookingRepository $bookingRepository,
|
||||
private readonly ApiClient $clickUpClient,
|
||||
private readonly LoggerInterface $logger,
|
||||
) {
|
||||
}
|
||||
|
||||
public function __invoke(CreateClickUpBookingTaskMessage $message): void
|
||||
{
|
||||
if (!$this->clickUpClient->isConfigured()) {
|
||||
$this->logger->warning('ClickUp is not configured, skipping task creation', [
|
||||
'bookingId' => $message->bookingId,
|
||||
]);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$booking = $this->bookingRepository->find($message->bookingId);
|
||||
if (null === $booking) {
|
||||
// The booking is gone — a retry cannot bring it back.
|
||||
$this->logger->warning('Accommodation booking not found, skipping ClickUp task creation', [
|
||||
'bookingId' => $message->bookingId,
|
||||
]);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$hotelCode = $booking->getAccommodation()?->getCalendarCode();
|
||||
$dateFrom = $booking->getDateFrom();
|
||||
$contactName = trim(trim((string) $booking->getFirstName()).' '.trim((string) $booking->getLastName()));
|
||||
|
||||
if (null === $hotelCode || '' === $hotelCode || null === $dateFrom || '' === $contactName) {
|
||||
// A task named " 2026-08-20 " helps nobody, and no amount of retrying fills the gaps.
|
||||
$this->logger->warning('Accommodation booking lacks the data for a ClickUp task name, skipping', [
|
||||
'bookingId' => $message->bookingId,
|
||||
]);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$name = sprintf('%s %s %s', $hotelCode, $dateFrom->format('Y-m-d'), $contactName);
|
||||
|
||||
$taskId = $this->clickUpClient->createTaskFromTemplate($name);
|
||||
|
||||
try {
|
||||
$this->clickUpClient->updateTaskStatus($taskId, self::TASK_STATUS);
|
||||
} catch (ClickUpException $e) {
|
||||
// Deliberately swallowed: letting this bubble would retry the whole handler and create
|
||||
// a second task in ClickUp. A task with the wrong status can be fixed by hand, a
|
||||
// duplicate task cannot be un-created.
|
||||
$this->logger->error('ClickUp task created but status update failed', [
|
||||
'bookingId' => $message->bookingId,
|
||||
'taskId' => $taskId,
|
||||
'status' => self::TASK_STATUS,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$this->logger->info('Created ClickUp task for accommodation booking', [
|
||||
'bookingId' => $message->bookingId,
|
||||
'taskId' => $taskId,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,7 @@ use App\Entity\NewsletterConsent;
|
||||
use App\Entity\NewsletterOptInRequest;
|
||||
use App\Exception\NewsletterListNotAllowedException;
|
||||
use App\Exception\NewsletterProviderException;
|
||||
use App\Mailjet\ApiClient;
|
||||
use App\Model\NewsletterConfirmationResult;
|
||||
use App\Model\NewsletterSubscriptionRequestResult;
|
||||
use App\Repository\NewsletterConsentRepository;
|
||||
@@ -27,7 +28,7 @@ class NewsletterManager
|
||||
private readonly NewsletterOptInRequestRepository $optInRequestRepository,
|
||||
private readonly NewsletterConsentRepository $consentRepository,
|
||||
private readonly EntityManagerInterface $entityManager,
|
||||
private readonly MailjetApiClient $newsletterService,
|
||||
private readonly ApiClient $newsletterService,
|
||||
private readonly Mailer $mailer,
|
||||
private readonly LoggerInterface $logger,
|
||||
private readonly int $newsletterConfirmationTtlHours,
|
||||
|
||||
Reference in New Issue
Block a user