diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 6f68ab2..02bfcbd 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -61,11 +61,7 @@ jobs: - name: Output PHP version for Symfony CLI run: php -v | head -n 1 | awk '{ print $2 }' > .php-version - - - - name: Install certificates - run: symfony server:ca:install - + - name: Run Chrome Headless run: google-chrome-stable --enable-automation --disable-background-networking --no-default-browser-check --no-first-run --disable-popup-blocking --disable-default-apps --allow-insecure-localhost --disable-translate --disable-extensions --no-sandbox --enable-features=Metal --headless --remote-debugging-port=9222 --window-size=2880,1800 --proxy-server='direct://' --proxy-bypass-list='*' http://127.0.0.1 > /dev/null 2>&1 & diff --git a/behat.yml.dist b/behat.yml.dist index ab46300..bb387af 100644 --- a/behat.yml.dist +++ b/behat.yml.dist @@ -20,7 +20,7 @@ default: Behat\MinkExtension: files_path: "%paths.base%/vendor/sylius/sylius/src/Sylius/Behat/Resources/fixtures/" - base_url: "https://127.0.0.1:8080/" + base_url: "http://127.0.0.1:8080/" default_session: symfony javascript_session: panther sessions: diff --git a/composer.json b/composer.json index b1f2c51..6666de3 100644 --- a/composer.json +++ b/composer.json @@ -53,6 +53,15 @@ "dealerdirect/phpcodesniffer-composer-installer": false, "phpstan/extension-installer": true, "symfony/flex": true + }, + "audit": { + "ignore": [ + "PKSA-gs8r-6kz6-pp56", + "PKSA-gnn4-pxdg-q76m", + "PKSA-yhcn-xrg3-68b1", + "PKSA-2wrf-1xmk-1pky", + "PKSA-4g5g-4rkv-myqs" + ] } }, "extra": { diff --git a/config/config.yml b/config/config.yml index b8cb8dd..9c3818e 100644 --- a/config/config.yml +++ b/config/config.yml @@ -2,7 +2,8 @@ parameters: user_com.frontend_api_key: '%env(USER_COM_FRONTEND_API_KEY)%' user_com.encryption_key: '%env(USER_COM_ENCRYPTION_KEY)%' user_com.encryption_iv: '%env(USER_COM_ENCRYPTION_IV)%' - + user_com.cookie_domain: '%env(USER_COM_COOKIE_DOMAIN)%' + twig: globals: user_com_frontend_api_key: '%user_com.frontend_api_key%' diff --git a/config/services/api.xml b/config/services/api.xml index aeaf200..b15cfbf 100644 --- a/config/services/api.xml +++ b/config/services/api.xml @@ -21,5 +21,9 @@ + + + + diff --git a/config/services/cookie.xml b/config/services/cookie.xml new file mode 100644 index 0000000..063f778 --- /dev/null +++ b/config/services/cookie.xml @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/config/services/event_subscriber.xml b/config/services/event_subscriber.xml index 322ec22..a40e769 100644 --- a/config/services/event_subscriber.xml +++ b/config/services/event_subscriber.xml @@ -15,5 +15,13 @@ + + + + + diff --git a/config/services/manager.xml b/config/services/manager.xml index 2dfe828..fb6371e 100644 --- a/config/services/manager.xml +++ b/config/services/manager.xml @@ -9,6 +9,8 @@ > + + %user_com.cookie_domain% If you use several channels, remember to select one of the available channels using the get method and parameters before using the API: +`?_channel_code=CHANNEL_CODE` + ```php public function assign(CustomerInterface $customer, array $agreements): void { diff --git a/doc/functionalities.md b/doc/functionalities.md index 4ff1607..d089167 100644 --- a/doc/functionalities.md +++ b/doc/functionalities.md @@ -22,9 +22,8 @@ The **BitBagSyliusUserComPlugin** integrates **User.com** with Sylius-based stor ### 4. Event-Driven System - Each customer interaction generates an **event**, which is stored and sent to **User.com** for automation and reporting. -### 5. Product Persistence & Feed Generation +### 5. Product Persistence - **Persists products** within the system for accurate data reporting. -- Generates a **product feed** that can be used for marketing and analytics purposes. ### 6. Tag Manager Script Injection - Allows users to **inject custom scripts** via **Tag Manager**. @@ -32,3 +31,8 @@ The **BitBagSyliusUserComPlugin** integrates **User.com** with Sylius-based stor ### 7. User information object - you can use `user_com_customer_info` in browser console to check currently logged in customer data + +### 8. Webhook Endpoint: Updating User Marketing Consents +- Exposes a dedicated endpoint for handling User.com webhooks (`UserComAgreements` in `Swagger`), +allowing the update of user marketing consents. By default, it manages the `subscribedToNewsletter` flag, +but the mechanism is fully extensible to support additional types of consents.” diff --git a/doc/installation.md b/doc/installation.md index bacbefe..3e7cd96 100644 --- a/doc/installation.md +++ b/doc/installation.md @@ -8,8 +8,20 @@ USER_COM_FRONTEND_API_KEY="" USER_COM_ENCRYPTION_KEY=your-32-character-long-key USER_COM_ENCRYPTION_IV=your-16-character-long-iv + USER_COM_COOKIE_DOMAIN="" MESSENGER_USER_COM_ASYNCHRONOUS_DSN="" ``` + - You can find the `USER_COM_FRONTEND_API_KEY` in the User.Com integration guide for `Google Tag Manager (Settings->Setup & Integrations)`. + - `USER_COM_ENCRYPTION_KEY` and `USER_COM_ENCRYPTION_IV` are required for cookie encryption. +- You can generate the encryption key and IV using the following command: + ```bash + php -r '$key = bin2hex(random_bytes(16)); echo "USER_COM_ENCRYPTION_KEY=\"" . $key . "\"\n"; $iv = bin2hex(random_bytes(8)); echo "USER_COM_ENCRYPTION_IV=\"" . $iv . "\"\n";' + ``` + - `MESSENGER_USER_COM_ASYNCHRONOUS_DSN` is the DSN for the messenger transport that will handle asynchronous messages. You can use different transports like `doctrine://default`, `amqp://guest:guest@localhost:5672/%2f/messages`, etc. + + - `USER_COM_COOKIE_DOMAIN` is optional, if not set, cookies will be set for the current domain. + + 3. Add plugin dependencies to `config/bundles.php` file. Make sure that none of the bundles are duplicated. ```php return [ diff --git a/ecs.php b/ecs.php index 3cf43de..fb0426f 100644 --- a/ecs.php +++ b/ecs.php @@ -18,4 +18,3 @@ VisibilityRequiredFixer::class => ['*Spec.php'], ]); }; - diff --git a/src/Api/UserApi.php b/src/Api/UserApi.php index cce4e2c..741284f 100644 --- a/src/Api/UserApi.php +++ b/src/Api/UserApi.php @@ -28,7 +28,7 @@ public function findUser( $url = $this->getApiEndpointUrl( $resource, self::FIND_USER_ENDPOINT, - sprintf('?%s=%s', $field, $value), + sprintf('?%s=%s', $field, rawurlencode($value)), ); return $this->request( diff --git a/src/Builder/Payload/CustomerPayloadBuilder.php b/src/Builder/Payload/CustomerPayloadBuilder.php index 3eb3913..5e22791 100644 --- a/src/Builder/Payload/CustomerPayloadBuilder.php +++ b/src/Builder/Payload/CustomerPayloadBuilder.php @@ -25,8 +25,8 @@ public function build(string $email, ?CustomerInterface $customer = null, ?Addre $payload = [ 'custom_id' => strtolower($customer?->getEmail() ?? $email), 'email' => strtolower($customer?->getEmail() ?? $email), - 'firstName' => $customer?->getFirstName(), - 'lastName' => $customer?->getLastName(), + 'first_name' => $customer?->getFirstName(), + 'last_name' => $customer?->getLastName(), 'phone_number' => $customer?->getPhoneNumber(), 'country' => $address?->getCountryCode(), 'region' => $address?->getProvinceCode(), diff --git a/src/Cookie/CookieQueue.php b/src/Cookie/CookieQueue.php new file mode 100644 index 0000000..283ff86 --- /dev/null +++ b/src/Cookie/CookieQueue.php @@ -0,0 +1,33 @@ +queued[] = $cookie; + } + + /** @return Cookie[] */ + public function pullAll(): array + { + $all = $this->queued; + $this->queued = []; + + return $all; + } +} diff --git a/src/Cookie/CookieQueueInterface.php b/src/Cookie/CookieQueueInterface.php new file mode 100644 index 0000000..efa7f64 --- /dev/null +++ b/src/Cookie/CookieQueueInterface.php @@ -0,0 +1,22 @@ +getResponse(); + + foreach ($this->queue->pullAll() as $cookie) { + $response->headers->setCookie($cookie); + } + } +} diff --git a/src/EventSubscriber/CustomerProfileUpdatedSubscriberInterface.php b/src/EventSubscriber/CustomerProfileUpdatedSubscriberInterface.php index 3652780..20de0b7 100644 --- a/src/EventSubscriber/CustomerProfileUpdatedSubscriberInterface.php +++ b/src/EventSubscriber/CustomerProfileUpdatedSubscriberInterface.php @@ -24,6 +24,7 @@ interface CustomerProfileUpdatedSubscriberInterface 'sylius_customer_profile' => 'customer_profile_update', 'sylius_admin_customer_update' => 'admin_customer_update', 'sylius_shop_account_profile_update' => 'shop_customer_update', + 'sylius_shop_account_address_book_set_as_default' => 'shop_customer_default_address_update', 'sylius_shop_register' => 'customer_registration', 'sylius_shop_checkout_address' => 'customer_order_address_provided', ]; diff --git a/src/Manager/CookieManager.php b/src/Manager/CookieManager.php index d1623c9..ff4b663 100644 --- a/src/Manager/CookieManager.php +++ b/src/Manager/CookieManager.php @@ -11,7 +11,9 @@ namespace BitBag\SyliusUserComPlugin\Manager; +use BitBag\SyliusUserComPlugin\Cookie\CookieQueueInterface; use Sylius\Component\Core\Model\AdminUserInterface; +use Symfony\Component\HttpFoundation\Cookie; use Symfony\Component\HttpFoundation\RequestStack; use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface; @@ -20,6 +22,8 @@ final class CookieManager implements CookieManagerInterface public function __construct( private readonly RequestStack $requestStack, private readonly TokenStorageInterface $tokenStorage, + private readonly CookieQueueInterface $queue, + private readonly ?string $cookieDomain = null, ) { } @@ -41,11 +45,29 @@ public function getUserComCookie(): ?string public function setUserComCookie(string $value): void { - $request = $this->requestStack->getCurrentRequest(); - if (null === $request) { - return; + $cookie = Cookie::create(self::CHAT_COOKIE_NAME) + ->withValue($value) + ->withPath('/') + ->withSecure(true) + ->withExpires(new \DateTimeImmutable('+1 year')) + ->withHttpOnly(false) + ->withSameSite('lax'); + + if (null !== $this->cookieDomain && '' !== $this->cookieDomain) { + $cookie = $cookie->withDomain($this->cookieDomain); + } else { + $request = $this->requestStack->getCurrentRequest(); + if (null === $request) { + return; + } + + $domain = $this->getBaseDomain($request->getHost()); + if (null !== $domain) { + $cookie = $cookie->withDomain($domain); + } } - $request->cookies->set(self::CHAT_COOKIE_NAME, $value); + + $this->queue->queue($cookie); } private function isShopUser(): bool @@ -59,4 +81,22 @@ private function isShopUser(): bool return true; } + + private function getBaseDomain(string $host): ?string + { + $host = (string) preg_replace('/:\d+$/', '', $host); + + if ($host === 'localhost' || filter_var($host, \FILTER_VALIDATE_IP) !== false) { + return null; + } + + $parts = explode('.', $host); + $count = count($parts); + + if ($count >= 2) { + return '.' . $parts[$count - 2] . '.' . $parts[$count - 1]; + } + + return null; + } } diff --git a/src/Manager/OrderUpdateManagerInterface.php b/src/Manager/OrderUpdateManagerInterface.php index 8b43f19..e71c707 100644 --- a/src/Manager/OrderUpdateManagerInterface.php +++ b/src/Manager/OrderUpdateManagerInterface.php @@ -16,8 +16,8 @@ interface OrderUpdateManagerInterface { public const PRODUCT_EVENT_MAP = [ - OrderInterface::STATE_NEW => 'purchase', - OrderInterface::STATE_FULFILLED => 'reservation', + OrderInterface::STATE_NEW => 'order', + OrderInterface::STATE_FULFILLED => 'purchase', OrderInterface::STATE_CANCELLED => 'remove', ]; diff --git a/src/OpenApi/OpenApiFactory.php b/src/OpenApi/OpenApiFactory.php new file mode 100644 index 0000000..e29745d --- /dev/null +++ b/src/OpenApi/OpenApiFactory.php @@ -0,0 +1,101 @@ +decorated)($context); + $paths = $openApi->getPaths(); + + $pathItem = new Model\PathItem( + summary: 'Synchronize user agreements coming from User.com', + post: new Model\Operation( + operationId: 'bitbag_usercom_customer_agreements_post', + tags: ['UserComAgreements'], + responses: [ + '200' => [ + 'description' => 'OK', + 'content' => [ + 'text/plain' => [ + 'schema' => [ + 'type' => 'string', + 'example' => 'OK', + ], + ], + ], + ], + '400' => ['description' => 'Invalid JSON payload'], + '401' => ['description' => 'Unauthorized'], + '404' => ['description' => 'Not found'], + '500' => ['description' => 'Internal server error'], + ], + parameters: [ + new Model\Parameter( + name: 'X-User-Com-Signature', + in: 'header', + description: 'Request signature', + required: false, + schema: ['type' => 'string'], + ), + ], + requestBody: new Model\RequestBody( + description: 'User.com agreements payload', + content: new \ArrayObject([ + 'application/json' => [ + 'schema' => [ + 'type' => 'object', + 'required' => ['extra'], + 'properties' => [ + 'extra' => [ + 'type' => 'object', + 'required' => ['email', 'agreements'], + 'properties' => [ + 'email' => [ + 'type' => 'string', + 'format' => 'email', + 'example' => 'john.doe@example.com', + ], + 'agreements' => [ + 'type' => 'object', + 'additionalProperties' => [ + 'type' => 'boolean', + ], + 'example' => [ + 'email_agreement' => true, + ], + ], + ], + ], + ], + ], + ], + ]), + ), + ), + ); + + $paths->addPath('/user-com/customer-agreements', $pathItem); + + return $openApi; + } +} diff --git a/src/Updater/CustomerWithKeyUpdater.php b/src/Updater/CustomerWithKeyUpdater.php index 90ede7a..8dd1f3c 100644 --- a/src/Updater/CustomerWithKeyUpdater.php +++ b/src/Updater/CustomerWithKeyUpdater.php @@ -78,11 +78,18 @@ public function updateWithUserKey( null !== $customer->getEmail() && $userFoundByKey['email'] === strtolower($customer->getEmail()) ) { - return $this->userApi->updateUser( + $user = $this->userApi->updateUser( $apiAwareResource, $userFoundByKey['id'], $payload, ); + + if (!is_array($user) || !isset($user['email']) || !is_string($user['email'])) { + throw new \RuntimeException('User was not created or updated (missing email).'); + } + $this->sendEvent($apiAwareResource, $user['email'], $eventName, $payload); + + return $user; } $userByEmailFromForm = $this->userApi->findUser( @@ -100,15 +107,20 @@ public function updateWithUserKey( $payload, ); - $this->userApi->mergeUsers($apiAwareResource, $userByEmailFromForm['id'], [$userFoundByKey['id']]); - - $this->changeCookieWithEvent($user, $apiAwareResource, $eventName); + if (is_array($user) && isset($user['email']) && is_string($user['email'])) { + $this->sendEvent($apiAwareResource, $user['email'], $eventName, $payload); + } + $this->changeCookie($user); return $user; } $user = $this->userApi->createUser($apiAwareResource, $payload); - $this->changeCookieWithEvent($user, $apiAwareResource, $eventName); + + if (is_array($user) && isset($user['email']) && is_string($user['email'])) { + $this->sendEvent($apiAwareResource, $user['email'], $eventName, $payload); + } + $this->changeCookie($user); return $user; } @@ -148,15 +160,16 @@ private function updateForUserWithoutEmail( $this->userApi->mergeUsers($apiAwareResource, $customerFoundByEmail['id'], [$userFromUserKey['id']]); } - $this->sendEvent($apiAwareResource, $email, $eventName, $payload); + if (is_array($user) && isset($user['email']) && is_string($user['email'])) { + $this->sendEvent($apiAwareResource, $user['email'], $eventName, $payload); + } + $this->changeCookie($user); return $user; } - public function changeCookieWithEvent( + public function changeCookie( ?array $user, - UserComApiAwareInterface $apiAwareResource, - string $eventName, ): void { if (false === is_array($user) || false === array_key_exists('id', $user) || @@ -166,6 +179,5 @@ public function changeCookieWithEvent( } $this->cookieManager->setUserComCookie($user['user_key']); - $this->sendEvent($apiAwareResource, $user['email'], $eventName); } } diff --git a/templates/Scripts/_userComScripts.html.twig b/templates/Scripts/_userComScripts.html.twig index 2c7a4af..f7699fc 100644 --- a/templates/Scripts/_userComScripts.html.twig +++ b/templates/Scripts/_userComScripts.html.twig @@ -2,15 +2,34 @@ {% set active = constant('BitBag\\SyliusUserComPlugin\\Builder\\Payload\\CustomerPayloadBuilderInterface::STATUS_USER')%} {% set visitor = constant('BitBag\\SyliusUserComPlugin\\Builder\\Payload\\CustomerPayloadBuilderInterface::STATUS_VISITOR')%} {% set customer = sylius.customer|default(null) %} - +{% if customer is not null %} + +{% else %} + +{% endif %} {% set apiAwareResource = getUserComApiAwareResource() %} {% if null != apiAwareResource and null != apiAwareResource.userComGTMContainerId %} diff --git a/tests/Application/.env b/tests/Application/.env index b5d9811..7a3f667 100644 --- a/tests/Application/.env +++ b/tests/Application/.env @@ -36,5 +36,6 @@ SYLIUS_MESSENGER_TRANSPORT_CATALOG_PROMOTION_REMOVAL_FAILED_DSN=doctrine://defau USER_COM_FRONTEND_API_KEY="" USER_COM_ENCRYPTION_KEY=your-32-character-long-key USER_COM_ENCRYPTION_IV=your-16-character-long- +USER_COM_COOKIE_DOMAIN="" MESSENGER_USER_COM_ASYNCHRONOUS_DSN="doctrine://default" ###< UserCom