<?php
declare(strict_types=1);
namespace Twigel\GetNotified\Service;
use Shopware\Core\Checkout\Customer\CustomerEntity;
use Shopware\Core\Content\Product\ProductEntity;
use Shopware\Core\Framework\Context;
use Shopware\Core\Framework\DataAbstractionLayer\EntityRepositoryInterface;
use Shopware\Core\Framework\DataAbstractionLayer\Event\EntityWrittenContainerEvent;
use Shopware\Core\Framework\DataAbstractionLayer\Search\Criteria;
use Shopware\Core\Framework\Uuid\Uuid;
use Shopware\Core\System\SalesChannel\SalesChannelContext;
use Shopware\Core\System\SalesChannel\SalesChannelEntity;
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
use Symfony\Component\Messenger\MessageBusInterface;
use Twigel\GetNotified\Content\StockSubscriber\Aggregate\StockSubscriberProduct;
use Twigel\GetNotified\Content\StockSubscriber\Aggregate\StockSubscriberProductCollection;
use Twigel\GetNotified\Content\StockSubscriber\Aggregate\StockSubscriberProductRepository;
use Twigel\GetNotified\Content\StockSubscriber\StockSubscriber;
use Twigel\GetNotified\Content\StockSubscriber\StockSubscriberRepository;
use Twigel\GetNotified\MessageBus\Message\DoubleOptInMessage;
use Doctrine\DBAL\Connection;
/**
* Class StockSubscriptionService
*
* @package Twigel\GetNotified\Service
*/
class StockSubscriptionService
{
const ACTION_SUBSCRIPTION_CREATED = 'subscription_created';
const ACTION_SUBSCRIPTION_UPDATED = 'subscription_updated';
protected EntityRepositoryInterface $productRepository;
protected StockSubscriberRepository $stockSubscriberRepository;
protected StockSubscriberProductRepository $stockSubscriberProductRepository;
protected ConfigService $configService;
protected MailService $mailService;
protected MessageBusInterface $messageBus;
protected ?EventDispatcherInterface $flowDispatcher;
/**
* @var Connection
*/
private Connection $connection;
public function __construct(
EntityRepositoryInterface $productRepository,
StockSubscriberRepository $stockSubscriberRepository,
StockSubscriberProductRepository $stockSubscriberProductRepository,
ConfigService $configService,
MailService $mailService,
MessageBusInterface $messageBus,
?EventDispatcherInterface $flowDispatcher = null,
Connection $connection
)
{
$this->productRepository = $productRepository;
$this->stockSubscriberRepository = $stockSubscriberRepository;
$this->stockSubscriberProductRepository = $stockSubscriberProductRepository;
$this->configService = $configService;
$this->mailService = $mailService;
$this->messageBus = $messageBus;
$this->flowDispatcher = $flowDispatcher;
$this->connection = $connection;
}
/**
* @param SalesChannelContext $context
* @param bool $isListing
*
* @return bool
*/
public function shouldShowSubscriptionForm(SalesChannelContext $context, bool $isListing = false): bool
{
$salesChannel = $context->getSalesChannel();
if (!$this->isActive($salesChannel)) {
return false;
}
$customerLoggedIn = $context->getCustomer() instanceof CustomerEntity;
$loginRequired = $this->configService->getHideIfNotLoggedIn($salesChannel->getId());
if (!$customerLoggedIn && $loginRequired) {
return false;
}
$customerGroupWhiteList = $this->configService->getCustomerGroupWhitelist($salesChannel->getId());
$currentCustomerGroupId = $context->getCurrentCustomerGroup()->getId();
if (
$loginRequired &&
!empty($customerGroupWhiteList) &&
!in_array($currentCustomerGroupId, $customerGroupWhiteList)
) {
return false;
}
return true;
}
/**
* @param SalesChannelEntity $salesChannelEntity
*
* @return bool
*/
public function isActive(SalesChannelEntity $salesChannelEntity): bool
{
return $this->configService->getActive($salesChannelEntity);
}
/**
* @param SalesChannelEntity $salesChannelEntity
*
* @return bool
*/
public function doubleOptInEnabled(SalesChannelEntity $salesChannelEntity): bool
{
return $this->configService->getDoubleOptInEnabled($salesChannelEntity);
}
/**
* @param SalesChannelEntity $salesChannelEntity
*
* @return bool
*/
public function useFlowBuilder(?SalesChannelEntity $salesChannelEntity = null): bool
{
return $this->configService->getUseFlowBuilder($salesChannelEntity) && $this->flowDispatcher !== null;
}
/**
* @param SalesChannelEntity|null $salesChannelEntity
* @param bool $clear
*/
public function setLastRunTimestamp(?SalesChannelEntity $salesChannelEntity = null, bool $clear = false): void
{
$this->configService->setLastRunTimestamp($salesChannelEntity, $clear);
}
/**
* @param SalesChannelEntity|null $salesChannelEntity
*
* @return int
*/
public function getNotificationBatchSize(?SalesChannelEntity $salesChannelEntity = null): int
{
return $this->configService->getNotificationBatchSize($salesChannelEntity);
}
/**
* @param SalesChannelEntity $salesChannelEntity
*
* @return bool
*/
public function getHideStockSubscriptionFormOnListingPage(SalesChannelEntity $salesChannelEntity): bool
{
return $this->configService->getHideStockSubscriptionFormOnListingPage($salesChannelEntity);
}
/**
* @param SalesChannelEntity $salesChannelEntity
*
* @return bool
*/
public function shouldReplaceHideAddToCartForm(SalesChannelEntity $salesChannelEntity): bool
{
return $this->configService->getReplaceAddToCart($salesChannelEntity);
}
/**
* @param SalesChannelEntity $salesChannelEntity
*
* @return bool
*/
public function showNotificationBell(SalesChannelEntity $salesChannelEntity): bool
{
return $this->configService->getShowNotificationBell($salesChannelEntity);
}
/**
* @param SalesChannelEntity $salesChannelEntity
*
* @return bool
*/
public function showTitle(SalesChannelEntity $salesChannelEntity): bool
{
return $this->configService->getShowTitle($salesChannelEntity->getId());
}
/**
* @param SalesChannelEntity $salesChannelEntity
*
* @return int
*/
public function getMinimalStockThreshold(SalesChannelEntity $salesChannelEntity): int
{
return $this->configService->getMinimalStockThreshold($salesChannelEntity);
}
/**
* @param ProductEntity $product
* @param SalesChannelEntity $salesChannelEntity
*
* @return int
*/
public function getPreferredProductStock(ProductEntity $product, SalesChannelEntity $salesChannelEntity): int
{
return $this->configService->getUseAvailableStock($salesChannelEntity)
? intval($product->getAvailableStock())
: $product->getStock();
}
/**
* @param SalesChannelEntity $salesChannelEntity
*
* @return bool
*/
public function getDisableQuantityField(SalesChannelEntity $salesChannelEntity): bool
{
return $this->configService->getDisableQuantityField($salesChannelEntity);
}
/**
* @param SalesChannelEntity $salesChannelEntity
*
* @return bool
*/
public function getEnableRecaptcha(SalesChannelEntity $salesChannelEntity): bool
{
return $this->configService->getEnableRecaptcha($salesChannelEntity->getId());
}
/**
* @param SalesChannelEntity $salesChannelEntity
*
* @return bool
*/
public function getShowPrivacyStatement(SalesChannelEntity $salesChannelEntity): bool
{
return $this->configService->getShowPrivacyStatement($salesChannelEntity->getId());
}
/**
* @param ProductEntity $productEntity
* @param SalesChannelEntity $salesChannelEntity
*
* @return bool
*/
public function canShowFormForProductOnCloseout(ProductEntity $productEntity, SalesChannelEntity $salesChannelEntity): bool
{
switch ($this->configService->getBackordersDeniedPresentation($salesChannelEntity)) {
case ConfigService::BACKORDERS_DENIED_PRESENTATION_ALWAYS:
return true;
case ConfigService::BACKORDERS_DENIED_PRESENTATION_NEVER:
return !$productEntity->getIsCloseout();
case ConfigService::BACKORDERS_DENIED_PRESENTATION_EXCLUSIVE:
return $productEntity->getIsCloseout();
}
// FALLBACK ON OLD CONFIGURATIONS
return !$productEntity->getIsCloseout() || !$this->configService->getHideStockSubscriptionFormOnClearanceSale($salesChannelEntity);
}
/**
* @param string $email
* @param array $products
* @param string $salesChannelDomainId
* @param SalesChannelContext $salesChannelContext
* @param CustomerEntity|null $customer
*
* @return string
*/
public function subscribeToProducts(
string $email,
array $products,
string $salesChannelDomainId,
SalesChannelContext $salesChannelContext,
CustomerEntity $customer = null
): string
{
$newSubscription = false;
$stockSubscriber = $this->stockSubscriberRepository->firstByEmail($email, $salesChannelContext);
if ($stockSubscriber instanceof StockSubscriber) {
$isNew = false;
$id = $stockSubscriber->getId();
$data['customerId'] = !empty($customer) ? $customer->getId() : null;
if ($stockSubscriber->getProducts()) {
$products = $this->mergeStockSubscriberProductsWithProducts($stockSubscriber->getProducts(), $products);
}
} else {
$isNew = true;
$id = Uuid::randomHex();
$data['email'] = $email;
$data['customerId'] = !empty($customer) ? $customer->getId() : null;
$data['salesChannelId'] = $salesChannelContext->getSalesChannelId();
$data['salesChannelDomainId'] = $salesChannelDomainId;
$data['languageIdChain'] = $salesChannelContext->getLanguageIdChain();
}
$products = array_map(function ($product) use ($id, &$newSubscription) {
$product['stockSubscriberId'] = $id;
if (!array_key_exists('id', $product)) {
$newSubscription = true;
$product['id'] = Uuid::randomHex();
}
return $product;
}, $products);
$data['id'] = $id;
$data['products'] = array_values($products);
$this->stockSubscriberRepository->upsert([$data], $salesChannelContext->getContext());
if ($isNew && $this->configService->getDoubleOptInEnabled($salesChannelContext->getSalesChannel())) {
$this->messageBus->dispatch(new DoubleOptInMessage($id));
}
return $newSubscription ? self::ACTION_SUBSCRIPTION_CREATED : self::ACTION_SUBSCRIPTION_UPDATED;
}
/**
* @param StockSubscriberProduct $stockSubscriberProduct
*
* @return bool
*/
public function shouldNotifySubscriber(StockSubscriberProduct $stockSubscriberProduct): bool
{
$product = $stockSubscriberProduct->getProduct();
$salesChannel = $stockSubscriberProduct->getStockSubscriber()->getSalesChannel();
$subscriber = $stockSubscriberProduct->getStockSubscriber();
if ($this->doubleOptInEnabled($salesChannel) && !$subscriber->getDoubleOptInAccepted()) {
return false;
}
# For backwards compatibility, if e-mail is invalid we mark the subscription as notified
if (filter_var($subscriber->getEmail(), FILTER_VALIDATE_EMAIL) === false) {
$this->markNotified($stockSubscriberProduct, Context::createDefaultContext());
return false;
}
$minStockThreshold = $product->getCustomFields()['zeobvGetNotifiedMinStockToNotify'] ?? 0;
return $this->getPreferredProductStock($product, $salesChannel) >= $stockSubscriberProduct->getQuantity()
&& $this->getPreferredProductStock($product, $salesChannel) >= $this->getMinimalStockThreshold($salesChannel)
&& $this->getPreferredProductStock($product, $salesChannel) >= $minStockThreshold;
}
/**
* @param StockSubscriberProduct $stockSubscriberProduct
* @param Context $context
* @param string|null $mailTemplateId
*
* @return bool
*/
public function notifySubscriber(StockSubscriberProduct $stockSubscriberProduct, Context $context, ?string $mailTemplateId = null): bool
{
if (!$this->shouldNotifySubscriber($stockSubscriberProduct)) {
return false;
}
$product = $stockSubscriberProduct->getProduct();
$languageId = $stockSubscriberProduct->getStockSubscriber()->getLanguageIdChain()[0];
if ($product !== null) {
$customFields = $product->getCustomFields();
if ($customFields !== null && $customFields !== []) {
// Save number of times notifications were sent for this product to a custom field
$notificationsSent = key_exists('zeobvGetNotifiedNotificationsSent', $customFields)
? intval($customFields['zeobvGetNotifiedNotificationsSent']) + 1
: 1;
} else {
$notificationsSent = 1;
$sql = "UPDATE `product_translation`
SET `custom_fields` = '{}'
WHERE `product_id` = :productId
AND `language_id` = :languageId";
$result = $this->connection->executeStatement($sql, [
'productId' => Uuid::fromHexToBytes($product->getId()),
'languageId' => Uuid::fromHexToBytes($languageId)
]);
}
$sql = "UPDATE `product_translation`
SET `custom_fields` = JSON_SET(custom_fields, '$.zeobvGetNotifiedNotificationsSent', :notificationsSent)
WHERE `product_id` = :productId
AND `language_id` = :languageId";
$result = $this->connection->executeStatement($sql, [
'notificationsSent' => $notificationsSent,
'productId' => Uuid::fromHexToBytes($product->getId()),
'languageId' => Uuid::fromHexToBytes($languageId)
]);
}
$result = $this->mailService->sendBackInStockNotification($stockSubscriberProduct, $context, $mailTemplateId);
if (empty($result)) {
return false;
}
$entityWrittenEvent = $this->markNotified($stockSubscriberProduct, $context);
return empty($entityWrittenEvent->getErrors());
}
/**
* @param StockSubscriberProduct $stockSubscriberProduct
* @param Context $context
*
* @return EntityWrittenContainerEvent
*/
public function markNotified(StockSubscriberProduct $stockSubscriberProduct, Context $context): EntityWrittenContainerEvent
{
$entityWrittenEvent = $this->stockSubscriberRepository->update([
[
'id' => $stockSubscriberProduct->getStockSubscriber()->getId(),
'products' => [[
'id' => $stockSubscriberProduct->getId(),
'notified' => true,
]]
]
], $context);
if (!empty($entityWrittenEvent->getErrors())) {
return $entityWrittenEvent;
}
if (!$this->configService->getAutoRemoveOnNotified(
$stockSubscriberProduct->getStockSubscriber()->getSalesChannel()
)) {
return $entityWrittenEvent;
}
/** @var StockSubscriber $stockSubscriber */
$stockSubscriber = $this->stockSubscriberRepository->search(
(new Criteria([$stockSubscriberProduct->getStockSubscriber()->getId()]))->addAssociation('products'),
$context
)->first();
$notifiedProducts = $stockSubscriber->getProducts()->filterByProperty('notified', true);
# Check if all notifications have been sent for subscribed products and remove the subscriber if so
if ($stockSubscriber->getProducts()->count() === $notifiedProducts->count()) {
$this->stockSubscriberProductRepository->delete(array_values(array_map(function ($id) {
return ['id' => $id];
}, $notifiedProducts->getIds())), $context);
$this->stockSubscriberRepository->delete([
['id' => $stockSubscriber->getId()]
], $context);
} else {
# Remove the notified products from the subscriber
$this->stockSubscriberProductRepository->delete(array_values(array_map(function ($id) {
return ['id' => $id];
}, $notifiedProducts->getIds())), $context);
}
return $entityWrittenEvent;
}
/**
* @param StockSubscriberProductCollection $stockSubscriberProductCollection
* @param array $products
*
* @return array
*/
protected function mergeStockSubscriberProductsWithProducts(
StockSubscriberProductCollection $stockSubscriberProductCollection,
array $products
): array
{
foreach ($stockSubscriberProductCollection->getElements() as $stockSubscriberProduct) {
# If product id is being resubscribed to add the product ID and set notified to false
if (array_key_exists($stockSubscriberProduct->getProductId(), $products)) {
$products[$stockSubscriberProduct->getProductId()]['id'] = $stockSubscriberProduct->getId();
$products[$stockSubscriberProduct->getProductId()]['notified'] = false;
} else {
# else just add the product to the list for update
$products[$stockSubscriberProduct->getProductId()] = [
'id' => $stockSubscriberProduct->getId(),
'productId' => $stockSubscriberProduct->getProductId(),
'quantity' => $stockSubscriberProduct->getQuantity(),
'notified' => $stockSubscriberProduct->isNotified()
];
}
}
return $products;
}
}