'order_shipped', 'title' => 'Order Shipped Event', 'processor_method' => 'processOrderShippedEvent', // Generic processor for order-based events ], [ 'id' => 'order_arrived', 'title' => 'Order Arrived Event', 'processor_method' => 'processOrderArrivedEvent', ], // Example: To add a "Refunded" event, just uncomment the next block. /* [ 'id' => 'order_refunded', 'title' => 'Order Refunded Event', 'processor_method' => 'processOrderEvent', ], */ ]; public function __construct() { $this->name = 'mauticconnect'; $this->tab = 'marketing'; $this->version = '1.2.0'; // Version incremented for new architecture $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('A data-driven module to integrate PrestaShop 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.'); } // --- DYNAMIC CONFIGURATION KEY HELPERS --- private function getEventConfigKey($eventId, $type) { $keyMap = [ 'ps_status' => 'PS_STATUS', 'mautic_segment' => 'M_SEGMENT', 'mautic_template' => 'M_TEMPLATE', ]; return 'MAUTICCONNECT_EVENT_' . strtoupper($eventId) . '_' . $keyMap[$type]; } /** * Module installation - now fully dynamic. */ 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); Configuration::updateValue(self::MAUTIC_URL, ''); // ... set other static configs to empty/0 ... // Dynamically install configuration for each defined event foreach (self::$eventDefinitions as $event) { Configuration::updateValue($this->getEventConfigKey($event['id'], 'ps_status'), 0); Configuration::updateValue($this->getEventConfigKey($event['id'], 'mautic_segment'), 0); Configuration::updateValue($this->getEventConfigKey($event['id'], 'mautic_template'), 0); } return parent::install() && $this->registerHook('actionCustomerAccountAdd') && $this->registerHook('actionObjectCustomerUpdateAfter') && $this->registerHook('actionOrderStatusUpdate'); } /** * Module uninstallation - now fully dynamic. */ public function uninstall() { // Delete static configs $staticConfigKeys = [self::MAUTIC_URL, self::MAUTIC_CLIENT_ID]; foreach ($staticConfigKeys as $key) { Configuration::deleteByName($key); } // Dynamically uninstall configuration for each defined event foreach (self::$eventDefinitions as $event) { Configuration::deleteByName($this->getEventConfigKey($event['id'], 'ps_status')); Configuration::deleteByName($this->getEventConfigKey($event['id'], 'mautic_segment')); Configuration::deleteByName($this->getEventConfigKey($event['id'], 'mautic_template')); } Db::getInstance()->execute('DROP TABLE IF EXISTS `' . _DB_PREFIX_ . 'mautic_processed_hooks`'); return parent::uninstall(); } /** * Renders the configuration page and handles all logic. */ public function getContent() { $output = ''; 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); $output .= $this->postProcess(); } 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.')); } $output .= $this->displayConnectionStatus(); $output .= $this->renderForms(); // Single method to render all forms return $output; } /** * Processes all form submissions dynamically. */ private function postProcess() { // Save connection settings Configuration::updateValue(self::MAUTIC_URL, rtrim(Tools::getValue(self::MAUTIC_URL, ''), '/')); Configuration::updateValue(self::MAUTIC_CLIENT_ID, Tools::getValue(self::MAUTIC_CLIENT_ID, '')); Configuration::updateValue(self::MAUTIC_CLIENT_SECRET, Tools::getValue(self::MAUTIC_CLIENT_SECRET, '')); // 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); } // Dynamically save event mapping settings if ($this->isConnected()) { foreach (self::$eventDefinitions as $event) { Configuration::updateValue($this->getEventConfigKey($event['id'], 'ps_status'), (int)Tools::getValue($this->getEventConfigKey($event['id'], 'ps_status'))); Configuration::updateValue($this->getEventConfigKey($event['id'], 'mautic_segment'), (int)Tools::getValue($this->getEventConfigKey($event['id'], 'mautic_segment'))); Configuration::updateValue($this->getEventConfigKey($event['id'], 'mautic_template'), (int)Tools::getValue($this->getEventConfigKey($event['id'], 'mautic_template'))); } } return $this->displayConfirmation($this->l('Settings saved.')); } /** * Generates all configuration forms dynamically. */ public function renderForms() { $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'); // --- Form 1: Connection Settings (no change) --- // ... (loading values for form 1) ... $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', ], ]; // --- Form 2: Event Mappings (only if connected) --- if ($this->isConnected()) { try { $mauticSegments = $this->getMauticSegments(); // We fetch the TRANSACTIONAL emails here $mauticTransactionalEmails = $this->getMauticEmails(); $prestashopStatuses = $this->getPrestaShopStatuses(); } catch (Exception $e) { return $this->displayError($this->l('Could not fetch data from Mautic to build the form. Error: ') . $e->getMessage()); } $event_inputs = []; foreach (self::$eventDefinitions as $event) { $psStatusKey = $this->getEventConfigKey($event['id'], 'ps_status'); $segmentKey = $this->getEventConfigKey($event['id'], 'mautic_segment'); $templateKey = $this->getEventConfigKey($event['id'], 'mautic_template'); if (!empty($event_inputs)) { $event_inputs[] = ['type' => 'html', 'name' => 'html_data', 'html_content' => '
']; } $event_inputs[] = ['type' => 'html', 'name' => 'html_data', 'html_content' => '

' . $this->l($event['title']) . '

']; $event_inputs[] = ['type' => 'select', 'label' => $this->l('PrestaShop Status (Trigger)'), 'name' => $psStatusKey, 'options' => ['query' => $prestashopStatuses, 'id' => 'id', 'name' => 'name']]; $event_inputs[] = ['type' => 'select', 'label' => $this->l('Mautic Segment (Target)'), 'name' => $segmentKey, 'options' => ['query' => $mauticSegments, 'id' => 'id', 'name' => 'name']]; // *** CHANGE IS HERE *** // We now clearly label the field and add a description for the user. $event_inputs[] = [ 'type' => 'select', 'label' => $this->l('Mautic Transactional Email'), 'name' => $templateKey, 'desc' => $this->l('Only "Template Emails" are shown. These are emails not tied to campaigns, suitable for transactional messages.'), 'options' => [ 'query' => $mauticTransactionalEmails, // Use the correctly named variable 'id' => 'id', 'name' => 'name' ] ]; // *** END OF CHANGE *** $helper->fields_value[$psStatusKey] = Configuration::get($psStatusKey); $helper->fields_value[$segmentKey] = Configuration::get($segmentKey); $helper->fields_value[$templateKey] = Configuration::get($templateKey); } $fields_form[1]['form'] = [ 'legend' => ['title' => $this->l('3. Event Mapping'), 'icon' => 'icon-random'], 'input' => $event_inputs, 'submit' => [ 'title' => $this->l('Save'), 'class' => 'btn btn-default pull-right', ], ]; } return $helper->generateForm($fields_form); } /** * Hook that triggers on order status updates. Now fully dynamic. */ public function hookActionOrderStatusUpdate($params) { if (!$this->isConnected()) { return false; } $orderId = (int)$params['id_order']; $newStatusId = (int)$params['newOrderStatus']->id; $eventHash = md5($newStatusId . '_' . $orderId); if ($this->isAlreadyProcessed($eventHash)) { return; } // Loop through our defined events to see if any match the new status foreach (self::$eventDefinitions as $event) { $configuredStatusId = (int)Configuration::get($this->getEventConfigKey($event['id'], 'ps_status')); // If the new status matches the one configured for this event... if ($configuredStatusId > 0 && $newStatusId === $configuredStatusId) { // ...call the processor method defined for this event. if (method_exists($this, $event['processor_method'])) { $this->{$event['processor_method']}($orderId, $event); $this->markAsProcessed($eventHash); // We break because an order status change should only trigger one event. break; } } } } // ... displayConnectionStatus, getMauticAuthUrl, getOauth2RedirectUri ... // ... makeApiRequest, refreshTokenIfNeeded ... // ... hookActionCustomerAccountAdd, hookActionObjectCustomerUpdateAfter, syncCustomer ... // ... sendOrderEmail, findContactByEmail, and all other helpers remain mostly unchanged ... // --- DATA PROVIDERS FOR FORMS --- private function getPrestaShopStatuses(): array { $statuses = OrderState::getOrderStates((int)$this->context->language->id); $options = [['id' => 0, 'name' => $this->l('--- Disabled ---')]]; foreach ($statuses as $status) { $options[] = ['id' => $status['id_order_state'], 'name' => $status['name']]; } return $options; } private function getMauticSegments(): array { $response = $this->makeApiRequest('/api/segments'); $segments = $response['lists'] ?? []; $options = [['id' => 0, 'name' => $this->l('--- Please Select ---')]]; foreach ($segments as $segment) { $options[] = ['id' => $segment['id'], 'name' => $segment['name']]; } return $options; } private function getMauticEmails(): array { $response = $this->makeApiRequest('/api/emails'); $emails = $response['emails'] ?? []; $options = [['id' => 0, 'name' => $this->l('--- Please Select ---')]]; foreach ($emails as $email) { // We MUST filter for 'template' type emails. These are the Mautic equivalent // of transactional emails, designed to be sent to a single contact via API. // 'list' emails are for mass-mailing to segments and are not suitable here. if (isset($email['emailType']) && $email['emailType'] === 'template') { $options[] = ['id' => $email['id'], 'name' => $email['name']]; } } return $options; } /** * 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 (!$this->isConnected()) { return false; } 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 (!$this->isConnected()) { return false; } 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) || strpos($customer->email, '@' . Tools::getShopDomainSsl())) { 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, array $eventDefinition) { $mauticSegmentId = (int)Configuration::get($this->getEventConfigKey($eventDefinition['id'], 'mautic_segment')); $mauticTemplateId = (int)Configuration::get($this->getEventConfigKey($eventDefinition['id'], 'mautic_template')); // Do nothing if this event is not fully configured if (!$mauticSegmentId || !$mauticTemplateId) { 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, $mauticSegmentId)) { 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'] . '
' . $product['product_quantity'] . ' x ' . round($product['unit_price_tax_incl'], 2) . ' ' . $currency->iso_code . '
' . 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 = implode('', [ '/api/emails/', $mauticTemplateId, '/contact/', $mauticContactId, '/send' ]); $response = $this->makeApiRequest($endpointUrl, 'POST', ['tokens' => $data_for_mautic]); return $response; } public function processOrderShippedEvent(int $id_order, array $eventDefinition) { $mauticSegmentId = (int)Configuration::get($this->getEventConfigKey($eventDefinition['id'], 'mautic_segment')); $mauticTemplateId = (int)Configuration::get($this->getEventConfigKey($eventDefinition['id'], 'mautic_template')); // Do nothing if this event is not fully configured if (!$mauticSegmentId || !$mauticTemplateId) { 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, $mauticSegmentId)) { 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'] . '
' . $product['product_quantity'] . ' x ' . round($product['unit_price_tax_incl'], 2) . ' ' . $currency->iso_code . '
' . 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 = implode('', [ '/api/emails/', $mauticTemplateId, '/contact/', $mauticContactId, '/send' ]); $response = $this->makeApiRequest($endpointUrl, 'POST', ['tokens' => $data_for_mautic]); return $response; } /** * 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 } }