commit c8a2ee6fa0f8c8def0cb2811bfb09a2682025fe4 Author: O K Date: Tue Jul 29 13:20:13 2025 +0300 first commit diff --git a/config_uk.xml b/config_uk.xml new file mode 100644 index 0000000..2f103d2 --- /dev/null +++ b/config_uk.xml @@ -0,0 +1,12 @@ + + + mauticconnect + + + + + + + 1 + 0 + \ No newline at end of file diff --git a/controllers/front/oauth2.php b/controllers/front/oauth2.php new file mode 100644 index 0000000..dff9297 --- /dev/null +++ b/controllers/front/oauth2.php @@ -0,0 +1,105 @@ +context->link->getAdminLink( + 'AdminModules', // Target Admin Controller + true, // Generate a security token + [], // No specific route needed + ['configure' => $this->module->name] // Add 'configure' parameter + ); + + // If Mautic returned an error (e.g., user denied access) + if ($error) { + // Use a cookie to pass the error message to the back office. + $this->context->cookie->mautic_error = $this->trans('Mautic authorization failed: %s', [$error], 'Modules.Mauticconnect.Admin'); + // Redirect back to the config page with an error flag. + Tools::redirect($adminModuleLink . '&auth_error=1'); + } + + // If Mautic did not return an authorization code, which is required. + if (!$code) { + $this->context->cookie->mautic_error = $this->trans('Invalid response from Mautic: No authorization code received.', [], 'Modules.Mauticconnect.Admin'); + Tools::redirect($adminModuleLink . '&auth_error=1'); + } + + // If we have a code, proceed to exchange it for an access token. + $this->exchangeCodeForToken($code, $adminModuleLink); + } + + /** + * Exchanges the authorization code for an access token by making a POST request to Mautic. + * + * @param string $code The authorization code from Mautic. + * @param string $redirectUrl The admin URL to redirect to after processing. + */ + protected function exchangeCodeForToken($code, $redirectUrl) + { + $mauticUrl = Configuration::get(MauticConnect::MAUTIC_URL); + $tokenUrl = $mauticUrl . '/oauth/v2/token'; + + $postData = [ + 'client_id' => Configuration::get(MauticConnect::MAUTIC_CLIENT_ID), + 'client_secret' => Configuration::get(MauticConnect::MAUTIC_CLIENT_SECRET), + 'grant_type' => 'authorization_code', + 'redirect_uri' => $this->context->link->getModuleLink($this->module->name, 'oauth2', [], true), + 'code' => $code, + ]; + + $ch = curl_init(); + curl_setopt($ch, CURLOPT_URL, $tokenUrl); + curl_setopt($ch, CURLOPT_POST, true); + curl_setopt($ch, CURLOPT_POSTFIELDS, http_build_query($postData)); + curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); + curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, true); + + $response = curl_exec($ch); + $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); + $curlError = curl_error($ch); + curl_close($ch); + + if ($curlError) { + $this->context->cookie->mautic_error = $this->trans('cURL Error while contacting Mautic: %s', [$curlError], 'Modules.Mauticconnect.Admin'); + Tools::redirect($redirectUrl . '&auth_error=1'); + } + + $data = json_decode($response, true); + + if ($httpCode !== 200 || !isset($data['access_token'])) { + $errorMessage = isset($data['error_description']) ? $data['error_description'] : $response; + $this->context->cookie->mautic_error = $this->trans('Failed to get access token. Mautic responded with: %s', [$errorMessage], 'Modules.Mauticconnect.Admin'); + Tools::redirect($redirectUrl . '&auth_error=1'); + } + + // --- Success! Save the tokens --- + Configuration::updateValue(MauticConnect::MAUTIC_ACCESS_TOKEN, $data['access_token']); + Configuration::updateValue(MauticConnect::MAUTIC_REFRESH_TOKEN, $data['refresh_token']); + + $expiresIn = isset($data['expires_in']) ? (int)$data['expires_in'] : 3600; + $expiresAt = time() + $expiresIn - 60; // Subtract 60s buffer + Configuration::updateValue(MauticConnect::MAUTIC_TOKEN_EXPIRES, $expiresAt); + + // Redirect back to the module configuration page with a success flag + Tools::redirect($redirectUrl . '&auth_success=1'); + } +} \ No newline at end of file diff --git a/mauticconnect.php b/mauticconnect.php new file mode 100644 index 0000000..f194594 --- /dev/null +++ b/mauticconnect.php @@ -0,0 +1,828 @@ +name = 'mauticconnect'; + $this->tab = 'marketing'; + $this->version = '1.0.0'; + $this->author = 'Your Name'; + $this->need_instance = 0; + $this->ps_versions_compliancy = ['min' => '1.7.0.0', 'max' => _PS_VERSION_]; + $this->bootstrap = true; + + parent::__construct(); + + $this->displayName = $this->l('Mautic Connect'); + $this->description = $this->l('Integrate your PrestaShop store with Mautic for marketing automation.'); + $this->confirmUninstall = $this->l('Are you sure you want to uninstall this module? All Mautic connection data will be lost.'); + } + + /** + * Module installation. + */ + public function install() + { + + $sql = 'CREATE TABLE IF NOT EXISTS `' . _DB_PREFIX_ . 'mautic_processed_hooks` ( + `id_processed_hook` INT(11) UNSIGNED NOT NULL AUTO_INCREMENT, + `hook_hash` VARCHAR(32) NOT NULL, + `date_add` DATETIME NOT NULL, + PRIMARY KEY (`id_processed_hook`), + UNIQUE KEY `hook_hash` (`hook_hash`) +) ENGINE=' . _MYSQL_ENGINE_ . ' DEFAULT CHARSET=utf8;'; + + Db::getInstance()->execute($sql); + // Set default empty values for configuration + Configuration::updateValue(self::MAUTIC_URL, ''); + Configuration::updateValue(self::MAUTIC_CLIENT_ID, ''); + Configuration::updateValue(self::MAUTIC_CLIENT_SECRET, ''); + Configuration::updateValue(self::MAUTIC_ACCESS_TOKEN, ''); + Configuration::updateValue(self::MAUTIC_REFRESH_TOKEN, ''); + Configuration::updateValue(self::MAUTIC_TOKEN_EXPIRES, 0); + + return parent::install() && $this->registerHook('actionCustomerAccountAdd'); + } + + /** + * Module uninstallation. + */ + public function uninstall() + { + // Delete all configuration keys + Configuration::deleteByName(self::MAUTIC_URL); + Configuration::deleteByName(self::MAUTIC_CLIENT_ID); + Configuration::deleteByName(self::MAUTIC_CLIENT_SECRET); + Configuration::deleteByName(self::MAUTIC_ACCESS_TOKEN); + Configuration::deleteByName(self::MAUTIC_REFRESH_TOKEN); + Configuration::deleteByName(self::MAUTIC_TOKEN_EXPIRES); + + return parent::uninstall(); + } + + + + /** + * Renders the configuration page in the back office. + */ + public function getContent() + { + $output = ''; + + // Handle form submission + if (Tools::isSubmit('submit' . $this->name)) { + $mauticUrl = Tools::getValue(self::MAUTIC_URL); + $clientId = Tools::getValue(self::MAUTIC_CLIENT_ID); + $clientSecret = Tools::getValue(self::MAUTIC_CLIENT_SECRET); + + if ($mauticUrl && $clientId && $clientSecret) { + Configuration::updateValue(self::MAUTIC_URL, rtrim($mauticUrl, '/')); + Configuration::updateValue(self::MAUTIC_CLIENT_ID, $clientId); + Configuration::updateValue(self::MAUTIC_CLIENT_SECRET, $clientSecret); + $output .= $this->displayConfirmation($this->l('Settings updated. Please connect to Mautic if you haven\'t already.')); + } else { + $output .= $this->displayError($this->l('Mautic URL, Client ID, and Client Secret are required.')); + } + } + + // Handle disconnect request + if (Tools::isSubmit('disconnectMautic')) { + Configuration::updateValue(self::MAUTIC_ACCESS_TOKEN, ''); + Configuration::updateValue(self::MAUTIC_REFRESH_TOKEN, ''); + Configuration::updateValue(self::MAUTIC_TOKEN_EXPIRES, 0); + $output .= $this->displayConfirmation($this->l('Successfully disconnected from Mautic.')); + } + + return $output . $this->displayConnectionStatus() . $this->renderForm(); + } + + /** + * Generates the configuration form. + */ + public function renderForm() + { + $helper = new HelperForm(); + $helper->module = $this; + $helper->name_controller = $this->name; + $helper->token = Tools::getAdminTokenLite('AdminModules'); + $helper->currentIndex = AdminController::$currentIndex . '&configure=' . $this->name; + $helper->submit_action = 'submit' . $this->name; + $helper->default_form_language = (int)Configuration::get('PS_LANG_DEFAULT'); + + $helper->fields_value[self::MAUTIC_URL] = Configuration::get(self::MAUTIC_URL); + $helper->fields_value[self::MAUTIC_CLIENT_ID] = Configuration::get(self::MAUTIC_CLIENT_ID); + $helper->fields_value[self::MAUTIC_CLIENT_SECRET] = Configuration::get(self::MAUTIC_CLIENT_SECRET); + + $fields_form[0]['form'] = [ + 'legend' => [ + 'title' => $this->l('Mautic API Settings'), + 'icon' => 'icon-cogs', + ], + 'input' => [ + [ + 'type' => 'text', + 'label' => $this->l('Mautic Base URL'), + 'name' => self::MAUTIC_URL, + 'required' => true, + 'desc' => $this->l('e.g., https://your-mautic-instance.com'), + ], + [ + 'type' => 'text', + 'label' => $this->l('Client ID'), + 'name' => self::MAUTIC_CLIENT_ID, + 'required' => true, + ], + [ + 'type' => 'text', + 'label' => $this->l('Client Secret'), + 'name' => self::MAUTIC_CLIENT_SECRET, + 'required' => true, + ], + ], + 'submit' => [ + 'title' => $this->l('Save'), + 'class' => 'btn btn-default pull-right', + ], + ]; + + return $helper->generateForm($fields_form); + } + + /** + * Displays the current connection status and connect/disconnect buttons. + */ + public function displayConnectionStatus() + { + $isConfigured = Configuration::get(self::MAUTIC_URL) && + Configuration::get(self::MAUTIC_CLIENT_ID) && + Configuration::get(self::MAUTIC_CLIENT_SECRET); + + $isConnected = (bool)Configuration::get(self::MAUTIC_ACCESS_TOKEN); + + $this->context->smarty->assign([ + 'is_configured' => $isConfigured, + 'is_connected' => $isConnected, + 'mautic_auth_url' => $isConfigured ? $this->getMauticAuthUrl() : '#', + 'disconnect_url' => AdminController::$currentIndex . '&configure=' . $this->name . '&disconnectMautic=1&token=' . Tools::getAdminTokenLite('AdminModules'), + ]); + + // We will create this tpl file in the future if needed, for now, we build HTML here. + $html = '
'; + $html .= '

' . $this->l('Connection Status') . '

'; + + if ($isConnected) { + $html .= '
' . $this->l('Successfully connected to Mautic.') . '
'; + $html .= '' . $this->l('Disconnect from Mautic') . ''; + } else { + $html .= '
' . $this->l('Not connected to Mautic.') . '
'; + if ($isConfigured) { + $html .= '' . $this->l('Connect to Mautic') . ''; + } else { + $html .= '

' . $this->l('Please fill in and save your API settings above before connecting.') . '

'; + } + } + $html .= '
'; + return $html; + } + + /** + * Builds the Mautic authorization URL. + */ + public function getMauticAuthUrl() + { + $mauticUrl = Configuration::get(self::MAUTIC_URL); + $params = [ + 'client_id' => Configuration::get(self::MAUTIC_CLIENT_ID), + 'grant_type' => 'authorization_code', + 'redirect_uri' => $this->getOauth2RedirectUri(), + 'response_type' => 'code', + 'state' => 'optional_csrf_token_' . bin2hex(random_bytes(16)) // For security + ]; + + return $mauticUrl . '/oauth/v2/authorize?' . http_build_query($params); + } + + /** + * Gets the OAuth2 callback URL for this module. + */ + public function getOauth2RedirectUri() + { + return $this->context->link->getModuleLink($this->name, 'oauth2', [], true); + } + + /* Core function to make any request to the Mautic API using Symfony HTTP Client. + * It handles token refreshing, authentication headers, and error checking. + * + * @param string $endpoint The API endpoint (e.g., '/api/contacts'). + * @param string $method The HTTP method (GET, POST, PATCH). + * @param array $data The data to send with POST/PATCH requests. + * @return array The decoded JSON response. + * @throws Exception On HTTP or API errors. + */ + private function makeApiRequest($endpoint, $method = 'GET', $data = []) + { + $this->refreshTokenIfNeeded(); + + $accessToken = Configuration::get(self::MAUTIC_ACCESS_TOKEN); + $mauticUrl = Configuration::get(self::MAUTIC_URL); + + /** @var \Symfony\Contracts\HttpClient\HttpClientInterface $client */ + $client = HttpClient::create(); + + $options = [ + 'headers' => [ + 'Authorization' => 'Bearer ' . $accessToken, + 'Accept' => 'application/json', + ], + ]; + + // For POST/PATCH requests, send the data as a JSON payload. + if (in_array($method, ['POST', 'PATCH', 'PUT']) && !empty($data)) { + $options['json'] = $data; + } + + try { + $response = $client->request($method, $mauticUrl . $endpoint, $options); + + // This will throw an exception for 4xx and 5xx status codes. + $responseData = $response->toArray(); + + // Mautic can still return a 200 OK with an error payload + if (isset($responseData['errors'])) { + $errorMessage = $responseData['errors'][0]['message'] ?? 'Unknown Mautic API Error'; + throw new Exception('Mautic API Error: ' . $errorMessage); + } + + return $responseData; + } catch (TransportExceptionInterface $e) { + // Errors related to the transport (e.g., DNS, connection timeout) + throw new Exception('Mautic Connection Error: ' . $e->getMessage(), 0, $e); + } catch (ClientExceptionInterface | ServerExceptionInterface | RedirectionExceptionInterface $e) { + // Errors for 3xx, 4xx, 5xx responses + $errorMessage = $e->getResponse()->getContent(false); // Get body without throwing another exception + throw new Exception('Mautic API Error (' . $e->getCode() . '): ' . $errorMessage, $e->getCode(), $e); + } + } + + /** + * Checks if the access token is expired and uses the refresh token to get a new one. + * + * @throws Exception if refreshing the token fails. + */ + private function refreshTokenIfNeeded() + { + $expiresAt = (int)Configuration::get(self::MAUTIC_TOKEN_EXPIRES); + + if (time() < $expiresAt) { + return; // Token is still valid + } + + $mauticUrl = Configuration::get(self::MAUTIC_URL); + $refreshToken = Configuration::get(self::MAUTIC_REFRESH_TOKEN); + + if (!$refreshToken) { + throw new Exception('Cannot refresh token: Refresh token is missing.'); + } + + $client = HttpClient::create(); + + $postData = [ + 'client_id' => Configuration::get(self::MAUTIC_CLIENT_ID), + 'client_secret' => Configuration::get(self::MAUTIC_CLIENT_SECRET), + 'grant_type' => 'refresh_token', + 'refresh_token' => $refreshToken, + ]; + + try { + $response = $client->request('POST', $mauticUrl . '/oauth/v2/token', [ + // Symfony client correctly encodes this as application/x-www-form-urlencoded + 'body' => $postData, + ]); + + $data = $response->toArray(); + + if (!isset($data['access_token'])) { + throw new Exception('Mautic response did not contain an access_token.'); + } + + // Success! Save the new tokens and expiry time. + Configuration::updateValue(self::MAUTIC_ACCESS_TOKEN, $data['access_token']); + Configuration::updateValue(self::MAUTIC_REFRESH_TOKEN, $data['refresh_token']); + $expiresAt = time() + (int)$data['expires_in'] - 60; // 60s buffer + Configuration::updateValue(self::MAUTIC_TOKEN_EXPIRES, $expiresAt); + } catch (Exception $e) { + // Critical failure: we can no longer authenticate. + Configuration::updateValue(self::MAUTIC_ACCESS_TOKEN, ''); + Configuration::updateValue(self::MAUTIC_REFRESH_TOKEN, ''); + Configuration::updateValue(self::MAUTIC_TOKEN_EXPIRES, 0); + throw new Exception('Failed to refresh Mautic token. Please reconnect the module. Error: ' . $e->getMessage(), 0, $e); + } + } + + + // --- HOOKS FOR CUSTOMER SYNC --- + + /** + * Hook called when a new customer account is created. + */ + public function hookActionCustomerAccountAdd($params) + { + if (isset($params['newCustomer']) && Validate::isLoadedObject($params['newCustomer'])) { + $this->syncCustomer($params['newCustomer']); + } + } + + /** + * Hook called after a customer object is updated. + */ + public function hookActionObjectCustomerUpdateAfter($params) + { + if (isset($params['object']) && $params['object'] instanceof Customer) { + $this->syncCustomer($params['object']); + } + } + /** + * Simple check to see if the module is fully configured and connected. + */ + public function isConnected() + { + return (bool)Configuration::get(self::MAUTIC_ACCESS_TOKEN); + } + public function syncAllCustomers() + { + $customers = new PrestaShopCollection('Customer'); + foreach ($customers as $customer) { + $this->syncCustomer($customer); + } + } + /** + * Synchronizes a PrestaShop customer with Mautic. + * Checks if the contact exists, then updates or creates it. + * + * @param Customer $customer The PrestaShop customer object. + * @return bool True on success, false on failure. + */ + public function syncCustomer(Customer $customer) + { + if (!$this->isConnected() || !Validate::isLoadedObject($customer)) { + return false; + } + + try { + // Find contact in Mautic by email + $mauticContact = $this->findContactByEmail($customer->email); + + $customerData = [ + 'firstname' => $customer->firstname, + 'lastname' => $customer->lastname, + 'email' => $customer->email, + ]; + + if ($mauticContact) { + // Contact exists, update it + $this->updateMauticContact($mauticContact['id'], $customerData); + } else { + // Contact does not exist, create it + $this->createMauticContact($customerData); + } + } catch (Exception $e) { + // Log the error for debugging without breaking the user's experience + PrestaShopLogger::addLog( + 'MauticConnect Error: ' . $e->getMessage(), + 3, // Severity: 3 for Error + null, + 'MauticConnect', + null, + true + ); + return false; + } + + return true; + } + + + // --- MAUTIC API HELPER FUNCTIONS --- + + /** + * Searches for a contact in Mautic by their email address. + * + * @param string $email + * @return array|null The contact data if found, otherwise null. + */ + private function findContactByEmail($email) + { + $endpoint = '/api/contacts?search=email:' . urlencode($email); + $response = $this->makeApiRequest($endpoint, 'GET'); + + // If contacts are found, Mautic returns them in a 'contacts' array. + if (!empty($response['contacts'])) { + // Return the first match + return reset($response['contacts']); + } + + return null; + } + + /** + * Creates a new contact in Mautic. + * + * @param array $data Contact data (firstname, lastname, email). + * @return array The API response. + */ + private function createMauticContact($data) + { + return $this->makeApiRequest('/api/contacts/new', 'POST', $data); + } + + /** + * Updates an existing contact in Mautic. + * + * @param int $contactId The Mautic contact ID. + * @param array $data Contact data to update. + * @return array The API response. + */ + private function updateMauticContact($contactId, $data) + { + // PATCH is used for partial updates, which is more efficient. + return $this->makeApiRequest('/api/contacts/' . (int)$contactId . '/edit', 'PATCH', $data); + } + + /** + * Checks if a contact with a given email is a member of a specific Mautic segment. + * This version uses the dedicated /contacts/{id}/segments endpoint. + * + * @param string $email The email address of the contact to check. + * @param int $segmentId The ID of the Mautic segment. + * @return bool True if the contact is in the segment, false otherwise. + */ + public function isContactInSegment($email, $segmentId) + { + // --- 1. Guard Clauses: Validate input and connection status --- + if (!$this->isConnected()) { + PrestaShopLogger::addLog('MauticConnect: Cannot check segment; module is not connected.', 2); + return false; + } + + if (empty($email) || !Validate::isEmail($email) || empty($segmentId) || !Validate::isUnsignedId($segmentId)) { + PrestaShopLogger::addLog('MauticConnect: Invalid email or segment ID provided for segment check.', 2); + return false; + } + + try { + // --- 2. Step 1: Find the contact by email to get their Mautic ID --- + $contact = $this->findContactByEmail($email); + + if (!$contact || !isset($contact['id'])) { + // Contact doesn't exist in Mautic, so they can't be in the segment. + return false; + } + + // --- 3. Step 2: Use the dedicated endpoint to get only their segments --- + $segments = $this->getContactSegments($contact['id']); + + // --- 4. Check if the target segment ID is in the list --- + if (!empty($segments)) { + // array_column creates an array of just the 'id' values from the segments + $segmentIds = array_column($segments, 'id'); + return in_array($segmentId, $segmentIds); + } + + // The contact exists but is in no segments. + return false; + } catch (Exception $e) { + // Log the error for debugging but return false to avoid breaking site functionality. + PrestaShopLogger::addLog( + 'MauticConnect: Error checking segment membership for ' . $email . ': ' . $e->getMessage(), + 3 // Severity 3 for Error + ); + return false; + } + } + + + /** + * Gets a list of segments a specific contact is a member of. + * Implements the GET /api/contacts/{id}/segments endpoint. + * + * @param int $contactId The Mautic contact ID. + * @return array A list of segment arrays, or an empty array. + * @throws Exception + */ + private function getContactSegments(int $contactId) + { + + $response = $this->makeApiRequest("/api/contacts/$contactId/segments", 'GET'); + // The response for this endpoint has a top-level key 'segments' + // which contains an array of the segment objects. + return $response['lists'] ?? []; + } + public function processOrderArrivedEvent(int $id_order) + { + if (!isset($this->mauticOrderArrivedSegmentId) || !isset($this->mauticOrderArrivedTemplateId)) { + return; + } + + // 2. Get all necessary objects + $order = new Order($id_order); + $customer = new Customer((int)$order->id_customer); + $currency = new Currency((int)$order->id_currency); + $carrier = new Carrier((int)$order->id_carrier); + $link = new Link(); // Needed for generating image URLs + + // 3. Gather primary data + $customer_email = $customer->email; + if (!$this->isContactInSegment($customer_email, $this->mauticOrderArrivedSegmentId)) { + return; + } + $tracking_number = $order->getWsShippingNumber(); + if (empty($tracking_number)) { + return; // Can't send a shipping email without a tracking number + } + + // Replace with your actual carrier's tracking URL format + $tracking_url = str_replace('@', $tracking_number, $carrier->url); + $order_number = $order->reference; + $order_date = date('Y-m-d', strtotime($order->date_add)); // Format as YYYY-MM-DD + + // 4. Build the dynamic Product HTML and JSON + $products_html = ''; + $order_items_for_json = []; + + $products = $order->getProducts(); + foreach ($products as $product) { + $product_obj = new Product($product['product_id'], false, $this->context->language->id); + $image_url = $link->getImageLink($product_obj->link_rewrite, $product['image']->id, 'cart_default'); + + // --- Build the HTML part --- + $products_html .= ' + + + + '; + + // --- Build the PHP array for the JSON part --- + $order_items_for_json[] = [ + "@type" => "Offer", + "itemOffered" => [ + "@type" => "Product", + "name" => $product['product_name'], + "sku" => $product['product_reference'], + "gtin" => $product['product_ean13'], + "image" => 'https://' . $image_url + ], + "price" => round($product['unit_price_tax_incl'], 2), + "priceCurrency" => $currency->iso_code + ]; + } + $products_html .= '
' . $product['product_name'] . '' . $product['product_name'] . '
Qty: ' . $product['product_quantity'] . '
' . round($product['total_price_tax_incl'], 2) . ' ' . $currency->iso_code . '
'; + $ldData = [ + "@context" => "http://schema.org", + "@type" => "Order", + "merchant" => [ + "@type" => "Organization", + "name" => "exclusion-ua.shop" + ], + "orderNumber" => $order_number, + "orderStatus" => "http://schema.org/OrderInTransit", + "orderDate" => date('Y-m-d H:i:sP', strtotime($order->date_add)), + "trackingUrl" => $tracking_url, + "acceptedOffer" => $order_items_for_json + ]; + // Convert the PHP array to a clean JSON string. IMPORTANT: remove the outer [] brackets for Mautic. + $order_items_json_string = ''; + + // 5. Prepare the final payload for the Mautic API + $data_for_mautic = [ + 'tracking_url' => $tracking_url, + 'tracking_number' => $tracking_number, + 'last_order_number' => $order_number, + 'last_order_date' => $order_date, + 'order_products_html' => $products_html, + 'order_items_json' => $order_items_json_string, + 'firstname' => $customer->firstname + ]; + $mauticContactId = $this->getMauticContactIdByEmail($customer_email); + + $endpointUrl = "/api/emails/$this->mauticOrderArrivedTemplateId/contact/$mauticContactId/send"; + $response = $this->makeApiRequest($endpointUrl, 'POST', ['tokens' => $data_for_mautic]); + return $response; + } + + public function processOrderShippedEvent(int $id_order) + { + if (!isset($this->mauticOrderShippedSegmentId) || !isset($this->mauticOrderShippedTemplateId)) { + return; + } + + // 2. Get all necessary objects + $order = new Order($id_order); + $customer = new Customer((int)$order->id_customer); + $currency = new Currency((int)$order->id_currency); + $carrier = new Carrier((int)$order->id_carrier); + $link = new Link(); // Needed for generating image URLs + + // 3. Gather primary data + $customer_email = $customer->email; + if (!$this->isContactInSegment($customer_email, $this->mauticOrderShippedSegmentId)) { + return; + } + $tracking_number = $order->getWsShippingNumber(); + if (empty($tracking_number)) { + return; // Can't send a shipping email without a tracking number + } + + // Replace with your actual carrier's tracking URL format + $tracking_url = str_replace('@', $tracking_number, $carrier->url); + $order_number = $order->reference; + $order_date = date('Y-m-d', strtotime($order->date_add)); // Format as YYYY-MM-DD + + // 4. Build the dynamic Product HTML and JSON + $products_html = ''; + $order_items_for_json = []; + + $products = $order->getProducts(); + foreach ($products as $product) { + $product_obj = new Product($product['product_id'], false, $this->context->language->id); + $image_url = $link->getImageLink($product_obj->link_rewrite, $product['image']->id, 'cart_default'); + + // --- Build the HTML part --- + $products_html .= ' + + + + '; + + // --- Build the PHP array for the JSON part --- + $order_items_for_json[] = [ + "@type" => "Offer", + "itemOffered" => [ + "@type" => "Product", + "name" => $product['product_name'], + "sku" => $product['product_reference'], + "gtin" => $product['product_ean13'], + "image" => 'https://' . $image_url + ], + "price" => round($product['unit_price_tax_incl'], 2), + "priceCurrency" => $currency->iso_code + ]; + } + $products_html .= '
' . $product['product_name'] . '' . $product['product_name'] . '
Qty: ' . $product['product_quantity'] . '
' . round($product['total_price_tax_incl'], 2) . ' ' . $currency->iso_code . '
'; + $ldData = [ + "@context" => "http://schema.org", + "@type" => "Order", + "merchant" => [ + "@type" => "Organization", + "name" => "exclusion-ua.shop" + ], + "orderNumber" => $order_number, + "orderStatus" => "http://schema.org/OrderInTransit", + "orderDate" => date('Y-m-d H:i:sP', strtotime($order->date_add)), + "trackingUrl" => $tracking_url, + "acceptedOffer" => $order_items_for_json + ]; + // Convert the PHP array to a clean JSON string. IMPORTANT: remove the outer [] brackets for Mautic. + $order_items_json_string = ''; + + // 5. Prepare the final payload for the Mautic API + $data_for_mautic = [ + 'tracking_url' => $tracking_url, + 'tracking_number' => $tracking_number, + 'last_order_number' => $order_number, + 'last_order_date' => $order_date, + 'order_products_html' => $products_html, + 'order_items_json' => $order_items_json_string, + 'firstname' => $customer->firstname + ]; + $mauticContactId = $this->getMauticContactIdByEmail($customer_email); + + $endpointUrl = "/api/emails/$this->mauticOrderShippedTemplateId/contact/$mauticContactId/send"; + $response = $this->makeApiRequest($endpointUrl, 'POST', ['tokens' => $data_for_mautic]); + return $response; + } + public function hookActionOrderStatusUpdate($params) + { + $eventHash = md5((int)$params['newOrderStatus']->id . '_' . (int)$params['id_order']); + + if ($this->isAlreadyProcessed($eventHash)) { + return; + } + + if ((int)$params['newOrderStatus']->id == $this->psOrderShippedStatusId) { + $this->processOrderShippedEvent((int)$params['id_order']); + } + if ((int)$params['newOrderStatus']->id == $this->psOrderArrivedStatusId) { + $this->processOrderArrivedEvent((int)$params['id_order']); + } + $this->markAsProcessed($eventHash); + } + + + /** + * Finds a Mautic contact by their email address and returns their Mautic ID. + * + * This function uses the Mautic API's search functionality. + * It's designed to be efficient by specifically requesting only the necessary data. + * + * @param string $email The email address of the contact to find. + * @return int|null The Mautic contact ID if found, otherwise null. + * @throws Exception If there is an API communication error. + */ + private function getMauticContactIdByEmail(string $email): int + { + // 1. Basic validation to prevent unnecessary API calls for invalid emails. + if (!filter_var($email, FILTER_VALIDATE_EMAIL)) { + // Or throw new \InvalidArgumentException('Invalid email format provided.'); + return 0; + } + + // 2. Construct the API endpoint with a search filter. + // The format `email:{value}` is Mautic's specific search syntax. + // We must urlencode the email to handle special characters like '+'. + $endpoint = '/api/contacts?search=email:' . urlencode($email); + + // 3. Use your existing helper function to make the GET request. + // We don't need to pass a method (defaults to 'GET') or data. + $responseData = $this->makeApiRequest($endpoint); + + // 4. Process the response to extract the ID. + // Mautic returns contacts as an associative array where the KEY is the contact ID. + // We check if the 'contacts' key exists and is not empty. + if (!empty($responseData['contacts']) && is_array($responseData['contacts'])) { + // Get all the keys (which are the contact IDs) from the associative array. + $contactIds = array_keys($responseData['contacts']); + + // Return the first ID found, cast to an integer. + return (int)$contactIds[0]; + } + + // 5. If the 'contacts' array is empty or doesn't exist, the contact was not found. + return 0; + } + + /** + * Checks the database to see if a hook event has already been logged. + * + * @param string $hash A unique md5 hash representing the event. + * @return bool True if the event has been processed, false otherwise. + */ + private function isAlreadyProcessed(string $hash): bool + { + $db = Db::getInstance(); + $sql = 'SELECT `id_processed_hook` + FROM `' . _DB_PREFIX_ . 'mautic_processed_hooks` + WHERE `hook_hash` = "' . pSQL($hash) . '"'; + + $result = $db->getValue($sql); + + return (bool)$result; + } + + /** + * Logs a processed hook event in the database to prevent duplicates. + * + * @param string $hash A unique md5 hash representing the event. + * @return void + */ + private function markAsProcessed(string $hash): void + { + $db = Db::getInstance(); + $db->insert('mautic_processed_hooks', [ + 'hook_hash' => pSQL($hash), + 'date_add' => date('Y-m-d H:i:s'), + ], false, true, Db::INSERT_IGNORE); // INSERT IGNORE is a safe way to prevent errors on race conditions + } +}