diff --git a/lib/API/JSON/v1/ImportExport.php b/lib/API/JSON/v1/ImportExport.php index 89d59a803f..5c36bf28a6 100644 --- a/lib/API/JSON/v1/ImportExport.php +++ b/lib/API/JSON/v1/ImportExport.php @@ -5,10 +5,16 @@ namespace MailPoet\API\JSON\v1; use MailPoet\API\JSON\Endpoint as APIEndpoint; use MailPoet\Config\AccessControl; use MailPoet\Cron\Workers\WooCommerceSync; +use MailPoet\CustomFields\CustomFieldsRepository; use MailPoet\Models\ScheduledTask; use MailPoet\Models\Segment; +use MailPoet\Newsletter\Options\NewsletterOptionsRepository; use MailPoet\Segments\WP; +use MailPoet\Subscribers\ImportExport\Import\Import; use MailPoet\Subscribers\ImportExport\Import\MailChimp; +use MailPoet\Subscribers\SubscriberCustomFieldRepository; +use MailPoet\Subscribers\SubscriberSegmentRepository; +use MailPoet\Subscribers\SubscribersRepository; use MailPoetVendor\Carbon\Carbon; class ImportExport extends APIEndpoint { @@ -16,12 +22,39 @@ class ImportExport extends APIEndpoint { /** @var WP */ private $wpSegment; + /** @var CustomFieldsRepository */ + private $customFieldsRepository; + + /** @var NewsletterOptionsRepository */ + private $newsletterOptionsRepository; + + /** @var SubscriberCustomFieldRepository */ + private $subscriberCustomFieldRepository; + + /** @var SubscribersRepository */ + private $subscriberRepository; + + /** @var SubscriberSegmentRepository */ + private $subscriberSegmentRepository; + public $permissions = [ 'global' => AccessControl::PERMISSION_MANAGE_SUBSCRIBERS, ]; - public function __construct(WP $wpSegment) { + public function __construct( + WP $wpSegment, + CustomFieldsRepository $customFieldsRepository, + NewsletterOptionsRepository $newsletterOptionsRepository, + SubscriberCustomFieldRepository $subscriberCustomFieldRepository, + SubscribersRepository $subscribersRepository, + SubscriberSegmentRepository $subscriberSegmentRepository + ) { $this->wpSegment = $wpSegment; + $this->customFieldsRepository = $customFieldsRepository; + $this->newsletterOptionsRepository = $newsletterOptionsRepository; + $this->subscriberCustomFieldRepository = $subscriberCustomFieldRepository; + $this->subscriberRepository = $subscribersRepository; + $this->subscriberSegmentRepository = $subscriberSegmentRepository; } public function getMailChimpLists($data) { @@ -63,8 +96,13 @@ class ImportExport extends APIEndpoint { public function processImport($data) { try { - $import = new \MailPoet\Subscribers\ImportExport\Import\Import( + $import = new Import( $this->wpSegment, + $this->customFieldsRepository, + $this->newsletterOptionsRepository, + $this->subscriberCustomFieldRepository, + $this->subscriberRepository, + $this->subscriberSegmentRepository, json_decode($data, true) ); $process = $import->process(); diff --git a/lib/Entities/SubscriberEntity.php b/lib/Entities/SubscriberEntity.php index 90acd28b06..97f376ae40 100644 --- a/lib/Entities/SubscriberEntity.php +++ b/lib/Entities/SubscriberEntity.php @@ -24,6 +24,9 @@ class SubscriberEntity { const STATUS_UNCONFIRMED = 'unconfirmed'; const STATUS_UNSUBSCRIBED = 'unsubscribed'; + public const OBSOLETE_LINK_TOKEN_LENGTH = 6; + public const LINK_TOKEN_LENGTH = 32; + use AutoincrementedIdTrait; use CreatedAtTrait; use UpdatedAtTrait; diff --git a/lib/Newsletter/Options/NewsletterOptionsRepository.php b/lib/Newsletter/Options/NewsletterOptionsRepository.php index 1d07cc4304..d9284719c2 100644 --- a/lib/Newsletter/Options/NewsletterOptionsRepository.php +++ b/lib/Newsletter/Options/NewsletterOptionsRepository.php @@ -3,7 +3,9 @@ namespace MailPoet\Newsletter\Options; use MailPoet\Doctrine\Repository; +use MailPoet\Entities\NewsletterEntity; use MailPoet\Entities\NewsletterOptionEntity; +use MailPoet\Entities\NewsletterOptionFieldEntity; /** * @extends Repository @@ -12,4 +14,20 @@ class NewsletterOptionsRepository extends Repository { protected function getEntityClassName() { return NewsletterOptionEntity::class; } + + public function findWelcomeNotificationsForSegments(array $segmentIds): array { + return $this->entityManager->createQueryBuilder() + ->select('no') + ->from(NewsletterOptionEntity::class, 'no') + ->join('no.newsletter', 'n') + ->join('no.optionField', 'nof') + ->where('n.deletedAt IS NULL') + ->andWhere('n.type = :typeWelcome') + ->andWhere('nof.name = :nameSegment') + ->andWhere('no.value IN (:segmentIds)') + ->setParameter('typeWelcome', NewsletterEntity::TYPE_WELCOME) + ->setParameter('nameSegment', NewsletterOptionFieldEntity::NAME_SEGMENT) + ->setParameter('segmentIds', $segmentIds) + ->getQuery()->getResult(); + } } diff --git a/lib/Subscribers/ImportExport/Import/Import.php b/lib/Subscribers/ImportExport/Import/Import.php index 137f3bbd56..8f26d1d94f 100644 --- a/lib/Subscribers/ImportExport/Import/Import.php +++ b/lib/Subscribers/ImportExport/Import/Import.php @@ -2,15 +2,17 @@ namespace MailPoet\Subscribers\ImportExport\Import; -use MailPoet\Models\CustomField; +use MailPoet\CustomFields\CustomFieldsRepository; +use MailPoet\Entities\CustomFieldEntity; +use MailPoet\Entities\SubscriberEntity; use MailPoet\Models\ModelValidator; -use MailPoet\Models\Newsletter; -use MailPoet\Models\Subscriber; -use MailPoet\Models\SubscriberCustomField; -use MailPoet\Models\SubscriberSegment; +use MailPoet\Newsletter\Options\NewsletterOptionsRepository; use MailPoet\Segments\WP; use MailPoet\Subscribers\ImportExport\ImportExportFactory; use MailPoet\Subscribers\Source; +use MailPoet\Subscribers\SubscriberCustomFieldRepository; +use MailPoet\Subscribers\SubscriberSegmentRepository; +use MailPoet\Subscribers\SubscribersRepository; use MailPoet\Util\DateConverter; use MailPoet\Util\Helpers; use MailPoet\Util\Security; @@ -35,8 +37,36 @@ class Import { /** @var WP */ private $wpSegment; - public function __construct(WP $wpSegment, $data) { + /** @var CustomFieldsRepository */ + private $customFieldsRepository; + + /** @var NewsletterOptionsRepository */ + private $newsletterOptionsRepository; + + /** @var SubscriberCustomFieldRepository */ + private $subscriberCustomFieldRepository; + + /** @var SubscribersRepository */ + private $subscriberRepository; + + /** @var SubscriberSegmentRepository */ + private $subscriberSegmentRepository; + + public function __construct( + WP $wpSegment, + CustomFieldsRepository $customFieldsRepository, + NewsletterOptionsRepository $newsletterOptionsRepository, + SubscriberCustomFieldRepository $subscriberCustomFieldRepository, + SubscribersRepository $subscriberRepository, + SubscriberSegmentRepository $subscriberSegmentRepository, + array $data + ) { $this->wpSegment = $wpSegment; + $this->customFieldsRepository = $customFieldsRepository; + $this->newsletterOptionsRepository = $newsletterOptionsRepository; + $this->subscriberCustomFieldRepository = $subscriberCustomFieldRepository; + $this->subscriberRepository = $subscriberRepository; + $this->subscriberSegmentRepository = $subscriberSegmentRepository; $this->validateImportData($data); $this->subscribersData = $this->transformSubscribersData( $data['subscribers'], @@ -56,7 +86,7 @@ class Import { $this->createdAt = Carbon::createFromTimestamp(WPFunctions::get()->currentTime('timestamp')); $this->updatedAt = Carbon::createFromTimestamp(WPFunctions::get()->currentTime('timestamp') + 1); $this->requiredSubscribersFields = [ - 'status' => Subscriber::STATUS_SUBSCRIBED, + 'status' => SubscriberEntity::STATUS_SUBSCRIBED, 'first_name' => '', 'last_name' => '', 'created_at' => $this->createdAt, @@ -111,23 +141,22 @@ class Import { $newSubscribers = $this->setLinkToken($newSubscribers); $createdSubscribers = $this->createOrUpdateSubscribers( - 'create', $newSubscribers, $this->subscribersCustomFields ); } + if ($existingSubscribers['data'] && $this->updateSubscribers) { $allowedStatuses = [ - Subscriber::STATUS_SUBSCRIBED, - Subscriber::STATUS_UNSUBSCRIBED, - Subscriber::STATUS_INACTIVE, + SubscriberEntity::STATUS_SUBSCRIBED, + SubscriberEntity::STATUS_UNSUBSCRIBED, + SubscriberEntity::STATUS_INACTIVE, ]; if (in_array($this->existingSubscribersStatus, $allowedStatuses, true)) { $existingSubscribers = $this->addField($existingSubscribers, 'status', $this->existingSubscribersStatus); } $updatedSubscribers = $this->createOrUpdateSubscribers( - 'update', $existingSubscribers, $this->subscribersCustomFields ); @@ -144,7 +173,7 @@ class Import { $segments = $importFactory->getSegments(); $welcomeNotificationsInSegments = ($createdSubscribers || $updatedSubscribers) ? - Newsletter::getWelcomeNotificationsForSegments($this->segmentsIds) : + $this->newsletterOptionsRepository->findWelcomeNotificationsForSegments($this->segmentsIds) : false; return [ @@ -172,12 +201,12 @@ class Import { } // if this is a custom column if (in_array($column, $this->subscribersCustomFields)) { - $customField = CustomField::findOne($column); - if (!$customField instanceof CustomField) { + $customField = $this->customFieldsRepository->findOneById($column); + if (!$customField instanceof CustomFieldEntity) { continue; } // validate date type - if ($customField->type === 'date') { + if ($customField->getType() === CustomFieldEntity::TYPE_DATE) { $validationRule = 'datetime'; $data = array_map( function($index, $date) use($validationRule, &$invalidRecords) { @@ -219,11 +248,7 @@ class Import { // with just wp_user_id and email fields: [[wp_user_id, email], [wp_user_id, email], ...] $tempExistingSubscribers = array_merge( $tempExistingSubscribers, - Subscriber::select('wp_user_id') - ->selectExpr('LOWER(email)', 'email') - ->whereIn('email', $subscribersEmails) - ->whereNull('deleted_at') - ->findArray() + $this->subscriberRepository->findWpUserIdAndEmailByEmails($subscribersEmails) ); } if (!$tempExistingSubscribers) { @@ -261,19 +286,15 @@ class Import { public function deleteExistingTrashedSubscribers($subscribersData) { $existingTrashedRecords = array_filter( array_map(function($subscriberEmails) { - return Subscriber::selectMany(['id']) - ->whereIn('email', $subscriberEmails) - ->whereNotNull('deleted_at') - ->findArray(); + return $this->subscriberRepository->findIdsOfDeletedByEmails($subscriberEmails); }, array_chunk($subscribersData['email'], self::DB_QUERY_CHUNK_SIZE)) ); - if (!$existingTrashedRecords) return; $existingTrashedRecords = Helpers::flattenArray($existingTrashedRecords); + if (!$existingTrashedRecords) { + return; + } foreach (array_chunk($existingTrashedRecords, self::DB_QUERY_CHUNK_SIZE) as $subscriberIds) { - Subscriber::whereIn('id', $subscriberIds) - ->deleteMany(); - SubscriberSegment::whereIn('subscriber_id', $subscriberIds) - ->deleteMany(); + $this->subscriberRepository->bulkDelete($subscriberIds); } } @@ -305,7 +326,7 @@ class Import { return $defaultStatus; }, $subscribersData['data']['status']); - if ($defaultStatus === Subscriber::STATUS_SUBSCRIBED) { + if ($defaultStatus === SubscriberEntity::STATUS_SUBSCRIBED) { if (!in_array('last_subscribed_at', $subscribersData['fields'])) { $subscribersData['fields'][] = 'last_subscribed_at'; } @@ -332,7 +353,7 @@ class Import { $subscribersData['fields'][] = 'link_token'; $subscribersData['data']['link_token'] = array_map( function () { - return Security::generateRandomString(Subscriber::LINK_TOKEN_LENGTH); + return Security::generateRandomString(SubscriberEntity::LINK_TOKEN_LENGTH); }, array_fill(0, $subscribersCount, null) ); return $subscribersData; @@ -359,9 +380,8 @@ class Import { } public function createOrUpdateSubscribers( - $action, - $subscribersData, - $subscribersCustomFields = false + array $subscribersData, + array $subscribersCustomFields = [] ) { $subscribersCount = count($subscribersData['data'][key($subscribersData['data'])]); $subscribers = array_map(function($index) use ($subscribersData) { @@ -370,23 +390,15 @@ class Import { }, $subscribersData['fields']); }, range(0, $subscribersCount - 1)); foreach (array_chunk($subscribers, self::DB_QUERY_CHUNK_SIZE) as $data) { - if ($action == 'create') { - Subscriber::createMultiple( - $subscribersData['fields'], - $data - ); - } - if ($action == 'update') { - Subscriber::updateMultiple( - $subscribersData['fields'], - $data, - $this->updatedAt - ); - } + $this->subscriberRepository->insertOrUpdateMultiple( + $subscribersData['fields'], + $data, + $this->updatedAt + ); } $createdOrUpdatedSubscribers = []; - foreach (array_chunk($subscribersData['data']['email'], self::DB_QUERY_CHUNK_SIZE) as $data) { - foreach (Subscriber::selectMany(['id', 'email'])->whereIn('email', $data)->findArray() as $createdOrUpdatedSubscriber) { + foreach (array_chunk($subscribersData['data']['email'], self::DB_QUERY_CHUNK_SIZE) as $emails) { + foreach ($this->subscriberRepository->findIdAndEmailByEmails($emails) as $createdOrUpdatedSubscriber) { // ensure emails loaded from the DB are lowercased (imported emails are lowercased as well) $createdOrUpdatedSubscriber['email'] = mb_strtolower($createdOrUpdatedSubscriber['email']); $createdOrUpdatedSubscribers[] = $createdOrUpdatedSubscriber; @@ -396,7 +408,6 @@ class Import { $createdOrUpdatedSubscribersIds = array_column($createdOrUpdatedSubscribers, 'id'); if ($subscribersCustomFields) { $this->createOrUpdateCustomFields( - $action, $createdOrUpdatedSubscribers, $subscribersData, $subscribersCustomFields @@ -410,18 +421,17 @@ class Import { } public function createOrUpdateCustomFields( - $action, - $createdOrUpdatedSubscribers, - $subscribersData, - $subscribersCustomFieldsIds + array $createdOrUpdatedSubscribers, + array $subscribersData, + array $subscribersCustomFieldsIds ) { // check if custom fields exist in the database - $subscribersCustomFieldsIds = Helpers::flattenArray( - CustomField::whereIn('id', $subscribersCustomFieldsIds) - ->select('id') - ->findArray() - ); - if (!$subscribersCustomFieldsIds) return; + $subscribersCustomFieldsIds = array_map(function(CustomFieldEntity $customField): int { + return (int)$customField->getId(); + }, $this->customFieldsRepository->findBy(['id' => $subscribersCustomFieldsIds])); + if (!$subscribersCustomFieldsIds) { + return; + } // assemble a two-dimensional array: [[custom_field_id, subscriber_id, value], [custom_field_id, subscriber_id, value], ...] $subscribersCustomFieldsData = []; $subscribersEmails = array_flip($subscribersData['data']['email']); @@ -434,18 +444,22 @@ class Import { (int)$field, $createdOrUpdatedSubscriber['id'], $values[$subscriberIndex], + $this->createdAt, ]; } } + $columns = [ + 'custom_field_id', + 'subscriber_id', + 'value', + 'created_at', + ]; foreach (array_chunk($subscribersCustomFieldsData, self::DB_QUERY_CHUNK_SIZE) as $subscribersCustomFieldsDataChunk) { - SubscriberCustomField::createMultiple( - $subscribersCustomFieldsDataChunk + $this->subscriberCustomFieldRepository->insertOrUpdateMultiple( + $columns, + $subscribersCustomFieldsDataChunk, + $this->updatedAt ); - if ($action === 'update') { - SubscriberCustomField::updateMultiple( - $subscribersCustomFieldsDataChunk - ); - } } } @@ -454,10 +468,23 @@ class Import { } public function addSubscribersToSegments($subscribersIds, $segmentsIds) { + $columns = [ + 'subscriber_id', + 'segment_id', + 'created_at', + ]; foreach (array_chunk($subscribersIds, self::DB_QUERY_CHUNK_SIZE) as $subscriberIdsChunk) { - SubscriberSegment::subscribeManyToSegments( - $subscriberIdsChunk, $segmentsIds - ); + $data = []; + foreach ($segmentsIds as $segmentId) { + $data = array_merge($data, array_map(function ($subscriberId) use ($segmentId): array { + return [ + $subscriberId, + $segmentId, + $this->createdAt, + ]; + }, $subscriberIdsChunk)); + } + $this->subscriberSegmentRepository->insertOrUpdateMultiple($columns, $data, $this->updatedAt); } } } diff --git a/lib/Subscribers/SubscribersRepository.php b/lib/Subscribers/SubscribersRepository.php index 759a00e135..0ffb978a1c 100644 --- a/lib/Subscribers/SubscribersRepository.php +++ b/lib/Subscribers/SubscribersRepository.php @@ -15,6 +15,14 @@ use MailPoetVendor\Doctrine\ORM\Query\Expr\Join; * @extends Repository */ class SubscribersRepository extends Repository { + protected $ignoreColumnsForUpdate = [ + 'wp_user_id', + 'is_woocommerce_user', + 'email', + 'created_at', + 'last_subscribed_at', + ]; + protected function getEntityClassName() { return SubscriberEntity::class; } @@ -244,4 +252,35 @@ class SubscribersRepository extends Repository { return count($ids); } + + public function findWpUserIdAndEmailByEmails(array $emails): array { + return $this->entityManager->createQueryBuilder() + ->select('s.wpUserId AS wp_user_id, LOWER(s.email) AS email') + ->from(SubscriberEntity::class, 's') + ->where('s.email IN (:emails)') + ->setParameter('emails', $emails) + ->getQuery()->getResult(); + } + + public function findIdAndEmailByEmails(array $emails): array { + return $this->entityManager->createQueryBuilder() + ->select('s.id, s.email') + ->from(SubscriberEntity::class, 's') + ->where('s.email IN (:emails)') + ->setParameter('emails', $emails) + ->getQuery()->getResult(); + } + + /** + * @return int[] + */ + public function findIdsOfDeletedByEmails(array $emails): array { + return $this->entityManager->createQueryBuilder() + ->select('s.id') + ->from(SubscriberEntity::class, 's') + ->where('s.email IN (:emails)') + ->andWhere('s.deletedAt IS NOT NULL') + ->setParameter('emails', $emails) + ->getQuery()->getResult(); + } }