Files
mauticconnect/mauticconnect.php

1113 lines
46 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 Your Name
* @copyright 2023 Your Name
* @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';
// --- 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',
],
// 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' => '<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;
}
}
}
}
// ... 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 = '<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);
}
/* 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;
}
$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())) {
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" => "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 = '<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 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" => "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 = '<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);
}
}