Skip to content

Commit ba70735

Browse files
committed
ISSUE-345: import/export subscribers
1 parent ab235f9 commit ba70735

File tree

5 files changed

+724
-2
lines changed

5 files changed

+724
-2
lines changed

config/services/managers.yml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,3 +51,8 @@ services:
5151
PhpList\Core\Domain\Subscription\Service\SubscriberAttributeManager:
5252
autowire: true
5353
autoconfigure: true
54+
55+
PhpList\Core\Domain\Subscription\Service\SubscriberCsvManager:
56+
autowire: true
57+
autoconfigure: true
58+
public: true

src/Domain/Subscription/Repository/SubscriberRepository.php

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,13 +14,16 @@
1414
/**
1515
* Repository for Subscriber models.
1616
*
17-
* @method Subscriber|null findOneByEmail(string $email)
18-
*
1917
* @author Oliver Klee <[email protected]>
2018
* @author Tatevik Grigoryan <[email protected]>
2119
*/
2220
class SubscriberRepository extends AbstractRepository implements PaginatableRepositoryInterface
2321
{
22+
public function findOneByEmail(string $email): ?Subscriber
23+
{
24+
return $this->findOneBy(['email' => $email]);
25+
}
26+
2427
public function findSubscribersBySubscribedList(int $listId): ?Subscriber
2528
{
2629
return $this->createQueryBuilder('s')
Lines changed: 277 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,277 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace PhpList\Core\Domain\Subscription\Service;
6+
7+
use Exception;
8+
use PhpList\Core\Domain\Subscription\Model\Dto\CreateSubscriberDto;
9+
use PhpList\Core\Domain\Subscription\Model\Dto\UpdateSubscriberDto;
10+
use PhpList\Core\Domain\Subscription\Model\Filter\SubscriberFilter;
11+
use PhpList\Core\Domain\Subscription\Repository\SubscriberAttributeDefinitionRepository;
12+
use PhpList\Core\Domain\Subscription\Repository\SubscriberRepository;
13+
use RuntimeException;
14+
use Symfony\Component\HttpFoundation\File\UploadedFile;
15+
use Symfony\Component\HttpFoundation\Response;
16+
use Symfony\Component\HttpFoundation\ResponseHeaderBag;
17+
use Symfony\Component\HttpFoundation\StreamedResponse;
18+
19+
/**
20+
* Service for importing and exporting subscribers from/to CSV files.
21+
*/
22+
class SubscriberCsvManager
23+
{
24+
private SubscriberManager $subscriberManager;
25+
private SubscriberAttributeManager $attributeManager;
26+
private SubscriberRepository $subscriberRepository;
27+
private SubscriberAttributeDefinitionRepository $attributeDefinitionRepository;
28+
29+
public function __construct(
30+
SubscriberManager $subscriberManager,
31+
SubscriberAttributeManager $attributeManager,
32+
SubscriberRepository $subscriberRepository,
33+
SubscriberAttributeDefinitionRepository $attributeDefinitionRepository
34+
) {
35+
$this->subscriberManager = $subscriberManager;
36+
$this->attributeManager = $attributeManager;
37+
$this->subscriberRepository = $subscriberRepository;
38+
$this->attributeDefinitionRepository = $attributeDefinitionRepository;
39+
}
40+
41+
/**
42+
* Import subscribers from a CSV file.
43+
*
44+
* @param UploadedFile $file The uploaded CSV file
45+
* @param bool $updateExisting Whether to update existing subscribers
46+
* @return array Import statistics
47+
*/
48+
public function importFromCsv(UploadedFile $file, bool $updateExisting = false): array
49+
{
50+
$stats = [
51+
'created' => 0,
52+
'updated' => 0,
53+
'skipped' => 0,
54+
'errors' => [],
55+
];
56+
57+
$handle = fopen($file->getPathname(), 'r');
58+
if (!$handle) {
59+
throw new RuntimeException('Could not open file for reading');
60+
}
61+
62+
$headers = fgetcsv($handle);
63+
if (!$headers) {
64+
fclose($handle);
65+
throw new RuntimeException('CSV file is empty or invalid');
66+
}
67+
68+
if (!in_array('email', $headers, true)) {
69+
fclose($handle);
70+
throw new RuntimeException('CSV file must contain an "email" column');
71+
}
72+
73+
$attributeDefinitions = [];
74+
foreach ($headers as $index => $header) {
75+
if (in_array($header, ['email', 'confirmed', 'blacklisted', 'html_email', 'disabled', 'extra_data'], true)) {
76+
continue;
77+
}
78+
79+
$attributeDefinition = $this->attributeDefinitionRepository->findOneBy(['name' => $header]);
80+
if ($attributeDefinition) {
81+
$attributeDefinitions[$index] = $attributeDefinition;
82+
}
83+
}
84+
85+
$lineNumber = 2;
86+
while (($data = fgetcsv($handle)) !== false) {
87+
try {
88+
$email = trim($data[array_search('email', $headers, true)]);
89+
if (empty($email) || !filter_var($email, FILTER_VALIDATE_EMAIL)) {
90+
$stats['errors'][] = "Line $lineNumber: Invalid email address";
91+
$stats['skipped']++;
92+
$lineNumber++;
93+
continue;
94+
}
95+
96+
$existingSubscriber = $this->subscriberRepository->findOneByEmail($email);
97+
98+
if ($existingSubscriber && !$updateExisting) {
99+
$stats['skipped']++;
100+
$lineNumber++;
101+
continue;
102+
}
103+
104+
$confirmedIndex = array_search('confirmed', $headers, true);
105+
if ($existingSubscriber) {
106+
$confirmed = $confirmedIndex !== false && isset($data[$confirmedIndex])
107+
? filter_var($data[$confirmedIndex], FILTER_VALIDATE_BOOLEAN)
108+
: $existingSubscriber->isConfirmed();
109+
110+
$blacklistedIndex = array_search('blacklisted', $headers, true);
111+
$blacklisted = $blacklistedIndex !== false && isset($data[$blacklistedIndex])
112+
? filter_var($data[$blacklistedIndex], FILTER_VALIDATE_BOOLEAN)
113+
: $existingSubscriber->isBlacklisted();
114+
115+
$htmlEmailIndex = array_search('html_email', $headers, true);
116+
$htmlEmail = $htmlEmailIndex !== false && isset($data[$htmlEmailIndex])
117+
? filter_var($data[$htmlEmailIndex], FILTER_VALIDATE_BOOLEAN)
118+
: $existingSubscriber->hasHtmlEmail();
119+
120+
$disabledIndex = array_search('disabled', $headers, true);
121+
$disabled = $disabledIndex !== false && isset($data[$disabledIndex])
122+
? filter_var($data[$disabledIndex], FILTER_VALIDATE_BOOLEAN)
123+
: $existingSubscriber->isDisabled();
124+
125+
$extraDataIndex = array_search('extra_data', $headers, true);
126+
$additionalData = $extraDataIndex !== false && isset($data[$extraDataIndex])
127+
? $data[$extraDataIndex]
128+
: $existingSubscriber->getExtraData();
129+
130+
$dto = new UpdateSubscriberDto(
131+
$existingSubscriber->getId(),
132+
$email,
133+
$confirmed,
134+
$blacklisted,
135+
$htmlEmail,
136+
$disabled,
137+
$additionalData
138+
);
139+
140+
$subscriber = $this->subscriberManager->updateSubscriber($dto);
141+
$stats['updated']++;
142+
} else {
143+
$requestConfirmation = !($confirmedIndex !== false && isset($data[$confirmedIndex]) &&
144+
filter_var($data[$confirmedIndex], FILTER_VALIDATE_BOOLEAN));
145+
146+
$htmlEmailIndex = array_search('html_email', $headers, true);
147+
$htmlEmail = $htmlEmailIndex !== false && isset($data[$htmlEmailIndex]) &&
148+
filter_var($data[$htmlEmailIndex], FILTER_VALIDATE_BOOLEAN);
149+
150+
$dto = new CreateSubscriberDto(
151+
$email,
152+
$requestConfirmation,
153+
$htmlEmail
154+
);
155+
156+
$subscriber = $this->subscriberManager->createSubscriber($dto);
157+
158+
$blacklistedIndex = array_search('blacklisted', $headers, true);
159+
if ($blacklistedIndex !== false && isset($data[$blacklistedIndex])) {
160+
$subscriber->setBlacklisted(filter_var($data[$blacklistedIndex], FILTER_VALIDATE_BOOLEAN));
161+
}
162+
163+
$disabledIndex = array_search('disabled', $headers, true);
164+
if ($disabledIndex !== false && isset($data[$disabledIndex])) {
165+
$subscriber->setDisabled(filter_var($data[$disabledIndex], FILTER_VALIDATE_BOOLEAN));
166+
}
167+
168+
$extraDataIndex = array_search('extra_data', $headers, true);
169+
if ($extraDataIndex !== false && isset($data[$extraDataIndex])) {
170+
$subscriber->setExtraData($data[$extraDataIndex]);
171+
}
172+
173+
$this->subscriberRepository->save($subscriber);
174+
$stats['created']++;
175+
}
176+
177+
foreach ($attributeDefinitions as $index => $attributeDefinition) {
178+
if (isset($data[$index]) && $data[$index] !== '') {
179+
$this->attributeManager->createOrUpdate(
180+
$subscriber,
181+
$attributeDefinition,
182+
$data[$index]
183+
);
184+
}
185+
}
186+
} catch (Exception $e) {
187+
$stats['errors'][] = "Line $lineNumber: " . $e->getMessage();
188+
$stats['skipped']++;
189+
}
190+
191+
$lineNumber++;
192+
}
193+
194+
fclose($handle);
195+
return $stats;
196+
}
197+
198+
/**
199+
* Export subscribers to a CSV file.
200+
*
201+
* @param SubscriberFilter|null $filter Optional filter to apply
202+
* @param int $batchSize Number of subscribers to process in each batch
203+
* @return Response A streamed response with the CSV file
204+
*/
205+
public function exportToCsv(?SubscriberFilter $filter = null, int $batchSize = 1000): Response
206+
{
207+
if ($filter === null) {
208+
$filter = new SubscriberFilter();
209+
}
210+
211+
$response = new StreamedResponse(function () use ($filter, $batchSize) {
212+
$handle = fopen('php://output', 'w');
213+
214+
$attributeDefinitions = $this->attributeDefinitionRepository->findAll();
215+
216+
$headers = [
217+
'email',
218+
'confirmed',
219+
'blacklisted',
220+
'html_email',
221+
'disabled',
222+
'extra_data',
223+
];
224+
225+
foreach ($attributeDefinitions as $definition) {
226+
$headers[] = $definition->getName();
227+
}
228+
229+
fputcsv($handle, $headers);
230+
231+
$lastId = 0;
232+
233+
do {
234+
$subscribers = $this->subscriberRepository->getFilteredAfterId(
235+
lastId: $lastId,
236+
limit: $batchSize,
237+
filter: $filter
238+
);
239+
240+
foreach ($subscribers as $subscriber) {
241+
$row = [
242+
$subscriber->getEmail(),
243+
$subscriber->isConfirmed() ? '1' : '0',
244+
$subscriber->isBlacklisted() ? '1' : '0',
245+
$subscriber->hasHtmlEmail() ? '1' : '0',
246+
$subscriber->isDisabled() ? '1' : '0',
247+
$subscriber->getExtraData(),
248+
];
249+
250+
foreach ($attributeDefinitions as $definition) {
251+
$attributeValue = $this->attributeManager->getSubscriberAttribute(
252+
subscriberId:$subscriber->getId(),
253+
attributeDefinitionId: $definition->getId()
254+
);
255+
$row[] = $attributeValue ? $attributeValue->getValue() : '';
256+
}
257+
258+
fputcsv($handle, $row);
259+
260+
$lastId = $subscriber->getId();
261+
}
262+
263+
} while (count($subscribers) === $batchSize);
264+
265+
fclose($handle);
266+
});
267+
268+
$response->headers->set('Content-Type', 'text/csv; charset=utf-8');
269+
$disposition = $response->headers->makeDisposition(
270+
ResponseHeaderBag::DISPOSITION_ATTACHMENT,
271+
'subscribers_export_' . date('Y-m-d') . '.csv'
272+
);
273+
$response->headers->set('Content-Disposition', $disposition);
274+
275+
return $response;
276+
}
277+
}

0 commit comments

Comments
 (0)