diff --git a/composer.json b/composer.json index d595958..052bd4c 100644 --- a/composer.json +++ b/composer.json @@ -35,9 +35,11 @@ "symfony/http-kernel": "^4.4 || ^5.1.5", "symfony/options-resolver": "^4.4 || ^5.0", "symfony/routing": "^4.4 || ^5.0", + "symfony/security": "^4.4 || ^5.0", "symfony/security-bundle": "^4.4 || ^5.0", "symfony/uid": "^4.4 || ^5.0", "symfony/workflow": "^4.4 || ^5.0", + "twig/twig": "^2.0 || ^3.0", "webmozart/assert": "^1.10" }, "require-dev": { diff --git a/src/Client/Client.php b/src/Client/Client.php index 8b9c613..76c86bb 100644 --- a/src/Client/Client.php +++ b/src/Client/Client.php @@ -6,6 +6,7 @@ use Setono\SyliusFacebookPlugin\Model\PixelEventInterface; use Symfony\Contracts\HttpClient\HttpClientInterface; +use Symfony\Contracts\HttpClient\ResponseInterface; use Webmozart\Assert\Assert; final class Client implements ClientInterface @@ -38,13 +39,15 @@ public function sendPixelEvent(PixelEventInterface $pixelEvent): int $pixelId = $pixel->getPixelId(); Assert::notNull($pixelId); + $accessToken = $pixel->getCustomAccessToken() ?? $this->accessToken; + $options = [ 'headers' => [ 'Content-Type' => 'application/x-www-form-urlencoded', 'Accept' => 'application/json', ], 'body' => [ - 'access_token' => $this->accessToken, + 'access_token' => $accessToken, 'data' => json_encode([ $pixelEvent->getData(), ]), @@ -61,11 +64,38 @@ public function sendPixelEvent(PixelEventInterface $pixelEvent): int $options ); - Assert::same($response->getStatusCode(), 200); - $content = $response->getContent(); + Assert::same($response->getStatusCode(), 200, $this->getErrorMessage($response)); + $content = $response->getContent(false); $json = json_decode($content, true); Assert::isArray($json); return (int) $json['events_received']; } + + private function getErrorMessage(ResponseInterface $response): string + { + $content = $response->getContent(false); + $json = json_decode($content, true); + Assert::isArray($json); + + $error = sprintf( + 'Wrong status code. Expected %s. Got: %s.', + 200, + $response->getStatusCode() + ); + + if (array_key_exists('error', $json)) { + /** @psalm-var array{message: string, error_subcode: int, error_user_msg: string} $errorPayload */ + $errorPayload = $json['error']; + + $error .= sprintf( + ' Reason: %s [%s] %s', + $errorPayload['error_subcode'], + $errorPayload['message'], + $errorPayload['error_user_msg'] + ); + } + + return $error; + } } diff --git a/src/Controller/Action/Pixel/ResetFailedEventsAction.php b/src/Controller/Action/Pixel/ResetFailedEventsAction.php new file mode 100644 index 0000000..ebb4611 --- /dev/null +++ b/src/Controller/Action/Pixel/ResetFailedEventsAction.php @@ -0,0 +1,91 @@ +pixelRepository = $pixelRepository; + $this->csrfTokenManager = $csrfTokenManager; + $this->pixelEventRepository = $pixelEventRepository; + $this->urlGeneratorInterface = $urlGeneratorInterface; + } + + public function __invoke(Request $request, int $id): Response + { + /** @var FlashBagInterface $flashBag */ + $flashBag = $request->getSession()->getBag('flashes'); + + /** @var PixelInterface|null $pixel */ + $pixel = $this->pixelRepository->find($id); + Assert::notNull($pixel); + + $csrfToken = $request->request->get('_csrf_token'); + Assert::string($csrfToken); + if (!$this->isCsrfTokenValid((string) $pixel->getId(), $csrfToken)) { + throw new HttpException(Response::HTTP_FORBIDDEN, 'Invalid csrf token.'); + } + + $this->pixelEventRepository->resetFailedByPixel($pixel); + + $flashBag->add('success', 'setono_sylius_facebook.pixel.failed_events_reset'); + + return new RedirectResponse( + $this->getRedirectUrl($request, 'setono_sylius_facebook_admin_pixel_index') + ); + } + + private function getRedirectUrl(Request $request, string $defaultRoute): string + { + $syliusParameters = []; + + if ($request->attributes->has('_sylius')) { + /** @var array|mixed $syliusParameters */ + $syliusParameters = $request->attributes->get('_sylius'); + Assert::isArray($syliusParameters); + } + + /** @var string|mixed $route */ + $route = $syliusParameters['redirect']['route'] ?? $defaultRoute; + Assert::string($route); + + /** @var array|mixed $parameters */ + $parameters = $syliusParameters['redirect']['parameters'] ?? []; + Assert::isArray($parameters); + + return $this->urlGeneratorInterface->generate($route, $parameters); + } + + private function isCsrfTokenValid(string $id, ?string $token): bool + { + return $this->csrfTokenManager->isTokenValid(new CsrfToken($id, $token)); + } +} diff --git a/src/Doctrine/ORM/PixelEventRepository.php b/src/Doctrine/ORM/PixelEventRepository.php index 43bc4a6..4c29207 100644 --- a/src/Doctrine/ORM/PixelEventRepository.php +++ b/src/Doctrine/ORM/PixelEventRepository.php @@ -9,12 +9,26 @@ use Doctrine\DBAL\Types\Types; use Doctrine\ORM\QueryBuilder; use Setono\SyliusFacebookPlugin\Model\PixelEventInterface; +use Setono\SyliusFacebookPlugin\Model\PixelInterface; use Setono\SyliusFacebookPlugin\Repository\PixelEventRepositoryInterface; use Sylius\Bundle\ResourceBundle\Doctrine\ORM\EntityRepository; use Webmozart\Assert\Assert; class PixelEventRepository extends EntityRepository implements PixelEventRepositoryInterface { + public function getCountByPixelAndState(PixelInterface $pixel, string $state): int + { + return (int) $this->createQueryBuilder('o') + ->select('count(o)') + ->andWhere('o.pixel = :pixel') + ->setParameter('pixel', $pixel) + ->andWhere('o.state = :state') + ->setParameter('state', $state) + ->getQuery() + ->getSingleScalarResult() + ; + } + public function hasConsentedPending(int $delay = 0): bool { $qb = $this->createQueryBuilder('o') @@ -79,6 +93,21 @@ protected static function applyDelay(QueryBuilder $qb, int $delay): void } } + public function resetFailedByPixel(PixelInterface $pixel): void + { + $this->createQueryBuilder('o') + ->update() + ->set('o.state', ':initialState') + ->setParameter('initialState', PixelEventInterface::STATE_PENDING, Types::STRING) + ->andWhere('o.pixel = :pixel') + ->setParameter('pixel', $pixel) + ->andWhere('o.state = :state') + ->setParameter('state', PixelEventInterface::STATE_FAILED, Types::STRING) + ->getQuery() + ->execute() + ; + } + public function removeSent(int $delay = 0): int { $qb = $this->_em->createQueryBuilder() diff --git a/src/Form/Type/PixelType.php b/src/Form/Type/PixelType.php index 5e066d9..c945673 100644 --- a/src/Form/Type/PixelType.php +++ b/src/Form/Type/PixelType.php @@ -8,6 +8,7 @@ use Sylius\Bundle\ResourceBundle\Form\Type\AbstractResourceType; use Symfony\Component\Form\Extension\Core\Type\CheckboxType; use Symfony\Component\Form\Extension\Core\Type\IntegerType; +use Symfony\Component\Form\Extension\Core\Type\TextareaType; use Symfony\Component\Form\FormBuilderInterface; final class PixelType extends AbstractResourceType @@ -17,12 +18,21 @@ public function buildForm(FormBuilderInterface $builder, array $options): void $builder ->add('pixelId', IntegerType::class, [ 'label' => 'setono_sylius_facebook.form.pixel.pixel_id', - 'help' => 'Get it from https://www.facebook.com/events_manager2', + 'help' => 'setono_sylius_facebook.form.pixel.pixel_id_help', 'attr' => [ 'min' => 1, 'placeholder' => 'setono_sylius_facebook.form.pixel.pixel_id_placeholder', ], ]) + ->add('customAccessToken', TextareaType::class, [ + 'label' => 'setono_sylius_facebook.form.pixel.custom_access_token', + 'help' => 'setono_sylius_facebook.form.pixel.custom_access_token_help', + 'required' => false, + 'attr' => [ + 'rows' => 3, + 'placeholder' => 'setono_sylius_facebook.form.pixel.custom_access_token_placeholder', + ], + ]) ->add('enabled', CheckboxType::class, [ 'required' => false, 'label' => 'sylius.ui.enabled', diff --git a/src/Model/Pixel.php b/src/Model/Pixel.php index cfb0ad3..8d69105 100644 --- a/src/Model/Pixel.php +++ b/src/Model/Pixel.php @@ -17,6 +17,8 @@ class Pixel implements PixelInterface protected ?string $pixelId = null; + protected ?string $customAccessToken = null; + /** * @var Collection|BaseChannelInterface[] * @@ -49,6 +51,16 @@ public function setPixelId(string $pixelId): void $this->pixelId = $pixelId; } + public function getCustomAccessToken(): ?string + { + return $this->customAccessToken; + } + + public function setCustomAccessToken(?string $customAccessToken): void + { + $this->customAccessToken = $customAccessToken; + } + public function getChannels(): Collection { return $this->channels; diff --git a/src/Model/PixelInterface.php b/src/Model/PixelInterface.php index 77ccd63..4b7f76b 100644 --- a/src/Model/PixelInterface.php +++ b/src/Model/PixelInterface.php @@ -15,4 +15,8 @@ public function getId(): ?int; public function getPixelId(): ?string; public function setPixelId(string $pixelId): void; + + public function getCustomAccessToken(): ?string; + + public function setCustomAccessToken(?string $customAccessToken): void; } diff --git a/src/Repository/PixelEventRepositoryInterface.php b/src/Repository/PixelEventRepositoryInterface.php index fbaeec2..283cdab 100644 --- a/src/Repository/PixelEventRepositoryInterface.php +++ b/src/Repository/PixelEventRepositoryInterface.php @@ -5,10 +5,13 @@ namespace Setono\SyliusFacebookPlugin\Repository; use Setono\SyliusFacebookPlugin\Model\PixelEventInterface; +use Setono\SyliusFacebookPlugin\Model\PixelInterface; use Sylius\Component\Resource\Repository\RepositoryInterface; interface PixelEventRepositoryInterface extends RepositoryInterface { + public function getCountByPixelAndState(PixelInterface $pixel, string $state): int; + /** * Returns true if there are pending consented hits created before $delay seconds ago * @@ -29,5 +32,7 @@ public function assignBulkIdentifierToPendingConsented(string $bulkIdentifier, i */ public function findByBulkIdentifier(string $bulkIdentifier): array; + public function resetFailedByPixel(PixelInterface $pixel): void; + public function removeSent(int $delay = 0): int; } diff --git a/src/Resources/config/doctrine/model/Pixel.orm.xml b/src/Resources/config/doctrine/model/Pixel.orm.xml index 3460a91..65d2d19 100644 --- a/src/Resources/config/doctrine/model/Pixel.orm.xml +++ b/src/Resources/config/doctrine/model/Pixel.orm.xml @@ -17,6 +17,8 @@ + + diff --git a/src/Resources/config/grids/setono_sylius_facebook_admin_pixel.yaml b/src/Resources/config/grids/setono_sylius_facebook_admin_pixel.yaml index 8559d29..0287196 100644 --- a/src/Resources/config/grids/setono_sylius_facebook_admin_pixel.yaml +++ b/src/Resources/config/grids/setono_sylius_facebook_admin_pixel.yaml @@ -1,4 +1,7 @@ sylius_grid: + templates: + action: + setono_facebook_reset_failed_events: "@SetonoSyliusFacebookPlugin/Admin/Grid/Action/setono_facebook_reset_failed_events.html.twig" grids: setono_sylius_facebook_admin_pixel: driver: @@ -7,8 +10,17 @@ sylius_grid: class: "%setono_sylius_facebook.model.pixel.class%" fields: pixelId: - type: string + type: twig label: setono_sylius_facebook.ui.pixel_id + path: . + options: + template: "@SetonoSyliusFacebookPlugin/Admin/Grid/Field/pixel.html.twig" + events_statistics: + type: twig + label: setono_sylius_facebook.ui.events_statistics + path: . + options: + template: "@SetonoSyliusFacebookPlugin/Admin/Grid/Field/statistics.html.twig" channels: type: twig label: sylius.ui.channels @@ -35,5 +47,7 @@ sylius_grid: item: update: type: update + setono_facebook_reset_failed_events: + type: setono_facebook_reset_failed_events delete: type: delete diff --git a/src/Resources/config/routes/admin.yaml b/src/Resources/config/routes/admin.yaml index 10109eb..a18759d 100644 --- a/src/Resources/config/routes/admin.yaml +++ b/src/Resources/config/routes/admin.yaml @@ -8,6 +8,19 @@ setono_sylius_facebook_admin_pixel: vars: all: subheader: setono_sylius_facebook.ui.manage_pixels + templates: + form: "@SetonoSyliusFacebookPlugin/Admin/Pixel/_form.html.twig" index: icon: 'facebook' type: sylius.resource + +setono_sylius_facebook_admin_pixel_reset_failed_events: + path: /{id}/events/failed/reset + methods: [PATCH] + defaults: + _controller: setono_sylius_facebook.controller.action.pixel.reset_failed_events + _sylius: + section: admin + permission: true + redirect: + route: setono_sylius_facebook_admin_pixel_index diff --git a/src/Resources/config/services.xml b/src/Resources/config/services.xml index b73e286..92e292a 100644 --- a/src/Resources/config/services.xml +++ b/src/Resources/config/services.xml @@ -5,6 +5,7 @@ + @@ -13,5 +14,6 @@ + diff --git a/src/Resources/config/services/controller.xml b/src/Resources/config/services/controller.xml new file mode 100644 index 0000000..db457d9 --- /dev/null +++ b/src/Resources/config/services/controller.xml @@ -0,0 +1,15 @@ + + + + + + + + + + + + + diff --git a/src/Resources/config/services/twig.xml b/src/Resources/config/services/twig.xml new file mode 100644 index 0000000..e07266a --- /dev/null +++ b/src/Resources/config/services/twig.xml @@ -0,0 +1,12 @@ + + + + + + + + + + diff --git a/src/Resources/translations/flashes.en.yml b/src/Resources/translations/flashes.en.yml new file mode 100644 index 0000000..eb647e3 --- /dev/null +++ b/src/Resources/translations/flashes.en.yml @@ -0,0 +1,3 @@ +setono_sylius_facebook: + pixel: + failed_events_reset: 'Failed events reset' diff --git a/src/Resources/translations/messages.en.yaml b/src/Resources/translations/messages.en.yaml index b00c243..e6f0771 100644 --- a/src/Resources/translations/messages.en.yaml +++ b/src/Resources/translations/messages.en.yaml @@ -1,13 +1,25 @@ setono_sylius_facebook: form: pixel: + custom_access_token: Pixel's access token + custom_access_token_help: Leave it empty to use default one from plugin configuration + custom_access_token_placeholder: Insert Facebook access token for this pixel pixel_id: Pixel id + pixel_id_help: Get it from https://www.facebook.com/events_manager2 pixel_id_placeholder: Insert your pixel id... ui: + custom_access_token_specified: Custom token edit_pixel: Edit facebook pixel + events_in_state: + pending: Pending + sent: Sent + failed: Failed + events_statistics: Statistics facebook: Facebook manage_pixels: Manage facebook pixels new_pixel: New facebook pixel + no_channels: No channels + no_events: No events pixel_id: Pixel id pixels: Facebook pixels - no_channels: No channels + reset_failed_events: Reset failed events diff --git a/src/Resources/views/Admin/Grid/Action/setono_facebook_reset_failed_events.html.twig b/src/Resources/views/Admin/Grid/Action/setono_facebook_reset_failed_events.html.twig new file mode 100644 index 0000000..b69478e --- /dev/null +++ b/src/Resources/views/Admin/Grid/Action/setono_facebook_reset_failed_events.html.twig @@ -0,0 +1,18 @@ +{# @var \Setono\SyliusFacebookPlugin\Model\PixelInterface data #} + +{% import '@SetonoSyliusFacebookPlugin/Admin/Macro/buttons.html.twig' as buttons %} + +{% set path = options.link.url|default(path( + options.link.route|default('setono_sylius_facebook_admin_pixel_reset_failed_events'), + options.link.parameters|default({'id': data.id}) +)) %} + +{% set failedCount = setono_facebook_events_count_by_state(data, constant('Setono\\SyliusFacebookPlugin\\Model\\PixelEventInterface::STATE_FAILED')) %} +{% if options.visible|default(failedCount > 0) %} + {{ buttons.resetFailedEvents( + path, + action.label|default('setono_sylius_facebook.ui.reset_failed_events'), + true, + data.id + ) }} +{% endif %} diff --git a/src/Resources/views/Admin/Grid/Field/pixel.html.twig b/src/Resources/views/Admin/Grid/Field/pixel.html.twig new file mode 100644 index 0000000..32f13ef --- /dev/null +++ b/src/Resources/views/Admin/Grid/Field/pixel.html.twig @@ -0,0 +1,10 @@ +{# @var \Setono\SyliusFacebookPlugin\Model\PixelInterface data #} + +{{ data.pixelId }} + +{% if data.customAccessToken is not null %} +
+ + {{ 'setono_sylius_facebook.ui.custom_access_token_specified'|trans }} +
+{% endif %} diff --git a/src/Resources/views/Admin/Grid/Field/statistics.html.twig b/src/Resources/views/Admin/Grid/Field/statistics.html.twig new file mode 100644 index 0000000..c37c45f --- /dev/null +++ b/src/Resources/views/Admin/Grid/Field/statistics.html.twig @@ -0,0 +1,10 @@ +{# @var \Setono\SyliusFacebookPlugin\Model\PixelInterface data #} + +{% for state, count in setono_facebook_events_pushing_statistics(data) %} +
+ {{ ('setono_sylius_facebook.ui.events_in_state.' ~ state)|trans }}: + {{ count }} +
+{% else %} + {{ 'setono_sylius_facebook.ui.no_events'|trans }} +{% endfor %} diff --git a/src/Resources/views/Admin/Macro/buttons.html.twig b/src/Resources/views/Admin/Macro/buttons.html.twig new file mode 100644 index 0000000..7ef4d8e --- /dev/null +++ b/src/Resources/views/Admin/Macro/buttons.html.twig @@ -0,0 +1,11 @@ +{% extends '@SyliusUi/Macro/buttons.html.twig' %} + +{% macro resetFailedEvents(url, message, labeled = true, resourceId = null) %} +
+ + + +
+{% endmacro %} diff --git a/src/Resources/views/Admin/Pixel/_form.html.twig b/src/Resources/views/Admin/Pixel/_form.html.twig new file mode 100644 index 0000000..b1288e0 --- /dev/null +++ b/src/Resources/views/Admin/Pixel/_form.html.twig @@ -0,0 +1,11 @@ +
+ {{ form_errors(form) }} +
+ {{ form_row(form.pixelId) }} + {{ form_row(form.channels) }} +
+ {{ form_row(form.enabled) }} +
+
+ {{ form_row(form.customAccessToken) }} +
diff --git a/src/Twig/EventsPushingStatisticsExtension.php b/src/Twig/EventsPushingStatisticsExtension.php new file mode 100644 index 0000000..bdf773b --- /dev/null +++ b/src/Twig/EventsPushingStatisticsExtension.php @@ -0,0 +1,52 @@ +pixelEventRepository = $pixelEventRepository; + } + + public function getFunctions(): array + { + return [ + new TwigFunction('setono_facebook_events_pushing_statistics', [$this, 'getEventsPushingStatistics']), + new TwigFunction('setono_facebook_events_count_by_state', [$this, 'getEventsCountByState']), + ]; + } + + /** + * @return array + */ + public function getEventsPushingStatistics(PixelInterface $pixel): array + { + $result = []; + foreach (SendPixelEventWorkflow::getStates() as $state) { + $count = $this->pixelEventRepository->getCountByPixelAndState($pixel, $state); + if (0 === $count) { + continue; + } + + $result[$state] = $count; + } + + return $result; + } + + public function getEventsCountByState(PixelInterface $pixel, string $state): int + { + return $this->pixelEventRepository->getCountByPixelAndState($pixel, $state); + } +} diff --git a/tests/Application/templates/bundles/SyliusUiBundle/Form/theme.html.twig b/tests/Application/templates/bundles/SyliusUiBundle/Form/theme.html.twig new file mode 100644 index 0000000..2843248 --- /dev/null +++ b/tests/Application/templates/bundles/SyliusUiBundle/Form/theme.html.twig @@ -0,0 +1,10 @@ +{% extends '@!SyliusUi/Form/theme.html.twig' %} + +{% block form_row -%} +
+ {{- form_label(form) -}} + {{- form_widget(form) -}} + {{- form_help(form) -}} + {{- form_errors(form) -}} +
+{%- endblock form_row %} diff --git a/tests/Client/ClientTest.php b/tests/Client/ClientTest.php index ddb0881..ea1d762 100644 --- a/tests/Client/ClientTest.php +++ b/tests/Client/ClientTest.php @@ -58,6 +58,8 @@ public function send_pixel_event_test(): void $pixel = new Pixel(); $pixel->setPixelId($this->pixelId); + $eventTime = time(); + $pixelEvent = new PixelEvent(); $pixelEvent->setPixel($pixel); $pixelEvent->setData([ @@ -66,7 +68,7 @@ public function send_pixel_event_test(): void 'client_user_agent' => 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/94.0.4606.61 Safari/537.36', ], 'event_name' => 'ViewContent', - 'event_time' => 1635769396, + 'event_time' => $eventTime, 'custom_data' => [ 'contents' => [ [ @@ -106,7 +108,7 @@ public function send_pixel_event_test(): void Assert::string($requestOptions['body']); $requestBody = urldecode($requestOptions['body']); - $expected = sprintf('access_token=%s&data=[{"user_data":{"client_ip_address":"::1","client_user_agent":"Mozilla\/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit\/537.36 (KHTML, like Gecko) Chrome\/94.0.4606.61 Safari\/537.36"},"event_name":"ViewContent","event_time":1635769396,"custom_data":{"contents":[{"id":"Beige_strappy_summer_dress","quantity":1}],"content_ids":["Beige_strappy_summer_dress"],"content_name":"Beige strappy summer dress","content_type":"product"},"action_source":"website","event_source_url":"https:\/\/localhost:8000\/en_US\/products\/beige-strappy-summer-dress"}]', $this->accessToken); + $expected = sprintf('access_token=%s&data=[{"user_data":{"client_ip_address":"::1","client_user_agent":"Mozilla\/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit\/537.36 (KHTML, like Gecko) Chrome\/94.0.4606.61 Safari\/537.36"},"event_name":"ViewContent","event_time":%s,"custom_data":{"contents":[{"id":"Beige_strappy_summer_dress","quantity":1}],"content_ids":["Beige_strappy_summer_dress"],"content_name":"Beige strappy summer dress","content_type":"product"},"action_source":"website","event_source_url":"https:\/\/localhost:8000\/en_US\/products\/beige-strappy-summer-dress"}]', $this->accessToken, $eventTime); if (null !== $this->testEventCode) { $expected .= sprintf('&test_event_code=%s', $this->testEventCode); }