Skip to content

Commit 5231d0a

Browse files
TatevikGrtatevikg1
andauthored
Import by foreign key (#367)
* Import with a foreign key --------- Co-authored-by: Tatevik <tatevikg1@gmail.com>
1 parent 938b090 commit 5231d0a

File tree

8 files changed

+412
-25
lines changed

8 files changed

+412
-25
lines changed

resources/translations/messages.en.xlf

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -730,6 +730,10 @@ Thank you.</target>
730730
<source>Campaign not found or not in submitted status</source>
731731
<target>__Campaign not found or not in submitted status</target>
732732
</trans-unit>
733+
<trans-unit id="TBYUW2m" resname="Conflict: email and foreign key refer to different subscribers.">
734+
<source>Conflict: email and foreign key refer to different subscribers.</source>
735+
<target>__Conflict: email and foreign key refer to different subscribers.</target>
736+
</trans-unit>
733737
</body>
734738
</file>
735739
</xliff>

src/Domain/Subscription/Model/Dto/ImportSubscriberDto.php

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,12 @@ class ImportSubscriberDto
1111
#[Assert\NotBlank]
1212
public string $email;
1313

14+
/**
15+
* Optional external identifier used for matching existing subscribers during import.
16+
*/
17+
#[Assert\Length(max: 191)]
18+
public ?string $foreignKey = null;
19+
1420
#[Assert\Type('bool')]
1521
public bool $confirmed;
1622

@@ -37,7 +43,8 @@ public function __construct(
3743
bool $htmlEmail,
3844
bool $disabled,
3945
?string $extraData = null,
40-
array $extraAttributes = []
46+
array $extraAttributes = [],
47+
?string $foreignKey = null,
4148
) {
4249
$this->email = $email;
4350
$this->confirmed = $confirmed;
@@ -47,5 +54,6 @@ public function __construct(
4754
$this->disabled = $disabled;
4855
$this->extraData = $extraData;
4956
$this->extraAttributes = $extraAttributes;
57+
$this->foreignKey = $foreignKey;
5058
}
5159
}

src/Domain/Subscription/Repository/SubscriberRepository.php

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
*
1717
* @author Oliver Klee <oliver@phplist.com>
1818
* @author Tatevik Grigoryan <tatevik@phplist.com>
19+
* @SuppressWarnings(PHPMD.TooManyPublicMethods)
1920
*/
2021
class SubscriberRepository extends AbstractRepository implements PaginatableRepositoryInterface
2122
{
@@ -41,6 +42,11 @@ public function findOneByUniqueId(string $uniqueId): ?Subscriber
4142
return $this->findOneBy(['uniqueId' => $uniqueId]);
4243
}
4344

45+
public function findOneByForeignKey(string $foreignKey): ?Subscriber
46+
{
47+
return $this->findOneBy(['foreignKey' => $foreignKey]);
48+
}
49+
4450
public function findSubscribersBySubscribedList(int $listId): ?Subscriber
4551
{
4652
return $this->createQueryBuilder('s')

src/Domain/Subscription/Service/CsvRowToDtoMapper.php

Lines changed: 30 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -8,24 +8,45 @@
88

99
class CsvRowToDtoMapper
1010
{
11+
private const FK_HEADER = 'foreignkey';
1112
private const KNOWN_HEADERS = [
12-
'email', 'confirmed', 'blacklisted', 'html_email', 'disabled', 'extra_data',
13+
'email', 'confirmed', 'blacklisted', 'html_email', 'disabled', 'extra_data', 'foreignkey',
1314
];
1415

1516
public function map(array $row): ImportSubscriberDto
1617
{
17-
$extraAttributes = array_filter($row, function ($key) {
18+
// Normalize keys to lower-case for header matching safety (CSV library keeps original headers)
19+
$normalizedRow = $this->normalizeData($row);
20+
21+
$email = strtolower(trim((string)($normalizedRow['email'] ?? '')));
22+
23+
if (array_key_exists(self::FK_HEADER, $normalizedRow) && $normalizedRow[self::FK_HEADER] !== '') {
24+
$foreignKey = (string)$normalizedRow[self::FK_HEADER];
25+
}
26+
27+
$extraAttributes = array_filter($normalizedRow, function ($key) {
1828
return !in_array($key, self::KNOWN_HEADERS, true);
1929
}, ARRAY_FILTER_USE_KEY);
2030

2131
return new ImportSubscriberDto(
22-
email: trim($row['email'] ?? ''),
23-
confirmed: filter_var($row['confirmed'] ?? false, FILTER_VALIDATE_BOOLEAN),
24-
blacklisted: filter_var($row['blacklisted'] ?? false, FILTER_VALIDATE_BOOLEAN),
25-
htmlEmail: filter_var($row['html_email'] ?? false, FILTER_VALIDATE_BOOLEAN),
26-
disabled: filter_var($row['disabled'] ?? false, FILTER_VALIDATE_BOOLEAN),
27-
extraData: $row['extra_data'] ?? null,
28-
extraAttributes: $extraAttributes
32+
email: $email,
33+
confirmed: filter_var($normalizedRow['confirmed'] ?? false, FILTER_VALIDATE_BOOLEAN),
34+
blacklisted: filter_var($normalizedRow['blacklisted'] ?? false, FILTER_VALIDATE_BOOLEAN),
35+
htmlEmail: filter_var($normalizedRow['html_email'] ?? false, FILTER_VALIDATE_BOOLEAN),
36+
disabled: filter_var($normalizedRow['disabled'] ?? false, FILTER_VALIDATE_BOOLEAN),
37+
extraData: $normalizedRow['extra_data'] ?? null,
38+
extraAttributes: $extraAttributes,
39+
foreignKey: $foreignKey ?? null,
2940
);
3041
}
42+
43+
private function normalizeData(array $row): array
44+
{
45+
$normalizedRow = [];
46+
foreach ($row as $key => $value) {
47+
$normalizedRow[strtolower((string)$key)] = is_string($value) ? trim($value) : $value;
48+
}
49+
50+
return $normalizedRow;
51+
}
3152
}

src/Domain/Subscription/Service/Manager/SubscriberManager.php

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,9 @@ public function createFromImport(ImportSubscriberDto $subscriberDto): Subscriber
114114
$subscriber->setHtmlEmail($subscriberDto->htmlEmail);
115115
$subscriber->setDisabled($subscriberDto->disabled);
116116
$subscriber->setExtraData($subscriberDto->extraData ?? '');
117+
if ($subscriberDto->foreignKey !== null) {
118+
$subscriber->setForeignKey($subscriberDto->foreignKey);
119+
}
117120

118121
$this->entityManager->persist($subscriber);
119122

@@ -129,6 +132,9 @@ public function updateFromImport(Subscriber $existingSubscriber, ImportSubscribe
129132
$existingSubscriber->setHtmlEmail($subscriberDto->htmlEmail);
130133
$existingSubscriber->setDisabled($subscriberDto->disabled);
131134
$existingSubscriber->setExtraData($subscriberDto->extraData);
135+
if ($subscriberDto->foreignKey !== null) {
136+
$existingSubscriber->setForeignKey($subscriberDto->foreignKey);
137+
}
132138

133139
$uow = $this->entityManager->getUnitOfWork();
134140
$meta = $this->entityManager->getClassMetadata(Subscriber::class);

src/Domain/Subscription/Service/SubscriberCsvImporter.php

Lines changed: 48 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -182,7 +182,14 @@ private function processRow(
182182
return null;
183183
}
184184

185-
$subscriber = $this->subscriberRepository->findOneByEmail($dto->email);
185+
[$subscriber, $conflictError] = $this->resolveSubscriber($dto);
186+
187+
if ($conflictError !== null) {
188+
$stats['skipped']++;
189+
$stats['errors'][] = $conflictError;
190+
return null;
191+
}
192+
186193
if ($this->handleSkipCase($subscriber, $options, $stats)) {
187194
return null;
188195
}
@@ -197,20 +204,7 @@ private function processRow(
197204

198205
$this->attributeManager->processAttributes($subscriber, $dto->extraAttributes);
199206

200-
$addedNewSubscriberToList = false;
201-
$listLines = [];
202-
if (!$subscriber->isBlacklisted() && count($options->listIds) > 0) {
203-
foreach ($options->listIds as $listId) {
204-
$created = $this->subscriptionManager->addSubscriberToAList($subscriber, $listId);
205-
if ($created) {
206-
$addedNewSubscriberToList = true;
207-
$listLines[] = $this->translator->trans(
208-
'Subscribed to %list%',
209-
['%list%' => $created->getSubscriberList()->getName()]
210-
);
211-
}
212-
}
213-
}
207+
[$listLines, $addedNewSubscriberToList] = $this->getHistoryListLines($subscriber, $options);
214208

215209
if ($subscriber->isBlacklisted()) {
216210
$stats['blacklisted']++;
@@ -226,6 +220,22 @@ private function processRow(
226220
return $this->prepareConfirmationMessage($subscriber, $options, $dto, $addedNewSubscriberToList);
227221
}
228222

223+
private function resolveSubscriber(ImportSubscriberDto $dto): array
224+
{
225+
$byEmail = $this->subscriberRepository->findOneByEmail($dto->email);
226+
$byFk = null;
227+
228+
if ($dto->foreignKey !== null) {
229+
$byFk = $this->subscriberRepository->findOneByForeignKey($dto->foreignKey);
230+
}
231+
232+
if ($byEmail && $byFk && $byEmail->getId() !== $byFk->getId()) {
233+
return [null, $this->translator->trans('Conflict: email and foreign key refer to different subscribers.')];
234+
}
235+
236+
return [$byFk ?? $byEmail, null];
237+
}
238+
229239
private function handleInvalidEmail(
230240
ImportSubscriberDto $dto,
231241
SubscriberImportOptions $options,
@@ -277,4 +287,27 @@ private function prepareConfirmationMessage(
277287

278288
return null;
279289
}
290+
291+
private function getHistoryListLines(Subscriber $subscriber, SubscriberImportOptions $options): array
292+
{
293+
$addedNewSubscriberToList = false;
294+
$listLines = [];
295+
if (!$subscriber->isBlacklisted() && count($options->listIds) > 0) {
296+
foreach ($options->listIds as $listId) {
297+
$created = $this->subscriptionManager->addSubscriberToAList($subscriber, $listId);
298+
if ($created) {
299+
$addedNewSubscriberToList = true;
300+
$listLines[] = $this->translator->trans(
301+
'Subscribed to %list%',
302+
['%list%' => $created->getSubscriberList()->getName()]
303+
);
304+
}
305+
}
306+
}
307+
308+
return [
309+
$listLines,
310+
$addedNewSubscriberToList,
311+
];
312+
}
280313
}
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace PhpList\Core\Migrations;
6+
7+
use Doctrine\DBAL\Platforms\PostgreSQLPlatform;
8+
use Doctrine\DBAL\Schema\Schema;
9+
use Doctrine\Migrations\AbstractMigration;
10+
11+
final class Version20251103SeedInitialAdmin extends AbstractMigration
12+
{
13+
public function getDescription(): string
14+
{
15+
return 'Seed initial admin user';
16+
}
17+
18+
public function up(Schema $schema): void
19+
{
20+
$platform = $this->connection->getDatabasePlatform();
21+
$this->skipIf(!$platform instanceof PostgreSQLPlatform, sprintf(
22+
'Unsupported platform for this migration: %s',
23+
get_class($platform)
24+
));
25+
26+
$this->addSql(<<<'SQL'
27+
INSERT INTO phplist_admin (id, created, modified, loginname, namelc, email, password, passwordchanged, disabled, superuser, privileges)
28+
VALUES (1, NOW(), NOW(), 'admin', 'admin', 'admin@example.com', :hash, CURRENT_DATE, FALSE, TRUE, :privileges)
29+
ON CONFLICT (id) DO UPDATE
30+
SET
31+
modified = EXCLUDED.modified,
32+
privileges = EXCLUDED.privileges
33+
SQL, [
34+
'hash' => hash('sha256', 'password'),
35+
'privileges' => 'a:4:{s:11:"subscribers";b:1;s:9:"campaigns";b:1;s:10:"statistics";b:1;s:8:"settings";b:1;}',
36+
]);
37+
}
38+
39+
public function down(Schema $schema): void
40+
{
41+
$platform = $this->connection->getDatabasePlatform();
42+
$this->skipIf(!$platform instanceof PostgreSQLPlatform, sprintf(
43+
'Unsupported platform for this migration: %s',
44+
get_class($platform)
45+
));
46+
47+
$this->addSql('DELETE FROM phplist_admin WHERE id = 1');
48+
}
49+
}

0 commit comments

Comments
 (0)