From a5f46a6139063553ec4ce54538be8c4589a29d5b Mon Sep 17 00:00:00 2001 From: Supun Wimalasena Date: Wed, 25 Feb 2026 13:26:41 +0100 Subject: [PATCH 01/43] wip, refactor, create subscriber update, etc. --- .../Console/Controller/ApiKeyController.php | 6 +- .../Console/Controller/ApprovalController.php | 18 +++--- .../Console/Controller/IssueController.php | 8 +-- .../Controller/NewsletterController.php | 8 +-- .../Controller/SendingProfileController.php | 14 ++--- .../Controller/SubscriberController.php | 55 ++++++++++++------- .../Subscriber/CreateSubscriberIfExists.php | 10 ++++ .../Subscriber/CreateSubscriberInput.php | 17 ++++-- backend/src/Service/ApiKey/ApiKeyService.php | 8 +-- .../src/Service/Approval/ApprovalService.php | 20 +++---- backend/src/Service/Import/ImportService.php | 6 +- backend/src/Service/Issue/IssueService.php | 20 +++---- backend/src/Service/Issue/SendService.php | 12 ++-- .../Service/Newsletter/NewsletterService.php | 8 +-- .../NewsletterList/NewsletterListService.php | 2 +- .../SendingProfile/SendingProfileService.php | 16 +++--- .../Service/Subscriber/SubscriberService.php | 16 +++--- .../src/Service/Template/TemplateService.php | 2 +- backend/src/Util/OptionalPropertyTrait.php | 4 +- .../Subscriber/CreateSubscriberTest.php | 20 ++++++- compose.yaml | 2 +- 21 files changed, 163 insertions(+), 109 deletions(-) create mode 100644 backend/src/Api/Console/Input/Subscriber/CreateSubscriberIfExists.php diff --git a/backend/src/Api/Console/Controller/ApiKeyController.php b/backend/src/Api/Console/Controller/ApiKeyController.php index ab10addf5..5ac8b9058 100644 --- a/backend/src/Api/Console/Controller/ApiKeyController.php +++ b/backend/src/Api/Console/Controller/ApiKeyController.php @@ -54,13 +54,13 @@ public function getApiKeys(Newsletter $newsletter): JsonResponse public function updateApiKey(#[MapRequestPayload] UpdateApiKeyInput $input, ApiKey $apiKey): JsonResponse { $updates = new UpdateApiKeyDto(); - if ($input->hasProperty('is_enabled')) { + if ($input->has('is_enabled')) { $updates->enabled = $input->is_enabled; } - if ($input->hasProperty('scopes')) { + if ($input->has('scopes')) { $updates->scopes = $input->scopes; } - if ($input->hasProperty('name')) { + if ($input->has('name')) { $updates->name = $input->name; } diff --git a/backend/src/Api/Console/Controller/ApprovalController.php b/backend/src/Api/Console/Controller/ApprovalController.php index df8015845..dab13e079 100644 --- a/backend/src/Api/Console/Controller/ApprovalController.php +++ b/backend/src/Api/Console/Controller/ApprovalController.php @@ -102,39 +102,39 @@ public function updateApproval( $updates = new UpdateApprovalDto(); - if ($input->hasProperty('company_name')) { + if ($input->has('company_name')) { $updates->companyName = $input->company_name; } - if ($input->hasProperty('country')) { + if ($input->has('country')) { $updates->country = $input->country; } - if ($input->hasProperty('website')) { + if ($input->has('website')) { $updates->website = $input->website; } - if ($input->hasProperty('social_links')) { + if ($input->has('social_links')) { $updates->socialLinks = $input->social_links; } - if ($input->hasProperty('type_of_content')) { + if ($input->has('type_of_content')) { $updates->typeOfContent = $input->type_of_content; } - if ($input->hasProperty('frequency')) { + if ($input->has('frequency')) { $updates->frequency = $input->frequency; } - if ($input->hasProperty('existing_list')) { + if ($input->has('existing_list')) { $updates->existingList = $input->existing_list; } - if ($input->hasProperty('sample')) { + if ($input->has('sample')) { $updates->sample = $input->sample; } - if ($input->hasProperty('why_post')) { + if ($input->has('why_post')) { $updates->whyPost = $input->why_post; } diff --git a/backend/src/Api/Console/Controller/IssueController.php b/backend/src/Api/Console/Controller/IssueController.php index 08c8b3cb8..03ba4bab2 100644 --- a/backend/src/Api/Console/Controller/IssueController.php +++ b/backend/src/Api/Console/Controller/IssueController.php @@ -93,15 +93,15 @@ public function updateIssue( { $updates = new UpdateIssueDto(); - if ($input->hasProperty('subject')) { + if ($input->has('subject')) { $updates->subject = $input->subject; } - if ($input->hasProperty('content')) { + if ($input->has('content')) { $updates->content = $input->content; } - if ($input->hasProperty('sending_profile_id')) { + if ($input->has('sending_profile_id')) { $sendingProfile = $this->sendingProfileService->getSendingProfileOfNewsletterById( $newsletter, $input->sending_profile_id @@ -114,7 +114,7 @@ public function updateIssue( $updates->sendingProfile = $sendingProfile; } - if ($input->hasProperty('lists')) { + if ($input->has('lists')) { $missingListIds = $this->newsletterListService->getMissingListIdsOfNewsletter($newsletter, $input->lists); if ($missingListIds !== null) { diff --git a/backend/src/Api/Console/Controller/NewsletterController.php b/backend/src/Api/Console/Controller/NewsletterController.php index bdb084499..d16eec6ee 100644 --- a/backend/src/Api/Console/Controller/NewsletterController.php +++ b/backend/src/Api/Console/Controller/NewsletterController.php @@ -93,19 +93,19 @@ public function updateNewsletter( ): JsonResponse { $updates = new UpdateNewsletterDto(); - if ($input->hasProperty('name')) { + if ($input->has('name')) { $updates->name = $input->name; } - if ($input->hasProperty('subdomain')) { + if ($input->has('subdomain')) { if ($this->newsletterService->isSubdomainTaken($input->subdomain)) { throw new UnprocessableEntityHttpException('Subdomain is already taken.'); } $updates->subdomain = $input->subdomain; } - if ($input->hasProperty('language_code')) { + if ($input->has('language_code')) { $updates->language_code = $input->language_code; } - if ($input->hasProperty('is_rtl')) { + if ($input->has('is_rtl')) { $updates->is_rtl = $input->is_rtl; } $newsletter = $this->newsletterService->updateNewsletter($newsletter, $updates); diff --git a/backend/src/Api/Console/Controller/SendingProfileController.php b/backend/src/Api/Console/Controller/SendingProfileController.php index 793d5485b..4fd7cfd50 100644 --- a/backend/src/Api/Console/Controller/SendingProfileController.php +++ b/backend/src/Api/Console/Controller/SendingProfileController.php @@ -83,33 +83,33 @@ public function updateSendingProfile( { $updates = new UpdateSendingProfileDto(); - if ($input->hasProperty('from_email')) { + if ($input->has('from_email')) { $domain = $this->getDomainFromEmail($input->from_email); $updates->customDomain = $domain; $updates->fromEmail = $input->from_email; } - if ($input->hasProperty('from_name')) { + if ($input->has('from_name')) { $updates->fromName = $input->from_name; } - if ($input->hasProperty('reply_to_email')) { + if ($input->has('reply_to_email')) { $updates->replyToEmail = $input->reply_to_email; } - if ($input->hasProperty('brand_name')) { + if ($input->has('brand_name')) { $updates->brandName = $input->brand_name; } - if ($input->hasProperty('brand_logo')) { + if ($input->has('brand_logo')) { $updates->brandLogo = $input->brand_logo; } - if ($input->hasProperty('brand_url')) { + if ($input->has('brand_url')) { $updates->brandUrl = $input->brand_url; } - if ($input->hasProperty('is_default')) { + if ($input->has('is_default')) { $updates->isDefault = $input->is_default; } diff --git a/backend/src/Api/Console/Controller/SubscriberController.php b/backend/src/Api/Console/Controller/SubscriberController.php index 12edad369..9163a7e10 100644 --- a/backend/src/Api/Console/Controller/SubscriberController.php +++ b/backend/src/Api/Console/Controller/SubscriberController.php @@ -5,6 +5,7 @@ use App\Api\Console\Authorization\Scope; use App\Api\Console\Authorization\ScopeRequired; use App\Api\Console\Input\Subscriber\BulkActionSubscriberInput; +use App\Api\Console\Input\Subscriber\CreateSubscriberIfExists; use App\Api\Console\Input\Subscriber\CreateSubscriberInput; use App\Api\Console\Input\Subscriber\UpdateSubscriberInput; use App\Api\Console\Object\SubscriberObject; @@ -79,6 +80,7 @@ public function createSubscriber( Newsletter $newsletter ): JsonResponse { + $missingListIds = $this ->newsletterListService ->getMissingListIdsOfNewsletter($newsletter, $input->list_ids); @@ -87,23 +89,38 @@ public function createSubscriber( throw new UnprocessableEntityHttpException("List with id {$missingListIds[0]} not found"); } - $subscriberDB = $this->subscriberService->getSubscriberByEmail($newsletter, $input->email); - if ($subscriberDB !== null) { - throw new UnprocessableEntityHttpException("Subscriber with email {$input->email} already exists"); - } - + $subscriber = $this->subscriberService->getSubscriberByEmail($newsletter, $input->email); $lists = $this->newsletterListService->getListsByIds($input->list_ids); - $subscriber = $this->subscriberService->createSubscriber( - $newsletter, - $input->email, - $lists, - SubscriberStatus::PENDING, - $input->source ?? SubscriberSource::CONSOLE, - $input->subscribe_ip, - $input->subscribed_at ? \DateTimeImmutable::createFromTimestamp($input->subscribed_at) : null, - $input->unsubscribed_at ? \DateTimeImmutable::createFromTimestamp($input->unsubscribed_at) : null, - ); + if ($subscriber === null) { + + // create subscriber + $subscriber = $this->subscriberService->createSubscriber( + $newsletter, + $input->email, + $lists, + SubscriberStatus::PENDING, + source: $input->source ?? SubscriberSource::CONSOLE, + subscribeIp: $input->subscribe_ip ?? null, + subscribedAt: $input->has('subscribed_at') ? \DateTimeImmutable::createFromTimestamp($input->subscribed_at) : null, + unsubscribedAt: $input->has('unsubscribed_at') ? \DateTimeImmutable::createFromTimestamp($input->unsubscribed_at) : null, + ); + + } elseif ($input->if_exists === CreateSubscriberIfExists::UPDATE) { + + // update + $updates = new UpdateSubscriberDto(); + $updates->lists = $lists; + $updates->status = $input->status; + $updates->subscribedAt = $input->subscribed_at; + $updates->unsubscribedAt = $input->unsubscribed_at; + + // TODO: + + + } else { + throw new UnprocessableEntityHttpException("Subscriber with email {$input->email} already exists"); + } return $this->json(new SubscriberObject($subscriber)); } @@ -118,7 +135,7 @@ public function updateSubscriber( { $updates = new UpdateSubscriberDto(); - if ($input->hasProperty('email')) { + if ($input->has('email')) { $subscriberDB = $this->subscriberService->getSubscriberByEmail($newsletter, $input->email); if ($subscriberDB !== null) { throw new UnprocessableEntityHttpException("Subscriber with email {$input->email} already exists"); @@ -127,7 +144,7 @@ public function updateSubscriber( $updates->email = $input->email; } - if ($input->hasProperty('list_ids')) { + if ($input->has('list_ids')) { $missingListIds = $this->newsletterListService->getMissingListIdsOfNewsletter( $newsletter, $input->list_ids @@ -140,7 +157,7 @@ public function updateSubscriber( $updates->lists = $this->newsletterListService->getListsByIds($input->list_ids); } - if ($input->hasProperty('status')) { + if ($input->has('status')) { if ($input->status === SubscriberStatus::SUBSCRIBED && $subscriber->getOptInAt() === null) { throw new UnprocessableEntityHttpException('Subscribers without opt-in can not be updated to SUBSCRIBED status.'); } @@ -150,7 +167,7 @@ public function updateSubscriber( $metadataDefinitions = $this->subscriberMetadataService->getMetadataDefinitions($newsletter); - if ($input->hasProperty('metadata')) { + if ($input->has('metadata')) { try { $this->subscriberMetadataService->validateMetadata($newsletter, $input->metadata); } catch (\Exception $e) { diff --git a/backend/src/Api/Console/Input/Subscriber/CreateSubscriberIfExists.php b/backend/src/Api/Console/Input/Subscriber/CreateSubscriberIfExists.php new file mode 100644 index 000000000..4d87ad75f --- /dev/null +++ b/backend/src/Api/Console/Input/Subscriber/CreateSubscriberIfExists.php @@ -0,0 +1,10 @@ +hasProperty('enabled')) { + if ($updates->has('enabled')) { $apiKey->setIsEnabled($updates->enabled); } - if ($updates->hasProperty('scopes')) { + if ($updates->has('scopes')) { $apiKey->setScopes($updates->scopes); } - if ($updates->hasProperty('name')) { + if ($updates->has('name')) { $apiKey->setName($updates->name); } - if ($updates->hasProperty('lastAccessedAt')) { + if ($updates->has('lastAccessedAt')) { $apiKey->setLastAccessedAt($updates->lastAccessedAt); } diff --git a/backend/src/Service/Approval/ApprovalService.php b/backend/src/Service/Approval/ApprovalService.php index f7c1de7a1..fc3c529f2 100644 --- a/backend/src/Service/Approval/ApprovalService.php +++ b/backend/src/Service/Approval/ApprovalService.php @@ -137,29 +137,29 @@ public function updateApproval( UpdateApprovalDto $updates ): Approval { - if ($updates->hasProperty('companyName')) { + if ($updates->has('companyName')) { $approval->setCompanyName($updates->companyName); } - if ($updates->hasProperty('country')) { + if ($updates->has('country')) { $approval->setCountry($updates->country); } - if ($updates->hasProperty('website')) { + if ($updates->has('website')) { $approval->setWebsite($updates->website); } - if ($updates->hasProperty('socialLinks')) { + if ($updates->has('socialLinks')) { $approval->setSocialLinks($updates->socialLinks); } - if ($updates->hasProperty('status')) { + if ($updates->has('status')) { $approval->setStatus($updates->status); } $otherInfo = $approval->getOtherInfo() ?? []; - if ($updates->hasProperty('typeOfContent')) { + if ($updates->has('typeOfContent')) { if ($updates->typeOfContent === null) { unset($otherInfo['type_of_content']); } else { @@ -167,7 +167,7 @@ public function updateApproval( } } - if ($updates->hasProperty('frequency')) { + if ($updates->has('frequency')) { if ($updates->frequency === null) { unset($otherInfo['frequency']); } else { @@ -175,7 +175,7 @@ public function updateApproval( } } - if ($updates->hasProperty('existingList')) { + if ($updates->has('existingList')) { if ($updates->existingList === null) { unset($otherInfo['existing_list']); } else { @@ -183,7 +183,7 @@ public function updateApproval( } } - if ($updates->hasProperty('sample')) { + if ($updates->has('sample')) { if ($updates->sample === null) { unset($otherInfo['sample']); } else { @@ -191,7 +191,7 @@ public function updateApproval( } } - if ($updates->hasProperty('whyPost')) { + if ($updates->has('whyPost')) { if ($updates->whyPost === null) { unset($otherInfo['why_post']); } else { diff --git a/backend/src/Service/Import/ImportService.php b/backend/src/Service/Import/ImportService.php index f002bc13f..718f84b3c 100644 --- a/backend/src/Service/Import/ImportService.php +++ b/backend/src/Service/Import/ImportService.php @@ -92,13 +92,13 @@ public function createSubscriberImport( public function updateSubscriberImport(SubscriberImport $subscriberImport, UpdateSubscriberImportDto $updates): SubscriberImport { - if ($updates->hasProperty('status')) { + if ($updates->has('status')) { $subscriberImport->setStatus($updates->status); } - if ($updates->hasProperty('fields')) { + if ($updates->has('fields')) { $subscriberImport->setFields($updates->fields); } - if ($updates->hasProperty('errorMessage')) { + if ($updates->has('errorMessage')) { $subscriberImport->setErrorMessage($updates->errorMessage); } $subscriberImport->setUpdatedAt($this->now()); diff --git a/backend/src/Service/Issue/IssueService.php b/backend/src/Service/Issue/IssueService.php index d25c43bef..e572cd471 100644 --- a/backend/src/Service/Issue/IssueService.php +++ b/backend/src/Service/Issue/IssueService.php @@ -60,43 +60,43 @@ public function createIssueDraft(Newsletter $newsletter): Issue public function updateIssue(Issue $issue, UpdateIssueDto $updates): Issue { - if ($updates->hasProperty('subject')) { + if ($updates->has('subject')) { $issue->setSubject($updates->subject); } - if ($updates->hasProperty('content')) { + if ($updates->has('content')) { $issue->setContent($updates->content); } - if ($updates->hasProperty('sendingProfile')) { + if ($updates->has('sendingProfile')) { $issue->setSendingProfile($updates->sendingProfile); } - if ($updates->hasProperty('status')) { + if ($updates->has('status')) { $issue->setStatus($updates->status); } - if ($updates->hasProperty('lists')) { + if ($updates->has('lists')) { $issue->setListids($updates->lists); } - if ($updates->hasProperty('html')) { + if ($updates->has('html')) { $issue->setHtml($updates->html); } - if ($updates->hasProperty('text')) { + if ($updates->has('text')) { $issue->setText($updates->text); } - if ($updates->hasProperty('sendingAt')) { + if ($updates->has('sendingAt')) { $issue->setSendingAt($updates->sendingAt); } - if ($updates->hasProperty('totalSendable')) { + if ($updates->has('totalSendable')) { $issue->setTotalSendable($updates->totalSendable); } - if ($updates->hasProperty('sentAt')) { + if ($updates->has('sentAt')) { $issue->setSentAt($updates->sentAt); } diff --git a/backend/src/Service/Issue/SendService.php b/backend/src/Service/Issue/SendService.php index eec3c36af..175958031 100644 --- a/backend/src/Service/Issue/SendService.php +++ b/backend/src/Service/Issue/SendService.php @@ -174,27 +174,27 @@ public function getIssueProgress(Issue $issue): ?array public function updateSend(Send $send, UpdateSendDto $updates): Send { - if ($updates->hasProperty('status')) { + if ($updates->has('status')) { $send->setStatus($updates->status); } - if ($updates->hasProperty('deliveredAt')) { + if ($updates->has('deliveredAt')) { $send->setDeliveredAt($updates->deliveredAt); } - if ($updates->hasProperty('failedAt')) { + if ($updates->has('failedAt')) { $send->setFailedAt($updates->failedAt); } - if ($updates->hasProperty('bouncedAt')) { + if ($updates->has('bouncedAt')) { $send->setBouncedAt($updates->bouncedAt); } - if ($updates->hasProperty('complainedAt')) { + if ($updates->has('complainedAt')) { $send->setComplainedAt($updates->complainedAt); } - if ($updates->hasProperty('hardBounce')) { + if ($updates->has('hardBounce')) { $send->setHardBounce($updates->hardBounce); } diff --git a/backend/src/Service/Newsletter/NewsletterService.php b/backend/src/Service/Newsletter/NewsletterService.php index 98b792309..5f1d8bf2a 100644 --- a/backend/src/Service/Newsletter/NewsletterService.php +++ b/backend/src/Service/Newsletter/NewsletterService.php @@ -364,11 +364,11 @@ public function updateNewsletterMeta(Newsletter $newsletter, UpdateNewsletterMet public function updateNewsletter(Newsletter $newsletter, UpdateNewsletterDto $updates): Newsletter { - if ($updates->hasProperty('name')) { + if ($updates->has('name')) { $newsletter->setName($updates->name); } - if ($updates->hasProperty('subdomain')) { + if ($updates->has('subdomain')) { $newsletter->setSubdomain($updates->subdomain); $systemSendingProfile = $this->sendingProfileService->getSystemSendingProfileOfNewsletter($newsletter); @@ -379,11 +379,11 @@ public function updateNewsletter(Newsletter $newsletter, UpdateNewsletterDto $up ->updateSendingProfile($systemSendingProfile, $sendingProfileUpdates); } - if ($updates->hasProperty('language_code')) { + if ($updates->has('language_code')) { $newsletter->setLanguageCode($updates->language_code); } - if ($updates->hasProperty('is_rtl')) { + if ($updates->has('is_rtl')) { $newsletter->setIsRtl($updates->is_rtl); } diff --git a/backend/src/Service/NewsletterList/NewsletterListService.php b/backend/src/Service/NewsletterList/NewsletterListService.php index a6935eea3..4e6612a1c 100644 --- a/backend/src/Service/NewsletterList/NewsletterListService.php +++ b/backend/src/Service/NewsletterList/NewsletterListService.php @@ -126,7 +126,7 @@ public function getMissingListIdsOfNewsletter(Newsletter $newsletter, array $lis } /** - * Note that we should validate the lists are within the newsletter (using isListsAvailable) before calling this method + * Note that we should validate the lists are within the newsletter (using getMissingListIdsOfNewsletter) before calling this method * @param array $listIds * @return ArrayCollection */ diff --git a/backend/src/Service/SendingProfile/SendingProfileService.php b/backend/src/Service/SendingProfile/SendingProfileService.php index f670aaf43..dcc4f51d9 100644 --- a/backend/src/Service/SendingProfile/SendingProfileService.php +++ b/backend/src/Service/SendingProfile/SendingProfileService.php @@ -86,35 +86,35 @@ public function updateSendingProfile( UpdateSendingProfileDto $updates ): SendingProfile { - if ($updates->hasProperty('fromEmail')) { + if ($updates->has('fromEmail')) { $sendingProfile->setFromEmail($updates->fromEmail); } - if ($updates->hasProperty('fromName')) { + if ($updates->has('fromName')) { $sendingProfile->setFromName($updates->fromName); } - if ($updates->hasProperty('replyToEmail')) { + if ($updates->has('replyToEmail')) { $sendingProfile->setReplyToEmail($updates->replyToEmail); } - if ($updates->hasProperty('brandName')) { + if ($updates->has('brandName')) { $sendingProfile->setBrandName($updates->brandName); } - if ($updates->hasProperty('brandLogo')) { + if ($updates->has('brandLogo')) { $sendingProfile->setBrandLogo($updates->brandLogo); } - if ($updates->hasProperty('brandUrl')) { + if ($updates->has('brandUrl')) { $sendingProfile->setBrandUrl($updates->brandUrl); } - if ($updates->hasProperty('customDomain')) { + if ($updates->has('customDomain')) { $sendingProfile->setDomain($updates->customDomain); } - if ($updates->hasProperty('isDefault')) { + if ($updates->has('isDefault')) { // only true is supported assert($updates->isDefault === true); $sendingProfile->setIsDefault($updates->isDefault); diff --git a/backend/src/Service/Subscriber/SubscriberService.php b/backend/src/Service/Subscriber/SubscriberService.php index 1cc62b9ea..43d339457 100644 --- a/backend/src/Service/Subscriber/SubscriberService.php +++ b/backend/src/Service/Subscriber/SubscriberService.php @@ -157,15 +157,15 @@ public function getSubscribers( public function updateSubscriber(Subscriber $subscriber, UpdateSubscriberDto $updates): Subscriber { - if ($updates->hasProperty('email')) { + if ($updates->has('email')) { $subscriber->setEmail($updates->email); } - if ($updates->hasProperty('status')) { + if ($updates->has('status')) { $subscriber->setStatus($updates->status); } - if ($updates->hasProperty('lists')) { + if ($updates->has('lists')) { // Clear & re-add lists foreach ($subscriber->getLists() as $list) { $subscriber->removeList($list); @@ -175,23 +175,23 @@ public function updateSubscriber(Subscriber $subscriber, UpdateSubscriberDto $up } } - if ($updates->hasProperty('subscribedAt')) { + if ($updates->has('subscribedAt')) { $subscriber->setSubscribedAt($updates->subscribedAt); } - if ($updates->hasProperty('optInAt')) { + if ($updates->has('optInAt')) { $subscriber->setOptInAt($updates->optInAt); } - if ($updates->hasProperty('unsubscribedAt')) { + if ($updates->has('unsubscribedAt')) { $subscriber->setUnsubscribedAt($updates->unsubscribedAt); } - if ($updates->hasProperty('unsubscribedReason')) { + if ($updates->has('unsubscribedReason')) { $subscriber->setUnsubscribeReason($updates->unsubscribedReason); } - if ($updates->hasProperty('metadata')) { + if ($updates->has('metadata')) { $metadata = $subscriber->getMetadata(); foreach ($updates->metadata as $key => $value) { $metadata[$key] = $value; diff --git a/backend/src/Service/Template/TemplateService.php b/backend/src/Service/Template/TemplateService.php index 49e9b6d35..235098b15 100644 --- a/backend/src/Service/Template/TemplateService.php +++ b/backend/src/Service/Template/TemplateService.php @@ -59,7 +59,7 @@ public function readDefaultTemplate(): string public function updateTemplate(Template $template, UpdateTemplateDto $updates): Template { - if ($updates->hasProperty('template')) { + if ($updates->has('template')) { $template->setTemplate($updates->template); } diff --git a/backend/src/Util/OptionalPropertyTrait.php b/backend/src/Util/OptionalPropertyTrait.php index 491750410..3c39f721e 100644 --- a/backend/src/Util/OptionalPropertyTrait.php +++ b/backend/src/Util/OptionalPropertyTrait.php @@ -8,7 +8,7 @@ trait OptionalPropertyTrait /** * Checks if the property is INITIALIZED */ - public function hasProperty(string $property): bool + public function has(string $property): bool { try { $_ = $this->{$property}; @@ -18,4 +18,4 @@ public function hasProperty(string $property): bool } } -} \ No newline at end of file +} diff --git a/backend/tests/Api/Console/Subscriber/CreateSubscriberTest.php b/backend/tests/Api/Console/Subscriber/CreateSubscriberTest.php index 15d3d2ade..d47c2157f 100644 --- a/backend/tests/Api/Console/Subscriber/CreateSubscriberTest.php +++ b/backend/tests/Api/Console/Subscriber/CreateSubscriberTest.php @@ -24,7 +24,25 @@ class CreateSubscriberTest extends WebTestCase { - // TODO: tests for authentication + public function test_test(): void + { + $newsletter = NewsletterFactory::createOne(); + $list = NewsletterListFactory::createOne(['newsletter' => $newsletter]); + + $response = $this->consoleApi( + $newsletter, + 'POST', + '/subscribers', + [ + 'email' => 'test@email.com', + 'list_ids' => [$list->getId()], + 'subscribe_ip' => null, // '222.222.222.222' + ] + ); + + dd($response->getContent()); + + } public function testCreateSubscriberMinimal(): void { diff --git a/compose.yaml b/compose.yaml index 84fe2b975..50bb42c61 100644 --- a/compose.yaml +++ b/compose.yaml @@ -33,7 +33,7 @@ services: target: backend-dev volumes: - ./backend:/app/backend - # - ../internal:/app/backend/vendor/hyvor/internal:ro + - ../internal:/app/backend/vendor/hyvor/internal:ro - ./shared:/app/shared labels: traefik.enable: true From a398a90039941525a298dd8da2e39e1203462b57 Mon Sep 17 00:00:00 2001 From: Supun Wimalasena Date: Wed, 25 Feb 2026 15:38:13 +0100 Subject: [PATCH 02/43] side project: sentry config --- backend/composer.json | 3 +- backend/composer.lock | 1410 +++++++++++++++++---------- backend/config/bundles.php | 1 + backend/config/packages/monolog.php | 1 - backend/config/packages/sentry.yaml | 39 + backend/config/reference.php | 83 ++ backend/symfony.lock | 12 + 7 files changed, 1008 insertions(+), 541 deletions(-) create mode 100644 backend/config/packages/sentry.yaml diff --git a/backend/composer.json b/backend/composer.json index d680a9808..63bc366c8 100644 --- a/backend/composer.json +++ b/backend/composer.json @@ -45,7 +45,8 @@ "twig/cssinliner-extra": "^3.21", "twig/extra-bundle": "^3.21", "symfony/dom-crawler": "7.4.*", - "zenstruck/messenger-monitor-bundle": "^0.6.0" + "zenstruck/messenger-monitor-bundle": "^0.6.0", + "sentry/sentry-symfony": "^5.9" }, "bump-after-update": true, "sort-packages": true, diff --git a/backend/composer.lock b/backend/composer.lock index e1695f39a..19eb728c7 100644 --- a/backend/composer.lock +++ b/backend/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "8a847f5250165b8ec9279317ebe8516a", + "content-hash": "84ff26b07990da9b940ce6cb14f5bcb2", "packages": [ { "name": "aws/aws-crt-php", @@ -121,16 +121,16 @@ }, { "name": "aws/aws-sdk-php", - "version": "3.376.2", + "version": "3.369.29", "source": { "type": "git", "url": "https://github.com/aws/aws-sdk-php.git", - "reference": "ff2f61f280fc46081a1fbb7ae201a09f431d436d" + "reference": "068195b2980cf5cf4ade2515850d461186db3310" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/aws/aws-sdk-php/zipball/ff2f61f280fc46081a1fbb7ae201a09f431d436d", - "reference": "ff2f61f280fc46081a1fbb7ae201a09f431d436d", + "url": "https://api.github.com/repos/aws/aws-sdk-php/zipball/068195b2980cf5cf4ade2515850d461186db3310", + "reference": "068195b2980cf5cf4ade2515850d461186db3310", "shasum": "" }, "require": { @@ -151,12 +151,12 @@ "aws/aws-php-sns-message-validator": "~1.0", "behat/behat": "~3.0", "composer/composer": "^2.7.8", - "dms/phpunit-arraysubset-asserts": "^v0.5.0", + "dms/phpunit-arraysubset-asserts": "^0.4.0", "doctrine/cache": "~1.4", "ext-dom": "*", "ext-openssl": "*", "ext-sockets": "*", - "phpunit/phpunit": "^10.0", + "phpunit/phpunit": "^9.6", "psr/cache": "^2.0 || ^3.0", "psr/simple-cache": "^2.0 || ^3.0", "sebastian/comparator": "^1.2.3 || ^4.0 || ^5.0", @@ -194,11 +194,11 @@ "authors": [ { "name": "Amazon Web Services", - "homepage": "https://aws.amazon.com" + "homepage": "http://aws.amazon.com" } ], "description": "AWS SDK for PHP - Use Amazon Web Services in your PHP project", - "homepage": "https://aws.amazon.com/sdk-for-php", + "homepage": "http://aws.amazon.com/sdkforphp", "keywords": [ "amazon", "aws", @@ -212,9 +212,9 @@ "support": { "forum": "https://github.com/aws/aws-sdk-php/discussions", "issues": "https://github.com/aws/aws-sdk-php/issues", - "source": "https://github.com/aws/aws-sdk-php/tree/3.376.2" + "source": "https://github.com/aws/aws-sdk-php/tree/3.369.29" }, - "time": "2026-04-02T18:09:03+00:00" + "time": "2026-02-06T19:08:50+00:00" }, { "name": "carbonphp/carbon-doctrine-types", @@ -373,16 +373,16 @@ }, { "name": "doctrine/dbal", - "version": "3.10.5", + "version": "3.10.4", "source": { "type": "git", "url": "https://github.com/doctrine/dbal.git", - "reference": "95d84866bf3c04b2ddca1df7c049714660959aef" + "reference": "63a46cb5aa6f60991186cc98c1d1b50c09311868" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/doctrine/dbal/zipball/95d84866bf3c04b2ddca1df7c049714660959aef", - "reference": "95d84866bf3c04b2ddca1df7c049714660959aef", + "url": "https://api.github.com/repos/doctrine/dbal/zipball/63a46cb5aa6f60991186cc98c1d1b50c09311868", + "reference": "63a46cb5aa6f60991186cc98c1d1b50c09311868", "shasum": "" }, "require": { @@ -403,9 +403,9 @@ "jetbrains/phpstorm-stubs": "2023.1", "phpstan/phpstan": "2.1.30", "phpstan/phpstan-strict-rules": "^2", - "phpunit/phpunit": "9.6.34", - "slevomat/coding-standard": "8.27.1", - "squizlabs/php_codesniffer": "4.0.1", + "phpunit/phpunit": "9.6.29", + "slevomat/coding-standard": "8.24.0", + "squizlabs/php_codesniffer": "4.0.0", "symfony/cache": "^5.4|^6.0|^7.0|^8.0", "symfony/console": "^4.4|^5.4|^6.0|^7.0|^8.0" }, @@ -467,7 +467,7 @@ ], "support": { "issues": "https://github.com/doctrine/dbal/issues", - "source": "https://github.com/doctrine/dbal/tree/3.10.5" + "source": "https://github.com/doctrine/dbal/tree/3.10.4" }, "funding": [ { @@ -483,7 +483,7 @@ "type": "tidelift" } ], - "time": "2026-02-24T08:03:57+00:00" + "time": "2025-11-29T10:46:08+00:00" }, { "name": "doctrine/deprecations", @@ -1068,16 +1068,16 @@ }, { "name": "doctrine/migrations", - "version": "3.9.6", + "version": "3.9.5", "source": { "type": "git", "url": "https://github.com/doctrine/migrations.git", - "reference": "ffd8355cdd8505fc650d9604f058bf62aedd80a1" + "reference": "1b823afbc40f932dae8272574faee53f2755eac5" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/doctrine/migrations/zipball/ffd8355cdd8505fc650d9604f058bf62aedd80a1", - "reference": "ffd8355cdd8505fc650d9604f058bf62aedd80a1", + "url": "https://api.github.com/repos/doctrine/migrations/zipball/1b823afbc40f932dae8272574faee53f2755eac5", + "reference": "1b823afbc40f932dae8272574faee53f2755eac5", "shasum": "" }, "require": { @@ -1151,7 +1151,7 @@ ], "support": { "issues": "https://github.com/doctrine/migrations/issues", - "source": "https://github.com/doctrine/migrations/tree/3.9.6" + "source": "https://github.com/doctrine/migrations/tree/3.9.5" }, "funding": [ { @@ -1167,20 +1167,20 @@ "type": "tidelift" } ], - "time": "2026-02-11T06:46:11+00:00" + "time": "2025-11-20T11:15:36+00:00" }, { "name": "doctrine/orm", - "version": "3.6.3", + "version": "3.6.2", "source": { "type": "git", "url": "https://github.com/doctrine/orm.git", - "reference": "e88cd591f0786089dee22b972c28aa2076df51c0" + "reference": "4262eb495b4d2a53b45de1ac58881e0091f2970f" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/doctrine/orm/zipball/e88cd591f0786089dee22b972c28aa2076df51c0", - "reference": "e88cd591f0786089dee22b972c28aa2076df51c0", + "url": "https://api.github.com/repos/doctrine/orm/zipball/4262eb495b4d2a53b45de1ac58881e0091f2970f", + "reference": "4262eb495b4d2a53b45de1ac58881e0091f2970f", "shasum": "" }, "require": { @@ -1253,9 +1253,9 @@ ], "support": { "issues": "https://github.com/doctrine/orm/issues", - "source": "https://github.com/doctrine/orm/tree/3.6.3" + "source": "https://github.com/doctrine/orm/tree/3.6.2" }, - "time": "2026-04-02T06:53:27+00:00" + "time": "2026-01-30T21:41:41+00:00" }, { "name": "doctrine/persistence", @@ -1893,16 +1893,16 @@ }, { "name": "guzzlehttp/psr7", - "version": "2.9.0", + "version": "2.8.0", "source": { "type": "git", "url": "https://github.com/guzzle/psr7.git", - "reference": "7d0ed42f28e42d61352a7a79de682e5e67fec884" + "reference": "21dc724a0583619cd1652f673303492272778051" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/guzzle/psr7/zipball/7d0ed42f28e42d61352a7a79de682e5e67fec884", - "reference": "7d0ed42f28e42d61352a7a79de682e5e67fec884", + "url": "https://api.github.com/repos/guzzle/psr7/zipball/21dc724a0583619cd1652f673303492272778051", + "reference": "21dc724a0583619cd1652f673303492272778051", "shasum": "" }, "require": { @@ -1918,7 +1918,6 @@ "require-dev": { "bamarni/composer-bin-plugin": "^1.8.2", "http-interop/http-factory-tests": "0.9.0", - "jshttp/mime-db": "1.54.0.1", "phpunit/phpunit": "^8.5.44 || ^9.6.25" }, "suggest": { @@ -1990,7 +1989,7 @@ ], "support": { "issues": "https://github.com/guzzle/psr7/issues", - "source": "https://github.com/guzzle/psr7/tree/2.9.0" + "source": "https://github.com/guzzle/psr7/tree/2.8.0" }, "funding": [ { @@ -2006,20 +2005,20 @@ "type": "tidelift" } ], - "time": "2026-03-10T16:41:02+00:00" + "time": "2025-08-23T21:21:41+00:00" }, { "name": "hyvor/internal", - "version": "4.0.10", + "version": "4.0.3", "source": { "type": "git", "url": "https://github.com/hyvor/internal.git", - "reference": "804fb03e52fd8c7c70fee53c7965f0016cf15170" + "reference": "0af4fc193b282d9b880fde3330e1fd91b7aaf3b4" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/hyvor/internal/zipball/804fb03e52fd8c7c70fee53c7965f0016cf15170", - "reference": "804fb03e52fd8c7c70fee53c7965f0016cf15170", + "url": "https://api.github.com/repos/hyvor/internal/zipball/0af4fc193b282d9b880fde3330e1fd91b7aaf3b4", + "reference": "0af4fc193b282d9b880fde3330e1fd91b7aaf3b4", "shasum": "" }, "require": { @@ -2079,9 +2078,9 @@ "description": "Internal Package for HYVOR Applications", "support": { "issues": "https://github.com/hyvor/internal/issues", - "source": "https://github.com/hyvor/internal/tree/4.0.10" + "source": "https://github.com/hyvor/internal/tree/4.0.3" }, - "time": "2026-03-31T21:58:01+00:00" + "time": "2026-02-11T10:33:08+00:00" }, { "name": "hyvor/phrosemirror", @@ -2132,34 +2131,35 @@ }, { "name": "illuminate/collections", - "version": "v13.3.0", + "version": "v12.50.0", "source": { "type": "git", "url": "https://github.com/illuminate/collections.git", - "reference": "389c5008087f8c48d35b85585b4315107b5a0f9e" + "reference": "b4bbe2a929aaacf0ade3bec535f1f8fac6e6ed38" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/illuminate/collections/zipball/389c5008087f8c48d35b85585b4315107b5a0f9e", - "reference": "389c5008087f8c48d35b85585b4315107b5a0f9e", + "url": "https://api.github.com/repos/illuminate/collections/zipball/b4bbe2a929aaacf0ade3bec535f1f8fac6e6ed38", + "reference": "b4bbe2a929aaacf0ade3bec535f1f8fac6e6ed38", "shasum": "" }, "require": { - "illuminate/conditionable": "^13.0", - "illuminate/contracts": "^13.0", - "illuminate/macroable": "^13.0", - "php": "^8.3", + "illuminate/conditionable": "^12.0", + "illuminate/contracts": "^12.0", + "illuminate/macroable": "^12.0", + "php": "^8.2", + "symfony/polyfill-php83": "^1.33", "symfony/polyfill-php84": "^1.33", "symfony/polyfill-php85": "^1.33" }, "suggest": { - "illuminate/http": "Required to convert collections to API resources (^13.0).", - "symfony/var-dumper": "Required to use the dump method (^7.4 || ^8.0)." + "illuminate/http": "Required to convert collections to API resources (^12.0).", + "symfony/var-dumper": "Required to use the dump method (^7.2)." }, "type": "library", "extra": { "branch-alias": { - "dev-master": "13.0.x-dev" + "dev-master": "12.x-dev" } }, "autoload": { @@ -2187,29 +2187,29 @@ "issues": "https://github.com/laravel/framework/issues", "source": "https://github.com/laravel/framework" }, - "time": "2026-03-30T19:06:46+00:00" + "time": "2026-02-01T16:38:26+00:00" }, { "name": "illuminate/conditionable", - "version": "v13.3.0", + "version": "v12.50.0", "source": { "type": "git", "url": "https://github.com/illuminate/conditionable.git", - "reference": "7f1ef52d9a346f829421b296adfb7644a951b216" + "reference": "ec677967c1f2faf90b8428919124d2184a4c9b49" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/illuminate/conditionable/zipball/7f1ef52d9a346f829421b296adfb7644a951b216", - "reference": "7f1ef52d9a346f829421b296adfb7644a951b216", + "url": "https://api.github.com/repos/illuminate/conditionable/zipball/ec677967c1f2faf90b8428919124d2184a4c9b49", + "reference": "ec677967c1f2faf90b8428919124d2184a4c9b49", "shasum": "" }, "require": { - "php": "^8.3" + "php": "^8.2" }, "type": "library", "extra": { "branch-alias": { - "dev-master": "13.0.x-dev" + "dev-master": "12.x-dev" } }, "autoload": { @@ -2233,31 +2233,31 @@ "issues": "https://github.com/laravel/framework/issues", "source": "https://github.com/laravel/framework" }, - "time": "2026-02-25T16:07:55+00:00" + "time": "2025-05-13T15:08:45+00:00" }, { "name": "illuminate/contracts", - "version": "v13.3.0", + "version": "v12.50.0", "source": { "type": "git", "url": "https://github.com/illuminate/contracts.git", - "reference": "8796cc5f30124b81210ae2f3b2ae0f69ad4fc7f8" + "reference": "3d4eeab332c04a9eaea90968c19a66f78745e47a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/illuminate/contracts/zipball/8796cc5f30124b81210ae2f3b2ae0f69ad4fc7f8", - "reference": "8796cc5f30124b81210ae2f3b2ae0f69ad4fc7f8", + "url": "https://api.github.com/repos/illuminate/contracts/zipball/3d4eeab332c04a9eaea90968c19a66f78745e47a", + "reference": "3d4eeab332c04a9eaea90968c19a66f78745e47a", "shasum": "" }, "require": { - "php": "^8.3", - "psr/container": "^1.1.1 || ^2.0.1", - "psr/simple-cache": "^1.0 || ^2.0 || ^3.0" + "php": "^8.2", + "psr/container": "^1.1.1|^2.0.1", + "psr/simple-cache": "^1.0|^2.0|^3.0" }, "type": "library", "extra": { "branch-alias": { - "dev-master": "13.0.x-dev" + "dev-master": "12.x-dev" } }, "autoload": { @@ -2281,34 +2281,34 @@ "issues": "https://github.com/laravel/framework/issues", "source": "https://github.com/laravel/framework" }, - "time": "2026-03-26T17:13:01+00:00" + "time": "2026-01-28T15:26:27+00:00" }, { "name": "illuminate/encryption", - "version": "v13.3.0", + "version": "v12.50.0", "source": { "type": "git", "url": "https://github.com/illuminate/encryption.git", - "reference": "2b8900ba327a465f527c158fe1867ab8dbc40683" + "reference": "a12474102c6ca2e2c2ec4d612bc9fc0d2f0b9f86" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/illuminate/encryption/zipball/2b8900ba327a465f527c158fe1867ab8dbc40683", - "reference": "2b8900ba327a465f527c158fe1867ab8dbc40683", + "url": "https://api.github.com/repos/illuminate/encryption/zipball/a12474102c6ca2e2c2ec4d612bc9fc0d2f0b9f86", + "reference": "a12474102c6ca2e2c2ec4d612bc9fc0d2f0b9f86", "shasum": "" }, "require": { "ext-hash": "*", "ext-mbstring": "*", "ext-openssl": "*", - "illuminate/contracts": "^13.0", - "illuminate/support": "^13.0", - "php": "^8.3" + "illuminate/contracts": "^12.0", + "illuminate/support": "^12.0", + "php": "^8.2" }, "type": "library", "extra": { "branch-alias": { - "dev-master": "13.0.x-dev" + "dev-master": "12.x-dev" } }, "autoload": { @@ -2332,20 +2332,20 @@ "issues": "https://github.com/laravel/framework/issues", "source": "https://github.com/laravel/framework" }, - "time": "2026-02-25T16:07:55+00:00" + "time": "2026-01-05T21:11:57+00:00" }, { "name": "illuminate/macroable", - "version": "v13.3.0", + "version": "v12.50.0", "source": { "type": "git", "url": "https://github.com/illuminate/macroable.git", - "reference": "f108cb3a8680f26e23c6ce7367c64525412d85b0" + "reference": "e862e5648ee34004fa56046b746f490dfa86c613" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/illuminate/macroable/zipball/f108cb3a8680f26e23c6ce7367c64525412d85b0", - "reference": "f108cb3a8680f26e23c6ce7367c64525412d85b0", + "url": "https://api.github.com/repos/illuminate/macroable/zipball/e862e5648ee34004fa56046b746f490dfa86c613", + "reference": "e862e5648ee34004fa56046b746f490dfa86c613", "shasum": "" }, "require": { @@ -2354,7 +2354,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-master": "13.0.x-dev" + "dev-master": "12.x-dev" } }, "autoload": { @@ -2378,31 +2378,31 @@ "issues": "https://github.com/laravel/framework/issues", "source": "https://github.com/laravel/framework" }, - "time": "2026-03-28T19:16:13+00:00" + "time": "2024-07-23T16:31:01+00:00" }, { "name": "illuminate/reflection", - "version": "v13.3.0", + "version": "v12.50.0", "source": { "type": "git", "url": "https://github.com/illuminate/reflection.git", - "reference": "4fe1659f068ab2b50131cf906c5d8bba4e34df0c" + "reference": "6188e97a587371b9951c2a7e337cd760308c17d7" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/illuminate/reflection/zipball/4fe1659f068ab2b50131cf906c5d8bba4e34df0c", - "reference": "4fe1659f068ab2b50131cf906c5d8bba4e34df0c", + "url": "https://api.github.com/repos/illuminate/reflection/zipball/6188e97a587371b9951c2a7e337cd760308c17d7", + "reference": "6188e97a587371b9951c2a7e337cd760308c17d7", "shasum": "" }, "require": { - "illuminate/collections": "^13.0", - "illuminate/contracts": "^13.0", - "php": "^8.3" + "illuminate/collections": "^12.0", + "illuminate/contracts": "^12.0", + "php": "^8.2" }, "type": "library", "extra": { "branch-alias": { - "dev-master": "13.0.x-dev" + "dev-master": "12.x-dev" } }, "autoload": { @@ -2429,20 +2429,20 @@ "issues": "https://github.com/laravel/framework/issues", "source": "https://github.com/laravel/framework" }, - "time": "2026-03-10T20:04:12+00:00" + "time": "2026-02-04T15:21:22+00:00" }, { "name": "illuminate/support", - "version": "v13.3.0", + "version": "v12.50.0", "source": { "type": "git", "url": "https://github.com/illuminate/support.git", - "reference": "f31e168e236a90d96d7894cd1f107b1ba095de69" + "reference": "411a11401406e7d542aa67a4b400feed6bedef0c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/illuminate/support/zipball/f31e168e236a90d96d7894cd1f107b1ba095de69", - "reference": "f31e168e236a90d96d7894cd1f107b1ba095de69", + "url": "https://api.github.com/repos/illuminate/support/zipball/411a11401406e7d542aa67a4b400feed6bedef0c", + "reference": "411a11401406e7d542aa67a4b400feed6bedef0c", "shasum": "" }, "require": { @@ -2450,13 +2450,14 @@ "ext-ctype": "*", "ext-filter": "*", "ext-mbstring": "*", - "illuminate/collections": "^13.0", - "illuminate/conditionable": "^13.0", - "illuminate/contracts": "^13.0", - "illuminate/macroable": "^13.0", - "illuminate/reflection": "^13.0", + "illuminate/collections": "^12.0", + "illuminate/conditionable": "^12.0", + "illuminate/contracts": "^12.0", + "illuminate/macroable": "^12.0", + "illuminate/reflection": "^12.0", "nesbot/carbon": "^3.8.4", - "php": "^8.3", + "php": "^8.2", + "symfony/polyfill-php83": "^1.33", "symfony/polyfill-php85": "^1.33", "voku/portable-ascii": "^2.0.2" }, @@ -2467,20 +2468,20 @@ "spatie/once": "*" }, "suggest": { - "illuminate/filesystem": "Required to use the Composer class (^13.0).", - "laravel/serializable-closure": "Required to use the once function (^2.0.10).", + "illuminate/filesystem": "Required to use the Composer class (^12.0).", + "laravel/serializable-closure": "Required to use the once function (^1.3|^2.0).", "league/commonmark": "Required to use Str::markdown() and Stringable::markdown() (^2.7).", "league/uri": "Required to use the Uri class (^7.5.1).", "ramsey/uuid": "Required to use Str::uuid() (^4.7).", - "symfony/process": "Required to use the Composer class (^7.4 || ^8.0).", - "symfony/uid": "Required to use Str::ulid() (^7.4 || ^8.0).", - "symfony/var-dumper": "Required to use the dd function (^7.4 || ^8.0).", + "symfony/process": "Required to use the Composer class (^7.2).", + "symfony/uid": "Required to use Str::ulid() (^7.2).", + "symfony/var-dumper": "Required to use the dd function (^7.2).", "vlucas/phpdotenv": "Required to use the Env class and env helper (^5.6.1)." }, "type": "library", "extra": { "branch-alias": { - "dev-master": "13.0.x-dev" + "dev-master": "12.x-dev" } }, "autoload": { @@ -2508,20 +2509,80 @@ "issues": "https://github.com/laravel/framework/issues", "source": "https://github.com/laravel/framework" }, - "time": "2026-03-30T13:32:27+00:00" + "time": "2026-02-04T15:14:59+00:00" + }, + { + "name": "jean85/pretty-package-versions", + "version": "2.1.1", + "source": { + "type": "git", + "url": "https://github.com/Jean85/pretty-package-versions.git", + "reference": "4d7aa5dab42e2a76d99559706022885de0e18e1a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/Jean85/pretty-package-versions/zipball/4d7aa5dab42e2a76d99559706022885de0e18e1a", + "reference": "4d7aa5dab42e2a76d99559706022885de0e18e1a", + "shasum": "" + }, + "require": { + "composer-runtime-api": "^2.1.0", + "php": "^7.4|^8.0" + }, + "require-dev": { + "friendsofphp/php-cs-fixer": "^3.2", + "jean85/composer-provided-replaced-stub-package": "^1.0", + "phpstan/phpstan": "^2.0", + "phpunit/phpunit": "^7.5|^8.5|^9.6", + "rector/rector": "^2.0", + "vimeo/psalm": "^4.3 || ^5.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.x-dev" + } + }, + "autoload": { + "psr-4": { + "Jean85\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Alessandro Lai", + "email": "alessandro.lai85@gmail.com" + } + ], + "description": "A library to get pretty versions strings of installed dependencies", + "keywords": [ + "composer", + "package", + "release", + "versions" + ], + "support": { + "issues": "https://github.com/Jean85/pretty-package-versions/issues", + "source": "https://github.com/Jean85/pretty-package-versions/tree/2.1.1" + }, + "time": "2025-03-19T14:43:43+00:00" }, { "name": "league/flysystem", - "version": "3.33.0", + "version": "3.31.0", "source": { "type": "git", "url": "https://github.com/thephpleague/flysystem.git", - "reference": "570b8871e0ce693764434b29154c54b434905350" + "reference": "1717e0b3642b0df65ecb0cc89cdd99fa840672ff" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/thephpleague/flysystem/zipball/570b8871e0ce693764434b29154c54b434905350", - "reference": "570b8871e0ce693764434b29154c54b434905350", + "url": "https://api.github.com/repos/thephpleague/flysystem/zipball/1717e0b3642b0df65ecb0cc89cdd99fa840672ff", + "reference": "1717e0b3642b0df65ecb0cc89cdd99fa840672ff", "shasum": "" }, "require": { @@ -2589,22 +2650,22 @@ ], "support": { "issues": "https://github.com/thephpleague/flysystem/issues", - "source": "https://github.com/thephpleague/flysystem/tree/3.33.0" + "source": "https://github.com/thephpleague/flysystem/tree/3.31.0" }, - "time": "2026-03-25T07:59:30+00:00" + "time": "2026-01-23T15:38:47+00:00" }, { "name": "league/flysystem-aws-s3-v3", - "version": "3.32.0", + "version": "3.31.0", "source": { "type": "git", "url": "https://github.com/thephpleague/flysystem-aws-s3-v3.git", - "reference": "a1979df7c9784d334ea6df356aed3d18ac6673d0" + "reference": "e36a2bc60b06332c92e4435047797ded352b446f" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/thephpleague/flysystem-aws-s3-v3/zipball/a1979df7c9784d334ea6df356aed3d18ac6673d0", - "reference": "a1979df7c9784d334ea6df356aed3d18ac6673d0", + "url": "https://api.github.com/repos/thephpleague/flysystem-aws-s3-v3/zipball/e36a2bc60b06332c92e4435047797ded352b446f", + "reference": "e36a2bc60b06332c92e4435047797ded352b446f", "shasum": "" }, "require": { @@ -2644,9 +2705,9 @@ "storage" ], "support": { - "source": "https://github.com/thephpleague/flysystem-aws-s3-v3/tree/3.32.0" + "source": "https://github.com/thephpleague/flysystem-aws-s3-v3/tree/3.31.0" }, - "time": "2026-02-25T16:46:44+00:00" + "time": "2026-01-23T15:30:45+00:00" }, { "name": "league/flysystem-local", @@ -3116,16 +3177,16 @@ }, { "name": "nesbot/carbon", - "version": "3.11.3", + "version": "3.11.1", "source": { "type": "git", "url": "https://github.com/CarbonPHP/carbon.git", - "reference": "6a7e652845bb018c668220c2a545aded8594fbbf" + "reference": "f438fcc98f92babee98381d399c65336f3a3827f" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/CarbonPHP/carbon/zipball/6a7e652845bb018c668220c2a545aded8594fbbf", - "reference": "6a7e652845bb018c668220c2a545aded8594fbbf", + "url": "https://api.github.com/repos/CarbonPHP/carbon/zipball/f438fcc98f92babee98381d399c65336f3a3827f", + "reference": "f438fcc98f92babee98381d399c65336f3a3827f", "shasum": "" }, "require": { @@ -3217,7 +3278,7 @@ "type": "tidelift" } ], - "time": "2026-03-11T17:23:39+00:00" + "time": "2026-01-29T09:26:29+00:00" }, { "name": "phpdocumentor/reflection-common", @@ -3274,16 +3335,16 @@ }, { "name": "phpdocumentor/reflection-docblock", - "version": "6.0.3", + "version": "5.6.6", "source": { "type": "git", "url": "https://github.com/phpDocumentor/ReflectionDocBlock.git", - "reference": "7bae67520aa9f5ecc506d646810bd40d9da54582" + "reference": "5cee1d3dfc2d2aa6599834520911d246f656bcb8" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpDocumentor/ReflectionDocBlock/zipball/7bae67520aa9f5ecc506d646810bd40d9da54582", - "reference": "7bae67520aa9f5ecc506d646810bd40d9da54582", + "url": "https://api.github.com/repos/phpDocumentor/ReflectionDocBlock/zipball/5cee1d3dfc2d2aa6599834520911d246f656bcb8", + "reference": "5cee1d3dfc2d2aa6599834520911d246f656bcb8", "shasum": "" }, "require": { @@ -3291,8 +3352,8 @@ "ext-filter": "*", "php": "^7.4 || ^8.0", "phpdocumentor/reflection-common": "^2.2", - "phpdocumentor/type-resolver": "^2.0", - "phpstan/phpdoc-parser": "^2.0", + "phpdocumentor/type-resolver": "^1.7", + "phpstan/phpdoc-parser": "^1.7|^2.0", "webmozart/assert": "^1.9.1 || ^2" }, "require-dev": { @@ -3302,8 +3363,7 @@ "phpstan/phpstan-mockery": "^1.1", "phpstan/phpstan-webmozart-assert": "^1.2", "phpunit/phpunit": "^9.5", - "psalm/phar": "^5.26", - "shipmonk/dead-code-detector": "^0.5.1" + "psalm/phar": "^5.26" }, "type": "library", "extra": { @@ -3333,44 +3393,44 @@ "description": "With this component, a library can provide support for annotations via DocBlocks or otherwise retrieve information that is embedded in a DocBlock.", "support": { "issues": "https://github.com/phpDocumentor/ReflectionDocBlock/issues", - "source": "https://github.com/phpDocumentor/ReflectionDocBlock/tree/6.0.3" + "source": "https://github.com/phpDocumentor/ReflectionDocBlock/tree/5.6.6" }, - "time": "2026-03-18T20:49:53+00:00" + "time": "2025-12-22T21:13:58+00:00" }, { "name": "phpdocumentor/type-resolver", - "version": "2.0.0", + "version": "1.12.0", "source": { "type": "git", "url": "https://github.com/phpDocumentor/TypeResolver.git", - "reference": "327a05bbee54120d4786a0dc67aad30226ad4cf9" + "reference": "92a98ada2b93d9b201a613cb5a33584dde25f195" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpDocumentor/TypeResolver/zipball/327a05bbee54120d4786a0dc67aad30226ad4cf9", - "reference": "327a05bbee54120d4786a0dc67aad30226ad4cf9", + "url": "https://api.github.com/repos/phpDocumentor/TypeResolver/zipball/92a98ada2b93d9b201a613cb5a33584dde25f195", + "reference": "92a98ada2b93d9b201a613cb5a33584dde25f195", "shasum": "" }, "require": { "doctrine/deprecations": "^1.0", - "php": "^7.4 || ^8.0", + "php": "^7.3 || ^8.0", "phpdocumentor/reflection-common": "^2.0", - "phpstan/phpdoc-parser": "^2.0" + "phpstan/phpdoc-parser": "^1.18|^2.0" }, "require-dev": { "ext-tokenizer": "*", "phpbench/phpbench": "^1.2", - "phpstan/extension-installer": "^1.4", - "phpstan/phpstan": "^2.1", - "phpstan/phpstan-phpunit": "^2.0", + "phpstan/extension-installer": "^1.1", + "phpstan/phpstan": "^1.8", + "phpstan/phpstan-phpunit": "^1.1", "phpunit/phpunit": "^9.5", - "psalm/phar": "^4" + "rector/rector": "^0.13.9", + "vimeo/psalm": "^4.25" }, "type": "library", "extra": { "branch-alias": { - "dev-1.x": "1.x-dev", - "dev-2.x": "2.x-dev" + "dev-1.x": "1.x-dev" } }, "autoload": { @@ -3391,9 +3451,9 @@ "description": "A PSR-5 based resolver of Class names, Types and Structural Element Names", "support": { "issues": "https://github.com/phpDocumentor/TypeResolver/issues", - "source": "https://github.com/phpDocumentor/TypeResolver/tree/2.0.0" + "source": "https://github.com/phpDocumentor/TypeResolver/tree/1.12.0" }, - "time": "2026-01-06T21:53:42+00:00" + "time": "2025-11-21T15:09:14+00:00" }, { "name": "phpstan/phpdoc-parser", @@ -3444,21 +3504,21 @@ }, { "name": "promphp/prometheus_client_php", - "version": "v2.15.0", + "version": "v2.14.1", "source": { "type": "git", "url": "https://github.com/PromPHP/prometheus_client_php.git", - "reference": "da86f1507b04dc44dc37ffb766d7d3a1d42c3050" + "reference": "a283aea8269287dc35313a0055480d950c59ac1f" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/PromPHP/prometheus_client_php/zipball/da86f1507b04dc44dc37ffb766d7d3a1d42c3050", - "reference": "da86f1507b04dc44dc37ffb766d7d3a1d42c3050", + "url": "https://api.github.com/repos/PromPHP/prometheus_client_php/zipball/a283aea8269287dc35313a0055480d950c59ac1f", + "reference": "a283aea8269287dc35313a0055480d950c59ac1f", "shasum": "" }, "require": { "ext-json": "*", - "php": "^8.2" + "php": "^7.4|^8.0" }, "replace": { "endclothing/prometheus_client_php": "*", @@ -3472,7 +3532,6 @@ "phpstan/phpstan-phpunit": "^1.1.0", "phpstan/phpstan-strict-rules": "^1.1.0", "phpunit/phpunit": "^9.4", - "predis/predis": "^2.3", "squizlabs/php_codesniffer": "^3.6", "symfony/polyfill-apcu": "^1.6" }, @@ -3480,9 +3539,8 @@ "ext-apc": "Required if using APCu.", "ext-pdo": "Required if using PDO.", "ext-redis": "Required if using Redis.", - "predis/predis": "Required if using Predis.", "promphp/prometheus_push_gateway_php": "An easy client for using Prometheus PushGateway.", - "symfony/polyfill-apcu": "Required if you use APCu." + "symfony/polyfill-apcu": "Required if you use APCu on PHP8.0+" }, "type": "library", "extra": { @@ -3508,9 +3566,9 @@ "description": "Prometheus instrumentation library for PHP applications.", "support": { "issues": "https://github.com/PromPHP/prometheus_client_php/issues", - "source": "https://github.com/PromPHP/prometheus_client_php/tree/v2.15.0" + "source": "https://github.com/PromPHP/prometheus_client_php/tree/v2.14.1" }, - "time": "2026-03-24T22:44:50+00:00" + "time": "2025-04-14T07:59:43+00:00" }, { "name": "psr/cache", @@ -4069,18 +4127,211 @@ ], "time": "2023-12-12T12:06:11+00:00" }, + { + "name": "sentry/sentry", + "version": "4.21.0", + "source": { + "type": "git", + "url": "https://github.com/getsentry/sentry-php.git", + "reference": "2bf405fc4d38f00073a7d023cf321e59f614d54c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/getsentry/sentry-php/zipball/2bf405fc4d38f00073a7d023cf321e59f614d54c", + "reference": "2bf405fc4d38f00073a7d023cf321e59f614d54c", + "shasum": "" + }, + "require": { + "ext-curl": "*", + "ext-json": "*", + "ext-mbstring": "*", + "guzzlehttp/psr7": "^1.8.4|^2.1.1", + "jean85/pretty-package-versions": "^1.5|^2.0.4", + "php": "^7.2|^8.0", + "psr/log": "^1.0|^2.0|^3.0", + "symfony/options-resolver": "^4.4.30|^5.0.11|^6.0|^7.0|^8.0" + }, + "conflict": { + "raven/raven": "*" + }, + "require-dev": { + "friendsofphp/php-cs-fixer": "^3.4", + "guzzlehttp/promises": "^2.0.3", + "guzzlehttp/psr7": "^1.8.4|^2.1.1", + "monolog/monolog": "^1.6|^2.0|^3.0", + "nyholm/psr7": "^1.8", + "phpbench/phpbench": "^1.0", + "phpstan/phpstan": "^1.3", + "phpunit/phpunit": "^8.5.52|^9.6.34", + "spiral/roadrunner-http": "^3.6", + "spiral/roadrunner-worker": "^3.6", + "vimeo/psalm": "^4.17" + }, + "suggest": { + "monolog/monolog": "Allow sending log messages to Sentry by using the included Monolog handler." + }, + "type": "library", + "autoload": { + "files": [ + "src/functions.php" + ], + "psr-4": { + "Sentry\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Sentry", + "email": "accounts@sentry.io" + } + ], + "description": "PHP SDK for Sentry (http://sentry.io)", + "homepage": "http://sentry.io", + "keywords": [ + "crash-reporting", + "crash-reports", + "error-handler", + "error-monitoring", + "log", + "logging", + "profiling", + "sentry", + "tracing" + ], + "support": { + "issues": "https://github.com/getsentry/sentry-php/issues", + "source": "https://github.com/getsentry/sentry-php/tree/4.21.0" + }, + "funding": [ + { + "url": "https://sentry.io/", + "type": "custom" + }, + { + "url": "https://sentry.io/pricing/", + "type": "custom" + } + ], + "time": "2026-02-24T15:32:51+00:00" + }, + { + "name": "sentry/sentry-symfony", + "version": "5.9.0", + "source": { + "type": "git", + "url": "https://github.com/getsentry/sentry-symfony.git", + "reference": "75a73de23b9af414b3c8b15c26187a4ae6c65732" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/getsentry/sentry-symfony/zipball/75a73de23b9af414b3c8b15c26187a4ae6c65732", + "reference": "75a73de23b9af414b3c8b15c26187a4ae6c65732", + "shasum": "" + }, + "require": { + "guzzlehttp/psr7": "^2.1.1", + "jean85/pretty-package-versions": "^1.5||^2.0", + "php": "^7.2||^8.0", + "sentry/sentry": "^4.20.0", + "symfony/cache-contracts": "^1.1||^2.4||^3.0", + "symfony/config": "^4.4.20||^5.0.11||^6.0||^7.0||^8.0", + "symfony/console": "^4.4.20||^5.0.11||^6.0||^7.0||^8.0", + "symfony/dependency-injection": "^4.4.20||^5.0.11||^6.0||^7.0||^8.0", + "symfony/event-dispatcher": "^4.4.20||^5.0.11||^6.0||^7.0||^8.0", + "symfony/http-kernel": "^4.4.20||^5.0.11||^6.0||^7.0||^8.0", + "symfony/polyfill-php80": "^1.22", + "symfony/psr-http-message-bridge": "^1.2||^2.0||^6.4||^7.0||^8.0", + "symfony/yaml": "^4.4.20||^5.0.11||^6.0||^7.0||^8.0" + }, + "require-dev": { + "doctrine/dbal": "^2.13||^3.3||^4.0", + "doctrine/doctrine-bundle": "^2.6||^3.0", + "friendsofphp/php-cs-fixer": "^2.19||^3.40", + "masterminds/html5": "^2.8", + "phpstan/extension-installer": "^1.0", + "phpstan/phpstan": "1.12.5", + "phpstan/phpstan-phpunit": "1.4.0", + "phpstan/phpstan-symfony": "1.4.10", + "phpunit/phpunit": "^8.5.40||^9.6.21", + "symfony/browser-kit": "^4.4.20||^5.0.11||^6.0||^7.0||^8.0", + "symfony/cache": "^4.4.20||^5.0.11||^6.0||^7.0||^8.0", + "symfony/dom-crawler": "^4.4.20||^5.0.11||^6.0||^7.0||^8.0", + "symfony/framework-bundle": "^4.4.20||^5.0.11||^6.0||^7.0||^8.0", + "symfony/http-client": "^4.4.20||^5.0.11||^6.0||^7.0||^8.0", + "symfony/messenger": "^4.4.20||^5.0.11||^6.0||^7.0||^8.0", + "symfony/monolog-bundle": "^3.4||^4.0", + "symfony/phpunit-bridge": "^5.2.6||^6.0||^7.0||^8.0", + "symfony/process": "^4.4.20||^5.0.11||^6.0||^7.0||^8.0", + "symfony/security-core": "^4.4.20||^5.0.11||^6.0||^7.0||^8.0", + "symfony/security-http": "^4.4.20||^5.0.11||^6.0||^7.0||^8.0", + "symfony/twig-bundle": "^4.4.20||^5.0.11||^6.0||^7.0||^8.0", + "vimeo/psalm": "^4.3||^5.16.0" + }, + "suggest": { + "doctrine/doctrine-bundle": "Allow distributed tracing of database queries using Sentry.", + "monolog/monolog": "Allow sending log messages to Sentry by using the included Monolog handler.", + "symfony/cache": "Allow distributed tracing of cache pools using Sentry.", + "symfony/twig-bundle": "Allow distributed tracing of Twig template rendering using Sentry." + }, + "type": "symfony-bundle", + "autoload": { + "files": [ + "src/aliases.php" + ], + "psr-4": { + "Sentry\\SentryBundle\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Sentry", + "email": "accounts@sentry.io" + } + ], + "description": "Symfony integration for Sentry (http://getsentry.com)", + "homepage": "http://getsentry.com", + "keywords": [ + "errors", + "logging", + "sentry", + "symfony" + ], + "support": { + "issues": "https://github.com/getsentry/sentry-symfony/issues", + "source": "https://github.com/getsentry/sentry-symfony/tree/5.9.0" + }, + "funding": [ + { + "url": "https://sentry.io/", + "type": "custom" + }, + { + "url": "https://sentry.io/pricing/", + "type": "custom" + } + ], + "time": "2026-02-23T12:32:36+00:00" + }, { "name": "symfony/cache", - "version": "v7.4.8", + "version": "v7.4.5", "source": { "type": "git", "url": "https://github.com/symfony/cache.git", - "reference": "467464da294734b0fb17e853e5712abc8470f819" + "reference": "8dde98d5a4123b53877aca493f9be57b333f14bd" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/cache/zipball/467464da294734b0fb17e853e5712abc8470f819", - "reference": "467464da294734b0fb17e853e5712abc8470f819", + "url": "https://api.github.com/repos/symfony/cache/zipball/8dde98d5a4123b53877aca493f9be57b333f14bd", + "reference": "8dde98d5a4123b53877aca493f9be57b333f14bd", "shasum": "" }, "require": { @@ -4151,7 +4402,7 @@ "psr6" ], "support": { - "source": "https://github.com/symfony/cache/tree/v7.4.8" + "source": "https://github.com/symfony/cache/tree/v7.4.5" }, "funding": [ { @@ -4171,7 +4422,7 @@ "type": "tidelift" } ], - "time": "2026-03-30T15:15:47+00:00" + "time": "2026-01-27T16:16:02+00:00" }, { "name": "symfony/cache-contracts", @@ -4251,16 +4502,16 @@ }, { "name": "symfony/clock", - "version": "v7.4.8", + "version": "v7.4.0", "source": { "type": "git", "url": "https://github.com/symfony/clock.git", - "reference": "674fa3b98e21531dd040e613479f5f6fa8f32111" + "reference": "9169f24776edde469914c1e7a1442a50f7a4e110" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/clock/zipball/674fa3b98e21531dd040e613479f5f6fa8f32111", - "reference": "674fa3b98e21531dd040e613479f5f6fa8f32111", + "url": "https://api.github.com/repos/symfony/clock/zipball/9169f24776edde469914c1e7a1442a50f7a4e110", + "reference": "9169f24776edde469914c1e7a1442a50f7a4e110", "shasum": "" }, "require": { @@ -4305,7 +4556,7 @@ "time" ], "support": { - "source": "https://github.com/symfony/clock/tree/v7.4.8" + "source": "https://github.com/symfony/clock/tree/v7.4.0" }, "funding": [ { @@ -4325,20 +4576,20 @@ "type": "tidelift" } ], - "time": "2026-03-24T13:12:05+00:00" + "time": "2025-11-12T15:39:26+00:00" }, { "name": "symfony/config", - "version": "v7.4.8", + "version": "v7.4.4", "source": { "type": "git", "url": "https://github.com/symfony/config.git", - "reference": "2d19dde43fa2ff720b9a40763ace7226594f503b" + "reference": "4275b53b8ab0cf37f48bf273dc2285c8178efdfb" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/config/zipball/2d19dde43fa2ff720b9a40763ace7226594f503b", - "reference": "2d19dde43fa2ff720b9a40763ace7226594f503b", + "url": "https://api.github.com/repos/symfony/config/zipball/4275b53b8ab0cf37f48bf273dc2285c8178efdfb", + "reference": "4275b53b8ab0cf37f48bf273dc2285c8178efdfb", "shasum": "" }, "require": { @@ -4384,7 +4635,7 @@ "description": "Helps you find, load, combine, autofill and validate configuration values of any kind", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/config/tree/v7.4.8" + "source": "https://github.com/symfony/config/tree/v7.4.4" }, "funding": [ { @@ -4404,20 +4655,20 @@ "type": "tidelift" } ], - "time": "2026-03-24T13:12:05+00:00" + "time": "2026-01-13T11:36:38+00:00" }, { "name": "symfony/console", - "version": "v7.4.8", + "version": "v7.4.4", "source": { "type": "git", "url": "https://github.com/symfony/console.git", - "reference": "1e92e39c51f95b88e3d66fa2d9f06d1fb45dd707" + "reference": "41e38717ac1dd7a46b6bda7d6a82af2d98a78894" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/console/zipball/1e92e39c51f95b88e3d66fa2d9f06d1fb45dd707", - "reference": "1e92e39c51f95b88e3d66fa2d9f06d1fb45dd707", + "url": "https://api.github.com/repos/symfony/console/zipball/41e38717ac1dd7a46b6bda7d6a82af2d98a78894", + "reference": "41e38717ac1dd7a46b6bda7d6a82af2d98a78894", "shasum": "" }, "require": { @@ -4482,7 +4733,7 @@ "terminal" ], "support": { - "source": "https://github.com/symfony/console/tree/v7.4.8" + "source": "https://github.com/symfony/console/tree/v7.4.4" }, "funding": [ { @@ -4502,20 +4753,20 @@ "type": "tidelift" } ], - "time": "2026-03-30T13:54:39+00:00" + "time": "2026-01-13T11:36:38+00:00" }, { "name": "symfony/css-selector", - "version": "v7.4.8", + "version": "v7.4.0", "source": { "type": "git", "url": "https://github.com/symfony/css-selector.git", - "reference": "b055f228a4178a1d6774909903905e3475f3eac8" + "reference": "ab862f478513e7ca2fe9ec117a6f01a8da6e1135" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/css-selector/zipball/b055f228a4178a1d6774909903905e3475f3eac8", - "reference": "b055f228a4178a1d6774909903905e3475f3eac8", + "url": "https://api.github.com/repos/symfony/css-selector/zipball/ab862f478513e7ca2fe9ec117a6f01a8da6e1135", + "reference": "ab862f478513e7ca2fe9ec117a6f01a8da6e1135", "shasum": "" }, "require": { @@ -4551,7 +4802,7 @@ "description": "Converts CSS selectors to XPath expressions", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/css-selector/tree/v7.4.8" + "source": "https://github.com/symfony/css-selector/tree/v7.4.0" }, "funding": [ { @@ -4571,20 +4822,20 @@ "type": "tidelift" } ], - "time": "2026-03-24T13:12:05+00:00" + "time": "2025-10-30T13:39:42+00:00" }, { "name": "symfony/dependency-injection", - "version": "v7.4.8", + "version": "v7.4.5", "source": { "type": "git", "url": "https://github.com/symfony/dependency-injection.git", - "reference": "f7025fd7b687c240426562f86ada06a93b1e771d" + "reference": "76a02cddca45a5254479ad68f9fa274ead0a7ef2" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/dependency-injection/zipball/f7025fd7b687c240426562f86ada06a93b1e771d", - "reference": "f7025fd7b687c240426562f86ada06a93b1e771d", + "url": "https://api.github.com/repos/symfony/dependency-injection/zipball/76a02cddca45a5254479ad68f9fa274ead0a7ef2", + "reference": "76a02cddca45a5254479ad68f9fa274ead0a7ef2", "shasum": "" }, "require": { @@ -4635,7 +4886,7 @@ "description": "Allows you to standardize and centralize the way objects are constructed in your application", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/dependency-injection/tree/v7.4.8" + "source": "https://github.com/symfony/dependency-injection/tree/v7.4.5" }, "funding": [ { @@ -4655,7 +4906,7 @@ "type": "tidelift" } ], - "time": "2026-03-31T06:50:29+00:00" + "time": "2026-01-27T16:16:02+00:00" }, { "name": "symfony/deprecation-contracts", @@ -4726,16 +4977,16 @@ }, { "name": "symfony/doctrine-bridge", - "version": "v7.4.8", + "version": "v7.4.4", "source": { "type": "git", "url": "https://github.com/symfony/doctrine-bridge.git", - "reference": "3f8f805e54ecb5cbd487b1eff8837a8bbd278669" + "reference": "3408d9fb7bda6c8db9f3e4099863c9017bcbc62d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/doctrine-bridge/zipball/3f8f805e54ecb5cbd487b1eff8837a8bbd278669", - "reference": "3f8f805e54ecb5cbd487b1eff8837a8bbd278669", + "url": "https://api.github.com/repos/symfony/doctrine-bridge/zipball/3408d9fb7bda6c8db9f3e4099863c9017bcbc62d", + "reference": "3408d9fb7bda6c8db9f3e4099863c9017bcbc62d", "shasum": "" }, "require": { @@ -4815,7 +5066,7 @@ "description": "Provides integration for Doctrine with various Symfony components", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/doctrine-bridge/tree/v7.4.8" + "source": "https://github.com/symfony/doctrine-bridge/tree/v7.4.4" }, "funding": [ { @@ -4835,20 +5086,20 @@ "type": "tidelift" } ], - "time": "2026-03-24T13:12:05+00:00" + "time": "2026-01-20T16:42:42+00:00" }, { "name": "symfony/doctrine-messenger", - "version": "v7.4.6", + "version": "v7.4.5", "source": { "type": "git", "url": "https://github.com/symfony/doctrine-messenger.git", - "reference": "a429cd95983eaea2371ea279bed3b8a93b9ecdd3" + "reference": "d2d638e56c51452bcb4a9b9eddb14e2a21c572e2" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/doctrine-messenger/zipball/a429cd95983eaea2371ea279bed3b8a93b9ecdd3", - "reference": "a429cd95983eaea2371ea279bed3b8a93b9ecdd3", + "url": "https://api.github.com/repos/symfony/doctrine-messenger/zipball/d2d638e56c51452bcb4a9b9eddb14e2a21c572e2", + "reference": "d2d638e56c51452bcb4a9b9eddb14e2a21c572e2", "shasum": "" }, "require": { @@ -4891,7 +5142,7 @@ "description": "Symfony Doctrine Messenger Bridge", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/doctrine-messenger/tree/v7.4.6" + "source": "https://github.com/symfony/doctrine-messenger/tree/v7.4.5" }, "funding": [ { @@ -4911,20 +5162,20 @@ "type": "tidelift" } ], - "time": "2026-02-18T09:40:04+00:00" + "time": "2026-01-27T16:16:02+00:00" }, { "name": "symfony/dom-crawler", - "version": "v7.4.8", + "version": "v7.4.4", "source": { "type": "git", "url": "https://github.com/symfony/dom-crawler.git", - "reference": "2918e7c2ba964defca1f5b69c6f74886529e2dc8" + "reference": "71fd6a82fc357c8b5de22f78b228acfc43dee965" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/dom-crawler/zipball/2918e7c2ba964defca1f5b69c6f74886529e2dc8", - "reference": "2918e7c2ba964defca1f5b69c6f74886529e2dc8", + "url": "https://api.github.com/repos/symfony/dom-crawler/zipball/71fd6a82fc357c8b5de22f78b228acfc43dee965", + "reference": "71fd6a82fc357c8b5de22f78b228acfc43dee965", "shasum": "" }, "require": { @@ -4963,7 +5214,7 @@ "description": "Eases DOM navigation for HTML and XML documents", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/dom-crawler/tree/v7.4.8" + "source": "https://github.com/symfony/dom-crawler/tree/v7.4.4" }, "funding": [ { @@ -4983,20 +5234,20 @@ "type": "tidelift" } ], - "time": "2026-03-24T13:12:05+00:00" + "time": "2026-01-05T08:47:25+00:00" }, { "name": "symfony/dotenv", - "version": "v7.4.8", + "version": "v7.4.0", "source": { "type": "git", "url": "https://github.com/symfony/dotenv.git", - "reference": "5df79f11350166125fe754c85b87f7e13d735314" + "reference": "1658a4d34df028f3d93bcdd8e81f04423925a364" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/dotenv/zipball/5df79f11350166125fe754c85b87f7e13d735314", - "reference": "5df79f11350166125fe754c85b87f7e13d735314", + "url": "https://api.github.com/repos/symfony/dotenv/zipball/1658a4d34df028f3d93bcdd8e81f04423925a364", + "reference": "1658a4d34df028f3d93bcdd8e81f04423925a364", "shasum": "" }, "require": { @@ -5041,7 +5292,7 @@ "environment" ], "support": { - "source": "https://github.com/symfony/dotenv/tree/v7.4.8" + "source": "https://github.com/symfony/dotenv/tree/v7.4.0" }, "funding": [ { @@ -5061,20 +5312,20 @@ "type": "tidelift" } ], - "time": "2026-03-30T12:55:43+00:00" + "time": "2025-11-16T10:14:42+00:00" }, { "name": "symfony/error-handler", - "version": "v7.4.8", + "version": "v7.4.4", "source": { "type": "git", "url": "https://github.com/symfony/error-handler.git", - "reference": "8dd79d8af777ee6cba2fd4d98da6ffb839f3c0fa" + "reference": "8da531f364ddfee53e36092a7eebbbd0b775f6b8" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/error-handler/zipball/8dd79d8af777ee6cba2fd4d98da6ffb839f3c0fa", - "reference": "8dd79d8af777ee6cba2fd4d98da6ffb839f3c0fa", + "url": "https://api.github.com/repos/symfony/error-handler/zipball/8da531f364ddfee53e36092a7eebbbd0b775f6b8", + "reference": "8da531f364ddfee53e36092a7eebbbd0b775f6b8", "shasum": "" }, "require": { @@ -5123,7 +5374,7 @@ "description": "Provides tools to manage errors and ease debugging PHP code", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/error-handler/tree/v7.4.8" + "source": "https://github.com/symfony/error-handler/tree/v7.4.4" }, "funding": [ { @@ -5143,20 +5394,20 @@ "type": "tidelift" } ], - "time": "2026-03-24T13:12:05+00:00" + "time": "2026-01-20T16:42:42+00:00" }, { "name": "symfony/event-dispatcher", - "version": "v7.4.8", + "version": "v7.4.4", "source": { "type": "git", "url": "https://github.com/symfony/event-dispatcher.git", - "reference": "f57b899fa736fd71121168ef268f23c206083f0a" + "reference": "dc2c0eba1af673e736bb851d747d266108aea746" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/event-dispatcher/zipball/f57b899fa736fd71121168ef268f23c206083f0a", - "reference": "f57b899fa736fd71121168ef268f23c206083f0a", + "url": "https://api.github.com/repos/symfony/event-dispatcher/zipball/dc2c0eba1af673e736bb851d747d266108aea746", + "reference": "dc2c0eba1af673e736bb851d747d266108aea746", "shasum": "" }, "require": { @@ -5208,7 +5459,7 @@ "description": "Provides tools that allow your application components to communicate with each other by dispatching events and listening to them", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/event-dispatcher/tree/v7.4.8" + "source": "https://github.com/symfony/event-dispatcher/tree/v7.4.4" }, "funding": [ { @@ -5228,7 +5479,7 @@ "type": "tidelift" } ], - "time": "2026-03-30T13:54:39+00:00" + "time": "2026-01-05T11:45:34+00:00" }, { "name": "symfony/event-dispatcher-contracts", @@ -5308,16 +5559,16 @@ }, { "name": "symfony/expression-language", - "version": "v7.4.8", + "version": "v7.4.4", "source": { "type": "git", "url": "https://github.com/symfony/expression-language.git", - "reference": "87ff95687748f4af65e4d5a6e917d448ec52aa83" + "reference": "f3a6497eb6573e185f2ec41cd3b3f0cd68ddf667" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/expression-language/zipball/87ff95687748f4af65e4d5a6e917d448ec52aa83", - "reference": "87ff95687748f4af65e4d5a6e917d448ec52aa83", + "url": "https://api.github.com/repos/symfony/expression-language/zipball/f3a6497eb6573e185f2ec41cd3b3f0cd68ddf667", + "reference": "f3a6497eb6573e185f2ec41cd3b3f0cd68ddf667", "shasum": "" }, "require": { @@ -5352,7 +5603,7 @@ "description": "Provides an engine that can compile and evaluate expressions", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/expression-language/tree/v7.4.8" + "source": "https://github.com/symfony/expression-language/tree/v7.4.4" }, "funding": [ { @@ -5372,20 +5623,20 @@ "type": "tidelift" } ], - "time": "2026-03-24T13:12:05+00:00" + "time": "2026-01-05T08:47:25+00:00" }, { "name": "symfony/filesystem", - "version": "v7.4.8", + "version": "v7.4.0", "source": { "type": "git", "url": "https://github.com/symfony/filesystem.git", - "reference": "58b9790d12f9670b7f53a1c1738febd3108970a5" + "reference": "d551b38811096d0be9c4691d406991b47c0c630a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/filesystem/zipball/58b9790d12f9670b7f53a1c1738febd3108970a5", - "reference": "58b9790d12f9670b7f53a1c1738febd3108970a5", + "url": "https://api.github.com/repos/symfony/filesystem/zipball/d551b38811096d0be9c4691d406991b47c0c630a", + "reference": "d551b38811096d0be9c4691d406991b47c0c630a", "shasum": "" }, "require": { @@ -5422,7 +5673,7 @@ "description": "Provides basic utilities for the filesystem", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/filesystem/tree/v7.4.8" + "source": "https://github.com/symfony/filesystem/tree/v7.4.0" }, "funding": [ { @@ -5442,20 +5693,20 @@ "type": "tidelift" } ], - "time": "2026-03-24T13:12:05+00:00" + "time": "2025-11-27T13:27:24+00:00" }, { "name": "symfony/finder", - "version": "v7.4.8", + "version": "v7.4.5", "source": { "type": "git", "url": "https://github.com/symfony/finder.git", - "reference": "e0be088d22278583a82da281886e8c3592fbf149" + "reference": "ad4daa7c38668dcb031e63bc99ea9bd42196a2cb" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/finder/zipball/e0be088d22278583a82da281886e8c3592fbf149", - "reference": "e0be088d22278583a82da281886e8c3592fbf149", + "url": "https://api.github.com/repos/symfony/finder/zipball/ad4daa7c38668dcb031e63bc99ea9bd42196a2cb", + "reference": "ad4daa7c38668dcb031e63bc99ea9bd42196a2cb", "shasum": "" }, "require": { @@ -5490,7 +5741,7 @@ "description": "Finds files and directories via an intuitive fluent interface", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/finder/tree/v7.4.8" + "source": "https://github.com/symfony/finder/tree/v7.4.5" }, "funding": [ { @@ -5510,7 +5761,7 @@ "type": "tidelift" } ], - "time": "2026-03-24T13:12:05+00:00" + "time": "2026-01-26T15:07:59+00:00" }, { "name": "symfony/flex", @@ -5587,16 +5838,16 @@ }, { "name": "symfony/framework-bundle", - "version": "v7.4.8", + "version": "v7.4.5", "source": { "type": "git", "url": "https://github.com/symfony/framework-bundle.git", - "reference": "180533cfbac2144349044267db31d5d3df9957cb" + "reference": "dcf89ca6712d9e1b5d3f14dea0e1c2685a05d1cd" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/framework-bundle/zipball/180533cfbac2144349044267db31d5d3df9957cb", - "reference": "180533cfbac2144349044267db31d5d3df9957cb", + "url": "https://api.github.com/repos/symfony/framework-bundle/zipball/dcf89ca6712d9e1b5d3f14dea0e1c2685a05d1cd", + "reference": "dcf89ca6712d9e1b5d3f14dea0e1c2685a05d1cd", "shasum": "" }, "require": { @@ -5619,7 +5870,7 @@ }, "conflict": { "doctrine/persistence": "<1.3", - "phpdocumentor/reflection-docblock": "<5.2|>=7", + "phpdocumentor/reflection-docblock": "<5.2|>=6", "phpdocumentor/type-resolver": "<1.5.1", "symfony/asset": "<6.4", "symfony/asset-mapper": "<6.4", @@ -5652,7 +5903,7 @@ "require-dev": { "doctrine/persistence": "^1.3|^2|^3", "dragonmantank/cron-expression": "^3.1", - "phpdocumentor/reflection-docblock": "^5.2|^6.0", + "phpdocumentor/reflection-docblock": "^5.2", "seld/jsonlint": "^1.10", "symfony/asset": "^6.4|^7.0|^8.0", "symfony/asset-mapper": "^6.4|^7.0|^8.0", @@ -5721,7 +5972,7 @@ "description": "Provides a tight integration between Symfony components and the Symfony full-stack framework", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/framework-bundle/tree/v7.4.8" + "source": "https://github.com/symfony/framework-bundle/tree/v7.4.5" }, "funding": [ { @@ -5741,20 +5992,20 @@ "type": "tidelift" } ], - "time": "2026-03-30T12:55:43+00:00" + "time": "2026-01-27T08:59:58+00:00" }, { "name": "symfony/http-client", - "version": "v7.4.8", + "version": "v7.4.5", "source": { "type": "git", "url": "https://github.com/symfony/http-client.git", - "reference": "01933e626c3de76bea1e22641e205e78f6a34342" + "reference": "84bb634857a893cc146cceb467e31b3f02c5fe9f" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/http-client/zipball/01933e626c3de76bea1e22641e205e78f6a34342", - "reference": "01933e626c3de76bea1e22641e205e78f6a34342", + "url": "https://api.github.com/repos/symfony/http-client/zipball/84bb634857a893cc146cceb467e31b3f02c5fe9f", + "reference": "84bb634857a893cc146cceb467e31b3f02c5fe9f", "shasum": "" }, "require": { @@ -5822,7 +6073,7 @@ "http" ], "support": { - "source": "https://github.com/symfony/http-client/tree/v7.4.8" + "source": "https://github.com/symfony/http-client/tree/v7.4.5" }, "funding": [ { @@ -5842,7 +6093,7 @@ "type": "tidelift" } ], - "time": "2026-03-30T12:55:43+00:00" + "time": "2026-01-27T16:16:02+00:00" }, { "name": "symfony/http-client-contracts", @@ -5924,16 +6175,16 @@ }, { "name": "symfony/http-foundation", - "version": "v7.4.8", + "version": "v7.4.5", "source": { "type": "git", "url": "https://github.com/symfony/http-foundation.git", - "reference": "9381209597ec66c25be154cbf2289076e64d1eab" + "reference": "446d0db2b1f21575f1284b74533e425096abdfb6" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/http-foundation/zipball/9381209597ec66c25be154cbf2289076e64d1eab", - "reference": "9381209597ec66c25be154cbf2289076e64d1eab", + "url": "https://api.github.com/repos/symfony/http-foundation/zipball/446d0db2b1f21575f1284b74533e425096abdfb6", + "reference": "446d0db2b1f21575f1284b74533e425096abdfb6", "shasum": "" }, "require": { @@ -5982,7 +6233,7 @@ "description": "Defines an object-oriented layer for the HTTP specification", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/http-foundation/tree/v7.4.8" + "source": "https://github.com/symfony/http-foundation/tree/v7.4.5" }, "funding": [ { @@ -6002,20 +6253,20 @@ "type": "tidelift" } ], - "time": "2026-03-24T13:12:05+00:00" + "time": "2026-01-27T16:16:02+00:00" }, { "name": "symfony/http-kernel", - "version": "v7.4.8", + "version": "v7.4.5", "source": { "type": "git", "url": "https://github.com/symfony/http-kernel.git", - "reference": "017e76ad089bac281553389269e259e155935e1a" + "reference": "229eda477017f92bd2ce7615d06222ec0c19e82a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/http-kernel/zipball/017e76ad089bac281553389269e259e155935e1a", - "reference": "017e76ad089bac281553389269e259e155935e1a", + "url": "https://api.github.com/repos/symfony/http-kernel/zipball/229eda477017f92bd2ce7615d06222ec0c19e82a", + "reference": "229eda477017f92bd2ce7615d06222ec0c19e82a", "shasum": "" }, "require": { @@ -6057,7 +6308,7 @@ "symfony/config": "^6.4|^7.0|^8.0", "symfony/console": "^6.4|^7.0|^8.0", "symfony/css-selector": "^6.4|^7.0|^8.0", - "symfony/dependency-injection": "^6.4.1|^7.0.1|^8.0", + "symfony/dependency-injection": "^6.4|^7.0|^8.0", "symfony/dom-crawler": "^6.4|^7.0|^8.0", "symfony/expression-language": "^6.4|^7.0|^8.0", "symfony/finder": "^6.4|^7.0|^8.0", @@ -6101,7 +6352,7 @@ "description": "Provides a structured process for converting a Request into a Response", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/http-kernel/tree/v7.4.8" + "source": "https://github.com/symfony/http-kernel/tree/v7.4.5" }, "funding": [ { @@ -6121,20 +6372,20 @@ "type": "tidelift" } ], - "time": "2026-03-31T20:57:01+00:00" + "time": "2026-01-28T10:33:42+00:00" }, { "name": "symfony/lock", - "version": "v7.4.8", + "version": "v7.4.5", "source": { "type": "git", "url": "https://github.com/symfony/lock.git", - "reference": "e0cd253a8a043edc69c04a6b51b5f598b6a06515" + "reference": "65615586799423c4d9a1983a08d5328ce0a070a8" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/lock/zipball/e0cd253a8a043edc69c04a6b51b5f598b6a06515", - "reference": "e0cd253a8a043edc69c04a6b51b5f598b6a06515", + "url": "https://api.github.com/repos/symfony/lock/zipball/65615586799423c4d9a1983a08d5328ce0a070a8", + "reference": "65615586799423c4d9a1983a08d5328ce0a070a8", "shasum": "" }, "require": { @@ -6184,7 +6435,7 @@ "semaphore" ], "support": { - "source": "https://github.com/symfony/lock/tree/v7.4.8" + "source": "https://github.com/symfony/lock/tree/v7.4.5" }, "funding": [ { @@ -6204,20 +6455,20 @@ "type": "tidelift" } ], - "time": "2026-03-31T07:11:17+00:00" + "time": "2026-01-27T16:16:02+00:00" }, { "name": "symfony/mailer", - "version": "v7.4.8", + "version": "v7.4.4", "source": { "type": "git", "url": "https://github.com/symfony/mailer.git", - "reference": "f6ea532250b476bfc1b56699b388a1bdbf168f62" + "reference": "7b750074c40c694ceb34cb926d6dffee231c5cd6" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/mailer/zipball/f6ea532250b476bfc1b56699b388a1bdbf168f62", - "reference": "f6ea532250b476bfc1b56699b388a1bdbf168f62", + "url": "https://api.github.com/repos/symfony/mailer/zipball/7b750074c40c694ceb34cb926d6dffee231c5cd6", + "reference": "7b750074c40c694ceb34cb926d6dffee231c5cd6", "shasum": "" }, "require": { @@ -6268,7 +6519,7 @@ "description": "Helps sending emails", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/mailer/tree/v7.4.8" + "source": "https://github.com/symfony/mailer/tree/v7.4.4" }, "funding": [ { @@ -6288,20 +6539,20 @@ "type": "tidelift" } ], - "time": "2026-03-24T13:12:05+00:00" + "time": "2026-01-08T08:25:11+00:00" }, { "name": "symfony/messenger", - "version": "v7.4.8", + "version": "v7.4.4", "source": { "type": "git", "url": "https://github.com/symfony/messenger.git", - "reference": "ddf5ab29bc0329ece30e16f01c86abb6241e92d8" + "reference": "0a39e1b256f280762293f2f441e430c8baf74f9c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/messenger/zipball/ddf5ab29bc0329ece30e16f01c86abb6241e92d8", - "reference": "ddf5ab29bc0329ece30e16f01c86abb6241e92d8", + "url": "https://api.github.com/repos/symfony/messenger/zipball/0a39e1b256f280762293f2f441e430c8baf74f9c", + "reference": "0a39e1b256f280762293f2f441e430c8baf74f9c", "shasum": "" }, "require": { @@ -6362,7 +6613,7 @@ "description": "Helps applications send and receive messages to/from other applications or via message queues", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/messenger/tree/v7.4.8" + "source": "https://github.com/symfony/messenger/tree/v7.4.4" }, "funding": [ { @@ -6382,20 +6633,20 @@ "type": "tidelift" } ], - "time": "2026-03-30T12:55:43+00:00" + "time": "2026-01-08T14:50:10+00:00" }, { "name": "symfony/mime", - "version": "v7.4.8", + "version": "v7.4.5", "source": { "type": "git", "url": "https://github.com/symfony/mime.git", - "reference": "6df02f99998081032da3407a8d6c4e1dcb5d4379" + "reference": "b18c7e6e9eee1e19958138df10412f3c4c316148" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/mime/zipball/6df02f99998081032da3407a8d6c4e1dcb5d4379", - "reference": "6df02f99998081032da3407a8d6c4e1dcb5d4379", + "url": "https://api.github.com/repos/symfony/mime/zipball/b18c7e6e9eee1e19958138df10412f3c4c316148", + "reference": "b18c7e6e9eee1e19958138df10412f3c4c316148", "shasum": "" }, "require": { @@ -6406,7 +6657,7 @@ }, "conflict": { "egulias/email-validator": "~3.0.0", - "phpdocumentor/reflection-docblock": "<5.2|>=7", + "phpdocumentor/reflection-docblock": "<5.2|>=6", "phpdocumentor/type-resolver": "<1.5.1", "symfony/mailer": "<6.4", "symfony/serializer": "<6.4.3|>7.0,<7.0.3" @@ -6414,7 +6665,7 @@ "require-dev": { "egulias/email-validator": "^2.1.10|^3.1|^4", "league/html-to-markdown": "^5.0", - "phpdocumentor/reflection-docblock": "^5.2|^6.0", + "phpdocumentor/reflection-docblock": "^5.2", "symfony/dependency-injection": "^6.4|^7.0|^8.0", "symfony/process": "^6.4|^7.0|^8.0", "symfony/property-access": "^6.4|^7.0|^8.0", @@ -6451,7 +6702,7 @@ "mime-type" ], "support": { - "source": "https://github.com/symfony/mime/tree/v7.4.8" + "source": "https://github.com/symfony/mime/tree/v7.4.5" }, "funding": [ { @@ -6471,20 +6722,20 @@ "type": "tidelift" } ], - "time": "2026-03-30T14:11:46+00:00" + "time": "2026-01-27T08:59:58+00:00" }, { "name": "symfony/monolog-bridge", - "version": "v7.4.8", + "version": "v7.4.4", "source": { "type": "git", "url": "https://github.com/symfony/monolog-bridge.git", - "reference": "b52aeb44645a9a84a1795b973cc5c77a76df0720" + "reference": "9c34e8170b09f062a9a38880a3cb58ee35cb7fd4" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/monolog-bridge/zipball/b52aeb44645a9a84a1795b973cc5c77a76df0720", - "reference": "b52aeb44645a9a84a1795b973cc5c77a76df0720", + "url": "https://api.github.com/repos/symfony/monolog-bridge/zipball/9c34e8170b09f062a9a38880a3cb58ee35cb7fd4", + "reference": "9c34e8170b09f062a9a38880a3cb58ee35cb7fd4", "shasum": "" }, "require": { @@ -6534,7 +6785,7 @@ "description": "Provides integration for Monolog with various Symfony components", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/monolog-bridge/tree/v7.4.8" + "source": "https://github.com/symfony/monolog-bridge/tree/v7.4.4" }, "funding": [ { @@ -6554,20 +6805,20 @@ "type": "tidelift" } ], - "time": "2026-03-30T13:54:39+00:00" + "time": "2026-01-07T11:35:36+00:00" }, { "name": "symfony/monolog-bundle", - "version": "v3.11.2", + "version": "v3.11.1", "source": { "type": "git", "url": "https://github.com/symfony/monolog-bundle.git", - "reference": "d87468010570b2ec766152184918ee8d267c7411" + "reference": "0e675a6e08f791ef960dc9c7e392787111a3f0c1" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/monolog-bundle/zipball/d87468010570b2ec766152184918ee8d267c7411", - "reference": "d87468010570b2ec766152184918ee8d267c7411", + "url": "https://api.github.com/repos/symfony/monolog-bundle/zipball/0e675a6e08f791ef960dc9c7e392787111a3f0c1", + "reference": "0e675a6e08f791ef960dc9c7e392787111a3f0c1", "shasum": "" }, "require": { @@ -6614,7 +6865,7 @@ ], "support": { "issues": "https://github.com/symfony/monolog-bundle/issues", - "source": "https://github.com/symfony/monolog-bundle/tree/v3.11.2" + "source": "https://github.com/symfony/monolog-bundle/tree/v3.11.1" }, "funding": [ { @@ -6634,20 +6885,20 @@ "type": "tidelift" } ], - "time": "2026-04-02T18:23:01+00:00" + "time": "2025-12-08T07:58:26+00:00" }, { "name": "symfony/options-resolver", - "version": "v7.4.8", + "version": "v7.4.0", "source": { "type": "git", "url": "https://github.com/symfony/options-resolver.git", - "reference": "2888fcdc4dc2fd5f7c7397be78631e8af12e02b4" + "reference": "b38026df55197f9e39a44f3215788edf83187b80" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/options-resolver/zipball/2888fcdc4dc2fd5f7c7397be78631e8af12e02b4", - "reference": "2888fcdc4dc2fd5f7c7397be78631e8af12e02b4", + "url": "https://api.github.com/repos/symfony/options-resolver/zipball/b38026df55197f9e39a44f3215788edf83187b80", + "reference": "b38026df55197f9e39a44f3215788edf83187b80", "shasum": "" }, "require": { @@ -6685,7 +6936,7 @@ "options" ], "support": { - "source": "https://github.com/symfony/options-resolver/tree/v7.4.8" + "source": "https://github.com/symfony/options-resolver/tree/v7.4.0" }, "funding": [ { @@ -6705,7 +6956,7 @@ "type": "tidelift" } ], - "time": "2026-03-24T13:12:05+00:00" + "time": "2025-11-12T15:39:26+00:00" }, { "name": "symfony/orm-pack", @@ -7423,16 +7674,16 @@ }, { "name": "symfony/property-access", - "version": "v7.4.8", + "version": "v7.4.4", "source": { "type": "git", "url": "https://github.com/symfony/property-access.git", - "reference": "b7dad9dae8b8a47ef7ecc76c8569e7d8c7d90cfc" + "reference": "fa49bf1ca8fce1ba0e2dba4e4658554cfb9364b1" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/property-access/zipball/b7dad9dae8b8a47ef7ecc76c8569e7d8c7d90cfc", - "reference": "b7dad9dae8b8a47ef7ecc76c8569e7d8c7d90cfc", + "url": "https://api.github.com/repos/symfony/property-access/zipball/fa49bf1ca8fce1ba0e2dba4e4658554cfb9364b1", + "reference": "fa49bf1ca8fce1ba0e2dba4e4658554cfb9364b1", "shasum": "" }, "require": { @@ -7480,7 +7731,7 @@ "reflection" ], "support": { - "source": "https://github.com/symfony/property-access/tree/v7.4.8" + "source": "https://github.com/symfony/property-access/tree/v7.4.4" }, "funding": [ { @@ -7500,37 +7751,37 @@ "type": "tidelift" } ], - "time": "2026-03-24T13:12:05+00:00" + "time": "2026-01-05T08:47:25+00:00" }, { "name": "symfony/property-info", - "version": "v7.4.8", + "version": "v7.4.5", "source": { "type": "git", "url": "https://github.com/symfony/property-info.git", - "reference": "ac5e82528b986c4f7cfccbf7764b5d2e824d6175" + "reference": "1c9d326bd69602561e2ea467a16c09b5972eee21" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/property-info/zipball/ac5e82528b986c4f7cfccbf7764b5d2e824d6175", - "reference": "ac5e82528b986c4f7cfccbf7764b5d2e824d6175", + "url": "https://api.github.com/repos/symfony/property-info/zipball/1c9d326bd69602561e2ea467a16c09b5972eee21", + "reference": "1c9d326bd69602561e2ea467a16c09b5972eee21", "shasum": "" }, "require": { "php": ">=8.2", "symfony/deprecation-contracts": "^2.5|^3", "symfony/string": "^6.4|^7.0|^8.0", - "symfony/type-info": "^7.4.7|^8.0.7" + "symfony/type-info": "~7.3.10|^7.4.4|^8.0.4" }, "conflict": { - "phpdocumentor/reflection-docblock": "<5.2|>=7", + "phpdocumentor/reflection-docblock": "<5.2|>=6", "phpdocumentor/type-resolver": "<1.5.1", "symfony/cache": "<6.4", "symfony/dependency-injection": "<6.4", "symfony/serializer": "<6.4" }, "require-dev": { - "phpdocumentor/reflection-docblock": "^5.2|^6.0", + "phpdocumentor/reflection-docblock": "^5.2", "phpstan/phpdoc-parser": "^1.0|^2.0", "symfony/cache": "^6.4|^7.0|^8.0", "symfony/dependency-injection": "^6.4|^7.0|^8.0", @@ -7570,7 +7821,7 @@ "validator" ], "support": { - "source": "https://github.com/symfony/property-info/tree/v7.4.8" + "source": "https://github.com/symfony/property-info/tree/v7.4.5" }, "funding": [ { @@ -7590,20 +7841,108 @@ "type": "tidelift" } ], - "time": "2026-03-24T13:12:05+00:00" + "time": "2026-01-27T16:16:02+00:00" + }, + { + "name": "symfony/psr-http-message-bridge", + "version": "v7.4.4", + "source": { + "type": "git", + "url": "https://github.com/symfony/psr-http-message-bridge.git", + "reference": "929ffe10bbfbb92e711ac3818d416f9daffee067" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/psr-http-message-bridge/zipball/929ffe10bbfbb92e711ac3818d416f9daffee067", + "reference": "929ffe10bbfbb92e711ac3818d416f9daffee067", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "psr/http-message": "^1.0|^2.0", + "symfony/http-foundation": "^6.4|^7.0|^8.0" + }, + "conflict": { + "php-http/discovery": "<1.15", + "symfony/http-kernel": "<6.4" + }, + "require-dev": { + "nyholm/psr7": "^1.1", + "php-http/discovery": "^1.15", + "psr/log": "^1.1.4|^2|^3", + "symfony/browser-kit": "^6.4|^7.0|^8.0", + "symfony/config": "^6.4|^7.0|^8.0", + "symfony/event-dispatcher": "^6.4|^7.0|^8.0", + "symfony/framework-bundle": "^6.4.13|^7.1.6|^8.0", + "symfony/http-kernel": "^6.4.13|^7.1.6|^8.0", + "symfony/runtime": "^6.4.13|^7.1.6|^8.0" + }, + "type": "symfony-bridge", + "autoload": { + "psr-4": { + "Symfony\\Bridge\\PsrHttpMessage\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "PSR HTTP message bridge", + "homepage": "https://symfony.com", + "keywords": [ + "http", + "http-message", + "psr-17", + "psr-7" + ], + "support": { + "source": "https://github.com/symfony/psr-http-message-bridge/tree/v7.4.4" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-01-03T23:30:35+00:00" }, { "name": "symfony/rate-limiter", - "version": "v7.4.8", + "version": "v7.4.5", "source": { "type": "git", "url": "https://github.com/symfony/rate-limiter.git", - "reference": "d55de9ec479418f58464e122e68d33886cf6f1fb" + "reference": "7e275c57293cd2d894e126cc68855ecd82bcd173" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/rate-limiter/zipball/d55de9ec479418f58464e122e68d33886cf6f1fb", - "reference": "d55de9ec479418f58464e122e68d33886cf6f1fb", + "url": "https://api.github.com/repos/symfony/rate-limiter/zipball/7e275c57293cd2d894e126cc68855ecd82bcd173", + "reference": "7e275c57293cd2d894e126cc68855ecd82bcd173", "shasum": "" }, "require": { @@ -7644,7 +7983,7 @@ "rate-limiter" ], "support": { - "source": "https://github.com/symfony/rate-limiter/tree/v7.4.8" + "source": "https://github.com/symfony/rate-limiter/tree/v7.4.5" }, "funding": [ { @@ -7664,20 +8003,20 @@ "type": "tidelift" } ], - "time": "2026-03-24T13:12:05+00:00" + "time": "2026-01-27T16:16:02+00:00" }, { "name": "symfony/routing", - "version": "v7.4.8", + "version": "v7.4.4", "source": { "type": "git", "url": "https://github.com/symfony/routing.git", - "reference": "9608de9873ec86e754fb6c0a0fa7e5f1a960eb6b" + "reference": "0798827fe2c79caeed41d70b680c2c3507d10147" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/routing/zipball/9608de9873ec86e754fb6c0a0fa7e5f1a960eb6b", - "reference": "9608de9873ec86e754fb6c0a0fa7e5f1a960eb6b", + "url": "https://api.github.com/repos/symfony/routing/zipball/0798827fe2c79caeed41d70b680c2c3507d10147", + "reference": "0798827fe2c79caeed41d70b680c2c3507d10147", "shasum": "" }, "require": { @@ -7729,7 +8068,7 @@ "url" ], "support": { - "source": "https://github.com/symfony/routing/tree/v7.4.8" + "source": "https://github.com/symfony/routing/tree/v7.4.4" }, "funding": [ { @@ -7749,20 +8088,20 @@ "type": "tidelift" } ], - "time": "2026-03-24T13:12:05+00:00" + "time": "2026-01-12T12:19:02+00:00" }, { "name": "symfony/runtime", - "version": "v7.4.8", + "version": "v7.4.1", "source": { "type": "git", "url": "https://github.com/symfony/runtime.git", - "reference": "6d792a64fec1eae2f011cfe9ab5978a9eab3071e" + "reference": "876f902a6cb6b26c003de244188c06b2ba1c172f" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/runtime/zipball/6d792a64fec1eae2f011cfe9ab5978a9eab3071e", - "reference": "6d792a64fec1eae2f011cfe9ab5978a9eab3071e", + "url": "https://api.github.com/repos/symfony/runtime/zipball/876f902a6cb6b26c003de244188c06b2ba1c172f", + "reference": "876f902a6cb6b26c003de244188c06b2ba1c172f", "shasum": "" }, "require": { @@ -7812,7 +8151,7 @@ "runtime" ], "support": { - "source": "https://github.com/symfony/runtime/tree/v7.4.8" + "source": "https://github.com/symfony/runtime/tree/v7.4.1" }, "funding": [ { @@ -7832,20 +8171,20 @@ "type": "tidelift" } ], - "time": "2026-03-24T13:12:05+00:00" + "time": "2025-12-05T14:04:53+00:00" }, { "name": "symfony/scheduler", - "version": "v7.4.8", + "version": "v7.4.4", "source": { "type": "git", "url": "https://github.com/symfony/scheduler.git", - "reference": "f95e696edaad466db9b087a6480ef936c766c3de" + "reference": "caa4688dd72c0d22d52ddd45315eb602ff97a42b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/scheduler/zipball/f95e696edaad466db9b087a6480ef936c766c3de", - "reference": "f95e696edaad466db9b087a6480ef936c766c3de", + "url": "https://api.github.com/repos/symfony/scheduler/zipball/caa4688dd72c0d22d52ddd45315eb602ff97a42b", + "reference": "caa4688dd72c0d22d52ddd45315eb602ff97a42b", "shasum": "" }, "require": { @@ -7897,7 +8236,7 @@ "scheduler" ], "support": { - "source": "https://github.com/symfony/scheduler/tree/v7.4.8" + "source": "https://github.com/symfony/scheduler/tree/v7.4.4" }, "funding": [ { @@ -7917,20 +8256,20 @@ "type": "tidelift" } ], - "time": "2026-03-24T13:12:05+00:00" + "time": "2026-01-19T10:50:45+00:00" }, { "name": "symfony/serializer", - "version": "v7.4.8", + "version": "v7.4.5", "source": { "type": "git", "url": "https://github.com/symfony/serializer.git", - "reference": "006fd51717addf2df2bd1a64dafef6b7fab6b455" + "reference": "480cd1237c98ab1219c20945b92c9d4480a44f47" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/serializer/zipball/006fd51717addf2df2bd1a64dafef6b7fab6b455", - "reference": "006fd51717addf2df2bd1a64dafef6b7fab6b455", + "url": "https://api.github.com/repos/symfony/serializer/zipball/480cd1237c98ab1219c20945b92c9d4480a44f47", + "reference": "480cd1237c98ab1219c20945b92c9d4480a44f47", "shasum": "" }, "require": { @@ -7940,18 +8279,17 @@ "symfony/polyfill-php84": "^1.30" }, "conflict": { - "phpdocumentor/reflection-docblock": "<5.2|>=7", + "phpdocumentor/reflection-docblock": "<5.2|>=6", "phpdocumentor/type-resolver": "<1.5.1", "symfony/dependency-injection": "<6.4", "symfony/property-access": "<6.4", "symfony/property-info": "<6.4", - "symfony/type-info": "<7.2.5", "symfony/uid": "<6.4", "symfony/validator": "<6.4", "symfony/yaml": "<6.4" }, "require-dev": { - "phpdocumentor/reflection-docblock": "^5.2|^6.0", + "phpdocumentor/reflection-docblock": "^5.2", "phpstan/phpdoc-parser": "^1.0|^2.0", "seld/jsonlint": "^1.10", "symfony/cache": "^6.4|^7.0|^8.0", @@ -7968,7 +8306,7 @@ "symfony/property-access": "^6.4|^7.0|^8.0", "symfony/property-info": "^6.4|^7.0|^8.0", "symfony/translation-contracts": "^2.5|^3", - "symfony/type-info": "^7.2.5|^8.0", + "symfony/type-info": "^7.1.8|^8.0", "symfony/uid": "^6.4|^7.0|^8.0", "symfony/validator": "^6.4|^7.0|^8.0", "symfony/var-dumper": "^6.4|^7.0|^8.0", @@ -8001,7 +8339,7 @@ "description": "Handles serializing and deserializing data structures, including object graphs, into array structures or other formats like XML and JSON.", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/serializer/tree/v7.4.8" + "source": "https://github.com/symfony/serializer/tree/v7.4.5" }, "funding": [ { @@ -8021,20 +8359,20 @@ "type": "tidelift" } ], - "time": "2026-03-30T21:34:42+00:00" + "time": "2026-01-27T08:59:58+00:00" }, { "name": "symfony/serializer-pack", - "version": "v1.4.0", + "version": "v1.3.0", "source": { "type": "git", "url": "https://github.com/symfony/serializer-pack.git", - "reference": "173d41c068b3cf150890a3d7700e203da13905bd" + "reference": "2844d81a5fc86b617b82f44a8bfcaaba1d583eee" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/serializer-pack/zipball/173d41c068b3cf150890a3d7700e203da13905bd", - "reference": "173d41c068b3cf150890a3d7700e203da13905bd", + "url": "https://api.github.com/repos/symfony/serializer-pack/zipball/2844d81a5fc86b617b82f44a8bfcaaba1d583eee", + "reference": "2844d81a5fc86b617b82f44a8bfcaaba1d583eee", "shasum": "" }, "require": { @@ -8045,8 +8383,8 @@ "symfony/serializer": "*" }, "conflict": { - "symfony/property-info": "<7.4", - "symfony/serializer": "<7.4" + "symfony/property-info": "<5.4", + "symfony/serializer": "<5.4" }, "type": "symfony-pack", "notification-url": "https://packagist.org/downloads/", @@ -8056,7 +8394,7 @@ "description": "A pack for the Symfony serializer", "support": { "issues": "https://github.com/symfony/serializer-pack/issues", - "source": "https://github.com/symfony/serializer-pack/tree/v1.4.0" + "source": "https://github.com/symfony/serializer-pack/tree/v1.3.0" }, "funding": [ { @@ -8067,16 +8405,12 @@ "url": "https://github.com/fabpot", "type": "github" }, - { - "url": "https://github.com/nicolas-grekas", - "type": "github" - }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2026-03-03T10:13:59+00:00" + "time": "2023-06-03T13:55:25+00:00" }, { "name": "symfony/service-contracts", @@ -8167,16 +8501,16 @@ }, { "name": "symfony/stopwatch", - "version": "v7.4.8", + "version": "v7.4.0", "source": { "type": "git", "url": "https://github.com/symfony/stopwatch.git", - "reference": "70a852d72fec4d51efb1f48dcd968efcaf5ccb89" + "reference": "8a24af0a2e8a872fb745047180649b8418303084" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/stopwatch/zipball/70a852d72fec4d51efb1f48dcd968efcaf5ccb89", - "reference": "70a852d72fec4d51efb1f48dcd968efcaf5ccb89", + "url": "https://api.github.com/repos/symfony/stopwatch/zipball/8a24af0a2e8a872fb745047180649b8418303084", + "reference": "8a24af0a2e8a872fb745047180649b8418303084", "shasum": "" }, "require": { @@ -8209,7 +8543,7 @@ "description": "Provides a way to profile code", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/stopwatch/tree/v7.4.8" + "source": "https://github.com/symfony/stopwatch/tree/v7.4.0" }, "funding": [ { @@ -8229,20 +8563,20 @@ "type": "tidelift" } ], - "time": "2026-03-24T13:12:05+00:00" + "time": "2025-08-04T07:05:15+00:00" }, { "name": "symfony/string", - "version": "v7.4.8", + "version": "v7.4.4", "source": { "type": "git", "url": "https://github.com/symfony/string.git", - "reference": "114ac57257d75df748eda23dd003878080b8e688" + "reference": "1c4b10461bf2ec27537b5f36105337262f5f5d6f" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/string/zipball/114ac57257d75df748eda23dd003878080b8e688", - "reference": "114ac57257d75df748eda23dd003878080b8e688", + "url": "https://api.github.com/repos/symfony/string/zipball/1c4b10461bf2ec27537b5f36105337262f5f5d6f", + "reference": "1c4b10461bf2ec27537b5f36105337262f5f5d6f", "shasum": "" }, "require": { @@ -8300,7 +8634,7 @@ "utf8" ], "support": { - "source": "https://github.com/symfony/string/tree/v7.4.8" + "source": "https://github.com/symfony/string/tree/v7.4.4" }, "funding": [ { @@ -8320,20 +8654,20 @@ "type": "tidelift" } ], - "time": "2026-03-24T13:12:05+00:00" + "time": "2026-01-12T10:54:30+00:00" }, { "name": "symfony/translation", - "version": "v7.4.8", + "version": "v7.4.4", "source": { "type": "git", "url": "https://github.com/symfony/translation.git", - "reference": "33600f8489485425bfcddd0d983391038d3422e7" + "reference": "bfde13711f53f549e73b06d27b35a55207528877" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/translation/zipball/33600f8489485425bfcddd0d983391038d3422e7", - "reference": "33600f8489485425bfcddd0d983391038d3422e7", + "url": "https://api.github.com/repos/symfony/translation/zipball/bfde13711f53f549e73b06d27b35a55207528877", + "reference": "bfde13711f53f549e73b06d27b35a55207528877", "shasum": "" }, "require": { @@ -8400,7 +8734,7 @@ "description": "Provides tools to internationalize your application", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/translation/tree/v7.4.8" + "source": "https://github.com/symfony/translation/tree/v7.4.4" }, "funding": [ { @@ -8420,7 +8754,7 @@ "type": "tidelift" } ], - "time": "2026-03-24T13:12:05+00:00" + "time": "2026-01-13T10:40:19+00:00" }, { "name": "symfony/translation-contracts", @@ -8506,16 +8840,16 @@ }, { "name": "symfony/twig-bridge", - "version": "v7.4.8", + "version": "v7.4.5", "source": { "type": "git", "url": "https://github.com/symfony/twig-bridge.git", - "reference": "ac43e7e59298ed1ce98c8d228b651d46e907d02c" + "reference": "f2dd26b604e856476ef7e0efa4568bc07eb7ddc8" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/twig-bridge/zipball/ac43e7e59298ed1ce98c8d228b651d46e907d02c", - "reference": "ac43e7e59298ed1ce98c8d228b651d46e907d02c", + "url": "https://api.github.com/repos/symfony/twig-bridge/zipball/f2dd26b604e856476ef7e0efa4568bc07eb7ddc8", + "reference": "f2dd26b604e856476ef7e0efa4568bc07eb7ddc8", "shasum": "" }, "require": { @@ -8525,13 +8859,13 @@ "twig/twig": "^3.21" }, "conflict": { - "phpdocumentor/reflection-docblock": "<5.2|>=7", + "phpdocumentor/reflection-docblock": "<5.2|>=6", "phpdocumentor/type-resolver": "<1.5.1", "symfony/console": "<6.4", "symfony/form": "<6.4.32|>7,<7.3.10|>7.4,<7.4.4|>8.0,<8.0.4", "symfony/http-foundation": "<6.4", "symfony/http-kernel": "<6.4", - "symfony/mime": "<6.4.36|>7,<7.4.8|>8.0,<8.0.8", + "symfony/mime": "<6.4", "symfony/serializer": "<6.4", "symfony/translation": "<6.4", "symfony/workflow": "<6.4" @@ -8539,7 +8873,7 @@ "require-dev": { "egulias/email-validator": "^2.1.10|^3|^4", "league/html-to-markdown": "^5.0", - "phpdocumentor/reflection-docblock": "^5.2|^6.0", + "phpdocumentor/reflection-docblock": "^5.2", "symfony/asset": "^6.4|^7.0|^8.0", "symfony/asset-mapper": "^6.4|^7.0|^8.0", "symfony/console": "^6.4|^7.0|^8.0", @@ -8552,7 +8886,7 @@ "symfony/http-foundation": "^7.3|^8.0", "symfony/http-kernel": "^6.4|^7.0|^8.0", "symfony/intl": "^6.4|^7.0|^8.0", - "symfony/mime": "^6.4.36|^7.4.8|^8.0.8", + "symfony/mime": "^6.4|^7.0|^8.0", "symfony/polyfill-intl-icu": "~1.0", "symfony/property-info": "^6.4|^7.0|^8.0", "symfony/routing": "^6.4|^7.0|^8.0", @@ -8597,7 +8931,7 @@ "description": "Provides integration for Twig with various Symfony components", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/twig-bridge/tree/v7.4.8" + "source": "https://github.com/symfony/twig-bridge/tree/v7.4.5" }, "funding": [ { @@ -8617,20 +8951,20 @@ "type": "tidelift" } ], - "time": "2026-03-30T15:17:09+00:00" + "time": "2026-01-27T08:59:58+00:00" }, { "name": "symfony/twig-bundle", - "version": "v7.4.8", + "version": "v7.4.4", "source": { "type": "git", "url": "https://github.com/symfony/twig-bundle.git", - "reference": "ba1e06d7ff1ebb1d1799b6608d925f4eaba88d95" + "reference": "e8829e02ff96a391ed0703bac9e7ff0537480b6b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/twig-bundle/zipball/ba1e06d7ff1ebb1d1799b6608d925f4eaba88d95", - "reference": "ba1e06d7ff1ebb1d1799b6608d925f4eaba88d95", + "url": "https://api.github.com/repos/symfony/twig-bundle/zipball/e8829e02ff96a391ed0703bac9e7ff0537480b6b", + "reference": "e8829e02ff96a391ed0703bac9e7ff0537480b6b", "shasum": "" }, "require": { @@ -8687,7 +9021,7 @@ "description": "Provides a tight integration of Twig into the Symfony full-stack framework", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/twig-bundle/tree/v7.4.8" + "source": "https://github.com/symfony/twig-bundle/tree/v7.4.4" }, "funding": [ { @@ -8707,20 +9041,20 @@ "type": "tidelift" } ], - "time": "2026-03-24T13:12:05+00:00" + "time": "2026-01-06T12:34:24+00:00" }, { "name": "symfony/type-info", - "version": "v7.4.8", + "version": "v7.4.4", "source": { "type": "git", "url": "https://github.com/symfony/type-info.git", - "reference": "6bf34da885ff5143a3dfd8f1b863bb8ab95f50bd" + "reference": "f83c725e72b39b2704b9d6fc85070ad6ac7a5889" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/type-info/zipball/6bf34da885ff5143a3dfd8f1b863bb8ab95f50bd", - "reference": "6bf34da885ff5143a3dfd8f1b863bb8ab95f50bd", + "url": "https://api.github.com/repos/symfony/type-info/zipball/f83c725e72b39b2704b9d6fc85070ad6ac7a5889", + "reference": "f83c725e72b39b2704b9d6fc85070ad6ac7a5889", "shasum": "" }, "require": { @@ -8770,7 +9104,7 @@ "type" ], "support": { - "source": "https://github.com/symfony/type-info/tree/v7.4.8" + "source": "https://github.com/symfony/type-info/tree/v7.4.4" }, "funding": [ { @@ -8790,20 +9124,20 @@ "type": "tidelift" } ], - "time": "2026-03-24T13:12:05+00:00" + "time": "2026-01-09T12:14:21+00:00" }, { "name": "symfony/uid", - "version": "v7.4.8", + "version": "v7.4.4", "source": { "type": "git", "url": "https://github.com/symfony/uid.git", - "reference": "6883ebdf7bf6a12b37519dbc0df62b0222401b56" + "reference": "7719ce8aba76be93dfe249192f1fbfa52c588e36" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/uid/zipball/6883ebdf7bf6a12b37519dbc0df62b0222401b56", - "reference": "6883ebdf7bf6a12b37519dbc0df62b0222401b56", + "url": "https://api.github.com/repos/symfony/uid/zipball/7719ce8aba76be93dfe249192f1fbfa52c588e36", + "reference": "7719ce8aba76be93dfe249192f1fbfa52c588e36", "shasum": "" }, "require": { @@ -8848,7 +9182,7 @@ "uuid" ], "support": { - "source": "https://github.com/symfony/uid/tree/v7.4.8" + "source": "https://github.com/symfony/uid/tree/v7.4.4" }, "funding": [ { @@ -8868,20 +9202,20 @@ "type": "tidelift" } ], - "time": "2026-03-24T13:12:05+00:00" + "time": "2026-01-03T23:30:35+00:00" }, { "name": "symfony/ux-twig-component", - "version": "v2.34.0", + "version": "v2.32.0", "source": { "type": "git", "url": "https://github.com/symfony/ux-twig-component.git", - "reference": "f9942e32246fe3fa9d31f60cffc1ada4d274830a" + "reference": "0a300088327d1b766733fdcd81ae4a77852d6177" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/ux-twig-component/zipball/f9942e32246fe3fa9d31f60cffc1ada4d274830a", - "reference": "f9942e32246fe3fa9d31f60cffc1ada4d274830a", + "url": "https://api.github.com/repos/symfony/ux-twig-component/zipball/0a300088327d1b766733fdcd81ae4a77852d6177", + "reference": "0a300088327d1b766733fdcd81ae4a77852d6177", "shasum": "" }, "require": { @@ -8935,7 +9269,7 @@ "twig" ], "support": { - "source": "https://github.com/symfony/ux-twig-component/tree/v2.34.0" + "source": "https://github.com/symfony/ux-twig-component/tree/v2.32.0" }, "funding": [ { @@ -8955,20 +9289,20 @@ "type": "tidelift" } ], - "time": "2026-03-15T18:48:53+00:00" + "time": "2025-12-25T09:25:01+00:00" }, { "name": "symfony/validator", - "version": "v7.4.8", + "version": "v7.4.5", "source": { "type": "git", "url": "https://github.com/symfony/validator.git", - "reference": "8f73cbddae916756f319b3e195088da216f0f12f" + "reference": "fcec92c40df1c93507857da08226005573b655c6" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/validator/zipball/8f73cbddae916756f319b3e195088da216f0f12f", - "reference": "8f73cbddae916756f319b3e195088da216f0f12f", + "url": "https://api.github.com/repos/symfony/validator/zipball/fcec92c40df1c93507857da08226005573b655c6", + "reference": "fcec92c40df1c93507857da08226005573b655c6", "shasum": "" }, "require": { @@ -9039,7 +9373,7 @@ "description": "Provides tools to validate values", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/validator/tree/v7.4.8" + "source": "https://github.com/symfony/validator/tree/v7.4.5" }, "funding": [ { @@ -9059,20 +9393,20 @@ "type": "tidelift" } ], - "time": "2026-03-30T12:55:43+00:00" + "time": "2026-01-27T08:59:58+00:00" }, { "name": "symfony/var-dumper", - "version": "v7.4.8", + "version": "v7.4.4", "source": { "type": "git", "url": "https://github.com/symfony/var-dumper.git", - "reference": "9510c3966f749a1d1ff0059e1eabef6cc621e7fd" + "reference": "0e4769b46a0c3c62390d124635ce59f66874b282" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/var-dumper/zipball/9510c3966f749a1d1ff0059e1eabef6cc621e7fd", - "reference": "9510c3966f749a1d1ff0059e1eabef6cc621e7fd", + "url": "https://api.github.com/repos/symfony/var-dumper/zipball/0e4769b46a0c3c62390d124635ce59f66874b282", + "reference": "0e4769b46a0c3c62390d124635ce59f66874b282", "shasum": "" }, "require": { @@ -9126,7 +9460,7 @@ "dump" ], "support": { - "source": "https://github.com/symfony/var-dumper/tree/v7.4.8" + "source": "https://github.com/symfony/var-dumper/tree/v7.4.4" }, "funding": [ { @@ -9146,20 +9480,20 @@ "type": "tidelift" } ], - "time": "2026-03-30T13:44:50+00:00" + "time": "2026-01-01T22:13:48+00:00" }, { "name": "symfony/var-exporter", - "version": "v7.4.8", + "version": "v7.4.0", "source": { "type": "git", "url": "https://github.com/symfony/var-exporter.git", - "reference": "398907e89a2a56fe426f7955c6fa943ec0c77225" + "reference": "03a60f169c79a28513a78c967316fbc8bf17816f" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/var-exporter/zipball/398907e89a2a56fe426f7955c6fa943ec0c77225", - "reference": "398907e89a2a56fe426f7955c6fa943ec0c77225", + "url": "https://api.github.com/repos/symfony/var-exporter/zipball/03a60f169c79a28513a78c967316fbc8bf17816f", + "reference": "03a60f169c79a28513a78c967316fbc8bf17816f", "shasum": "" }, "require": { @@ -9207,7 +9541,7 @@ "serialize" ], "support": { - "source": "https://github.com/symfony/var-exporter/tree/v7.4.8" + "source": "https://github.com/symfony/var-exporter/tree/v7.4.0" }, "funding": [ { @@ -9227,20 +9561,20 @@ "type": "tidelift" } ], - "time": "2026-03-24T13:12:05+00:00" + "time": "2025-09-11T10:15:23+00:00" }, { "name": "symfony/yaml", - "version": "v7.4.8", + "version": "v7.4.1", "source": { "type": "git", "url": "https://github.com/symfony/yaml.git", - "reference": "c58fdf7b3d6c2995368264c49e4e8b05bcff2883" + "reference": "24dd4de28d2e3988b311751ac49e684d783e2345" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/yaml/zipball/c58fdf7b3d6c2995368264c49e4e8b05bcff2883", - "reference": "c58fdf7b3d6c2995368264c49e4e8b05bcff2883", + "url": "https://api.github.com/repos/symfony/yaml/zipball/24dd4de28d2e3988b311751ac49e684d783e2345", + "reference": "24dd4de28d2e3988b311751ac49e684d783e2345", "shasum": "" }, "require": { @@ -9283,7 +9617,7 @@ "description": "Loads and dumps YAML files", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/yaml/tree/v7.4.8" + "source": "https://github.com/symfony/yaml/tree/v7.4.1" }, "funding": [ { @@ -9303,7 +9637,7 @@ "type": "tidelift" } ], - "time": "2026-03-24T13:12:05+00:00" + "time": "2025-12-04T18:11:45+00:00" }, { "name": "tijsverkoyen/css-to-inline-styles", @@ -9362,7 +9696,7 @@ }, { "name": "twig/cssinliner-extra", - "version": "v3.24.0", + "version": "v3.23.0", "source": { "type": "git", "url": "https://github.com/twigphp/cssinliner-extra.git", @@ -9415,7 +9749,7 @@ "twig" ], "support": { - "source": "https://github.com/twigphp/cssinliner-extra/tree/v3.24.0" + "source": "https://github.com/twigphp/cssinliner-extra/tree/v3.23.0" }, "funding": [ { @@ -9431,16 +9765,16 @@ }, { "name": "twig/extra-bundle", - "version": "v3.24.0", + "version": "v3.23.0", "source": { "type": "git", "url": "https://github.com/twigphp/twig-extra-bundle.git", - "reference": "6a621fcb1f28aa9ea7b34a99047ae0cdf5b834c9" + "reference": "7a27e784dc56eddfef5e9295829b290ce06f1682" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/twigphp/twig-extra-bundle/zipball/6a621fcb1f28aa9ea7b34a99047ae0cdf5b834c9", - "reference": "6a621fcb1f28aa9ea7b34a99047ae0cdf5b834c9", + "url": "https://api.github.com/repos/twigphp/twig-extra-bundle/zipball/7a27e784dc56eddfef5e9295829b290ce06f1682", + "reference": "7a27e784dc56eddfef5e9295829b290ce06f1682", "shasum": "" }, "require": { @@ -9489,7 +9823,7 @@ "twig" ], "support": { - "source": "https://github.com/twigphp/twig-extra-bundle/tree/v3.24.0" + "source": "https://github.com/twigphp/twig-extra-bundle/tree/v3.23.0" }, "funding": [ { @@ -9501,20 +9835,20 @@ "type": "tidelift" } ], - "time": "2026-02-07T08:07:38+00:00" + "time": "2025-12-18T20:46:15+00:00" }, { "name": "twig/twig", - "version": "v3.24.0", + "version": "v3.23.0", "source": { "type": "git", "url": "https://github.com/twigphp/Twig.git", - "reference": "a6769aefb305efef849dc25c9fd1653358c148f0" + "reference": "a64dc5d2cc7d6cafb9347f6cd802d0d06d0351c9" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/twigphp/Twig/zipball/a6769aefb305efef849dc25c9fd1653358c148f0", - "reference": "a6769aefb305efef849dc25c9fd1653358c148f0", + "url": "https://api.github.com/repos/twigphp/Twig/zipball/a64dc5d2cc7d6cafb9347f6cd802d0d06d0351c9", + "reference": "a64dc5d2cc7d6cafb9347f6cd802d0d06d0351c9", "shasum": "" }, "require": { @@ -9524,8 +9858,7 @@ "symfony/polyfill-mbstring": "^1.3" }, "require-dev": { - "php-cs-fixer/shim": "^3.0@stable", - "phpstan/phpstan": "^2.0@stable", + "phpstan/phpstan": "^2.0", "psr/container": "^1.0|^2.0", "symfony/phpunit-bridge": "^5.4.9|^6.4|^7.0" }, @@ -9569,7 +9902,7 @@ ], "support": { "issues": "https://github.com/twigphp/Twig/issues", - "source": "https://github.com/twigphp/Twig/tree/v3.24.0" + "source": "https://github.com/twigphp/Twig/tree/v3.23.0" }, "funding": [ { @@ -9581,7 +9914,7 @@ "type": "tidelift" } ], - "time": "2026-03-17T21:31:11+00:00" + "time": "2026-01-23T21:00:41+00:00" }, { "name": "voku/portable-ascii", @@ -9659,16 +9992,16 @@ }, { "name": "webmozart/assert", - "version": "2.1.6", + "version": "2.1.2", "source": { "type": "git", "url": "https://github.com/webmozarts/assert.git", - "reference": "ff31ad6efc62e66e518fbab1cde3453d389bcdc8" + "reference": "ce6a2f100c404b2d32a1dd1270f9b59ad4f57649" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/webmozarts/assert/zipball/ff31ad6efc62e66e518fbab1cde3453d389bcdc8", - "reference": "ff31ad6efc62e66e518fbab1cde3453d389bcdc8", + "url": "https://api.github.com/repos/webmozarts/assert/zipball/ce6a2f100c404b2d32a1dd1270f9b59ad4f57649", + "reference": "ce6a2f100c404b2d32a1dd1270f9b59ad4f57649", "shasum": "" }, "require": { @@ -9715,9 +10048,9 @@ ], "support": { "issues": "https://github.com/webmozarts/assert/issues", - "source": "https://github.com/webmozarts/assert/tree/2.1.6" + "source": "https://github.com/webmozarts/assert/tree/2.1.2" }, - "time": "2026-02-27T10:28:38+00:00" + "time": "2026-01-13T14:02:24+00:00" }, { "name": "zenstruck/assert", @@ -9934,16 +10267,16 @@ }, { "name": "zenstruck/foundry", - "version": "v2.9.2", + "version": "v2.9.1", "source": { "type": "git", "url": "https://github.com/zenstruck/foundry.git", - "reference": "9f3fe969d37fd5a0799ca455af9990a88036b6a0" + "reference": "304e5333e62745a8f1b70e40b284c1d278b61cf0" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/zenstruck/foundry/zipball/9f3fe969d37fd5a0799ca455af9990a88036b6a0", - "reference": "9f3fe969d37fd5a0799ca455af9990a88036b6a0", + "url": "https://api.github.com/repos/zenstruck/foundry/zipball/304e5333e62745a8f1b70e40b284c1d278b61cf0", + "reference": "304e5333e62745a8f1b70e40b284c1d278b61cf0", "shasum": "" }, "require": { @@ -10042,7 +10375,7 @@ ], "support": { "issues": "https://github.com/zenstruck/foundry/issues", - "source": "https://github.com/zenstruck/foundry/tree/v2.9.2" + "source": "https://github.com/zenstruck/foundry/tree/v2.9.1" }, "funding": [ { @@ -10054,7 +10387,7 @@ "type": "github" } ], - "time": "2026-02-17T15:48:50+00:00" + "time": "2026-02-08T14:53:41+00:00" }, { "name": "zenstruck/messenger-monitor-bundle", @@ -10141,16 +10474,16 @@ }, { "name": "zenstruck/messenger-test", - "version": "v1.14.0", + "version": "v1.13.0", "source": { "type": "git", "url": "https://github.com/zenstruck/messenger-test.git", - "reference": "2c1947b5be4987756d532c0397074b59a4ebbf01" + "reference": "bb2446c9fa50d513a896dcb914392bac1f052f82" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/zenstruck/messenger-test/zipball/2c1947b5be4987756d532c0397074b59a4ebbf01", - "reference": "2c1947b5be4987756d532c0397074b59a4ebbf01", + "url": "https://api.github.com/repos/zenstruck/messenger-test/zipball/bb2446c9fa50d513a896dcb914392bac1f052f82", + "reference": "bb2446c9fa50d513a896dcb914392bac1f052f82", "shasum": "" }, "require": { @@ -10198,7 +10531,7 @@ ], "support": { "issues": "https://github.com/zenstruck/messenger-test/issues", - "source": "https://github.com/zenstruck/messenger-test/tree/v1.14.0" + "source": "https://github.com/zenstruck/messenger-test/tree/v1.13.0" }, "funding": [ { @@ -10210,7 +10543,7 @@ "type": "github" } ], - "time": "2026-02-11T10:02:00+00:00" + "time": "2025-12-06T23:12:43+00:00" } ], "packages-dev": [ @@ -10461,11 +10794,11 @@ }, { "name": "phpstan/phpstan", - "version": "2.1.46", + "version": "2.1.38", "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpstan/phpstan/zipball/a193923fc2d6325ef4e741cf3af8c3e8f54dbf25", - "reference": "a193923fc2d6325ef4e741cf3af8c3e8f54dbf25", + "url": "https://api.github.com/repos/phpstan/phpstan/zipball/dfaf1f530e1663aa167bc3e52197adb221582629", + "reference": "dfaf1f530e1663aa167bc3e52197adb221582629", "shasum": "" }, "require": { @@ -10510,7 +10843,7 @@ "type": "github" } ], - "time": "2026-04-01T09:25:14+00:00" + "time": "2026-01-30T17:12:46+00:00" }, { "name": "phpunit/php-code-coverage", @@ -10861,16 +11194,16 @@ }, { "name": "phpunit/phpunit", - "version": "11.5.55", + "version": "11.5.52", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/phpunit.git", - "reference": "adc7262fccc12de2b30f12a8aa0b33775d814f00" + "reference": "b287d32c26f78768e391843c5a59395f24b62605" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/adc7262fccc12de2b30f12a8aa0b33775d814f00", - "reference": "adc7262fccc12de2b30f12a8aa0b33775d814f00", + "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/b287d32c26f78768e391843c5a59395f24b62605", + "reference": "b287d32c26f78768e391843c5a59395f24b62605", "shasum": "" }, "require": { @@ -10943,7 +11276,7 @@ "support": { "issues": "https://github.com/sebastianbergmann/phpunit/issues", "security": "https://github.com/sebastianbergmann/phpunit/security/policy", - "source": "https://github.com/sebastianbergmann/phpunit/tree/11.5.55" + "source": "https://github.com/sebastianbergmann/phpunit/tree/11.5.52" }, "funding": [ { @@ -10967,7 +11300,7 @@ "type": "tidelift" } ], - "time": "2026-02-18T12:37:06+00:00" + "time": "2026-02-08T07:05:14+00:00" }, { "name": "sebastian/cli-parser", @@ -12009,16 +12342,16 @@ }, { "name": "symfony/browser-kit", - "version": "v7.4.8", + "version": "v7.4.4", "source": { "type": "git", "url": "https://github.com/symfony/browser-kit.git", - "reference": "41850d8f8ddef9a9cd7314fa9f4902cf48885521" + "reference": "bed167eadaaba641f51fc842c9227aa5e251309e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/browser-kit/zipball/41850d8f8ddef9a9cd7314fa9f4902cf48885521", - "reference": "41850d8f8ddef9a9cd7314fa9f4902cf48885521", + "url": "https://api.github.com/repos/symfony/browser-kit/zipball/bed167eadaaba641f51fc842c9227aa5e251309e", + "reference": "bed167eadaaba641f51fc842c9227aa5e251309e", "shasum": "" }, "require": { @@ -12058,7 +12391,7 @@ "description": "Simulates the behavior of a web browser, allowing you to make requests, click on links and submit forms programmatically", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/browser-kit/tree/v7.4.8" + "source": "https://github.com/symfony/browser-kit/tree/v7.4.4" }, "funding": [ { @@ -12078,24 +12411,23 @@ "type": "tidelift" } ], - "time": "2026-03-24T13:12:05+00:00" + "time": "2026-01-13T10:40:19+00:00" }, { "name": "symfony/maker-bundle", - "version": "v1.67.0", + "version": "v1.65.1", "source": { "type": "git", "url": "https://github.com/symfony/maker-bundle.git", - "reference": "6ce8b313845f16bcf385ee3cb31d8b24e30d5516" + "reference": "eba30452d212769c9a5bcf0716959fd8ba1e54e3" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/maker-bundle/zipball/6ce8b313845f16bcf385ee3cb31d8b24e30d5516", - "reference": "6ce8b313845f16bcf385ee3cb31d8b24e30d5516", + "url": "https://api.github.com/repos/symfony/maker-bundle/zipball/eba30452d212769c9a5bcf0716959fd8ba1e54e3", + "reference": "eba30452d212769c9a5bcf0716959fd8ba1e54e3", "shasum": "" }, "require": { - "composer-runtime-api": "^2.1", "doctrine/inflector": "^2.0", "nikic/php-parser": "^5.0", "php": ">=8.1", @@ -12115,7 +12447,7 @@ }, "require-dev": { "composer/semver": "^3.0", - "doctrine/doctrine-bundle": "^2.10|^3.0", + "doctrine/doctrine-bundle": "^2.5.0|^3.0.0", "doctrine/orm": "^2.15|^3", "doctrine/persistence": "^3.1|^4.0", "symfony/http-client": "^6.4|^7.0|^8.0", @@ -12157,7 +12489,7 @@ ], "support": { "issues": "https://github.com/symfony/maker-bundle/issues", - "source": "https://github.com/symfony/maker-bundle/tree/v1.67.0" + "source": "https://github.com/symfony/maker-bundle/tree/v1.65.1" }, "funding": [ { @@ -12177,20 +12509,20 @@ "type": "tidelift" } ], - "time": "2026-03-18T13:39:06+00:00" + "time": "2025-12-02T07:14:37+00:00" }, { "name": "symfony/phpunit-bridge", - "version": "v7.4.8", + "version": "v7.4.3", "source": { "type": "git", "url": "https://github.com/symfony/phpunit-bridge.git", - "reference": "140bbbe1cd1c21a084494ccddeee33f3c3381d7d" + "reference": "f933e68bb9df29d08077a37e1515a23fea8562ab" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/phpunit-bridge/zipball/140bbbe1cd1c21a084494ccddeee33f3c3381d7d", - "reference": "140bbbe1cd1c21a084494ccddeee33f3c3381d7d", + "url": "https://api.github.com/repos/symfony/phpunit-bridge/zipball/f933e68bb9df29d08077a37e1515a23fea8562ab", + "reference": "f933e68bb9df29d08077a37e1515a23fea8562ab", "shasum": "" }, "require": { @@ -12242,7 +12574,7 @@ "testing" ], "support": { - "source": "https://github.com/symfony/phpunit-bridge/tree/v7.4.8" + "source": "https://github.com/symfony/phpunit-bridge/tree/v7.4.3" }, "funding": [ { @@ -12262,20 +12594,20 @@ "type": "tidelift" } ], - "time": "2026-03-24T13:12:05+00:00" + "time": "2025-12-09T15:33:45+00:00" }, { "name": "symfony/process", - "version": "v7.4.8", + "version": "v7.4.5", "source": { "type": "git", "url": "https://github.com/symfony/process.git", - "reference": "60f19cd3badc8de688421e21e4305eba50f8089a" + "reference": "608476f4604102976d687c483ac63a79ba18cc97" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/process/zipball/60f19cd3badc8de688421e21e4305eba50f8089a", - "reference": "60f19cd3badc8de688421e21e4305eba50f8089a", + "url": "https://api.github.com/repos/symfony/process/zipball/608476f4604102976d687c483ac63a79ba18cc97", + "reference": "608476f4604102976d687c483ac63a79ba18cc97", "shasum": "" }, "require": { @@ -12307,7 +12639,7 @@ "description": "Executes commands in sub-processes", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/process/tree/v7.4.8" + "source": "https://github.com/symfony/process/tree/v7.4.5" }, "funding": [ { @@ -12327,20 +12659,20 @@ "type": "tidelift" } ], - "time": "2026-03-24T13:12:05+00:00" + "time": "2026-01-26T15:07:59+00:00" }, { "name": "symfony/web-profiler-bundle", - "version": "v7.4.8", + "version": "v7.4.4", "source": { "type": "git", "url": "https://github.com/symfony/web-profiler-bundle.git", - "reference": "79f039096c67cc1cc3f607d2ba72af86cd27e6a4" + "reference": "be165e29e6109efb89bfaefe56e3deccf72a8643" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/web-profiler-bundle/zipball/79f039096c67cc1cc3f607d2ba72af86cd27e6a4", - "reference": "79f039096c67cc1cc3f607d2ba72af86cd27e6a4", + "url": "https://api.github.com/repos/symfony/web-profiler-bundle/zipball/be165e29e6109efb89bfaefe56e3deccf72a8643", + "reference": "be165e29e6109efb89bfaefe56e3deccf72a8643", "shasum": "" }, "require": { @@ -12397,7 +12729,7 @@ "dev" ], "support": { - "source": "https://github.com/symfony/web-profiler-bundle/tree/v7.4.8" + "source": "https://github.com/symfony/web-profiler-bundle/tree/v7.4.4" }, "funding": [ { @@ -12417,7 +12749,7 @@ "type": "tidelift" } ], - "time": "2026-03-24T13:12:05+00:00" + "time": "2026-01-07T11:56:45+00:00" }, { "name": "theseer/tokenizer", diff --git a/backend/config/bundles.php b/backend/config/bundles.php index 0e09a6366..2db7f2726 100644 --- a/backend/config/bundles.php +++ b/backend/config/bundles.php @@ -17,4 +17,5 @@ Symfony\UX\TwigComponent\TwigComponentBundle::class => ['all' => true], Twig\Extra\TwigExtraBundle\TwigExtraBundle::class => ['all' => true], Zenstruck\Messenger\Monitor\ZenstruckMessengerMonitorBundle::class => ['all' => true], + Sentry\SentryBundle\SentryBundle::class => ['prod' => true], ]; diff --git a/backend/config/packages/monolog.php b/backend/config/packages/monolog.php index 1c667be44..fb0a87268 100644 --- a/backend/config/packages/monolog.php +++ b/backend/config/packages/monolog.php @@ -9,7 +9,6 @@ ->type('buffer') ->handler('final') ->level("%env(LOG_LEVEL)%") - ->bubble(false) ->channels()->elements(['app']); $monolog->handler('non_app') ->type('buffer') diff --git a/backend/config/packages/sentry.yaml b/backend/config/packages/sentry.yaml new file mode 100644 index 000000000..81ad7b5c2 --- /dev/null +++ b/backend/config/packages/sentry.yaml @@ -0,0 +1,39 @@ +when@dev: + sentry: + dsn: '%env(SENTRY_DSN)%' + options: + # Add request headers, cookies, IP address and the authenticated user + # see https://docs.sentry.io/platforms/php/data-management/data-collected/ for more info + # send_default_pii: true + ignore_exceptions: + - 'Symfony\Component\ErrorHandler\Error\FatalError' + - 'Symfony\Component\Debug\Exception\FatalErrorException' +# +# # If you are using Monolog, you also need this additional configuration to log the errors correctly: +# # https://docs.sentry.io/platforms/php/guides/symfony/integrations/monolog/ +# register_error_listener: false +# register_error_handler: false +# + monolog: + handlers: + # Use this only if you don't want to use structured logging and instead receive + # certain log levels as errors. + sentry: + type: service + id: Sentry\Monolog\Handler +# +# # Use this for structured log integration +# sentry_logs: +# type: service +# id: Sentry\SentryBundle\Monolog\LogsHandler +# +# # Enable one of the two services below, depending on your choice above + services: + Sentry\Monolog\Handler: + arguments: + $hub: '@Sentry\State\HubInterface' + $level: !php/const Monolog\Logger::ERROR + $fillExtraContext: true # Enables sending monolog context to Sentry +# Sentry\SentryBundle\Monolog\LogsHandler: +# arguments: +# - !php/const Monolog\Logger::INFO diff --git a/backend/config/reference.php b/backend/config/reference.php index fa8fa85ec..006f11be3 100644 --- a/backend/config/reference.php +++ b/backend/config/reference.php @@ -1320,6 +1320,85 @@ * expired_worker_ttl?: int|Param, // How long to keep expired workers in cache (in seconds). // Default: 3600 * }, * } + * @psalm-type SentryConfig = array{ + * dsn?: scalar|Param|null, // If this value is not provided, the SDK will try to read it from the SENTRY_DSN environment variable. If that variable also does not exist, the SDK will not send any events. + * register_error_listener?: bool|Param, // Default: true + * register_error_handler?: bool|Param, // Default: true + * logger?: scalar|Param|null, // The service ID of the PSR-3 logger used to log messages coming from the SDK client. Be aware that setting the same logger of the application may create a circular loop when an event fails to be sent. // Default: null + * options?: array{ + * integrations?: mixed, // Default: [] + * default_integrations?: bool|Param, + * prefixes?: list, + * sample_rate?: float|Param, // The sampling factor to apply to events. A value of 0 will deny sending any event, and a value of 1 will send all events. + * enable_tracing?: bool|Param, + * traces_sample_rate?: float|Param, // The sampling factor to apply to transactions. A value of 0 will deny sending any transaction, and a value of 1 will send all transactions. + * traces_sampler?: scalar|Param|null, + * profiles_sample_rate?: float|Param, // The sampling factor to apply to profiles. A value of 0 will deny sending any profiles, and a value of 1 will send all profiles. Profiles are sampled in relation to traces_sample_rate + * enable_logs?: bool|Param, + * enable_metrics?: bool|Param, // Default: true + * attach_stacktrace?: bool|Param, + * attach_metric_code_locations?: bool|Param, + * context_lines?: int|Param, + * environment?: scalar|Param|null, // Default: "%kernel.environment%" + * logger?: scalar|Param|null, + * spotlight?: bool|Param, + * spotlight_url?: scalar|Param|null, + * release?: scalar|Param|null, // Default: "%env(default::SENTRY_RELEASE)%" + * server_name?: scalar|Param|null, + * ignore_exceptions?: list, + * ignore_transactions?: list, + * before_send?: scalar|Param|null, + * before_send_transaction?: scalar|Param|null, + * before_send_check_in?: scalar|Param|null, + * before_send_metrics?: scalar|Param|null, + * before_send_log?: scalar|Param|null, + * before_send_metric?: scalar|Param|null, + * trace_propagation_targets?: mixed, + * tags?: array, + * error_types?: scalar|Param|null, + * max_breadcrumbs?: int|Param, + * before_breadcrumb?: mixed, + * in_app_exclude?: list, + * in_app_include?: list, + * send_default_pii?: bool|Param, + * max_value_length?: int|Param, + * transport?: scalar|Param|null, + * http_client?: scalar|Param|null, + * http_proxy?: scalar|Param|null, + * http_proxy_authentication?: scalar|Param|null, + * http_connect_timeout?: float|Param, // The maximum number of seconds to wait while trying to connect to a server. It works only when using the default transport. + * http_timeout?: float|Param, // The maximum execution time for the request+response as a whole. It works only when using the default transport. + * http_ssl_verify_peer?: bool|Param, + * http_compression?: bool|Param, + * capture_silenced_errors?: bool|Param, + * max_request_body_size?: "none"|"never"|"small"|"medium"|"always"|Param, + * class_serializers?: array, + * }, + * messenger?: bool|array{ + * enabled?: bool|Param, // Default: true + * capture_soft_fails?: bool|Param, // Default: true + * isolate_breadcrumbs_by_message?: bool|Param, // Default: false + * }, + * tracing?: bool|array{ + * enabled?: bool|Param, // Default: true + * dbal?: bool|array{ + * enabled?: bool|Param, // Default: true + * connections?: list, + * }, + * twig?: bool|array{ + * enabled?: bool|Param, // Default: true + * }, + * cache?: bool|array{ + * enabled?: bool|Param, // Default: true + * }, + * http_client?: bool|array{ + * enabled?: bool|Param, // Default: true + * }, + * console?: array{ + * excluded_commands?: list, + * }, + * }, + * } * @psalm-type ConfigType = array{ * imports?: ImportsConfig, * parameters?: ParametersConfig, @@ -1335,6 +1414,7 @@ * twig_component?: TwigComponentConfig, * twig_extra?: TwigExtraConfig, * zenstruck_messenger_monitor?: ZenstruckMessengerMonitorConfig, + * sentry?: SentryConfig, * "when@dev"?: array{ * imports?: ImportsConfig, * parameters?: ParametersConfig, @@ -1353,6 +1433,7 @@ * twig_component?: TwigComponentConfig, * twig_extra?: TwigExtraConfig, * zenstruck_messenger_monitor?: ZenstruckMessengerMonitorConfig, + * sentry?: SentryConfig, * }, * "when@prod"?: array{ * imports?: ImportsConfig, @@ -1369,6 +1450,7 @@ * twig_component?: TwigComponentConfig, * twig_extra?: TwigExtraConfig, * zenstruck_messenger_monitor?: ZenstruckMessengerMonitorConfig, + * sentry?: SentryConfig, * }, * "when@test"?: array{ * imports?: ImportsConfig, @@ -1388,6 +1470,7 @@ * twig_component?: TwigComponentConfig, * twig_extra?: TwigExtraConfig, * zenstruck_messenger_monitor?: ZenstruckMessengerMonitorConfig, + * sentry?: SentryConfig, * }, * ... Date: Thu, 26 Feb 2026 10:04:58 +0100 Subject: [PATCH 03/43] subscriber lists done --- backend/.env | 9 +- backend/.gitignore | 1 + backend/config/reference.php | 3 - backend/migrations/Version20260225000000.php | 35 +++ .../Controller/SubscriberController.php | 64 +++++- .../Subscriber/AddSubscriberListInput.php | 10 + .../Subscriber/RemoveSubscriberListInput.php | 10 + .../Subscriber/RemoveSubscriberListReason.php | 9 + .../SubscriberListIfUnsubscribed.php | 9 + .../src/Entity/SubscriberListUnsubscribed.php | 65 ++++++ .../NewsletterList/NewsletterListService.php | 17 ++ .../Service/Subscriber/SubscriberService.php | 60 ++++++ .../Subscriber/AddSubscriberListTest.php | 201 ++++++++++++++++++ .../Subscriber/CreateSubscriberTest.php | 2 - .../Subscriber/RemoveSubscriberListTest.php | 192 +++++++++++++++++ .../SubscriberListUnsubscribedFactory.php | 38 ++++ 16 files changed, 714 insertions(+), 11 deletions(-) create mode 100644 backend/migrations/Version20260225000000.php create mode 100644 backend/src/Api/Console/Input/Subscriber/AddSubscriberListInput.php create mode 100644 backend/src/Api/Console/Input/Subscriber/RemoveSubscriberListInput.php create mode 100644 backend/src/Api/Console/Input/Subscriber/RemoveSubscriberListReason.php create mode 100644 backend/src/Api/Console/Input/Subscriber/SubscriberListIfUnsubscribed.php create mode 100644 backend/src/Entity/SubscriberListUnsubscribed.php create mode 100644 backend/tests/Api/Console/Subscriber/AddSubscriberListTest.php create mode 100644 backend/tests/Api/Console/Subscriber/RemoveSubscriberListTest.php create mode 100644 backend/tests/Factory/SubscriberListUnsubscribedFactory.php diff --git a/backend/.env b/backend/.env index 2bc3d9ce0..2526076b6 100644 --- a/backend/.env +++ b/backend/.env @@ -31,7 +31,6 @@ URL_ARCHIVE=https://hyvorpost.email # One of: debug, info, notice, warning, error, critical, alert, emergency LOG_LEVEL=info - ### ============ HYVOR CLOUD ============ ### # Deployment type @@ -54,13 +53,11 @@ COMMS_KEY= # @deprecated. Migrate to DEPLOYMENT env IS_CLOUD=true - ### ============ DATABASE ============ ### # PostgreSQL database is the single source of truth DATABASE_URL="" - ### ============ MAIL ============ ### # Hyvor Relay configuration @@ -87,7 +84,6 @@ NOTIFICATION_MAIL_REPLY_TO= # If not set, if will fallback to RELAY_API_KEY NOTIFICATION_RELAY_API_KEY= - ### ============ FILE STORAGE ============ ### # You can use any S3-compatibly storage like @@ -100,6 +96,10 @@ S3_ACCESS_KEY_ID=key-id S3_SECRET_ACCESS_KEY=access-key S3_BUCKET=hyvor-post +### ============ INTEGRATIONS ============ ### + +# Sentry-compatible DSN for error tracking +SENTRY_DSN= ### ============ SCALING ============ ### @@ -107,7 +107,6 @@ S3_BUCKET=hyvor-post # Default is x2 CPUs WORKERS= - ### ============ DOCKER IMAGE ============ ### # Defaults (do not change or add to docs): diff --git a/backend/.gitignore b/backend/.gitignore index 05abde281..67fd15a83 100644 --- a/backend/.gitignore +++ b/backend/.gitignore @@ -2,6 +2,7 @@ /.env.dev.local /.env.local.php /.env.*.local +/.env.local /config/secrets/prod/prod.decrypt.private.php /public/bundles/ /var/ diff --git a/backend/config/reference.php b/backend/config/reference.php index 006f11be3..3b4449954 100644 --- a/backend/config/reference.php +++ b/backend/config/reference.php @@ -1414,7 +1414,6 @@ * twig_component?: TwigComponentConfig, * twig_extra?: TwigExtraConfig, * zenstruck_messenger_monitor?: ZenstruckMessengerMonitorConfig, - * sentry?: SentryConfig, * "when@dev"?: array{ * imports?: ImportsConfig, * parameters?: ParametersConfig, @@ -1433,7 +1432,6 @@ * twig_component?: TwigComponentConfig, * twig_extra?: TwigExtraConfig, * zenstruck_messenger_monitor?: ZenstruckMessengerMonitorConfig, - * sentry?: SentryConfig, * }, * "when@prod"?: array{ * imports?: ImportsConfig, @@ -1470,7 +1468,6 @@ * twig_component?: TwigComponentConfig, * twig_extra?: TwigExtraConfig, * zenstruck_messenger_monitor?: ZenstruckMessengerMonitorConfig, - * sentry?: SentryConfig, * }, * ...addSql(<<addSql('DROP TABLE list_subscriber_unsubscribed'); + } +} diff --git a/backend/src/Api/Console/Controller/SubscriberController.php b/backend/src/Api/Console/Controller/SubscriberController.php index 9163a7e10..29d76aa7b 100644 --- a/backend/src/Api/Console/Controller/SubscriberController.php +++ b/backend/src/Api/Console/Controller/SubscriberController.php @@ -4,9 +4,13 @@ use App\Api\Console\Authorization\Scope; use App\Api\Console\Authorization\ScopeRequired; +use App\Api\Console\Input\Subscriber\AddSubscriberListInput; use App\Api\Console\Input\Subscriber\BulkActionSubscriberInput; use App\Api\Console\Input\Subscriber\CreateSubscriberIfExists; use App\Api\Console\Input\Subscriber\CreateSubscriberInput; +use App\Api\Console\Input\Subscriber\RemoveSubscriberListInput; +use App\Api\Console\Input\Subscriber\RemoveSubscriberListReason; +use App\Api\Console\Input\Subscriber\SubscriberListIfUnsubscribed; use App\Api\Console\Input\Subscriber\UpdateSubscriberInput; use App\Api\Console\Object\SubscriberObject; use App\Entity\Newsletter; @@ -31,7 +35,7 @@ class SubscriberController extends AbstractController public function __construct( private SubscriberService $subscriberService, private NewsletterListService $newsletterListService, - private SubscriberMetadataService $subscriberMetadataService + private SubscriberMetadataService $subscriberMetadataService, ) { } @@ -272,4 +276,62 @@ public function bulkActions(Newsletter $newsletter, #[MapRequestPayload] BulkAct throw new BadRequestHttpException("Unhandled action"); } + + #[Route('/subscribers/{id}/lists', methods: 'POST')] + #[ScopeRequired(Scope::SUBSCRIBERS_WRITE)] + public function addSubscriberList( + Subscriber $subscriber, + Newsletter $newsletter, + #[MapRequestPayload] AddSubscriberListInput $input + ): JsonResponse + { + if ($input->id === null && $input->name === null) { + throw new UnprocessableEntityHttpException('Either id or name must be provided'); + } + + $list = $this->newsletterListService->getListByIdOrName($newsletter, $input->id, $input->name); + + if ($list === null) { + throw new UnprocessableEntityHttpException('List not found'); + } + + try { + $this->subscriberService->addSubscriberToList( + $subscriber, + $list, + $input->if_unsubscribed === SubscriberListIfUnsubscribed::ERROR + ); + } catch (\RuntimeException $e) { + throw new UnprocessableEntityHttpException($e->getMessage()); + } + + return $this->json(new SubscriberObject($subscriber)); + } + + #[Route('/subscribers/{id}/lists', methods: 'DELETE')] + #[ScopeRequired(Scope::SUBSCRIBERS_WRITE)] + public function removeSubscriberList( + Subscriber $subscriber, + Newsletter $newsletter, + #[MapRequestPayload] RemoveSubscriberListInput $input + ): JsonResponse + { + if ($input->id === null && $input->name === null) { + throw new UnprocessableEntityHttpException('Either id or name must be provided'); + } + + $list = $this->newsletterListService->getListByIdOrName($newsletter, $input->id, $input->name); + + if ($list === null) { + throw new UnprocessableEntityHttpException('List not found'); + } + + $this->subscriberService->removeSubscriberFromList( + $subscriber, + $list, + $input->reason === RemoveSubscriberListReason::UNSUBSCRIBE + ); + + return $this->json(new SubscriberObject($subscriber)); + } } diff --git a/backend/src/Api/Console/Input/Subscriber/AddSubscriberListInput.php b/backend/src/Api/Console/Input/Subscriber/AddSubscriberListInput.php new file mode 100644 index 000000000..db45af811 --- /dev/null +++ b/backend/src/Api/Console/Input/Subscriber/AddSubscriberListInput.php @@ -0,0 +1,10 @@ +id; + } + + public function getList(): NewsletterList + { + return $this->list; + } + + public function setList(NewsletterList $list): static + { + $this->list = $list; + return $this; + } + + public function getSubscriber(): Subscriber + { + return $this->subscriber; + } + + public function setSubscriber(Subscriber $subscriber): static + { + $this->subscriber = $subscriber; + return $this; + } + + public function getCreatedAt(): \DateTimeImmutable + { + return $this->created_at; + } + + public function setCreatedAt(\DateTimeImmutable $created_at): static + { + $this->created_at = $created_at; + return $this; + } +} diff --git a/backend/src/Service/NewsletterList/NewsletterListService.php b/backend/src/Service/NewsletterList/NewsletterListService.php index 4e6612a1c..fc2b7c11e 100644 --- a/backend/src/Service/NewsletterList/NewsletterListService.php +++ b/backend/src/Service/NewsletterList/NewsletterListService.php @@ -137,6 +137,23 @@ public function getListsByIds(array $listIds): ArrayCollection ); } + public function getListByIdOrName(Newsletter $newsletter, ?int $id, ?string $name): ?NewsletterList + { + assert($id !== null || $name !== null, 'Either id or name must be provided'); + + if ($id !== null) { + return $this->em->getRepository(NewsletterList::class)->findOneBy([ + 'id' => $id, + 'newsletter' => $newsletter, + ]); + } + + return $this->em->getRepository(NewsletterList::class)->findOneBy([ + 'name' => $name, + 'newsletter' => $newsletter, + ]); + } + /** * @param int[] $listIds * @return array key: list_id, value: subscriber_count diff --git a/backend/src/Service/Subscriber/SubscriberService.php b/backend/src/Service/Subscriber/SubscriberService.php index 43d339457..1ff7528fd 100644 --- a/backend/src/Service/Subscriber/SubscriberService.php +++ b/backend/src/Service/Subscriber/SubscriberService.php @@ -8,6 +8,7 @@ use App\Entity\Send; use App\Entity\Subscriber; use App\Entity\SubscriberExport; +use App\Entity\SubscriberListUnsubscribed; use App\Entity\Type\SubscriberExportStatus; use App\Entity\Type\SubscriberSource; use App\Entity\Type\SubscriberStatus; @@ -301,6 +302,65 @@ public function getExports(Newsletter $newsletter): array ->findBy(['newsletter' => $newsletter], ['created_at' => 'DESC']); } + /** + * @throws \RuntimeException if $checkUnsubscribed is true and the subscriber has a prior unsubscription record + */ + public function addSubscriberToList( + Subscriber $subscriber, + NewsletterList $list, + bool $checkUnsubscribed + ): void + { + if ($checkUnsubscribed) { + $record = $this->em->getRepository(SubscriberListUnsubscribed::class)->findOneBy([ + 'list' => $list, + 'subscriber' => $subscriber, + ]); + + if ($record !== null) { + throw new \RuntimeException('Subscriber has previously unsubscribed from this list'); + } + } + + $subscriber->addList($list); + $subscriber->setUpdatedAt($this->now()); + + $this->em->persist($subscriber); + $this->em->flush(); + } + + public function removeSubscriberFromList( + Subscriber $subscriber, + NewsletterList $list, + bool $recordUnsubscription + ): void + { + $subscriber->removeList($list); + $subscriber->setUpdatedAt($this->now()); + + $this->em->persist($subscriber); + $this->em->flush(); + + + if ($recordUnsubscription) { + $existing = $this->em->getRepository(SubscriberListUnsubscribed::class)->findOneBy([ + 'list' => $list, + 'subscriber' => $subscriber, + ]); + + if ($existing === null) { + $unsubscribed = new SubscriberListUnsubscribed() + ->setList($list) + ->setSubscriber($subscriber) + ->setCreatedAt($this->now()); + + $this->em->persist($unsubscribed); + $this->em->persist($list); // test fails otherwise, since this is used in removeElement, but sure why + $this->em->flush(); + } + } + } + public function getSubscriberById(int $id): ?Subscriber { return $this->subscriberRepository->find($id); diff --git a/backend/tests/Api/Console/Subscriber/AddSubscriberListTest.php b/backend/tests/Api/Console/Subscriber/AddSubscriberListTest.php new file mode 100644 index 000000000..84e16ba2d --- /dev/null +++ b/backend/tests/Api/Console/Subscriber/AddSubscriberListTest.php @@ -0,0 +1,201 @@ + $newsletter]); + $subscriber = SubscriberFactory::createOne(['newsletter' => $newsletter]); + + $response = $this->consoleApi( + $newsletter, + 'POST', + '/subscribers/' . $subscriber->getId() . '/lists', + ['id' => $list->getId()] + ); + + $this->assertSame(200, $response->getStatusCode()); + + $subscriberDb = $this->em->getRepository(Subscriber::class)->find($subscriber->getId()); + $this->assertInstanceOf(Subscriber::class, $subscriberDb); + $this->assertCount(1, $subscriberDb->getLists()); + $this->assertSame($list->getId(), $subscriberDb->getLists()->first()->getId()); + } + + public function testAddSubscriberToListByName(): void + { + $newsletter = NewsletterFactory::createOne(); + $list = NewsletterListFactory::createOne(['newsletter' => $newsletter, 'name' => 'My List']); + $subscriber = SubscriberFactory::createOne(['newsletter' => $newsletter]); + + $response = $this->consoleApi( + $newsletter, + 'POST', + '/subscribers/' . $subscriber->getId() . '/lists', + ['name' => 'My List'] + ); + + $this->assertSame(200, $response->getStatusCode()); + + $subscriberDb = $this->em->getRepository(Subscriber::class)->find($subscriber->getId()); + $this->assertInstanceOf(Subscriber::class, $subscriberDb); + $this->assertCount(1, $subscriberDb->getLists()); + $this->assertSame($list->getId(), $subscriberDb->getLists()->first()->getId()); + } + + public function testAddSubscriberToListValidation(): void + { + $newsletter = NewsletterFactory::createOne(); + $subscriber = SubscriberFactory::createOne(['newsletter' => $newsletter]); + + $response = $this->consoleApi( + $newsletter, + 'POST', + '/subscribers/' . $subscriber->getId() . '/lists', + [] + ); + + $this->assertSame(422, $response->getStatusCode()); + $this->assertSame('Either id or name must be provided', $this->getJson()['message']); + } + + public function testAddSubscriberToListNotFound(): void + { + $newsletter = NewsletterFactory::createOne(); + $subscriber = SubscriberFactory::createOne(['newsletter' => $newsletter]); + + $response = $this->consoleApi( + $newsletter, + 'POST', + '/subscribers/' . $subscriber->getId() . '/lists', + ['id' => 999999] + ); + + $this->assertSame(422, $response->getStatusCode()); + $this->assertSame('List not found', $this->getJson()['message']); + } + + public function testAddSubscriberToListAlreadyInList(): void + { + $newsletter = NewsletterFactory::createOne(); + $list = NewsletterListFactory::createOne(['newsletter' => $newsletter]); + $subscriber = SubscriberFactory::createOne(['newsletter' => $newsletter, 'lists' => [$list]]); + + $response = $this->consoleApi( + $newsletter, + 'POST', + '/subscribers/' . $subscriber->getId() . '/lists', + ['id' => $list->getId()] + ); + + $this->assertSame(200, $response->getStatusCode()); + + // Still only in the list once (idempotent) + $subscriberDb = $this->em->getRepository(Subscriber::class)->find($subscriber->getId()); + $this->assertInstanceOf(Subscriber::class, $subscriberDb); + $this->assertCount(1, $subscriberDb->getLists()); + } + + public function testAddSubscriberToListIfUnsubscribedError(): void + { + $newsletter = NewsletterFactory::createOne(); + $list = NewsletterListFactory::createOne(['newsletter' => $newsletter]); + $subscriber = SubscriberFactory::createOne(['newsletter' => $newsletter]); + + SubscriberListUnsubscribedFactory::createOne([ + 'list' => $list, + 'subscriber' => $subscriber, + ]); + + $response = $this->consoleApi( + $newsletter, + 'POST', + '/subscribers/' . $subscriber->getId() . '/lists', + ['id' => $list->getId(), 'if_unsubscribed' => 'error'] + ); + + $this->assertSame(422, $response->getStatusCode()); + $this->assertSame( + 'Subscriber has previously unsubscribed from this list', + $this->getJson()['message'] + ); + } + + public function testAddSubscriberToListIfUnsubscribedForceCreate(): void + { + $newsletter = NewsletterFactory::createOne(); + $list = NewsletterListFactory::createOne(['newsletter' => $newsletter]); + $subscriber = SubscriberFactory::createOne(['newsletter' => $newsletter]); + + SubscriberListUnsubscribedFactory::createOne([ + 'list' => $list, + 'subscriber' => $subscriber, + ]); + + $response = $this->consoleApi( + $newsletter, + 'POST', + '/subscribers/' . $subscriber->getId() . '/lists', + ['id' => $list->getId(), 'if_unsubscribed' => 'force_create'] + ); + + $this->assertSame(200, $response->getStatusCode()); + + $subscriberDb = $this->em->getRepository(Subscriber::class)->find($subscriber->getId()); + $this->assertInstanceOf(Subscriber::class, $subscriberDb); + $this->assertCount(1, $subscriberDb->getLists()); + } + + public function testCannotAddSubscriberOfOtherNewsletter(): void + { + $newsletter1 = NewsletterFactory::createOne(); + $newsletter2 = NewsletterFactory::createOne(); + $list = NewsletterListFactory::createOne(['newsletter' => $newsletter1]); + $subscriber = SubscriberFactory::createOne(['newsletter' => $newsletter1]); + + $response = $this->consoleApi( + $newsletter2, + 'POST', + '/subscribers/' . $subscriber->getId() . '/lists', + ['id' => $list->getId()] + ); + + $this->assertSame(403, $response->getStatusCode()); + $this->assertSame('Entity does not belong to the newsletter', $this->getJson()['message']); + } + + public function testCannotAddListOfOtherNewsletter(): void + { + $newsletter1 = NewsletterFactory::createOne(); + $newsletter2 = NewsletterFactory::createOne(); + $listOfNewsletter2 = NewsletterListFactory::createOne(['newsletter' => $newsletter2]); + $subscriber = SubscriberFactory::createOne(['newsletter' => $newsletter1]); + + $response = $this->consoleApi( + $newsletter1, + 'POST', + '/subscribers/' . $subscriber->getId() . '/lists', + ['id' => $listOfNewsletter2->getId()] + ); + + $this->assertSame(422, $response->getStatusCode()); + $this->assertSame('List not found', $this->getJson()['message']); + } +} diff --git a/backend/tests/Api/Console/Subscriber/CreateSubscriberTest.php b/backend/tests/Api/Console/Subscriber/CreateSubscriberTest.php index d47c2157f..b29d266c8 100644 --- a/backend/tests/Api/Console/Subscriber/CreateSubscriberTest.php +++ b/backend/tests/Api/Console/Subscriber/CreateSubscriberTest.php @@ -40,8 +40,6 @@ public function test_test(): void ] ); - dd($response->getContent()); - } public function testCreateSubscriberMinimal(): void diff --git a/backend/tests/Api/Console/Subscriber/RemoveSubscriberListTest.php b/backend/tests/Api/Console/Subscriber/RemoveSubscriberListTest.php new file mode 100644 index 000000000..d19db0d1b --- /dev/null +++ b/backend/tests/Api/Console/Subscriber/RemoveSubscriberListTest.php @@ -0,0 +1,192 @@ + $newsletter]); + $subscriber = SubscriberFactory::createOne(['newsletter' => $newsletter, 'lists' => [$list]]); + + $response = $this->consoleApi( + $newsletter, + 'DELETE', + '/subscribers/' . $subscriber->getId() . '/lists', + ['id' => $list->getId()] + ); + + $this->assertSame(200, $response->getStatusCode()); + + $this->em->clear(); + $subscriberDb = $this->em->getRepository(Subscriber::class)->find($subscriber->getId()); + $this->assertInstanceOf(Subscriber::class, $subscriberDb); + $this->assertCount(0, $subscriberDb->getLists()); + } + + public function testRemoveSubscriberFromListByName(): void + { + $newsletter = NewsletterFactory::createOne(); + $list = NewsletterListFactory::createOne(['newsletter' => $newsletter, 'name' => 'Remove Me']); + $subscriber = SubscriberFactory::createOne(['newsletter' => $newsletter, 'lists' => [$list]]); + + $response = $this->consoleApi( + $newsletter, + 'DELETE', + '/subscribers/' . $subscriber->getId() . '/lists', + ['name' => 'Remove Me'] + ); + + $this->assertSame(200, $response->getStatusCode()); + + $this->em->clear(); + $subscriberDb = $this->em->getRepository(Subscriber::class)->find($subscriber->getId()); + $this->assertInstanceOf(Subscriber::class, $subscriberDb); + $this->assertCount(0, $subscriberDb->getLists()); + } + + public function testRemoveSubscriberFromListValidation(): void + { + $newsletter = NewsletterFactory::createOne(); + $subscriber = SubscriberFactory::createOne(['newsletter' => $newsletter]); + + $response = $this->consoleApi( + $newsletter, + 'DELETE', + '/subscribers/' . $subscriber->getId() . '/lists', + [] + ); + + $this->assertSame(422, $response->getStatusCode()); + $this->assertSame('Either id or name must be provided', $this->getJson()['message']); + } + + public function testRemoveSubscriberFromListNotFound(): void + { + $newsletter = NewsletterFactory::createOne(); + $subscriber = SubscriberFactory::createOne(['newsletter' => $newsletter]); + + $response = $this->consoleApi( + $newsletter, + 'DELETE', + '/subscribers/' . $subscriber->getId() . '/lists', + ['id' => 999999] + ); + + $this->assertSame(422, $response->getStatusCode()); + $this->assertSame('List not found', $this->getJson()['message']); + } + + public function testRemoveSubscriberFromListNotInList(): void + { + $newsletter = NewsletterFactory::createOne(); + $list = NewsletterListFactory::createOne(['newsletter' => $newsletter]); + $subscriber = SubscriberFactory::createOne(['newsletter' => $newsletter]); + + $response = $this->consoleApi( + $newsletter, + 'DELETE', + '/subscribers/' . $subscriber->getId() . '/lists', + ['id' => $list->getId()] + ); + + $this->assertSame(200, $response->getStatusCode()); + } + + public function testRemoveSubscriberFromListWithReasonUnsubscribe(): void + { + $newsletter = NewsletterFactory::createOne(); + $list = NewsletterListFactory::createOne(['newsletter' => $newsletter]); + $subscriber = SubscriberFactory::createOne(['newsletter' => $newsletter, 'lists' => [$list]]); + + $response = $this->consoleApi( + $newsletter, + 'DELETE', + '/subscribers/' . $subscriber->getId() . '/lists', + ['id' => $list->getId(), 'reason' => 'unsubscribe'] + ); + + $this->assertSame(200, $response->getStatusCode()); + + $record = $this->em->getRepository(SubscriberListUnsubscribed::class)->findOneBy([ + 'list' => $list->_real(), + 'subscriber' => $subscriber->_real(), + ]); + + $this->assertInstanceOf(SubscriberListUnsubscribed::class, $record); + } + + public function testRemoveSubscriberFromListWithReasonOther(): void + { + $newsletter = NewsletterFactory::createOne(); + $list = NewsletterListFactory::createOne(['newsletter' => $newsletter]); + $subscriber = SubscriberFactory::createOne(['newsletter' => $newsletter, 'lists' => [$list]]); + + $response = $this->consoleApi( + $newsletter, + 'DELETE', + '/subscribers/' . $subscriber->getId() . '/lists', + ['id' => $list->getId(), 'reason' => 'other'] + ); + + $this->assertSame(200, $response->getStatusCode()); + + $record = $this->em->getRepository(SubscriberListUnsubscribed::class)->findOneBy([ + 'list' => $list->_real(), + 'subscriber' => $subscriber->_real(), + ]); + + $this->assertNull($record); + } + + public function testCannotRemoveSubscriberOfOtherNewsletter(): void + { + $newsletter1 = NewsletterFactory::createOne(); + $newsletter2 = NewsletterFactory::createOne(); + $list = NewsletterListFactory::createOne(['newsletter' => $newsletter1]); + $subscriber = SubscriberFactory::createOne(['newsletter' => $newsletter1, 'lists' => [$list]]); + + $response = $this->consoleApi( + $newsletter2, + 'DELETE', + '/subscribers/' . $subscriber->getId() . '/lists', + ['id' => $list->getId()] + ); + + $this->assertSame(403, $response->getStatusCode()); + $this->assertSame('Entity does not belong to the newsletter', $this->getJson()['message']); + } + + public function testCannotRemoveListOfOtherNewsletter(): void + { + $newsletter1 = NewsletterFactory::createOne(); + $newsletter2 = NewsletterFactory::createOne(); + $listOfNewsletter2 = NewsletterListFactory::createOne(['newsletter' => $newsletter2]); + $subscriber = SubscriberFactory::createOne(['newsletter' => $newsletter1]); + + $response = $this->consoleApi( + $newsletter1, + 'DELETE', + '/subscribers/' . $subscriber->getId() . '/lists', + ['id' => $listOfNewsletter2->getId()] + ); + + $this->assertSame(422, $response->getStatusCode()); + $this->assertSame('List not found', $this->getJson()['message']); + } +} diff --git a/backend/tests/Factory/SubscriberListUnsubscribedFactory.php b/backend/tests/Factory/SubscriberListUnsubscribedFactory.php new file mode 100644 index 000000000..ee3f0660c --- /dev/null +++ b/backend/tests/Factory/SubscriberListUnsubscribedFactory.php @@ -0,0 +1,38 @@ + + */ +final class SubscriberListUnsubscribedFactory extends PersistentProxyObjectFactory +{ + public function __construct() + { + } + + public static function class(): string + { + return SubscriberListUnsubscribed::class; + } + + /** + * @return array + */ + protected function defaults(): array + { + return [ + 'list' => NewsletterListFactory::new(), + 'subscriber' => SubscriberFactory::new(), + 'created_at' => \DateTimeImmutable::createFromMutable(self::faker()->dateTime()), + ]; + } + + protected function initialize(): static + { + return $this; + } +} From 07d310d12920c61514d707d5ad00223482353476 Mon Sep 17 00:00:00 2001 From: Supun Wimalasena Date: Thu, 26 Feb 2026 10:48:25 +0100 Subject: [PATCH 04/43] clean subscriber endpoints --- .../Controller/SubscriberController.php | 45 ++++----- .../Subscriber/CreateSubscriberInput.php | 10 -- .../Subscriber/UpdateSubscriberInput.php | 10 -- .../Public/Controller/Form/FormController.php | 1 - .../Subscriber/Dto/UpdateSubscriberDto.php | 6 +- .../Service/Subscriber/SubscriberService.php | 10 -- .../Subscriber/CreateSubscriberTest.php | 89 ++---------------- .../Subscriber/UpdateSubscriberTest.php | 92 ++----------------- .../docs/[...slug]/content/ConsoleApi.svelte | 48 +++++++++- 9 files changed, 78 insertions(+), 233 deletions(-) diff --git a/backend/src/Api/Console/Controller/SubscriberController.php b/backend/src/Api/Console/Controller/SubscriberController.php index 29d76aa7b..6a3a0e36e 100644 --- a/backend/src/Api/Console/Controller/SubscriberController.php +++ b/backend/src/Api/Console/Controller/SubscriberController.php @@ -85,16 +85,7 @@ public function createSubscriber( ): JsonResponse { - $missingListIds = $this - ->newsletterListService - ->getMissingListIdsOfNewsletter($newsletter, $input->list_ids); - - if ($missingListIds !== null) { - throw new UnprocessableEntityHttpException("List with id {$missingListIds[0]} not found"); - } - $subscriber = $this->subscriberService->getSubscriberByEmail($newsletter, $input->email); - $lists = $this->newsletterListService->getListsByIds($input->list_ids); if ($subscriber === null) { @@ -102,7 +93,7 @@ public function createSubscriber( $subscriber = $this->subscriberService->createSubscriber( $newsletter, $input->email, - $lists, + [], SubscriberStatus::PENDING, source: $input->source ?? SubscriberSource::CONSOLE, subscribeIp: $input->subscribe_ip ?? null, @@ -114,13 +105,24 @@ public function createSubscriber( // update $updates = new UpdateSubscriberDto(); - $updates->lists = $lists; - $updates->status = $input->status; - $updates->subscribedAt = $input->subscribed_at; - $updates->unsubscribedAt = $input->unsubscribed_at; - // TODO: + if ($updates->has('status')) { + $updates->status = $input->status; + } + + if ($updates->has('subscribe_ip')) { + $updates->subscribeIp = $input->subscribe_ip; + } + + if ($updates->has('subscribed_at')) { + $updates->subscribedAt = $input->subscribed_at ? \DateTimeImmutable::createFromTimestamp($input->subscribed_at) : null; + } + + if ($updates->has('unsubscribed_at')) { + $updates->unsubscribedAt = $input->unsubscribed_at ? \DateTimeImmutable::createFromTimestamp($input->unsubscribed_at) : null; + } + $subscriber = $this->subscriberService->updateSubscriber($subscriber, $updates); } else { throw new UnprocessableEntityHttpException("Subscriber with email {$input->email} already exists"); @@ -148,19 +150,6 @@ public function updateSubscriber( $updates->email = $input->email; } - if ($input->has('list_ids')) { - $missingListIds = $this->newsletterListService->getMissingListIdsOfNewsletter( - $newsletter, - $input->list_ids - ); - - if ($missingListIds !== null) { - throw new UnprocessableEntityHttpException("List with id {$missingListIds[0]} not found"); - } - - $updates->lists = $this->newsletterListService->getListsByIds($input->list_ids); - } - if ($input->has('status')) { if ($input->status === SubscriberStatus::SUBSCRIBED && $subscriber->getOptInAt() === null) { throw new UnprocessableEntityHttpException('Subscribers without opt-in can not be updated to SUBSCRIBED status.'); diff --git a/backend/src/Api/Console/Input/Subscriber/CreateSubscriberInput.php b/backend/src/Api/Console/Input/Subscriber/CreateSubscriberInput.php index 04f8c8901..3ca8ba409 100644 --- a/backend/src/Api/Console/Input/Subscriber/CreateSubscriberInput.php +++ b/backend/src/Api/Console/Input/Subscriber/CreateSubscriberInput.php @@ -17,16 +17,6 @@ class CreateSubscriberInput #[Assert\Length(max: 255)] public string $email; - /** - * @var int[] $list_ids - */ - #[Assert\NotBlank] - #[Assert\All([ - new Assert\NotBlank(), - new Assert\Type('int'), - ])] - public array $list_ids; - public SubscriberStatus $status = SubscriberStatus::PENDING; public ?SubscriberSource $source; diff --git a/backend/src/Api/Console/Input/Subscriber/UpdateSubscriberInput.php b/backend/src/Api/Console/Input/Subscriber/UpdateSubscriberInput.php index e2b9014d2..f49588527 100644 --- a/backend/src/Api/Console/Input/Subscriber/UpdateSubscriberInput.php +++ b/backend/src/Api/Console/Input/Subscriber/UpdateSubscriberInput.php @@ -14,16 +14,6 @@ class UpdateSubscriberInput #[Assert\Email] public string $email; - /** - * @var int[] $list_ids - */ - #[Assert\Count(min: 1, minMessage: "There should be at least one list.")] - #[Assert\All([ - new Assert\NotBlank(), - new Assert\Type('int'), - ])] - public array $list_ids; - public SubscriberStatus $status; /** diff --git a/backend/src/Api/Public/Controller/Form/FormController.php b/backend/src/Api/Public/Controller/Form/FormController.php index d0e1beee4..997d7d736 100644 --- a/backend/src/Api/Public/Controller/Form/FormController.php +++ b/backend/src/Api/Public/Controller/Form/FormController.php @@ -5,7 +5,6 @@ namespace App\Api\Public\Controller\Form; use App\Api\Public\Input\Form\FormInitInput; -use App\Api\Public\Input\Form\FormRenderInput; use App\Api\Public\Input\Form\FormSubscribeInput; use App\Api\Public\Object\Form\FormListObject; use App\Api\Public\Object\Form\FormSubscriberObject; diff --git a/backend/src/Service/Subscriber/Dto/UpdateSubscriberDto.php b/backend/src/Service/Subscriber/Dto/UpdateSubscriberDto.php index f4b758fa7..d42fd0f88 100644 --- a/backend/src/Service/Subscriber/Dto/UpdateSubscriberDto.php +++ b/backend/src/Service/Subscriber/Dto/UpdateSubscriberDto.php @@ -13,12 +13,8 @@ class UpdateSubscriberDto public string $email; - /** - * @var iterable - */ - public iterable $lists; - public SubscriberStatus $status; + public ?string $subscribeIp; public ?\DateTimeImmutable $subscribedAt; diff --git a/backend/src/Service/Subscriber/SubscriberService.php b/backend/src/Service/Subscriber/SubscriberService.php index 1ff7528fd..cc301f55c 100644 --- a/backend/src/Service/Subscriber/SubscriberService.php +++ b/backend/src/Service/Subscriber/SubscriberService.php @@ -166,16 +166,6 @@ public function updateSubscriber(Subscriber $subscriber, UpdateSubscriberDto $up $subscriber->setStatus($updates->status); } - if ($updates->has('lists')) { - // Clear & re-add lists - foreach ($subscriber->getLists() as $list) { - $subscriber->removeList($list); - } - foreach ($updates->lists as $list) { - $subscriber->addList($list); - } - } - if ($updates->has('subscribedAt')) { $subscriber->setSubscribedAt($updates->subscribedAt); } diff --git a/backend/tests/Api/Console/Subscriber/CreateSubscriberTest.php b/backend/tests/Api/Console/Subscriber/CreateSubscriberTest.php index b29d266c8..83f1dc108 100644 --- a/backend/tests/Api/Console/Subscriber/CreateSubscriberTest.php +++ b/backend/tests/Api/Console/Subscriber/CreateSubscriberTest.php @@ -11,7 +11,6 @@ use App\Service\Subscriber\Message\SubscriberCreatedMessage; use App\Service\Subscriber\SubscriberService; use App\Tests\Case\WebTestCase; -use App\Tests\Factory\NewsletterListFactory; use App\Tests\Factory\NewsletterFactory; use App\Tests\Factory\SubscriberFactory; use PHPUnit\Framework\Attributes\CoversClass; @@ -24,39 +23,17 @@ class CreateSubscriberTest extends WebTestCase { - public function test_test(): void - { - $newsletter = NewsletterFactory::createOne(); - $list = NewsletterListFactory::createOne(['newsletter' => $newsletter]); - - $response = $this->consoleApi( - $newsletter, - 'POST', - '/subscribers', - [ - 'email' => 'test@email.com', - 'list_ids' => [$list->getId()], - 'subscribe_ip' => null, // '222.222.222.222' - ] - ); - - } - public function testCreateSubscriberMinimal(): void { $this->mockRelayClient(); $newsletter = NewsletterFactory::createOne(); - $list1 = NewsletterListFactory::createOne(['newsletter' => $newsletter]); - $list2 = NewsletterListFactory::createOne(['newsletter' => $newsletter]); - $response = $this->consoleApi( $newsletter, 'POST', '/subscribers', [ 'email' => 'test@email.com', - 'list_ids' => [$list1->getId(), $list2->getId()] ] ); @@ -72,11 +49,7 @@ public function testCreateSubscriberMinimal(): void $this->assertSame('test@email.com', $subscriber->getEmail()); $this->assertSame(SubscriberStatus::PENDING, $subscriber->getStatus()); $this->assertSame('console', $subscriber->getSource()->value); - - $subscriberLists = $subscriber->getLists(); - $this->assertCount(2, $subscriberLists); - $this->assertSame($list1->getId(), $subscriberLists[0]?->getId()); - $this->assertSame($list2->getId(), $subscriberLists[1]?->getId()); + $this->assertCount(0, $subscriber->getLists()); $transport = $this->transport('async'); $transport->queue()->assertCount(1); @@ -88,7 +61,6 @@ public function testCreateSubscriberWithAllInputs(): void { $this->mockRelayClient(); $newsletter = NewsletterFactory::createOne(); - $list = NewsletterListFactory::createOne(['newsletter' => $newsletter]); $subscribedAt = new \DateTimeImmutable('2021-08-27 12:00:00'); $unsubscribedAt = new \DateTimeImmutable('2021-08-29 12:00:00'); @@ -99,7 +71,6 @@ public function testCreateSubscriberWithAllInputs(): void '/subscribers', [ 'email' => 'supun@hyvor.com', - 'list_ids' => [$list->getId()], 'source' => 'form', 'subscribe_ip' => '79.255.1.1', 'subscribed_at' => $subscribedAt->getTimestamp(), @@ -134,7 +105,7 @@ public function testCreateSubscriberWithAllInputs(): void $this->assertInstanceOf(SubscriberCreatedMessage::class, $message); } - public function testInputValidationEmptyEmailAndListIds(): void + public function testInputValidationEmptyEmail(): void { $this->validateInput( fn(Newsletter $newsletter) => [], @@ -143,38 +114,21 @@ public function testInputValidationEmptyEmailAndListIds(): void 'property' => 'email', 'message' => 'This value should not be blank.', ], - [ - 'property' => 'list_ids', - 'message' => 'This value should not be blank.', - ] ] ); } - public function testInputValidationInvalidEmailAndListIds(): void + public function testInputValidationInvalidEmail(): void { $this->validateInput( fn(Newsletter $newsletter) => [ 'email' => 'not-email', - 'list_ids' => [ - null, - 1, - 'string', - ], ], [ [ 'property' => 'email', 'message' => 'This value is not a valid email address.', ], - [ - 'property' => 'list_ids[0]', - 'message' => 'This value should not be blank.', - ], - [ - 'property' => 'list_ids[2]', - 'message' => 'This value should be of type int.', - ], ] ); } @@ -184,7 +138,6 @@ public function testInputValidationEmailTooLong(): void $this->validateInput( fn(Newsletter $newsletter) => [ 'email' => str_repeat('a', 256) . '@hyvor.com', - 'list_ids' => [1], ], [ [ @@ -200,7 +153,6 @@ public function testInputValidationOptionalValues(): void $this->validateInput( fn(Newsletter $newsletter) => [ 'email' => 'supun@hyvor.com', - 'list_ids' => [1], 'source' => 'invalid-source', 'subscribe_ip' => '127.0.0.1', 'subscribed_at' => 'invalid-date', @@ -234,7 +186,6 @@ public function testValidatesIp( $this->validateInput( fn(Newsletter $newsletter) => [ 'email' => 'supun@hyvor.com', - 'list_ids' => [1], 'subscribe_ip' => $ip, ], [ @@ -269,38 +220,13 @@ private function validateInput( $this->assertHasViolation($violations[0]['property'], $violations[0]['message']); } - public function testCreateSubscriberInvalidList(): void - { - $newsletter1 = NewsletterFactory::createOne(); - $newsletter2 = NewsletterFactory::createOne(); - - $newsletterList1 = NewsletterListFactory::createOne(['newsletter' => $newsletter2]); - - $response = $this->consoleApi( - $newsletter1, - 'POST', - '/subscribers', - [ - 'email' => 'supun@hyvor.com', - 'list_ids' => [$newsletterList1->getId()] - ] - ); - - $this->assertSame(422, $response->getStatusCode()); - $this->assertSame('List with id ' . $newsletterList1->getId() . ' not found', $this->getJson()['message']); - } - public function testCreateSubscriberDuplicateEmail(): void { $newsletter = NewsletterFactory::createOne(); - $list = NewsletterListFactory::createOne(['newsletter' => $newsletter]); - $subscriber = SubscriberFactory::createOne( - [ - 'newsletter' => $newsletter, - 'email' => 'thibault@hyvor.com', - 'lists' => [$list], - ] - ); + $subscriber = SubscriberFactory::createOne([ + 'newsletter' => $newsletter, + 'email' => 'thibault@hyvor.com', + ]); $response = $this->consoleApi( $newsletter, @@ -308,7 +234,6 @@ public function testCreateSubscriberDuplicateEmail(): void '/subscribers', [ 'email' => 'thibault@hyvor.com', - 'list_ids' => [$list->getId()], ] ); diff --git a/backend/tests/Api/Console/Subscriber/UpdateSubscriberTest.php b/backend/tests/Api/Console/Subscriber/UpdateSubscriberTest.php index 6056b32f9..f6053e8bb 100644 --- a/backend/tests/Api/Console/Subscriber/UpdateSubscriberTest.php +++ b/backend/tests/Api/Console/Subscriber/UpdateSubscriberTest.php @@ -14,8 +14,6 @@ use App\Tests\Factory\SubscriberFactory; use App\Tests\Factory\SubscriberMetadataDefinitionFactory; use PHPUnit\Framework\Attributes\CoversClass; -use Symfony\Component\Clock\Clock; -use Symfony\Component\Clock\MockClock; use Symfony\Component\Clock\Test\ClockSensitiveTrait; #[CoversClass(SubscriberController::class)] @@ -26,20 +24,15 @@ class UpdateSubscriberTest extends WebTestCase { use ClockSensitiveTrait; - // TODO: tests for authentication - - public function testUpdateList(): void + public function testUpdateStatus(): void { static::mockTime(new \DateTimeImmutable('2025-02-21')); $newsletter = NewsletterFactory::createOne(); - $list1 = NewsletterListFactory::createOne(['newsletter' => $newsletter]); - $list2 = NewsletterListFactory::createOne(['newsletter' => $newsletter]); - $subscriber = SubscriberFactory::createOne([ 'newsletter' => $newsletter, - 'lists' => [$list1], 'status' => SubscriberStatus::UNSUBSCRIBED, + 'opt_in_at' => new \DateTimeImmutable('2025-01-01'), ]); $response = $this->consoleApi( @@ -47,47 +40,19 @@ public function testUpdateList(): void 'PATCH', '/subscribers/' . $subscriber->getId(), [ - 'email' => 'new@email.com', - 'list_ids' => [$list1->getId(), $list2->getId()], 'status' => 'subscribed', ] ); $this->assertSame(200, $response->getStatusCode()); - $json = $this->getJson(); - $this->assertSame('new@email.com', $json['email']); $repository = $this->em->getRepository(Subscriber::class); - $subscriber = $repository->find($json['id']); + $subscriber = $repository->find($subscriber->getId()); $this->assertInstanceOf(Subscriber::class, $subscriber); - $this->assertSame('new@email.com', $subscriber->getEmail()); $this->assertSame('subscribed', $subscriber->getStatus()->value); - $this->assertCount(2, $subscriber->getLists()); - $this->assertContains($list1->_real(), $subscriber->getLists()); - $this->assertContains($list2->_real(), $subscriber->getLists()); $this->assertSame('2025-02-21 00:00:00', $subscriber->getUpdatedAt()->format('Y-m-d H:i:s')); } - public function testCannotUpdateSubscriberToEmptyList(): void - { - $this->validateInput( - fn(Newsletter $newsletter) => [ - 'email' => 'mybademail', - 'list_ids' => [], - ], - [ - [ - 'property' => 'email', - 'message' => 'This value is not a valid email address.', - ], - [ - 'property' => 'list_ids', - 'message' => 'There should be at least one list.', - ], - ] - ); - } - public function testValidatesStatus(): void { $this->validateInput( @@ -127,42 +92,14 @@ private function validateInput( $this->assertHasViolation($violations[0]['property'], $violations[0]['message']); } - public function testUpdateSubscriberInvalidListId(): void - { - $newsletter1 = NewsletterFactory::createOne(); - $newsletter2 = NewsletterFactory::createOne(); - - $newsletterList = NewsletterListFactory::createOne(['newsletter' => $newsletter2]); - $subscriber = SubscriberFactory::createOne(['newsletter' => $newsletter1]); - - $response = $this->consoleApi( - $newsletter1, - 'PATCH', - '/subscribers/' . $subscriber->getId(), - [ - 'list_ids' => [$newsletterList->getId()], - ] - ); - - $this->assertSame(422, $response->getStatusCode()); - $json = $this->getJson(); - - $this->assertSame( - 'List with id ' . $newsletterList->getId() . ' not found', - $json['message'] - ); - } - public function testCannotUpdateSubscriberOfOtherNewsletter(): void { $newsletter1 = NewsletterFactory::createOne(); $newsletter2 = NewsletterFactory::createOne(); - $newsletterList = NewsletterListFactory::createOne(['newsletter' => $newsletter1]); $subscriber = SubscriberFactory::createOne([ 'newsletter' => $newsletter1, 'email' => 'ishini@hyvor.com', - 'lists' => [$newsletterList], ]); $response = $this->consoleApi( @@ -207,10 +144,8 @@ public function testUpdateSubscriberWithTakenEmail(): void public function test_update_subscriber_metadata(): void { $newsletter = NewsletterFactory::createOne(); - $list1 = NewsletterListFactory::createOne(['newsletter' => $newsletter]); - $list2 = NewsletterListFactory::createOne(['newsletter' => $newsletter]); - $metadata = SubscriberMetadataDefinitionFactory::createOne([ + SubscriberMetadataDefinitionFactory::createOne([ 'key' => 'name', 'name' => 'Name', 'newsletter' => $newsletter, @@ -218,7 +153,6 @@ public function test_update_subscriber_metadata(): void $subscriber = SubscriberFactory::createOne([ 'newsletter' => $newsletter, - 'lists' => [$list1], 'status' => SubscriberStatus::UNSUBSCRIBED, ]); @@ -231,10 +165,7 @@ public function test_update_subscriber_metadata(): void 'PATCH', '/subscribers/' . $subscriber->getId(), [ - 'email' => 'new@email.com', - 'list_ids' => [$list1->getId(), $list2->getId()], - 'status' => 'subscribed', - 'metadata' => $metaUpdate + 'metadata' => $metaUpdate, ] ); @@ -248,32 +179,24 @@ public function test_update_subscriber_metadata(): void public function test_update_subscriber_metadata_invalid_name(): void { $newsletter = NewsletterFactory::createOne(); - $list1 = NewsletterListFactory::createOne(['newsletter' => $newsletter]); - $list2 = NewsletterListFactory::createOne(['newsletter' => $newsletter]); $subscriber = SubscriberFactory::createOne([ 'newsletter' => $newsletter, - 'lists' => [$list1], 'status' => SubscriberStatus::UNSUBSCRIBED, ]); - $metadata = SubscriberMetadataDefinitionFactory::createOne([ + SubscriberMetadataDefinitionFactory::createOne([ 'key' => 'age', 'name' => 'Age', 'newsletter' => $newsletter, ]); - - $metaUpdate = [ - 'name' => 'Thibault', - ]; - $response = $this->consoleApi( $newsletter, 'PATCH', '/subscribers/' . $subscriber->getId(), [ - 'metadata' => $metaUpdate, + 'metadata' => ['name' => 'Thibault'], ] ); @@ -285,7 +208,6 @@ public function test_update_subscriber_metadata_invalid_name(): void ); } - public function test_update_subscriber_metadata_invalid_type(): void { // TODO: Implement this test when other metadata types are implemented diff --git a/frontend/src/routes/(marketing)/docs/[...slug]/content/ConsoleApi.svelte b/frontend/src/routes/(marketing)/docs/[...slug]/content/ConsoleApi.svelte index e2a890ab3..a87ca52fd 100644 --- a/frontend/src/routes/(marketing)/docs/[...slug]/content/ConsoleApi.svelte +++ b/frontend/src/routes/(marketing)/docs/[...slug]/content/ConsoleApi.svelte @@ -294,6 +294,12 @@
  • POST /subscribers/bulk - Bulk update subscribers
  • +
  • + POST /subscribers/{'{id}'}/lists - Add a subscriber to a list +
  • +
  • + DELETE /subscribers/{'{id}'}/lists - Remove a subscriber from a list +
  • Objects:

    @@ -334,7 +340,6 @@ code={` type Request = { email: string; - list_ids: number[]; source?: 'console' | 'form' | 'import'; // default: 'console' subscribe_ip?: string | null; subscribed_at?: number | null; // unix timestamp @@ -353,7 +358,6 @@ code={` type Request = { email?: string; - list_ids?: number[]; status?: 'subscribed' | 'unsubscribed' | 'pending'; metadata?: Record; } @@ -394,6 +398,46 @@ `} /> +

    Add a subscriber to a list

    + +POST /subscribers/{'{id}'}/lists + + + +

    Remove a subscriber from a list

    + +DELETE /subscribers/{'{id}'}/lists + + +

    Subscriber Metadata

    From 988bce05b783f4a18e025b33649c02be4e17ebc1 Mon Sep 17 00:00:00 2001 From: Supun Wimalasena Date: Thu, 26 Feb 2026 14:35:36 +0100 Subject: [PATCH 05/43] create subscriber endpoint updated --- .../Controller/SubscriberController.php | 20 ++-- .../Subscriber/Dto/UpdateSubscriberDto.php | 4 +- .../Service/Subscriber/SubscriberService.php | 8 ++ .../Subscriber/CreateSubscriberTest.php | 101 ++++++++++++++++++ 4 files changed, 125 insertions(+), 8 deletions(-) diff --git a/backend/src/Api/Console/Controller/SubscriberController.php b/backend/src/Api/Console/Controller/SubscriberController.php index 6a3a0e36e..f8bfc4f3a 100644 --- a/backend/src/Api/Console/Controller/SubscriberController.php +++ b/backend/src/Api/Console/Controller/SubscriberController.php @@ -106,20 +106,26 @@ public function createSubscriber( // update $updates = new UpdateSubscriberDto(); - if ($updates->has('status')) { - $updates->status = $input->status; + $updates->status = $input->status; + + if ($input->has('source')) { + $updates->source = $input->source; } - if ($updates->has('subscribe_ip')) { + if ($input->has('subscribe_ip')) { $updates->subscribeIp = $input->subscribe_ip; } - if ($updates->has('subscribed_at')) { - $updates->subscribedAt = $input->subscribed_at ? \DateTimeImmutable::createFromTimestamp($input->subscribed_at) : null; + if ($input->has('subscribed_at')) { + $updates->subscribedAt = $input->subscribed_at !== null + ? \DateTimeImmutable::createFromTimestamp($input->subscribed_at) + : null; } - if ($updates->has('unsubscribed_at')) { - $updates->unsubscribedAt = $input->unsubscribed_at ? \DateTimeImmutable::createFromTimestamp($input->unsubscribed_at) : null; + if ($input->has('unsubscribed_at')) { + $updates->unsubscribedAt = $input->unsubscribed_at !== null + ? \DateTimeImmutable::createFromTimestamp($input->unsubscribed_at) + : null; } $subscriber = $this->subscriberService->updateSubscriber($subscriber, $updates); diff --git a/backend/src/Service/Subscriber/Dto/UpdateSubscriberDto.php b/backend/src/Service/Subscriber/Dto/UpdateSubscriberDto.php index d42fd0f88..2b3fc8950 100644 --- a/backend/src/Service/Subscriber/Dto/UpdateSubscriberDto.php +++ b/backend/src/Service/Subscriber/Dto/UpdateSubscriberDto.php @@ -3,6 +3,7 @@ namespace App\Service\Subscriber\Dto; use App\Entity\NewsletterList; +use App\Entity\Type\SubscriberSource; use App\Entity\Type\SubscriberStatus; use App\Util\OptionalPropertyTrait; @@ -14,13 +15,14 @@ class UpdateSubscriberDto public string $email; public SubscriberStatus $status; + public SubscriberSource $source; public ?string $subscribeIp; public ?\DateTimeImmutable $subscribedAt; public ?\DateTimeImmutable $optInAt; - public \DateTimeImmutable $unsubscribedAt; + public ?\DateTimeImmutable $unsubscribedAt; public ?string $unsubscribedReason; diff --git a/backend/src/Service/Subscriber/SubscriberService.php b/backend/src/Service/Subscriber/SubscriberService.php index cc301f55c..089acdf2d 100644 --- a/backend/src/Service/Subscriber/SubscriberService.php +++ b/backend/src/Service/Subscriber/SubscriberService.php @@ -166,6 +166,14 @@ public function updateSubscriber(Subscriber $subscriber, UpdateSubscriberDto $up $subscriber->setStatus($updates->status); } + if ($updates->has('source')) { + $subscriber->setSource($updates->source); + } + + if ($updates->has('subscribeIp')) { + $subscriber->setSubscribeIp($updates->subscribeIp); + } + if ($updates->has('subscribedAt')) { $subscriber->setSubscribedAt($updates->subscribedAt); } diff --git a/backend/tests/Api/Console/Subscriber/CreateSubscriberTest.php b/backend/tests/Api/Console/Subscriber/CreateSubscriberTest.php index 83f1dc108..a215f2ce4 100644 --- a/backend/tests/Api/Console/Subscriber/CreateSubscriberTest.php +++ b/backend/tests/Api/Console/Subscriber/CreateSubscriberTest.php @@ -220,6 +220,107 @@ private function validateInput( $this->assertHasViolation($violations[0]['property'], $violations[0]['message']); } + public function test_updates_if_exists(): void + { + $newsletter = NewsletterFactory::createOne(); + + $subscriber = SubscriberFactory::createOne([ + 'newsletter' => $newsletter, + 'email' => 'supun@hyvor.com', + 'status' => SubscriberStatus::UNSUBSCRIBED, + 'subscribe_ip' => '1.2.3.4', + ]); + + $subscribedAt = new \DateTimeImmutable('2024-01-01 00:00:00'); + + $response = $this->consoleApi( + $newsletter, + 'POST', + '/subscribers', + [ + 'email' => 'supun@hyvor.com', + 'if_exists' => 'update', + 'status' => 'pending', + 'subscribe_ip' => '79.255.1.1', + 'subscribed_at' => $subscribedAt->getTimestamp(), + 'source' => 'import' + ] + ); + + $this->assertSame(200, $response->getStatusCode()); + + $repository = $this->em->getRepository(Subscriber::class); + $updated = $repository->find($subscriber->getId()); + $this->assertInstanceOf(Subscriber::class, $updated); + $this->assertSame(SubscriberStatus::PENDING, $updated->getStatus()); + $this->assertSame('79.255.1.1', $updated->getSubscribeIp()); + $this->assertSame('2024-01-01 00:00:00', $updated->getSubscribedAt()?->format('Y-m-d H:i:s')); + $this->assertSame(SubscriberSource::IMPORT, $updated->getSource()); + } + + public function testCreateSubscriberIfExistsUpdateClearsTimestamps(): void + { + $newsletter = NewsletterFactory::createOne(); + + $subscriber = SubscriberFactory::createOne([ + 'newsletter' => $newsletter, + 'email' => 'supun@hyvor.com', + 'subscribed_at' => new \DateTimeImmutable('2024-01-01'), + 'unsubscribed_at' => new \DateTimeImmutable('2024-06-01'), + ]); + + $response = $this->consoleApi( + $newsletter, + 'POST', + '/subscribers', + [ + 'email' => 'supun@hyvor.com', + 'if_exists' => 'update', + 'subscribed_at' => null, + 'unsubscribed_at' => null, + ] + ); + + $this->assertSame(200, $response->getStatusCode()); + + $repository = $this->em->getRepository(Subscriber::class); + $updated = $repository->find($subscriber->getId()); + $this->assertInstanceOf(Subscriber::class, $updated); + $this->assertNull($updated->getSubscribedAt()); + $this->assertNull($updated->getUnsubscribedAt()); + } + + public function testCreateSubscriberIfExistsUpdateDoesNotChangeUnsentFields(): void + { + $newsletter = NewsletterFactory::createOne(); + + $subscriber = SubscriberFactory::createOne([ + 'newsletter' => $newsletter, + 'email' => 'supun@hyvor.com', + 'subscribe_ip' => '1.2.3.4', + 'subscribed_at' => new \DateTimeImmutable('2024-01-01'), + ]); + + $response = $this->consoleApi( + $newsletter, + 'POST', + '/subscribers', + [ + 'email' => 'supun@hyvor.com', + 'if_exists' => 'update', + // subscribe_ip and subscribed_at not sent + ] + ); + + $this->assertSame(200, $response->getStatusCode()); + + $repository = $this->em->getRepository(Subscriber::class); + $updated = $repository->find($subscriber->getId()); + $this->assertInstanceOf(Subscriber::class, $updated); + $this->assertSame('1.2.3.4', $updated->getSubscribeIp()); + $this->assertSame('2024-01-01 00:00:00', $updated->getSubscribedAt()?->format('Y-m-d H:i:s')); + } + public function testCreateSubscriberDuplicateEmail(): void { $newsletter = NewsletterFactory::createOne(); From 89c23a0a8281ea47c6a732f4cc57e7a7e6badb27 Mon Sep 17 00:00:00 2001 From: Supun Wimalasena Date: Thu, 26 Feb 2026 14:44:45 +0100 Subject: [PATCH 06/43] wip --- backend/config/packages/sentry.yaml | 2 +- backend/src/Entity/SubscriberListUnsubscribed.php | 5 +++++ 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/backend/config/packages/sentry.yaml b/backend/config/packages/sentry.yaml index 81ad7b5c2..23a0dd9ee 100644 --- a/backend/config/packages/sentry.yaml +++ b/backend/config/packages/sentry.yaml @@ -1,4 +1,4 @@ -when@dev: +when@prod: sentry: dsn: '%env(SENTRY_DSN)%' options: diff --git a/backend/src/Entity/SubscriberListUnsubscribed.php b/backend/src/Entity/SubscriberListUnsubscribed.php index eb4e78cc6..efcf10157 100644 --- a/backend/src/Entity/SubscriberListUnsubscribed.php +++ b/backend/src/Entity/SubscriberListUnsubscribed.php @@ -30,6 +30,11 @@ public function getId(): int return $this->id; } + public function setId(int $id): void + { + $this->id = $id; + } + public function getList(): NewsletterList { return $this->list; From 3642bf31dd56ef8a16a66f93dec71d21818667a7 Mon Sep 17 00:00:00 2001 From: Supun Wimalasena Date: Fri, 27 Feb 2026 12:37:25 +0100 Subject: [PATCH 07/43] wip --- .../Controller/SubscriberController.php | 18 ++++--- .../Public/Controller/Form/FormController.php | 37 +++++++------ .../Service/Subscriber/SubscriberService.php | 53 ++++++++++++++----- 3 files changed, 72 insertions(+), 36 deletions(-) diff --git a/backend/src/Api/Console/Controller/SubscriberController.php b/backend/src/Api/Console/Controller/SubscriberController.php index f8bfc4f3a..5ccd97448 100644 --- a/backend/src/Api/Console/Controller/SubscriberController.php +++ b/backend/src/Api/Console/Controller/SubscriberController.php @@ -290,16 +290,18 @@ public function addSubscriberList( throw new UnprocessableEntityHttpException('List not found'); } - try { - $this->subscriberService->addSubscriberToList( - $subscriber, - $list, - $input->if_unsubscribed === SubscriberListIfUnsubscribed::ERROR - ); - } catch (\RuntimeException $e) { - throw new UnprocessableEntityHttpException($e->getMessage()); + if ( + $input->if_unsubscribed === SubscriberListIfUnsubscribed::ERROR && + $this->subscriberService->hasSubscriberUnsubscribedFromList($subscriber, $list) + ) { + throw new BadRequestHttpException('Subscriber was previously unsubscribed and can not be added to the list again'); } + $this->subscriberService->addSubscriberToList( + $subscriber, + $list, + ); + return $this->json(new SubscriberObject($subscriber)); } diff --git a/backend/src/Api/Public/Controller/Form/FormController.php b/backend/src/Api/Public/Controller/Form/FormController.php index 997d7d736..4c6cb44a3 100644 --- a/backend/src/Api/Public/Controller/Form/FormController.php +++ b/backend/src/Api/Public/Controller/Form/FormController.php @@ -31,13 +31,11 @@ class FormController extends AbstractController use ClockAwareTrait; public function __construct( - private NewsletterService $newsletterService, + private NewsletterService $newsletterService, private NewsletterListService $newsletterListService, - private SubscriberService $subscriberService, - private AppConfig $appConfig, - ) - { - } + private SubscriberService $subscriberService, + private AppConfig $appConfig, + ) {} #[Route('/form/init', methods: 'POST')] public function init(#[MapRequestPayload] FormInitInput $input): JsonResponse @@ -53,7 +51,7 @@ public function init(#[MapRequestPayload] FormInitInput $input): JsonResponse if ($listIds !== null) { $missingListIds = $this->newsletterListService->getMissingListIdsOfNewsletter( $newsletter, - $listIds + $listIds, ); if ($missingListIds !== null) { throw new UnprocessableEntityHttpException("List with id {$missingListIds[0]} not found"); @@ -74,9 +72,8 @@ public function init(#[MapRequestPayload] FormInitInput $input): JsonResponse #[Route('/form/subscribe', methods: 'POST')] public function subscribe( #[MapRequestPayload] FormSubscribeInput $input, - Request $request, - ): JsonResponse - { + Request $request, + ): JsonResponse { $ip = $request->getClientIp(); $newsletter = $this->newsletterService->getNewsletterBySubdomain($input->newsletter_subdomain); @@ -87,7 +84,7 @@ public function subscribe( $listIds = $input->list_ids; $missingListIds = $this->newsletterListService->getMissingListIdsOfNewsletter( $newsletter, - $listIds + $listIds, ); if ($missingListIds !== null) { @@ -101,12 +98,22 @@ public function subscribe( if ($subscriber) { $update = new UpdateSubscriberDto(); - $update->status = $subscriber->getOptInAt() !== null ? SubscriberStatus::SUBSCRIBED : SubscriberStatus::PENDING; + + // if the user is already subscribed, we do not want to change the status + if ($subscriber->getStatus() !== SubscriberStatus::SUBSCRIBED) { + // if the user has previously opted-in + // we can directly set the status to subscribed + $update->status = + $subscriber->getOptInAt() !== null ? + SubscriberStatus::SUBSCRIBED : + SubscriberStatus::PENDING; + } + $update->lists = $lists; $this->subscriberService->updateSubscriber( $subscriber, - $update + $update, ); } else { $subscriber = $this->subscriberService->createSubscriber( @@ -115,7 +122,7 @@ public function subscribe( $lists, SubscriberStatus::PENDING, SubscriberSource::FORM, - $ip + $ip, ); } @@ -138,7 +145,7 @@ public function renderForm(Request $request): Response getSubdomain()} instance={$instance}> - HTML; + HTML; return new Response($response); } diff --git a/backend/src/Service/Subscriber/SubscriberService.php b/backend/src/Service/Subscriber/SubscriberService.php index 089acdf2d..6fac24b67 100644 --- a/backend/src/Service/Subscriber/SubscriberService.php +++ b/backend/src/Service/Subscriber/SubscriberService.php @@ -19,7 +19,6 @@ use Doctrine\Common\Collections\ArrayCollection; use Doctrine\ORM\EntityManagerInterface; use Symfony\Component\Clock\ClockAwareTrait; -use Symfony\Component\EventDispatcher\EventDispatcherInterface; use Symfony\Component\Messenger\MessageBusInterface; class SubscriberService @@ -301,25 +300,43 @@ public function getExports(Newsletter $newsletter): array } /** - * @throws \RuntimeException if $checkUnsubscribed is true and the subscriber has a prior unsubscription record + * @param Subscriber $subscriber + * @param NewsletterList[] $lists */ - public function addSubscriberToList( - Subscriber $subscriber, - NewsletterList $list, - bool $checkUnsubscribed + public function setSubscriberLists( + Subscriber $subscriber, + array $lists ): void { - if ($checkUnsubscribed) { - $record = $this->em->getRepository(SubscriberListUnsubscribed::class)->findOneBy([ - 'list' => $list, - 'subscriber' => $subscriber, - ]); - if ($record !== null) { - throw new \RuntimeException('Subscriber has previously unsubscribed from this list'); + $listIds = array_map(fn(NewsletterList $list) => $list->getId(), $lists); + + // remove lists that are not in the new list + foreach ($subscriber->getLists() as $existingList) { + if (!in_array($existingList->getId(), $listIds)) { + $subscriber->removeList($existingList); } } + // add new lists + foreach ($lists as $list) { + if (!$subscriber->getLists()->contains($list)) { + $subscriber->addList($list); + } + } + + $subscriber->setUpdatedAt($this->now()); + + $this->em->persist($subscriber); + $this->em->flush(); + + } + + public function addSubscriberToList( + Subscriber $subscriber, + NewsletterList $list, + ): void + { $subscriber->addList($list); $subscriber->setUpdatedAt($this->now()); @@ -359,6 +376,16 @@ public function removeSubscriberFromList( } } + public function hasSubscriberUnsubscribedFromList(Subscriber $subscriber, NewsletterList $list): bool + { + $record = $this->em->getRepository(SubscriberListUnsubscribed::class)->findOneBy([ + 'list' => $list, + 'subscriber' => $subscriber, + ]); + + return $record !== null; + } + public function getSubscriberById(int $id): ?Subscriber { return $this->subscriberRepository->find($id); From 3b7a46602e14c1df6968843c05362793bdc6919f Mon Sep 17 00:00:00 2001 From: Supun Wimalasena Date: Mon, 2 Mar 2026 16:03:21 +0100 Subject: [PATCH 08/43] cleanup lists --- .../Subscriber/AddSubscriberListInput.php | 10 - .../Subscriber/CreateSubscriberIfExists.php | 10 - .../Subscriber/RemoveSubscriberListInput.php | 10 - .../Subscriber/RemoveSubscriberListReason.php | 9 - .../SubscriberListIfUnsubscribed.php | 9 - .../Subscriber/UpdateSubscriberInput.php | 23 -- .../Subscriber/AddSubscriberListTest.php | 201 ---------------- .../Subscriber/RemoveSubscriberListTest.php | 192 ---------------- .../Subscriber/UpdateSubscriberTest.php | 216 ------------------ 9 files changed, 680 deletions(-) delete mode 100644 backend/src/Api/Console/Input/Subscriber/AddSubscriberListInput.php delete mode 100644 backend/src/Api/Console/Input/Subscriber/CreateSubscriberIfExists.php delete mode 100644 backend/src/Api/Console/Input/Subscriber/RemoveSubscriberListInput.php delete mode 100644 backend/src/Api/Console/Input/Subscriber/RemoveSubscriberListReason.php delete mode 100644 backend/src/Api/Console/Input/Subscriber/SubscriberListIfUnsubscribed.php delete mode 100644 backend/src/Api/Console/Input/Subscriber/UpdateSubscriberInput.php delete mode 100644 backend/tests/Api/Console/Subscriber/AddSubscriberListTest.php delete mode 100644 backend/tests/Api/Console/Subscriber/RemoveSubscriberListTest.php delete mode 100644 backend/tests/Api/Console/Subscriber/UpdateSubscriberTest.php diff --git a/backend/src/Api/Console/Input/Subscriber/AddSubscriberListInput.php b/backend/src/Api/Console/Input/Subscriber/AddSubscriberListInput.php deleted file mode 100644 index db45af811..000000000 --- a/backend/src/Api/Console/Input/Subscriber/AddSubscriberListInput.php +++ /dev/null @@ -1,10 +0,0 @@ - - */ - public array $metadata; -} diff --git a/backend/tests/Api/Console/Subscriber/AddSubscriberListTest.php b/backend/tests/Api/Console/Subscriber/AddSubscriberListTest.php deleted file mode 100644 index 84e16ba2d..000000000 --- a/backend/tests/Api/Console/Subscriber/AddSubscriberListTest.php +++ /dev/null @@ -1,201 +0,0 @@ - $newsletter]); - $subscriber = SubscriberFactory::createOne(['newsletter' => $newsletter]); - - $response = $this->consoleApi( - $newsletter, - 'POST', - '/subscribers/' . $subscriber->getId() . '/lists', - ['id' => $list->getId()] - ); - - $this->assertSame(200, $response->getStatusCode()); - - $subscriberDb = $this->em->getRepository(Subscriber::class)->find($subscriber->getId()); - $this->assertInstanceOf(Subscriber::class, $subscriberDb); - $this->assertCount(1, $subscriberDb->getLists()); - $this->assertSame($list->getId(), $subscriberDb->getLists()->first()->getId()); - } - - public function testAddSubscriberToListByName(): void - { - $newsletter = NewsletterFactory::createOne(); - $list = NewsletterListFactory::createOne(['newsletter' => $newsletter, 'name' => 'My List']); - $subscriber = SubscriberFactory::createOne(['newsletter' => $newsletter]); - - $response = $this->consoleApi( - $newsletter, - 'POST', - '/subscribers/' . $subscriber->getId() . '/lists', - ['name' => 'My List'] - ); - - $this->assertSame(200, $response->getStatusCode()); - - $subscriberDb = $this->em->getRepository(Subscriber::class)->find($subscriber->getId()); - $this->assertInstanceOf(Subscriber::class, $subscriberDb); - $this->assertCount(1, $subscriberDb->getLists()); - $this->assertSame($list->getId(), $subscriberDb->getLists()->first()->getId()); - } - - public function testAddSubscriberToListValidation(): void - { - $newsletter = NewsletterFactory::createOne(); - $subscriber = SubscriberFactory::createOne(['newsletter' => $newsletter]); - - $response = $this->consoleApi( - $newsletter, - 'POST', - '/subscribers/' . $subscriber->getId() . '/lists', - [] - ); - - $this->assertSame(422, $response->getStatusCode()); - $this->assertSame('Either id or name must be provided', $this->getJson()['message']); - } - - public function testAddSubscriberToListNotFound(): void - { - $newsletter = NewsletterFactory::createOne(); - $subscriber = SubscriberFactory::createOne(['newsletter' => $newsletter]); - - $response = $this->consoleApi( - $newsletter, - 'POST', - '/subscribers/' . $subscriber->getId() . '/lists', - ['id' => 999999] - ); - - $this->assertSame(422, $response->getStatusCode()); - $this->assertSame('List not found', $this->getJson()['message']); - } - - public function testAddSubscriberToListAlreadyInList(): void - { - $newsletter = NewsletterFactory::createOne(); - $list = NewsletterListFactory::createOne(['newsletter' => $newsletter]); - $subscriber = SubscriberFactory::createOne(['newsletter' => $newsletter, 'lists' => [$list]]); - - $response = $this->consoleApi( - $newsletter, - 'POST', - '/subscribers/' . $subscriber->getId() . '/lists', - ['id' => $list->getId()] - ); - - $this->assertSame(200, $response->getStatusCode()); - - // Still only in the list once (idempotent) - $subscriberDb = $this->em->getRepository(Subscriber::class)->find($subscriber->getId()); - $this->assertInstanceOf(Subscriber::class, $subscriberDb); - $this->assertCount(1, $subscriberDb->getLists()); - } - - public function testAddSubscriberToListIfUnsubscribedError(): void - { - $newsletter = NewsletterFactory::createOne(); - $list = NewsletterListFactory::createOne(['newsletter' => $newsletter]); - $subscriber = SubscriberFactory::createOne(['newsletter' => $newsletter]); - - SubscriberListUnsubscribedFactory::createOne([ - 'list' => $list, - 'subscriber' => $subscriber, - ]); - - $response = $this->consoleApi( - $newsletter, - 'POST', - '/subscribers/' . $subscriber->getId() . '/lists', - ['id' => $list->getId(), 'if_unsubscribed' => 'error'] - ); - - $this->assertSame(422, $response->getStatusCode()); - $this->assertSame( - 'Subscriber has previously unsubscribed from this list', - $this->getJson()['message'] - ); - } - - public function testAddSubscriberToListIfUnsubscribedForceCreate(): void - { - $newsletter = NewsletterFactory::createOne(); - $list = NewsletterListFactory::createOne(['newsletter' => $newsletter]); - $subscriber = SubscriberFactory::createOne(['newsletter' => $newsletter]); - - SubscriberListUnsubscribedFactory::createOne([ - 'list' => $list, - 'subscriber' => $subscriber, - ]); - - $response = $this->consoleApi( - $newsletter, - 'POST', - '/subscribers/' . $subscriber->getId() . '/lists', - ['id' => $list->getId(), 'if_unsubscribed' => 'force_create'] - ); - - $this->assertSame(200, $response->getStatusCode()); - - $subscriberDb = $this->em->getRepository(Subscriber::class)->find($subscriber->getId()); - $this->assertInstanceOf(Subscriber::class, $subscriberDb); - $this->assertCount(1, $subscriberDb->getLists()); - } - - public function testCannotAddSubscriberOfOtherNewsletter(): void - { - $newsletter1 = NewsletterFactory::createOne(); - $newsletter2 = NewsletterFactory::createOne(); - $list = NewsletterListFactory::createOne(['newsletter' => $newsletter1]); - $subscriber = SubscriberFactory::createOne(['newsletter' => $newsletter1]); - - $response = $this->consoleApi( - $newsletter2, - 'POST', - '/subscribers/' . $subscriber->getId() . '/lists', - ['id' => $list->getId()] - ); - - $this->assertSame(403, $response->getStatusCode()); - $this->assertSame('Entity does not belong to the newsletter', $this->getJson()['message']); - } - - public function testCannotAddListOfOtherNewsletter(): void - { - $newsletter1 = NewsletterFactory::createOne(); - $newsletter2 = NewsletterFactory::createOne(); - $listOfNewsletter2 = NewsletterListFactory::createOne(['newsletter' => $newsletter2]); - $subscriber = SubscriberFactory::createOne(['newsletter' => $newsletter1]); - - $response = $this->consoleApi( - $newsletter1, - 'POST', - '/subscribers/' . $subscriber->getId() . '/lists', - ['id' => $listOfNewsletter2->getId()] - ); - - $this->assertSame(422, $response->getStatusCode()); - $this->assertSame('List not found', $this->getJson()['message']); - } -} diff --git a/backend/tests/Api/Console/Subscriber/RemoveSubscriberListTest.php b/backend/tests/Api/Console/Subscriber/RemoveSubscriberListTest.php deleted file mode 100644 index d19db0d1b..000000000 --- a/backend/tests/Api/Console/Subscriber/RemoveSubscriberListTest.php +++ /dev/null @@ -1,192 +0,0 @@ - $newsletter]); - $subscriber = SubscriberFactory::createOne(['newsletter' => $newsletter, 'lists' => [$list]]); - - $response = $this->consoleApi( - $newsletter, - 'DELETE', - '/subscribers/' . $subscriber->getId() . '/lists', - ['id' => $list->getId()] - ); - - $this->assertSame(200, $response->getStatusCode()); - - $this->em->clear(); - $subscriberDb = $this->em->getRepository(Subscriber::class)->find($subscriber->getId()); - $this->assertInstanceOf(Subscriber::class, $subscriberDb); - $this->assertCount(0, $subscriberDb->getLists()); - } - - public function testRemoveSubscriberFromListByName(): void - { - $newsletter = NewsletterFactory::createOne(); - $list = NewsletterListFactory::createOne(['newsletter' => $newsletter, 'name' => 'Remove Me']); - $subscriber = SubscriberFactory::createOne(['newsletter' => $newsletter, 'lists' => [$list]]); - - $response = $this->consoleApi( - $newsletter, - 'DELETE', - '/subscribers/' . $subscriber->getId() . '/lists', - ['name' => 'Remove Me'] - ); - - $this->assertSame(200, $response->getStatusCode()); - - $this->em->clear(); - $subscriberDb = $this->em->getRepository(Subscriber::class)->find($subscriber->getId()); - $this->assertInstanceOf(Subscriber::class, $subscriberDb); - $this->assertCount(0, $subscriberDb->getLists()); - } - - public function testRemoveSubscriberFromListValidation(): void - { - $newsletter = NewsletterFactory::createOne(); - $subscriber = SubscriberFactory::createOne(['newsletter' => $newsletter]); - - $response = $this->consoleApi( - $newsletter, - 'DELETE', - '/subscribers/' . $subscriber->getId() . '/lists', - [] - ); - - $this->assertSame(422, $response->getStatusCode()); - $this->assertSame('Either id or name must be provided', $this->getJson()['message']); - } - - public function testRemoveSubscriberFromListNotFound(): void - { - $newsletter = NewsletterFactory::createOne(); - $subscriber = SubscriberFactory::createOne(['newsletter' => $newsletter]); - - $response = $this->consoleApi( - $newsletter, - 'DELETE', - '/subscribers/' . $subscriber->getId() . '/lists', - ['id' => 999999] - ); - - $this->assertSame(422, $response->getStatusCode()); - $this->assertSame('List not found', $this->getJson()['message']); - } - - public function testRemoveSubscriberFromListNotInList(): void - { - $newsletter = NewsletterFactory::createOne(); - $list = NewsletterListFactory::createOne(['newsletter' => $newsletter]); - $subscriber = SubscriberFactory::createOne(['newsletter' => $newsletter]); - - $response = $this->consoleApi( - $newsletter, - 'DELETE', - '/subscribers/' . $subscriber->getId() . '/lists', - ['id' => $list->getId()] - ); - - $this->assertSame(200, $response->getStatusCode()); - } - - public function testRemoveSubscriberFromListWithReasonUnsubscribe(): void - { - $newsletter = NewsletterFactory::createOne(); - $list = NewsletterListFactory::createOne(['newsletter' => $newsletter]); - $subscriber = SubscriberFactory::createOne(['newsletter' => $newsletter, 'lists' => [$list]]); - - $response = $this->consoleApi( - $newsletter, - 'DELETE', - '/subscribers/' . $subscriber->getId() . '/lists', - ['id' => $list->getId(), 'reason' => 'unsubscribe'] - ); - - $this->assertSame(200, $response->getStatusCode()); - - $record = $this->em->getRepository(SubscriberListUnsubscribed::class)->findOneBy([ - 'list' => $list->_real(), - 'subscriber' => $subscriber->_real(), - ]); - - $this->assertInstanceOf(SubscriberListUnsubscribed::class, $record); - } - - public function testRemoveSubscriberFromListWithReasonOther(): void - { - $newsletter = NewsletterFactory::createOne(); - $list = NewsletterListFactory::createOne(['newsletter' => $newsletter]); - $subscriber = SubscriberFactory::createOne(['newsletter' => $newsletter, 'lists' => [$list]]); - - $response = $this->consoleApi( - $newsletter, - 'DELETE', - '/subscribers/' . $subscriber->getId() . '/lists', - ['id' => $list->getId(), 'reason' => 'other'] - ); - - $this->assertSame(200, $response->getStatusCode()); - - $record = $this->em->getRepository(SubscriberListUnsubscribed::class)->findOneBy([ - 'list' => $list->_real(), - 'subscriber' => $subscriber->_real(), - ]); - - $this->assertNull($record); - } - - public function testCannotRemoveSubscriberOfOtherNewsletter(): void - { - $newsletter1 = NewsletterFactory::createOne(); - $newsletter2 = NewsletterFactory::createOne(); - $list = NewsletterListFactory::createOne(['newsletter' => $newsletter1]); - $subscriber = SubscriberFactory::createOne(['newsletter' => $newsletter1, 'lists' => [$list]]); - - $response = $this->consoleApi( - $newsletter2, - 'DELETE', - '/subscribers/' . $subscriber->getId() . '/lists', - ['id' => $list->getId()] - ); - - $this->assertSame(403, $response->getStatusCode()); - $this->assertSame('Entity does not belong to the newsletter', $this->getJson()['message']); - } - - public function testCannotRemoveListOfOtherNewsletter(): void - { - $newsletter1 = NewsletterFactory::createOne(); - $newsletter2 = NewsletterFactory::createOne(); - $listOfNewsletter2 = NewsletterListFactory::createOne(['newsletter' => $newsletter2]); - $subscriber = SubscriberFactory::createOne(['newsletter' => $newsletter1]); - - $response = $this->consoleApi( - $newsletter1, - 'DELETE', - '/subscribers/' . $subscriber->getId() . '/lists', - ['id' => $listOfNewsletter2->getId()] - ); - - $this->assertSame(422, $response->getStatusCode()); - $this->assertSame('List not found', $this->getJson()['message']); - } -} diff --git a/backend/tests/Api/Console/Subscriber/UpdateSubscriberTest.php b/backend/tests/Api/Console/Subscriber/UpdateSubscriberTest.php deleted file mode 100644 index f6053e8bb..000000000 --- a/backend/tests/Api/Console/Subscriber/UpdateSubscriberTest.php +++ /dev/null @@ -1,216 +0,0 @@ - $newsletter, - 'status' => SubscriberStatus::UNSUBSCRIBED, - 'opt_in_at' => new \DateTimeImmutable('2025-01-01'), - ]); - - $response = $this->consoleApi( - $newsletter, - 'PATCH', - '/subscribers/' . $subscriber->getId(), - [ - 'status' => 'subscribed', - ] - ); - - $this->assertSame(200, $response->getStatusCode()); - - $repository = $this->em->getRepository(Subscriber::class); - $subscriber = $repository->find($subscriber->getId()); - $this->assertInstanceOf(Subscriber::class, $subscriber); - $this->assertSame('subscribed', $subscriber->getStatus()->value); - $this->assertSame('2025-02-21 00:00:00', $subscriber->getUpdatedAt()->format('Y-m-d H:i:s')); - } - - public function testValidatesStatus(): void - { - $this->validateInput( - fn(Newsletter $newsletter) => [ - 'status' => 'invalid', - ], - [ - [ - 'property' => 'status', - 'message' => 'This value should be of type int|string.', - ], - ] - ); - } - - /** - * @param callable(Newsletter): array $input - * @param array $violations - * @return void - */ - private function validateInput( - callable $input, - array $violations - ): void - { - $newsletter = NewsletterFactory::createOne(); - $subscriber = SubscriberFactory::createOne(['newsletter' => $newsletter]); - - $response = $this->consoleApi( - $newsletter, - 'PATCH', - '/subscribers/' . $subscriber->getId(), - $input($newsletter), - ); - - $this->assertSame(422, $response->getStatusCode()); - $this->assertHasViolation($violations[0]['property'], $violations[0]['message']); - } - - public function testCannotUpdateSubscriberOfOtherNewsletter(): void - { - $newsletter1 = NewsletterFactory::createOne(); - $newsletter2 = NewsletterFactory::createOne(); - - $subscriber = SubscriberFactory::createOne([ - 'newsletter' => $newsletter1, - 'email' => 'ishini@hyvor.com', - ]); - - $response = $this->consoleApi( - $newsletter2, - 'PATCH', - '/subscribers/' . $subscriber->getId(), - [ - 'email' => 'supun@hyvor.com', - ] - ); - - $this->assertSame(403, $response->getStatusCode()); - $this->assertSame('Entity does not belong to the newsletter', $this->getJson()['message']); - - $repository = $this->em->getRepository(Subscriber::class); - $subscriber = $repository->find($subscriber->getId()); - $this->assertSame('ishini@hyvor.com', $subscriber?->getEmail()); - } - - public function testUpdateSubscriberWithTakenEmail(): void - { - $newsletter = NewsletterFactory::createOne(); - $subscriber1 = SubscriberFactory::createOne(['newsletter' => $newsletter, 'email' => 'thibault@hyvor.com']); - $subscriber2 = SubscriberFactory::createOne(['newsletter' => $newsletter, 'email' => 'supun@hyvor.com']); - - $response = $this->consoleApi( - $newsletter, - 'PATCH', - '/subscribers/' . $subscriber1->getId(), - [ - 'email' => 'supun@hyvor.com', - ] - ); - - $this->assertSame(422, $response->getStatusCode()); - $this->assertSame( - 'Subscriber with email ' . $subscriber2->getEmail() . ' already exists', - $this->getJson()['message'] - ); - } - - public function test_update_subscriber_metadata(): void - { - $newsletter = NewsletterFactory::createOne(); - - SubscriberMetadataDefinitionFactory::createOne([ - 'key' => 'name', - 'name' => 'Name', - 'newsletter' => $newsletter, - ]); - - $subscriber = SubscriberFactory::createOne([ - 'newsletter' => $newsletter, - 'status' => SubscriberStatus::UNSUBSCRIBED, - ]); - - $metaUpdate = [ - 'name' => 'Thibault', - ]; - - $response = $this->consoleApi( - $newsletter, - 'PATCH', - '/subscribers/' . $subscriber->getId(), - [ - 'metadata' => $metaUpdate, - ] - ); - - $this->assertSame(200, $response->getStatusCode()); - - $subscriber = $this->em->getRepository(Subscriber::class)->find($subscriber->getId()); - $this->assertInstanceOf(Subscriber::class, $subscriber); - $this->assertSame($subscriber->getMetadata(), $metaUpdate); - } - - public function test_update_subscriber_metadata_invalid_name(): void - { - $newsletter = NewsletterFactory::createOne(); - - $subscriber = SubscriberFactory::createOne([ - 'newsletter' => $newsletter, - 'status' => SubscriberStatus::UNSUBSCRIBED, - ]); - - SubscriberMetadataDefinitionFactory::createOne([ - 'key' => 'age', - 'name' => 'Age', - 'newsletter' => $newsletter, - ]); - - $response = $this->consoleApi( - $newsletter, - 'PATCH', - '/subscribers/' . $subscriber->getId(), - [ - 'metadata' => ['name' => 'Thibault'], - ] - ); - - $this->assertSame(422, $response->getStatusCode()); - $json = $this->getJson(); - $this->assertSame( - 'Metadata definition with key name not found', - $json['message'] - ); - } - - public function test_update_subscriber_metadata_invalid_type(): void - { - // TODO: Implement this test when other metadata types are implemented - $this->markTestSkipped(); - } -} From 8703b93878adb0fcc168219dba0618f31fb02351 Mon Sep 17 00:00:00 2001 From: Supun Wimalasena Date: Mon, 2 Mar 2026 16:03:37 +0100 Subject: [PATCH 09/43] API docs --- .../docs/[...slug]/content/ConsoleApi.svelte | 144 ++++++++++-------- 1 file changed, 81 insertions(+), 63 deletions(-) diff --git a/frontend/src/routes/(marketing)/docs/[...slug]/content/ConsoleApi.svelte b/frontend/src/routes/(marketing)/docs/[...slug]/content/ConsoleApi.svelte index a87ca52fd..01971edcb 100644 --- a/frontend/src/routes/(marketing)/docs/[...slug]/content/ConsoleApi.svelte +++ b/frontend/src/routes/(marketing)/docs/[...slug]/content/ConsoleApi.svelte @@ -284,9 +284,8 @@

    Objects:

    @@ -331,7 +324,7 @@ `} /> -

    Create a subscriber

    +

    Create or update a subscriber

    POST /subscribers @@ -339,32 +332,97 @@ language="ts" code={` type Request = { + // If a subscriber with the given email already exists, it will be updated. + // Otherwise, a new subscriber will be created. email: string; - source?: 'console' | 'form' | 'import'; // default: 'console' + + // The lists that the subscriber has subscribed to + // Send an array of list IDs (number) or names (string) + lists: (number | string)[]; + + // The subscriber's subscription status + // set \`send_pending_confirmation_email=true\` to send a confirmation email + // default: pending + status?: 'pending' | 'subscribed' | 'unsubscribed'; + + // the source of the subscriber (default: 'console') + source?: 'console' | 'form' | 'import'; + + // subscriber's IP address subscribe_ip?: string | null; + + // unix timestamp of when the subscriber opted in + // if not set, it will be set to the current time if status is 'subscribed' subscribed_at?: number | null; // unix timestamp + + // unix timestamp of when the subscriber unsubscribed + // if not set, it will be set to the current time if status is 'unsubscribed' unsubscribed_at?: number | null; // unix timestamp - } - type Response = Subscriber - `} -/> -

    Update a subscriber

    + // additional metadata for the subscriber + // keys must be defined in the Subscriber Metadata Definitions section (or using the API) + metadata?: Record; -PATCH /subscribers/{'{id}'} + // ============ SETTINGS =========== + // change how the endpoint behaves -; + // define how to handle the case when the subscriber has + // previously unsubscribed from a list that is provided + // see below for more info + // default: 'ignore' + list_add_strategy_if_unsubscribed: 'ignore' | 'force_add'; + + // define the reason for removing the subscriber from a list + // see below + // default: 'unsubscribe' + list_remove_reason: 'unsubscribe' | 'other'; + + // whether to send a confirmation email when adding a subscriber with 'pending' status + // or when changing an existing subscriber's status to 'pending'. + // default: false + send_pending_confirmation_email?: boolean; } type Response = Subscriber `} /> +
    Managing list unsubscriptions and re-subscriptions
    + +

    + list_add_strategy_if_unsubscribed: +

    + +
      +
    • + ignore - use this strategy for most auto-subscribing cases (e.g. automatically subscribing + a user to a list when they start a trial). This makes sures that if the user has previously unsubscribed + from the list, they will not be re-subscribed. +
    • +
    • + force_add - use this strategy if the user is explicitly asking to subscribe to the + list again (e.g. they checked a checkbox to subscribe to the newsletter). This will add the subscriber + to the list even if they have previously unsubscribed. +
    • +
    + +

    + list_remove_reason: +

    + +
      +
    • + unsubscribe - use this reason if the subscriber is explicitly asking to be + removed from the list (e.g. they unchecked a checkbox to unsubscribe). This will record an + unsubscription, blocking future re-adds unless + list_add_strategy_if_unsubscribed=force_add. Hyvor Post's default unsubscribe + form uses this. +
    • +
    • + other - use this reason if you want to remove the subscriber from the list without + recording an unsubscription. +
    • +
    +

    Delete a subscriber

    DELETE /subscribers/{'{id}'} @@ -398,46 +456,6 @@ `} /> -

    Add a subscriber to a list

    - -POST /subscribers/{'{id}'}/lists - - - -

    Remove a subscriber from a list

    - -DELETE /subscribers/{'{id}'}/lists - - -

    Subscriber Metadata

    From 38ed5460e65af8f626038dce9eef7499d33b3fe3 Mon Sep 17 00:00:00 2001 From: Supun Wimalasena Date: Tue, 3 Mar 2026 09:33:19 +0100 Subject: [PATCH 10/43] wip endpoint --- .../Controller/SubscriberController.php | 211 +++----- .../Subscriber/CreateSubscriberInput.php | 45 +- .../ListAddStrategyIfUnsubscribed.php | 9 + .../Input/Subscriber/ListRemoveReason.php | 9 + .../Input/Subscriber/MetadataStrategy.php | 9 + .../App/Messenger/MessageTransport.php | 8 + .../Event/SubscriberCreatedEvent.php | 25 + .../Service/Subscriber/SubscriberService.php | 106 ++-- .../Subscriber/CreateSubscriberTest.php | 495 ++++++++++++------ .../docs/[...slug]/content/ConsoleApi.svelte | 138 ++++- 10 files changed, 676 insertions(+), 379 deletions(-) create mode 100644 backend/src/Api/Console/Input/Subscriber/ListAddStrategyIfUnsubscribed.php create mode 100644 backend/src/Api/Console/Input/Subscriber/ListRemoveReason.php create mode 100644 backend/src/Api/Console/Input/Subscriber/MetadataStrategy.php create mode 100644 backend/src/Service/App/Messenger/MessageTransport.php create mode 100644 backend/src/Service/Subscriber/Event/SubscriberCreatedEvent.php diff --git a/backend/src/Api/Console/Controller/SubscriberController.php b/backend/src/Api/Console/Controller/SubscriberController.php index 5ccd97448..515ab2762 100644 --- a/backend/src/Api/Console/Controller/SubscriberController.php +++ b/backend/src/Api/Console/Controller/SubscriberController.php @@ -4,14 +4,10 @@ use App\Api\Console\Authorization\Scope; use App\Api\Console\Authorization\ScopeRequired; -use App\Api\Console\Input\Subscriber\AddSubscriberListInput; use App\Api\Console\Input\Subscriber\BulkActionSubscriberInput; -use App\Api\Console\Input\Subscriber\CreateSubscriberIfExists; use App\Api\Console\Input\Subscriber\CreateSubscriberInput; -use App\Api\Console\Input\Subscriber\RemoveSubscriberListInput; -use App\Api\Console\Input\Subscriber\RemoveSubscriberListReason; -use App\Api\Console\Input\Subscriber\SubscriberListIfUnsubscribed; -use App\Api\Console\Input\Subscriber\UpdateSubscriberInput; +use App\Api\Console\Input\Subscriber\ListAddStrategyIfUnsubscribed; +use App\Api\Console\Input\Subscriber\ListRemoveReason; use App\Api\Console\Object\SubscriberObject; use App\Entity\Newsletter; use App\Entity\Subscriber; @@ -19,6 +15,7 @@ use App\Entity\Type\SubscriberStatus; use App\Service\NewsletterList\NewsletterListService; use App\Service\Subscriber\Dto\UpdateSubscriberDto; +use App\Service\Subscriber\Message\SubscriberCreatedMessage; use App\Service\Subscriber\SubscriberService; use App\Service\SubscriberMetadata\SubscriberMetadataService; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; @@ -27,18 +24,18 @@ use Symfony\Component\HttpKernel\Attribute\MapRequestPayload; use Symfony\Component\HttpKernel\Exception\BadRequestHttpException; use Symfony\Component\HttpKernel\Exception\UnprocessableEntityHttpException; +use Symfony\Component\Messenger\MessageBusInterface; use Symfony\Component\Routing\Attribute\Route; class SubscriberController extends AbstractController { public function __construct( - private SubscriberService $subscriberService, - private NewsletterListService $newsletterListService, + private SubscriberService $subscriberService, + private NewsletterListService $newsletterListService, private SubscriberMetadataService $subscriberMetadataService, - ) - { - } + private MessageBusInterface $messageBus, + ) {} #[Route('/subscribers', methods: 'GET')] #[ScopeRequired(Scope::SUBSCRIBERS_READ)] @@ -70,7 +67,7 @@ public function getSubscribers(Request $request, Newsletter $newsletter): JsonRe $listId, $search, $limit, - $offset + $offset, ) ->map(fn($subscriber) => new SubscriberObject($subscriber)); @@ -81,29 +78,36 @@ public function getSubscribers(Request $request, Newsletter $newsletter): JsonRe #[ScopeRequired(Scope::SUBSCRIBERS_WRITE)] public function createSubscriber( #[MapRequestPayload] CreateSubscriberInput $input, - Newsletter $newsletter - ): JsonResponse - { + Newsletter $newsletter, + ): JsonResponse { + // Resolve lists + $resolvedLists = []; + foreach ($input->lists as $listIdOrName) { + $id = is_int($listIdOrName) ? $listIdOrName : null; + $name = is_string($listIdOrName) ? $listIdOrName : null; + $list = $this->newsletterListService->getListByIdOrName($newsletter, $id, $name); + if ($list === null) { + throw new UnprocessableEntityHttpException("List not found: {$listIdOrName}"); + } + $resolvedLists[] = $list; + } $subscriber = $this->subscriberService->getSubscriberByEmail($newsletter, $input->email); if ($subscriber === null) { - - // create subscriber $subscriber = $this->subscriberService->createSubscriber( $newsletter, $input->email, - [], - SubscriberStatus::PENDING, + $resolvedLists, + $input->status, source: $input->source ?? SubscriberSource::CONSOLE, - subscribeIp: $input->subscribe_ip ?? null, - subscribedAt: $input->has('subscribed_at') ? \DateTimeImmutable::createFromTimestamp($input->subscribed_at) : null, - unsubscribedAt: $input->has('unsubscribed_at') ? \DateTimeImmutable::createFromTimestamp($input->unsubscribed_at) : null, + subscribeIp: $input->has('subscribe_ip') ? $input->subscribe_ip : null, + subscribedAt: $input->getSubscribedAt(), + unsubscribedAt: $input->getUnsubscribedAt(), + sendConfirmationEmail: $input->send_pending_confirmation_email, ); - - } elseif ($input->if_exists === CreateSubscriberIfExists::UPDATE) { - - // update + } else { + // Update existing subscriber with provided fields $updates = new UpdateSubscriberDto(); $updates->status = $input->status; @@ -128,54 +132,41 @@ public function createSubscriber( : null; } - $subscriber = $this->subscriberService->updateSubscriber($subscriber, $updates); - - } else { - throw new UnprocessableEntityHttpException("Subscriber with email {$input->email} already exists"); - } - - return $this->json(new SubscriberObject($subscriber)); - } - - #[Route('/subscribers/{id}', methods: 'PATCH')] - #[ScopeRequired(Scope::SUBSCRIBERS_WRITE)] - public function updateSubscriber( - Subscriber $subscriber, - Newsletter $newsletter, - #[MapRequestPayload] UpdateSubscriberInput $input - ): JsonResponse - { - $updates = new UpdateSubscriberDto(); - - if ($input->has('email')) { - $subscriberDB = $this->subscriberService->getSubscriberByEmail($newsletter, $input->email); - if ($subscriberDB !== null) { - throw new UnprocessableEntityHttpException("Subscriber with email {$input->email} already exists"); - } - - $updates->email = $input->email; - } - - if ($input->has('status')) { - if ($input->status === SubscriberStatus::SUBSCRIBED && $subscriber->getOptInAt() === null) { - throw new UnprocessableEntityHttpException('Subscribers without opt-in can not be updated to SUBSCRIBED status.'); + if ($input->has('metadata')) { + $updates->metadata = $input->metadata; } - $updates->status = $input->status; + $subscriber = $this->subscriberService->updateSubscriber($subscriber, $updates); } - $metadataDefinitions = $this->subscriberMetadataService->getMetadataDefinitions($newsletter); +// // Sync lists +// $resolvedListIds = array_map(fn($l) => $l->getId(), $resolvedLists); +// $currentListIds = $subscriber->getLists()->map(fn($l) => $l->getId())->toArray(); +// +// // Add new lists +// foreach ($resolvedLists as $list) { +// if (!in_array($list->getId(), $currentListIds)) { +// if ( +// $input->list_add_strategy_if_unsubscribed === ListAddStrategyIfUnsubscribed::IGNORE && +// $this->subscriberService->hasSubscriberUnsubscribedFromList($subscriber, $list) +// ) { +// continue; +// } +// $this->subscriberService->addSubscriberToList($subscriber, $list); +// } +// } +// +// // Remove lists no longer in the resolved set +// foreach ($subscriber->getLists()->toArray() as $existingList) { +// if (!in_array($existingList->getId(), $resolvedListIds)) { +// $this->subscriberService->removeSubscriberFromList( +// $subscriber, +// $existingList, +// $input->list_remove_reason === ListRemoveReason::UNSUBSCRIBE, +// ); +// } +// } - if ($input->has('metadata')) { - try { - $this->subscriberMetadataService->validateMetadata($newsletter, $input->metadata); - } catch (\Exception $e) { - throw new UnprocessableEntityHttpException($e->getMessage()); - } - $updates->metadata = $input->metadata; - } - - $subscriber = $this->subscriberService->updateSubscriber($subscriber, $updates); return $this->json(new SubscriberObject($subscriber)); } @@ -189,8 +180,10 @@ public function deleteSubscriber(Subscriber $subscriber): JsonResponse #[Route('/subscribers/bulk', methods: 'POST')] #[ScopeRequired(Scope::SUBSCRIBERS_WRITE)] - public function bulkActions(Newsletter $newsletter, #[MapRequestPayload] BulkActionSubscriberInput $input): JsonResponse - { + public function bulkActions( + Newsletter $newsletter, + #[MapRequestPayload] BulkActionSubscriberInput $input, + ): JsonResponse { if (count($input->subscribers_ids) >= $this->subscriberService::BULK_SUBSCRIBER_LIMIT) { throw new UnprocessableEntityHttpException("Subscribers limit exceeded"); } @@ -202,7 +195,9 @@ public function bulkActions(Newsletter $newsletter, #[MapRequestPayload] BulkAct $subscriber = array_find($currentSubscribers, fn($s) => $s->getId() === $subscriberId); if ($subscriber === null) { - throw new UnprocessableEntityHttpException("Subscriber with ID {$subscriberId} not found in the newsletter"); + throw new UnprocessableEntityHttpException( + "Subscriber with ID {$subscriberId} not found in the newsletter", + ); } $subscribers[] = $subscriber; @@ -213,13 +208,14 @@ public function bulkActions(Newsletter $newsletter, #[MapRequestPayload] BulkAct return $this->json([ 'status' => 'success', 'message' => 'Subscribers deleted successfully', - 'subscribers' => [] + 'subscribers' => [], ]); } if ($input->action == 'status_change') { - if ($input->status == null) + if ($input->status == null) { throw new UnprocessableEntityHttpException("Status must be provided for status change action"); + } $status = SubscriberStatus::tryFrom($input->status); if (!$status) { @@ -241,13 +237,14 @@ public function bulkActions(Newsletter $newsletter, #[MapRequestPayload] BulkAct return $this->json([ 'status' => 'success', 'message' => 'Subscribers status updated successfully', - 'subscribers' => array_map(fn($s) => new SubscriberObject($s), $subscribers) + 'subscribers' => array_map(fn($s) => new SubscriberObject($s), $subscribers), ]); } if ($input->action == 'metadata_update') { - if ($input->metadata == null) + if ($input->metadata == null) { throw new UnprocessableEntityHttpException("Metadata must be provided for metadata update action"); + } foreach ($subscribers as $subscriber) { $updates = new UpdateSubscriberDto(); @@ -265,70 +262,10 @@ public function bulkActions(Newsletter $newsletter, #[MapRequestPayload] BulkAct return $this->json([ 'status' => 'success', 'message' => 'Subscribers metadata updated successfully', - 'subscribers' => array_map(fn($s) => new SubscriberObject($s), $subscribers) + 'subscribers' => array_map(fn($s) => new SubscriberObject($s), $subscribers), ]); } throw new BadRequestHttpException("Unhandled action"); } - - #[Route('/subscribers/{id}/lists', methods: 'POST')] - #[ScopeRequired(Scope::SUBSCRIBERS_WRITE)] - public function addSubscriberList( - Subscriber $subscriber, - Newsletter $newsletter, - #[MapRequestPayload] AddSubscriberListInput $input - ): JsonResponse - { - if ($input->id === null && $input->name === null) { - throw new UnprocessableEntityHttpException('Either id or name must be provided'); - } - - $list = $this->newsletterListService->getListByIdOrName($newsletter, $input->id, $input->name); - - if ($list === null) { - throw new UnprocessableEntityHttpException('List not found'); - } - - if ( - $input->if_unsubscribed === SubscriberListIfUnsubscribed::ERROR && - $this->subscriberService->hasSubscriberUnsubscribedFromList($subscriber, $list) - ) { - throw new BadRequestHttpException('Subscriber was previously unsubscribed and can not be added to the list again'); - } - - $this->subscriberService->addSubscriberToList( - $subscriber, - $list, - ); - - return $this->json(new SubscriberObject($subscriber)); - } - - #[Route('/subscribers/{id}/lists', methods: 'DELETE')] - #[ScopeRequired(Scope::SUBSCRIBERS_WRITE)] - public function removeSubscriberList( - Subscriber $subscriber, - Newsletter $newsletter, - #[MapRequestPayload] RemoveSubscriberListInput $input - ): JsonResponse - { - if ($input->id === null && $input->name === null) { - throw new UnprocessableEntityHttpException('Either id or name must be provided'); - } - - $list = $this->newsletterListService->getListByIdOrName($newsletter, $input->id, $input->name); - - if ($list === null) { - throw new UnprocessableEntityHttpException('List not found'); - } - - $this->subscriberService->removeSubscriberFromList( - $subscriber, - $list, - $input->reason === RemoveSubscriberListReason::UNSUBSCRIBE - ); - - return $this->json(new SubscriberObject($subscriber)); - } } diff --git a/backend/src/Api/Console/Input/Subscriber/CreateSubscriberInput.php b/backend/src/Api/Console/Input/Subscriber/CreateSubscriberInput.php index 3ca8ba409..ce36f698a 100644 --- a/backend/src/Api/Console/Input/Subscriber/CreateSubscriberInput.php +++ b/backend/src/Api/Console/Input/Subscriber/CreateSubscriberInput.php @@ -5,12 +5,14 @@ use App\Entity\Type\SubscriberSource; use App\Entity\Type\SubscriberStatus; use App\Util\OptionalPropertyTrait; +use Symfony\Component\Clock\ClockAwareTrait; use Symfony\Component\Validator\Constraints as Assert; class CreateSubscriberInput { use OptionalPropertyTrait; + use ClockAwareTrait; #[Assert\NotBlank] #[Assert\Email] @@ -19,15 +21,48 @@ class CreateSubscriberInput public SubscriberStatus $status = SubscriberStatus::PENDING; - public ?SubscriberSource $source; + public ?SubscriberSource $source = null; #[Assert\Ip(version: Assert\Ip::ALL_ONLY_PUBLIC)] - public ?string $subscribe_ip; + private ?string $subscribe_ip; - public ?int $subscribed_at; + private ?int $subscribed_at; - public ?int $unsubscribed_at; + private ?int $unsubscribed_at; - public CreateSubscriberIfExists $if_exists = CreateSubscriberIfExists::ERROR; + /** + * @var ?(int|string)[] + */ + public ?array $lists = null; + + /** + * @var array|null + */ + public ?array $metadata = null; + + public ListAddStrategyIfUnsubscribed $list_add_strategy_if_unsubscribed = ListAddStrategyIfUnsubscribed::IGNORE; + + public ListRemoveReason $list_remove_reason = ListRemoveReason::UNSUBSCRIBE; + + public MetadataStrategy $metadata_strategy = MetadataStrategy::MERGE; + + public bool $send_pending_confirmation_email = false; + + public function getSubscriberIp(): ?string + { + return $this->has('subscribe_ip') ? $this->subscribe_ip : null; + } + + public function getSubscribedAt(): ?\DateTimeImmutable + { + $subscribedAt = $this->has('subscribed_at') ? $this->subscribed_at : null; + return $subscribedAt ? new \DateTimeImmutable()->setTimestamp($this->subscribed_at) : null; + } + + public function getUnsubscribedAt(): ?\DateTimeImmutable + { + $unsubscribedAt = $this->has('unsubscribed_at') ? $this->unsubscribed_at : null; + return $unsubscribedAt ? new \DateTimeImmutable()->setTimestamp($this->unsubscribed_at) : null; + } } diff --git a/backend/src/Api/Console/Input/Subscriber/ListAddStrategyIfUnsubscribed.php b/backend/src/Api/Console/Input/Subscriber/ListAddStrategyIfUnsubscribed.php new file mode 100644 index 000000000..78f12fb20 --- /dev/null +++ b/backend/src/Api/Console/Input/Subscriber/ListAddStrategyIfUnsubscribed.php @@ -0,0 +1,9 @@ +subscriber; + } + + public function shouldSendConfirmationEmail(): bool + { + return $this->sendConfirmationEmail; + } + +} diff --git a/backend/src/Service/Subscriber/SubscriberService.php b/backend/src/Service/Subscriber/SubscriberService.php index 6fac24b67..1c1128fd8 100644 --- a/backend/src/Service/Subscriber/SubscriberService.php +++ b/backend/src/Service/Subscriber/SubscriberService.php @@ -14,11 +14,13 @@ use App\Entity\Type\SubscriberStatus; use App\Repository\SubscriberRepository; use App\Service\Subscriber\Dto\UpdateSubscriberDto; +use App\Service\Subscriber\Event\SubscriberCreatedEvent; use App\Service\Subscriber\Message\ExportSubscribersMessage; use App\Service\Subscriber\Message\SubscriberCreatedMessage; use Doctrine\Common\Collections\ArrayCollection; use Doctrine\ORM\EntityManagerInterface; use Symfony\Component\Clock\ClockAwareTrait; +use Symfony\Component\EventDispatcher\EventDispatcherInterface; use Symfony\Component\Messenger\MessageBusInterface; class SubscriberService @@ -29,27 +31,25 @@ class SubscriberService public const BULK_SUBSCRIBER_LIMIT = 100; public function __construct( - private EntityManagerInterface $em, - private SubscriberRepository $subscriberRepository, - private MessageBusInterface $messageBus, - ) - { - } + private EntityManagerInterface $em, + private SubscriberRepository $subscriberRepository, + private EventDispatcherInterface $ed, + ) {} /** * @param iterable $lists */ public function createSubscriber( - Newsletter $newsletter, - string $email, - iterable $lists, - SubscriberStatus $status, - SubscriberSource $source, - ?string $subscribeIp = null, + Newsletter $newsletter, + string $email, + iterable $lists, + SubscriberStatus $status, + SubscriberSource $source, + ?string $subscribeIp = null, ?\DateTimeImmutable $subscribedAt = null, - ?\DateTimeImmutable $unsubscribedAt = null - ): Subscriber - { + ?\DateTimeImmutable $unsubscribedAt = null, + bool $sendConfirmationEmail = true, + ): Subscriber { $subscriber = new Subscriber() ->setNewsletter($newsletter) ->setEmail($email) @@ -83,7 +83,7 @@ public function createSubscriber( $this->em->persist($subscriber); $this->em->flush(); - $this->messageBus->dispatch(new SubscriberCreatedMessage($subscriber->getId())); + $this->ed->dispatch(new SubscriberCreatedEvent($subscriber, $sendConfirmationEmail)); return $subscriber; } @@ -102,7 +102,8 @@ public function deleteSubscribers(array $subscribers): void $ids = array_map(fn(Subscriber $s) => $s->getId(), $subscribers); $qb = $this->em->createQueryBuilder(); - $qb->delete(Subscriber::class, 's') + $qb + ->delete(Subscriber::class, 's') ->where($qb->expr()->in('s.id', ':ids')) ->setParameter('ids', $ids); @@ -113,14 +114,13 @@ public function deleteSubscribers(array $subscribers): void * @return ArrayCollection */ public function getSubscribers( - Newsletter $newsletter, + Newsletter $newsletter, ?SubscriberStatus $status, - ?int $listId, - ?string $search, - int $limit, - int $offset - ): ArrayCollection - { + ?int $listId, + ?string $search, + int $limit, + int $offset, + ): ArrayCollection { $qb = $this->subscriberRepository->createQueryBuilder('s'); $qb @@ -133,18 +133,21 @@ public function getSubscribers( ->setFirstResult($offset); if ($status !== null) { - $qb->andWhere('s.status = :status') + $qb + ->andWhere('s.status = :status') ->setParameter('status', $status->value); } if ($listId !== null) { - $qb->andWhere('l.id = :listId') + $qb + ->andWhere('l.id = :listId') ->andWhere('l.deleted_at IS NULL') ->setParameter('listId', $listId); } if ($search !== null) { - $qb->andWhere('s.email LIKE :search') + $qb + ->andWhere('s.email LIKE :search') ->setParameter('search', '%' . $search . '%'); } @@ -211,11 +214,10 @@ public function getSubscriberByEmail(Newsletter $newsletter, string $email): ?Su } public function unsubscribeBySend( - Send $send, + Send $send, ?\DateTimeImmutable $at = null, - ?string $reason = null - ): void - { + ?string $reason = null, + ): void { $subscriber = $send->getSubscriber(); $update = new UpdateSubscriberDto(); @@ -229,14 +231,14 @@ public function unsubscribeBySend( } public function unsubscribeByEmail( - string $email, + string $email, ?\DateTimeImmutable $at = null, - ?string $reason = null - ): void - { + ?string $reason = null, + ): void { $qb = $this->em->createQueryBuilder(); - $qb->update(Subscriber::class, 's') + $qb + ->update(Subscriber::class, 's') ->set('s.status', ':status') ->set('s.opt_in_at', ':optInAt') ->set('s.unsubscribed_at', ':unsubscribedAt') @@ -270,9 +272,8 @@ public function exportSubscribers(Newsletter $newsletter): SubscriberExport public function markSubscriberExportAsFailed( SubscriberExport $subscriberExport, - string $errorMessage - ): void - { + string $errorMessage, + ): void { $subscriberExport->setStatus(SubscriberExportStatus::FAILED); $subscriberExport->setErrorMessage($errorMessage); $this->em->persist($subscriberExport); @@ -281,9 +282,8 @@ public function markSubscriberExportAsFailed( public function markSubscriberExportAsCompleted( SubscriberExport $subscriberExport, - Media $media - ): void - { + Media $media, + ): void { $subscriberExport->setStatus(SubscriberExportStatus::COMPLETED); $subscriberExport->setMedia($media); $this->em->persist($subscriberExport); @@ -295,7 +295,8 @@ public function markSubscriberExportAsCompleted( */ public function getExports(Newsletter $newsletter): array { - return $this->em->getRepository(SubscriberExport::class) + return $this->em + ->getRepository(SubscriberExport::class) ->findBy(['newsletter' => $newsletter], ['created_at' => 'DESC']); } @@ -305,10 +306,8 @@ public function getExports(Newsletter $newsletter): array */ public function setSubscriberLists( Subscriber $subscriber, - array $lists - ): void - { - + array $lists, + ): void { $listIds = array_map(fn(NewsletterList $list) => $list->getId(), $lists); // remove lists that are not in the new list @@ -329,14 +328,12 @@ public function setSubscriberLists( $this->em->persist($subscriber); $this->em->flush(); - } public function addSubscriberToList( - Subscriber $subscriber, + Subscriber $subscriber, NewsletterList $list, - ): void - { + ): void { $subscriber->addList($list); $subscriber->setUpdatedAt($this->now()); @@ -345,11 +342,10 @@ public function addSubscriberToList( } public function removeSubscriberFromList( - Subscriber $subscriber, + Subscriber $subscriber, NewsletterList $list, - bool $recordUnsubscription - ): void - { + bool $recordUnsubscription, + ): void { $subscriber->removeList($list); $subscriber->setUpdatedAt($this->now()); diff --git a/backend/tests/Api/Console/Subscriber/CreateSubscriberTest.php b/backend/tests/Api/Console/Subscriber/CreateSubscriberTest.php index a215f2ce4..53e1dc120 100644 --- a/backend/tests/Api/Console/Subscriber/CreateSubscriberTest.php +++ b/backend/tests/Api/Console/Subscriber/CreateSubscriberTest.php @@ -5,14 +5,20 @@ use App\Api\Console\Controller\SubscriberController; use App\Entity\Newsletter; use App\Entity\Subscriber; +use App\Entity\SubscriberListUnsubscribed; use App\Entity\Type\SubscriberSource; use App\Entity\Type\SubscriberStatus; use App\Repository\SubscriberRepository; +use App\Service\App\Messenger\MessageTransport; +use App\Service\NewsletterList\NewsletterListService; +use App\Service\Subscriber\Event\SubscriberCreatedEvent; use App\Service\Subscriber\Message\SubscriberCreatedMessage; use App\Service\Subscriber\SubscriberService; use App\Tests\Case\WebTestCase; use App\Tests\Factory\NewsletterFactory; +use App\Tests\Factory\NewsletterListFactory; use App\Tests\Factory\SubscriberFactory; +use App\Tests\Factory\SubscriberListUnsubscribedFactory; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\Attributes\TestWith; @@ -20,21 +26,29 @@ #[CoversClass(SubscriberService::class)] #[CoversClass(SubscriberRepository::class)] #[CoversClass(Subscriber::class)] +#[CoversClass(NewsletterListService::class)] class CreateSubscriberTest extends WebTestCase { - public function testCreateSubscriberMinimal(): void + public function test_create_subscriber(): void { - $this->mockRelayClient(); $newsletter = NewsletterFactory::createOne(); + $list = NewsletterListFactory::createOne([ + 'newsletter' => $newsletter, + 'name' => 'List 1', + ]); + $response = $this->consoleApi( $newsletter, 'POST', '/subscribers', [ 'email' => 'test@email.com', - ] + 'lists' => [ + 'List 1', + ], + ], ); $this->assertSame(200, $response->getStatusCode()); @@ -49,18 +63,73 @@ public function testCreateSubscriberMinimal(): void $this->assertSame('test@email.com', $subscriber->getEmail()); $this->assertSame(SubscriberStatus::PENDING, $subscriber->getStatus()); $this->assertSame('console', $subscriber->getSource()->value); - $this->assertCount(0, $subscriber->getLists()); - $transport = $this->transport('async'); - $transport->queue()->assertCount(1); - $message = $transport->queue()->first()->getMessage(); - $this->assertInstanceOf(SubscriberCreatedMessage::class, $message); + $lists = $subscriber->getLists(); + $this->assertCount(1, $lists); + $this->assertSame('List 1', $lists->first()?->getName()); + + $event = $this->getEd()->getFirstEvent(SubscriberCreatedEvent::class); + $this->assertNotNull($event); + $this->assertFalse($event->shouldSendConfirmationEmail()); + } + + public function testCreateSubscriberWithListsById(): void + { + $newsletter = NewsletterFactory::createOne(); + $list1 = NewsletterListFactory::createOne(['newsletter' => $newsletter]); + $list2 = NewsletterListFactory::createOne(['newsletter' => $newsletter]); + + $response = $this->consoleApi( + $newsletter, + 'POST', + '/subscribers', + [ + 'email' => 'test@email.com', + 'lists' => [$list1->getId(), $list2->getId()], + ], + ); + + $this->assertSame(200, $response->getStatusCode()); + + $this->em->clear(); + $json = $this->getJson(); + $subscriber = $this->em->getRepository(Subscriber::class)->find($json['id']); + $this->assertInstanceOf(Subscriber::class, $subscriber); + $this->assertCount(2, $subscriber->getLists()); + $listIds = $subscriber->getLists()->map(fn($l) => $l->getId())->toArray(); + $this->assertContains($list1->getId(), $listIds); + $this->assertContains($list2->getId(), $listIds); + } + + public function testCreateSubscriberWithListsByName(): void + { + $newsletter = NewsletterFactory::createOne(); + $list = NewsletterListFactory::createOne(['newsletter' => $newsletter, 'name' => 'My Newsletter']); + + $response = $this->consoleApi( + $newsletter, + 'POST', + '/subscribers', + [ + 'email' => 'test@email.com', + 'lists' => ['My Newsletter'], + ], + ); + + $this->assertSame(200, $response->getStatusCode()); + + $this->em->clear(); + $json = $this->getJson(); + $subscriber = $this->em->getRepository(Subscriber::class)->find($json['id']); + $this->assertInstanceOf(Subscriber::class, $subscriber); + $this->assertCount(1, $subscriber->getLists()); + $this->assertSame($list->getId(), $subscriber->getLists()->first()->getId()); } public function testCreateSubscriberWithAllInputs(): void { - $this->mockRelayClient(); $newsletter = NewsletterFactory::createOne(); + $list = NewsletterListFactory::createOne(['newsletter' => $newsletter]); $subscribedAt = new \DateTimeImmutable('2021-08-27 12:00:00'); $unsubscribedAt = new \DateTimeImmutable('2021-08-29 12:00:00'); @@ -75,7 +144,11 @@ public function testCreateSubscriberWithAllInputs(): void 'subscribe_ip' => '79.255.1.1', 'subscribed_at' => $subscribedAt->getTimestamp(), 'unsubscribed_at' => $unsubscribedAt->getTimestamp(), - ] + 'lists' => [$list->getId()], + 'list_add_strategy_if_unsubscribed' => 'force_add', + 'list_remove_reason' => 'other', + 'send_pending_confirmation_email' => false, + ], ); $this->assertSame(200, $response->getStatusCode()); @@ -89,8 +162,8 @@ public function testCreateSubscriberWithAllInputs(): void $this->assertSame($subscribedAt->getTimestamp(), $json['subscribed_at']); $this->assertSame($unsubscribedAt->getTimestamp(), $json['unsubscribed_at']); - $repository = $this->em->getRepository(Subscriber::class); - $subscriber = $repository->find($json['id']); + $this->em->clear(); + $subscriber = $this->em->getRepository(Subscriber::class)->find($json['id']); $this->assertInstanceOf(Subscriber::class, $subscriber); $this->assertSame('supun@hyvor.com', $subscriber->getEmail()); $this->assertSame(SubscriberStatus::PENDING, $subscriber->getStatus()); @@ -98,251 +171,329 @@ public function testCreateSubscriberWithAllInputs(): void $this->assertSame('79.255.1.1', $subscriber->getSubscribeIp()); $this->assertSame('2021-08-27 12:00:00', $subscriber->getSubscribedAt()?->format('Y-m-d H:i:s')); $this->assertSame('2021-08-29 12:00:00', $subscriber->getUnsubscribedAt()?->format('Y-m-d H:i:s')); - - $transport = $this->transport('async'); - $transport->queue()->assertCount(1); - $message = $transport->queue()->first()->getMessage(); - $this->assertInstanceOf(SubscriberCreatedMessage::class, $message); + $this->assertCount(1, $subscriber->getLists()); } - public function testInputValidationEmptyEmail(): void + public function testUpdateExistingSubscriber(): void { - $this->validateInput( - fn(Newsletter $newsletter) => [], - [ - [ - 'property' => 'email', - 'message' => 'This value should not be blank.', - ], - ] - ); - } + $newsletter = NewsletterFactory::createOne(); + $list = NewsletterListFactory::createOne(['newsletter' => $newsletter]); - public function testInputValidationInvalidEmail(): void - { - $this->validateInput( - fn(Newsletter $newsletter) => [ - 'email' => 'not-email', - ], - [ - [ - 'property' => 'email', - 'message' => 'This value is not a valid email address.', - ], - ] - ); - } + $subscriber = SubscriberFactory::createOne([ + 'newsletter' => $newsletter, + 'email' => 'supun@hyvor.com', + 'status' => SubscriberStatus::UNSUBSCRIBED, + 'subscribe_ip' => '1.2.3.4', + ]); - public function testInputValidationEmailTooLong(): void - { - $this->validateInput( - fn(Newsletter $newsletter) => [ - 'email' => str_repeat('a', 256) . '@hyvor.com', - ], - [ - [ - 'property' => 'email', - 'message' => 'This value is too long. It should have 255 characters or less.', - ], - ] - ); - } + $subscribedAt = new \DateTimeImmutable('2024-01-01 00:00:00'); - public function testInputValidationOptionalValues(): void - { - $this->validateInput( - fn(Newsletter $newsletter) => [ + $response = $this->consoleApi( + $newsletter, + 'POST', + '/subscribers', + [ 'email' => 'supun@hyvor.com', - 'source' => 'invalid-source', - 'subscribe_ip' => '127.0.0.1', - 'subscribed_at' => 'invalid-date', - 'unsubscribed_at' => 'invalid-date', + 'status' => 'pending', + 'subscribe_ip' => '79.255.1.1', + 'subscribed_at' => $subscribedAt->getTimestamp(), + 'source' => 'import', + 'lists' => [$list->getId()], ], - [ - [ - 'property' => 'source', - 'message' => 'This value should be of type int|string.', - ], - [ - 'property' => 'subscribed_at', - 'message' => 'This value should be of type int|null.', - ], - [ - 'property' => 'unsubscribed_at', - 'message' => 'This value should be of type int|null.', - ], - ] ); + + $this->assertSame(200, $response->getStatusCode()); + + $this->em->clear(); + $updated = $this->em->getRepository(Subscriber::class)->find($subscriber->getId()); + $this->assertInstanceOf(Subscriber::class, $updated); + $this->assertSame(SubscriberStatus::PENDING, $updated->getStatus()); + $this->assertSame('79.255.1.1', $updated->getSubscribeIp()); + $this->assertSame('2024-01-01 00:00:00', $updated->getSubscribedAt()?->format('Y-m-d H:i:s')); + $this->assertSame(SubscriberSource::IMPORT, $updated->getSource()); + $this->assertCount(1, $updated->getLists()); + $this->assertSame($list->getId(), $updated->getLists()->first()->getId()); } - #[TestWith(['not a valid ip'])] - #[TestWith(['127.0.0.1'])] // private ip - #[TestWith(['::1'])] // localhost - #[TestWith(['169.254.255.255'])] // reserved ip - public function testValidatesIp( - string $ip - ): void + public function testListAddStrategyIgnore(): void { - $this->validateInput( - fn(Newsletter $newsletter) => [ - 'email' => 'supun@hyvor.com', - 'subscribe_ip' => $ip, - ], + $newsletter = NewsletterFactory::createOne(); + $list = NewsletterListFactory::createOne(['newsletter' => $newsletter]); + $subscriber = SubscriberFactory::createOne(['newsletter' => $newsletter]); + + SubscriberListUnsubscribedFactory::createOne([ + 'list' => $list, + 'subscriber' => $subscriber, + ]); + + $response = $this->consoleApi( + $newsletter, + 'POST', + '/subscribers', [ - [ - 'property' => 'subscribe_ip', - 'message' => 'This value is not a valid IP address.', - ], - ] + 'email' => $subscriber->getEmail(), + 'lists' => [$list->getId()], + 'list_add_strategy_if_unsubscribed' => 'ignore', + ], ); + + $this->assertSame(200, $response->getStatusCode()); + + $this->em->clear(); + $updated = $this->em->getRepository(Subscriber::class)->find($subscriber->getId()); + $this->assertInstanceOf(Subscriber::class, $updated); + // Subscriber should NOT be added to the list (was previously unsubscribed, strategy=ignore) + $this->assertCount(0, $updated->getLists()); } - /** - * @param callable(Newsletter): array $input - * @param array $violations - * @return void - */ - private function validateInput( - callable $input, - array $violations - ): void + public function testListAddStrategyForceAdd(): void { $newsletter = NewsletterFactory::createOne(); + $list = NewsletterListFactory::createOne(['newsletter' => $newsletter]); + $subscriber = SubscriberFactory::createOne(['newsletter' => $newsletter]); + + SubscriberListUnsubscribedFactory::createOne([ + 'list' => $list, + 'subscriber' => $subscriber, + ]); $response = $this->consoleApi( $newsletter, 'POST', '/subscribers', - $input($newsletter), + [ + 'email' => $subscriber->getEmail(), + 'lists' => [$list->getId()], + 'list_add_strategy_if_unsubscribed' => 'force_add', + ], ); - $this->assertSame(422, $response->getStatusCode()); - $this->assertHasViolation($violations[0]['property'], $violations[0]['message']); + $this->assertSame(200, $response->getStatusCode()); + + $this->em->clear(); + $updated = $this->em->getRepository(Subscriber::class)->find($subscriber->getId()); + $this->assertInstanceOf(Subscriber::class, $updated); + // Subscriber SHOULD be added even though previously unsubscribed + $this->assertCount(1, $updated->getLists()); + $this->assertSame($list->getId(), $updated->getLists()->first()->getId()); } - public function test_updates_if_exists(): void + public function testListRemoveReasonUnsubscribe(): void { $newsletter = NewsletterFactory::createOne(); + $list = NewsletterListFactory::createOne(['newsletter' => $newsletter]); + $subscriber = SubscriberFactory::createOne(['newsletter' => $newsletter, 'lists' => [$list]]); - $subscriber = SubscriberFactory::createOne([ - 'newsletter' => $newsletter, - 'email' => 'supun@hyvor.com', - 'status' => SubscriberStatus::UNSUBSCRIBED, - 'subscribe_ip' => '1.2.3.4', + $response = $this->consoleApi( + $newsletter, + 'POST', + '/subscribers', + [ + 'email' => $subscriber->getEmail(), + 'lists' => [], // empty = remove from all lists + 'list_remove_reason' => 'unsubscribe', + ], + ); + + $this->assertSame(200, $response->getStatusCode()); + + $this->em->clear(); + $updated = $this->em->getRepository(Subscriber::class)->find($subscriber->getId()); + $this->assertInstanceOf(Subscriber::class, $updated); + $this->assertCount(0, $updated->getLists()); + + // Should record unsubscription + $record = $this->em->getRepository(SubscriberListUnsubscribed::class)->findOneBy([ + 'list' => $list->_real(), + 'subscriber' => $updated, ]); + $this->assertInstanceOf(SubscriberListUnsubscribed::class, $record); + } - $subscribedAt = new \DateTimeImmutable('2024-01-01 00:00:00'); + public function testListRemoveReasonOther(): void + { + $newsletter = NewsletterFactory::createOne(); + $list = NewsletterListFactory::createOne(['newsletter' => $newsletter]); + $subscriber = SubscriberFactory::createOne(['newsletter' => $newsletter, 'lists' => [$list]]); $response = $this->consoleApi( $newsletter, 'POST', '/subscribers', [ - 'email' => 'supun@hyvor.com', - 'if_exists' => 'update', - 'status' => 'pending', - 'subscribe_ip' => '79.255.1.1', - 'subscribed_at' => $subscribedAt->getTimestamp(), - 'source' => 'import' - ] + 'email' => $subscriber->getEmail(), + 'lists' => [], // empty = remove from all lists + 'list_remove_reason' => 'other', + ], ); $this->assertSame(200, $response->getStatusCode()); - $repository = $this->em->getRepository(Subscriber::class); - $updated = $repository->find($subscriber->getId()); + $this->em->clear(); + $updated = $this->em->getRepository(Subscriber::class)->find($subscriber->getId()); $this->assertInstanceOf(Subscriber::class, $updated); - $this->assertSame(SubscriberStatus::PENDING, $updated->getStatus()); - $this->assertSame('79.255.1.1', $updated->getSubscribeIp()); - $this->assertSame('2024-01-01 00:00:00', $updated->getSubscribedAt()?->format('Y-m-d H:i:s')); - $this->assertSame(SubscriberSource::IMPORT, $updated->getSource()); + $this->assertCount(0, $updated->getLists()); + + // Should NOT record unsubscription + $record = $this->em->getRepository(SubscriberListUnsubscribed::class)->findOneBy([ + 'list' => $list->_real(), + 'subscriber' => $updated, + ]); + $this->assertNull($record); } - public function testCreateSubscriberIfExistsUpdateClearsTimestamps(): void + public function testSendPendingConfirmationEmail(): void { + $this->mockRelayClient(); $newsletter = NewsletterFactory::createOne(); - $subscriber = SubscriberFactory::createOne([ - 'newsletter' => $newsletter, - 'email' => 'supun@hyvor.com', - 'subscribed_at' => new \DateTimeImmutable('2024-01-01'), - 'unsubscribed_at' => new \DateTimeImmutable('2024-06-01'), - ]); - $response = $this->consoleApi( $newsletter, 'POST', '/subscribers', [ - 'email' => 'supun@hyvor.com', - 'if_exists' => 'update', - 'subscribed_at' => null, - 'unsubscribed_at' => null, - ] + 'email' => 'test@email.com', + 'lists' => [], + 'send_pending_confirmation_email' => true, + ], ); $this->assertSame(200, $response->getStatusCode()); - $repository = $this->em->getRepository(Subscriber::class); - $updated = $repository->find($subscriber->getId()); - $this->assertInstanceOf(Subscriber::class, $updated); - $this->assertNull($updated->getSubscribedAt()); - $this->assertNull($updated->getUnsubscribedAt()); + $transport = $this->transport('async'); + $transport->queue()->assertCount(1); + $message = $transport->queue()->first()->getMessage(); + $this->assertInstanceOf(SubscriberCreatedMessage::class, $message); } - public function testCreateSubscriberIfExistsUpdateDoesNotChangeUnsentFields(): void + public function testNoConfirmationEmailByDefault(): void { $newsletter = NewsletterFactory::createOne(); - $subscriber = SubscriberFactory::createOne([ - 'newsletter' => $newsletter, - 'email' => 'supun@hyvor.com', - 'subscribe_ip' => '1.2.3.4', - 'subscribed_at' => new \DateTimeImmutable('2024-01-01'), - ]); - $response = $this->consoleApi( $newsletter, 'POST', '/subscribers', [ - 'email' => 'supun@hyvor.com', - 'if_exists' => 'update', - // subscribe_ip and subscribed_at not sent - ] + 'email' => 'test@email.com', + 'lists' => [], + ], ); $this->assertSame(200, $response->getStatusCode()); - $repository = $this->em->getRepository(Subscriber::class); - $updated = $repository->find($subscriber->getId()); - $this->assertInstanceOf(Subscriber::class, $updated); - $this->assertSame('1.2.3.4', $updated->getSubscribeIp()); - $this->assertSame('2024-01-01 00:00:00', $updated->getSubscribedAt()?->format('Y-m-d H:i:s')); + $transport = $this->transport('async'); + $transport->queue()->assertCount(0); } - public function testCreateSubscriberDuplicateEmail(): void + public function testListNotFound(): void { $newsletter = NewsletterFactory::createOne(); - $subscriber = SubscriberFactory::createOne([ - 'newsletter' => $newsletter, - 'email' => 'thibault@hyvor.com', - ]); $response = $this->consoleApi( $newsletter, 'POST', '/subscribers', [ - 'email' => 'thibault@hyvor.com', - ] + 'email' => 'test@email.com', + 'lists' => [999999], + ], ); $this->assertSame(422, $response->getStatusCode()); - $this->assertSame( - 'Subscriber with email ' . $subscriber->getEmail() . ' already exists', - $this->getJson()['message'] + $this->assertStringContainsString('List not found', $this->getJson()['message']); + } + + public function testInputValidationEmptyEmail(): void + { + $this->validateInput( + fn(Newsletter $newsletter) => ['lists' => []], + [ + [ + 'property' => 'email', + 'message' => 'This value should not be blank.', + ], + ], + ); + } + + public function testInputValidationInvalidEmail(): void + { + $this->validateInput( + fn(Newsletter $newsletter) + => [ + 'email' => 'not-email', + 'lists' => [], + ], + [ + [ + 'property' => 'email', + 'message' => 'This value is not a valid email address.', + ], + ], + ); + } + + public function testInputValidationEmailTooLong(): void + { + $this->validateInput( + fn(Newsletter $newsletter) + => [ + 'email' => str_repeat('a', 256) . '@hyvor.com', + 'lists' => [], + ], + [ + [ + 'property' => 'email', + 'message' => 'This value is too long. It should have 255 characters or less.', + ], + ], ); } + #[TestWith(['not a valid ip'])] + #[TestWith(['127.0.0.1'])] // private ip + #[TestWith(['::1'])] // localhost + #[TestWith(['169.254.255.255'])] // reserved ip + public function testValidatesIp( + string $ip, + ): void { + $this->validateInput( + fn(Newsletter $newsletter) + => [ + 'email' => 'supun@hyvor.com', + 'lists' => [], + 'subscribe_ip' => $ip, + ], + [ + [ + 'property' => 'subscribe_ip', + 'message' => 'This value is not a valid IP address.', + ], + ], + ); + } + + /** + * @param callable(Newsletter): array $input + * @param array $violations + * @return void + */ + private function validateInput( + callable $input, + array $violations, + ): void { + $newsletter = NewsletterFactory::createOne(); + + $response = $this->consoleApi( + $newsletter, + 'POST', + '/subscribers', + $input($newsletter), + ); + + $this->assertSame(422, $response->getStatusCode()); + $this->assertHasViolation($violations[0]['property'], $violations[0]['message']); + } + } diff --git a/frontend/src/routes/(marketing)/docs/[...slug]/content/ConsoleApi.svelte b/frontend/src/routes/(marketing)/docs/[...slug]/content/ConsoleApi.svelte index 01971edcb..171f38bc8 100644 --- a/frontend/src/routes/(marketing)/docs/[...slug]/content/ConsoleApi.svelte +++ b/frontend/src/routes/(marketing)/docs/[...slug]/content/ConsoleApi.svelte @@ -1,5 +1,5 @@

    Console API

    @@ -336,13 +336,18 @@ // Otherwise, a new subscriber will be created. email: string; - // The lists that the subscriber has subscribed to - // Send an array of list IDs (number) or names (string) + // Subscribe to or unsubscribe from lists based + // on the given \`lists_strategy\`. lists: (number | string)[]; + lists_strategy: + | 'sync' // (default) sets the subscriber's lists to the given lists (overwriting existing lists) + | 'add' // adds the subscriber to the given lists + | 'remove'; // removes the subscriber from the given lists + // The subscriber's subscription status // set \`send_pending_confirmation_email=true\` to send a confirmation email - // default: pending + // default: subscribed status?: 'pending' | 'subscribed' | 'unsubscribed'; // the source of the subscriber (default: 'console') @@ -366,16 +371,21 @@ // ============ SETTINGS =========== // change how the endpoint behaves - // define how to handle the case when the subscriber has - // previously unsubscribed from a list that is provided + // if the subscriber was previously removed from a list, + // define the reason(s) for ignoring the re-subscription to that list. // see below for more info - // default: 'ignore' - list_add_strategy_if_unsubscribed: 'ignore' | 'force_add'; + // default: ['unsubscribe', 'bounce'] + list_ignore_resubscribe_on: ('unsubscribe' | 'bounce' | 'auto')[]; // define the reason for removing the subscriber from a list - // see below + // (only when updating, see below for more info) // default: 'unsubscribe' - list_remove_reason: 'unsubscribe' | 'other'; + list_remove_reason: 'unsubscribe' | 'bounce' | 'auto'; + + // whether to overwrite or merge the subscriber's metadata + // when updating an existing subscriber. + // default: 'merge' + metadata_strategy: 'merge' | 'overwrite'; // whether to send a confirmation email when adding a subscriber with 'pending' status // or when changing an existing subscriber's status to 'pending'. @@ -388,6 +398,12 @@
    Managing list unsubscriptions and re-subscriptions
    +

    + For all subscribers, Hyvor Post records the lists they have previously unsubscribed from. This + makes it easier to build automations around list subscriptions while respecting subscribers' + preferences. +

    +

    list_add_strategy_if_unsubscribed:

    @@ -423,6 +439,108 @@ +
    Examples
    + +
    + +
    + This example creates a new subscriber with a subscription to the "Default" list. If a + subscriber exists in with the same email, they will be updated and their lists will be + set to only "Default" (overwriting existing lists). +
    + + +
    + + +
    + Assuming you have a list with List ID 123, this example adds the subscriber to that list + without affecting their other list subscriptions. If the subscriber is already + subscribed to the list, no changes will be made. +
    + + +
    + + +
    This example simply removes the subscriber from the list named "Paid Users".
    + + +
    + + +
    + This example creates a subscriber or updates an existing subscriber with "pending" + status, and will send a confirmation email to the subscriber asking them to confirm + their subscription. +
    + +
    + + +
    + By default, this endpoint ignores re-subscription attempts to lists that the subscriber + has previously unsubscribed from (or was removed from due to a bounce). This example + shows how to override that behavior. +
    + + +

    + To force re-adding both previous unsubscribes and bounces, use an empty array for list_ignore_resubscribe_on. +

    +
    +
    +

    Delete a subscriber

    DELETE /subscribers/{'{id}'} From 2ff5549657bd0242a8e9848ad5645055be017d65 Mon Sep 17 00:00:00 2001 From: Supun Wimalasena Date: Tue, 3 Mar 2026 13:11:57 +0100 Subject: [PATCH 11/43] input planned --- backend/config/reference.php | 80 ------------------ .../Controller/SubscriberController.php | 72 ++++++++++++---- .../Subscriber/CreateSubscriberInput.php | 37 +++++++-- .../ListAddStrategyIfUnsubscribed.php | 9 -- .../Input/Subscriber/ListRemoveReason.php | 1 + .../Subscriber/ListSkipResubscribeOn.php | 10 +++ .../Input/Subscriber/ListsStrategy.php | 12 +++ .../NewsletterList/NewsletterListService.php | 83 +++++++++++-------- .../Service/Subscriber/SubscriberService.php | 4 +- .../Subscriber/CreateSubscriberTest.php | 14 +++- .../docs/[...slug]/content/ConsoleApi.svelte | 31 ++++--- 11 files changed, 188 insertions(+), 165 deletions(-) delete mode 100644 backend/src/Api/Console/Input/Subscriber/ListAddStrategyIfUnsubscribed.php create mode 100644 backend/src/Api/Console/Input/Subscriber/ListSkipResubscribeOn.php create mode 100644 backend/src/Api/Console/Input/Subscriber/ListsStrategy.php diff --git a/backend/config/reference.php b/backend/config/reference.php index 3b4449954..fa8fa85ec 100644 --- a/backend/config/reference.php +++ b/backend/config/reference.php @@ -1320,85 +1320,6 @@ * expired_worker_ttl?: int|Param, // How long to keep expired workers in cache (in seconds). // Default: 3600 * }, * } - * @psalm-type SentryConfig = array{ - * dsn?: scalar|Param|null, // If this value is not provided, the SDK will try to read it from the SENTRY_DSN environment variable. If that variable also does not exist, the SDK will not send any events. - * register_error_listener?: bool|Param, // Default: true - * register_error_handler?: bool|Param, // Default: true - * logger?: scalar|Param|null, // The service ID of the PSR-3 logger used to log messages coming from the SDK client. Be aware that setting the same logger of the application may create a circular loop when an event fails to be sent. // Default: null - * options?: array{ - * integrations?: mixed, // Default: [] - * default_integrations?: bool|Param, - * prefixes?: list, - * sample_rate?: float|Param, // The sampling factor to apply to events. A value of 0 will deny sending any event, and a value of 1 will send all events. - * enable_tracing?: bool|Param, - * traces_sample_rate?: float|Param, // The sampling factor to apply to transactions. A value of 0 will deny sending any transaction, and a value of 1 will send all transactions. - * traces_sampler?: scalar|Param|null, - * profiles_sample_rate?: float|Param, // The sampling factor to apply to profiles. A value of 0 will deny sending any profiles, and a value of 1 will send all profiles. Profiles are sampled in relation to traces_sample_rate - * enable_logs?: bool|Param, - * enable_metrics?: bool|Param, // Default: true - * attach_stacktrace?: bool|Param, - * attach_metric_code_locations?: bool|Param, - * context_lines?: int|Param, - * environment?: scalar|Param|null, // Default: "%kernel.environment%" - * logger?: scalar|Param|null, - * spotlight?: bool|Param, - * spotlight_url?: scalar|Param|null, - * release?: scalar|Param|null, // Default: "%env(default::SENTRY_RELEASE)%" - * server_name?: scalar|Param|null, - * ignore_exceptions?: list, - * ignore_transactions?: list, - * before_send?: scalar|Param|null, - * before_send_transaction?: scalar|Param|null, - * before_send_check_in?: scalar|Param|null, - * before_send_metrics?: scalar|Param|null, - * before_send_log?: scalar|Param|null, - * before_send_metric?: scalar|Param|null, - * trace_propagation_targets?: mixed, - * tags?: array, - * error_types?: scalar|Param|null, - * max_breadcrumbs?: int|Param, - * before_breadcrumb?: mixed, - * in_app_exclude?: list, - * in_app_include?: list, - * send_default_pii?: bool|Param, - * max_value_length?: int|Param, - * transport?: scalar|Param|null, - * http_client?: scalar|Param|null, - * http_proxy?: scalar|Param|null, - * http_proxy_authentication?: scalar|Param|null, - * http_connect_timeout?: float|Param, // The maximum number of seconds to wait while trying to connect to a server. It works only when using the default transport. - * http_timeout?: float|Param, // The maximum execution time for the request+response as a whole. It works only when using the default transport. - * http_ssl_verify_peer?: bool|Param, - * http_compression?: bool|Param, - * capture_silenced_errors?: bool|Param, - * max_request_body_size?: "none"|"never"|"small"|"medium"|"always"|Param, - * class_serializers?: array, - * }, - * messenger?: bool|array{ - * enabled?: bool|Param, // Default: true - * capture_soft_fails?: bool|Param, // Default: true - * isolate_breadcrumbs_by_message?: bool|Param, // Default: false - * }, - * tracing?: bool|array{ - * enabled?: bool|Param, // Default: true - * dbal?: bool|array{ - * enabled?: bool|Param, // Default: true - * connections?: list, - * }, - * twig?: bool|array{ - * enabled?: bool|Param, // Default: true - * }, - * cache?: bool|array{ - * enabled?: bool|Param, // Default: true - * }, - * http_client?: bool|array{ - * enabled?: bool|Param, // Default: true - * }, - * console?: array{ - * excluded_commands?: list, - * }, - * }, - * } * @psalm-type ConfigType = array{ * imports?: ImportsConfig, * parameters?: ParametersConfig, @@ -1448,7 +1369,6 @@ * twig_component?: TwigComponentConfig, * twig_extra?: TwigExtraConfig, * zenstruck_messenger_monitor?: ZenstruckMessengerMonitorConfig, - * sentry?: SentryConfig, * }, * "when@test"?: array{ * imports?: ImportsConfig, diff --git a/backend/src/Api/Console/Controller/SubscriberController.php b/backend/src/Api/Console/Controller/SubscriberController.php index 515ab2762..6a4ae212c 100644 --- a/backend/src/Api/Console/Controller/SubscriberController.php +++ b/backend/src/Api/Console/Controller/SubscriberController.php @@ -6,10 +6,11 @@ use App\Api\Console\Authorization\ScopeRequired; use App\Api\Console\Input\Subscriber\BulkActionSubscriberInput; use App\Api\Console\Input\Subscriber\CreateSubscriberInput; -use App\Api\Console\Input\Subscriber\ListAddStrategyIfUnsubscribed; +use App\Api\Console\Input\Subscriber\ListSkipResubscribeOn; use App\Api\Console\Input\Subscriber\ListRemoveReason; use App\Api\Console\Object\SubscriberObject; use App\Entity\Newsletter; +use App\Entity\NewsletterList; use App\Entity\Subscriber; use App\Entity\Type\SubscriberSource; use App\Entity\Type\SubscriberStatus; @@ -80,28 +81,17 @@ public function createSubscriber( #[MapRequestPayload] CreateSubscriberInput $input, Newsletter $newsletter, ): JsonResponse { - // Resolve lists - $resolvedLists = []; - foreach ($input->lists as $listIdOrName) { - $id = is_int($listIdOrName) ? $listIdOrName : null; - $name = is_string($listIdOrName) ? $listIdOrName : null; - $list = $this->newsletterListService->getListByIdOrName($newsletter, $id, $name); - if ($list === null) { - throw new UnprocessableEntityHttpException("List not found: {$listIdOrName}"); - } - $resolvedLists[] = $list; - } - + $lists = $this->resolveLists($newsletter, $input->lists); $subscriber = $this->subscriberService->getSubscriberByEmail($newsletter, $input->email); if ($subscriber === null) { $subscriber = $this->subscriberService->createSubscriber( $newsletter, $input->email, - $resolvedLists, + $lists, $input->status, source: $input->source ?? SubscriberSource::CONSOLE, - subscribeIp: $input->has('subscribe_ip') ? $input->subscribe_ip : null, + subscribeIp: $input->getSubscriberIp(), subscribedAt: $input->getSubscribedAt(), unsubscribedAt: $input->getUnsubscribedAt(), sendConfirmationEmail: $input->send_pending_confirmation_email, @@ -170,6 +160,58 @@ public function createSubscriber( return $this->json(new SubscriberObject($subscriber)); } + /** + * @param (string|int)[] $listIdsOrNames + * @return NewsletterList[] + */ + private function resolveLists(Newsletter $newsletter, array $listIdsOrNames): array + { + $listIds = []; + $listNames = []; + + foreach ($listIdsOrNames as $listIdOrName) { + if (is_int($listIdOrName)) { + $listIds[] = $listIdOrName; + } elseif (is_string($listIdOrName)) { + $listNames[] = $listIdOrName; + } + } + + $resolvedLists = []; + + if (count($listIds) > 0) { + $resolvedLists = $this->newsletterListService->getListsByIds($newsletter, $listIds); + + if (count($resolvedLists) !== count($listIds)) { + $resolvedListIds = array_map(fn($l) => $l->getId(), $resolvedLists); + $missingIds = array_diff($listIds, $resolvedListIds); + throw new UnprocessableEntityHttpException( + "Lists with IDs " . implode(', ', $missingIds) . " not found", + ); + } + } + + if (count($listNames) > 0) { + $listsByName = $this->newsletterListService->getListsByNames($newsletter, $listNames); + + foreach ($listsByName as $list) { + if (!in_array($list, $resolvedLists)) { + $resolvedLists[] = $list; + } + } + + if (count($listsByName) !== count($listNames)) { + $resolvedListNames = array_map(fn($l) => $l->getName(), $listsByName); + $missingNames = array_diff($listNames, $resolvedListNames); + throw new UnprocessableEntityHttpException( + "Lists with names " . implode(', ', $missingNames) . " not found", + ); + } + } + + return $resolvedLists; + } + #[Route('/subscribers/{id}', methods: 'DELETE')] #[ScopeRequired(Scope::SUBSCRIBERS_WRITE)] public function deleteSubscriber(Subscriber $subscriber): JsonResponse diff --git a/backend/src/Api/Console/Input/Subscriber/CreateSubscriberInput.php b/backend/src/Api/Console/Input/Subscriber/CreateSubscriberInput.php index ce36f698a..db4dc0e07 100644 --- a/backend/src/Api/Console/Input/Subscriber/CreateSubscriberInput.php +++ b/backend/src/Api/Console/Input/Subscriber/CreateSubscriberInput.php @@ -19,7 +19,12 @@ class CreateSubscriberInput #[Assert\Length(max: 255)] public string $email; - public SubscriberStatus $status = SubscriberStatus::PENDING; + /** + * @var ?(int|string)[] + */ + public ?array $lists = null; + + public SubscriberStatus $status = SubscriberStatus::SUBSCRIBED; public ?SubscriberSource $source = null; @@ -30,17 +35,18 @@ class CreateSubscriberInput private ?int $unsubscribed_at; - /** - * @var ?(int|string)[] - */ - public ?array $lists = null; - /** * @var array|null */ public ?array $metadata = null; - public ListAddStrategyIfUnsubscribed $list_add_strategy_if_unsubscribed = ListAddStrategyIfUnsubscribed::IGNORE; + + // settings + + public ListsStrategy $lists_strategy = ListsStrategy::SYNC; + + #[Assert\All(new Assert\Choice(callback: 'getListResubscribeOnValues'))] + private array $list_skip_resubscribe_on = ['unsubscribe', 'bounce']; public ListRemoveReason $list_remove_reason = ListRemoveReason::UNSUBSCRIBE; @@ -65,4 +71,21 @@ public function getUnsubscribedAt(): ?\DateTimeImmutable return $unsubscribedAt ? new \DateTimeImmutable()->setTimestamp($this->unsubscribed_at) : null; } + /** + * @return ListSkipResubscribeOn[] + */ + public function getListSkipResubscribeOn(): array + { + $listSkipResubscribeOn = $this->has('list_skip_resubscribe_on') ? $this->list_skip_resubscribe_on : []; + return array_map(fn($item) => ListSkipResubscribeOn::tryFrom($item), $listSkipResubscribeOn); + } + + /** + * @return string[] + */ + public function getListResubscribeOnValues(): array + { + return array_map(fn($value) => $value->value, ListSkipResubscribeOn::cases()); + } + } diff --git a/backend/src/Api/Console/Input/Subscriber/ListAddStrategyIfUnsubscribed.php b/backend/src/Api/Console/Input/Subscriber/ListAddStrategyIfUnsubscribed.php deleted file mode 100644 index 78f12fb20..000000000 --- a/backend/src/Api/Console/Input/Subscriber/ListAddStrategyIfUnsubscribed.php +++ /dev/null @@ -1,9 +0,0 @@ -em->getRepository(NewsletterList::class) + return $this->em + ->getRepository(NewsletterList::class) ->count([ 'newsletter' => $newsletter, ]); @@ -32,10 +31,10 @@ public function getListCounter(Newsletter $newsletter): int public function isNameAvailable( Newsletter $newsletter, - string $name - ): bool - { - return $this->em->getRepository(NewsletterList::class) + string $name, + ): bool { + return $this->em + ->getRepository(NewsletterList::class) ->count([ 'newsletter' => $newsletter, 'name' => $name, @@ -44,10 +43,9 @@ public function isNameAvailable( public function createNewsletterList( Newsletter $newsletter, - string $name, - ?string $description - ): NewsletterList - { + string $name, + ?string $description, + ): NewsletterList { $list = new NewsletterList() ->setNewsletter($newsletter) ->setName($name) @@ -79,13 +77,14 @@ public function getListById(int $id): ?NewsletterList public function getListsOfNewsletter(Newsletter $newsletter): ArrayCollection { return new ArrayCollection( - $this->em->getRepository(NewsletterList::class) + $this->em + ->getRepository(NewsletterList::class) ->findBy( [ 'newsletter' => $newsletter, 'deleted_at' => null, - ] - ) + ], + ), ); } @@ -126,32 +125,45 @@ public function getMissingListIdsOfNewsletter(Newsletter $newsletter, array $lis } /** - * Note that we should validate the lists are within the newsletter (using getMissingListIdsOfNewsletter) before calling this method - * @param array $listIds - * @return ArrayCollection + * @param int[] $ids + * @return NewsletterList[] */ - public function getListsByIds(array $listIds): ArrayCollection + public function getListsByIds(Newsletter $newsletter, array $ids): array { - return new ArrayCollection( - $this->em->getRepository(NewsletterList::class)->findBy(['id' => $listIds]) - ); + $qb = $this->em->createQueryBuilder(); + $qb + ->select('l') + ->from(NewsletterList::class, 'l') + ->where('l.newsletter = :newsletter') + ->andWhere($qb->expr()->in('l.id', ':ids')) + ->setParameter('newsletter', $newsletter) + ->setParameter('ids', $ids); + + /** @var array $result */ + $result = $qb->getQuery()->getResult(); + + return $result; } - public function getListByIdOrName(Newsletter $newsletter, ?int $id, ?string $name): ?NewsletterList + /** + * @param string[] $names + * @return NewsletterList[] + */ + public function getListsByNames(Newsletter $newsletter, array $names): array { - assert($id !== null || $name !== null, 'Either id or name must be provided'); + $qb = $this->em->createQueryBuilder(); + $qb + ->select('l') + ->from(NewsletterList::class, 'l') + ->where('l.newsletter = :newsletter') + ->andWhere($qb->expr()->in('l.name', ':names')) + ->setParameter('newsletter', $newsletter) + ->setParameter('names', $names); - if ($id !== null) { - return $this->em->getRepository(NewsletterList::class)->findOneBy([ - 'id' => $id, - 'newsletter' => $newsletter, - ]); - } + /** @var array $result */ + $result = $qb->getQuery()->getResult(); - return $this->em->getRepository(NewsletterList::class)->findOneBy([ - 'name' => $name, - 'newsletter' => $newsletter, - ]); + return $result; } /** @@ -162,7 +174,8 @@ public function getSubscriberCountOfLists(array $listIds): array { $qb = $this->em->createQueryBuilder(); - $qb->select('l.id AS list_id, COUNT(s.id) AS subscriber_count') + $qb + ->select('l.id AS list_id, COUNT(s.id) AS subscriber_count') ->from(NewsletterList::class, 'l') ->leftJoin('l.subscribers', 's', 'WITH', 's.status = :subscribed') ->where($qb->expr()->in('l.id', ':listIds')) diff --git a/backend/src/Service/Subscriber/SubscriberService.php b/backend/src/Service/Subscriber/SubscriberService.php index 1c1128fd8..e77a425c0 100644 --- a/backend/src/Service/Subscriber/SubscriberService.php +++ b/backend/src/Service/Subscriber/SubscriberService.php @@ -37,12 +37,12 @@ public function __construct( ) {} /** - * @param iterable $lists + * @param array $lists */ public function createSubscriber( Newsletter $newsletter, string $email, - iterable $lists, + array $lists, SubscriberStatus $status, SubscriberSource $source, ?string $subscribeIp = null, diff --git a/backend/tests/Api/Console/Subscriber/CreateSubscriberTest.php b/backend/tests/Api/Console/Subscriber/CreateSubscriberTest.php index 53e1dc120..ed6230253 100644 --- a/backend/tests/Api/Console/Subscriber/CreateSubscriberTest.php +++ b/backend/tests/Api/Console/Subscriber/CreateSubscriberTest.php @@ -30,7 +30,7 @@ class CreateSubscriberTest extends WebTestCase { - public function test_create_subscriber(): void + public function test_create_subscriber_minimal(): void { $newsletter = NewsletterFactory::createOne(); @@ -61,18 +61,24 @@ public function test_create_subscriber(): void $subscriber = $repository->find($json['id']); $this->assertInstanceOf(Subscriber::class, $subscriber); $this->assertSame('test@email.com', $subscriber->getEmail()); - $this->assertSame(SubscriberStatus::PENDING, $subscriber->getStatus()); + $this->assertSame(SubscriberStatus::SUBSCRIBED, $subscriber->getStatus()); $this->assertSame('console', $subscriber->getSource()->value); $lists = $subscriber->getLists(); $this->assertCount(1, $lists); - $this->assertSame('List 1', $lists->first()?->getName()); + $this->assertSame('List 1', $lists[0]?->getName()); $event = $this->getEd()->getFirstEvent(SubscriberCreatedEvent::class); - $this->assertNotNull($event); + $this->assertSame($json['id'], $event->getSubscriber()->getId()); $this->assertFalse($event->shouldSendConfirmationEmail()); } + public function test_create_subscriber_with_all_inputs(): void + { + // + } + + public function testCreateSubscriberWithListsById(): void { $newsletter = NewsletterFactory::createOne(); diff --git a/frontend/src/routes/(marketing)/docs/[...slug]/content/ConsoleApi.svelte b/frontend/src/routes/(marketing)/docs/[...slug]/content/ConsoleApi.svelte index 171f38bc8..988a9f6c0 100644 --- a/frontend/src/routes/(marketing)/docs/[...slug]/content/ConsoleApi.svelte +++ b/frontend/src/routes/(marketing)/docs/[...slug]/content/ConsoleApi.svelte @@ -338,19 +338,15 @@ // Subscribe to or unsubscribe from lists based // on the given \`lists_strategy\`. + // an array of list IDs or names. lists: (number | string)[]; - lists_strategy: - | 'sync' // (default) sets the subscriber's lists to the given lists (overwriting existing lists) - | 'add' // adds the subscriber to the given lists - | 'remove'; // removes the subscriber from the given lists - // The subscriber's subscription status - // set \`send_pending_confirmation_email=true\` to send a confirmation email // default: subscribed - status?: 'pending' | 'subscribed' | 'unsubscribed'; + status?: 'subscribed' | 'unsubscribed' | 'pending'; - // the source of the subscriber (default: 'console') + // the source of the subscriber + // default: console source?: 'console' | 'form' | 'import'; // subscriber's IP address @@ -371,16 +367,22 @@ // ============ SETTINGS =========== // change how the endpoint behaves + // how \`lists\` field is processed when updating an existing subscriber's list subscriptions. + // sync: overwrites the lists (default) + // add: adds to the current lists + // remove: removes from the current lists + lists_strategy: 'sync' | 'add' | 'remove'; + // if the subscriber was previously removed from a list, // define the reason(s) for ignoring the re-subscription to that list. // see below for more info // default: ['unsubscribe', 'bounce'] - list_ignore_resubscribe_on: ('unsubscribe' | 'bounce' | 'auto')[]; + list_skip_resubscribe_on: ('unsubscribe' | 'bounce' | 'auto')[]; // define the reason for removing the subscriber from a list // (only when updating, see below for more info) // default: 'unsubscribe' - list_remove_reason: 'unsubscribe' | 'bounce' | 'auto'; + list_remove_reason: 'unsubscribe' | 'bounce' | 'other'; // whether to overwrite or merge the subscriber's metadata // when updating an existing subscriber. @@ -488,7 +490,10 @@ { "email": "example@example.com", "lists": ["Paid Users"], - "lists_strategy": "remove" + "lists_strategy": "remove", + + // unsubscribe, bounce, or other + "list_remove_reason": "unsubscribe" } `} /> @@ -528,14 +533,14 @@ "lists_strategy": "add", // ignore unsubscription if the subscriber was removed from the list due to a bounce // but allow re-adding if they previously unsubscribed themselves - "list_ignore_resubscribe_on": ["bounce"] + "list_skip_resubscribe_on": ["bounce"] } `} />

    To force re-adding both previous unsubscribes and bounces, use an empty array for list_ignore_resubscribe_onlist_skip_resubscribe_on.

    From 5627cfff4e2ef48fcd5c3c4ba522a697b1ad949c Mon Sep 17 00:00:00 2001 From: Supun Wimalasena Date: Tue, 3 Mar 2026 13:37:51 +0100 Subject: [PATCH 12/43] create subscriber, validate metadata, etc. --- .../Controller/SubscriberController.php | 18 ++++- .../Subscriber/CreateSubscriberInput.php | 19 +++-- backend/src/Entity/Subscriber.php | 6 +- .../Type/SubscriberMetadataDefinitionType.php | 9 ++- .../Service/Subscriber/SubscriberService.php | 22 +++--- .../MetadataValidationFailedException.php | 5 ++ .../SubscriberMetadataService.php | 79 ++++++++++++------- .../Subscriber/CreateSubscriberTest.php | 74 ++++++++++++++++- 8 files changed, 173 insertions(+), 59 deletions(-) create mode 100644 backend/src/Service/SubscriberMetadata/Exception/MetadataValidationFailedException.php diff --git a/backend/src/Api/Console/Controller/SubscriberController.php b/backend/src/Api/Console/Controller/SubscriberController.php index 6a4ae212c..9a3f15bb1 100644 --- a/backend/src/Api/Console/Controller/SubscriberController.php +++ b/backend/src/Api/Console/Controller/SubscriberController.php @@ -18,6 +18,7 @@ use App\Service\Subscriber\Dto\UpdateSubscriberDto; use App\Service\Subscriber\Message\SubscriberCreatedMessage; use App\Service\Subscriber\SubscriberService; +use App\Service\SubscriberMetadata\Exception\MetadataValidationFailedException; use App\Service\SubscriberMetadata\SubscriberMetadataService; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Component\HttpFoundation\JsonResponse; @@ -35,7 +36,6 @@ public function __construct( private SubscriberService $subscriberService, private NewsletterListService $newsletterListService, private SubscriberMetadataService $subscriberMetadataService, - private MessageBusInterface $messageBus, ) {} #[Route('/subscribers', methods: 'GET')] @@ -81,9 +81,20 @@ public function createSubscriber( #[MapRequestPayload] CreateSubscriberInput $input, Newsletter $newsletter, ): JsonResponse { - $lists = $this->resolveLists($newsletter, $input->lists); + $lists = $input->lists ? $this->resolveLists($newsletter, $input->lists) : []; $subscriber = $this->subscriberService->getSubscriberByEmail($newsletter, $input->email); + if ($input->metadata) { + try { + $this->subscriberMetadataService->validateMetadata( + $newsletter, + $input->metadata, + ); + } catch (MetadataValidationFailedException $e) { + throw new UnprocessableEntityHttpException($e->getMessage()); + } + } + if ($subscriber === null) { $subscriber = $this->subscriberService->createSubscriber( $newsletter, @@ -91,9 +102,10 @@ public function createSubscriber( $lists, $input->status, source: $input->source ?? SubscriberSource::CONSOLE, - subscribeIp: $input->getSubscriberIp(), + subscribeIp: $input->getSubscribeIp(), subscribedAt: $input->getSubscribedAt(), unsubscribedAt: $input->getUnsubscribedAt(), + metadata: $input->metadata ?? [], sendConfirmationEmail: $input->send_pending_confirmation_email, ); } else { diff --git a/backend/src/Api/Console/Input/Subscriber/CreateSubscriberInput.php b/backend/src/Api/Console/Input/Subscriber/CreateSubscriberInput.php index db4dc0e07..13eb6d3ea 100644 --- a/backend/src/Api/Console/Input/Subscriber/CreateSubscriberInput.php +++ b/backend/src/Api/Console/Input/Subscriber/CreateSubscriberInput.php @@ -4,9 +4,11 @@ use App\Entity\Type\SubscriberSource; use App\Entity\Type\SubscriberStatus; +use App\Service\SubscriberMetadata\SubscriberMetadataService; use App\Util\OptionalPropertyTrait; use Symfony\Component\Clock\ClockAwareTrait; use Symfony\Component\Validator\Constraints as Assert; +use Symfony\Component\Validator\Context\ExecutionContextInterface; class CreateSubscriberInput { @@ -29,22 +31,25 @@ class CreateSubscriberInput public ?SubscriberSource $source = null; #[Assert\Ip(version: Assert\Ip::ALL_ONLY_PUBLIC)] - private ?string $subscribe_ip; + public ?string $subscribe_ip; - private ?int $subscribed_at; + public ?int $subscribed_at; - private ?int $unsubscribed_at; + public ?int $unsubscribed_at; /** - * @var array|null + * @var array|null */ + #[Assert\All(new Assert\Type('scalar'))] public ?array $metadata = null; - // settings public ListsStrategy $lists_strategy = ListsStrategy::SYNC; + /** + * @var string[] + */ #[Assert\All(new Assert\Choice(callback: 'getListResubscribeOnValues'))] private array $list_skip_resubscribe_on = ['unsubscribe', 'bounce']; @@ -54,7 +59,7 @@ class CreateSubscriberInput public bool $send_pending_confirmation_email = false; - public function getSubscriberIp(): ?string + public function getSubscribeIp(): ?string { return $this->has('subscribe_ip') ? $this->subscribe_ip : null; } @@ -77,7 +82,7 @@ public function getUnsubscribedAt(): ?\DateTimeImmutable public function getListSkipResubscribeOn(): array { $listSkipResubscribeOn = $this->has('list_skip_resubscribe_on') ? $this->list_skip_resubscribe_on : []; - return array_map(fn($item) => ListSkipResubscribeOn::tryFrom($item), $listSkipResubscribeOn); + return array_map(fn($item) => ListSkipResubscribeOn::from($item), $listSkipResubscribeOn); } /** diff --git a/backend/src/Entity/Subscriber.php b/backend/src/Entity/Subscriber.php index 9b687d258..512d32257 100644 --- a/backend/src/Entity/Subscriber.php +++ b/backend/src/Entity/Subscriber.php @@ -64,7 +64,7 @@ class Subscriber private ?string $unsubscribe_reason = null; /** - * @var array + * @var array */ #[ORM\Column(type: 'json', options: ['default' => '{}'])] private array $metadata = []; @@ -254,7 +254,7 @@ public function removeList(NewsletterList $list): self } /** - * @return array + * @return array */ public function getMetadata(): array { @@ -262,7 +262,7 @@ public function getMetadata(): array } /** - * @param array $metadata + * @param array $metadata */ public function setMetadata(array $metadata): static { diff --git a/backend/src/Entity/Type/SubscriberMetadataDefinitionType.php b/backend/src/Entity/Type/SubscriberMetadataDefinitionType.php index 1c32d935d..1ad828364 100644 --- a/backend/src/Entity/Type/SubscriberMetadataDefinitionType.php +++ b/backend/src/Entity/Type/SubscriberMetadataDefinitionType.php @@ -8,5 +8,12 @@ enum SubscriberMetadataDefinitionType: string case TEXT = 'text'; // Maybe in the future, we will add more types like select, checkbox, etc. + + public function toJsonType(): string + { + return match ($this) { + SubscriberMetadataDefinitionType::TEXT => 'string', + }; + } -} \ No newline at end of file +} diff --git a/backend/src/Service/Subscriber/SubscriberService.php b/backend/src/Service/Subscriber/SubscriberService.php index e77a425c0..8e0e60683 100644 --- a/backend/src/Service/Subscriber/SubscriberService.php +++ b/backend/src/Service/Subscriber/SubscriberService.php @@ -38,6 +38,7 @@ public function __construct( /** * @param array $lists + * @param array $metadata */ public function createSubscriber( Newsletter $newsletter, @@ -48,6 +49,7 @@ public function createSubscriber( ?string $subscribeIp = null, ?\DateTimeImmutable $subscribedAt = null, ?\DateTimeImmutable $unsubscribedAt = null, + array $metadata = [], bool $sendConfirmationEmail = true, ): Subscriber { $subscriber = new Subscriber() @@ -56,24 +58,18 @@ public function createSubscriber( ->setCreatedAt($this->now()) ->setUpdatedAt($this->now()) ->setStatus($status) - ->setSource($source); + ->setSubscribedAt($subscribedAt) + ->setUnsubscribedAt($unsubscribedAt) + ->setSubscribeIp($subscribeIp) + ->setSource($source) + ->setMetadata($metadata); // if status is subscribed, subscribed_at should be set to now // if status is unsubscribed, unsubscribed_at should be set to now if ($status === SubscriberStatus::SUBSCRIBED) { - $subscriber->setSubscribedAt($this->now()); + $subscriber->setSubscribedAt($subscribedAt ?? $this->now()); } elseif ($status === SubscriberStatus::UNSUBSCRIBED) { - $subscriber->setUnsubscribedAt($this->now()); - } - - if ($subscribedAt !== null) { - $subscriber->setSubscribedAt($subscribedAt); - } - if ($unsubscribedAt !== null) { - $subscriber->setUnsubscribedAt($unsubscribedAt); - } - if ($subscribeIp !== null) { - $subscriber->setSubscribeIp($subscribeIp); + $subscriber->setUnsubscribedAt($unsubscribedAt ?? $this->now()); } foreach ($lists as $list) { diff --git a/backend/src/Service/SubscriberMetadata/Exception/MetadataValidationFailedException.php b/backend/src/Service/SubscriberMetadata/Exception/MetadataValidationFailedException.php new file mode 100644 index 000000000..ea59983be --- /dev/null +++ b/backend/src/Service/SubscriberMetadata/Exception/MetadataValidationFailedException.php @@ -0,0 +1,5 @@ +findOneBy(['newsletter' => $newsletter, 'key' => $key]); } + /** + * @param string[] $keys + * @return SubscriberMetadataDefinition[] + */ + public function getMetadataDefinitionsByKeys(Newsletter $newsletter, array $keys): array + { + return $this->entityManager + ->getRepository(SubscriberMetadataDefinition::class) + ->findBy(['newsletter' => $newsletter, 'key' => $keys]); + } + public function getMetadataDefinitionsCount(Newsletter $newsletter): int { return $this->entityManager @@ -47,10 +57,9 @@ public function getMetadataDefinitionsCount(Newsletter $newsletter): int public function createMetadataDefinition( Newsletter $newsletter, - string $key, - string $name, - ): SubscriberMetadataDefinition - { + string $key, + string $name, + ): SubscriberMetadataDefinition { $metadataDefinition = new SubscriberMetadataDefinition(); $metadataDefinition->setNewsletter($newsletter); $metadataDefinition->setKey($key); @@ -67,9 +76,8 @@ public function createMetadataDefinition( public function updateMetadataDefinition( SubscriberMetadataDefinition $metadataDefinition, - string $name, - ): void - { + string $name, + ): void { $metadataDefinition->setName($name); $metadataDefinition->setUpdatedAt($this->now()); @@ -82,11 +90,38 @@ public function deleteMetadataDefinition(SubscriberMetadataDefinition $metadataD $this->entityManager->flush(); } - public function validateValueType( - SubscriberMetadataDefinition $metadataDefinition, - mixed $value - ): bool + /** + * @param array $metadata + * @throws MetadataValidationFailedException + */ + public function validateMetadata(Newsletter $newsletter, array $metadata): void { + $keys = array_keys($metadata); + $definitions = $this->getMetadataDefinitionsByKeys($newsletter, $keys); + + if (count($definitions) !== count($keys)) { + $foundKeys = array_map(fn(SubscriberMetadataDefinition $def) => $def->getKey(), $definitions); + $missingKeys = array_diff($keys, $foundKeys); + throw new MetadataValidationFailedException( + "Metadata definitions with keys " . implode(', ', $missingKeys) . " not found", + ); + } + + foreach ($definitions as $definition) { + $value = $metadata[$definition->getKey()] ?? null; + if (!$this->validateValueType($definition, $value)) { + throw new MetadataValidationFailedException( + "Invalid value type for metadata key " . $definition->getKey( + ) . ". Expected type: " . $definition->getType()->toJsonType(), + ); + } + } + } + + private function validateValueType( + SubscriberMetadataDefinition $metadataDefinition, + mixed $value, + ): bool { return match ($metadataDefinition->getType()) { // @phpstan-ignore-next-line SubscriberMetadataDefinitionType::TEXT => is_string($value), @@ -94,20 +129,4 @@ public function validateValueType( default => false, }; } - - /** - * @param array $metadata - */ - public function validateMetadata(Newsletter $newsletter, array $metadata): bool - { - foreach ($metadata as $key => $value) { - $metaDef = $this->getMetadataDefinitionByKey($newsletter, $key); - if ($metaDef === null) - throw new \Exception("Metadata definition with key {$key} not found"); - if (!$this->validateValueType($metaDef, $value)) { - throw new \Exception("Value for metadata key {$key} is not valid"); - } - } - return true; - } } diff --git a/backend/tests/Api/Console/Subscriber/CreateSubscriberTest.php b/backend/tests/Api/Console/Subscriber/CreateSubscriberTest.php index ed6230253..6c6fd0b92 100644 --- a/backend/tests/Api/Console/Subscriber/CreateSubscriberTest.php +++ b/backend/tests/Api/Console/Subscriber/CreateSubscriberTest.php @@ -6,10 +6,10 @@ use App\Entity\Newsletter; use App\Entity\Subscriber; use App\Entity\SubscriberListUnsubscribed; +use App\Entity\Type\SubscriberMetadataDefinitionType; use App\Entity\Type\SubscriberSource; use App\Entity\Type\SubscriberStatus; use App\Repository\SubscriberRepository; -use App\Service\App\Messenger\MessageTransport; use App\Service\NewsletterList\NewsletterListService; use App\Service\Subscriber\Event\SubscriberCreatedEvent; use App\Service\Subscriber\Message\SubscriberCreatedMessage; @@ -19,6 +19,7 @@ use App\Tests\Factory\NewsletterListFactory; use App\Tests\Factory\SubscriberFactory; use App\Tests\Factory\SubscriberListUnsubscribedFactory; +use App\Tests\Factory\SubscriberMetadataDefinitionFactory; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\Attributes\TestWith; @@ -75,7 +76,76 @@ public function test_create_subscriber_minimal(): void public function test_create_subscriber_with_all_inputs(): void { - // + $this->getEd()->setMockEvents([SubscriberCreatedEvent::class]); + + $newsletter = NewsletterFactory::createOne(); + $list1 = NewsletterListFactory::createOne(['newsletter' => $newsletter]); + $list2 = NewsletterListFactory::createOne(['newsletter' => $newsletter, 'name' => 'Named List']); + + $subscribedAt = new \DateTimeImmutable('2023-06-15 10:00:00'); + $unsubscribedAt = new \DateTimeImmutable('2023-06-20 10:00:00'); + + SubscriberMetadataDefinitionFactory::createOne([ + 'newsletter' => $newsletter, + 'key' => 'test-key', + 'type' => SubscriberMetadataDefinitionType::TEXT, + ]); + + $response = $this->consoleApi( + $newsletter, + 'POST', + '/subscribers', + [ + 'email' => 'test@hyvor.com', + 'lists' => [$list1->getId(), 'Named List'], + 'status' => 'pending', + 'source' => 'import', + 'subscribe_ip' => '203.0.113.1', + 'subscribed_at' => $subscribedAt->getTimestamp(), + 'unsubscribed_at' => $unsubscribedAt->getTimestamp(), + 'metadata' => [ + 'test-key' => 'test', + ], + 'send_pending_confirmation_email' => true, + ], + ); + $this->assertSame(200, $response->getStatusCode()); + + $json = $this->getJson(); + $this->assertIsInt($json['id']); + $this->assertSame('test@hyvor.com', $json['email']); + $this->assertSame('pending', $json['status']); + $this->assertSame('import', $json['source']); + $this->assertSame('203.0.113.1', $json['subscribe_ip']); + $this->assertSame($subscribedAt->getTimestamp(), $json['subscribed_at']); + $this->assertSame($unsubscribedAt->getTimestamp(), $json['unsubscribed_at']); + $this->assertCount(2, $json['list_ids']); + $this->assertContains($list1->getId(), $json['list_ids']); + $this->assertContains($list2->getId(), $json['list_ids']); + + $this->em->clear(); + $subscriber = $this->em->getRepository(Subscriber::class)->find($json['id']); + $this->assertInstanceOf(Subscriber::class, $subscriber); + $this->assertSame('test@hyvor.com', $subscriber->getEmail()); + $this->assertSame(SubscriberStatus::PENDING, $subscriber->getStatus()); + $this->assertSame(SubscriberSource::IMPORT, $subscriber->getSource()); + $this->assertSame('203.0.113.1', $subscriber->getSubscribeIp()); + $this->assertSame('2023-06-15 10:00:00', $subscriber->getSubscribedAt()?->format('Y-m-d H:i:s')); + $this->assertSame('2023-06-20 10:00:00', $subscriber->getUnsubscribedAt()?->format('Y-m-d H:i:s')); + $this->assertSame( + [ + 'test-key' => 'test', + ], + $subscriber->getMetadata(), + ); + $listIds = $subscriber->getLists()->map(fn($l) => $l->getId())->toArray(); + $this->assertCount(2, $listIds); + $this->assertContains($list1->getId(), $listIds); + $this->assertContains($list2->getId(), $listIds); + + $event = $this->getEd()->getFirstEvent(SubscriberCreatedEvent::class); + $this->assertSame($json['id'], $event->getSubscriber()->getId()); + $this->assertTrue($event->shouldSendConfirmationEmail()); } From 59ae7b572fa1e4a195208c88a659bd034f183256 Mon Sep 17 00:00:00 2001 From: Supun Wimalasena Date: Tue, 3 Mar 2026 14:54:41 +0100 Subject: [PATCH 13/43] update subscriber wip --- .../Controller/SubscriberController.php | 18 ++-- .../Subscriber/CreateSubscriberInput.php | 10 +- .../Api/Console/Object/SubscriberObject.php | 2 - backend/src/Entity/Subscriber.php | 15 --- backend/src/Entity/Type/SubscriberStatus.php | 1 - .../Subscriber/Dto/UpdateSubscriberDto.php | 7 +- .../Service/Subscriber/SubscriberService.php | 12 --- .../Subscriber/CreateSubscriberTest.php | 94 +++++++++++++++++-- backend/tests/Factory/SubscriberFactory.php | 5 +- .../docs/[...slug]/content/ConsoleApi.svelte | 20 ++-- 10 files changed, 105 insertions(+), 79 deletions(-) diff --git a/backend/src/Api/Console/Controller/SubscriberController.php b/backend/src/Api/Console/Controller/SubscriberController.php index 9a3f15bb1..58e1e77a6 100644 --- a/backend/src/Api/Console/Controller/SubscriberController.php +++ b/backend/src/Api/Console/Controller/SubscriberController.php @@ -100,21 +100,21 @@ public function createSubscriber( $newsletter, $input->email, $lists, - $input->status, + status: $input->status ?? SubscriberStatus::SUBSCRIBED, source: $input->source ?? SubscriberSource::CONSOLE, subscribeIp: $input->getSubscribeIp(), subscribedAt: $input->getSubscribedAt(), - unsubscribedAt: $input->getUnsubscribedAt(), metadata: $input->metadata ?? [], sendConfirmationEmail: $input->send_pending_confirmation_email, ); } else { - // Update existing subscriber with provided fields $updates = new UpdateSubscriberDto(); - $updates->status = $input->status; + if ($input->status) { + $updates->status = $input->status; + } - if ($input->has('source')) { + if ($input->source) { $updates->source = $input->source; } @@ -128,13 +128,7 @@ public function createSubscriber( : null; } - if ($input->has('unsubscribed_at')) { - $updates->unsubscribedAt = $input->unsubscribed_at !== null - ? \DateTimeImmutable::createFromTimestamp($input->unsubscribed_at) - : null; - } - - if ($input->has('metadata')) { + if ($input->metadata) { $updates->metadata = $input->metadata; } diff --git a/backend/src/Api/Console/Input/Subscriber/CreateSubscriberInput.php b/backend/src/Api/Console/Input/Subscriber/CreateSubscriberInput.php index 13eb6d3ea..fdca526f4 100644 --- a/backend/src/Api/Console/Input/Subscriber/CreateSubscriberInput.php +++ b/backend/src/Api/Console/Input/Subscriber/CreateSubscriberInput.php @@ -26,7 +26,7 @@ class CreateSubscriberInput */ public ?array $lists = null; - public SubscriberStatus $status = SubscriberStatus::SUBSCRIBED; + public ?SubscriberStatus $status = null; public ?SubscriberSource $source = null; @@ -35,8 +35,6 @@ class CreateSubscriberInput public ?int $subscribed_at; - public ?int $unsubscribed_at; - /** * @var array|null */ @@ -70,12 +68,6 @@ public function getSubscribedAt(): ?\DateTimeImmutable return $subscribedAt ? new \DateTimeImmutable()->setTimestamp($this->subscribed_at) : null; } - public function getUnsubscribedAt(): ?\DateTimeImmutable - { - $unsubscribedAt = $this->has('unsubscribed_at') ? $this->unsubscribed_at : null; - return $unsubscribedAt ? new \DateTimeImmutable()->setTimestamp($this->unsubscribed_at) : null; - } - /** * @return ListSkipResubscribeOn[] */ diff --git a/backend/src/Api/Console/Object/SubscriberObject.php b/backend/src/Api/Console/Object/SubscriberObject.php index daba7352a..efc944f5d 100644 --- a/backend/src/Api/Console/Object/SubscriberObject.php +++ b/backend/src/Api/Console/Object/SubscriberObject.php @@ -20,7 +20,6 @@ class SubscriberObject public ?string $subscribe_ip; public bool $is_opted_in = false; public ?int $subscribed_at; - public ?int $unsubscribed_at; /** * @var array @@ -37,7 +36,6 @@ public function __construct(Subscriber $subscriber) $this->subscribe_ip = $subscriber->getSubscribeIp(); $this->is_opted_in = $subscriber->getOptInAt() !== null; $this->subscribed_at = $subscriber->getSubscribedAt()?->getTimestamp(); - $this->unsubscribed_at = $subscriber->getUnsubscribedAt()?->getTimestamp(); $this->metadata = $subscriber->getMetadata(); } diff --git a/backend/src/Entity/Subscriber.php b/backend/src/Entity/Subscriber.php index 512d32257..e92dec88b 100644 --- a/backend/src/Entity/Subscriber.php +++ b/backend/src/Entity/Subscriber.php @@ -48,9 +48,6 @@ class Subscriber #[ORM\Column(nullable: true)] private ?\DateTimeImmutable $opt_in_at = null; - #[ORM\Column(nullable: true)] - private ?\DateTimeImmutable $unsubscribed_at = null; - #[ORM\Column(enumType: SubscriberSource::class)] private SubscriberSource $source; @@ -170,18 +167,6 @@ public function setOptInAt(?\DateTimeImmutable $opt_in_at): static return $this; } - public function getUnsubscribedAt(): ?\DateTimeImmutable - { - return $this->unsubscribed_at; - } - - public function setUnsubscribedAt(?\DateTimeImmutable $unsubscribed_at): static - { - $this->unsubscribed_at = $unsubscribed_at; - - return $this; - } - public function getSource(): SubscriberSource { return $this->source; diff --git a/backend/src/Entity/Type/SubscriberStatus.php b/backend/src/Entity/Type/SubscriberStatus.php index d1237c65b..8f5394e93 100644 --- a/backend/src/Entity/Type/SubscriberStatus.php +++ b/backend/src/Entity/Type/SubscriberStatus.php @@ -5,6 +5,5 @@ enum SubscriberStatus: string { case SUBSCRIBED = 'subscribed'; - case UNSUBSCRIBED = 'unsubscribed'; case PENDING = 'pending'; } diff --git a/backend/src/Service/Subscriber/Dto/UpdateSubscriberDto.php b/backend/src/Service/Subscriber/Dto/UpdateSubscriberDto.php index 2b3fc8950..b010fe5ee 100644 --- a/backend/src/Service/Subscriber/Dto/UpdateSubscriberDto.php +++ b/backend/src/Service/Subscriber/Dto/UpdateSubscriberDto.php @@ -2,7 +2,6 @@ namespace App\Service\Subscriber\Dto; -use App\Entity\NewsletterList; use App\Entity\Type\SubscriberSource; use App\Entity\Type\SubscriberStatus; use App\Util\OptionalPropertyTrait; @@ -12,8 +11,6 @@ class UpdateSubscriberDto use OptionalPropertyTrait; - public string $email; - public SubscriberStatus $status; public SubscriberSource $source; public ?string $subscribeIp; @@ -22,12 +19,10 @@ class UpdateSubscriberDto public ?\DateTimeImmutable $optInAt; - public ?\DateTimeImmutable $unsubscribedAt; - public ?string $unsubscribedReason; /** - * @var array + * @var array */ public array $metadata; diff --git a/backend/src/Service/Subscriber/SubscriberService.php b/backend/src/Service/Subscriber/SubscriberService.php index 8e0e60683..b05a4d8a9 100644 --- a/backend/src/Service/Subscriber/SubscriberService.php +++ b/backend/src/Service/Subscriber/SubscriberService.php @@ -48,7 +48,6 @@ public function createSubscriber( SubscriberSource $source, ?string $subscribeIp = null, ?\DateTimeImmutable $subscribedAt = null, - ?\DateTimeImmutable $unsubscribedAt = null, array $metadata = [], bool $sendConfirmationEmail = true, ): Subscriber { @@ -59,7 +58,6 @@ public function createSubscriber( ->setUpdatedAt($this->now()) ->setStatus($status) ->setSubscribedAt($subscribedAt) - ->setUnsubscribedAt($unsubscribedAt) ->setSubscribeIp($subscribeIp) ->setSource($source) ->setMetadata($metadata); @@ -68,8 +66,6 @@ public function createSubscriber( // if status is unsubscribed, unsubscribed_at should be set to now if ($status === SubscriberStatus::SUBSCRIBED) { $subscriber->setSubscribedAt($subscribedAt ?? $this->now()); - } elseif ($status === SubscriberStatus::UNSUBSCRIBED) { - $subscriber->setUnsubscribedAt($unsubscribedAt ?? $this->now()); } foreach ($lists as $list) { @@ -156,10 +152,6 @@ public function getSubscribers( public function updateSubscriber(Subscriber $subscriber, UpdateSubscriberDto $updates): Subscriber { - if ($updates->has('email')) { - $subscriber->setEmail($updates->email); - } - if ($updates->has('status')) { $subscriber->setStatus($updates->status); } @@ -180,10 +172,6 @@ public function updateSubscriber(Subscriber $subscriber, UpdateSubscriberDto $up $subscriber->setOptInAt($updates->optInAt); } - if ($updates->has('unsubscribedAt')) { - $subscriber->setUnsubscribedAt($updates->unsubscribedAt); - } - if ($updates->has('unsubscribedReason')) { $subscriber->setUnsubscribeReason($updates->unsubscribedReason); } diff --git a/backend/tests/Api/Console/Subscriber/CreateSubscriberTest.php b/backend/tests/Api/Console/Subscriber/CreateSubscriberTest.php index 6c6fd0b92..96c391bc9 100644 --- a/backend/tests/Api/Console/Subscriber/CreateSubscriberTest.php +++ b/backend/tests/Api/Console/Subscriber/CreateSubscriberTest.php @@ -22,15 +22,19 @@ use App\Tests\Factory\SubscriberMetadataDefinitionFactory; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\Attributes\TestWith; +use Symfony\Component\Clock\Test\ClockSensitiveTrait; + +use function Zenstruck\Foundry\Persistence\refresh; #[CoversClass(SubscriberController::class)] #[CoversClass(SubscriberService::class)] -#[CoversClass(SubscriberRepository::class)] -#[CoversClass(Subscriber::class)] +#[CoversClass(SubscriberCreatedEvent::class)] #[CoversClass(NewsletterListService::class)] class CreateSubscriberTest extends WebTestCase { + use ClockSensitiveTrait; + public function test_create_subscriber_minimal(): void { $newsletter = NewsletterFactory::createOne(); @@ -83,7 +87,6 @@ public function test_create_subscriber_with_all_inputs(): void $list2 = NewsletterListFactory::createOne(['newsletter' => $newsletter, 'name' => 'Named List']); $subscribedAt = new \DateTimeImmutable('2023-06-15 10:00:00'); - $unsubscribedAt = new \DateTimeImmutable('2023-06-20 10:00:00'); SubscriberMetadataDefinitionFactory::createOne([ 'newsletter' => $newsletter, @@ -102,7 +105,6 @@ public function test_create_subscriber_with_all_inputs(): void 'source' => 'import', 'subscribe_ip' => '203.0.113.1', 'subscribed_at' => $subscribedAt->getTimestamp(), - 'unsubscribed_at' => $unsubscribedAt->getTimestamp(), 'metadata' => [ 'test-key' => 'test', ], @@ -118,7 +120,6 @@ public function test_create_subscriber_with_all_inputs(): void $this->assertSame('import', $json['source']); $this->assertSame('203.0.113.1', $json['subscribe_ip']); $this->assertSame($subscribedAt->getTimestamp(), $json['subscribed_at']); - $this->assertSame($unsubscribedAt->getTimestamp(), $json['unsubscribed_at']); $this->assertCount(2, $json['list_ids']); $this->assertContains($list1->getId(), $json['list_ids']); $this->assertContains($list2->getId(), $json['list_ids']); @@ -131,7 +132,6 @@ public function test_create_subscriber_with_all_inputs(): void $this->assertSame(SubscriberSource::IMPORT, $subscriber->getSource()); $this->assertSame('203.0.113.1', $subscriber->getSubscribeIp()); $this->assertSame('2023-06-15 10:00:00', $subscriber->getSubscribedAt()?->format('Y-m-d H:i:s')); - $this->assertSame('2023-06-20 10:00:00', $subscriber->getUnsubscribedAt()?->format('Y-m-d H:i:s')); $this->assertSame( [ 'test-key' => 'test', @@ -149,6 +149,88 @@ public function test_create_subscriber_with_all_inputs(): void } + public function test_creates_subscriber_fills_subscribed_at(): void + { + $this->mockTime('2026-01-01'); + + $newsletter = NewsletterFactory::createOne(); + + $response = $this->consoleApi( + $newsletter, + 'POST', + '/subscribers', + [ + 'email' => 'test@email.com', + ], + ); + + $subscriber = $this->em->getRepository(Subscriber::class)->find($this->getJson()['id']); + $this->assertNotNull($subscriber); + $this->assertSame(SubscriberStatus::SUBSCRIBED, $subscriber->getStatus()); + $this->assertSame('2026-01-01', $subscriber->getSubscribedAt()?->format('Y-m-d')); + } + + public function test_updates_subscriber_all(): void + { + $newsletter = NewsletterFactory::createOne(); + $list1 = NewsletterListFactory::createOne(['newsletter' => $newsletter]); + $list2 = NewsletterListFactory::createOne(['newsletter' => $newsletter]); + + SubscriberMetadataDefinitionFactory::createOne([ + 'newsletter' => $newsletter, + 'key' => 'a', + ]); + + $subscriber = SubscriberFactory::createOne([ + 'email' => 'supun@hyvor.com', + 'newsletter' => $newsletter, + 'lists' => [ + $list1, + ], + 'status' => SubscriberStatus::PENDING, + 'source' => SubscriberSource::FORM, + 'subscribe_ip' => '1.2.3.4', + 'subscribed_at' => new \DateTimeImmutable('2026-01-02'), + ]); + + $this->consoleApi( + $newsletter, + 'POST', + '/subscribers', + [ + 'email' => 'supun@hyvor.com', + 'lists' => [$list2->getId()], // merge + 'status' => 'subscribed', + 'source' => 'console', + 'subscribe_ip' => '2.3.4.5', + 'subscribed_at' => new \DateTimeImmutable('2026-01-01')->getTimestamp(), + 'metadata' => [ + 'a' => 'b', + ], + ], + ); + + $this->assertResponseIsSuccessful(); + + refresh($subscriber); + + $this->assertSame(SubscriberStatus::SUBSCRIBED, $subscriber->getStatus()); + $this->assertSame(SubscriberSource::CONSOLE, $subscriber->getSource()); + $this->assertSame('2.3.4.5', $subscriber->getSubscribeIp()); + $this->assertSame('2026-01-01', $subscriber->getSubscribedAt()?->format('Y-m-d')); + $this->assertSame([ + 'a' => 'b', + ], $subscriber->getMetadata()); + + $lists = $subscriber->getLists(); + $this->assertCount(2, $lists); + + $listIds = $lists->map(fn($l) => $l->getId())->toArray(); + $this->assertContains($list1->getId(), $listIds); + $this->assertContains($list2->getId(), $listIds); + } + + public function testCreateSubscriberWithListsById(): void { $newsletter = NewsletterFactory::createOne(); diff --git a/backend/tests/Factory/SubscriberFactory.php b/backend/tests/Factory/SubscriberFactory.php index 2d8f36ee6..45a657e49 100644 --- a/backend/tests/Factory/SubscriberFactory.php +++ b/backend/tests/Factory/SubscriberFactory.php @@ -17,9 +17,7 @@ final class SubscriberFactory extends PersistentProxyObjectFactory * * @todo inject services if required */ - public function __construct() - { - } + public function __construct() {} public static function class(): string { @@ -44,7 +42,6 @@ protected function defaults(): array 'subscribed_at' => \DateTimeImmutable::createFromMutable(self::faker()->dateTime()), 'opt_in_at' => \DateTimeImmutable::createFromMutable(self::faker()->dateTime()), 'unsubscribe_reason' => self::faker()->text(255), - 'unsubscribed_at' => \DateTimeImmutable::createFromMutable(self::faker()->dateTime()), 'updated_at' => \DateTimeImmutable::createFromMutable(self::faker()->dateTime()), ]; } diff --git a/frontend/src/routes/(marketing)/docs/[...slug]/content/ConsoleApi.svelte b/frontend/src/routes/(marketing)/docs/[...slug]/content/ConsoleApi.svelte index 988a9f6c0..8ec62e217 100644 --- a/frontend/src/routes/(marketing)/docs/[...slug]/content/ConsoleApi.svelte +++ b/frontend/src/routes/(marketing)/docs/[...slug]/content/ConsoleApi.svelte @@ -339,11 +339,11 @@ // Subscribe to or unsubscribe from lists based // on the given \`lists_strategy\`. // an array of list IDs or names. - lists: (number | string)[]; + lists?: (number | string)[]; // The subscriber's subscription status // default: subscribed - status?: 'subscribed' | 'unsubscribed' | 'pending'; + status?: 'subscribed' | 'pending'; // the source of the subscriber // default: console @@ -356,10 +356,6 @@ // if not set, it will be set to the current time if status is 'subscribed' subscribed_at?: number | null; // unix timestamp - // unix timestamp of when the subscriber unsubscribed - // if not set, it will be set to the current time if status is 'unsubscribed' - unsubscribed_at?: number | null; // unix timestamp - // additional metadata for the subscriber // keys must be defined in the Subscriber Metadata Definitions section (or using the API) metadata?: Record; @@ -368,26 +364,26 @@ // change how the endpoint behaves // how \`lists\` field is processed when updating an existing subscriber's list subscriptions. - // sync: overwrites the lists (default) - // add: adds to the current lists + // merge: merges the lists (default) + // overwrite: overwrites the lists // remove: removes from the current lists - lists_strategy: 'sync' | 'add' | 'remove'; + lists_strategy?: 'merge' | 'overwrite' | 'remove'; // if the subscriber was previously removed from a list, // define the reason(s) for ignoring the re-subscription to that list. // see below for more info // default: ['unsubscribe', 'bounce'] - list_skip_resubscribe_on: ('unsubscribe' | 'bounce' | 'auto')[]; + list_skip_resubscribe_on?: ('unsubscribe' | 'bounce' | 'auto')[]; // define the reason for removing the subscriber from a list // (only when updating, see below for more info) // default: 'unsubscribe' - list_remove_reason: 'unsubscribe' | 'bounce' | 'other'; + list_remove_reason?: 'unsubscribe' | 'bounce' | 'other'; // whether to overwrite or merge the subscriber's metadata // when updating an existing subscriber. // default: 'merge' - metadata_strategy: 'merge' | 'overwrite'; + metadata_strategy?: 'merge' | 'overwrite'; // whether to send a confirmation email when adding a subscriber with 'pending' status // or when changing an existing subscriber's status to 'pending'. From e6f6cb05d8d14470bdac468cecea65a1fe17ead8 Mon Sep 17 00:00:00 2001 From: Supun Wimalasena Date: Tue, 3 Mar 2026 15:15:09 +0100 Subject: [PATCH 14/43] lists strategy, before tests --- backend/migrations/Version20260225000000.php | 20 +++---- .../Controller/SubscriberController.php | 30 ++++++++--- .../Subscriber/CreateSubscriberInput.php | 11 ++-- .../Input/Subscriber/ListsStrategy.php | 4 +- backend/src/Entity/Subscriber.php | 9 ++++ ...bscribed.php => SubscriberListRemoval.php} | 18 ++++++- .../Type/ListRemovalReason.php} | 5 +- .../Subscriber/Dto/UpdateSubscriberDto.php | 4 ++ .../Service/Subscriber/SubscriberService.php | 53 +++++-------------- .../Subscriber/CreateSubscriberTest.php | 18 ++++--- ...y.php => SubscriberListRemovalFactory.php} | 13 +++-- .../docs/[...slug]/content/ConsoleApi.svelte | 4 +- 12 files changed, 107 insertions(+), 82 deletions(-) rename backend/src/Entity/{SubscriberListUnsubscribed.php => SubscriberListRemoval.php} (80%) rename backend/src/{Api/Console/Input/Subscriber/ListSkipResubscribeOn.php => Entity/Type/ListRemovalReason.php} (53%) rename backend/tests/Factory/{SubscriberListUnsubscribedFactory.php => SubscriberListRemovalFactory.php} (62%) diff --git a/backend/migrations/Version20260225000000.php b/backend/migrations/Version20260225000000.php index fff68152a..5078457bb 100644 --- a/backend/migrations/Version20260225000000.php +++ b/backend/migrations/Version20260225000000.php @@ -16,15 +16,17 @@ public function getDescription(): string public function up(Schema $schema): void { - $this->addSql(<<addSql( + <<lists ? $this->resolveLists($newsletter, $input->lists) : []; + $resolvedLists = $input->lists ? $this->resolveLists($newsletter, $input->lists) : []; $subscriber = $this->subscriberService->getSubscriberByEmail($newsletter, $input->email); if ($input->metadata) { @@ -99,7 +96,7 @@ public function createSubscriber( $subscriber = $this->subscriberService->createSubscriber( $newsletter, $input->email, - $lists, + $resolvedLists, status: $input->status ?? SubscriberStatus::SUBSCRIBED, source: $input->source ?? SubscriberSource::CONSOLE, subscribeIp: $input->getSubscribeIp(), @@ -132,6 +129,27 @@ public function createSubscriber( $updates->metadata = $input->metadata; } + if ($input->lists !== null) { + $newLists = $subscriber->getLists()->toArray(); + + if ($input->lists_strategy === ListsStrategy::MERGE) { + foreach ($resolvedLists as $list) { + if (!array_find($newLists, fn($l) => $l->getId() === $list->getId())) { + $newLists[] = $list; + } + } + } elseif ($input->lists_strategy === ListsStrategy::OVERWRITE) { + $newLists = $resolvedLists; + } else { + $newLists = array_filter( + $newLists, + fn($l) => !array_find($resolvedLists, fn($rl) => $rl->getId() === $l->getId()), + ); + } + + $updates->lists = $newLists; + } + $subscriber = $this->subscriberService->updateSubscriber($subscriber, $updates); } diff --git a/backend/src/Api/Console/Input/Subscriber/CreateSubscriberInput.php b/backend/src/Api/Console/Input/Subscriber/CreateSubscriberInput.php index fdca526f4..34dab0278 100644 --- a/backend/src/Api/Console/Input/Subscriber/CreateSubscriberInput.php +++ b/backend/src/Api/Console/Input/Subscriber/CreateSubscriberInput.php @@ -2,6 +2,7 @@ namespace App\Api\Console\Input\Subscriber; +use App\Entity\Type\ListRemovalReason; use App\Entity\Type\SubscriberSource; use App\Entity\Type\SubscriberStatus; use App\Service\SubscriberMetadata\SubscriberMetadataService; @@ -43,13 +44,13 @@ class CreateSubscriberInput // settings - public ListsStrategy $lists_strategy = ListsStrategy::SYNC; + public ListsStrategy $lists_strategy = ListsStrategy::MERGE; /** * @var string[] */ #[Assert\All(new Assert\Choice(callback: 'getListResubscribeOnValues'))] - private array $list_skip_resubscribe_on = ['unsubscribe', 'bounce']; + private array $list_skip_resubscribe_on = ['unsubscribe', 'bounce', 'complaint']; public ListRemoveReason $list_remove_reason = ListRemoveReason::UNSUBSCRIBE; @@ -69,12 +70,12 @@ public function getSubscribedAt(): ?\DateTimeImmutable } /** - * @return ListSkipResubscribeOn[] + * @return ListRemovalReason[] */ public function getListSkipResubscribeOn(): array { $listSkipResubscribeOn = $this->has('list_skip_resubscribe_on') ? $this->list_skip_resubscribe_on : []; - return array_map(fn($item) => ListSkipResubscribeOn::from($item), $listSkipResubscribeOn); + return array_map(fn($item) => ListRemovalReason::from($item), $listSkipResubscribeOn); } /** @@ -82,7 +83,7 @@ public function getListSkipResubscribeOn(): array */ public function getListResubscribeOnValues(): array { - return array_map(fn($value) => $value->value, ListSkipResubscribeOn::cases()); + return array_map(fn($value) => $value->value, ListRemovalReason::cases()); } } diff --git a/backend/src/Api/Console/Input/Subscriber/ListsStrategy.php b/backend/src/Api/Console/Input/Subscriber/ListsStrategy.php index 27e35a086..fdb239f70 100644 --- a/backend/src/Api/Console/Input/Subscriber/ListsStrategy.php +++ b/backend/src/Api/Console/Input/Subscriber/ListsStrategy.php @@ -5,8 +5,8 @@ enum ListsStrategy: string { - case SYNC = 'sync'; - case ADD = 'add'; + case MERGE = 'merge'; + case OVERWRITE = 'overwrite'; case REMOVE = 'remove'; } diff --git a/backend/src/Entity/Subscriber.php b/backend/src/Entity/Subscriber.php index e92dec88b..58278234b 100644 --- a/backend/src/Entity/Subscriber.php +++ b/backend/src/Entity/Subscriber.php @@ -224,6 +224,15 @@ public function getLists(): Collection return $this->lists; } + /** + * @param Collection $lists + */ + public function setLists(Collection $lists): static + { + $this->lists = $lists; + return $this; + } + public function addList(NewsletterList $list): self { if (!$this->lists->contains($list)) { diff --git a/backend/src/Entity/SubscriberListUnsubscribed.php b/backend/src/Entity/SubscriberListRemoval.php similarity index 80% rename from backend/src/Entity/SubscriberListUnsubscribed.php rename to backend/src/Entity/SubscriberListRemoval.php index efcf10157..e0caa8f8c 100644 --- a/backend/src/Entity/SubscriberListUnsubscribed.php +++ b/backend/src/Entity/SubscriberListRemoval.php @@ -5,9 +5,9 @@ use Doctrine\ORM\Mapping as ORM; #[ORM\Entity] -#[ORM\Table(name: 'list_subscriber_unsubscribed')] +#[ORM\Table(name: 'subscriber_list_removals')] #[ORM\UniqueConstraint(columns: ['list_id', 'subscriber_id'])] -class SubscriberListUnsubscribed +class SubscriberListRemoval { #[ORM\Id] #[ORM\GeneratedValue] @@ -22,6 +22,9 @@ class SubscriberListUnsubscribed #[ORM\JoinColumn(name: 'subscriber_id', nullable: false, onDelete: 'CASCADE')] private Subscriber $subscriber; + #[ORM\Column(type: 'string')] + private string $reason; + #[ORM\Column] private \DateTimeImmutable $created_at; @@ -57,6 +60,17 @@ public function setSubscriber(Subscriber $subscriber): static return $this; } + public function getReason(): string + { + return $this->reason; + } + + public function setReason(string $reason): static + { + $this->reason = $reason; + return $this; + } + public function getCreatedAt(): \DateTimeImmutable { return $this->created_at; diff --git a/backend/src/Api/Console/Input/Subscriber/ListSkipResubscribeOn.php b/backend/src/Entity/Type/ListRemovalReason.php similarity index 53% rename from backend/src/Api/Console/Input/Subscriber/ListSkipResubscribeOn.php rename to backend/src/Entity/Type/ListRemovalReason.php index 207d7d4ae..e4356887e 100644 --- a/backend/src/Api/Console/Input/Subscriber/ListSkipResubscribeOn.php +++ b/backend/src/Entity/Type/ListRemovalReason.php @@ -1,10 +1,11 @@ */ diff --git a/backend/src/Service/Subscriber/SubscriberService.php b/backend/src/Service/Subscriber/SubscriberService.php index b05a4d8a9..1eb3b4ec2 100644 --- a/backend/src/Service/Subscriber/SubscriberService.php +++ b/backend/src/Service/Subscriber/SubscriberService.php @@ -8,7 +8,7 @@ use App\Entity\Send; use App\Entity\Subscriber; use App\Entity\SubscriberExport; -use App\Entity\SubscriberListUnsubscribed; +use App\Entity\SubscriberListRemoval; use App\Entity\Type\SubscriberExportStatus; use App\Entity\Type\SubscriberSource; use App\Entity\Type\SubscriberStatus; @@ -150,8 +150,10 @@ public function getSubscribers( return new ArrayCollection($results); } - public function updateSubscriber(Subscriber $subscriber, UpdateSubscriberDto $updates): Subscriber - { + public function updateSubscriber( + Subscriber $subscriber, + UpdateSubscriberDto $updates, + ): Subscriber { if ($updates->has('status')) { $subscriber->setStatus($updates->status); } @@ -177,11 +179,11 @@ public function updateSubscriber(Subscriber $subscriber, UpdateSubscriberDto $up } if ($updates->has('metadata')) { - $metadata = $subscriber->getMetadata(); - foreach ($updates->metadata as $key => $value) { - $metadata[$key] = $value; - } - $subscriber->setMetadata($metadata); + $subscriber->setMetadata($updates->metadata); + } + + if ($updates->has('lists')) { + $subscriber->setLists(new ArrayCollection($updates->lists)); } $subscriber->setUpdatedAt($this->now()); @@ -284,35 +286,6 @@ public function getExports(Newsletter $newsletter): array ->findBy(['newsletter' => $newsletter], ['created_at' => 'DESC']); } - /** - * @param Subscriber $subscriber - * @param NewsletterList[] $lists - */ - public function setSubscriberLists( - Subscriber $subscriber, - array $lists, - ): void { - $listIds = array_map(fn(NewsletterList $list) => $list->getId(), $lists); - - // remove lists that are not in the new list - foreach ($subscriber->getLists() as $existingList) { - if (!in_array($existingList->getId(), $listIds)) { - $subscriber->removeList($existingList); - } - } - - // add new lists - foreach ($lists as $list) { - if (!$subscriber->getLists()->contains($list)) { - $subscriber->addList($list); - } - } - - $subscriber->setUpdatedAt($this->now()); - - $this->em->persist($subscriber); - $this->em->flush(); - } public function addSubscriberToList( Subscriber $subscriber, @@ -338,13 +311,13 @@ public function removeSubscriberFromList( if ($recordUnsubscription) { - $existing = $this->em->getRepository(SubscriberListUnsubscribed::class)->findOneBy([ + $existing = $this->em->getRepository(SubscriberListRemoval::class)->findOneBy([ 'list' => $list, 'subscriber' => $subscriber, ]); if ($existing === null) { - $unsubscribed = new SubscriberListUnsubscribed() + $unsubscribed = new SubscriberListRemoval() ->setList($list) ->setSubscriber($subscriber) ->setCreatedAt($this->now()); @@ -358,7 +331,7 @@ public function removeSubscriberFromList( public function hasSubscriberUnsubscribedFromList(Subscriber $subscriber, NewsletterList $list): bool { - $record = $this->em->getRepository(SubscriberListUnsubscribed::class)->findOneBy([ + $record = $this->em->getRepository(SubscriberListRemoval::class)->findOneBy([ 'list' => $list, 'subscriber' => $subscriber, ]); diff --git a/backend/tests/Api/Console/Subscriber/CreateSubscriberTest.php b/backend/tests/Api/Console/Subscriber/CreateSubscriberTest.php index 96c391bc9..c47a69016 100644 --- a/backend/tests/Api/Console/Subscriber/CreateSubscriberTest.php +++ b/backend/tests/Api/Console/Subscriber/CreateSubscriberTest.php @@ -5,7 +5,7 @@ use App\Api\Console\Controller\SubscriberController; use App\Entity\Newsletter; use App\Entity\Subscriber; -use App\Entity\SubscriberListUnsubscribed; +use App\Entity\SubscriberListRemoval; use App\Entity\Type\SubscriberMetadataDefinitionType; use App\Entity\Type\SubscriberSource; use App\Entity\Type\SubscriberStatus; @@ -18,7 +18,7 @@ use App\Tests\Factory\NewsletterFactory; use App\Tests\Factory\NewsletterListFactory; use App\Tests\Factory\SubscriberFactory; -use App\Tests\Factory\SubscriberListUnsubscribedFactory; +use App\Tests\Factory\SubscriberListRemovalFactory; use App\Tests\Factory\SubscriberMetadataDefinitionFactory; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\Attributes\TestWith; @@ -230,6 +230,10 @@ public function test_updates_subscriber_all(): void $this->assertContains($list2->getId(), $listIds); } + public function test_lists_strategy_overwrite(): void {} + + public function test_lists_strategy_remove(): void {} + public function testCreateSubscriberWithListsById(): void { @@ -379,7 +383,7 @@ public function testListAddStrategyIgnore(): void $list = NewsletterListFactory::createOne(['newsletter' => $newsletter]); $subscriber = SubscriberFactory::createOne(['newsletter' => $newsletter]); - SubscriberListUnsubscribedFactory::createOne([ + SubscriberListRemovalFactory::createOne([ 'list' => $list, 'subscriber' => $subscriber, ]); @@ -410,7 +414,7 @@ public function testListAddStrategyForceAdd(): void $list = NewsletterListFactory::createOne(['newsletter' => $newsletter]); $subscriber = SubscriberFactory::createOne(['newsletter' => $newsletter]); - SubscriberListUnsubscribedFactory::createOne([ + SubscriberListRemovalFactory::createOne([ 'list' => $list, 'subscriber' => $subscriber, ]); @@ -461,11 +465,11 @@ public function testListRemoveReasonUnsubscribe(): void $this->assertCount(0, $updated->getLists()); // Should record unsubscription - $record = $this->em->getRepository(SubscriberListUnsubscribed::class)->findOneBy([ + $record = $this->em->getRepository(SubscriberListRemoval::class)->findOneBy([ 'list' => $list->_real(), 'subscriber' => $updated, ]); - $this->assertInstanceOf(SubscriberListUnsubscribed::class, $record); + $this->assertInstanceOf(SubscriberListRemoval::class, $record); } public function testListRemoveReasonOther(): void @@ -493,7 +497,7 @@ public function testListRemoveReasonOther(): void $this->assertCount(0, $updated->getLists()); // Should NOT record unsubscription - $record = $this->em->getRepository(SubscriberListUnsubscribed::class)->findOneBy([ + $record = $this->em->getRepository(SubscriberListRemoval::class)->findOneBy([ 'list' => $list->_real(), 'subscriber' => $updated, ]); diff --git a/backend/tests/Factory/SubscriberListUnsubscribedFactory.php b/backend/tests/Factory/SubscriberListRemovalFactory.php similarity index 62% rename from backend/tests/Factory/SubscriberListUnsubscribedFactory.php rename to backend/tests/Factory/SubscriberListRemovalFactory.php index ee3f0660c..a18a877d8 100644 --- a/backend/tests/Factory/SubscriberListUnsubscribedFactory.php +++ b/backend/tests/Factory/SubscriberListRemovalFactory.php @@ -2,21 +2,19 @@ namespace App\Tests\Factory; -use App\Entity\SubscriberListUnsubscribed; +use App\Entity\SubscriberListRemoval; use Zenstruck\Foundry\Persistence\PersistentProxyObjectFactory; /** - * @extends PersistentProxyObjectFactory + * @extends PersistentProxyObjectFactory */ -final class SubscriberListUnsubscribedFactory extends PersistentProxyObjectFactory +final class SubscriberListRemovalFactory extends PersistentProxyObjectFactory { - public function __construct() - { - } + public function __construct() {} public static function class(): string { - return SubscriberListUnsubscribed::class; + return SubscriberListRemoval::class; } /** @@ -27,6 +25,7 @@ protected function defaults(): array return [ 'list' => NewsletterListFactory::new(), 'subscriber' => SubscriberFactory::new(), + 'reason' => self::faker()->randomElement(['unsubscribe', 'bounce', 'other']), 'created_at' => \DateTimeImmutable::createFromMutable(self::faker()->dateTime()), ]; } diff --git a/frontend/src/routes/(marketing)/docs/[...slug]/content/ConsoleApi.svelte b/frontend/src/routes/(marketing)/docs/[...slug]/content/ConsoleApi.svelte index 8ec62e217..a6973e148 100644 --- a/frontend/src/routes/(marketing)/docs/[...slug]/content/ConsoleApi.svelte +++ b/frontend/src/routes/(marketing)/docs/[...slug]/content/ConsoleApi.svelte @@ -372,8 +372,8 @@ // if the subscriber was previously removed from a list, // define the reason(s) for ignoring the re-subscription to that list. // see below for more info - // default: ['unsubscribe', 'bounce'] - list_skip_resubscribe_on?: ('unsubscribe' | 'bounce' | 'auto')[]; + // default: ['unsubscribe', 'bounce', 'complaint'] + list_skip_resubscribe_on?: ('unsubscribe' | 'bounce' | 'complaint' | 'auto')[]; // define the reason for removing the subscriber from a list // (only when updating, see below for more info) From 7dc9c431f0c2dc89812962b1ffff0efb402e6007 Mon Sep 17 00:00:00 2001 From: Supun Wimalasena Date: Tue, 3 Mar 2026 15:20:06 +0100 Subject: [PATCH 15/43] lists strategy done, metadata strategy before tests --- .../Controller/SubscriberController.php | 10 +++- .../Subscriber/CreateSubscriberTest.php | 53 ++++++++++++++++++- 2 files changed, 60 insertions(+), 3 deletions(-) diff --git a/backend/src/Api/Console/Controller/SubscriberController.php b/backend/src/Api/Console/Controller/SubscriberController.php index 395ab687a..f0d9907ec 100644 --- a/backend/src/Api/Console/Controller/SubscriberController.php +++ b/backend/src/Api/Console/Controller/SubscriberController.php @@ -7,6 +7,7 @@ use App\Api\Console\Input\Subscriber\BulkActionSubscriberInput; use App\Api\Console\Input\Subscriber\CreateSubscriberInput; use App\Api\Console\Input\Subscriber\ListsStrategy; +use App\Api\Console\Input\Subscriber\MetadataStrategy; use App\Api\Console\Object\SubscriberObject; use App\Entity\Newsletter; use App\Entity\NewsletterList; @@ -126,7 +127,14 @@ public function createSubscriber( } if ($input->metadata) { - $updates->metadata = $input->metadata; + if ($input->metadata_strategy === MetadataStrategy::MERGE) { + $updates->metadata = array_merge( + $subscriber->getMetadata(), + $input->metadata, + ); + } else { + $updates->metadata = $input->metadata; + } } if ($input->lists !== null) { diff --git a/backend/tests/Api/Console/Subscriber/CreateSubscriberTest.php b/backend/tests/Api/Console/Subscriber/CreateSubscriberTest.php index c47a69016..bf782b0fa 100644 --- a/backend/tests/Api/Console/Subscriber/CreateSubscriberTest.php +++ b/backend/tests/Api/Console/Subscriber/CreateSubscriberTest.php @@ -230,10 +230,59 @@ public function test_updates_subscriber_all(): void $this->assertContains($list2->getId(), $listIds); } - public function test_lists_strategy_overwrite(): void {} + public function test_lists_strategy_overwrite(): void + { + $newsletter = NewsletterFactory::createOne(); + $list1 = NewsletterListFactory::createOne(['newsletter' => $newsletter]); + $list2 = NewsletterListFactory::createOne(['newsletter' => $newsletter]); + + $subscriber = SubscriberFactory::createOne([ + 'newsletter' => $newsletter, + 'lists' => [$list1], + ]); + + $this->consoleApi($newsletter, 'POST', '/subscribers', [ + 'email' => $subscriber->getEmail(), + 'lists' => [$list2->getId()], + 'lists_strategy' => 'overwrite', + ]); + + $this->assertResponseIsSuccessful(); + + refresh($subscriber); + $listIds = $subscriber->getLists()->map(fn($l) => $l->getId())->toArray(); + $this->assertCount(1, $listIds); + $this->assertContains($list2->getId(), $listIds); + } + + public function test_lists_strategy_remove(): void + { + $newsletter = NewsletterFactory::createOne(); + $list1 = NewsletterListFactory::createOne(['newsletter' => $newsletter]); + $list2 = NewsletterListFactory::createOne(['newsletter' => $newsletter]); + + $subscriber = SubscriberFactory::createOne([ + 'newsletter' => $newsletter, + 'lists' => [$list1, $list2], + ]); + + $this->consoleApi($newsletter, 'POST', '/subscribers', [ + 'email' => $subscriber->getEmail(), + 'lists' => [$list1->getId()], + 'lists_strategy' => 'remove', + ]); + + $this->assertResponseIsSuccessful(); + + refresh($subscriber); + $listIds = $subscriber->getLists()->map(fn($l) => $l->getId())->toArray(); + $this->assertCount(1, $listIds); + $this->assertContains($list2->getId(), $listIds); + } - public function test_lists_strategy_remove(): void {} + public function test_metadata_strategy_merge(): void {} + public function test_metadata_strategy_overwrite(): void {} public function testCreateSubscriberWithListsById(): void { From 86a1db4154d33dfa5504243291036fa3abe8e934 Mon Sep 17 00:00:00 2001 From: Supun Wimalasena Date: Tue, 3 Mar 2026 15:40:06 +0100 Subject: [PATCH 16/43] saving list removals --- .../Controller/SubscriberController.php | 34 ++----- .../Subscriber/CreateSubscriberInput.php | 2 +- .../Input/Subscriber/ListRemoveReason.php | 10 --- .../Event/SubscriberUpdatedEvent.php | 25 ++++++ .../Event/SubscriberUpdatingEvent.php | 32 +++++++ .../ListRemoval/ListRemovalListener.php | 48 ++++++++++ .../Service/Subscriber/SubscriberService.php | 29 +++++- .../Subscriber/CreateSubscriberTest.php | 89 ++++++++++++++++++- .../docs/[...slug]/content/ConsoleApi.svelte | 6 +- 9 files changed, 227 insertions(+), 48 deletions(-) delete mode 100644 backend/src/Api/Console/Input/Subscriber/ListRemoveReason.php create mode 100644 backend/src/Service/Subscriber/Event/SubscriberUpdatedEvent.php create mode 100644 backend/src/Service/Subscriber/Event/SubscriberUpdatingEvent.php create mode 100644 backend/src/Service/Subscriber/ListRemoval/ListRemovalListener.php diff --git a/backend/src/Api/Console/Controller/SubscriberController.php b/backend/src/Api/Console/Controller/SubscriberController.php index f0d9907ec..f78ab676d 100644 --- a/backend/src/Api/Console/Controller/SubscriberController.php +++ b/backend/src/Api/Console/Controller/SubscriberController.php @@ -158,37 +158,13 @@ public function createSubscriber( $updates->lists = $newLists; } - $subscriber = $this->subscriberService->updateSubscriber($subscriber, $updates); + $subscriber = $this->subscriberService->updateSubscriber( + $subscriber, + $updates, + listRemovalReason: $input->list_removal_reason, + ); } -// // Sync lists -// $resolvedListIds = array_map(fn($l) => $l->getId(), $resolvedLists); -// $currentListIds = $subscriber->getLists()->map(fn($l) => $l->getId())->toArray(); -// -// // Add new lists -// foreach ($resolvedLists as $list) { -// if (!in_array($list->getId(), $currentListIds)) { -// if ( -// $input->list_add_strategy_if_unsubscribed === ListAddStrategyIfUnsubscribed::IGNORE && -// $this->subscriberService->hasSubscriberUnsubscribedFromList($subscriber, $list) -// ) { -// continue; -// } -// $this->subscriberService->addSubscriberToList($subscriber, $list); -// } -// } -// -// // Remove lists no longer in the resolved set -// foreach ($subscriber->getLists()->toArray() as $existingList) { -// if (!in_array($existingList->getId(), $resolvedListIds)) { -// $this->subscriberService->removeSubscriberFromList( -// $subscriber, -// $existingList, -// $input->list_remove_reason === ListRemoveReason::UNSUBSCRIBE, -// ); -// } -// } - return $this->json(new SubscriberObject($subscriber)); } diff --git a/backend/src/Api/Console/Input/Subscriber/CreateSubscriberInput.php b/backend/src/Api/Console/Input/Subscriber/CreateSubscriberInput.php index 34dab0278..54fc4babc 100644 --- a/backend/src/Api/Console/Input/Subscriber/CreateSubscriberInput.php +++ b/backend/src/Api/Console/Input/Subscriber/CreateSubscriberInput.php @@ -52,7 +52,7 @@ class CreateSubscriberInput #[Assert\All(new Assert\Choice(callback: 'getListResubscribeOnValues'))] private array $list_skip_resubscribe_on = ['unsubscribe', 'bounce', 'complaint']; - public ListRemoveReason $list_remove_reason = ListRemoveReason::UNSUBSCRIBE; + public ListRemovalReason $list_removal_reason = ListRemovalReason::UNSUBSCRIBE; public MetadataStrategy $metadata_strategy = MetadataStrategy::MERGE; diff --git a/backend/src/Api/Console/Input/Subscriber/ListRemoveReason.php b/backend/src/Api/Console/Input/Subscriber/ListRemoveReason.php deleted file mode 100644 index c92c66339..000000000 --- a/backend/src/Api/Console/Input/Subscriber/ListRemoveReason.php +++ /dev/null @@ -1,10 +0,0 @@ -subscriber; + } + + public function getSubscriberOld(): Subscriber + { + return $this->subscriberOld; + } + +} diff --git a/backend/src/Service/Subscriber/Event/SubscriberUpdatingEvent.php b/backend/src/Service/Subscriber/Event/SubscriberUpdatingEvent.php new file mode 100644 index 000000000..5c3197ed1 --- /dev/null +++ b/backend/src/Service/Subscriber/Event/SubscriberUpdatingEvent.php @@ -0,0 +1,32 @@ +subscriber; + } + + public function getSubscriberOld(): Subscriber + { + return $this->subscriberOld; + } + + public function getListRemovalReason(): ListRemovalReason + { + return $this->listRemovalReason; + } + +} diff --git a/backend/src/Service/Subscriber/ListRemoval/ListRemovalListener.php b/backend/src/Service/Subscriber/ListRemoval/ListRemovalListener.php new file mode 100644 index 000000000..4585be776 --- /dev/null +++ b/backend/src/Service/Subscriber/ListRemoval/ListRemovalListener.php @@ -0,0 +1,48 @@ +getSubscriberOld()->getLists()->map(fn($list) => $list->getId())->toArray(); + $newListIds = $event->getSubscriber()->getLists()->map(fn($list) => $list->getId())->toArray(); + + $removedListIds = array_diff($oldListIds, $newListIds); + + foreach ($removedListIds as $removedListId) { + // PGSQL query with ON CONFLICT to update subscriber_list_removals + + $query = << $removedListId, + 'subscriber_id' => $event->getSubscriber()->getId(), + 'reason' => $event->getListRemovalReason()->value, + 'created_at' => $this->now()->format('Y-m-d H:i:s'), + ]; + + $this->em->getConnection()->executeQuery($query, $params); + } + } + +} diff --git a/backend/src/Service/Subscriber/SubscriberService.php b/backend/src/Service/Subscriber/SubscriberService.php index 1eb3b4ec2..7365db111 100644 --- a/backend/src/Service/Subscriber/SubscriberService.php +++ b/backend/src/Service/Subscriber/SubscriberService.php @@ -9,12 +9,15 @@ use App\Entity\Subscriber; use App\Entity\SubscriberExport; use App\Entity\SubscriberListRemoval; +use App\Entity\Type\ListRemovalReason; use App\Entity\Type\SubscriberExportStatus; use App\Entity\Type\SubscriberSource; use App\Entity\Type\SubscriberStatus; use App\Repository\SubscriberRepository; use App\Service\Subscriber\Dto\UpdateSubscriberDto; use App\Service\Subscriber\Event\SubscriberCreatedEvent; +use App\Service\Subscriber\Event\SubscriberUpdatedEvent; +use App\Service\Subscriber\Event\SubscriberUpdatingEvent; use App\Service\Subscriber\Message\ExportSubscribersMessage; use App\Service\Subscriber\Message\SubscriberCreatedMessage; use Doctrine\Common\Collections\ArrayCollection; @@ -153,7 +156,13 @@ public function getSubscribers( public function updateSubscriber( Subscriber $subscriber, UpdateSubscriberDto $updates, + + // if some lists are being removed, set the reason to correctly record + // it in ListRemovalListener + ListRemovalReason $listRemovalReason = ListRemovalReason::UNSUBSCRIBE, ): Subscriber { + $subscriberOld = clone $subscriber; + if ($updates->has('status')) { $subscriber->setStatus($updates->status); } @@ -188,8 +197,24 @@ public function updateSubscriber( $subscriber->setUpdatedAt($this->now()); - $this->em->persist($subscriber); - $this->em->flush(); + $this->em->wrapInTransaction(function () use ($subscriberOld, $subscriber, $listRemovalReason) { + $this->em->persist($subscriber); + $this->ed->dispatch( + new SubscriberUpdatingEvent( + $subscriberOld, + $subscriber, + listRemovalReason: $listRemovalReason, + ), + ); + $this->em->flush(); + }); + + $this->ed->dispatch( + new SubscriberUpdatedEvent( + $subscriberOld, + $subscriber, + ), + ); return $subscriber; } diff --git a/backend/tests/Api/Console/Subscriber/CreateSubscriberTest.php b/backend/tests/Api/Console/Subscriber/CreateSubscriberTest.php index bf782b0fa..c862f2e5b 100644 --- a/backend/tests/Api/Console/Subscriber/CreateSubscriberTest.php +++ b/backend/tests/Api/Console/Subscriber/CreateSubscriberTest.php @@ -6,12 +6,15 @@ use App\Entity\Newsletter; use App\Entity\Subscriber; use App\Entity\SubscriberListRemoval; +use App\Entity\Type\ListRemovalReason; use App\Entity\Type\SubscriberMetadataDefinitionType; use App\Entity\Type\SubscriberSource; use App\Entity\Type\SubscriberStatus; -use App\Repository\SubscriberRepository; use App\Service\NewsletterList\NewsletterListService; use App\Service\Subscriber\Event\SubscriberCreatedEvent; +use App\Service\Subscriber\Event\SubscriberUpdatedEvent; +use App\Service\Subscriber\Event\SubscriberUpdatingEvent; +use App\Service\Subscriber\ListRemoval\ListRemovalListener; use App\Service\Subscriber\Message\SubscriberCreatedMessage; use App\Service\Subscriber\SubscriberService; use App\Tests\Case\WebTestCase; @@ -29,7 +32,10 @@ #[CoversClass(SubscriberController::class)] #[CoversClass(SubscriberService::class)] #[CoversClass(SubscriberCreatedEvent::class)] +#[CoversClass(SubscriberUpdatingEvent::class)] +#[CoversClass(SubscriberUpdatedEvent::class)] #[CoversClass(NewsletterListService::class)] +#[CoversClass(ListRemovalListener::class)] class CreateSubscriberTest extends WebTestCase { @@ -280,9 +286,86 @@ public function test_lists_strategy_remove(): void $this->assertContains($list2->getId(), $listIds); } - public function test_metadata_strategy_merge(): void {} + public function test_metadata_strategy_merge(): void + { + $newsletter = NewsletterFactory::createOne(); + + foreach (['a', 'b', 'c'] as $key) { + SubscriberMetadataDefinitionFactory::createOne(['newsletter' => $newsletter, 'key' => $key]); + } + + $subscriber = SubscriberFactory::createOne([ + 'newsletter' => $newsletter, + 'metadata' => ['a' => '1', 'b' => '2'], + ]); + + $this->consoleApi($newsletter, 'POST', '/subscribers', [ + 'email' => $subscriber->getEmail(), + 'metadata' => ['b' => 'updated', 'c' => '3'], + 'metadata_strategy' => 'merge', + ]); + + $this->assertResponseIsSuccessful(); + + refresh($subscriber); + $this->assertSame(['a' => '1', 'b' => 'updated', 'c' => '3'], $subscriber->getMetadata()); + } + + public function test_metadata_strategy_overwrite(): void + { + $newsletter = NewsletterFactory::createOne(); + + foreach (['a', 'b', 'c'] as $key) { + SubscriberMetadataDefinitionFactory::createOne(['newsletter' => $newsletter, 'key' => $key]); + } + + $subscriber = SubscriberFactory::createOne([ + 'newsletter' => $newsletter, + 'metadata' => ['a' => '1', 'b' => '2'], + ]); + + $this->consoleApi($newsletter, 'POST', '/subscribers', [ + 'email' => $subscriber->getEmail(), + 'metadata' => ['c' => '3'], + 'metadata_strategy' => 'overwrite', + ]); + + $this->assertResponseIsSuccessful(); + + refresh($subscriber); + $this->assertSame(['c' => '3'], $subscriber->getMetadata()); + } + + #[TestWith([ListRemovalReason::UNSUBSCRIBE])] + #[TestWith([ListRemovalReason::BOUNCE])] + public function test_records_list_removal(ListRemovalReason $reason): void + { + $newsletter = NewsletterFactory::createOne(); + $list1 = NewsletterListFactory::createOne(['newsletter' => $newsletter]); + $list2 = NewsletterListFactory::createOne(['newsletter' => $newsletter]); + + $subscriber = SubscriberFactory::createOne([ + 'newsletter' => $newsletter, + 'lists' => [$list1, $list2], + ]); + + $this->consoleApi($newsletter, 'POST', '/subscribers', [ + 'email' => $subscriber->getEmail(), + 'lists' => [], + 'lists_strategy' => 'overwrite', + 'list_removal_reason' => $reason->value, + ]); + // make sure the records are recorded + } + + public function test_updates_list_removal(): void + { + // test ON CONFLICT + } + + public function test_list_removal_make_sure_adding_lists_is_not_recorded(): void {} - public function test_metadata_strategy_overwrite(): void {} + public function test_list_removal_with_strategy_remove(): void {} public function testCreateSubscriberWithListsById(): void { diff --git a/frontend/src/routes/(marketing)/docs/[...slug]/content/ConsoleApi.svelte b/frontend/src/routes/(marketing)/docs/[...slug]/content/ConsoleApi.svelte index a6973e148..a30a66010 100644 --- a/frontend/src/routes/(marketing)/docs/[...slug]/content/ConsoleApi.svelte +++ b/frontend/src/routes/(marketing)/docs/[...slug]/content/ConsoleApi.svelte @@ -378,7 +378,7 @@ // define the reason for removing the subscriber from a list // (only when updating, see below for more info) // default: 'unsubscribe' - list_remove_reason?: 'unsubscribe' | 'bounce' | 'other'; + list_removal_reason?: 'unsubscribe' | 'bounce' | 'other'; // whether to overwrite or merge the subscriber's metadata // when updating an existing subscriber. @@ -420,7 +420,7 @@

    - list_remove_reason: + list_removal_reason:

      @@ -489,7 +489,7 @@ "lists_strategy": "remove", // unsubscribe, bounce, or other - "list_remove_reason": "unsubscribe" + "list_removal_reason": "unsubscribe" } `} /> From de1d927b97265b9bd9e4d7e11e0796fe2632271a Mon Sep 17 00:00:00 2001 From: Supun Wimalasena Date: Tue, 3 Mar 2026 15:58:20 +0100 Subject: [PATCH 17/43] wip --- .../Message/SendConfirmationEmailMessage.php | 19 +++ .../Message/SubscriberCreatedMessage.php | 20 --- .../SendConfirmationEmailMessageHandler.php | 89 ++++++++++++ .../SubscriberCreatedMessageHandler.php | 128 ------------------ .../Service/Subscriber/SubscriberService.php | 2 - .../newsletter/mail/confirm.json.twig | 44 ++++++ .../Subscriber/CreateSubscriberTest.php | 108 ++++++++++++++- .../SubscriberCreatedMessageHandlerTest.php | 10 +- 8 files changed, 259 insertions(+), 161 deletions(-) create mode 100644 backend/src/Service/Subscriber/Message/SendConfirmationEmailMessage.php delete mode 100644 backend/src/Service/Subscriber/Message/SubscriberCreatedMessage.php create mode 100644 backend/src/Service/Subscriber/MessageHandler/SendConfirmationEmailMessageHandler.php delete mode 100644 backend/src/Service/Subscriber/MessageHandler/SubscriberCreatedMessageHandler.php create mode 100644 backend/templates/newsletter/mail/confirm.json.twig diff --git a/backend/src/Service/Subscriber/Message/SendConfirmationEmailMessage.php b/backend/src/Service/Subscriber/Message/SendConfirmationEmailMessage.php new file mode 100644 index 000000000..7759fe50c --- /dev/null +++ b/backend/src/Service/Subscriber/Message/SendConfirmationEmailMessage.php @@ -0,0 +1,19 @@ +subscriberId; + } +} diff --git a/backend/src/Service/Subscriber/Message/SubscriberCreatedMessage.php b/backend/src/Service/Subscriber/Message/SubscriberCreatedMessage.php deleted file mode 100644 index 0e84ed5cc..000000000 --- a/backend/src/Service/Subscriber/Message/SubscriberCreatedMessage.php +++ /dev/null @@ -1,20 +0,0 @@ -subscriberExportId; - } -} diff --git a/backend/src/Service/Subscriber/MessageHandler/SendConfirmationEmailMessageHandler.php b/backend/src/Service/Subscriber/MessageHandler/SendConfirmationEmailMessageHandler.php new file mode 100644 index 000000000..3d5e5e4ef --- /dev/null +++ b/backend/src/Service/Subscriber/MessageHandler/SendConfirmationEmailMessageHandler.php @@ -0,0 +1,89 @@ +em->getRepository(Subscriber::class)->find($message->getSubscriberId()); + assert($subscriber !== null); + $newsletter = $subscriber->getNewsletter(); + + if ($subscriber->getStatus() !== SubscriberStatus::PENDING) { + // If the subscriber is not pending, we do not send a confirmation email. + return; + } + + $data = [ + 'subscriber_id' => $subscriber->getId(), + 'expires_at' => $this->now()->add(new \DateInterval('P1D'))->format('Y-m-d H:i:s'), + ]; + + $token = $this->encryption->encrypt($data); + + $strings = $this->stringsFactory->create(); + + $heading = $strings->get('mail.subscriberConfirmation.heading'); + + $variables = $this->templateVariableService->variablesFromNewsletter($newsletter); + + $content = $this->twig->render('newsletter/mail/config.json', [ + 'newsletterName' => $newsletter->getName(), + 'buttonUrl' => $this->newsletterService->getArchiveUrl($newsletter) . "/confirm?token=" . $token, + 'buttonText' => $strings->get('mail.subscriberConfirmation.buttonText'), + ]); + + $variables->subject = $heading; + $variables->content = $this->contentService->getHtmlFromJson($content); + + $template = $this->templateService->getTemplateStringFromNewsletter($newsletter); + + $email = new Email(); + $this->sendingProfileService->setSendingProfileToEmail( + $email, + $this->sendingProfileService->getCurrentDefaultSendingProfileOfNewsletter($newsletter), + ); + + $email + ->to($subscriber->getEmail()) + ->html($this->htmlTemplateRenderer->render($template, $variables)) + ->subject($heading . ' to ' . $newsletter->getName()); + $this->relayApiClient->sendEmail($email); + } +} diff --git a/backend/src/Service/Subscriber/MessageHandler/SubscriberCreatedMessageHandler.php b/backend/src/Service/Subscriber/MessageHandler/SubscriberCreatedMessageHandler.php deleted file mode 100644 index b471182e3..000000000 --- a/backend/src/Service/Subscriber/MessageHandler/SubscriberCreatedMessageHandler.php +++ /dev/null @@ -1,128 +0,0 @@ -em->getRepository(Subscriber::class)->find($message->getSubscriberId()); - assert($subscriber !== null); - $newsletter = $subscriber->getNewsletter(); - - if ($subscriber->getStatus() !== SubscriberStatus::PENDING) { - // If the subscriber is not pending, we do not send a confirmation email. - return; - } - - $data = [ - 'subscriber_id' => $subscriber->getId(), - 'expires_at' => $this->now()->add(new \DateInterval('P1D'))->format('Y-m-d H:i:s'), - ]; - - $token = $this->encryption->encrypt($data); - - $strings = $this->stringsFactory->create(); - - $heading = $strings->get('mail.subscriberConfirmation.heading'); - - $variables = $this->templateVariableService->variablesFromNewsletter($newsletter); - - $content = (string)json_encode([ - 'type' => 'doc', - 'content' => [ - [ - 'type' => 'paragraph', - 'content' => [ - [ - 'type' => 'text', - 'text' => 'Hey 👋,', - ], - ], - ], - [ - 'type' => 'paragraph', - 'content' => [ - [ - 'type' => 'text', - 'text' => 'Thank you for subscribing to ' . $newsletter->getName() . '! To confirm your subscription and start receiving updates, please click the button below.', - ], - ], - ], - [ - 'type' => 'button', - 'attrs' => [ - 'href' => $this->newsletterService->getArchiveUrl($newsletter) . "/confirm?token=" . $token, - ], - 'content' => [ - [ - 'type' => 'text', - 'text' => $strings->get('mail.subscriberConfirmation.buttonText'), - ], - ], - ], - [ - 'type' => 'paragraph', - 'content' => [ - [ - 'type' => 'text', - 'text' => 'This link will expire in 24 hours. If you did not request or expect this invitation, you can safely ignore this email.', - ], - ], - ], - ], - ]); - - $variables->subject = $heading; - $variables->content = $this->contentService->getHtmlFromJson($content); - - $template = $this->templateService->getTemplateStringFromNewsletter($newsletter); - - $email = new Email(); - $this->sendingProfileService->setSendingProfileToEmail( - $email, - $this->sendingProfileService->getCurrentDefaultSendingProfileOfNewsletter($newsletter) - ); - - $email->to($subscriber->getEmail()) - ->html($this->htmlTemplateRenderer->render($template, $variables)) - ->subject($heading . ' to ' . $newsletter->getName()); - $this->relayApiClient->sendEmail($email); - } -} diff --git a/backend/src/Service/Subscriber/SubscriberService.php b/backend/src/Service/Subscriber/SubscriberService.php index 7365db111..23d05e728 100644 --- a/backend/src/Service/Subscriber/SubscriberService.php +++ b/backend/src/Service/Subscriber/SubscriberService.php @@ -19,12 +19,10 @@ use App\Service\Subscriber\Event\SubscriberUpdatedEvent; use App\Service\Subscriber\Event\SubscriberUpdatingEvent; use App\Service\Subscriber\Message\ExportSubscribersMessage; -use App\Service\Subscriber\Message\SubscriberCreatedMessage; use Doctrine\Common\Collections\ArrayCollection; use Doctrine\ORM\EntityManagerInterface; use Symfony\Component\Clock\ClockAwareTrait; use Symfony\Component\EventDispatcher\EventDispatcherInterface; -use Symfony\Component\Messenger\MessageBusInterface; class SubscriberService { diff --git a/backend/templates/newsletter/mail/confirm.json.twig b/backend/templates/newsletter/mail/confirm.json.twig new file mode 100644 index 000000000..2e32289c5 --- /dev/null +++ b/backend/templates/newsletter/mail/confirm.json.twig @@ -0,0 +1,44 @@ +{ + "type": "doc", + "content": [ + { + "type": "paragraph", + "content": [ + { + "type": "text", + "text": "Hey 👋," + } + ] + }, + { + "type": "paragraph", + "content": [ + { + "type": "text", + "text": "Thank you for subscribing to {{ newsletterName }}! To confirm your subscription and start receiving updates, please click the button below." + } + ] + }, + { + "type": "button", + "attrs": { + "href": "{{ buttonUrl }}" + }, + "content": [ + { + "type": "text", + "text": "{{ buttonText }}" + } + ] + }, + { + "type": "paragraph", + "content": [ + { + "type": "text", + "text": "This link will expire in 24 hours. If you did not request or expect this invitation, you can safely ignore this email." + } + ] + } + ] +} diff --git a/backend/tests/Api/Console/Subscriber/CreateSubscriberTest.php b/backend/tests/Api/Console/Subscriber/CreateSubscriberTest.php index c862f2e5b..60c6e17d8 100644 --- a/backend/tests/Api/Console/Subscriber/CreateSubscriberTest.php +++ b/backend/tests/Api/Console/Subscriber/CreateSubscriberTest.php @@ -15,7 +15,7 @@ use App\Service\Subscriber\Event\SubscriberUpdatedEvent; use App\Service\Subscriber\Event\SubscriberUpdatingEvent; use App\Service\Subscriber\ListRemoval\ListRemovalListener; -use App\Service\Subscriber\Message\SubscriberCreatedMessage; +use App\Service\Subscriber\Message\SendConfirmationEmailMessage; use App\Service\Subscriber\SubscriberService; use App\Tests\Case\WebTestCase; use App\Tests\Factory\NewsletterFactory; @@ -355,17 +355,113 @@ public function test_records_list_removal(ListRemovalReason $reason): void 'lists_strategy' => 'overwrite', 'list_removal_reason' => $reason->value, ]); - // make sure the records are recorded + + $this->assertResponseIsSuccessful(); + + $repo = $this->em->getRepository(SubscriberListRemoval::class); + foreach ([$list1, $list2] as $list) { + $record = $repo->findOneBy(['list' => $list, 'subscriber' => $subscriber]); + $this->assertInstanceOf(SubscriberListRemoval::class, $record); + $this->assertSame($reason->value, $record->getReason()); + } } public function test_updates_list_removal(): void { - // test ON CONFLICT + $newsletter = NewsletterFactory::createOne(); + $list = NewsletterListFactory::createOne(['newsletter' => $newsletter]); + + $subscriber = SubscriberFactory::createOne([ + 'newsletter' => $newsletter, + 'lists' => [$list], + ]); + + // First removal: UNSUBSCRIBE + $this->consoleApi($newsletter, 'POST', '/subscribers', [ + 'email' => $subscriber->getEmail(), + 'lists' => [], + 'lists_strategy' => 'overwrite', + 'list_removal_reason' => 'unsubscribe', + ]); + + // Re-add the list + $this->consoleApi($newsletter, 'POST', '/subscribers', [ + 'email' => $subscriber->getEmail(), + 'lists' => [$list->getId()], + 'lists_strategy' => 'overwrite', + ]); + + // Second removal: BOUNCE + $this->consoleApi($newsletter, 'POST', '/subscribers', [ + 'email' => $subscriber->getEmail(), + 'lists' => [], + 'lists_strategy' => 'overwrite', + 'list_removal_reason' => 'bounce', + ]); + + $this->assertResponseIsSuccessful(); + + $records = $this->em->getRepository(SubscriberListRemoval::class)->findBy([ + 'list' => $list->_real(), + 'subscriber' => $subscriber->_real(), + ]); + + $this->assertCount(1, $records); + $this->assertSame('bounce', $records[0]->getReason()); } - public function test_list_removal_make_sure_adding_lists_is_not_recorded(): void {} + public function test_list_removal_make_sure_adding_lists_is_not_recorded(): void + { + $newsletter = NewsletterFactory::createOne(); + $list = NewsletterListFactory::createOne(['newsletter' => $newsletter]); - public function test_list_removal_with_strategy_remove(): void {} + $subscriber = SubscriberFactory::createOne(['newsletter' => $newsletter]); + + $this->consoleApi($newsletter, 'POST', '/subscribers', [ + 'email' => $subscriber->getEmail(), + 'lists' => [$list->getId()], + 'lists_strategy' => 'overwrite', + ]); + + $this->assertResponseIsSuccessful(); + + $records = $this->em->getRepository(SubscriberListRemoval::class)->findBy([ + 'subscriber' => $subscriber->_real(), + ]); + $this->assertCount(0, $records); + } + + public function test_list_removal_with_strategy_remove(): void + { + $newsletter = NewsletterFactory::createOne(); + $list1 = NewsletterListFactory::createOne(['newsletter' => $newsletter]); + $list2 = NewsletterListFactory::createOne(['newsletter' => $newsletter]); + + $subscriber = SubscriberFactory::createOne([ + 'newsletter' => $newsletter, + 'lists' => [$list1, $list2], + ]); + + $this->consoleApi($newsletter, 'POST', '/subscribers', [ + 'email' => $subscriber->getEmail(), + 'lists' => [$list1->getId()], + 'lists_strategy' => 'remove', + 'list_removal_reason' => 'unsubscribe', + ]); + + $this->assertResponseIsSuccessful(); + + refresh($subscriber); + $this->assertCount(1, $subscriber->getLists()); + $this->assertSame($list2->getId(), $subscriber->getLists()->first()->getId()); + + $record = $this->em->getRepository(SubscriberListRemoval::class)->findOneBy([ + 'list' => $list1->_real(), + 'subscriber' => $subscriber->_real(), + ]); + $this->assertInstanceOf(SubscriberListRemoval::class, $record); + $this->assertSame('unsubscribe', $record->getReason()); + } public function testCreateSubscriberWithListsById(): void { @@ -657,7 +753,7 @@ public function testSendPendingConfirmationEmail(): void $transport = $this->transport('async'); $transport->queue()->assertCount(1); $message = $transport->queue()->first()->getMessage(); - $this->assertInstanceOf(SubscriberCreatedMessage::class, $message); + $this->assertInstanceOf(SendConfirmationEmailMessage::class, $message); } public function testNoConfirmationEmailByDefault(): void diff --git a/backend/tests/Api/Console/Subscriber/SubscriberCreatedMessageHandlerTest.php b/backend/tests/Api/Console/Subscriber/SubscriberCreatedMessageHandlerTest.php index e389ac456..db1fc756d 100644 --- a/backend/tests/Api/Console/Subscriber/SubscriberCreatedMessageHandlerTest.php +++ b/backend/tests/Api/Console/Subscriber/SubscriberCreatedMessageHandlerTest.php @@ -4,8 +4,8 @@ use App\Entity\Subscriber; use App\Entity\Type\SubscriberStatus; -use App\Service\Subscriber\Message\SubscriberCreatedMessage; -use App\Service\Subscriber\MessageHandler\SubscriberCreatedMessageHandler; +use App\Service\Subscriber\Message\SendConfirmationEmailMessage; +use App\Service\Subscriber\MessageHandler\SendConfirmationEmailMessageHandler; use App\Tests\Case\KernelTestCase; use App\Tests\Factory\NewsletterFactory; use App\Tests\Factory\NewsletterListFactory; @@ -17,8 +17,8 @@ use Symfony\Component\Clock\Test\ClockSensitiveTrait; use Symfony\Component\HttpClient\Response\JsonMockResponse; -#[CoversClass(SubscriberCreatedMessageHandler::class)] -#[CoversClass(SubscriberCreatedMessage::class)] +#[CoversClass(SendConfirmationEmailMessageHandler::class)] +#[CoversClass(SendConfirmationEmailMessage::class)] class SubscriberCreatedMessageHandlerTest extends KernelTestCase { use ClockSensitiveTrait; @@ -66,7 +66,7 @@ public function test_send_confirmation_email_for_pending_subscriber(): void $this->mockRelayClient($callback); - $message = new SubscriberCreatedMessage($subscriber->getId()); + $message = new SendConfirmationEmailMessage($subscriber->getId()); $this->getMessageBus()->dispatch($message); $transport = $this->transport('async'); From dbfbd381cb74a767366a0be8a611542609343669 Mon Sep 17 00:00:00 2001 From: Supun Wimalasena Date: Tue, 3 Mar 2026 16:07:14 +0100 Subject: [PATCH 18/43] record list removal --- backend/src/Entity/SubscriberListRemoval.php | 9 ++-- .../Subscriber/CreateSubscriberTest.php | 43 ++++++++----------- backend/tests/Factory/ApiKeyFactory.php | 10 ++--- backend/tests/Factory/ApprovalFactory.php | 6 +-- backend/tests/Factory/DomainFactory.php | 10 ++--- backend/tests/Factory/IssueFactory.php | 10 ++--- backend/tests/Factory/MediaFactory.php | 6 +-- backend/tests/Factory/NewsletterFactory.php | 10 ++--- .../tests/Factory/NewsletterListFactory.php | 15 +++---- backend/tests/Factory/SendFactory.php | 10 ++--- .../tests/Factory/SendingProfileFactory.php | 10 ++--- .../tests/Factory/SubscriberExportFactory.php | 15 +++---- backend/tests/Factory/SubscriberFactory.php | 6 +-- .../tests/Factory/SubscriberImportFactory.php | 10 ++--- .../Factory/SubscriberListRemovalFactory.php | 9 ++-- .../SubscriberMetadataDefinitionFactory.php | 8 ++-- backend/tests/Factory/TemplateFactory.php | 15 +++---- backend/tests/Factory/UserFactory.php | 15 +++---- backend/tests/Factory/UserInviteFactory.php | 15 +++---- 19 files changed, 99 insertions(+), 133 deletions(-) diff --git a/backend/src/Entity/SubscriberListRemoval.php b/backend/src/Entity/SubscriberListRemoval.php index e0caa8f8c..e00f87aeb 100644 --- a/backend/src/Entity/SubscriberListRemoval.php +++ b/backend/src/Entity/SubscriberListRemoval.php @@ -2,6 +2,7 @@ namespace App\Entity; +use App\Entity\Type\ListRemovalReason; use Doctrine\ORM\Mapping as ORM; #[ORM\Entity] @@ -22,8 +23,8 @@ class SubscriberListRemoval #[ORM\JoinColumn(name: 'subscriber_id', nullable: false, onDelete: 'CASCADE')] private Subscriber $subscriber; - #[ORM\Column(type: 'string')] - private string $reason; + #[ORM\Column(enumType: ListRemovalReason::class)] + private ListRemovalReason $reason; #[ORM\Column] private \DateTimeImmutable $created_at; @@ -60,12 +61,12 @@ public function setSubscriber(Subscriber $subscriber): static return $this; } - public function getReason(): string + public function getReason(): ListRemovalReason { return $this->reason; } - public function setReason(string $reason): static + public function setReason(ListRemovalReason $reason): static { $this->reason = $reason; return $this; diff --git a/backend/tests/Api/Console/Subscriber/CreateSubscriberTest.php b/backend/tests/Api/Console/Subscriber/CreateSubscriberTest.php index 60c6e17d8..a816c9bf2 100644 --- a/backend/tests/Api/Console/Subscriber/CreateSubscriberTest.php +++ b/backend/tests/Api/Console/Subscriber/CreateSubscriberTest.php @@ -362,7 +362,7 @@ public function test_records_list_removal(ListRemovalReason $reason): void foreach ([$list1, $list2] as $list) { $record = $repo->findOneBy(['list' => $list, 'subscriber' => $subscriber]); $this->assertInstanceOf(SubscriberListRemoval::class, $record); - $this->assertSame($reason->value, $record->getReason()); + $this->assertSame($reason, $record->getReason()); } } @@ -376,22 +376,12 @@ public function test_updates_list_removal(): void 'lists' => [$list], ]); - // First removal: UNSUBSCRIBE - $this->consoleApi($newsletter, 'POST', '/subscribers', [ - 'email' => $subscriber->getEmail(), - 'lists' => [], - 'lists_strategy' => 'overwrite', - 'list_removal_reason' => 'unsubscribe', - ]); - - // Re-add the list - $this->consoleApi($newsletter, 'POST', '/subscribers', [ - 'email' => $subscriber->getEmail(), - 'lists' => [$list->getId()], - 'lists_strategy' => 'overwrite', + $removal = SubscriberListRemovalFactory::createOne([ + 'subscriber' => $subscriber, + 'list' => $list, + 'reason' => ListRemovalReason::OTHER, ]); - // Second removal: BOUNCE $this->consoleApi($newsletter, 'POST', '/subscribers', [ 'email' => $subscriber->getEmail(), 'lists' => [], @@ -401,13 +391,14 @@ public function test_updates_list_removal(): void $this->assertResponseIsSuccessful(); - $records = $this->em->getRepository(SubscriberListRemoval::class)->findBy([ - 'list' => $list->_real(), - 'subscriber' => $subscriber->_real(), - ]); + $this->em->clear(); + $records = $this->em->getRepository(SubscriberListRemoval::class)->findAll(); $this->assertCount(1, $records); - $this->assertSame('bounce', $records[0]->getReason()); + $this->assertSame($removal->getId(), $records[0]->getId()); + $this->assertSame(ListRemovalReason::BOUNCE, $records[0]->getReason()); + $this->assertSame($subscriber->getId(), $records[0]->getSubscriber()->getId()); + $this->assertSame($list->getId(), $records[0]->getList()->getId()); } public function test_list_removal_make_sure_adding_lists_is_not_recorded(): void @@ -426,7 +417,7 @@ public function test_list_removal_make_sure_adding_lists_is_not_recorded(): void $this->assertResponseIsSuccessful(); $records = $this->em->getRepository(SubscriberListRemoval::class)->findBy([ - 'subscriber' => $subscriber->_real(), + 'subscriber' => $subscriber, ]); $this->assertCount(0, $records); } @@ -446,21 +437,21 @@ public function test_list_removal_with_strategy_remove(): void 'email' => $subscriber->getEmail(), 'lists' => [$list1->getId()], 'lists_strategy' => 'remove', - 'list_removal_reason' => 'unsubscribe', + 'list_removal_reason' => 'other', ]); $this->assertResponseIsSuccessful(); refresh($subscriber); $this->assertCount(1, $subscriber->getLists()); - $this->assertSame($list2->getId(), $subscriber->getLists()->first()->getId()); + $this->assertSame($list2->getId(), $subscriber->getLists()[0]?->getId()); $record = $this->em->getRepository(SubscriberListRemoval::class)->findOneBy([ - 'list' => $list1->_real(), - 'subscriber' => $subscriber->_real(), + 'list' => $list1, + 'subscriber' => $subscriber, ]); $this->assertInstanceOf(SubscriberListRemoval::class, $record); - $this->assertSame('unsubscribe', $record->getReason()); + $this->assertSame(ListRemovalReason::OTHER, $record->getReason()); } public function testCreateSubscriberWithListsById(): void diff --git a/backend/tests/Factory/ApiKeyFactory.php b/backend/tests/Factory/ApiKeyFactory.php index 85766d130..70d1405c7 100644 --- a/backend/tests/Factory/ApiKeyFactory.php +++ b/backend/tests/Factory/ApiKeyFactory.php @@ -4,12 +4,12 @@ use App\Api\Console\Authorization\Scope; use App\Entity\ApiKey; -use Zenstruck\Foundry\Persistence\PersistentProxyObjectFactory; +use Zenstruck\Foundry\Persistence\PersistentObjectFactory; /** - * @extends PersistentProxyObjectFactory + * @extends PersistentObjectFactory */ -final class ApiKeyFactory extends PersistentProxyObjectFactory +final class ApiKeyFactory extends PersistentObjectFactory { public function __construct() { @@ -34,8 +34,8 @@ protected function defaults(): array 'scopes' => [ ...array_map( fn(Scope $scope) => $scope->value, - Scope::cases() - ) + Scope::cases(), + ), ], 'is_enabled' => true, 'last_accessed_at' => null, diff --git a/backend/tests/Factory/ApprovalFactory.php b/backend/tests/Factory/ApprovalFactory.php index 82c295880..39e8b5b00 100644 --- a/backend/tests/Factory/ApprovalFactory.php +++ b/backend/tests/Factory/ApprovalFactory.php @@ -4,12 +4,12 @@ use App\Entity\Approval; use App\Entity\Type\ApprovalStatus; -use Zenstruck\Foundry\Persistence\PersistentProxyObjectFactory; +use Zenstruck\Foundry\Persistence\PersistentObjectFactory; /** - * @extends PersistentProxyObjectFactory + * @extends PersistentObjectFactory */ -final class ApprovalFactory extends PersistentProxyObjectFactory +final class ApprovalFactory extends PersistentObjectFactory { public function __construct() { diff --git a/backend/tests/Factory/DomainFactory.php b/backend/tests/Factory/DomainFactory.php index dce4535e1..1efbe5afc 100644 --- a/backend/tests/Factory/DomainFactory.php +++ b/backend/tests/Factory/DomainFactory.php @@ -4,21 +4,19 @@ use App\Entity\Domain; use App\Entity\Type\RelayDomainStatus; -use Zenstruck\Foundry\Persistence\PersistentProxyObjectFactory; +use Zenstruck\Foundry\Persistence\PersistentObjectFactory; /** - * @extends PersistentProxyObjectFactory + * @extends PersistentObjectFactory */ -final class DomainFactory extends PersistentProxyObjectFactory +final class DomainFactory extends PersistentObjectFactory { /** * @see https://symfony.com/bundles/ZenstruckFoundryBundle/current/index.html#factories-as-services * * @todo inject services if required */ - public function __construct() - { - } + public function __construct() {} public static function class(): string { diff --git a/backend/tests/Factory/IssueFactory.php b/backend/tests/Factory/IssueFactory.php index b70e2acd2..3da90685c 100644 --- a/backend/tests/Factory/IssueFactory.php +++ b/backend/tests/Factory/IssueFactory.php @@ -5,21 +5,19 @@ use App\Entity\Issue; use App\Entity\Type\IssueStatus; use Symfony\Component\Uid\Uuid; -use Zenstruck\Foundry\Persistence\PersistentProxyObjectFactory; +use Zenstruck\Foundry\Persistence\PersistentObjectFactory; /** - * @extends PersistentProxyObjectFactory + * @extends PersistentObjectFactory */ -final class IssueFactory extends PersistentProxyObjectFactory +final class IssueFactory extends PersistentObjectFactory { /** * @see https://symfony.com/bundles/ZenstruckFoundryBundle/current/index.html#factories-as-services * * @todo inject services if required */ - public function __construct() - { - } + public function __construct() {} public static function class(): string { diff --git a/backend/tests/Factory/MediaFactory.php b/backend/tests/Factory/MediaFactory.php index 0abf7bdf0..464680a17 100644 --- a/backend/tests/Factory/MediaFactory.php +++ b/backend/tests/Factory/MediaFactory.php @@ -4,12 +4,12 @@ use App\Entity\Media; use App\Entity\Type\MediaFolder; -use Zenstruck\Foundry\Persistence\PersistentProxyObjectFactory; +use Zenstruck\Foundry\Persistence\PersistentObjectFactory; /** - * @extends PersistentProxyObjectFactory + * @extends PersistentObjectFactory */ -final class MediaFactory extends PersistentProxyObjectFactory +final class MediaFactory extends PersistentObjectFactory { public static function class(): string diff --git a/backend/tests/Factory/NewsletterFactory.php b/backend/tests/Factory/NewsletterFactory.php index 408b13e85..e0c73bca5 100644 --- a/backend/tests/Factory/NewsletterFactory.php +++ b/backend/tests/Factory/NewsletterFactory.php @@ -4,21 +4,19 @@ use App\Entity\Meta\NewsletterMeta; use App\Entity\Newsletter; -use Zenstruck\Foundry\Persistence\PersistentProxyObjectFactory; +use Zenstruck\Foundry\Persistence\PersistentObjectFactory; /** - * @extends PersistentProxyObjectFactory + * @extends PersistentObjectFactory */ -final class NewsletterFactory extends PersistentProxyObjectFactory +final class NewsletterFactory extends PersistentObjectFactory { /** * @see https://symfony.com/bundles/ZenstruckFoundryBundle/current/index.html#factories-as-services * * @todo inject services if required */ - public function __construct() - { - } + public function __construct() {} public static function class(): string { diff --git a/backend/tests/Factory/NewsletterListFactory.php b/backend/tests/Factory/NewsletterListFactory.php index 6d94d596e..5304e9b4b 100644 --- a/backend/tests/Factory/NewsletterListFactory.php +++ b/backend/tests/Factory/NewsletterListFactory.php @@ -3,21 +3,19 @@ namespace App\Tests\Factory; use App\Entity\NewsletterList; -use Zenstruck\Foundry\Persistence\PersistentProxyObjectFactory; +use Zenstruck\Foundry\Persistence\PersistentObjectFactory; /** - * @extends PersistentProxyObjectFactory + * @extends PersistentObjectFactory */ -final class NewsletterListFactory extends PersistentProxyObjectFactory +final class NewsletterListFactory extends PersistentObjectFactory { /** * @see https://symfony.com/bundles/ZenstruckFoundryBundle/current/index.html#factories-as-services * * @todo inject services if required */ - public function __construct() - { - } + public function __construct() {} public static function class(): string { @@ -45,8 +43,7 @@ protected function defaults(): array */ protected function initialize(): static { - return $this - // ->afterInstantiate(function(NewsletterList $newsletterList): void {}) - ; + return $this// ->afterInstantiate(function(NewsletterList $newsletterList): void {}) + ; } } diff --git a/backend/tests/Factory/SendFactory.php b/backend/tests/Factory/SendFactory.php index 4fb9edfaf..0d9454499 100644 --- a/backend/tests/Factory/SendFactory.php +++ b/backend/tests/Factory/SendFactory.php @@ -5,21 +5,19 @@ use App\Entity\Send; use App\Entity\Type\IssueStatus; use App\Entity\Type\SendStatus; -use Zenstruck\Foundry\Persistence\PersistentProxyObjectFactory; +use Zenstruck\Foundry\Persistence\PersistentObjectFactory; /** - * @extends PersistentProxyObjectFactory + * @extends PersistentObjectFactory */ -final class SendFactory extends PersistentProxyObjectFactory +final class SendFactory extends PersistentObjectFactory { /** * @see https://symfony.com/bundles/ZenstruckFoundryBundle/current/index.html#factories-as-services * * @todo inject services if required */ - public function __construct() - { - } + public function __construct() {} public static function class(): string { diff --git a/backend/tests/Factory/SendingProfileFactory.php b/backend/tests/Factory/SendingProfileFactory.php index 0c128019f..99411e116 100644 --- a/backend/tests/Factory/SendingProfileFactory.php +++ b/backend/tests/Factory/SendingProfileFactory.php @@ -3,21 +3,19 @@ namespace App\Tests\Factory; use App\Entity\SendingProfile; -use Zenstruck\Foundry\Persistence\PersistentProxyObjectFactory; +use Zenstruck\Foundry\Persistence\PersistentObjectFactory; /** - * @extends PersistentProxyObjectFactory + * @extends PersistentObjectFactory */ -final class SendingProfileFactory extends PersistentProxyObjectFactory +final class SendingProfileFactory extends PersistentObjectFactory { /** * @see https://symfony.com/bundles/ZenstruckFoundryBundle/current/index.html#factories-as-services * * @todo inject services if required */ - public function __construct() - { - } + public function __construct() {} public static function class(): string { diff --git a/backend/tests/Factory/SubscriberExportFactory.php b/backend/tests/Factory/SubscriberExportFactory.php index 3317d34b6..27326be05 100644 --- a/backend/tests/Factory/SubscriberExportFactory.php +++ b/backend/tests/Factory/SubscriberExportFactory.php @@ -4,21 +4,19 @@ use App\Entity\SubscriberExport; use App\Entity\Type\SubscriberExportStatus; -use Zenstruck\Foundry\Persistence\PersistentProxyObjectFactory; +use Zenstruck\Foundry\Persistence\PersistentObjectFactory; /** - * @extends PersistentProxyObjectFactory + * @extends PersistentObjectFactory */ -final class SubscriberExportFactory extends PersistentProxyObjectFactory +final class SubscriberExportFactory extends PersistentObjectFactory { /** * @see https://symfony.com/bundles/ZenstruckFoundryBundle/current/index.html#factories-as-services * * @todo inject services if required */ - public function __construct() - { - } + public function __construct() {} public static function class(): string { @@ -45,8 +43,7 @@ protected function defaults(): array */ protected function initialize(): static { - return $this - // ->afterInstantiate(function(Domain $domain): void {}) - ; + return $this// ->afterInstantiate(function(Domain $domain): void {}) + ; } } diff --git a/backend/tests/Factory/SubscriberFactory.php b/backend/tests/Factory/SubscriberFactory.php index 45a657e49..809f194a3 100644 --- a/backend/tests/Factory/SubscriberFactory.php +++ b/backend/tests/Factory/SubscriberFactory.php @@ -5,12 +5,12 @@ use App\Entity\Subscriber; use App\Entity\Type\SubscriberSource; use App\Entity\Type\SubscriberStatus; -use Zenstruck\Foundry\Persistence\PersistentProxyObjectFactory; +use Zenstruck\Foundry\Persistence\PersistentObjectFactory; /** - * @extends PersistentProxyObjectFactory + * @extends PersistentObjectFactory */ -final class SubscriberFactory extends PersistentProxyObjectFactory +final class SubscriberFactory extends PersistentObjectFactory { /** * @see https://symfony.com/bundles/ZenstruckFoundryBundle/current/index.html#factories-as-services diff --git a/backend/tests/Factory/SubscriberImportFactory.php b/backend/tests/Factory/SubscriberImportFactory.php index 3beabedd6..febd31a1a 100644 --- a/backend/tests/Factory/SubscriberImportFactory.php +++ b/backend/tests/Factory/SubscriberImportFactory.php @@ -4,21 +4,19 @@ use App\Entity\SubscriberImport; use App\Entity\Type\SubscriberImportStatus; -use Zenstruck\Foundry\Persistence\PersistentProxyObjectFactory; +use Zenstruck\Foundry\Persistence\PersistentObjectFactory; /** - * @extends PersistentProxyObjectFactory + * @extends PersistentObjectFactory */ -final class SubscriberImportFactory extends PersistentProxyObjectFactory +final class SubscriberImportFactory extends PersistentObjectFactory { /** * @see https://symfony.com/bundles/ZenstruckFoundryBundle/current/index.html#factories-as-services * * @todo inject services if required */ - public function __construct() - { - } + public function __construct() {} public static function class(): string { diff --git a/backend/tests/Factory/SubscriberListRemovalFactory.php b/backend/tests/Factory/SubscriberListRemovalFactory.php index a18a877d8..f56a97058 100644 --- a/backend/tests/Factory/SubscriberListRemovalFactory.php +++ b/backend/tests/Factory/SubscriberListRemovalFactory.php @@ -3,12 +3,13 @@ namespace App\Tests\Factory; use App\Entity\SubscriberListRemoval; -use Zenstruck\Foundry\Persistence\PersistentProxyObjectFactory; +use App\Entity\Type\ListRemovalReason; +use Zenstruck\Foundry\Persistence\PersistentObjectFactory; /** - * @extends PersistentProxyObjectFactory + * @extends PersistentObjectFactory */ -final class SubscriberListRemovalFactory extends PersistentProxyObjectFactory +final class SubscriberListRemovalFactory extends PersistentObjectFactory { public function __construct() {} @@ -25,7 +26,7 @@ protected function defaults(): array return [ 'list' => NewsletterListFactory::new(), 'subscriber' => SubscriberFactory::new(), - 'reason' => self::faker()->randomElement(['unsubscribe', 'bounce', 'other']), + 'reason' => self::faker()->randomElement(ListRemovalReason::cases()), 'created_at' => \DateTimeImmutable::createFromMutable(self::faker()->dateTime()), ]; } diff --git a/backend/tests/Factory/SubscriberMetadataDefinitionFactory.php b/backend/tests/Factory/SubscriberMetadataDefinitionFactory.php index cdedcc871..86b5271f7 100644 --- a/backend/tests/Factory/SubscriberMetadataDefinitionFactory.php +++ b/backend/tests/Factory/SubscriberMetadataDefinitionFactory.php @@ -4,12 +4,12 @@ use App\Entity\SubscriberMetadataDefinition; use App\Entity\Type\SubscriberMetadataDefinitionType; -use Zenstruck\Foundry\Persistence\PersistentProxyObjectFactory; +use Zenstruck\Foundry\Persistence\PersistentObjectFactory; /** - * @extends PersistentProxyObjectFactory + * @extends PersistentObjectFactory */ -final class SubscriberMetadataDefinitionFactory extends PersistentProxyObjectFactory +final class SubscriberMetadataDefinitionFactory extends PersistentObjectFactory { public static function class(): string @@ -34,4 +34,4 @@ protected function defaults(): array ]; } -} \ No newline at end of file +} diff --git a/backend/tests/Factory/TemplateFactory.php b/backend/tests/Factory/TemplateFactory.php index d0258863f..538af4524 100644 --- a/backend/tests/Factory/TemplateFactory.php +++ b/backend/tests/Factory/TemplateFactory.php @@ -6,21 +6,19 @@ use App\Entity\Template; use App\Entity\Type\IssueStatus; use App\Entity\Type\SendStatus; -use Zenstruck\Foundry\Persistence\PersistentProxyObjectFactory; +use Zenstruck\Foundry\Persistence\PersistentObjectFactory; /** - * @extends PersistentProxyObjectFactory