Files
mauticconnect/mauticconnect.php
2025-10-01 13:22:15 +03:00

1527 lines
66 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?php
/**
* Mautic Integration for PrestaShop
*
* @author Panariga
* @copyright 2025 Panariga
* @license https://opensource.org/licenses/AFL-3.0 Academic Free License (AFL 3.0)
*/
if (!defined('_PS_VERSION_')) {
exit;
}
use Symfony\Component\HttpClient\HttpClient;
use Symfony\Contracts\HttpClient\Exception\ClientExceptionInterface;
use Symfony\Contracts\HttpClient\Exception\RedirectionExceptionInterface;
use Symfony\Contracts\HttpClient\Exception\ServerExceptionInterface;
use Symfony\Contracts\HttpClient\Exception\TransportExceptionInterface;
class MauticConnect extends Module
{
// --- STATIC CONFIGURATION ---
// Connection Settings
const MAUTIC_URL = 'MAUTICCONNECT_URL';
const MAUTIC_CLIENT_ID = 'MAUTICCONNECT_CLIENT_ID';
const MAUTIC_CLIENT_SECRET = 'MAUTICCONNECT_CLIENT_SECRET';
const MAUTIC_ACCESS_TOKEN = 'MAUTICCONNECT_ACCESS_TOKEN';
const MAUTIC_REFRESH_TOKEN = 'MAUTICCONNECT_REFRESH_TOKEN';
const MAUTIC_TOKEN_EXPIRES = 'MAUTICCONNECT_TOKEN_EXPIRES';
/**
* A static property to cache the access token for the duration of a single request.
* This prevents multiple API calls to verify/refresh the token within one script execution.
* @var array|null
*/
private static $runtimeCache = null;
// --- DATA-DRIVEN EVENT DEFINITIONS ---
// To add a new event, simply add a new entry to this array.
// The module will automatically handle installation, forms, and processing.
private static $eventDefinitions = [
[
'id' => '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',
],
[
'id' => 'cart_abandon',
'title' => 'Abandon Cart Event',
'processor_method' => 'processAbandonCartEvent',
],
// 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 = '';
$mauticUrl = Tools::getValue(self::MAUTIC_URL);
$clientId = Tools::getValue(self::MAUTIC_CLIENT_ID);
$clientSecret = Tools::getValue(self::MAUTIC_CLIENT_SECRET);
if (Tools::isSubmit('submit' . $this->name)) {
$output .= $this->postProcess();
}
$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);
}
$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);
} else {
return $this->displayError($this->l('Mautic URL, Client ID, and Client Secret are required.'));
}
// 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' => '<hr>'];
}
$event_inputs[] = ['type' => 'html', 'name' => 'html_data', 'html_content' => '<h4>' . $this->l($event['title']) . '</h4>'];
$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;
$this->log(['orderId' => $orderId, 'newStatusId' => $newStatusId]);
$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;
}
}
}
}
public function runAbandonCartCampaign()
{
$cartCollection = new PrestaShopCollection('Cart');
$cartCollection->where('id_customer', '!=', 0);
$cartCollection->where('date_add', '>', date('Y-m-d', time() - 60 * 60 * 24 * 1));
$cartCollection->where('date_add', '<', date('Y-m-d'));
//@var Cart $cart
foreach ($cartCollection as $cart) {
if (!Order::getIdByCartId($cart->id)) {
$this->processAbandonCart($cart->id);
}
}
}
public function processAbandonCart(int $id_cart)
{
if (!$this->isConnected()) {
return false;
}
$eventHash = md5('abandon_cart' . '_' . $id_cart);
if ($this->isAlreadyProcessed($eventHash)) {
return;
}
// Loop through our defined events to see if any match the new status
foreach (self::$eventDefinitions as $event) {
if ($event['id'] === 'cart_abandon') {
// ...call the processor method defined for this event.
if (method_exists($this, $event['processor_method'])) {
$this->{$event['processor_method']}($id_cart, $event);
$this->markAsProcessed($eventHash);
// We break because an order status change should only trigger one event.
break;
}
}
}
}
// ... displayConnectionStatus, getMauticAuthUrl, getOauth2RedirectUri ...
// ... 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;
}
public 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 = '<div class="panel">';
$html .= '<h3><i class="icon-link"></i> ' . $this->l('Connection Status') . '</h3>';
if ($isConnected) {
$html .= '<div class="alert alert-success">' . $this->l('Successfully connected to Mautic.') . '</div>';
$html .= '<a href="' . AdminController::$currentIndex . '&configure=' . $this->name . '&disconnectMautic=1&token=' . Tools::getAdminTokenLite('AdminModules') . '" class="btn btn-danger">' . $this->l('Disconnect from Mautic') . '</a>';
} else {
$html .= '<div class="alert alert-warning">' . $this->l('Not connected to Mautic.') . '</div>';
if ($isConfigured) {
$html .= '<a href="' . $isConfigured ? $this->getMauticAuthUrl() : '#' . '" class="btn btn-primary">' . $this->l('Connect to Mautic') . '</a>';
} else {
$html .= '<p>' . $this->l('Please fill in and save your API settings above before connecting.') . '</p>';
}
}
$html .= '</div>';
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);
}
/**
* Gets a guaranteed valid Mautic access token.
*
* This function uses a three-level approach for maximum reliability and efficiency:
* 1. Runtime Cache: Returns a token if one was already validated in the current script execution.
* 2. Database Cache + Live Ping: Uses the stored token but verifies it with a quick API call.
* 3. Token Refresh: If the token is expired or invalid, uses the refresh token to get a new one.
*
* This is the ONLY function you should call to get a token before making an API request.
*
* @return string The valid access token.
* @throws Exception if a valid token cannot be obtained.
*/
public function getValidAccessToken(): string
{
// Level 1: Check the runtime cache for this script execution.
if (self::$runtimeCache && time() < self::$runtimeCache['expires']) {
return self::$runtimeCache['token'];
}
$mauticUrl = Configuration::get(self::MAUTIC_URL);
if (!$mauticUrl || !Configuration::get(self::MAUTIC_CLIENT_ID)) {
throw new DomainException('Mautic is not configured. Please check the module settings.');
}
$accessToken = Configuration::get(self::MAUTIC_ACCESS_TOKEN);
$expiresAt = (int)Configuration::get(self::MAUTIC_TOKEN_EXPIRES);
// Level 2: Check the database token and perform a live "ping" to verify it.
if ($accessToken && time() < $expiresAt) {
try {
$client = HttpClient::create(['auth_bearer' => $accessToken]);
// A simple, low-impact API call to check if the token is still valid.
$client->request('GET', $mauticUrl . '/api/contacts?limit=1');
// If the ping succeeds, the token is valid. Cache it for this runtime and return.
self::$runtimeCache = ['token' => $accessToken, 'expires' => $expiresAt];
return $accessToken;
} catch (ClientExceptionInterface $e) {
if ($e->getResponse()->getStatusCode() !== 401) {
// It's a different error (e.g., 404, 500). Log and fail fast.
PrestaShopLogger::addLog(
sprintf(
'Mautic API ping failed with status %d. Response: %s',
$e->getResponse()->getStatusCode(),
$e->getResponse()->getContent(false)
),
3 // Severity 3: Error
);
throw new Exception('Mautic API is not responding correctly. Please check Mautic status.', 0, $e);
}
// If it IS a 401, the token is dead. Log it and proceed to the refresh logic below.
PrestaShopLogger::addLog('Mautic token from database was rejected (401). Forcing refresh.', 1);
} catch (Exception $e) {
PrestaShopLogger::addLog('Mautic API ping failed with a transport error: ' . $e->getMessage(), 3);
throw new Exception('Could not connect to Mautic to verify the token.', 0, $e);
}
}
// Level 3: The token is expired or was proven invalid. We MUST refresh.
PrestaShopLogger::addLog('Attempting to refresh Mautic token.', 1);
$refreshToken = Configuration::get(self::MAUTIC_REFRESH_TOKEN);
if (!$refreshToken) {
PrestaShopLogger::addLog('Cannot refresh Mautic token: Refresh Token is missing. Please re-authenticate the module.', 4);
throw new DomainException('Mautic Refresh Token is missing. Please reconnect the module.');
}
try {
$client = HttpClient::create();
$response = $client->request('POST', $mauticUrl . '/oauth/v2/token', [
'body' => [
'client_id' => Configuration::get(self::MAUTIC_CLIENT_ID),
'client_secret' => Configuration::get(self::MAUTIC_CLIENT_SECRET),
'grant_type' => 'refresh_token',
'refresh_token' => $refreshToken,
],
]);
$data = $response->toArray(); // Throws on non-2xx responses
if (empty($data['access_token'])) {
PrestaShopLogger::addLog('Mautic refresh response was successful but did not contain an access_token. Response: ' . json_encode($data), 4);
throw new Exception('Invalid Mautic refresh response.');
}
// SUCCESS! Save everything to the database AND the runtime cache.
$newAccessToken = $data['access_token'];
$newRefreshToken = $data['refresh_token'];
$newExpiresAt = time() + (int)$data['expires_in'] - 120; // 120s safety buffer
Configuration::updateValue(self::MAUTIC_ACCESS_TOKEN, $newAccessToken);
Configuration::updateValue(self::MAUTIC_REFRESH_TOKEN, $newRefreshToken);
Configuration::updateValue(self::MAUTIC_TOKEN_EXPIRES, $newExpiresAt);
self::$runtimeCache = ['token' => $newAccessToken, 'expires' => $newExpiresAt];
PrestaShopLogger::addLog('Successfully refreshed Mautic access token.', 1);
return $newAccessToken;
} catch (Exception $e) {
$errorContext = 'CRITICAL: Mautic token refresh failed: ' . $e->getMessage();
if ($e instanceof ClientExceptionInterface) {
$errorContext .= ' | Mautic Response: ' . $e->getResponse()->getContent(false);
}
PrestaShopLogger::addLog($errorContext, 4); // Severity 4: CRITICAL
// Invalidate the expired access token. DO NOT touch the refresh token unless Mautic
// explicitly tells us it's invalid.
Configuration::updateValue(self::MAUTIC_ACCESS_TOKEN, '');
Configuration::updateValue(self::MAUTIC_TOKEN_EXPIRES, 0);
if ($e instanceof ClientExceptionInterface && str_contains($e->getResponse()->getContent(false), 'invalid_grant')) {
// This is the only case where the refresh token is truly dead.
Configuration::updateValue(self::MAUTIC_REFRESH_TOKEN, '');
throw new Exception('Mautic refresh token is invalid or expired. Please reconnect the module in the configuration page.', 0, $e);
}
// For all other errors (network issues, Mautic down), we keep the refresh token to try again later.
throw new Exception('Could not refresh Mautic token. The system will try again on the next run.', 0, $e);
}
}
/* 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 = [])
{
try {
$accessToken = $this->getValidAccessToken();
} catch (Exception $e) {
// The getValidAccessToken function already logged the critical error.
// We just log the consequence and stop this specific operation.
PrestaShopLogger::addLog(
'valid Mautic token could not be obtained. Error: ' . $e->getMessage(),
3 // Severity 3: Error
);
return false; // Or handle the failure as needed
}
$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);
}
}
/**
* Verifies the Mautic API connection by making a lightweight API call.
* If the call fails due to an expired token (401), it triggers a token refresh.
* This function should be called before making any critical API calls to Mautic.
*
* @throws Exception if the connection is invalid and cannot be refreshed.
*/
public function verifyConnectionAndRefresh(): void
{
$accessToken = Configuration::get(self::MAUTIC_ACCESS_TOKEN);
$mauticUrl = Configuration::get(self::MAUTIC_URL);
if (!$accessToken || !$mauticUrl) {
// Not configured yet, nothing to do.
PrestaShopLogger::addLog('Mautic connection check skipped: module is not configured.', 1);
return;
}
// First, check the local expiry time. If it's not expired, do a quick "ping".
$expiresAt = (int)Configuration::get(self::MAUTIC_TOKEN_EXPIRES);
if (time() < $expiresAt) {
try {
$client = HttpClient::create([
'auth_bearer' => $accessToken,
]);
// A simple, low-impact API call to check if the token is still valid.
$client->request('GET', $mauticUrl . '/api/contacts?limit=1');
// If the above line doesn't throw, the token is valid. We are done.
return;
} catch (ClientExceptionInterface $e) {
// Check if it's an authentication error (token expired/revoked)
if ($e->getResponse()->getStatusCode() === 401) {
PrestaShopLogger::addLog('Mautic access token is invalid or expired. Attempting to refresh.', 1); // Severity 1: Informational
// The token is invalid, proceed to refresh it below.
} else {
// Another client error (e.g., 404 Not Found, 403 Forbidden). Log and re-throw.
PrestaShopLogger::addLog(
sprintf(
'Mautic API ping failed with status code %d. Error: %s',
$e->getResponse()->getStatusCode(),
$e->getResponse()->getContent(false)
),
3 // Severity 3: Error
);
throw new Exception('Mautic API ping failed. Please check module configuration and Mautic API status.', 0, $e);
}
} catch (Exception $e) {
// Other errors like network issues.
PrestaShopLogger::addLog('Mautic API ping failed with a general error: ' . $e->getMessage(), 3);
throw new Exception('Could not connect to Mautic for ping test.', 0, $e);
}
}
// If the code reaches here, it's either because the token is expired locally
// or the ping returned a 401. Time to refresh.
try {
$this->refreshToken(true); // Force refresh
} catch (Exception $e) {
// The refresh itself failed. The original exception from refreshToken is already logged.
// We re-throw it to stop the current process.
throw $e;
}
}
/**
* Checks if the access token is expired and uses the refresh token to get a new one.
* This version is made more resilient and provides detailed logging.
*
* @param bool $force Forces a refresh attempt even if the token is not expired locally.
* @return string The new expiry date.
* @throws Exception if refreshing the token fails critically.
*/
public function refreshToken(bool $force = false): string
{
$expiresAt = (int)Configuration::get(self::MAUTIC_TOKEN_EXPIRES);
if ((time() + 3600) < $expiresAt && !$force) {
return date("Y-m-d H:i:s", $expiresAt); // Token is still valid
}
$mauticUrl = Configuration::get(self::MAUTIC_URL);
$refreshToken = Configuration::get(self::MAUTIC_REFRESH_TOKEN);
if (!$mauticUrl || !$refreshToken) {
// Log this critical state
PrestaShopLogger::addLog('Cannot refresh Mautic token: Mautic URL or Refresh Token is missing. Please re-authenticate.', 4); // Severity 4: Critical
throw new DomainException('Cannot refresh token: Mautic URL or Refresh token is missing in configuration.');
}
$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', [
'body' => $postData,
]);
$data = $response->toArray(); // Throws exception on non-2xx response
if (!isset($data['access_token'])) {
PrestaShopLogger::addLog('Mautic refresh response did not contain an access_token. Response: ' . json_encode($data), 4);
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']);
$newExpiresAt = time() + (int)$data['expires_in'] - 120; // 120s buffer for safety
Configuration::updateValue(self::MAUTIC_TOKEN_EXPIRES, $newExpiresAt);
PrestaShopLogger::addLog('Successfully refreshed Mautic access token. New expiry: ' . date("Y-m-d H:i:s", $newExpiresAt), 1);
return date("Y-m-d H:i:s", $newExpiresAt);
} catch (Exception $e) {
// This is the CRITICAL change. We log with high severity but DO NOT delete the refresh token.
$errorContext = 'Mautic token refresh failed: ' . $e->getMessage();
// Try to get the specific API error message from Mautic for better debugging
if ($e instanceof ClientExceptionInterface) {
$errorContext .= ' | Mautic Response: ' . $e->getResponse()->getContent(false);
}
PrestaShopLogger::addLog($errorContext, 4); // Severity 4: CRITICAL ERROR
// Invalidate the CURRENT access token, as it's clearly not working.
// DO NOT delete the refresh token. It's our only chance to recover.
Configuration::updateValue(self::MAUTIC_ACCESS_TOKEN, '');
Configuration::updateValue(self::MAUTIC_TOKEN_EXPIRES, 0);
// If the error response indicates the refresh token itself is invalid, then we can't recover.
if ($e instanceof ClientExceptionInterface && str_contains($e->getResponse()->getContent(false), 'invalid_grant')) {
// Now it's truly a fatal error. The refresh token is dead.
Configuration::updateValue(self::MAUTIC_REFRESH_TOKEN, '');
throw new Exception('Failed to refresh Mautic token because the refresh token is invalid or expired. Please reconnect the module in the configuration page. Original Error: ' . $e->getMessage(), 0, $e);
}
// For other errors (network, Mautic down), we throw an exception to halt the current operation,
// but we preserve the refresh token so the system can try again later.
throw new Exception('Failed to refresh Mautic token. The system will try again later. 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;
}
$this->log(['newCustomer' => $params['newCustomer']]);
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(bool $full = false)
{
$customers = new PrestaShopCollection('Customer');
if (!$full) {
$customers->where('date_add', '>', date("Y-m-d H:i:s", time() - 60 - 60 * 24 * 2));
}
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()) || $customer->email == 'anonymous@psgdpr.com') {
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
$this->syncCustomer($customer);
// 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 = '<table width="100%" cellpadding="10" cellspacing="0" style="border-collapse: collapse; margin-top: 20px;">';
$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 .= '<tr style="border-bottom: 1px solid #eee;">
<td width="80"><img src="https://' . $image_url . '" alt="' . $product['product_name'] . '" width="70" style="border: 1px solid #ddd;"></td>
<td><a href="' . $product_obj->getLink() . '" >' . $product['product_name'] . '</a><br><small>' . $product['product_quantity'] . ' x ' . round($product['unit_price_tax_incl'], 2) . ' ' . $currency->iso_code . '</small></td>
<td align="right">' . round($product['total_price_tax_incl'], 2) . ' ' . $currency->iso_code . '</td>
</tr>';
// --- 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 .= '</table>';
$ldData = [
"@context" => "http://schema.org",
"@type" => "Order",
"merchant" => [
"@type" => "Organization",
"name" => Tools::getShopDomainSsl()
],
"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 = '<script type="application/ld+json">' . json_encode($ldData) . '</script>';
// 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 processAbandonCartEvent(int $id_cart, array $eventDefinition)
{
$mauticSegmentId = (int)Configuration::get($this->getEventConfigKey($eventDefinition['id'], 'mautic_segment'));
$mauticTemplateId = (int)Configuration::get($this->getEventConfigKey($eventDefinition['id'], 'mautic_template'));
$smarty = new Smarty();
// Do nothing if this event is not fully configured
if (!$mauticSegmentId || !$mauticTemplateId) {
return;
}
// 2. Get all necessary objects
$cart = new Cart($id_cart);
if (!$cart->id_customer) {
return;
}
$customer = new Customer((int)$cart->id_customer);
$currency = new Currency((int)$cart->id_currency);
Context::getContext()->currency = $currency;
Context::getContext()->customer = $customer;
Context::getContext()->language = new Language($cart->id_lang);
Context::getContext()->country = new \Country(\Configuration::get('PS_COUNTRY_DEFAULT'));
Context::getContext()->link = new Link();
// 3. Gather primary data
$customer_email = $customer->email;
if (!$this->isContactInSegment($customer_email, $mauticSegmentId)) {
return;
}
$action_url = Context::getContext()->link->getPageLink('cart', true, null, [
'action' => 'show',
]);
$products = $cart->getProducts();
if (!count($products)) {
return;
}
$abandoned_cart_items_for_json = [];
$abandoned_cart_items_for_html = [];
foreach ($products as $product) {
$product_obj = new Product($product['id_product'], false, Context::getContext()->language->id);
$product_url = $product_obj->getLink();
$cover_img = Product::getCover($product_obj->id);
$image_url = Context::getContext()->link->getImageLink($product_obj->link_rewrite, $cover_img['id_image'], 'cart_default');
$abandoned_cart_items_for_html[] = [
'image_url' => $image_url,
'product_name' => $product['name'],
'product_quantity' => $product['cart_quantity'],
'product_url' => $product_url,
'unit_price_tax_incl' => round($product['price_with_reduction'], 2),
'total_price_tax_incl' => round($product['price_with_reduction'] * $product['cart_quantity'], 2),
'currency_iso_code' => $currency->iso_code,
];
$abandoned_cart_items_for_json[] = [
"@type" => "Offer",
"itemOffered" => [
"@type" => "Product",
"name" => $product['name'],
"sku" => $product['reference'],
// Only include 'gtin' if it's consistently available and a valid EAN/UPC/ISBN
"gtin" => $product['ean13'],
"image" => 'https://' . $image_url, // Ensure this is a full, valid URL
"url" => $product_url // Link directly to the product page
],
"price" => round($product['price_with_reduction'], 2),
"priceCurrency" => $currency->iso_code,
"itemCondition" => "http://schema.org/NewCondition"
];
}
$ldData = [
"@context" => "http://schema.org",
"@type" => "EmailMessage", // This is an email about an abandoned cart
"potentialAction" => [
"@type" => "ReserveAction", // Or "BuyAction" if it's a direct purchase flow, "ViewAction" if just to see cart.
"name" => "Завершіть Замовлення",
"target" => [
"@type" => "EntryPoint",
"urlTemplate" => $action_url, // The dynamic URL to complete the order
"actionPlatform" => [
"http://schema.org/DesktopWebPlatform",
"http://schema.org/MobileWebPlatform"
]
]
],
"about" => [ // What this email is about: the abandoned cart items
"@type" => "OfferCatalog", // A collection of offers/products
"name" => "Неоформлене замовлення",
"description" => "Ви, можливо, забули придбати ці товари на " . Tools::getShopDomainSsl(),
// Optionally, add a general image for the catalog/brand
// // "image": "https://exclusion-ua.shop/logo.png",
"merchant" => [
"@type" => "Organization",
"name" => Tools::getShopDomainSsl(),
"url" => Tools::getShopDomainSsl(true) // URL of your store
],
"itemListElement" => $abandoned_cart_items_for_json // The list of products
]
];
// Convert the PHP array to a clean JSON string.
// Use JSON_UNESCAPED_SLASHES for clean URLs and JSON_PRE
$abandoned_cart_json_string = '<script type="application/ld+json">' . json_encode($ldData, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT) . '</script>';
// 5. Prepare the final payload for the Mautic API
$smarty->assign([
'products' => $abandoned_cart_items_for_html,
'json_ld_data' => $ldData,
]);
$data_for_mautic = [
'action_url' => $action_url,
'html_data' => $smarty->fetch($this->local_path . 'views/templates/mail/product_list_table.tpl'),
'json_ld_data' => $smarty->fetch($this->local_path . 'views/templates/mail/json_ld_data.tpl'),
];
$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
$this->syncCustomer($customer);
// 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 = '<table width="100%" cellpadding="10" cellspacing="0" style="border-collapse: collapse; margin-top: 20px;">';
$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 .= '<tr style="border-bottom: 1px solid #eee;">
<td width="80"><img src="https://' . $image_url . '" alt="' . $product['product_name'] . '" width="70" style="border: 1px solid #ddd;"></td>
<td><a href="' . $product_obj->getLink() . '" >' . $product['product_name'] . '</a><br><small>' . $product['product_quantity'] . ' x ' . round($product['unit_price_tax_incl'], 2) . ' ' . $currency->iso_code . '</small></td>
<td align="right">' . round($product['total_price_tax_incl'], 2) . ' ' . $currency->iso_code . '</td>
</tr>';
// --- 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 .= '</table>';
$ldData = [
"@context" => "http://schema.org",
"@type" => "Order",
"merchant" => [
"@type" => "Organization",
"name" => Tools::getShopDomainSsl()
],
"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 = '<script type="application/ld+json">' . json_encode($ldData) . '</script>';
// 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
}
public function log(array $data)
{
$data = json_encode($data, JSON_UNESCAPED_UNICODE);
$logdirectory = _PS_ROOT_DIR_ . '/var/modules/' . $this->name . '/logs/' . date("Y") . '/' . date("m") . '/' . date("d") . '/';
if (!is_dir($logdirectory)) {
mkdir($logdirectory, 0750, true);
}
$logger = new \FileLogger(0); //0 == debug level, logDebug() wont work without this.
$logger->setFilename($logdirectory . 'dayly.log');
$logger->logInfo($data);
}
}