feat: create ClickUp task after confirming accommodation booking

This commit is contained in:
Björn Fromme
2026-08-20 17:56:29 +02:00
parent 0a1683667f
commit 7c5f6dd5ef
18 changed files with 535 additions and 48 deletions
-353
View File
@@ -1,353 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Service;
use App\Exception\NewsletterProviderException;
use Psr\Log\LoggerInterface;
use Symfony\Contracts\HttpClient\HttpClientInterface;
class MailjetApiClient
{
private const string DEFAULT_BASE_URL = 'https://api.mailjet.com/v3/REST';
private const int NAME_MAX_LENGTH = 255;
/**
* @param array{firstName?: string, lastName?: string} $contactMetadataFields
*/
public function __construct(
private readonly HttpClientInterface $httpClient,
private readonly LoggerInterface $logger,
private readonly ?string $apiKey = null,
private readonly ?string $apiSecret = null,
private readonly ?string $apiBaseUrl = null,
private readonly ?string $defaultListId = null,
private readonly array $contactMetadataFields = [],
) {
}
public function upsertContact(string $email, ?string $firstName = null, ?string $lastName = null): void
{
$normalizedEmail = $this->normalizeEmail($email);
$normalizedFirstName = $this->normalizeName($firstName);
$normalizedLastName = $this->normalizeName($lastName);
if (null === $normalizedFirstName && null === $normalizedLastName) {
return;
}
$contactData = $this->buildContactDataPayload($normalizedFirstName, $normalizedLastName);
if ([] === $contactData) {
return;
}
$contactId = $this->findOrCreateContactId($normalizedEmail);
$this->request('POST', 'contactdata', [
'json' => [
'ContactID' => $contactId,
'Data' => $contactData,
],
]);
}
public function isSubscribed(string $email, ?int $listId = null): bool
{
$resolvedListId = $this->resolveListId($listId);
$normalizedEmail = $this->normalizeEmail($email);
$contactId = $this->resolveContactId($normalizedEmail);
if (null === $contactId) {
return false;
}
$response = $this->request('GET', 'Listrecipient', [
'query' => [
'Contact' => $contactId,
'ContactsList' => $resolvedListId,
],
]);
$entries = $response['Data'] ?? [];
if (false === is_array($entries)) {
return false;
}
foreach ($entries as $entry) {
if (false === is_array($entry)) {
continue;
}
$entryContactId = isset($entry['ContactID']) ? (int) $entry['ContactID'] : null;
if ($entryContactId !== $contactId) {
continue;
}
$isActive = true === ($entry['IsActive'] ?? false);
$isUnsubscribed = true === ($entry['IsUnsubscribed'] ?? false);
return $isActive && !$isUnsubscribed;
}
return false;
}
public function ensureSubscribed(string $email, ?int $listId = null): void
{
$resolvedListId = $this->resolveListId($listId);
$normalizedEmail = $this->normalizeEmail($email);
$resource = sprintf('Contactslist/%s/managecontact', $resolvedListId);
try {
$this->request('POST', $resource, [
'json' => [
'Email' => $normalizedEmail,
'Action' => 'addforce',
],
]);
} catch (NewsletterProviderException $exception) {
$this->logger->error('Mailjet subscribe failed', [
'email' => $normalizedEmail,
'list_id' => $resolvedListId,
'error' => $exception->getMessage(),
]);
throw $exception;
}
}
/**
* @return array<string, mixed>
*/
public function createEventCallbackUrl(string $eventType, string $url, int $version = 2, bool $isBackup = false): array
{
return $this->request('POST', 'eventcallbackurl', [
'json' => [
'EventType' => strtolower(trim($eventType)),
'Url' => $url,
'Version' => $version,
'isBackup' => $isBackup,
],
]);
}
public function deleteEventCallbackUrl(int $eventCallbackUrlId): void
{
$this->request('DELETE', sprintf('eventcallbackurl/%d', $eventCallbackUrlId), [
'allow_empty' => true,
]);
}
private function findOrCreateContactId(string $email): int
{
$contactId = $this->resolveContactId($email);
if (null !== $contactId) {
return $contactId;
}
try {
$contactId = $this->createContact($email);
if (null !== $contactId) {
return $contactId;
}
} catch (NewsletterProviderException $exception) {
$contactId = $this->resolveContactId($email);
if (null !== $contactId) {
return $contactId;
}
throw $exception;
}
$contactId = $this->resolveContactId($email);
if (null !== $contactId) {
return $contactId;
}
throw new NewsletterProviderException(sprintf('Mailjet contact could not be resolved for %s', $email));
}
private function createContact(string $email): ?int
{
$response = $this->request('POST', 'Contact', [
'json' => [
'Email' => $email,
],
]);
return $this->extractContactId($response);
}
private function resolveContactId(string $email): ?int
{
$this->assertConfigured();
$response = $this->request('GET', 'Contact', [
'query' => [
'Email' => $email,
'Limit' => 1,
],
'allow_404' => true,
]);
return $this->extractContactId($response);
}
/**
* @param array<string, mixed> $response
*/
private function extractContactId(array $response): ?int
{
$entry = $response['Data'][0] ?? null;
if (true === is_array($entry) && true === isset($entry['ID'])) {
return (int) $entry['ID'];
}
if (true === isset($response['Data']['ID'])) {
return (int) $response['Data']['ID'];
}
return null;
}
/**
* @return list<array{Name: string, Value: string}>
*/
private function buildContactDataPayload(?string $firstName, ?string $lastName): array
{
$data = [];
if (null !== $firstName && true === isset($this->contactMetadataFields['firstName'])) {
$data[] = [
'Name' => $this->contactMetadataFields['firstName'],
'Value' => $firstName,
];
}
if (null !== $lastName && true === isset($this->contactMetadataFields['lastName'])) {
$data[] = [
'Name' => $this->contactMetadataFields['lastName'],
'Value' => $lastName,
];
}
return $data;
}
/**
* @param array<string, mixed> $options
*
* @return array<string, mixed>
*/
private function request(string $method, string $resource, array $options = []): array
{
$allow404 = true === ($options['allow_404'] ?? false);
$allowEmpty = true === ($options['allow_empty'] ?? false);
unset($options['allow_404']);
unset($options['allow_empty']);
$resourcePath = trim($resource, '/');
try {
$response = $this->httpClient->request(
$method,
sprintf('%s/%s', $this->getBaseUrl(), $resourcePath),
array_merge($options, [
'auth_basic' => sprintf('%s:%s', $this->apiKey, $this->apiSecret),
])
);
$statusCode = $response->getStatusCode();
if (404 === $statusCode && $allow404) {
return [];
}
$content = $response->getContent(false);
if ('' === trim($content)) {
if (true === $allowEmpty && $statusCode < 400) {
return [];
}
throw new NewsletterProviderException(sprintf('Mailjet request returned an empty response for resource %s', $resource));
}
try {
$payload = json_decode($content, true, 512, JSON_THROW_ON_ERROR);
} catch (\JsonException $exception) {
throw new NewsletterProviderException(sprintf('Mailjet request error for resource %s', $resource), previous: $exception);
}
if ($statusCode >= 400) {
$payloadSummary = json_encode($payload, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
$payloadSummary = false === $payloadSummary ? null : $payloadSummary;
throw new NewsletterProviderException(sprintf('Mailjet request failed with status %d for resource %s%s', $statusCode, $resource, null !== $payloadSummary ? sprintf(' (%s)', $payloadSummary) : ''));
}
return $payload;
} catch (\Throwable $exception) {
if (true === $allow404 && true === str_contains($exception->getMessage(), '404')) {
return [];
}
if ($exception instanceof NewsletterProviderException) {
throw $exception;
}
throw new NewsletterProviderException(sprintf('Mailjet request error for resource %s', $resource), previous: $exception);
}
}
private function getBaseUrl(): string
{
$baseUrl = null !== $this->apiBaseUrl && '' !== trim($this->apiBaseUrl)
? trim($this->apiBaseUrl)
: self::DEFAULT_BASE_URL;
return rtrim($baseUrl, '/');
}
private function assertConfigured(): void
{
if (true === empty($this->apiKey) || true === empty($this->apiSecret)) {
throw new NewsletterProviderException('Mailjet newsletter service is not fully configured.');
}
}
private function resolveListId(?int $listId): string
{
$this->assertConfigured();
$resolvedListId = null !== $listId
? (string) $listId
: (string) $this->defaultListId;
if ('' === trim($resolvedListId)) {
throw new NewsletterProviderException('Mailjet newsletter list is not configured.');
}
return trim($resolvedListId);
}
private function normalizeEmail(string $email): string
{
return mb_strtolower(trim($email));
}
private function normalizeName(?string $name): ?string
{
if (null === $name) {
return null;
}
$trimmed = trim($name);
if ('' === $trimmed) {
return null;
}
return mb_substr($trimmed, 0, self::NAME_MAX_LENGTH);
}
}
+2 -1
View File
@@ -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,