Passed
Push — trunk ( fdce1b...33e524 )
by Christian
09:59 queued 13s
created

getNewsletterRecipientId()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 14
Code Lines 9

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 1
eloc 9
nc 1
nop 2
dl 0
loc 14
rs 9.9666
c 0
b 0
f 0
1
<?php declare(strict_types=1);
2
3
namespace Shopware\Core\Content\Newsletter\SalesChannel;
4
5
use Shopware\Core\Content\Newsletter\Aggregate\NewsletterRecipient\NewsletterRecipientEntity;
0 ignored issues
show
Bug introduced by
The type Shopware\Core\Content\Ne...wsletterRecipientEntity was not found. Maybe you did not declare it correctly or list all dependencies?

The issue could also be caused by a filter entry in the build configuration. If the path has been excluded in your configuration, e.g. excluded_paths: ["lib/*"], you can move it to the dependency path list as follows:

filter:
    dependency_paths: ["lib/*"]

For further information see https://scrutinizer-ci.com/docs/tools/php/php-scrutinizer/#list-dependency-paths

Loading history...
6
use Shopware\Core\Content\Newsletter\Event\NewsletterConfirmEvent;
7
use Shopware\Core\Content\Newsletter\Event\NewsletterRegisterEvent;
8
use Shopware\Core\Content\Newsletter\Event\NewsletterSubscribeUrlEvent;
9
use Shopware\Core\Content\Newsletter\Exception\NewsletterRecipientNotFoundException;
10
use Shopware\Core\Framework\Context;
11
use Shopware\Core\Framework\DataAbstractionLayer\EntityRepository;
12
use Shopware\Core\Framework\DataAbstractionLayer\Search\Criteria;
13
use Shopware\Core\Framework\DataAbstractionLayer\Search\Filter\EqualsFilter;
14
use Shopware\Core\Framework\DataAbstractionLayer\Search\Filter\MultiFilter;
15
use Shopware\Core\Framework\Plugin\Exception\DecorationPatternException;
16
use Shopware\Core\Framework\RateLimiter\RateLimiter;
17
use Shopware\Core\Framework\Routing\Annotation\Since;
18
use Shopware\Core\Framework\Uuid\Uuid;
19
use Shopware\Core\Framework\Validation\BuildValidationEvent;
20
use Shopware\Core\Framework\Validation\DataBag\DataBag;
21
use Shopware\Core\Framework\Validation\DataBag\RequestDataBag;
22
use Shopware\Core\Framework\Validation\DataValidationDefinition;
23
use Shopware\Core\Framework\Validation\DataValidator;
24
use Shopware\Core\System\SalesChannel\Aggregate\SalesChannelDomain\SalesChannelDomainEntity;
0 ignored issues
show
Bug introduced by
The type Shopware\Core\System\Sal...alesChannelDomainEntity was not found. Maybe you did not declare it correctly or list all dependencies?

The issue could also be caused by a filter entry in the build configuration. If the path has been excluded in your configuration, e.g. excluded_paths: ["lib/*"], you can move it to the dependency path list as follows:

filter:
    dependency_paths: ["lib/*"]

For further information see https://scrutinizer-ci.com/docs/tools/php/php-scrutinizer/#list-dependency-paths

Loading history...
25
use Shopware\Core\System\SalesChannel\NoContentResponse;
26
use Shopware\Core\System\SalesChannel\SalesChannelContext;
27
use Shopware\Core\System\SystemConfig\SystemConfigService;
28
use Symfony\Component\HttpFoundation\RequestStack;
29
use Symfony\Component\Routing\Annotation\Route;
30
use Symfony\Component\Validator\Constraints\Choice;
31
use Symfony\Component\Validator\Constraints\Email;
32
use Symfony\Component\Validator\Constraints\NotBlank;
33
use Symfony\Component\Validator\Constraints\Regex;
34
use Symfony\Contracts\EventDispatcher\EventDispatcherInterface;
35
36
/**
37
 * @Route(defaults={"_routeScope"={"store-api"}})
38
 *
39
 * @phpstan-type SubscribeRequest array{email: string, storefrontUrl: string, option: string, firstName?: string, lastName?: string, zipCode?: string, city?: string, street?: string, salutationId?: string}
40
 *
41
 * @package customer-order
42
 */
43
class NewsletterSubscribeRoute extends AbstractNewsletterSubscribeRoute
44
{
45
    public const STATUS_NOT_SET = 'notSet';
46
    public const STATUS_OPT_IN = 'optIn';
47
    public const STATUS_OPT_OUT = 'optOut';
48
    public const STATUS_DIRECT = 'direct';
49
50
    /**
51
     * The subscription is directly active and does not need a confirmation.
52
     */
53
    public const OPTION_DIRECT = 'direct';
54
55
    /**
56
     * An email will be send to the provided email addrees containing a link to the /newsletter/confirm route.
57
     */
58
    public const OPTION_SUBSCRIBE = 'subscribe';
59
60
    /**
61
     * The email address will be removed from the newsletter subscriptions.
62
     */
63
    public const OPTION_UNSUBSCRIBE = 'unsubscribe';
64
65
    /**
66
     * Confirms the newsletter subscription for the provided email address.
67
     */
68
    public const OPTION_CONFIRM_SUBSCRIBE = 'confirmSubscribe';
69
70
    /**
71
     * The regex to check if string contains an url
72
     */
73
    public const DOMAIN_NAME_REGEX = '/((https?:\/\/))/';
74
75
    private EntityRepository $newsletterRecipientRepository;
76
77
    private DataValidator $validator;
78
79
    private EventDispatcherInterface $eventDispatcher;
80
81
    private SystemConfigService $systemConfigService;
82
83
    private RequestStack $requestStack;
84
85
    private RateLimiter $rateLimiter;
86
87
    /**
88
     * @internal
89
     */
90
    public function __construct(
91
        EntityRepository $newsletterRecipientRepository,
92
        DataValidator $validator,
93
        EventDispatcherInterface $eventDispatcher,
94
        SystemConfigService $systemConfigService,
95
        RateLimiter $rateLimiter,
96
        RequestStack $requestStack
97
    ) {
98
        $this->newsletterRecipientRepository = $newsletterRecipientRepository;
99
        $this->validator = $validator;
100
        $this->eventDispatcher = $eventDispatcher;
101
        $this->systemConfigService = $systemConfigService;
102
        $this->rateLimiter = $rateLimiter;
103
        $this->requestStack = $requestStack;
104
    }
105
106
    public function getDecorated(): AbstractNewsletterSubscribeRoute
107
    {
108
        throw new DecorationPatternException(self::class);
109
    }
110
111
    /**
112
     * @Since("6.2.0.0")
113
     * @Route("/store-api/newsletter/subscribe", name="store-api.newsletter.subscribe", methods={"POST"})
114
     */
115
    public function subscribe(RequestDataBag $dataBag, SalesChannelContext $context, bool $validateStorefrontUrl = true): NoContentResponse
116
    {
117
        $doubleOptInDomain = $this->systemConfigService->getString(
118
            'core.newsletter.doubleOptInDomain',
119
            $context->getSalesChannelId()
120
        );
121
        if ($doubleOptInDomain !== '') {
122
            $dataBag->set('storefrontUrl', $doubleOptInDomain);
123
            $validateStorefrontUrl = false;
124
        }
125
126
        $validator = $this->getOptInValidator($dataBag, $context, $validateStorefrontUrl);
127
128
        $this->validator->validate($dataBag->all(), $validator);
129
130
        if (($request = $this->requestStack->getMainRequest()) !== null && $request->getClientIp() !== null) {
131
            $this->rateLimiter->ensureAccepted(RateLimiter::NEWSLETTER_FORM, $request->getClientIp());
132
        }
133
134
        /** @var SubscribeRequest $data */
135
        $data = $dataBag->only(
136
            'email',
137
            'title',
138
            'firstName',
139
            'lastName',
140
            'zipCode',
141
            'city',
142
            'street',
143
            'salutationId',
144
            'option',
145
            'storefrontUrl'
146
        );
147
148
        $recipientId = $this->getNewsletterRecipientId($data['email'], $context);
149
150
        if (isset($recipientId)) {
151
            /** @var NewsletterRecipientEntity $recipient */
152
            $recipient = $this->newsletterRecipientRepository->search(new Criteria([$recipientId]), $context->getContext())->first();
153
154
            // If the user was previously subscribed but has unsubscribed now, the `getConfirmedAt()`
155
            // will still be set. So we need to check for the status as well.
156
            if ($recipient->getStatus() !== self::STATUS_OPT_OUT && $recipient->getConfirmedAt()) {
157
                return new NoContentResponse();
158
            }
159
        }
160
161
        $data = $this->completeData($data, $context);
0 ignored issues
show
Bug introduced by
$data of type Shopware\Core\Content\Ne...hannel\SubscribeRequest is incompatible with the type array expected by parameter $data of Shopware\Core\Content\Ne...beRoute::completeData(). ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

161
        $data = $this->completeData(/** @scrutinizer ignore-type */ $data, $context);
Loading history...
162
163
        $this->newsletterRecipientRepository->upsert([$data], $context->getContext());
164
165
        $recipient = $this->getNewsletterRecipient('email', $data['email'], $context->getContext());
166
167
        if (!$this->isNewsletterDoi($context)) {
168
            $event = new NewsletterConfirmEvent($context->getContext(), $recipient, $context->getSalesChannel()->getId());
169
            $this->eventDispatcher->dispatch($event);
170
171
            return new NoContentResponse();
172
        }
173
174
        $hashedEmail = hash('sha1', $data['email']);
175
        $url = $this->getSubscribeUrl($context, $hashedEmail, $data['hash'], $data, $recipient);
176
177
        $event = new NewsletterRegisterEvent($context->getContext(), $recipient, $url, $context->getSalesChannel()->getId());
178
        $this->eventDispatcher->dispatch($event);
179
180
        return new NoContentResponse();
181
    }
182
183
    public function isNewsletterDoi(SalesChannelContext $context): ?bool
184
    {
185
        if ($context->getCustomerId() === null) {
186
            return $this->systemConfigService->getBool('core.newsletter.doubleOptIn', $context->getSalesChannelId());
187
        }
188
189
        return $this->systemConfigService->getBool('core.newsletter.doubleOptInRegistered', $context->getSalesChannelId());
190
    }
191
192
    private function getOptInValidator(DataBag $dataBag, SalesChannelContext $context, bool $validateStorefrontUrl): DataValidationDefinition
193
    {
194
        $definition = new DataValidationDefinition('newsletter_recipient.create');
195
        $definition->add('email', new NotBlank(), new Email())
196
            ->add('option', new NotBlank(), new Choice(array_keys($this->getOptionSelection($context))));
197
198
        if (!empty($dataBag->get('firstName'))) {
199
            $definition->add('firstName', new NotBlank(), new Regex([
200
                'pattern' => self::DOMAIN_NAME_REGEX,
201
                'match' => false,
202
            ]));
203
        }
204
205
        if (!empty($dataBag->get('lastName'))) {
206
            $definition->add('lastName', new NotBlank(), new Regex([
207
                'pattern' => self::DOMAIN_NAME_REGEX,
208
                'match' => false,
209
            ]));
210
        }
211
212
        if ($validateStorefrontUrl) {
213
            $definition
214
                ->add('storefrontUrl', new NotBlank(), new Choice(array_values($this->getDomainUrls($context))));
215
        }
216
217
        $validationEvent = new BuildValidationEvent($definition, $dataBag, $context->getContext());
218
        $this->eventDispatcher->dispatch($validationEvent, $validationEvent->getName());
219
220
        return $definition;
221
    }
222
223
    /**
224
     * @param SubscribeRequest $data
0 ignored issues
show
Bug introduced by
The type Shopware\Core\Content\Ne...hannel\SubscribeRequest was not found. Maybe you did not declare it correctly or list all dependencies?

The issue could also be caused by a filter entry in the build configuration. If the path has been excluded in your configuration, e.g. excluded_paths: ["lib/*"], you can move it to the dependency path list as follows:

filter:
    dependency_paths: ["lib/*"]

For further information see https://scrutinizer-ci.com/docs/tools/php/php-scrutinizer/#list-dependency-paths

Loading history...
225
     *
226
     * @return array{id: string, languageId: string, salesChannelId: string, status: string, hash: string, email: string, storefrontUrl: string, firstName?: string, lastName?: string, zipCode?: string, city?: string, street?: string, salutationId?: string}
227
     */
228
    private function completeData(array $data, SalesChannelContext $context): array
229
    {
230
        $id = $this->getNewsletterRecipientId($data['email'], $context);
231
232
        $data['id'] = $id ?: Uuid::randomHex();
233
        $data['languageId'] = $context->getContext()->getLanguageId();
234
        $data['salesChannelId'] = $context->getSalesChannel()->getId();
235
        $data['status'] = $this->getOptionSelection($context)[$data['option']];
236
        $data['hash'] = Uuid::randomHex();
237
238
        return $data;
239
    }
240
241
    private function getNewsletterRecipientId(string $email, SalesChannelContext $context): ?string
242
    {
243
        $criteria = new Criteria();
244
        $criteria->addFilter(
245
            new MultiFilter(MultiFilter::CONNECTION_AND, [
246
                new EqualsFilter('email', $email),
247
                new EqualsFilter('salesChannelId', $context->getSalesChannel()->getId()),
248
            ]),
249
        );
250
        $criteria->setLimit(1);
251
252
        return $this->newsletterRecipientRepository
253
            ->searchIds($criteria, $context->getContext())
254
            ->firstId();
255
    }
256
257
    /**
258
     * @return array<string, string>
259
     */
260
    private function getOptionSelection(SalesChannelContext $context): array
261
    {
262
        return [
263
            self::OPTION_DIRECT => $this->isNewsletterDoi($context) ? self::STATUS_NOT_SET : self::STATUS_DIRECT,
264
            self::OPTION_SUBSCRIBE => $this->isNewsletterDoi($context) ? self::STATUS_NOT_SET : self::STATUS_DIRECT,
265
            self::OPTION_CONFIRM_SUBSCRIBE => self::STATUS_OPT_IN,
266
            self::OPTION_UNSUBSCRIBE => self::STATUS_OPT_OUT,
267
        ];
268
    }
269
270
    private function getNewsletterRecipient(string $identifier, string $value, Context $context): NewsletterRecipientEntity
271
    {
272
        $criteria = new Criteria();
273
        $criteria->addFilter(new EqualsFilter($identifier, $value));
274
        $criteria->addAssociation('salutation');
275
        $criteria->setLimit(1);
276
277
        $newsletterRecipient = $this->newsletterRecipientRepository->search($criteria, $context)->getEntities()->first();
278
279
        if (empty($newsletterRecipient)) {
280
            throw new NewsletterRecipientNotFoundException($identifier, $value);
281
        }
282
283
        return $newsletterRecipient;
284
    }
285
286
    /**
287
     * @return string[]
288
     */
289
    private function getDomainUrls(SalesChannelContext $context): array
290
    {
291
        $salesChannelDomainCollection = $context->getSalesChannel()->getDomains();
292
        if ($salesChannelDomainCollection === null) {
293
            return [];
294
        }
295
296
        return array_map(static function (SalesChannelDomainEntity $domainEntity) {
297
            return rtrim($domainEntity->getUrl(), '/');
298
        }, $salesChannelDomainCollection->getElements());
299
    }
300
301
    /**
302
     * @param array{storefrontUrl: string} $data
303
     */
304
    private function getSubscribeUrl(
305
        SalesChannelContext $context,
306
        string $hashedEmail,
307
        string $hash,
308
        array $data,
309
        NewsletterRecipientEntity $recipient
310
    ): string {
311
        $urlTemplate = $this->systemConfigService->get(
312
            'core.newsletter.subscribeUrl',
313
            $context->getSalesChannelId()
314
        );
315
        if (!\is_string($urlTemplate)) {
316
            $urlTemplate = '/newsletter-subscribe?em=%%HASHEDEMAIL%%&hash=%%SUBSCRIBEHASH%%';
317
        }
318
319
        $urlEvent = new NewsletterSubscribeUrlEvent($context, $urlTemplate, $hashedEmail, $hash, $data, $recipient);
320
        $this->eventDispatcher->dispatch($urlEvent);
321
322
        return $data['storefrontUrl'] . str_replace(
323
            [
324
                '%%HASHEDEMAIL%%',
325
                '%%SUBSCRIBEHASH%%',
326
            ],
327
            [
328
                $hashedEmail,
329
                $hash,
330
            ],
331
            $urlEvent->getSubscribeUrl()
332
        );
333
    }
334
}
335