From 953a7cc8fd12ddd748f8811640a3d8e28dff4bb2 Mon Sep 17 00:00:00 2001
From: Supun Wimalasena
Date: Wed, 25 Feb 2026 13:26:41 +0100
Subject: [PATCH 01/23] 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 ab10addf..5ac8b905 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 df801584..dab13e07 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 f3c7c32d..958db24c 100644
--- a/backend/src/Api/Console/Controller/IssueController.php
+++ b/backend/src/Api/Console/Controller/IssueController.php
@@ -96,15 +96,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
@@ -117,7 +117,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 bdb08449..d16eec6e 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 793d5485..4fd7cfd5 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 12edad36..9163a7e1 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 00000000..4d87ad75
--- /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 f7c1de7a..fc3c529f 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 f002bc13..718f84b3 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 953ff277..dfb96856 100644
--- a/backend/src/Service/Issue/IssueService.php
+++ b/backend/src/Service/Issue/IssueService.php
@@ -58,43 +58,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 eec3c36a..17595803 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 a14e5bee..790c913f 100644
--- a/backend/src/Service/Newsletter/NewsletterService.php
+++ b/backend/src/Service/Newsletter/NewsletterService.php
@@ -266,11 +266,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);
@@ -281,11 +281,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 a6935eea..4e6612a1 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 f670aaf4..dcc4f51d 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 1cc62b9e..43d33945 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 49e9b6d3..235098b1 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 49175041..3c39f721 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 15d3d2ad..d47c2157 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 84fe2b97..50bb42c6 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 a8060cf15032bb6576f672900f2966e1a2f7d07e Mon Sep 17 00:00:00 2001
From: Supun Wimalasena
Date: Wed, 25 Feb 2026 15:38:13 +0100
Subject: [PATCH 02/23] side project: sentry config
---
backend/composer.json | 3 +-
backend/composer.lock | 343 +++++++++++++++++++++++++++-
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, 479 insertions(+), 3 deletions(-)
create mode 100644 backend/config/packages/sentry.yaml
diff --git a/backend/composer.json b/backend/composer.json
index 40bf0f2f..f109f740 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 cec1baf5..19eb728c 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": "cdd012dd5bf3ebdc6cb6a7c0079c7639",
+ "content-hash": "84ff26b07990da9b940ce6cb14f5bcb2",
"packages": [
{
"name": "aws/aws-crt-php",
@@ -2511,6 +2511,66 @@
},
"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.31.0",
@@ -4067,6 +4127,199 @@
],
"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.5",
@@ -7590,6 +7843,94 @@
],
"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.5",
diff --git a/backend/config/bundles.php b/backend/config/bundles.php
index 0e09a636..2db7f272 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 1c667be4..fb0a8726 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 00000000..81ad7b5c
--- /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 61e9ec8e..a97740a1 100644
--- a/backend/config/reference.php
+++ b/backend/config/reference.php
@@ -1316,6 +1316,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,
@@ -1331,6 +1410,7 @@
* twig_component?: TwigComponentConfig,
* twig_extra?: TwigExtraConfig,
* zenstruck_messenger_monitor?: ZenstruckMessengerMonitorConfig,
+ * sentry?: SentryConfig,
* "when@dev"?: array{
* imports?: ImportsConfig,
* parameters?: ParametersConfig,
@@ -1349,6 +1429,7 @@
* twig_component?: TwigComponentConfig,
* twig_extra?: TwigExtraConfig,
* zenstruck_messenger_monitor?: ZenstruckMessengerMonitorConfig,
+ * sentry?: SentryConfig,
* },
* "when@prod"?: array{
* imports?: ImportsConfig,
@@ -1365,6 +1446,7 @@
* twig_component?: TwigComponentConfig,
* twig_extra?: TwigExtraConfig,
* zenstruck_messenger_monitor?: ZenstruckMessengerMonitorConfig,
+ * sentry?: SentryConfig,
* },
* "when@test"?: array{
* imports?: ImportsConfig,
@@ -1384,6 +1466,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/23] 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 44f99e24..1926fa02 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
@@ -84,7 +81,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
@@ -97,6 +93,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 ============ ###
@@ -104,7 +104,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 05abde28..67fd15a8 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 a97740a1..36bb2776 100644
--- a/backend/config/reference.php
+++ b/backend/config/reference.php
@@ -1410,7 +1410,6 @@
* twig_component?: TwigComponentConfig,
* twig_extra?: TwigExtraConfig,
* zenstruck_messenger_monitor?: ZenstruckMessengerMonitorConfig,
- * sentry?: SentryConfig,
* "when@dev"?: array{
* imports?: ImportsConfig,
* parameters?: ParametersConfig,
@@ -1429,7 +1428,6 @@
* twig_component?: TwigComponentConfig,
* twig_extra?: TwigExtraConfig,
* zenstruck_messenger_monitor?: ZenstruckMessengerMonitorConfig,
- * sentry?: SentryConfig,
* },
* "when@prod"?: array{
* imports?: ImportsConfig,
@@ -1466,7 +1464,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 9163a7e1..29d76aa7 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 00000000..db45af81
--- /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 4e6612a1..fc2b7c11 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 43d33945..1ff7528f 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 00000000..84e16ba2
--- /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 d47c2157..b29d266c 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 00000000..d19db0d1
--- /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 00000000..ee3f0660
--- /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 9d882bc39e1d673cb60a96ddc1593b820174d302 Mon Sep 17 00:00:00 2001
From: Supun Wimalasena
Date: Thu, 26 Feb 2026 10:48:25 +0100
Subject: [PATCH 04/23] 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 29d76aa7..6a3a0e36 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 04f8c890..3ca8ba40 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 e2b9014d..f4958852 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 d0e1beee..997d7d73 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 f4b758fa..d42fd0f8 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 1ff7528f..cc301f55 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 b29d266c..83f1dc10 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 6056b32f..f6053e8b 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 e2a890ab..a87ca52f 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
+
+
+
From 68fb6ec3269473add806f03fb6036e3fe899b18c Mon Sep 17 00:00:00 2001
From: Supun Wimalasena
Date: Thu, 26 Feb 2026 14:35:36 +0100
Subject: [PATCH 05/23] 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 6a3a0e36..f8bfc4f3 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 d42fd0f8..2b3fc895 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 cc301f55..089acdf2 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 83f1dc10..a215f2ce 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 bb195f199e1779357773d64df358fd063ab0d438 Mon Sep 17 00:00:00 2001
From: Supun Wimalasena
Date: Thu, 26 Feb 2026 14:44:45 +0100
Subject: [PATCH 06/23] 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 81ad7b5c..23a0dd9e 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 eb4e78cc..efcf1015 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 17e762908e5cba801b81e96c7243d62a74dba572 Mon Sep 17 00:00:00 2001
From: Supun Wimalasena
Date: Fri, 27 Feb 2026 12:37:25 +0100
Subject: [PATCH 07/23] wip
---
.../Controller/SubscriberController.php | 18 ++++---
.../Public/Controller/Form/FormController.php | 37 +++++++------
backend/src/Command/Dev/DevSeedCommand.php | 2 +-
.../Service/Subscriber/SubscriberService.php | 53 ++++++++++++++-----
4 files changed, 73 insertions(+), 37 deletions(-)
diff --git a/backend/src/Api/Console/Controller/SubscriberController.php b/backend/src/Api/Console/Controller/SubscriberController.php
index f8bfc4f3..5ccd9744 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 997d7d73..4c6cb44a 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/Command/Dev/DevSeedCommand.php b/backend/src/Command/Dev/DevSeedCommand.php
index 52a1ed4b..ff86be9a 100644
--- a/backend/src/Command/Dev/DevSeedCommand.php
+++ b/backend/src/Command/Dev/DevSeedCommand.php
@@ -28,7 +28,7 @@
* @codeCoverageIgnore
*/
#[AsCommand(
- name: 'app:dev:seed',
+ name: 'dev:seed',
description: 'Seeds the database with test data for development purposes.'
)]
class DevSeedCommand extends Command
diff --git a/backend/src/Service/Subscriber/SubscriberService.php b/backend/src/Service/Subscriber/SubscriberService.php
index 089acdf2..6fac24b6 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 9a90b95d222f018291e87600836d644a3b7fe6b4 Mon Sep 17 00:00:00 2001
From: Supun Wimalasena
Date: Mon, 2 Mar 2026 16:03:21 +0100
Subject: [PATCH 08/23] 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 db45af81..00000000
--- 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 84e16ba2..00000000
--- 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 d19db0d1..00000000
--- 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 f6053e8b..00000000
--- 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 0052ec7ea13533fb03b6f444df761fb1ec24b54f Mon Sep 17 00:00:00 2001
From: Supun Wimalasena
Date: Mon, 2 Mar 2026 16:03:37 +0100
Subject: [PATCH 09/23] 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 a87ca52f..01971edc 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
-
-
-
From 9a43ad471d45b95e4876b647aca752b8580159af Mon Sep 17 00:00:00 2001
From: Supun Wimalasena
Date: Tue, 3 Mar 2026 09:33:19 +0100
Subject: [PATCH 10/23] 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 5ccd9744..515ab276 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 3ca8ba40..ce36f698 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 00000000..78f12fb2
--- /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 6fac24b6..1c1128fd 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 a215f2ce..53e1dc12 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 01971edc..171f38bc 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 bc4c675318a56fe919e970845241547ccee2843d Mon Sep 17 00:00:00 2001
From: Supun Wimalasena
Date: Tue, 3 Mar 2026 13:11:57 +0100
Subject: [PATCH 11/23] input planned
---
backend/config/reference.php | 125 ++++--------------
.../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, 212 insertions(+), 186 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 36bb2776..06bd1875 100644
--- a/backend/config/reference.php
+++ b/backend/config/reference.php
@@ -208,29 +208,29 @@
* initial_marking?: list,
* events_to_dispatch?: list|null,
* places?: list,
+ * name?: scalar|Param|null,
+ * metadata?: array,
* }>,
- * transitions: list,
* to?: list,
* weight?: int|Param, // Default: 1
- * metadata?: list,
+ * metadata?: array,
* }>,
- * metadata?: list,
+ * metadata?: array,
* }>,
* },
* router?: bool|array{ // Router configuration
* enabled?: bool|Param, // Default: false
- * resource: scalar|Param|null,
+ * resource?: scalar|Param|null,
* type?: scalar|Param|null,
* cache_dir?: scalar|Param|null, // Deprecated: Setting the "framework.router.cache_dir.cache_dir" configuration option is deprecated. It will be removed in version 8.0. // Default: "%kernel.build_dir%"
* default_uri?: scalar|Param|null, // The default URI used to generate URLs in a non-HTTP context. // Default: null
@@ -360,10 +360,10 @@
* mapping?: array{
* paths?: list,
* },
- * default_context?: list,
+ * default_context?: array,
* named_serializers?: array,
+ * default_context?: array,
* include_built_in_normalizers?: bool|Param, // Whether to include the built-in normalizers // Default: true
* include_built_in_encoders?: bool|Param, // Whether to include the built-in encoders // Default: true
* }>,
@@ -427,7 +427,7 @@
* },
* messenger?: bool|array{ // Messenger configuration
* enabled?: bool|Param, // Default: true
- * routing?: array,
* }>,
* serializer?: array{
@@ -440,7 +440,7 @@
* transports?: array,
+ * options?: array,
* failure_transport?: scalar|Param|null, // Transport name to send failed messages to (after all retries have failed). // Default: null
* retry_strategy?: string|array{
* service?: scalar|Param|null, // Service id to override the retry strategy entirely. // Default: null
@@ -462,7 +462,7 @@
* allow_no_senders?: bool|Param, // Default: true
* },
* middleware?: list,
* }>,
* }>,
@@ -634,7 +634,7 @@
* lock_factory?: scalar|Param|null, // The service ID of the lock factory used by this limiter (or null to disable locking). // Default: "auto"
* cache_pool?: scalar|Param|null, // The cache pool to use for storing the current limiter state. // Default: "cache.rate_limiter"
* storage_service?: scalar|Param|null, // The service ID of a custom storage implementation, this precedes any configured "cache_pool". // Default: null
- * policy: "fixed_window"|"token_bucket"|"sliding_window"|"compound"|"no_limit"|Param, // The algorithm to be used by this limiter.
+ * policy?: "fixed_window"|"token_bucket"|"sliding_window"|"compound"|"no_limit"|Param, // The algorithm to be used by this limiter.
* limiters?: list,
* limit?: int|Param, // The maximum allowed hits in a fixed interval or burst.
* interval?: scalar|Param|null, // Configures the fixed interval if "policy" is set to "fixed_window" or "sliding_window". The value must be a number followed by "second", "minute", "hour", "day", "week" or "month" (or their plural equivalent).
@@ -679,7 +679,7 @@
* enabled?: bool|Param, // Default: false
* message_bus?: scalar|Param|null, // The message bus to use. // Default: "messenger.default_bus"
* routing?: array,
* },
@@ -694,7 +694,7 @@
* dbal?: array{
* default_connection?: scalar|Param|null,
* types?: array,
* driver_schemes?: array,
@@ -910,7 +910,7 @@
* datetime_functions?: array,
* },
* filters?: array,
* }>,
@@ -1045,7 +1045,7 @@
* use_microseconds?: scalar|Param|null, // Default: true
* channels?: list,
* handlers?: array,
* mailer?: scalar|Param|null, // Default: null
* email_prototype?: string|array{
- * id: scalar|Param|null,
+ * id?: scalar|Param|null,
* method?: scalar|Param|null, // Default: null
* },
* lazy?: bool|Param, // Default: true
@@ -1316,85 +1316,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,
@@ -1444,7 +1365,6 @@
* twig_component?: TwigComponentConfig,
* twig_extra?: TwigExtraConfig,
* zenstruck_messenger_monitor?: ZenstruckMessengerMonitorConfig,
- * sentry?: SentryConfig,
* },
* "when@test"?: array{
* imports?: ImportsConfig,
@@ -1482,7 +1402,10 @@ final class App
*/
public static function config(array $config): array
{
- return AppReference::config($config);
+ /** @var ConfigType $config */
+ $config = AppReference::config($config);
+
+ return $config;
}
}
diff --git a/backend/src/Api/Console/Controller/SubscriberController.php b/backend/src/Api/Console/Controller/SubscriberController.php
index 515ab276..6a4ae212 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 ce36f698..db4dc0e0 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 78f12fb2..00000000
--- 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 1c1128fd..e77a425c 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 53e1dc12..ed623025 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 171f38bc..988a9f6c 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 6de795796973cc4b11a46f0563c44fd0d73e223d Mon Sep 17 00:00:00 2001
From: Supun Wimalasena
Date: Tue, 3 Mar 2026 13:37:51 +0100
Subject: [PATCH 12/23] 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 6a4ae212..9a3f15bb 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 db4dc0e0..13eb6d3e 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 9b687d25..512d3225 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 1c32d935..1ad82836 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 e77a425c..8e0e6068 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 00000000..ea59983b
--- /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 ed623025..6c6fd0b9 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 1f1a437d08b967b7ea5736a178a3dcbe06685002 Mon Sep 17 00:00:00 2001
From: Supun Wimalasena
Date: Tue, 3 Mar 2026 14:54:41 +0100
Subject: [PATCH 13/23] 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 9a3f15bb..58e1e77a 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 13eb6d3e..fdca526f 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 daba7352..efc944f5 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 512d3225..e92dec88 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 d1237c65..8f5394e9 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 2b3fc895..b010fe5e 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 8e0e6068..b05a4d8a 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 6c6fd0b9..96c391bc 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 2d8f36ee..45a657e4 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 988a9f6c..8ec62e21 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 3bf8855356fa1936759b7daec4dc2b38980ffba9 Mon Sep 17 00:00:00 2001
From: Supun Wimalasena
Date: Tue, 3 Mar 2026 15:15:09 +0100
Subject: [PATCH 14/23] 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 fff68152..5078457b 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 fdca526f..34dab027 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 27e35a08..fdb239f7 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 e92dec88..58278234 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 efcf1015..e0caa8f8 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 207d7d4a..e4356887 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 b05a4d8a..1eb3b4ec 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 96c391bc..c47a6901 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 ee3f0660..a18a877d 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 8ec62e21..a6973e14 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 1020efda5e4e86ba23db0bef144f258878c356c2 Mon Sep 17 00:00:00 2001
From: Supun Wimalasena
Date: Tue, 3 Mar 2026 15:20:06 +0100
Subject: [PATCH 15/23] 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 395ab687..f0d9907e 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 c47a6901..bf782b0f 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 b010c7e9378f288f23faba86840ce15411b76174 Mon Sep 17 00:00:00 2001
From: Supun Wimalasena
Date: Tue, 3 Mar 2026 15:40:06 +0100
Subject: [PATCH 16/23] 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 f0d9907e..f78ab676 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 34dab027..54fc4bab 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 c92c6633..00000000
--- 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 00000000..5c3197ed
--- /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 00000000..4585be77
--- /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 1eb3b4ec..7365db11 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 bf782b0f..c862f2e5 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 a6973e14..a30a6601 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 8564977475b1b70471cb5967ae041abb8eef5a36 Mon Sep 17 00:00:00 2001
From: Supun Wimalasena
Date: Tue, 3 Mar 2026 15:58:20 +0100
Subject: [PATCH 17/23] 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 00000000..7759fe50
--- /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 0e84ed5c..00000000
--- 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 00000000..3d5e5e4e
--- /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 b471182e..00000000
--- 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 7365db11..23d05e72 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 00000000..2e32289c
--- /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 c862f2e5..60c6e17d 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 55af18c1..5cd50234 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;
@@ -65,7 +65,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 4cdc90b73a0e51d1ff4ba6796f6f6dfc55627182 Mon Sep 17 00:00:00 2001
From: Supun Wimalasena
Date: Tue, 3 Mar 2026 16:07:14 +0100
Subject: [PATCH 18/23] 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 e0caa8f8..e00f87ae 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 60c6e17d..a816c9bf 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 85766d13..70d1405c 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 82c29588..39e8b5b0 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 dce4535e..1efbe5af 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 b70e2acd..3da90685 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 0abf7bdf..464680a1 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 c2bdebda..dd302db7 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 6d94d596..5304e9b4 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 4fb9edfa..0d945449 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 0c128019..99411e11 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 3317d34b..27326be0 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 45a657e4..809f194a 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 3beabedd..febd31a1 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 a18a877d..f56a9705 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 cdedcc87..86b5271f 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 d0258863..538af452 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
+ * @extends PersistentObjectFactory
*/
-final class TemplateFactory extends PersistentProxyObjectFactory
+final class TemplateFactory 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
{
@@ -46,8 +44,7 @@ protected function defaults(): array
*/
protected function initialize(): static
{
- return $this
- // ->afterInstantiate(function(Send $issue): void {})
- ;
+ return $this// ->afterInstantiate(function(Send $issue): void {})
+ ;
}
}
diff --git a/backend/tests/Factory/UserFactory.php b/backend/tests/Factory/UserFactory.php
index 60e57c45..02baec16 100644
--- a/backend/tests/Factory/UserFactory.php
+++ b/backend/tests/Factory/UserFactory.php
@@ -4,21 +4,19 @@
use App\Entity\Type\UserRole;
use App\Entity\User;
-use Zenstruck\Foundry\Persistence\PersistentProxyObjectFactory;
+use Zenstruck\Foundry\Persistence\PersistentObjectFactory;
/**
- * @extends PersistentProxyObjectFactory
+ * @extends PersistentObjectFactory
*/
-final class UserFactory extends PersistentProxyObjectFactory
+final class UserFactory 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/UserInviteFactory.php b/backend/tests/Factory/UserInviteFactory.php
index 7d36ca35..8f24be20 100644
--- a/backend/tests/Factory/UserInviteFactory.php
+++ b/backend/tests/Factory/UserInviteFactory.php
@@ -5,21 +5,19 @@
use App\Entity\Type\UserRole;
use App\Entity\User;
use App\Entity\UserInvite;
-use Zenstruck\Foundry\Persistence\PersistentProxyObjectFactory;
+use Zenstruck\Foundry\Persistence\PersistentObjectFactory;
/**
- * @extends PersistentProxyObjectFactory
+ * @extends PersistentObjectFactory
*/
-final class UserInviteFactory extends PersistentProxyObjectFactory
+final class UserInviteFactory 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
{
@@ -48,8 +46,7 @@ protected function defaults(): array
*/
protected function initialize(): static
{
- return $this
- // ->afterInstantiate(function(Domain $domain): void {})
- ;
+ return $this// ->afterInstantiate(function(Domain $domain): void {})
+ ;
}
}
From 02f3def87454c12dab3360ec9a7bc60a092e98a6 Mon Sep 17 00:00:00 2001
From: Supun Wimalasena
Date: Tue, 3 Mar 2026 17:09:02 +0100
Subject: [PATCH 19/23] tests wiop
---
.../Controller/SubscriberController.php | 41 ++
.../Subscriber/CreateSubscriberInput.php | 7 +-
.../Event/SubscriberUpdatedEvent.php | 7 +
.../ListRemoval/ListRemovalListener.php | 11 +
.../ListRemoval/ListRemovalService.php | 42 ++
.../Service/Subscriber/SubscriberService.php | 3 +
.../Subscriber/CreateSubscriberTest.php | 429 ++++--------------
backend/tests/Case/WebTestCase.php | 63 ++-
8 files changed, 225 insertions(+), 378 deletions(-)
create mode 100644 backend/src/Service/Subscriber/ListRemoval/ListRemovalService.php
diff --git a/backend/src/Api/Console/Controller/SubscriberController.php b/backend/src/Api/Console/Controller/SubscriberController.php
index f78ab676..0d0e13df 100644
--- a/backend/src/Api/Console/Controller/SubscriberController.php
+++ b/backend/src/Api/Console/Controller/SubscriberController.php
@@ -12,10 +12,12 @@
use App\Entity\Newsletter;
use App\Entity\NewsletterList;
use App\Entity\Subscriber;
+use App\Entity\Type\ListRemovalReason;
use App\Entity\Type\SubscriberSource;
use App\Entity\Type\SubscriberStatus;
use App\Service\NewsletterList\NewsletterListService;
use App\Service\Subscriber\Dto\UpdateSubscriberDto;
+use App\Service\Subscriber\ListRemoval\ListRemovalService;
use App\Service\Subscriber\SubscriberService;
use App\Service\SubscriberMetadata\Exception\MetadataValidationFailedException;
use App\Service\SubscriberMetadata\SubscriberMetadataService;
@@ -34,6 +36,7 @@ public function __construct(
private SubscriberService $subscriberService,
private NewsletterListService $newsletterListService,
private SubscriberMetadataService $subscriberMetadataService,
+ private ListRemovalService $listRemovalService,
) {}
#[Route('/subscribers', methods: 'GET')]
@@ -149,12 +152,19 @@ public function createSubscriber(
} elseif ($input->lists_strategy === ListsStrategy::OVERWRITE) {
$newLists = $resolvedLists;
} else {
+ // remove
$newLists = array_filter(
$newLists,
fn($l) => !array_find($resolvedLists, fn($rl) => $rl->getId() === $l->getId()),
);
}
+ $newLists = $this->skipLists(
+ $subscriber,
+ $newLists,
+ $input->getListSkipResubscribeOn(),
+ );
+
$updates->lists = $newLists;
}
@@ -162,6 +172,7 @@ public function createSubscriber(
$subscriber,
$updates,
listRemovalReason: $input->list_removal_reason,
+ sendConfirmationEmail: $input->send_pending_confirmation_email,
);
}
@@ -220,6 +231,36 @@ private function resolveLists(Newsletter $newsletter, array $listIdsOrNames): ar
return $resolvedLists;
}
+ /**
+ * @param Subscriber $subscriber
+ * @param NewsletterList[] $lists
+ * @param ListRemovalReason[] $reasonsToSkip
+ * @return NewsletterList[]
+ */
+ private function skipLists(Subscriber $subscriber, array $lists, array $reasonsToSkip): array
+ {
+ $newlyAddedLists = [];
+
+ foreach ($lists as $list) {
+ if (!$subscriber->getLists()->contains($list)) {
+ $newlyAddedLists[] = $list;
+ }
+ }
+
+ if (count($newlyAddedLists) === 0) {
+ return $lists;
+ }
+
+ $newlyAddedListIds = array_map(fn($l) => $l->getId(), $newlyAddedLists);
+
+ $removals = $this->listRemovalService->getRemovals($subscriber, $newlyAddedListIds, $reasonsToSkip);
+
+ return array_filter(
+ $lists,
+ fn($list) => !array_find($removals, fn($r) => $r->getList()->getId() === $list->getId()),
+ );
+ }
+
#[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 54fc4bab..b27236f0 100644
--- a/backend/src/Api/Console/Input/Subscriber/CreateSubscriberInput.php
+++ b/backend/src/Api/Console/Input/Subscriber/CreateSubscriberInput.php
@@ -5,11 +5,9 @@
use App\Entity\Type\ListRemovalReason;
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
{
@@ -50,7 +48,7 @@ class CreateSubscriberInput
* @var string[]
*/
#[Assert\All(new Assert\Choice(callback: 'getListResubscribeOnValues'))]
- private array $list_skip_resubscribe_on = ['unsubscribe', 'bounce', 'complaint'];
+ public array $list_skip_resubscribe_on = ['unsubscribe', 'bounce', 'complaint'];
public ListRemovalReason $list_removal_reason = ListRemovalReason::UNSUBSCRIBE;
@@ -74,8 +72,7 @@ public function getSubscribedAt(): ?\DateTimeImmutable
*/
public function getListSkipResubscribeOn(): array
{
- $listSkipResubscribeOn = $this->has('list_skip_resubscribe_on') ? $this->list_skip_resubscribe_on : [];
- return array_map(fn($item) => ListRemovalReason::from($item), $listSkipResubscribeOn);
+ return array_map(fn($item) => ListRemovalReason::from($item), $this->list_skip_resubscribe_on);
}
/**
diff --git a/backend/src/Service/Subscriber/Event/SubscriberUpdatedEvent.php b/backend/src/Service/Subscriber/Event/SubscriberUpdatedEvent.php
index cd25e1a3..2731331e 100644
--- a/backend/src/Service/Subscriber/Event/SubscriberUpdatedEvent.php
+++ b/backend/src/Service/Subscriber/Event/SubscriberUpdatedEvent.php
@@ -10,6 +10,8 @@
public function __construct(
private Subscriber $subscriberOld,
private Subscriber $subscriber,
+ // whether to send confirmation email if status changed to pending
+ private bool $sendConfirmationEmail,
) {}
public function getSubscriber(): Subscriber
@@ -22,4 +24,9 @@ public function getSubscriberOld(): Subscriber
return $this->subscriberOld;
}
+ public function shouldSendConfirmationEmail(): bool
+ {
+ return $this->sendConfirmationEmail;
+ }
+
}
diff --git a/backend/src/Service/Subscriber/ListRemoval/ListRemovalListener.php b/backend/src/Service/Subscriber/ListRemoval/ListRemovalListener.php
index 4585be77..808a82e3 100644
--- a/backend/src/Service/Subscriber/ListRemoval/ListRemovalListener.php
+++ b/backend/src/Service/Subscriber/ListRemoval/ListRemovalListener.php
@@ -18,6 +18,17 @@ public function __construct(
#[AsEventListener()]
public function onSubscriberUpdating(SubscriberUpdatingEvent $event): void
+ {
+ $this->skipRemoved($event);
+ $this->recordRemoving($event);
+ }
+
+ private function skipRemoved(SubscriberUpdatingEvent $event): void
+ {
+ //
+ }
+
+ private function recordRemoving(SubscriberUpdatingEvent $event): void
{
$oldListIds = $event->getSubscriberOld()->getLists()->map(fn($list) => $list->getId())->toArray();
$newListIds = $event->getSubscriber()->getLists()->map(fn($list) => $list->getId())->toArray();
diff --git a/backend/src/Service/Subscriber/ListRemoval/ListRemovalService.php b/backend/src/Service/Subscriber/ListRemoval/ListRemovalService.php
new file mode 100644
index 00000000..b075b2cd
--- /dev/null
+++ b/backend/src/Service/Subscriber/ListRemoval/ListRemovalService.php
@@ -0,0 +1,42 @@
+em->createQueryBuilder();
+ $qb
+ ->select('r')
+ ->from(SubscriberListRemoval::class, 'r')
+ ->where('r.subscriber = :subscriber')
+ ->andWhere($qb->expr()->in('r.list', ':listIds'))
+ ->andWhere($qb->expr()->in('r.reason', ':reasons'))
+ ->setParameter('subscriber', $subscriber)
+ ->setParameter('listIds', $listIds)
+ ->setParameter('reasons', $reasons);
+
+ /** @var SubscriberListRemoval[] */
+ return $qb->getQuery()->getResult();
+ }
+
+}
diff --git a/backend/src/Service/Subscriber/SubscriberService.php b/backend/src/Service/Subscriber/SubscriberService.php
index 23d05e72..4d83adb9 100644
--- a/backend/src/Service/Subscriber/SubscriberService.php
+++ b/backend/src/Service/Subscriber/SubscriberService.php
@@ -158,6 +158,8 @@ public function updateSubscriber(
// if some lists are being removed, set the reason to correctly record
// it in ListRemovalListener
ListRemovalReason $listRemovalReason = ListRemovalReason::UNSUBSCRIBE,
+ // whether to send the confirmation email if the status was changed to "pending"
+ bool $sendConfirmationEmail = false,
): Subscriber {
$subscriberOld = clone $subscriber;
@@ -211,6 +213,7 @@ public function updateSubscriber(
new SubscriberUpdatedEvent(
$subscriberOld,
$subscriber,
+ $sendConfirmationEmail,
),
);
diff --git a/backend/tests/Api/Console/Subscriber/CreateSubscriberTest.php b/backend/tests/Api/Console/Subscriber/CreateSubscriberTest.php
index a816c9bf..03500e2c 100644
--- a/backend/tests/Api/Console/Subscriber/CreateSubscriberTest.php
+++ b/backend/tests/Api/Console/Subscriber/CreateSubscriberTest.php
@@ -3,6 +3,7 @@
namespace App\Tests\Api\Console\Subscriber;
use App\Api\Console\Controller\SubscriberController;
+use App\Api\Console\Input\Subscriber\CreateSubscriberInput;
use App\Entity\Newsletter;
use App\Entity\Subscriber;
use App\Entity\SubscriberListRemoval;
@@ -15,7 +16,7 @@
use App\Service\Subscriber\Event\SubscriberUpdatedEvent;
use App\Service\Subscriber\Event\SubscriberUpdatingEvent;
use App\Service\Subscriber\ListRemoval\ListRemovalListener;
-use App\Service\Subscriber\Message\SendConfirmationEmailMessage;
+use App\Service\Subscriber\ListRemoval\ListRemovalService;
use App\Service\Subscriber\SubscriberService;
use App\Tests\Case\WebTestCase;
use App\Tests\Factory\NewsletterFactory;
@@ -36,6 +37,8 @@
#[CoversClass(SubscriberUpdatedEvent::class)]
#[CoversClass(NewsletterListService::class)]
#[CoversClass(ListRemovalListener::class)]
+#[CoversClass(ListRemovalService::class)]
+#[CoversClass(CreateSubscriberInput::class)]
class CreateSubscriberTest extends WebTestCase
{
@@ -167,6 +170,7 @@ public function test_creates_subscriber_fills_subscribed_at(): void
'/subscribers',
[
'email' => 'test@email.com',
+ 'subscribed_at' => null, // even if set
],
);
@@ -336,6 +340,9 @@ public function test_metadata_strategy_overwrite(): void
$this->assertSame(['c' => '3'], $subscriber->getMetadata());
}
+ public function test_validates_metadata_definition_exists(): void {}
+
+
#[TestWith([ListRemovalReason::UNSUBSCRIBE])]
#[TestWith([ListRemovalReason::BOUNCE])]
public function test_records_list_removal(ListRemovalReason $reason): void
@@ -454,278 +461,89 @@ public function test_list_removal_with_strategy_remove(): void
$this->assertSame(ListRemovalReason::OTHER, $record->getReason());
}
- 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
- {
- $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');
-
- $response = $this->consoleApi(
- $newsletter,
- 'POST',
- '/subscribers',
- [
- 'email' => 'supun@hyvor.com',
- 'source' => 'form',
- '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());
-
- $json = $this->getJson();
- $this->assertIsInt($json['id']);
- $this->assertSame('supun@hyvor.com', $json['email']);
- $this->assertSame(SubscriberStatus::PENDING->value, $json['status']);
- $this->assertSame('form', $json['source']);
- $this->assertSame('79.255.1.1', $json['subscribe_ip']);
- $this->assertSame($subscribedAt->getTimestamp(), $json['subscribed_at']);
- $this->assertSame($unsubscribedAt->getTimestamp(), $json['unsubscribed_at']);
-
- $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());
- $this->assertSame(SubscriberSource::FORM, $subscriber->getSource());
- $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'));
- $this->assertCount(1, $subscriber->getLists());
- }
-
- public function testUpdateExistingSubscriber(): void
+ #[TestWith([ListRemovalReason::UNSUBSCRIBE])]
+ #[TestWith([ListRemovalReason::BOUNCE])]
+ public function test_list_skips_if_previously_removed(ListRemovalReason $reason): void
{
$newsletter = NewsletterFactory::createOne();
$list = NewsletterListFactory::createOne(['newsletter' => $newsletter]);
+ $subscriber = SubscriberFactory::createOne(['newsletter' => $newsletter]);
- $subscriber = SubscriberFactory::createOne([
- 'newsletter' => $newsletter,
- 'email' => 'supun@hyvor.com',
- 'status' => SubscriberStatus::UNSUBSCRIBED,
- 'subscribe_ip' => '1.2.3.4',
+ SubscriberListRemovalFactory::createOne([
+ 'subscriber' => $subscriber,
+ 'list' => $list,
+ 'reason' => $reason,
]);
- $subscribedAt = new \DateTimeImmutable('2024-01-01 00:00:00');
-
- $response = $this->consoleApi(
- $newsletter,
- 'POST',
- '/subscribers',
- [
- 'email' => 'supun@hyvor.com',
- 'status' => 'pending',
- 'subscribe_ip' => '79.255.1.1',
- 'subscribed_at' => $subscribedAt->getTimestamp(),
- 'source' => 'import',
- 'lists' => [$list->getId()],
- ],
- );
+ $this->consoleApi($newsletter, 'POST', '/subscribers', [
+ 'email' => $subscriber->getEmail(),
+ 'lists' => [$list->getId()],
+ ]);
- $this->assertSame(200, $response->getStatusCode());
+ $this->assertResponseIsSuccessful();
- $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());
+ refresh($subscriber);
+ $this->assertCount(0, $subscriber->getLists());
}
- public function testListAddStrategyIgnore(): void
+ public function test_list_does_not_skip_other(): void
{
$newsletter = NewsletterFactory::createOne();
$list = NewsletterListFactory::createOne(['newsletter' => $newsletter]);
$subscriber = SubscriberFactory::createOne(['newsletter' => $newsletter]);
SubscriberListRemovalFactory::createOne([
- 'list' => $list,
'subscriber' => $subscriber,
+ 'list' => $list,
+ 'reason' => ListRemovalReason::OTHER,
]);
- $response = $this->consoleApi(
- $newsletter,
- 'POST',
- '/subscribers',
- [
- 'email' => $subscriber->getEmail(),
- 'lists' => [$list->getId()],
- 'list_add_strategy_if_unsubscribed' => 'ignore',
- ],
- );
+ $this->consoleApi($newsletter, 'POST', '/subscribers', [
+ 'email' => $subscriber->getEmail(),
+ 'lists' => [$list->getId()],
+ ]);
- $this->assertSame(200, $response->getStatusCode());
+ $this->assertResponseIsSuccessful();
- $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());
+ refresh($subscriber);
+ $this->assertCount(1, $subscriber->getLists());
+ $this->assertSame($list->getId(), $subscriber->getLists()[0]?->getId());
}
- public function testListAddStrategyForceAdd(): void
+ public function test_list_can_bypass_removal(): void
{
$newsletter = NewsletterFactory::createOne();
$list = NewsletterListFactory::createOne(['newsletter' => $newsletter]);
$subscriber = SubscriberFactory::createOne(['newsletter' => $newsletter]);
SubscriberListRemovalFactory::createOne([
- 'list' => $list,
'subscriber' => $subscriber,
+ 'list' => $list,
+ 'reason' => ListRemovalReason::UNSUBSCRIBE,
]);
- $response = $this->consoleApi(
- $newsletter,
- 'POST',
- '/subscribers',
- [
- 'email' => $subscriber->getEmail(),
- 'lists' => [$list->getId()],
- 'list_add_strategy_if_unsubscribed' => 'force_add',
- ],
- );
-
- $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 testListRemoveReasonUnsubscribe(): void
- {
- $newsletter = NewsletterFactory::createOne();
- $list = NewsletterListFactory::createOne(['newsletter' => $newsletter]);
- $subscriber = SubscriberFactory::createOne(['newsletter' => $newsletter, 'lists' => [$list]]);
-
- $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->consoleApi($newsletter, 'POST', '/subscribers', [
+ 'email' => $subscriber->getEmail(),
+ 'lists' => [$list->getId()],
+ 'list_skip_resubscribe_on' => [],
+ ]);
- $this->em->clear();
- $updated = $this->em->getRepository(Subscriber::class)->find($subscriber->getId());
- $this->assertInstanceOf(Subscriber::class, $updated);
- $this->assertCount(0, $updated->getLists());
+ $this->assertResponseIsSuccessful();
- // Should record unsubscription
- $record = $this->em->getRepository(SubscriberListRemoval::class)->findOneBy([
- 'list' => $list->_real(),
- 'subscriber' => $updated,
- ]);
- $this->assertInstanceOf(SubscriberListRemoval::class, $record);
+ refresh($subscriber);
+ $this->assertCount(1, $subscriber->getLists());
+ $this->assertSame($list->getId(), $subscriber->getLists()[0]?->getId());
}
- public function testListRemoveReasonOther(): void
+ public function test_updates_with_confirmation_email_true(): void
{
- $newsletter = NewsletterFactory::createOne();
- $list = NewsletterListFactory::createOne(['newsletter' => $newsletter]);
- $subscriber = SubscriberFactory::createOne(['newsletter' => $newsletter, 'lists' => [$list]]);
-
- $response = $this->consoleApi(
- $newsletter,
- 'POST',
- '/subscribers',
- [
- 'email' => $subscriber->getEmail(),
- 'lists' => [], // empty = remove from all lists
- 'list_remove_reason' => 'other',
- ],
- );
-
- $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 NOT record unsubscription
- $record = $this->em->getRepository(SubscriberListRemoval::class)->findOneBy([
- 'list' => $list->_real(),
- 'subscriber' => $updated,
- ]);
- $this->assertNull($record);
+ // check event has true
}
- public function testSendPendingConfirmationEmail(): void
+ #[TestWith([9999])]
+ #[TestWith(['list'])]
+ public function test_list_not_found(int|string $val): void
{
- $this->mockRelayClient();
$newsletter = NewsletterFactory::createOne();
$response = $this->consoleApi(
@@ -734,40 +552,36 @@ public function testSendPendingConfirmationEmail(): void
'/subscribers',
[
'email' => 'test@email.com',
- 'lists' => [],
- 'send_pending_confirmation_email' => true,
+ 'lists' => [$val],
],
);
- $this->assertSame(200, $response->getStatusCode());
-
- $transport = $this->transport('async');
- $transport->queue()->assertCount(1);
- $message = $transport->queue()->first()->getMessage();
- $this->assertInstanceOf(SendConfirmationEmailMessage::class, $message);
+ $this->assertResponseFailed(
+ 422,
+ 'Lists with ' . (is_int($val) ? 'IDs' : 'names') . ' ' . $val . ' not found',
+ );
}
- public function testNoConfirmationEmailByDefault(): void
- {
- $newsletter = NewsletterFactory::createOne();
-
- $response = $this->consoleApi(
- $newsletter,
- 'POST',
- '/subscribers',
+ /**
+ * @param array $input
+ * add the following:
+ * - invalid email
+ * - too long email
+ * - send string to lists
+ * - invalid status
+ * - invalid IP address
+ * - invalid list_skip_resubscribe_on value
+ */
+ #[TestWith(
+ [
[
- 'email' => 'test@email.com',
- 'lists' => [],
+ 'email' => '',
],
- );
-
- $this->assertSame(200, $response->getStatusCode());
-
- $transport = $this->transport('async');
- $transport->queue()->assertCount(0);
- }
-
- public function testListNotFound(): void
+ 'email: This value should not be blank',
+ ],
+ 'empty email'
+ )]
+ public function test_validation(array $input, string $message): void
{
$newsletter = NewsletterFactory::createOne();
@@ -775,60 +589,12 @@ public function testListNotFound(): void
$newsletter,
'POST',
'/subscribers',
- [
- 'email' => 'test@email.com',
- 'lists' => [999999],
- ],
- );
-
- $this->assertSame(422, $response->getStatusCode());
- $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.',
- ],
- ],
+ $input,
);
- }
- 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.',
- ],
- ],
+ $this->assertResponseFailed(
+ 422,
+ $message,
);
}
@@ -836,33 +602,8 @@ public function testInputValidationEmailTooLong(): void
#[TestWith(['127.0.0.1'])] // private ip
#[TestWith(['::1'])] // localhost
#[TestWith(['169.254.255.255'])] // reserved ip
- public function testValidatesIp(
+ public function test_validates_ip(
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();
@@ -870,11 +611,17 @@ private function validateInput(
$newsletter,
'POST',
'/subscribers',
- $input($newsletter),
+ [
+ 'email' => 'supun@hyvor.com',
+ 'lists' => [],
+ 'subscribe_ip' => $ip,
+ ],
);
- $this->assertSame(422, $response->getStatusCode());
- $this->assertHasViolation($violations[0]['property'], $violations[0]['message']);
+ $this->assertResponseFailed(
+ 422,
+ 'This value is not a valid IP address.',
+ );
}
}
diff --git a/backend/tests/Case/WebTestCase.php b/backend/tests/Case/WebTestCase.php
index 8e68ccbe..a95a6b56 100644
--- a/backend/tests/Case/WebTestCase.php
+++ b/backend/tests/Case/WebTestCase.php
@@ -46,7 +46,7 @@ protected function setUp(): void
id: 1,
name: 'Fake Organization',
role: 'admin',
- )
+ ),
);
}
/** @var EntityManagerInterface $em */
@@ -67,11 +67,13 @@ protected function mockRelayClient(?callable $callback = null, bool $forSystemNo
{
if (!$callback) {
$callback = function ($method, $url, $options) use ($forSystemNotification): JsonMockResponse {
-
$this->assertSame('POST', $method);
$this->assertStringStartsWith('https://relay.hyvor.com/api/console/', $url);
$this->assertContains('Content-Type: application/json', $options['headers']);
- $this->assertContains('Authorization: Bearer ' . ($forSystemNotification ? 'test-notification-relay-key' : 'test-relay-key'), $options['headers']);
+ $this->assertContains(
+ 'Authorization: Bearer ' . ($forSystemNotification ? 'test-notification-relay-key' : 'test-relay-key'),
+ $options['headers'],
+ );
if ($forSystemNotification) {
$body = json_decode($options['body'], true);
@@ -100,23 +102,24 @@ protected function mockRelayClient(?callable $callback = null, bool $forSystemNo
*/
public function consoleApi(
Newsletter|int|null $newsletter,
- string $method,
- string $uri,
- array $data = [],
- array $files = [],
+ string $method,
+ string $uri,
+ array $data = [],
+ array $files = [],
// only use this if $files is used. otherwise, use $data
- array $parameters = [],
- array $server = [],
- true|array $scopes = true,
- bool $useSession = false
- ): Response
- {
+ array $parameters = [],
+ array $server = [],
+ true|array $scopes = true,
+ bool $useSession = false,
+ ): Response {
if ($newsletter instanceof Newsletter) {
$newsletterId = $newsletter->getId();
- } else if ($newsletter) {
- $newsletterId = $newsletter;
- $newsletter = NewsletterFactory::findBy(['id' => $newsletterId]);
- $newsletter = count($newsletter) > 0 ? $newsletter[0] : null;
+ } else {
+ if ($newsletter) {
+ $newsletterId = $newsletter;
+ $newsletter = NewsletterFactory::findBy(['id' => $newsletterId]);
+ $newsletter = count($newsletter) > 0 ? $newsletter[0] : null;
+ }
}
if ($newsletter) {
@@ -143,7 +146,7 @@ public function consoleApi(
if ($scopes !== true) {
$apiKeyFactory['scopes'] = array_map(
fn(Scope|string $scope) => is_string($scope) ? $scope : $scope->value,
- $scopes
+ $scopes,
);
}
ApiKeyFactory::createOne($apiKeyFactory);
@@ -166,7 +169,7 @@ public function consoleApi(
if ($response->getStatusCode() === 500) {
throw new \Exception(
'API call failed with status code 500. ' .
- 'Response: ' . $response->getContent()
+ 'Response: ' . $response->getContent(),
);
}
@@ -180,10 +183,9 @@ public function consoleApi(
public function publicApi(
string $method,
string $uri,
- array $data = [],
- array $headers = [],
- ): Response
- {
+ array $data = [],
+ array $headers = [],
+ ): Response {
$server = [
'CONTENT_TYPE' => 'application/json',
];
@@ -196,7 +198,7 @@ public function publicApi(
$method,
'/api/public' . $uri,
server: $server,
- content: (string)json_encode($data)
+ content: (string)json_encode($data),
);
return $this->client->getResponse();
}
@@ -208,12 +210,11 @@ public function publicApi(
public function sudoApi(
string $method,
string $uri,
- array $data = [],
- array $server = [],
- ): Response
- {
+ array $data = [],
+ array $server = [],
+ ): Response {
SudoUserFactory::findOrCreate([
- 'user_id' => 1
+ 'user_id' => 1,
]);
$this->client->getCookieJar()->set(new Cookie('authsess', 'test-session'));
@@ -232,7 +233,7 @@ public function sudoApi(
if ($response->getStatusCode() === 500) {
throw new \Exception(
'API call failed with status code 500. ' .
- 'Response: ' . $response->getContent()
+ 'Response: ' . $response->getContent(),
);
}
@@ -248,7 +249,6 @@ public function getTestLogger(): TestHandler
public function assertApiFailed(int $expectedStatus, string $expectedMessage): void
{
-
$response = $this->client->getResponse();
$this->assertSame($expectedStatus, $response->getStatusCode());
@@ -256,7 +256,6 @@ public function assertApiFailed(int $expectedStatus, string $expectedMessage): v
$this->assertArrayHasKey('message', $json);
$this->assertIsString($json['message']);
$this->assertStringContainsString($expectedMessage, $json['message']);
-
}
From 276365304ae8a1f0ae8a75ffc6adc410a73992c1 Mon Sep 17 00:00:00 2001
From: Supun Wimalasena
Date: Tue, 3 Mar 2026 17:31:56 +0100
Subject: [PATCH 20/23] tests
---
.../Subscriber/CreateSubscriberTest.php | 75 ++++++++++++++++---
1 file changed, 65 insertions(+), 10 deletions(-)
diff --git a/backend/tests/Api/Console/Subscriber/CreateSubscriberTest.php b/backend/tests/Api/Console/Subscriber/CreateSubscriberTest.php
index 03500e2c..378743a6 100644
--- a/backend/tests/Api/Console/Subscriber/CreateSubscriberTest.php
+++ b/backend/tests/Api/Console/Subscriber/CreateSubscriberTest.php
@@ -4,7 +4,6 @@
use App\Api\Console\Controller\SubscriberController;
use App\Api\Console\Input\Subscriber\CreateSubscriberInput;
-use App\Entity\Newsletter;
use App\Entity\Subscriber;
use App\Entity\SubscriberListRemoval;
use App\Entity\Type\ListRemovalReason;
@@ -340,7 +339,17 @@ public function test_metadata_strategy_overwrite(): void
$this->assertSame(['c' => '3'], $subscriber->getMetadata());
}
- public function test_validates_metadata_definition_exists(): void {}
+ public function test_validates_metadata_definition_exists(): void
+ {
+ $newsletter = NewsletterFactory::createOne();
+
+ $this->consoleApi($newsletter, 'POST', '/subscribers', [
+ 'email' => 'test@email.com',
+ 'metadata' => ['nonexistent-key' => 'value'],
+ ]);
+
+ $this->assertResponseFailed(422, 'nonexistent-key');
+ }
#[TestWith([ListRemovalReason::UNSUBSCRIBE])]
@@ -537,7 +546,18 @@ public function test_list_can_bypass_removal(): void
public function test_updates_with_confirmation_email_true(): void
{
- // check event has true
+ $newsletter = NewsletterFactory::createOne();
+ $subscriber = SubscriberFactory::createOne(['newsletter' => $newsletter]);
+
+ $this->consoleApi($newsletter, 'POST', '/subscribers', [
+ 'email' => $subscriber->getEmail(),
+ 'send_pending_confirmation_email' => true,
+ ]);
+
+ $this->assertResponseIsSuccessful();
+
+ $event = $this->getEd()->getFirstEvent(SubscriberUpdatedEvent::class);
+ $this->assertTrue($event->shouldSendConfirmationEmail());
}
#[TestWith([9999])]
@@ -564,13 +584,6 @@ public function test_list_not_found(int|string $val): void
/**
* @param array $input
- * add the following:
- * - invalid email
- * - too long email
- * - send string to lists
- * - invalid status
- * - invalid IP address
- * - invalid list_skip_resubscribe_on value
*/
#[TestWith(
[
@@ -581,6 +594,48 @@ public function test_list_not_found(int|string $val): void
],
'empty email'
)]
+ #[TestWith(
+ [
+ ['email' => 'not-an-email'],
+ 'email: This value is not a valid email address',
+ ],
+ 'invalid email'
+ )]
+ #[TestWith(
+ [
+ ['email' => 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa@h.co'],
+ 'email: This value is too long',
+ ],
+ 'too long email'
+ )]
+ #[TestWith(
+ [
+ ['email' => 'test@email.com', 'lists' => 'not-array'],
+ 'lists',
+ ],
+ 'string for lists'
+ )]
+ #[TestWith(
+ [
+ ['email' => 'test@email.com', 'status' => 'invalid-status'],
+ 'status',
+ ],
+ 'invalid status'
+ )]
+ #[TestWith(
+ [
+ ['email' => 'test@email.com', 'subscribe_ip' => 'not-an-ip'],
+ 'subscribe_ip: This value is not a valid IP address',
+ ],
+ 'invalid IP address'
+ )]
+ #[TestWith(
+ [
+ ['email' => 'test@email.com', 'list_skip_resubscribe_on' => ['invalid-reason']],
+ 'not a valid choice',
+ ],
+ 'invalid list_skip_resubscribe_on value'
+ )]
public function test_validation(array $input, string $message): void
{
$newsletter = NewsletterFactory::createOne();
From 323d4512be57507f4d6811a235a7465514c02d3f Mon Sep 17 00:00:00 2001
From: Supun Wimalasena
Date: Wed, 4 Mar 2026 11:37:48 +0100
Subject: [PATCH 21/23] removing a removal when re-adding
---
.../ListRemoval/ListRemovalListener.php | 36 +++++++++++++++----
.../Subscriber/CreateSubscriberTest.php | 18 ++++++++--
2 files changed, 46 insertions(+), 8 deletions(-)
diff --git a/backend/src/Service/Subscriber/ListRemoval/ListRemovalListener.php b/backend/src/Service/Subscriber/ListRemoval/ListRemovalListener.php
index 808a82e3..fd433900 100644
--- a/backend/src/Service/Subscriber/ListRemoval/ListRemovalListener.php
+++ b/backend/src/Service/Subscriber/ListRemoval/ListRemovalListener.php
@@ -2,7 +2,10 @@
namespace App\Service\Subscriber\ListRemoval;
+use App\Entity\SubscriberListRemoval;
use App\Service\Subscriber\Event\SubscriberUpdatingEvent;
+use Doctrine\DBAL\ArrayParameterType;
+use Doctrine\DBAL\ParameterType;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\Clock\ClockAwareTrait;
use Symfony\Component\EventDispatcher\Attribute\AsEventListener;
@@ -19,13 +22,8 @@ public function __construct(
#[AsEventListener()]
public function onSubscriberUpdating(SubscriberUpdatingEvent $event): void
{
- $this->skipRemoved($event);
$this->recordRemoving($event);
- }
-
- private function skipRemoved(SubscriberUpdatingEvent $event): void
- {
- //
+ $this->deleteAdding($event);
}
private function recordRemoving(SubscriberUpdatingEvent $event): void
@@ -56,4 +54,30 @@ private function recordRemoving(SubscriberUpdatingEvent $event): void
}
}
+ private function deleteAdding(SubscriberUpdatingEvent $event): void
+ {
+ $oldListIds = $event->getSubscriberOld()->getLists()->map(fn($list) => $list->getId())->toArray();
+ $newListIds = $event->getSubscriber()->getLists()->map(fn($list) => $list->getId())->toArray();
+
+ $addedListIds = array_diff($newListIds, $oldListIds);
+
+ if (count($addedListIds) === 0) {
+ return;
+ }
+
+ $this->em->getConnection()->executeQuery(
+ "DELETE FROM subscriber_list_removals
+ WHERE subscriber_id = :subscriber_id
+ AND list_id IN (:added_list_ids)",
+ [
+ 'subscriber_id' => $event->getSubscriber()->getId(),
+ 'added_list_ids' => $addedListIds,
+ ],
+ [
+ 'subscriber_id' => \Doctrine\DBAL\ParameterType::INTEGER,
+ 'added_list_ids' => \Doctrine\DBAL\ArrayParameterType::INTEGER,
+ ],
+ );
+ }
+
}
diff --git a/backend/tests/Api/Console/Subscriber/CreateSubscriberTest.php b/backend/tests/Api/Console/Subscriber/CreateSubscriberTest.php
index 378743a6..a71f7c73 100644
--- a/backend/tests/Api/Console/Subscriber/CreateSubscriberTest.php
+++ b/backend/tests/Api/Console/Subscriber/CreateSubscriberTest.php
@@ -519,29 +519,43 @@ public function test_list_does_not_skip_other(): void
$this->assertSame($list->getId(), $subscriber->getLists()[0]?->getId());
}
- public function test_list_can_bypass_removal(): void
+ public function test_list_can_bypass_removal_and_removes_removal(): void
{
$newsletter = NewsletterFactory::createOne();
$list = NewsletterListFactory::createOne(['newsletter' => $newsletter]);
$subscriber = SubscriberFactory::createOne(['newsletter' => $newsletter]);
- SubscriberListRemovalFactory::createOne([
+ $removal = SubscriberListRemovalFactory::createOne([
'subscriber' => $subscriber,
'list' => $list,
'reason' => ListRemovalReason::UNSUBSCRIBE,
]);
+ $removalId = $removal->getId();
+
+ $otherRemoval = SubscriberListRemovalFactory::createOne([
+ 'subscriber' => $subscriber,
+ 'list' => NewsletterListFactory::createOne(['newsletter' => $newsletter]),
+ 'reason' => ListRemovalReason::UNSUBSCRIBE,
+ ]);
$this->consoleApi($newsletter, 'POST', '/subscribers', [
'email' => $subscriber->getEmail(),
'lists' => [$list->getId()],
'list_skip_resubscribe_on' => [],
]);
+ $this->em->clear();
$this->assertResponseIsSuccessful();
refresh($subscriber);
$this->assertCount(1, $subscriber->getLists());
$this->assertSame($list->getId(), $subscriber->getLists()[0]?->getId());
+
+ $record = $this->em->getRepository(SubscriberListRemoval::class)->find($removalId);
+ $this->assertNull($record);
+
+ $recordOther = $this->em->getRepository(SubscriberListRemoval::class)->find($otherRemoval->getId());
+ $this->assertInstanceOf(SubscriberListRemoval::class, $recordOther);
}
public function test_updates_with_confirmation_email_true(): void
From cd5e7adbceffcf8b699633adb9997011857e9fad Mon Sep 17 00:00:00 2001
From: Supun Wimalasena
Date: Wed, 4 Mar 2026 11:52:17 +0100
Subject: [PATCH 22/23] sending confirmation mail
---
.../ConfirmationMailListener.php | 46 +++++++++++++++++++
.../SendConfirmationMailMessage.php} | 4 +-
.../SendConfirmationMailMessageHandler.php} | 18 ++++----
.../Subscriber/CreateSubscriberTest.php | 14 +++++-
...endConfirmationMailMessageHandlerTest.php} | 16 +++----
5 files changed, 78 insertions(+), 20 deletions(-)
create mode 100644 backend/src/Service/Subscriber/ConfirmationMail/ConfirmationMailListener.php
rename backend/src/Service/Subscriber/{Message/SendConfirmationEmailMessage.php => ConfirmationMail/SendConfirmationMailMessage.php} (77%)
rename backend/src/Service/Subscriber/{MessageHandler/SendConfirmationEmailMessageHandler.php => ConfirmationMail/SendConfirmationMailMessageHandler.php} (90%)
rename backend/tests/{Api/Console/Subscriber/SubscriberCreatedMessageHandlerTest.php => Service/Subscriber/ConfirmationMail/SendConfirmationMailMessageHandlerTest.php} (83%)
diff --git a/backend/src/Service/Subscriber/ConfirmationMail/ConfirmationMailListener.php b/backend/src/Service/Subscriber/ConfirmationMail/ConfirmationMailListener.php
new file mode 100644
index 00000000..65774709
--- /dev/null
+++ b/backend/src/Service/Subscriber/ConfirmationMail/ConfirmationMailListener.php
@@ -0,0 +1,46 @@
+shouldSendConfirmationEmail() &&
+ $event->getSubscriber()->getStatus() === SubscriberStatus::PENDING
+ ) {
+ $this->send($event->getSubscriber());
+ }
+ }
+
+ #[AsEventListener]
+ public function onSubscriberUpdate(SubscriberUpdatedEvent $event): void
+ {
+ if (
+ $event->getSubscriberOld()->getStatus() !== SubscriberStatus::PENDING &&
+ $event->getSubscriber()->getStatus() === SubscriberStatus::PENDING &&
+ $event->shouldSendConfirmationEmail()
+ ) {
+ $this->send($event->getSubscriber());
+ }
+ }
+
+ private function send(Subscriber $subscriber): void
+ {
+ $this->bus->dispatch(new SendConfirmationMailMessage($subscriber->getId()));
+ }
+
+
+}
diff --git a/backend/src/Service/Subscriber/Message/SendConfirmationEmailMessage.php b/backend/src/Service/Subscriber/ConfirmationMail/SendConfirmationMailMessage.php
similarity index 77%
rename from backend/src/Service/Subscriber/Message/SendConfirmationEmailMessage.php
rename to backend/src/Service/Subscriber/ConfirmationMail/SendConfirmationMailMessage.php
index 7759fe50..e71b3f31 100644
--- a/backend/src/Service/Subscriber/Message/SendConfirmationEmailMessage.php
+++ b/backend/src/Service/Subscriber/ConfirmationMail/SendConfirmationMailMessage.php
@@ -1,12 +1,12 @@
em->getRepository(Subscriber::class)->find($message->getSubscriberId());
- assert($subscriber !== null);
+
+ if ($subscriber === null) {
+ return;
+ }
+
$newsletter = $subscriber->getNewsletter();
if ($subscriber->getStatus() !== SubscriberStatus::PENDING) {
@@ -56,14 +59,12 @@ public function __invoke(SendConfirmationEmailMessage $message): void
];
$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', [
+ $content = $this->twig->render('newsletter/mail/confirm.json.twig', [
'newsletterName' => $newsletter->getName(),
'buttonUrl' => $this->newsletterService->getArchiveUrl($newsletter) . "/confirm?token=" . $token,
'buttonText' => $strings->get('mail.subscriberConfirmation.buttonText'),
@@ -84,6 +85,7 @@ public function __invoke(SendConfirmationEmailMessage $message): void
->to($subscriber->getEmail())
->html($this->htmlTemplateRenderer->render($template, $variables))
->subject($heading . ' to ' . $newsletter->getName());
+
$this->relayApiClient->sendEmail($email);
}
}
diff --git a/backend/tests/Api/Console/Subscriber/CreateSubscriberTest.php b/backend/tests/Api/Console/Subscriber/CreateSubscriberTest.php
index a71f7c73..8dda36fd 100644
--- a/backend/tests/Api/Console/Subscriber/CreateSubscriberTest.php
+++ b/backend/tests/Api/Console/Subscriber/CreateSubscriberTest.php
@@ -10,7 +10,10 @@
use App\Entity\Type\SubscriberMetadataDefinitionType;
use App\Entity\Type\SubscriberSource;
use App\Entity\Type\SubscriberStatus;
+use App\Service\App\Messenger\MessageTransport;
use App\Service\NewsletterList\NewsletterListService;
+use App\Service\Subscriber\ConfirmationMail\ConfirmationMailListener;
+use App\Service\Subscriber\ConfirmationMail\SendConfirmationMailMessage;
use App\Service\Subscriber\Event\SubscriberCreatedEvent;
use App\Service\Subscriber\Event\SubscriberUpdatedEvent;
use App\Service\Subscriber\Event\SubscriberUpdatingEvent;
@@ -38,6 +41,7 @@
#[CoversClass(ListRemovalListener::class)]
#[CoversClass(ListRemovalService::class)]
#[CoversClass(CreateSubscriberInput::class)]
+#[CoversClass(ConfirmationMailListener::class)]
class CreateSubscriberTest extends WebTestCase
{
@@ -561,10 +565,13 @@ public function test_list_can_bypass_removal_and_removes_removal(): void
public function test_updates_with_confirmation_email_true(): void
{
$newsletter = NewsletterFactory::createOne();
- $subscriber = SubscriberFactory::createOne(['newsletter' => $newsletter]);
+ $subscriber = SubscriberFactory::createOne(
+ ['newsletter' => $newsletter, 'status' => SubscriberStatus::SUBSCRIBED],
+ );
$this->consoleApi($newsletter, 'POST', '/subscribers', [
'email' => $subscriber->getEmail(),
+ 'status' => 'pending',
'send_pending_confirmation_email' => true,
]);
@@ -572,6 +579,11 @@ public function test_updates_with_confirmation_email_true(): void
$event = $this->getEd()->getFirstEvent(SubscriberUpdatedEvent::class);
$this->assertTrue($event->shouldSendConfirmationEmail());
+
+ $transport = $this->transport(MessageTransport::ASYNC);
+ $transport->queue()->assertContains(SendConfirmationMailMessage::class);
+ $message = $transport->queue()->messages(SendConfirmationMailMessage::class)[0];
+ $this->assertSame($subscriber->getId(), $message->getSubscriberId());
}
#[TestWith([9999])]
diff --git a/backend/tests/Api/Console/Subscriber/SubscriberCreatedMessageHandlerTest.php b/backend/tests/Service/Subscriber/ConfirmationMail/SendConfirmationMailMessageHandlerTest.php
similarity index 83%
rename from backend/tests/Api/Console/Subscriber/SubscriberCreatedMessageHandlerTest.php
rename to backend/tests/Service/Subscriber/ConfirmationMail/SendConfirmationMailMessageHandlerTest.php
index 5cd50234..4b535476 100644
--- a/backend/tests/Api/Console/Subscriber/SubscriberCreatedMessageHandlerTest.php
+++ b/backend/tests/Service/Subscriber/ConfirmationMail/SendConfirmationMailMessageHandlerTest.php
@@ -1,25 +1,23 @@
mockRelayClient($callback);
- $message = new SendConfirmationEmailMessage($subscriber->getId());
+ $message = new SendConfirmationMailMessage($subscriber->getId());
$this->getMessageBus()->dispatch($message);
$transport = $this->transport('async');
From 93f6a7213cd8fca9aac9c3dd9f81e1aba0957dc7 Mon Sep 17 00:00:00 2001
From: Supun Wimalasena
Date: Wed, 4 Mar 2026 12:54:03 +0100
Subject: [PATCH 23/23] phpstan fixes, wip
---
.../Subscriber/CreateSubscriberInput.php | 6 +-
.../Api/Console/Object/SubscriberObject.php | 2 +-
.../Public/Controller/Form/FormController.php | 6 +-
.../Subscriber/SubscriberController.php | 26 +-
.../Object/Form/FormSubscriberObject.php | 4 +-
.../ImportSubscribersMessageHandler.php | 38 +-
backend/src/Service/Issue/IssueService.php | 27 +-
.../NewsletterList/NewsletterListService.php | 22 +-
.../tests/Api/Console/AuthorizationTest.php | 84 +--
.../Api/Console/ConsoleInitNewsletterTest.php | 11 +-
backend/tests/Api/Console/ConsoleInitTest.php | 48 +-
.../tests/Api/Console/Import/ImportTest.php | 58 +-
.../Console/Subscriber/GetSubscribersTest.php | 26 +-
.../GetImportingSubscribersTest.php | 6 +-
backend/tests/Case/WebTestCase.php | 11 +-
.../ImportSubscribersMessageHandlerTest.php | 8 +-
.../ExportSubscribersMessageHandlerTest.php | 16 +-
.../Subscriber/ExportSubscriberTest.php | 4 +-
.../tests/Service/Import/CsvParserTest.php | 2 +-
.../tests/Service/Media/DeleteMediaTest.php | 2 +-
.../docs/[...slug]/content/ConsoleApi.svelte | 669 +++++++++---------
frontend/src/routes/console/types.ts | 463 ++++++------
22 files changed, 752 insertions(+), 787 deletions(-)
diff --git a/backend/src/Api/Console/Input/Subscriber/CreateSubscriberInput.php b/backend/src/Api/Console/Input/Subscriber/CreateSubscriberInput.php
index b27236f0..b6678eef 100644
--- a/backend/src/Api/Console/Input/Subscriber/CreateSubscriberInput.php
+++ b/backend/src/Api/Console/Input/Subscriber/CreateSubscriberInput.php
@@ -63,8 +63,10 @@ public function getSubscribeIp(): ?string
public function getSubscribedAt(): ?\DateTimeImmutable
{
- $subscribedAt = $this->has('subscribed_at') ? $this->subscribed_at : null;
- return $subscribedAt ? new \DateTimeImmutable()->setTimestamp($this->subscribed_at) : null;
+ if (!$this->has('subscribed_at')) {
+ return null;
+ }
+ return $this->subscribed_at ? new \DateTimeImmutable()->setTimestamp($this->subscribed_at) : null;
}
/**
diff --git a/backend/src/Api/Console/Object/SubscriberObject.php b/backend/src/Api/Console/Object/SubscriberObject.php
index efc944f5..14b17a9a 100644
--- a/backend/src/Api/Console/Object/SubscriberObject.php
+++ b/backend/src/Api/Console/Object/SubscriberObject.php
@@ -22,7 +22,7 @@ class SubscriberObject
public ?int $subscribed_at;
/**
- * @var array
+ * @var array
*/
public array $metadata;
diff --git a/backend/src/Api/Public/Controller/Form/FormController.php b/backend/src/Api/Public/Controller/Form/FormController.php
index 4c6cb44a..8350d8b7 100644
--- a/backend/src/Api/Public/Controller/Form/FormController.php
+++ b/backend/src/Api/Public/Controller/Form/FormController.php
@@ -57,7 +57,7 @@ public function init(#[MapRequestPayload] FormInitInput $input): JsonResponse
throw new UnprocessableEntityHttpException("List with id {$missingListIds[0]} not found");
}
- $lists = $this->newsletterListService->getListsByIds($listIds);
+ $lists = $this->newsletterListService->getListsByIds($newsletter, $listIds);
} else {
$lists = $this->newsletterListService->getListsOfNewsletter($newsletter);
}
@@ -65,7 +65,7 @@ public function init(#[MapRequestPayload] FormInitInput $input): JsonResponse
return new JsonResponse([
'newsletter' => new FormNewsletterObject($newsletter),
'is_subscribed' => false,
- 'lists' => $lists->map(fn($list) => new FormListObject($list))->toArray(),
+ 'lists' => array_map(fn($list) => new FormListObject($list), $lists),
]);
}
@@ -91,7 +91,7 @@ public function subscribe(
throw new UnprocessableEntityHttpException("List with id {$missingListIds[0]} not found");
}
- $lists = $this->newsletterListService->getListsByIds($listIds);
+ $lists = $this->newsletterListService->getListsByIds($newsletter, $listIds);
$email = $input->email;
$subscriber = $this->subscriberService->getSubscriberByEmail($newsletter, $email);
diff --git a/backend/src/Api/Public/Controller/Subscriber/SubscriberController.php b/backend/src/Api/Public/Controller/Subscriber/SubscriberController.php
index df6ee6d3..c558f590 100644
--- a/backend/src/Api/Public/Controller/Subscriber/SubscriberController.php
+++ b/backend/src/Api/Public/Controller/Subscriber/SubscriberController.php
@@ -26,13 +26,11 @@ class SubscriberController extends AbstractController
use ClockAwareTrait;
public function __construct(
- private SubscriberService $subscriberService,
- private SendService $sendService,
+ private SubscriberService $subscriberService,
+ private SendService $sendService,
private NewsletterListService $newsletterListService,
- private Encryption $encryption,
- )
- {
- }
+ private Encryption $encryption,
+ ) {}
#[Route('/subscriber/confirm', methods: ['GET'])]
public function confirm(Request $request): JsonResponse
@@ -57,7 +55,7 @@ public function confirm(Request $request): JsonResponse
assert(is_string($data['expires_at']));
if (new \DateTimeImmutable($data['expires_at'])->getTimestamp() < $this->now()->getTimestamp()) {
throw new BadRequestHttpException(
- 'The confirmation link has expired. Please request a new confirmation link.'
+ 'The confirmation link has expired. Please request a new confirmation link.',
);
}
@@ -73,9 +71,8 @@ public function confirm(Request $request): JsonResponse
#[Route('/subscriber/unsubscribe', methods: ['POST'])]
public function unsubscribe(
- #[MapRequestPayload] UnsubscribeInput $input
- ): JsonResponse
- {
+ #[MapRequestPayload] UnsubscribeInput $input,
+ ): JsonResponse {
try {
$sendId = $this->encryption->decrypt($input->token);
} catch (DecryptException) {
@@ -97,15 +94,14 @@ public function unsubscribe(
$lists = $this->newsletterListService->getListsOfNewsletter($send->getNewsletter());
return new JsonResponse([
- 'lists' => $lists->map(fn($list) => new FormListObject($list))->toArray(),
+ 'lists' => array_map(fn($list) => new FormListObject($list), $lists),
]);
}
#[Route('/subscriber/resubscribe', methods: ['PATCH'])]
public function resubscribe(
#[MapRequestPayload] ResubscribeInput $input,
- ): JsonResponse
- {
+ ): JsonResponse {
try {
$sendId = $this->encryption->decrypt($input->token);
} catch (DecryptException) {
@@ -128,7 +124,7 @@ public function resubscribe(
$missingListIds = $this->newsletterListService->getMissingListIdsOfNewsletter(
$send->getNewsletter(),
- $input->list_ids
+ $input->list_ids,
);
if ($missingListIds !== null) {
@@ -139,7 +135,7 @@ public function resubscribe(
throw new UnprocessableEntityHttpException('At least one list must be provided.');
}
- $lists = $this->newsletterListService->getListsByIds($input->list_ids);
+ $lists = $this->newsletterListService->getListsByIds($send->getNewsletter(), $input->list_ids);
$updates->lists = $lists;
$this->subscriberService->updateSubscriber($subscriber, $updates);
diff --git a/backend/src/Api/Public/Object/Form/FormSubscriberObject.php b/backend/src/Api/Public/Object/Form/FormSubscriberObject.php
index 78e38acc..6fae52e1 100644
--- a/backend/src/Api/Public/Object/Form/FormSubscriberObject.php
+++ b/backend/src/Api/Public/Object/Form/FormSubscriberObject.php
@@ -15,7 +15,6 @@ class FormSubscriberObject
public string $email;
public SubscriberStatus $status;
public ?int $subscribed_at;
- public ?int $unsubscribed_at;
public function __construct(Subscriber $subscriber)
{
@@ -24,7 +23,6 @@ public function __construct(Subscriber $subscriber)
$this->email = $subscriber->getEmail();
$this->status = $subscriber->getStatus();
$this->subscribed_at = $subscriber->getSubscribedAt()?->getTimestamp();
- $this->unsubscribed_at = $subscriber->getUnsubscribedAt()?->getTimestamp();
}
-}
\ No newline at end of file
+}
diff --git a/backend/src/Service/Import/MessageHandler/ImportSubscribersMessageHandler.php b/backend/src/Service/Import/MessageHandler/ImportSubscribersMessageHandler.php
index 63885a56..fc2c464b 100644
--- a/backend/src/Service/Import/MessageHandler/ImportSubscribersMessageHandler.php
+++ b/backend/src/Service/Import/MessageHandler/ImportSubscribersMessageHandler.php
@@ -23,13 +23,11 @@ class ImportSubscribersMessageHandler
public function __construct(
private EntityManagerInterface $em,
- private NewsletterListService $newsletterListService,
- private ParserFactory $parserFactory,
- private ManagerRegistry $registry,
- private LoggerInterface $logger,
- )
- {
- }
+ private NewsletterListService $newsletterListService,
+ private ParserFactory $parserFactory,
+ private ManagerRegistry $registry,
+ private LoggerInterface $logger,
+ ) {}
public function __invoke(ImportSubscribersMessage $message): void
{
@@ -62,7 +60,6 @@ public function __invoke(ImportSubscribersMessage $message): void
'exception' => $e,
'importId' => $subscriberImport->getId(),
]);
-
} else {
$subscriberImport->setErrorMessage('An unexpected error occurred.');
$this->logger->error('Unexpected error during import', [
@@ -86,14 +83,14 @@ private function import(SubscriberImport $subscriberImport, CsvParser $parser):
$importedCount = 0;
foreach ($subscribers as $dto) {
-
$subscriberLists = [];
if (count($dto->lists) === 0) {
$subscriberLists = $lists;
} else {
foreach ($dto->lists as $listName) {
- $list = $lists->findFirst(fn($key, $l) => $l->getName() === $listName);
+ $list = array_find($lists, fn($l) => $l->getName() === $listName);
+
if ($list === null) {
continue;
}
@@ -104,15 +101,15 @@ private function import(SubscriberImport $subscriberImport, CsvParser $parser):
}
$query = <<em->getConnection()->fetchOne($query, $params);
if ($subscriberId && count($subscriberLists) > 0) {
-
$placeholders = [];
$params = ['subscriber_id' => $subscriberId];
@@ -141,7 +137,7 @@ private function import(SubscriberImport $subscriberImport, CsvParser $parser):
$sql = sprintf(
'INSERT INTO list_subscriber (list_id, subscriber_id) VALUES %s',
- implode(', ', $placeholders)
+ implode(', ', $placeholders),
);
$this->em->getConnection()->executeStatement($sql, $params);
diff --git a/backend/src/Service/Issue/IssueService.php b/backend/src/Service/Issue/IssueService.php
index dfb96856..023da144 100644
--- a/backend/src/Service/Issue/IssueService.php
+++ b/backend/src/Service/Issue/IssueService.php
@@ -22,13 +22,11 @@ class IssueService
public function __construct(
private EntityManagerInterface $em,
- private IssueRepository $issueRepository,
- private NewsletterListService $newsletterListService,
- private SendingProfileService $sendingProfileService,
- private EmailSenderService $emailSenderService,
- )
- {
- }
+ private IssueRepository $issueRepository,
+ private NewsletterListService $newsletterListService,
+ private SendingProfileService $sendingProfileService,
+ private EmailSenderService $emailSenderService,
+ ) {}
public function getIssueByUuid(string $uuid): ?Issue
{
@@ -38,7 +36,7 @@ public function getIssueByUuid(string $uuid): ?Issue
public function createIssueDraft(Newsletter $newsletter): Issue
{
$lists = $this->newsletterListService->getListsOfNewsletter($newsletter);
- $listIds = $lists->map(fn(NewsletterList $list) => $list->getId())->toArray();
+ $listIds = array_map(fn(NewsletterList $list) => $list->getId(), $lists);
$sendingProfile = $this->sendingProfileService->getCurrentDefaultSendingProfileOfNewsletter($newsletter);
$issue = new Issue()
@@ -110,12 +108,11 @@ public function updateIssue(Issue $issue, UpdateIssueDto $updates): Issue
* @return ArrayCollection
*/
public function getIssues(
- Newsletter $newsletter,
- int $limit,
- int $offset,
+ Newsletter $newsletter,
+ int $limit,
+ int $offset,
?IssueStatus $status = null,
- ): ArrayCollection
- {
+ ): ArrayCollection {
$where = ['newsletter' => $newsletter];
if ($status !== null) {
@@ -128,8 +125,8 @@ public function getIssues(
$where,
['id' => 'DESC'],
$limit,
- $offset
- )
+ $offset,
+ ),
);
}
diff --git a/backend/src/Service/NewsletterList/NewsletterListService.php b/backend/src/Service/NewsletterList/NewsletterListService.php
index a88c4095..8f57d719 100644
--- a/backend/src/Service/NewsletterList/NewsletterListService.php
+++ b/backend/src/Service/NewsletterList/NewsletterListService.php
@@ -72,20 +72,18 @@ public function getListById(int $id): ?NewsletterList
}
/**
- * @return ArrayCollection
+ * @return NewsletterList[]
*/
- public function getListsOfNewsletter(Newsletter $newsletter): ArrayCollection
+ public function getListsOfNewsletter(Newsletter $newsletter): array
{
- return new ArrayCollection(
- $this->em
- ->getRepository(NewsletterList::class)
- ->findBy(
- [
- 'newsletter' => $newsletter,
- 'deleted_at' => null,
- ],
- ),
- );
+ return $this->em
+ ->getRepository(NewsletterList::class)
+ ->findBy(
+ [
+ 'newsletter' => $newsletter,
+ 'deleted_at' => null,
+ ],
+ );
}
public function updateNewsletterList(NewsletterList $list, string $name, ?string $description): NewsletterList
diff --git a/backend/tests/Api/Console/AuthorizationTest.php b/backend/tests/Api/Console/AuthorizationTest.php
index 28996683..30d0ba13 100644
--- a/backend/tests/Api/Console/AuthorizationTest.php
+++ b/backend/tests/Api/Console/AuthorizationTest.php
@@ -41,7 +41,7 @@ public function test_api_key_authentication_nothing(): void
$this->assertResponseStatusCodeSame(401);
$this->assertSame(
"Unauthorized",
- $this->getJson()["message"]
+ $this->getJson()["message"],
);
}
@@ -52,12 +52,12 @@ public function test_wrong_authorization_header(): void
"/api/console/issues",
server: [
"HTTP_AUTHORIZATION" => "WrongHeader",
- ]
+ ],
);
$this->assertResponseStatusCodeSame(403);
$this->assertSame(
'Authorization header must start with "Bearer ".',
- $this->getJson()["message"]
+ $this->getJson()["message"],
);
}
@@ -68,12 +68,12 @@ public function test_missing_bearer_token(): void
"/api/console/issues",
server: [
"HTTP_AUTHORIZATION" => "Bearer ",
- ]
+ ],
);
$this->assertResponseStatusCodeSame(403);
$this->assertSame(
"API key is missing or empty.",
- $this->getJson()["message"]
+ $this->getJson()["message"],
);
}
@@ -84,7 +84,7 @@ public function test_invalid_api_key(): void
"/api/console/issues",
server: [
"HTTP_AUTHORIZATION" => "Bearer InvalidApiKey",
- ]
+ ],
);
$this->assertResponseStatusCodeSame(403);
$this->assertSame("Invalid API key.", $this->getJson()["message"]);
@@ -102,7 +102,7 @@ public function test_invalid_session(): void
"/api/console/issues",
server: [
"HTTP_X_NEWSLETTER_ID" => $newsletter->getId(),
- ]
+ ],
);
$this->assertResponseStatusCodeSame(401);
$this->assertSame("Unauthorized", $this->getJson()["message"]);
@@ -115,7 +115,7 @@ public function test_fails_when_organization_is_required(): void
$this->client->getCookieJar()->set(new Cookie('authsess', 'validSession'));
$this->client->request(
"GET",
- "/api/console/issues"
+ "/api/console/issues",
);
$this->assertResponseStatusCodeSame(403);
$this->assertSame("Current organization is missing.", $this->getJson()["message"]);
@@ -129,8 +129,8 @@ public function test_fails_organization_mismatch(): void
new AuthUserOrganization(
id: 1,
name: 'Fake Organization',
- role: 'admin'
- )
+ role: 'admin',
+ ),
);
$this->client->getCookieJar()->set(new Cookie('authsess', 'validSession'));
@@ -139,7 +139,7 @@ public function test_fails_organization_mismatch(): void
"/api/console/issues",
server: [
"HTTP_X_ORGANIZATION_ID" => 2,
- ]
+ ],
);
$this->assertResponseStatusCodeSame(403);
$this->assertSame("org_mismatch", $this->getJson()["message"]);
@@ -153,8 +153,8 @@ public function test_fails_when_xnewsletterid_header_is_not_set(): void
new AuthUserOrganization(
id: 1,
name: 'Fake Organization',
- role: 'admin'
- )
+ role: 'admin',
+ ),
);
$this->client->getCookieJar()->set(new Cookie('authsess', 'validSession'));
@@ -163,7 +163,7 @@ public function test_fails_when_xnewsletterid_header_is_not_set(): void
"/api/console/issues",
server: [
"HTTP_X_ORGANIZATION_ID" => 1,
- ]
+ ],
);
$this->assertResponseStatusCodeSame(403);
$this->assertSame("X-Newsletter-ID is required for this endpoint.", $this->getJson()["message"]);
@@ -177,8 +177,8 @@ public function test_invalid_newsletter_id(): void
new AuthUserOrganization(
id: 1,
name: 'Fake Organization',
- role: 'admin'
- )
+ role: 'admin',
+ ),
);
$this->client->getCookieJar()->set(new Cookie('authsess', 'validSession'));
$this->client->request(
@@ -201,8 +201,8 @@ public function test_newsletter_does_not_belong_to_current_organization(): void
new AuthUserOrganization(
id: 1,
name: 'Fake Organization',
- role: 'admin'
- )
+ role: 'admin',
+ ),
);
$newsletter = NewsletterFactory::createOne(['organization_id' => 2]);
$this->client->getCookieJar()->set(new Cookie('authsess', 'validSession'));
@@ -212,12 +212,12 @@ public function test_newsletter_does_not_belong_to_current_organization(): void
server: [
"HTTP_X_ORGANIZATION_ID" => 1,
"HTTP_X_NEWSLETTER_ID" => $newsletter->getId(),
- ]
+ ],
);
$this->assertResponseStatusCodeSame(403);
$this->assertSame(
"does_not_belong_the_resource",
- $this->getJson()["message"]
+ $this->getJson()["message"],
);
}
@@ -229,8 +229,8 @@ public function test_user_not_authorized_for_newsletter(): void
new AuthUserOrganization(
id: 1,
name: 'Fake Organization',
- role: 'admin'
- )
+ role: 'admin',
+ ),
);
$newsletter = NewsletterFactory::createOne(['organization_id' => 1]);
$this->client->getCookieJar()->set(new Cookie('authsess', 'validSession'));
@@ -240,12 +240,12 @@ public function test_user_not_authorized_for_newsletter(): void
server: [
"HTTP_X_ORGANIZATION_ID" => 1,
"HTTP_X_NEWSLETTER_ID" => $newsletter->getId(),
- ]
+ ],
);
$this->assertResponseStatusCodeSame(403);
$this->assertSame(
"You do not have access to this newsletter.",
- $this->getJson()["message"]
+ $this->getJson()["message"],
);
}
@@ -256,12 +256,12 @@ public function test_missing_scope_required_attribute(): void
$newsletter,
'GET',
'/issues',
- scopes: [Scope::ISSUES_WRITE]
+ scopes: [Scope::ISSUES_WRITE],
);
$this->assertResponseStatusCodeSame(403);
$this->assertSame(
"You do not have the required scope 'issues.read' to access this resource.",
- $this->getJson()["message"]
+ $this->getJson()["message"],
);
}
@@ -274,23 +274,23 @@ public function test_authorizes_via_api_key_and_updates_last_usage(): void
$newsletter,
'GET',
'/issues',
- scopes: [Scope::ISSUES_READ]
+ scopes: [Scope::ISSUES_READ],
);
$this->assertResponseStatusCodeSame(200);
$newsletterFromAttr = $this->client->getRequest()->attributes->get('console_api_resolved_newsletter');
$this->assertInstanceOf(
Newsletter::class,
- $newsletterFromAttr
+ $newsletterFromAttr,
);
$this->assertSame($newsletter->getId(), $newsletterFromAttr->getId());
- $apiKey = $this->em->getRepository(ApiKey::class)->findOneBy(['newsletter' => $newsletter->_real()]);
+ $apiKey = $this->em->getRepository(ApiKey::class)->findOneBy(['newsletter' => $newsletter]);
$this->assertInstanceOf(ApiKey::class, $apiKey);
$this->assertSame(
'2025-06-01 00:00:00',
- $apiKey->getLastAccessedAt()?->format('Y-m-d H:i:s')
+ $apiKey->getLastAccessedAt()?->format('Y-m-d H:i:s'),
);
}
@@ -302,15 +302,15 @@ public function test_authorizes_via_session(): void
new AuthUserOrganization(
id: 1,
name: 'Fake Organization',
- role: 'admin'
- )
+ role: 'admin',
+ ),
);
$newsletter = NewsletterFactory::createOne([
- 'organization_id' => 1
+ 'organization_id' => 1,
]);
UserFactory::createOne([
'hyvor_user_id' => 1,
- 'newsletter' => $newsletter
+ 'newsletter' => $newsletter,
]);
$this->client->getCookieJar()->set(new Cookie('authsess', 'validSession'));
@@ -320,14 +320,14 @@ public function test_authorizes_via_session(): void
server: [
"HTTP_X_ORGANIZATION_ID" => 1,
"HTTP_X_NEWSLETTER_ID" => $newsletter->getId(),
- ]
+ ],
);
$this->assertResponseStatusCodeSame(200);
$newsletterFromAttr = $this->client->getRequest()->attributes->get('console_api_resolved_newsletter');
$this->assertInstanceOf(
Newsletter::class,
- $newsletterFromAttr
+ $newsletterFromAttr,
);
$this->assertSame($newsletter->getId(), $newsletterFromAttr->getId());
@@ -344,20 +344,20 @@ public function test_user_level_endpoint_works(): void
new AuthUserOrganization(
id: 1,
name: 'Fake Organization',
- role: 'admin'
- )
+ role: 'admin',
+ ),
);
BillingFake::enableForSymfony(
$this->container,
- [1 => new ResolvedLicense(ResolvedLicenseType::TRIAL, PostLicense::trial())]
+ [1 => new ResolvedLicense(ResolvedLicenseType::TRIAL, PostLicense::trial())],
);
$newsletter = NewsletterFactory::createOne([
- 'organization_id' => 1
+ 'organization_id' => 1,
]);
UserFactory::createOne([
'hyvor_user_id' => 1,
- 'newsletter' => $newsletter
+ 'newsletter' => $newsletter,
]);
$this->client->getCookieJar()->set(new Cookie('authsess', 'validSession'));
@@ -379,7 +379,7 @@ public function test_when_no_organization_is_required(): void
$this->client->getCookieJar()->set(new Cookie('authsess', 'validSession'));
$this->client->request(
"GET",
- "/api/console/init"
+ "/api/console/init",
);
$this->assertResponseStatusCodeSame(200);
diff --git a/backend/tests/Api/Console/ConsoleInitNewsletterTest.php b/backend/tests/Api/Console/ConsoleInitNewsletterTest.php
index 2cc9f97e..470c6639 100644
--- a/backend/tests/Api/Console/ConsoleInitNewsletterTest.php
+++ b/backend/tests/Api/Console/ConsoleInitNewsletterTest.php
@@ -37,7 +37,7 @@ public function test_stats_subscribers_and_issues(): void
BillingFake::enableForSymfony(
$this->container,
- [1 => new ResolvedLicense(ResolvedLicenseType::SUBSCRIPTION, $license)]
+ [1 => new ResolvedLicense(ResolvedLicenseType::SUBSCRIPTION, $license)],
);
// --- subscribers
@@ -54,11 +54,6 @@ public function test_stats_subscribers_and_issues(): void
'newsletter' => $newsletter,
'status' => SubscriberStatus::SUBSCRIBED,
]);
- // unsubscribed
- SubscriberFactory::createMany(2, [
- 'newsletter' => $newsletter,
- 'status' => SubscriberStatus::UNSUBSCRIBED,
- ]);
// other newsletters
SubscriberFactory::createMany(3, [
'newsletter' => $otherNewsletter,
@@ -134,7 +129,7 @@ public function test_when_can_no_permissions_to_change_branding(): void
BillingFake::enableForSymfony(
$this->container,
- [1 => new ResolvedLicense(ResolvedLicenseType::SUBSCRIPTION, PostLicense::trial())]
+ [1 => new ResolvedLicense(ResolvedLicenseType::SUBSCRIPTION, PostLicense::trial())],
);
$response = $this->consoleApi(
@@ -217,7 +212,7 @@ public function test_stats_bounced_complained_rates(): void
BillingFake::enableForSymfony(
$this->container,
- [1 => new ResolvedLicense(ResolvedLicenseType::SUBSCRIPTION, PostLicense::trial())]
+ [1 => new ResolvedLicense(ResolvedLicenseType::SUBSCRIPTION, PostLicense::trial())],
);
$response = $this->consoleApi(
diff --git a/backend/tests/Api/Console/ConsoleInitTest.php b/backend/tests/Api/Console/ConsoleInitTest.php
index 61250b15..9d200bca 100644
--- a/backend/tests/Api/Console/ConsoleInitTest.php
+++ b/backend/tests/Api/Console/ConsoleInitTest.php
@@ -27,9 +27,6 @@
class ConsoleInitTest extends WebTestCase
{
- // TODO: tests for input validation
- // TODO: tests for authentication
-
protected function shouldEnableAuthFake(): bool
{
return false;
@@ -43,8 +40,8 @@ private function enableAuthFake(bool $withOrganization = true): void
$withOrganization ? new AuthUserOrganization(
id: 1,
name: 'Fake Organization',
- role: 'admin'
- ) : null
+ role: 'admin',
+ ) : null,
);
}
@@ -60,7 +57,7 @@ public function testInitConsole(): void
UserFactory::createOne([
'newsletter' => $newsletter,
'hyvor_user_id' => 1,
- 'role' => UserRole::OWNER
+ 'role' => UserRole::OWNER,
]);
}
@@ -74,14 +71,14 @@ public function testInitConsole(): void
]);
$newsletterAdmin = NewsletterFactory::createOne([
- 'organization_id' => 1
+ 'organization_id' => 1,
]);
// admin
$user = UserFactory::createOne([
'newsletter' => $newsletterAdmin,
'hyvor_user_id' => 1,
- 'role' => UserRole::ADMIN
+ 'role' => UserRole::ADMIN,
]);
$noAccessNewsletter = NewsletterFactory::createOne([
@@ -90,18 +87,18 @@ public function testInitConsole(): void
UserFactory::createOne([
'newsletter' => $noAccessNewsletter,
'hyvor_user_id' => 2,
- 'role' => UserRole::OWNER
+ 'role' => UserRole::OWNER,
]);
BillingFake::enableForSymfony(
$this->container,
- [1 => new ResolvedLicense(ResolvedLicenseType::TRIAL, PostLicense::trial())]
+ [1 => new ResolvedLicense(ResolvedLicenseType::TRIAL, PostLicense::trial())],
);
$response = $this->consoleApi(
null,
'GET',
- '/init'
+ '/init',
);
$this->assertSame(200, $response->getStatusCode());
@@ -139,7 +136,7 @@ public function testInitConsoleWithoutOrg(): void
$response = $this->consoleApi(
null,
'GET',
- '/init'
+ '/init',
);
$this->assertSame(200, $response->getStatusCode());
@@ -182,12 +179,12 @@ public function testInitNewsletter(): void
$user = UserFactory::createOne([
'newsletter' => $newsletter,
'hyvor_user_id' => 1,
- 'role' => UserRole::OWNER
+ 'role' => UserRole::OWNER,
]);
BillingFake::enableForSymfony(
$this->container,
- [1 => new ResolvedLicense(ResolvedLicenseType::SUBSCRIPTION, PostLicense::trial())]
+ [1 => new ResolvedLicense(ResolvedLicenseType::SUBSCRIPTION, PostLicense::trial())],
);
$response = $this->consoleApi(
@@ -218,25 +215,18 @@ public function testInitNewsletterWithLists(): void
$user = UserFactory::createOne([
'newsletter' => $newsletter,
'hyvor_user_id' => 1,
- 'role' => UserRole::OWNER
+ 'role' => UserRole::OWNER,
]);
$newsletterList = NewsletterListFactory::createOne([
'newsletter' => $newsletter,
]);
- $subscribersOldUnsubscribed = SubscriberFactory::createMany(2, [
- 'newsletter' => $newsletter,
- 'lists' => [$newsletterList],
- 'created_at' => new \DateTimeImmutable('2021-01-01'),
- 'status' => SubscriberStatus::UNSUBSCRIBED
- ]);
-
$subscribersOld = SubscriberFactory::createMany(5, [
'newsletter' => $newsletter,
'lists' => [$newsletterList],
'created_at' => new \DateTimeImmutable('2021-01-01'),
- 'status' => SubscriberStatus::SUBSCRIBED
+ 'status' => SubscriberStatus::SUBSCRIBED,
]);
@@ -244,24 +234,20 @@ public function testInitNewsletterWithLists(): void
'newsletter' => $newsletter,
'lists' => [$newsletterList],
'created_at' => new \DateTimeImmutable(),
- 'status' => SubscriberStatus::SUBSCRIBED
+ 'status' => SubscriberStatus::SUBSCRIBED,
]);
- foreach ($subscribersOldUnsubscribed as $subscriber) {
- $newsletterList->addSubscriber($subscriber->_real());
- }
-
foreach ($subscribersOld as $subscriber) {
- $newsletterList->addSubscriber($subscriber->_real());
+ $newsletterList->addSubscriber($subscriber);
}
foreach ($subscribersNew as $subscriber) {
- $newsletterList->addSubscriber($subscriber->_real());
+ $newsletterList->addSubscriber($subscriber);
}
BillingFake::enableForSymfony(
$this->container,
- [1 => new ResolvedLicense(ResolvedLicenseType::SUBSCRIPTION, PostLicense::trial())]
+ [1 => new ResolvedLicense(ResolvedLicenseType::SUBSCRIPTION, PostLicense::trial())],
);
$response = $this->consoleApi(
diff --git a/backend/tests/Api/Console/Import/ImportTest.php b/backend/tests/Api/Console/Import/ImportTest.php
index be0034aa..477b169d 100644
--- a/backend/tests/Api/Console/Import/ImportTest.php
+++ b/backend/tests/Api/Console/Import/ImportTest.php
@@ -21,7 +21,7 @@ class ImportTest extends WebTestCase
/** @var array */
const array MAPPING = [
'email' => 'email',
- 'lists' => 'lists'
+ 'lists' => 'lists',
];
public function test_import(): void
@@ -37,7 +37,7 @@ public function test_import(): void
/** @var MediaService $mediaService */
$mediaService = $this->container->get(MediaService::class);
$media = $mediaService->upload(
- $newsletter->_real(),
+ $newsletter,
MediaFolder::IMPORT,
$file,
);
@@ -45,7 +45,7 @@ public function test_import(): void
$subscriberImport = SubscriberImportFactory::createOne([
'newsletter' => $newsletter,
'media' => $media,
- 'status' => SubscriberImportStatus::REQUIRES_INPUT
+ 'status' => SubscriberImportStatus::REQUIRES_INPUT,
]);
$response = $this->consoleApi(
@@ -53,8 +53,8 @@ public function test_import(): void
'POST',
'/imports/' . $subscriberImport->getId(),
[
- 'mapping' => self::MAPPING
- ]
+ 'mapping' => self::MAPPING,
+ ],
);
$this->assertSame(200, $response->getStatusCode());
@@ -75,7 +75,7 @@ public function test_import_in_non_pending_status(): void
$subscriberImport = SubscriberImportFactory::createOne([
'newsletter' => $newsletter,
- 'status' => SubscriberImportStatus::COMPLETED
+ 'status' => SubscriberImportStatus::COMPLETED,
]);
$response = $this->consoleApi(
@@ -83,8 +83,8 @@ public function test_import_in_non_pending_status(): void
'POST',
'/imports/' . $subscriberImport->getId(),
[
- 'mapping' => self::MAPPING
- ]
+ 'mapping' => self::MAPPING,
+ ],
);
$this->assertSame(422, $response->getStatusCode());
@@ -99,7 +99,7 @@ public function test_import_without_email_mapping(): void
$subscriberImport = SubscriberImportFactory::createOne([
'newsletter' => $newsletter,
- 'status' => SubscriberImportStatus::REQUIRES_INPUT
+ 'status' => SubscriberImportStatus::REQUIRES_INPUT,
]);
$response = $this->consoleApi(
@@ -108,9 +108,9 @@ public function test_import_without_email_mapping(): void
'/imports/' . $subscriberImport->getId(),
[
'mapping' => [
- 'lists' => 'lists'
- ]
- ]
+ 'lists' => 'lists',
+ ],
+ ],
);
$this->assertSame(422, $response->getStatusCode());
@@ -129,7 +129,7 @@ public function test_import_with_null_email_mapping(): void
$subscriberImport = SubscriberImportFactory::createOne([
'newsletter' => $newsletter,
- 'status' => SubscriberImportStatus::REQUIRES_INPUT
+ 'status' => SubscriberImportStatus::REQUIRES_INPUT,
]);
$response = $this->consoleApi(
@@ -138,9 +138,9 @@ public function test_import_with_null_email_mapping(): void
'/imports/' . $subscriberImport->getId(),
[
'mapping' => [
- 'email' => null
- ]
- ]
+ 'email' => null,
+ ],
+ ],
);
$this->assertSame(422, $response->getStatusCode());
@@ -159,7 +159,7 @@ public function test_import_with_empty_email_mapping(): void
$subscriberImport = SubscriberImportFactory::createOne([
'newsletter' => $newsletter,
- 'status' => SubscriberImportStatus::REQUIRES_INPUT
+ 'status' => SubscriberImportStatus::REQUIRES_INPUT,
]);
$response = $this->consoleApi(
@@ -168,9 +168,9 @@ public function test_import_with_empty_email_mapping(): void
'/imports/' . $subscriberImport->getId(),
[
'mapping' => [
- 'email' => ''
- ]
- ]
+ 'email' => '',
+ ],
+ ],
);
$this->assertSame(422, $response->getStatusCode());
@@ -192,7 +192,7 @@ public function test_daily_import_limit(): void
SubscriberImportFactory::createOne([
'newsletter' => $newsletter,
'created_at' => $date,
- 'status' => SubscriberImportStatus::COMPLETED
+ 'status' => SubscriberImportStatus::COMPLETED,
]);
$response = $this->consoleApi(
@@ -200,8 +200,8 @@ public function test_daily_import_limit(): void
'POST',
'/imports/upload',
parameters: [
- 'source' => 'test'
- ]
+ 'source' => 'test',
+ ],
);
$this->assertSame(422, $response->getStatusCode());
@@ -219,7 +219,7 @@ public function test_monthly_import_limit(): void
SubscriberImportFactory::createMany(5, [
'newsletter' => $newsletter,
'created_at' => $date->modify('-7 day'),
- 'status' => SubscriberImportStatus::COMPLETED
+ 'status' => SubscriberImportStatus::COMPLETED,
]);
$response = $this->consoleApi(
@@ -227,8 +227,8 @@ public function test_monthly_import_limit(): void
'POST',
'/imports/upload',
parameters: [
- 'source' => 'test'
- ]
+ 'source' => 'test',
+ ],
);
$this->assertSame(422, $response->getStatusCode());
@@ -252,11 +252,11 @@ public function test_import_upload_small(): void
'POST',
'/imports/upload',
files: [
- 'file' => $file
+ 'file' => $file,
],
parameters: [
- 'source' => 'test'
- ]
+ 'source' => 'test',
+ ],
);
$this->assertSame(200, $response->getStatusCode());
diff --git a/backend/tests/Api/Console/Subscriber/GetSubscribersTest.php b/backend/tests/Api/Console/Subscriber/GetSubscribersTest.php
index 6cbe8034..9ec0f458 100644
--- a/backend/tests/Api/Console/Subscriber/GetSubscribersTest.php
+++ b/backend/tests/Api/Console/Subscriber/GetSubscribersTest.php
@@ -23,8 +23,6 @@
class GetSubscribersTest extends WebTestCase
{
- // TODO: tests for authentication
-
public function testListSubscribersNonEmpty(): void
{
$newsletter = NewsletterFactory::createOne();
@@ -48,7 +46,7 @@ public function testListSubscribersNonEmpty(): void
$response = $this->consoleApi(
$newsletter,
'GET',
- '/subscribers'
+ '/subscribers',
);
$this->assertSame(200, $response->getStatusCode());
@@ -81,7 +79,7 @@ public function testListSubscribersPagination(): void
$response = $this->consoleApi(
$newsletter,
'GET',
- '/subscribers?limit=2&offset=1'
+ '/subscribers?limit=2&offset=1',
);
$this->assertSame(200, $response->getStatusCode());
@@ -101,7 +99,7 @@ public function testListSubscribersEmpty(): void
$response = $this->consoleApi(
$newsletter,
'GET',
- '/subscribers'
+ '/subscribers',
);
$this->assertSame(200, $response->getStatusCode());
@@ -109,8 +107,8 @@ public function testListSubscribersEmpty(): void
$this->assertCount(0, $json);
}
- #[TestWith([SubscriberStatus::SUBSCRIBED, SubscriberStatus::UNSUBSCRIBED])]
- #[TestWith([SubscriberStatus::UNSUBSCRIBED, SubscriberStatus::SUBSCRIBED])]
+ #[TestWith([SubscriberStatus::SUBSCRIBED, SubscriberStatus::PENDING])]
+ #[TestWith([SubscriberStatus::PENDING, SubscriberStatus::SUBSCRIBED])]
public function testListSubscribersByStatus(SubscriberStatus $status, SubscriberStatus $oppositeStatus): void
{
$newsletter = NewsletterFactory::createOne();
@@ -133,7 +131,7 @@ public function testListSubscribersByStatus(SubscriberStatus $status, Subscriber
$response = $this->consoleApi(
$newsletter,
'GET',
- "/subscribers?status={$status->value}"
+ "/subscribers?status={$status->value}",
);
$this->assertSame(200, $response->getStatusCode());
@@ -176,7 +174,7 @@ public function test_list_subscribers_email_search(): void
$response = $this->consoleApi(
$newsletter,
'GET',
- "/subscribers?search=thibault"
+ "/subscribers?search=thibault",
);
$this->assertSame(200, $response->getStatusCode());
@@ -194,15 +192,15 @@ public function test_list_subscribers_list_search(): void
$list1 = NewsletterListFactory::createOne(
[
'newsletter' => $newsletter,
- 'name' => 'list_1'
- ]
+ 'name' => 'list_1',
+ ],
);
$list2 = NewsletterListFactory::createOne(
[
'newsletter' => $newsletter,
- 'name' => 'list_2'
- ]
+ 'name' => 'list_2',
+ ],
);
$subscriber1 = SubscriberFactory::createOne([
@@ -222,7 +220,7 @@ public function test_list_subscribers_list_search(): void
$response = $this->consoleApi(
$newsletter,
'GET',
- "/subscribers?list_id={$list1->getId()}"
+ "/subscribers?list_id={$list1->getId()}",
);
$this->assertSame(200, $response->getStatusCode());
diff --git a/backend/tests/Api/Sudo/SubscriberImports/GetImportingSubscribersTest.php b/backend/tests/Api/Sudo/SubscriberImports/GetImportingSubscribersTest.php
index 71cb6667..a55a7841 100644
--- a/backend/tests/Api/Sudo/SubscriberImports/GetImportingSubscribersTest.php
+++ b/backend/tests/Api/Sudo/SubscriberImports/GetImportingSubscribersTest.php
@@ -31,7 +31,7 @@ private function uploadImportFile(): SubscriberImport
/** @var MediaService $mediaService */
$mediaService = $this->container->get(MediaService::class);
$media = $mediaService->upload(
- $newsletter->_real(),
+ $newsletter,
MediaFolder::IMPORT,
$file,
);
@@ -42,7 +42,7 @@ private function uploadImportFile(): SubscriberImport
'status' => SubscriberImportStatus::PENDING_APPROVAL,
'fields' => [
'email' => 'email',
- 'lists' => 'lists'
+ 'lists' => 'lists',
],
'csv_fields' => ['email', 'lists', 'extra_col_1', 'extra_col_2'],
@@ -102,4 +102,4 @@ public function test_get_importing_subscribers_with_limit_and_offset(): void
$this->assertArrayHasKey('email', $importingSubscriber2);
$this->assertSame('doe@hyvor.com', $importingSubscriber2['email']);
}
-}
\ No newline at end of file
+}
diff --git a/backend/tests/Case/WebTestCase.php b/backend/tests/Case/WebTestCase.php
index a95a6b56..4d91571d 100644
--- a/backend/tests/Case/WebTestCase.php
+++ b/backend/tests/Case/WebTestCase.php
@@ -112,14 +112,13 @@ public function consoleApi(
true|array $scopes = true,
bool $useSession = false,
): Response {
+ $newsletterId = null;
if ($newsletter instanceof Newsletter) {
$newsletterId = $newsletter->getId();
- } else {
- if ($newsletter) {
- $newsletterId = $newsletter;
- $newsletter = NewsletterFactory::findBy(['id' => $newsletterId]);
- $newsletter = count($newsletter) > 0 ? $newsletter[0] : null;
- }
+ } elseif ($newsletter) {
+ $newsletterId = $newsletter;
+ $newsletter = NewsletterFactory::findBy(['id' => $newsletterId]);
+ $newsletter = count($newsletter) > 0 ? $newsletter[0] : null;
}
if ($newsletter) {
diff --git a/backend/tests/MessageHandler/Import/ImportSubscribersMessageHandlerTest.php b/backend/tests/MessageHandler/Import/ImportSubscribersMessageHandlerTest.php
index 04b28101..d3dfee01 100644
--- a/backend/tests/MessageHandler/Import/ImportSubscribersMessageHandlerTest.php
+++ b/backend/tests/MessageHandler/Import/ImportSubscribersMessageHandlerTest.php
@@ -32,7 +32,7 @@ public function test_import_subscribers(): void
/** @var MediaService $mediaService */
$mediaService = $this->container->get(MediaService::class);
$media = $mediaService->upload(
- $newsletter->_real(),
+ $newsletter,
MediaFolder::IMPORT,
$file,
);
@@ -53,7 +53,7 @@ public function test_import_subscribers(): void
'status' => SubscriberImportStatus::IMPORTING,
'fields' => [
"email" => "email",
- "lists" => "lists"
+ "lists" => "lists",
],
]);
@@ -63,8 +63,8 @@ public function test_import_subscribers(): void
$this->transport('async')->throwExceptions()->process();
$importedSubscribers = $this->em->getRepository(Subscriber::class)->findBy(
- ['newsletter' => $newsletter->_real()],
- ['id' => 'ASC']
+ ['newsletter' => $newsletter],
+ ['id' => 'ASC'],
);
$this->assertCount(3, $importedSubscribers);
diff --git a/backend/tests/MessageHandler/Subscriber/ExportSubscribersMessageHandlerTest.php b/backend/tests/MessageHandler/Subscriber/ExportSubscribersMessageHandlerTest.php
index 6e69d3b5..fcd6c72b 100644
--- a/backend/tests/MessageHandler/Subscriber/ExportSubscribersMessageHandlerTest.php
+++ b/backend/tests/MessageHandler/Subscriber/ExportSubscribersMessageHandlerTest.php
@@ -61,7 +61,7 @@ public function test_export_subscribers(): void
$this->transport('async')->throwExceptions()->process();
$media = $this->em->getRepository(Media::class)->findBy([
- 'newsletter' => $newsletter->_real(),
+ 'newsletter' => $newsletter,
'folder' => MediaFolder::EXPORT,
]);
$this->assertCount(1, $media);
@@ -74,17 +74,20 @@ public function test_export_subscribers(): void
$read = $filesystem->read(
$newsletter->getId() . '/' .
MediaFolder::EXPORT->value . '/' .
- $media[0]->getUuid() . '.' . $media[0]->getExtension()
+ $media[0]->getUuid() . '.' . $media[0]->getExtension(),
);
// Headers
- $this->assertStringContainsString("Email,Status,\"Subscribed At\",Source,\"{$metadata[0]->getKey()}\",\"{$metadata[1]->getKey()}\"", $read);
+ $this->assertStringContainsString(
+ "Email,Status,\"Subscribed At\",Source,\"{$metadata[0]->getKey()}\",\"{$metadata[1]->getKey()}\"",
+ $read,
+ );
// Subscriber rows
$subscriberMetadata = $subscriber->getMetadata();
$this->assertStringContainsString(
"{$subscriber->getEmail()},{$subscriber->getStatus()->value},\"{$subscriber->getSubscribedAt()?->format('Y-m-d H:i:s')}\",{$subscriber->getSource()->value},{$subscriberMetadata[$metadata[0]->getKey()]},{$subscriberMetadata[$metadata[1]->getKey()]}",
- $read
+ $read,
);
}
@@ -102,7 +105,7 @@ public function test_export_subscribers_with_no_subscribers(): void
// Verify the file was created and uploaded even with no subscribers
$media = $this->em->getRepository(Media::class)->findBy([
- 'newsletter' => $newsletter->_real(),
+ 'newsletter' => $newsletter,
'folder' => MediaFolder::EXPORT,
]);
$this->assertCount(1, $media);
@@ -114,11 +117,10 @@ public function test_export_subscribers_with_no_subscribers(): void
$read = $filesystem->read(
$newsletter->getId() . '/' .
MediaFolder::EXPORT->value . '/' .
- $media[0]->getUuid() . '.' . $media[0]->getExtension()
+ $media[0]->getUuid() . '.' . $media[0]->getExtension(),
);
// Only default headers should be present
$this->assertSame("Email,Status,\"Subscribed At\",Source\n", $read);
-
}
}
diff --git a/backend/tests/Service/Export/Subscriber/ExportSubscriberTest.php b/backend/tests/Service/Export/Subscriber/ExportSubscriberTest.php
index c20c81ff..069ff471 100644
--- a/backend/tests/Service/Export/Subscriber/ExportSubscriberTest.php
+++ b/backend/tests/Service/Export/Subscriber/ExportSubscriberTest.php
@@ -25,10 +25,10 @@ public function test_export_subscriber(): void
]);
$exporter = new SubscriberCsvExporter(
- $this->em
+ $this->em,
);
- $file = $exporter->createFile($newsletter->_real());
+ $file = $exporter->createFile($newsletter);
$read = file_get_contents($file);
$this->assertNotFalse($read);
$lines = explode("\n", $read);
diff --git a/backend/tests/Service/Import/CsvParserTest.php b/backend/tests/Service/Import/CsvParserTest.php
index 1e359877..75a49e2d 100644
--- a/backend/tests/Service/Import/CsvParserTest.php
+++ b/backend/tests/Service/Import/CsvParserTest.php
@@ -40,7 +40,7 @@ public function test_parse(): void
/** @var MediaService $mediaService */
$mediaService = $this->container->get(MediaService::class);
$media = $mediaService->upload(
- $newsletter->_real(),
+ $newsletter,
MediaFolder::IMPORT,
$file,
);
diff --git a/backend/tests/Service/Media/DeleteMediaTest.php b/backend/tests/Service/Media/DeleteMediaTest.php
index 99057ca9..e9dff008 100644
--- a/backend/tests/Service/Media/DeleteMediaTest.php
+++ b/backend/tests/Service/Media/DeleteMediaTest.php
@@ -60,7 +60,7 @@ public function test_delete_media(): void
$content,
);
- $mediaService->delete($media1->_real());
+ $mediaService->delete($media1);
$this->assertFalse($filesystem->fileExists($mediaService->getUploadPath($media1)));
$this->assertNull($mediaService->getMediaByUuid($uuid1));
diff --git a/frontend/src/routes/(marketing)/docs/[...slug]/content/ConsoleApi.svelte b/frontend/src/routes/(marketing)/docs/[...slug]/content/ConsoleApi.svelte
index a30a6601..cb0446c0 100644
--- a/frontend/src/routes/(marketing)/docs/[...slug]/content/ConsoleApi.svelte
+++ b/frontend/src/routes/(marketing)/docs/[...slug]/content/ConsoleApi.svelte
@@ -1,46 +1,46 @@
Console API
- The Console API allows you to automate your newsletter-related tasks over HTTP with API key
- authentication. This is the same API that we internally use at the Console.
+ The Console API allows you to automate your newsletter-related tasks over HTTP with API key
+ authentication. This is the same API that we internally use at the Console.
Getting Started
- -
- Create a Console API key at Console → Settings → API Keys.
-
- - The base URL:
https://post.hyvor.com/api/console
- -
- For each request, set
Authorization header to
- Bearer {''}.
-
- - Available HTTP methods:
-
- GET - Retrieve a resource
- POST - Create a resource or perform an action
- PUT - Update a resource
- DELETE - Remove a resource
-
- -
- Request params can be set as
JSON (recommended) or as
- application/x-www-form-urlencoded.
-
- - All endpoints return JSON data. The response will be an object or an array of objects.
+ -
+ Create a Console API key at Console → Settings → API Keys.
+
+ - The base URL:
https://post.hyvor.com/api/console
+ -
+ For each request, set
Authorization header to
+ Bearer {''}.
+
+ - Available HTTP methods:
+
+ GET - Retrieve a resource
+ POST - Create a resource or perform an action
+ PUT - Update a resource
+ DELETE - Remove a resource
+
+ -
+ Request params can be set as
JSON (recommended) or as
+ application/x-www-form-urlencoded.
+
+ - All endpoints return JSON data. The response will be an object or an array of objects.
-
- In this documentation, all objects, request params, and responses are written as Typescript interfaces in order to make type declarations concise.
-
+
+ In this documentation, all objects, request params, and responses are written as Typescript interfaces in order to make type declarations concise.
+
Categories
@@ -50,16 +50,16 @@
Jump to each category:
@@ -69,16 +69,16 @@
Endpoints:
Objects:
Get newsletter data
@@ -86,8 +86,8 @@
GET /newsletter
PATCH /newsletter
// except id, created_at
type Response = Newsletter
`}
@@ -110,22 +110,22 @@
Endpoints:
Objects:
Get issues
@@ -133,8 +133,8 @@
GET /issues
POST /issues
GET /issues/{'{id}'}
PATCH /issues/{'{id}'}
DELETE /issues/{'{id}'}
POST /issues/{'{id}'}/send
GET /issues/{'{id}'}/sends
Endpoints:
Objects:
Create a list
@@ -241,8 +241,8 @@
POST /lists
PATCH /lists/{'{id}'}
DELETE /lists/{'{id}'}
Endpoints:
Objects:
Get subscribers
@@ -305,20 +305,20 @@
GET /subscribers
POST /subscribers
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.
+ 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:
+ 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.
-
+ -
+
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_removal_reason:
+ list_removal_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.
-
+ -
+
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.
+
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).
-
-
-
+
+ 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.
-
-
-
+
+
+
+
+ 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 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.
-
-
+
+
+
+
+ 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.
-
-
+
+
+
+
+ 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_skip_resubscribe_on.
-
-
+ />
+
+
+ To force re-adding both previous unsubscribes and bounces, use an empty array for list_skip_resubscribe_on.
+
+
Delete a subscriber
@@ -547,8 +547,8 @@
DELETE /subscribers/{'{id}'}
POST /subscribers/bulk
Subscriber Metadata
- Subscriber metadata definitions allow you to define custom fields for subscribers. These fields
- can be used to store additional information about subscribers.
+ Subscriber metadata definitions allow you to define custom fields for subscribers. These fields
+ can be used to store additional information about subscribers.
Endpoints:
Objects:
@@ -615,8 +615,8 @@
POST /subscriber-metadata-definitions
-
- key can only contain lowercase letters, numbers, and underscores.
- - Once created, the
key cannot be changed.
-
+
+ key can only contain lowercase letters, numbers, and underscores.
+ - Once created, the
key cannot be changed.
+
@@ -637,8 +637,8 @@
PATCH /subscriber-metadata-definitions/{'{id}'}
DELETE /subscriber-metadata-definitions/{'{id}'}
Endpoints:
Objects:
Get sending profiles
@@ -690,8 +690,8 @@
GET /sending-profiles
POST /sending-profiles
PATCH /sending-profiles/{'{id}'}
DELETE /sending-profiles/{'{id}'}
Endpoints:
Objects:
Get newsletter template
@@ -777,8 +777,8 @@ appearance of your newsletters.
GET /templates
PATCH /templates
POST /templates/render
User
- The owner of the newsletter can invite other users as Admins to collaborate on managing the
- newsletter.
+ The owner of the newsletter can invite other users as Admins to collaborate on managing the
+ newsletter.
Endpoints:
Objects:
Get user
@@ -843,8 +843,8 @@ appearance of your newsletters.
GET /users
DELETE /users/{'{id}'}
GET /invites
POST /invites
- You must ask your Admins to create a HYVOR account before sending an invitation.
+ You must ask your Admins to create a HYVOR account before sending an invitation.
-
- -
- Either
username or email of the invitee's HYVOR account is required.
-
-
+
+ -
+ Either
username or email of the invitee's HYVOR account is required.
+
+
Delete an invite
@@ -908,8 +908,8 @@ appearance of your newsletters.
DELETE /invites/{'{id}'}
Endpoints:
Objects:
@@ -934,12 +934,12 @@ appearance of your newsletters.
POST /media
Endpoints:
Objects:
Get subscriber exports
@@ -966,8 +966,8 @@ appearance of your newsletters.
GET /export
POST /export
Newsletter Object
Issue Object
Send Object
List Object
Subscriber Object
;
}
`}
@@ -1146,8 +1145,8 @@ appearance of your newsletters.
Sending Profile Object
Template Object
User Mini Object
User Object
User Invite Object
Media Object
Subscriber Export Object
;
+ id: number;
+ email: string;
+ status: NewsletterSubscriberStatus;
+ list_ids: number[];
+ source: NewsletterSubscriberSource;
+ is_opted_in: boolean;
+ subscribed_at: number;
+ metadata: Record;
};
export type IssueStatus = 'draft' | 'scheduled' | 'sending' | 'failed' | 'sent';
export type Issue = {
- id: number;
- uuid: string;
- created_at: number;
- subject: string;
- content: string;
- sending_profile_id: number;
- status: IssueStatus;
- lists: number[];
- scheduled_at: number | null;
- sending_at: number | null;
- sent_at: number | null;
-
- total_sends: number;
- opened_sends: number;
- clicked_sends: number;
-
- sendable_subscribers_count: number;
+ id: number;
+ uuid: string;
+ created_at: number;
+ subject: string;
+ content: string;
+ sending_profile_id: number;
+ status: IssueStatus;
+ lists: number[];
+ scheduled_at: number | null;
+ sending_at: number | null;
+ sent_at: number | null;
+
+ total_sends: number;
+ opened_sends: number;
+ clicked_sends: number;
+
+ sendable_subscribers_count: number;
};
export type SendStatus = 'pending' | 'sent' | 'failed';
export type SendType = 'all' | 'unsubscribed' | 'bounced' | 'complained';
export interface IssueSend {
- id: number;
- created_at: number;
- subscriber: Subscriber | null;
- email: string;
- status: SendStatus;
- sent_at: number | null;
- failed_at: number | null;
- delivered_at: number | null;
- unsubscribed_at: number | null;
- bounced_at: number | null;
- hard_bounce: boolean;
- complained_at: number | null;
+ id: number;
+ created_at: number;
+ subscriber: Subscriber | null;
+ email: string;
+ status: SendStatus;
+ sent_at: number | null;
+ failed_at: number | null;
+ delivered_at: number | null;
+ unsubscribed_at: number | null;
+ bounced_at: number | null;
+ hard_bounce: boolean;
+ complained_at: number | null;
}
export type RelayDomainStatus = 'pending' | 'active' | 'warning' | 'suspended';
export type Domain = {
- id: number;
- domain: string;
- dkim_public_key: string;
- dkim_txt_name: string;
- dkim_txt_value: string;
- relay_status: RelayDomainStatus;
- relay_last_checked_at: number | null;
- relay_error_message: string | null;
+ id: number;
+ domain: string;
+ dkim_public_key: string;
+ dkim_txt_name: string;
+ dkim_txt_value: string;
+ relay_status: RelayDomainStatus;
+ relay_last_checked_at: number | null;
+ relay_error_message: string | null;
};
export type ExportStatus = 'pending' | 'completed' | 'failed';
export type Export = {
- id: number;
- created_at: number;
- status: ExportStatus;
- url: string | null;
- error_message: string | null;
+ id: number;
+ created_at: number;
+ status: ExportStatus;
+ url: string | null;
+ error_message: string | null;
};
export type SendingProfile = {
- id: number;
- created_at: number;
- from_email: string;
- from_name: string | null;
- reply_to_email: string | null;
- brand_name: string | null;
- brand_logo: string | null;
- brand_url: string | null;
- is_default: boolean;
- is_system: boolean;
+ id: number;
+ created_at: number;
+ from_email: string;
+ from_name: string | null;
+ reply_to_email: string | null;
+ brand_name: string | null;
+ brand_logo: string | null;
+ brand_url: string | null;
+ is_default: boolean;
+ is_system: boolean;
};
export type MediaFolder = 'issue_images' | 'newsletter_images' | 'import' | 'export';
export type Media = {
- id: number;
- created_at: number;
- folder: MediaFolder;
- url: string;
- extension: string;
- size: number;
+ id: number;
+ created_at: number;
+ folder: MediaFolder;
+ url: string;
+ extension: string;
+ size: number;
};
export type ApprovalStatus = 'pending' | 'reviewing' | 'approved' | 'rejected';
export type Approval = {
- id: number;
- created_at: number;
- status: ApprovalStatus;
- company_name: string;
- country: string;
- website: string;
- social_links: string | null;
- type_of_content: string | null;
- frequency: string | null;
- existing_list: string | null;
- sample: string | null;
- why_post: string | null;
- public_note: string | null;
- approved_at: number | null;
- rejected_at: number | null;
+ id: number;
+ created_at: number;
+ status: ApprovalStatus;
+ company_name: string;
+ country: string;
+ website: string;
+ social_links: string | null;
+ type_of_content: string | null;
+ frequency: string | null;
+ existing_list: string | null;
+ sample: string | null;
+ why_post: string | null;
+ public_note: string | null;
+ approved_at: number | null;
+ rejected_at: number | null;
};
export type ImportStatus =
- | 'requires_input'
- | 'pending_approval'
- | 'importing'
- | 'failed'
- | 'completed';
+ | 'requires_input'
+ | 'pending_approval'
+ | 'importing'
+ | 'failed'
+ | 'completed';
export type Import = {
- id: number;
- created_at: number;
- status: ImportStatus;
- fields: Record | null;
- csv_fields: string[] | null;
- imported_subscribers: number | null;
- warnings: string | null;
- error_message: string | null;
+ id: number;
+ created_at: number;
+ status: ImportStatus;
+ fields: Record | null;
+ csv_fields: string[] | null;
+ imported_subscribers: number | null;
+ warnings: string | null;
+ error_message: string | null;
};
export type ImportLimits = {
- daily_limit_exceeded: boolean;
- monthly_limit_exceeded: boolean;
+ daily_limit_exceeded: boolean;
+ monthly_limit_exceeded: boolean;
};
export type ApiKey = {
- id: number;
- name: string;
- scopes: string[];
- key?: string;
- created_at: number;
- is_enabled: boolean;
- last_accessed_at?: number;
+ id: number;
+ name: string;
+ scopes: string[];
+ key?: string;
+ created_at: number;
+ is_enabled: boolean;
+ last_accessed_at?: number;
};