diff --git a/mailpoet/lib/API/MP/v1/API.php b/mailpoet/lib/API/MP/v1/API.php index 04b14d2b9c..69539ea207 100644 --- a/mailpoet/lib/API/MP/v1/API.php +++ b/mailpoet/lib/API/MP/v1/API.php @@ -68,6 +68,10 @@ class API { return $this->subscribers->unsubscribeFromLists($subscriberId, $listIds); } + public function unsubscribe($subscriberIdOrEmail) { + return $this->subscribers->unsubscribe($subscriberIdOrEmail); + } + public function getLists(): array { return $this->segments->getAll(); } diff --git a/mailpoet/lib/API/MP/v1/APIException.php b/mailpoet/lib/API/MP/v1/APIException.php index fd50d513d5..c05f858d41 100644 --- a/mailpoet/lib/API/MP/v1/APIException.php +++ b/mailpoet/lib/API/MP/v1/APIException.php @@ -24,4 +24,5 @@ class APIException extends \Exception { const LIST_USED_IN_FORM = 21; const FAILED_TO_DELETE_LIST = 22; const LIST_TYPE_IS_NOT_SUPPORTED = 23; + const SUBSCRIBER_ALREADY_UNSUBSCRIBED = 24; } diff --git a/mailpoet/lib/API/MP/v1/Subscribers.php b/mailpoet/lib/API/MP/v1/Subscribers.php index 40230ecc33..b4f46e7c2d 100644 --- a/mailpoet/lib/API/MP/v1/Subscribers.php +++ b/mailpoet/lib/API/MP/v1/Subscribers.php @@ -4,11 +4,13 @@ namespace MailPoet\API\MP\v1; use MailPoet\API\JSON\ResponseBuilders\SubscribersResponseBuilder; use MailPoet\Entities\SegmentEntity; +use MailPoet\Entities\StatisticsUnsubscribeEntity; use MailPoet\Entities\SubscriberEntity; use MailPoet\Listing\ListingDefinition; use MailPoet\Newsletter\Scheduler\WelcomeScheduler; use MailPoet\Segments\SegmentsRepository; use MailPoet\Settings\SettingsController; +use MailPoet\Statistics\Track\Unsubscribes; use MailPoet\Subscribers\ConfirmationEmailMailer; use MailPoet\Subscribers\NewSubscriberNotificationMailer; use MailPoet\Subscribers\RequiredCustomFieldValidator; @@ -62,6 +64,9 @@ class Subscribers { /** @var SubscriberListingRepository */ private $subscriberListingRepository; + /** @var Unsubscribes */ + private $unsubscribesTracker; + public function __construct ( ConfirmationEmailMailer $confirmationEmailMailer, NewSubscriberNotificationMailer $newSubscriberNotificationMailer, @@ -74,7 +79,8 @@ class Subscribers { WelcomeScheduler $welcomeScheduler, RequiredCustomFieldValidator $requiredCustomFieldsValidator, SubscriberListingRepository $subscriberListingRepository, - WPFunctions $wp + WPFunctions $wp, + Unsubscribes $unsubscribesTracker ) { $this->confirmationEmailMailer = $confirmationEmailMailer; $this->newSubscriberNotificationMailer = $newSubscriberNotificationMailer; @@ -88,6 +94,7 @@ class Subscribers { $this->requiredCustomFieldsValidator = $requiredCustomFieldsValidator; $this->wp = $wp; $this->subscriberListingRepository = $subscriberListingRepository; + $this->unsubscribesTracker = $unsubscribesTracker; } public function getSubscriber($subscriberIdOrEmail): array { @@ -229,6 +236,28 @@ class Subscribers { return $this->subscribersResponseBuilder->build($subscriber); } + public function unsubscribe($subscriberIdOrEmail): array { + $this->checkSubscriberParam($subscriberIdOrEmail); + $subscriber = $this->findSubscriber($subscriberIdOrEmail); + + if ($subscriber->getStatus() === SubscriberEntity::STATUS_UNSUBSCRIBED) { + throw new APIException(__('This subscriber is already unsubscribed.', 'mailpoet'), APIException::SUBSCRIBER_ALREADY_UNSUBSCRIBED); + } + + $this->unsubscribesTracker->track( + (int)$subscriber->getId(), + StatisticsUnsubscribeEntity::SOURCE_MP_API + ); + + $subscriber->setStatus(SubscriberEntity::STATUS_UNSUBSCRIBED); + $this->subscribersRepository->persist($subscriber); + $this->subscribersRepository->flush(); + + $this->subscribersSegmentRepository->unsubscribeFromSegments($subscriber); + + return $this->subscribersResponseBuilder->build($subscriber); + } + public function unsubscribeFromLists($subscriberIdOrEmail, array $listIds): array { $this->checkSubscriberAndListParams($subscriberIdOrEmail, $listIds); $subscriber = $this->findSubscriber($subscriberIdOrEmail); @@ -321,6 +350,13 @@ class Subscribers { if (empty($listIds)) { throw new APIException(__('At least one segment ID is required.', 'mailpoet'), APIException::SEGMENT_REQUIRED); } + $this->checkSubscriberParam($subscriberIdOrEmail); + } + + /** + * @throws APIException + */ + private function checkSubscriberParam($subscriberIdOrEmail): void { if (empty($subscriberIdOrEmail)) { throw new APIException(__('A subscriber is required.', 'mailpoet'), APIException::SUBSCRIBER_NOT_EXISTS); } diff --git a/mailpoet/lib/Entities/StatisticsUnsubscribeEntity.php b/mailpoet/lib/Entities/StatisticsUnsubscribeEntity.php index 903bbb87b0..99395ecd67 100644 --- a/mailpoet/lib/Entities/StatisticsUnsubscribeEntity.php +++ b/mailpoet/lib/Entities/StatisticsUnsubscribeEntity.php @@ -21,6 +21,7 @@ class StatisticsUnsubscribeEntity { const SOURCE_ADMINISTRATOR = 'admin'; const SOURCE_ORDER_CHECKOUT = 'order_checkout'; const SOURCE_AUTOMATION = 'automation'; + const SOURCE_MP_API = 'mp_api'; const METHOD_LINK = 'link'; const METHOD_ONE_CLICK = 'one_click'; diff --git a/mailpoet/tests/integration/API/MP/SubscribersTest.php b/mailpoet/tests/integration/API/MP/SubscribersTest.php index 8d2242fcbc..b1ce2371b3 100644 --- a/mailpoet/tests/integration/API/MP/SubscribersTest.php +++ b/mailpoet/tests/integration/API/MP/SubscribersTest.php @@ -14,6 +14,7 @@ use MailPoet\Config\Changelog; use MailPoet\CustomFields\CustomFieldsRepository; use MailPoet\Entities\CustomFieldEntity; use MailPoet\Entities\SegmentEntity; +use MailPoet\Entities\StatisticsUnsubscribeEntity; use MailPoet\Entities\SubscriberEntity; use MailPoet\Entities\SubscriberSegmentEntity; use MailPoet\Models\ScheduledTask; @@ -284,6 +285,75 @@ class SubscribersTest extends \MailPoetTest { $API->subscribeToLists($subscriber->getId(), [$segment->getId()], $options); } + public function testUnsubscribeRaisesExceptionWhenSubscriberIdIsNotPassed() { + try { + $this->getApi()->unsubscribe(false); + $this->fail('Subscriber does not exist exception should have been thrown.'); + } catch (\Exception $e) { + expect($e->getMessage())->equals('A subscriber is required.'); + } + } + + public function testUnsubscribeRaisesExceptionWhenSubscriberDoesNotExist() { + try { + $this->getApi()->unsubscribe('asdf'); + $this->fail('Subscriber does not exist exception should have been thrown.'); + } catch (\Exception $e) { + expect($e->getMessage())->equals('This subscriber does not exist.'); + } + } + + public function testUnsubscribeRaisesExceptionIfSubscriberAlreadyUnsubscribed() { + $subscriber = $this->subscriberFactory + ->withStatus(SubscriberEntity::STATUS_UNSUBSCRIBED) + ->create(); + + try { + $this->getApi()->unsubscribe($subscriber->getId()); + $this->fail('Subscriber already unsubscribed exception should have been thrown.'); + } catch (\Exception $e) { + expect($e->getMessage())->equals('This subscriber is already unsubscribed.'); + } + } + + public function testUnsubscribesSubscriberFromAllListsAndChangesItsStatus() { + $subscriber = $this->subscriberFactory->create(); + $segment1 = $this->getSegment('Segment 1'); + $segment2 = $this->getSegment('Segment 2'); + $this->getApi()->subscribeToLists($subscriber->getId(), [$segment1->getId(), $segment2->getId()]); + $this->assertSame(SubscriberEntity::STATUS_SUBSCRIBED, $subscriber->getStatus()); + + $result = $this->getApi()->unsubscribe($subscriber->getId()); + $this->assertSame(SubscriberEntity::STATUS_UNSUBSCRIBED, $subscriber->getStatus()); + + foreach ($subscriber->getSubscriberSegments() as $subscriberSegment) { + $this->assertSame(SubscriberEntity::STATUS_UNSUBSCRIBED, $subscriberSegment->getStatus()); + } + + $this->assertSame(SubscriberEntity::STATUS_UNSUBSCRIBED, $result['status']); + $this->assertSame(SubscriberEntity::STATUS_UNSUBSCRIBED, $result['subscriptions'][0]['status']); + $this->assertSame(SubscriberEntity::STATUS_UNSUBSCRIBED, $result['subscriptions'][1]['status']); + } + + public function testUnsubscribesSubscriberFromAllListsAndChangesItsStatusUsingEmailInsteadOfId() { + $subscriber = $this->subscriberFactory->create(); + $segment1 = $this->getSegment('Segment 1'); + $segment2 = $this->getSegment('Segment 2'); + $this->getApi()->subscribeToLists($subscriber->getId(), [$segment1->getId(), $segment2->getId()]); + $this->assertSame(SubscriberEntity::STATUS_SUBSCRIBED, $subscriber->getStatus()); + + $result = $this->getApi()->unsubscribe($subscriber->getEmail()); + $this->assertSame(SubscriberEntity::STATUS_UNSUBSCRIBED, $subscriber->getStatus()); + + foreach ($subscriber->getSubscriberSegments() as $subscriberSegment) { + $this->assertSame(SubscriberEntity::STATUS_UNSUBSCRIBED, $subscriberSegment->getStatus()); + } + + $this->assertSame(SubscriberEntity::STATUS_UNSUBSCRIBED, $result['status']); + $this->assertSame(SubscriberEntity::STATUS_UNSUBSCRIBED, $result['subscriptions'][0]['status']); + $this->assertSame(SubscriberEntity::STATUS_UNSUBSCRIBED, $result['subscriptions'][1]['status']); + } + public function testItDoesNotUnsubscribeWhenSubscriberIdNotPassedFromLists() { try { $this->getApi()->unsubscribeFromLists(false, [1,2,3]); @@ -961,6 +1031,7 @@ class SubscribersTest extends \MailPoetTest { } public function _after() { + $this->truncateEntity(StatisticsUnsubscribeEntity::class); $this->truncateEntity(SubscriberSegmentEntity::class); $this->truncateEntity(SubscriberEntity::class); $this->truncateEntity(SegmentEntity::class);