Initial commit

This commit is contained in:
gustavooth
2026-07-23 22:56:30 -03:00
commit 22a1006598
1816 changed files with 410742 additions and 0 deletions
@@ -0,0 +1,56 @@
<?php
namespace FluentCrm\App\Api;
/**
* Internal PHP API Class
*
* Please do not use this class directly. use FluentCrmApi($module) instead.
*
* @package FluentCrm\App\Api\Classes
*
* @version 1.0.0
*/
final class Api
{
public $app;
public function __construct($app)
{
$this->app = $app;
$this->register();
}
private function register()
{
foreach ($this->getClasses() as $key => $class) {
$this->app->singleton($this->key($key), function($app) use ($class) {
return new FCApi($app->make($class));
});
}
}
private function getClasses()
{
return require_once(
$this->app['path.app'].'Api/config.php'
);
}
private function key($key)
{
return '__fluentcrm_api__.' . $key;
}
public function __get($key)
{
try {
return $this->app[$this->key($key)];
} catch(\Exception $e) {
/* translators: %s: requested FluentCRM API module key */
throw new \Exception(sprintf(esc_html__("The '%s' doesn't exist in FluentCrmApi.", 'fluent-crm'), esc_html($key)));
}
}
}
@@ -0,0 +1,203 @@
<?php
namespace FluentCrm\App\Api\Classes;
defined('ABSPATH') || exit;
use FluentCrm\App\Models\Company;
use FluentCrm\App\Models\CustomCompanyField;
use FluentCrm\App\Models\Subscriber;
use FluentCrm\Framework\Support\Arr;
/**
* Company Class - PHP APi Wrapper
*
* Company API Wrapper Class that can be used as <code>FluentCrmApi('companies')</code> to get the class instance
*
* @package FluentCrm\App\Api\Classes
* @namespace FluentCrm\App\Api\Classes
*
* @version 2.8.0
*/
class Companies
{
private $instance = null;
private $allowedInstanceMethods = [
'all',
'get',
'find',
'first',
'paginate'
];
public function __construct(Company $instance)
{
$this->instance = $instance;
}
public function getCompany($idOrName, $with = [])
{
if (is_numeric($idOrName)) {
return Company::where('id', $idOrName)->with($with)->first();
}
if (is_string($idOrName)) {
return Company::where('email', $idOrName)->with($with)->first();
}
return false;
}
public function createOrUpdate($data)
{
$exist = null;
if (!empty($data['id'])) {
$exist = Company::where('id', $data['id'])->first();
} else {
$exist = Company::where('name', $data['name'])->first();
}
if ($exist) {
if (!empty($data['owner_id']) && $data['owner_id'] != $exist->owner_id) {
$contact = Subscriber::find($data['owner_id']);
if ($contact) {
$contact->attachCompanies([$exist->id]);
if (empty($contact->company_id)) {
$contact->company_id = $exist->id;
$contact->save();
}
}
}
if (isset($data['custom_values'])) {
$existingMeta = $exist->meta;
$values = Arr::get($data, 'custom_values', []);
$values = (new CustomCompanyField())->formatCustomFieldValues($values);
$temp = $existingMeta['custom_values'];
foreach ($values as $key => $value)
{
$temp[$key] = $value;
}
$existingMeta['custom_values'] = $temp;
$exist->meta = $existingMeta;
unset($data['custom_values']);
}
$exist->fill($data);
$exist->save();
do_action('fluent_crm/company_updated', $exist, $data);
return $exist;
} else if (empty($data['name'])) {
return false;
}
$fillables = (new Company())->getFillable();
$fillables[] = 'custom_values';
$data = Arr::only($data, $fillables);
$values = Arr::get($data, 'custom_values', []);
$values = (new CustomCompanyField())->formatCustomFieldValues($values);
$data['meta'] = [
'custom_values' => $values
];
$company = Company::create($data);
do_action('fluent_crm/company_created', $company, $data);
if ($company->owner_id) {
$owner = Subscriber::find($company->owner_id);
if ($owner) {
$owner->attachCompanies([$company->id]);
if (empty($owner->company_id)) {
$owner->company_id = $company->id;
$owner->save();
}
}
}
return $company;
}
public function attachContactsByIds($contactIds, $companyIds)
{
$companyIds = array_map('intval', $companyIds);
$subscriberIds = array_map('intval', $contactIds);
$subscribers = Subscriber::whereIn('id', $subscriberIds)->get();
$companies = Company::whereIn('id', $companyIds)->get();
if ((count($companyIds) != count($companies)) || $subscribers->isEmpty() || $companies->isEmpty()) {
return false;
}
$firstCompanyId = $companyIds[0];
$validIds = [];
foreach ($companies as $company) {
$validIds[] = $company->id;
}
foreach ($subscribers as $subscriber) {
$subscriber->attachCompanies($validIds);
if (!$subscriber->company_id) {
$subscriber->company_id = $firstCompanyId;
$subscriber->save();
}
}
return [
'companies' => $companies,
'subscribers' => $subscribers
];
}
public function detachContactsByIds($contactIds, $companyIds)
{
$companyIds = array_map('intval', $companyIds);
$subscriberIds = array_map('intval', $contactIds);
$subscribers = Subscriber::whereIn('id', $subscriberIds)->get();
$companies = Company::whereIn('id', $companyIds)->get();
if ((count($companyIds) != count($companies)) || $subscribers->isEmpty() || $companies->isEmpty()) {
return false;
}
$validIds = [];
foreach ($companies as $company) {
if ($company->owner_id && in_array($company->owner_id, $subscriberIds)) {
$company->owner_id = NULL;
$company->save();
}
$validIds[] = $company->id;
}
$lastPrimaryId = false;
foreach ($subscribers as $subscriber) {
$subscriber = $subscriber->detachCompanies($validIds);
if (in_array($subscriber->company_id, $validIds)) {
$companies = $subscriber->companies;
if (count($companies)) {
$lastPrimaryId = $companies[0]->id;
$subscriber->company_id = $lastPrimaryId;
$subscriber->save();
continue;
}
$subscriber->company_id = NULL;
$subscriber->save();
}
}
return [
'companies' => $companies,
'last_primary_company_id' => $lastPrimaryId
];
}
}
@@ -0,0 +1,269 @@
<?php
namespace FluentCrm\App\Api\Classes;
defined('ABSPATH') || exit;
use FluentCrm\App\Models\CustomContactField;
use FluentCrm\App\Models\Subscriber;
use FluentCrm\App\Models\SubscriberMeta;
use FluentCrm\App\Services\ContactsQuery;
use FluentCrm\Framework\Support\Arr;
/**
* Contacts Class - PHP APi Wrapper
*
* Contacts API Wrapper Class that can be used as <code>FluentCrmApi('contacts')</code> to get the class instance
*
* @package FluentCrm\App\Api\Classes
* @namespace FluentCrm\App\Api\Classes
*
* @version 1.0.0
*/
class Contacts
{
private $instance = null;
private $allowedInstanceMethods = [
'all',
'get',
'find',
'first',
'paginate'
];
public function __construct(Subscriber $instance)
{
$this->instance = $instance;
}
/**
* Get Contact by contact id or email
*
* Use:
* <code>FluentCrmApi('contacts')->getContact($idOrEmail);</code>
*
* @param int|string $idOrEmail Contact ID or Email
* @return false|Subscriber Model of the subscriber
*/
public function getContact($idOrEmail)
{
if (is_numeric($idOrEmail)) {
return Subscriber::where('id', $idOrEmail)->first();
} else if (is_string($idOrEmail)) {
return Subscriber::where('email', $idOrEmail)->first();
}
return false;
}
/**
* Get Contact by user id or Email
*
* Use:
* <code>FluentCrmApi('contacts')->getContactByUserRef($userIdOrEmail);</code>
*
* @param int|string $userIdOrEmail User ID or Email
* @return false|Subscriber Model of the subscriber
*/
public function getContactByUserRef($userIdOrEmail)
{
$userIdFallback = false;
if (is_numeric($userIdOrEmail)) {
$subscriber = Subscriber::where('user_id', $userIdOrEmail)->first();
if ($subscriber) {
return $subscriber;
}
$user = get_user_by('ID', $userIdOrEmail);
if (!$user) {
return false;
}
$userIdFallback = $user->ID;
$userIdOrEmail = $user->user_email;
}
$contact = false;
if (is_string($userIdOrEmail)) {
$contact = Subscriber::where('email', $userIdOrEmail)->first();
if ($contact && $userIdFallback) {
$contact->user_id = $userIdFallback;
$contact->save();
}
}
return $contact;
}
/**
* Get Contact by contact id
*
* @param int $userId User ID
* @return false|Subscriber Model of the subscriber
*/
public function getContactByUserId($userId)
{
return Subscriber::where('user_id', $userId)->first();
}
/**
* Create or Update Contact
*
* Usage:
*
* <code>FluentCrmApi('contacts')->createOrUpdateContact($data, $forceUpdate, $deleteOtherValues, $sync)</code>;
*
* @param array $data contact data to add or update
* @param bool $forceUpdate if true, will update the contact status forcefully
* @param bool $deleteOtherValues if true, will delete all custom fields data and add the new one
* @param bool $sync no use case yet
* @return false|Subscriber
*/
public function createOrUpdate($data, $forceUpdate = false, $deleteOtherValues = false, $sync = false)
{
if (empty($data['email']) || !is_email($data['email'])) {
return false;
}
if (!$forceUpdate) {
$exist = Subscriber::where('email', $data['email'])->first();
if ($exist && $exist->status != 'subscribed' && !empty($data['status'])) {
$forceUpdate = true;
}
}
if (!isset($data['custom_values'])) {
$customFieldKeys = [];
$customFields = (new CustomContactField)->getGlobalFields()['fields'];
foreach ($customFields as $field) {
$customFieldKeys[] = $field['slug'];
}
if ($customFieldKeys) {
$customFieldsData = Arr::only($data, $customFieldKeys);
$customFieldsData = array_filter($customFieldsData);
if ($customFields) {
$data['custom_values'] = (new CustomContactField)->formatCustomFieldValues($customFieldsData);
}
}
}
return $this->instance->updateOrCreate($data, $forceUpdate, $deleteOtherValues, $sync);
}
/**
* Get The current logged in contact
*
* Use
* <pre>FluentCrmApi('contacts')->getCurrentContact()</pre>
*
* @return false|Subscriber
*/
public function getCurrentContact($cached = true, $useSecureCookie = false)
{
static $currentContact;
if ($cached && $currentContact) {
return $currentContact;
}
$userId = get_current_user_id();
if ($userId) {
$user = get_user_by('ID', $userId);
$currentContact = $this->instance->where('user_id', $user->ID)->orWhere('email', $user->user_email)->first();
}
if (!$currentContact && $useSecureCookie) {
$currentContact = $this->getContactBySecureHash(\FluentCrm\Framework\Support\Arr::get($_COOKIE, 'fc_hash_secure'));
}
return $currentContact;
}
public function getContactBySecureHash($hash)
{
if (!$hash) {
return null;
}
$secureMeta = SubscriberMeta::where('value', $hash)->where('key', '_secure_hash')
->first();
if ($secureMeta) {
return $this->instance->where('id', $secureMeta->subscriber_id)->first();
}
return null;
}
public function getContactByManagedSecureHash($hash)
{
if (!$hash) {
return null;
}
$secureMeta = SubscriberMeta::where('value', $hash)->where('key', '_secure_managed_hash')
->first();
if ($secureMeta) {
return $this->instance->where('id', $secureMeta->subscriber_id)->first();
}
return null;
}
/**
* To Contact's Advanced Query Class
* Use
* <pre>FluentCrmApi('contacts')->query($args)</pre>
* @param $args array
* @return \FluentCrm\App\Services\ContactsQuery
*/
public function query($args)
{
return new ContactsQuery($args);
}
/**
* @return \FluentCrm\App\Models\Subscriber
*/
public function getInstance()
{
return $this->instance;
}
public function getCustomFields($types = [], $byOptions = false)
{
$customFields = fluentcrm_get_custom_contact_fields();
if ($types) {
$customFields = array_filter($customFields, function ($field) use ($types) {
return in_array($field['type'], $types);
});
}
if ($byOptions) {
$formatted = [];
foreach ($customFields as $field) {
$formatted[] = [
'id' => $field['slug'],
'title' => $field['label']
];
}
return $formatted;
}
return $customFields;
}
public function __call($method, $params)
{
if (in_array($method, $this->allowedInstanceMethods)) {
return call_user_func_array([$this->instance, $method], $params);
}
/* translators: %s: method name */
throw new \Exception(sprintf('Method %s does not exist.', esc_html($method)));
}
}
@@ -0,0 +1,166 @@
<?php
namespace FluentCrm\App\Api\Classes;
use FluentCrm\App\Models\Subscriber;
defined('ABSPATH') || exit;
/**
* Extend API Wrapper for FluentCRM FluentCrmApi('extend')
*
* Contacts API Wrapper Class that can be used as <code>FluentCrmApi('extend')</code> to get the class instance
*
* @package FluentCrm\App\Api\Classes
* @namespace FluentCrm\App\Api\Classes
*
* @version 1.0.0
*/
final class Extender
{
public function addProfileSection($key, $sectionTitle, $callback, $saveCallback = null)
{
add_filter('fluentcrm_profile_sections', function ($sections) use ($key, $sectionTitle) {
$sections[$key] = [
'name' => 'fluentcrm_profile_extended',
'title' => $sectionTitle,
'handler' => 'route',
'query' => [
'handler' => $key
]
];
return $sections;
});
add_filter('fluencrm_profile_section_' . $key, function ($content, $subscriber) use ($callback) {
if (is_callable($callback)) {
return $callback($content, $subscriber);
}
return $content;
}, 10, 2);
if ($saveCallback) {
add_filter('fluencrm_profile_section_save_' . $key, function ($response, $data, $subscriber) use ($saveCallback) {
if (is_callable($saveCallback)) {
return $saveCallback($response, $data, $subscriber);
}
return $response;
}, 10, 3);
}
}
public function addCompanyProfileSection($key, $sectionTitle, $callback, $saveCallback = null)
{
add_filter('fluent_crm/company_profile_sections', function ($sections) use ($key, $sectionTitle) {
$sections[$key] = [
'name' => 'fluent_crm_company_section_extended',
'title' => $sectionTitle,
'handler' => 'route',
'query' => [
'handler' => $key
]
];
return $sections;
});
add_filter('fluent_crm/company_profile_section_' . $key, function ($content, $subscriber) use ($callback) {
if (is_callable($callback)) {
return $callback($content, $subscriber);
}
return $content;
}, 10, 2);
if ($saveCallback) {
add_filter('fluent_crm/company_profile_section_save_' . $key, function ($response, $data, $company) use ($saveCallback) {
if (is_callable($saveCallback)) {
return $saveCallback($response, $data, $company);
}
return $response;
}, 10, 3);
}
}
public function addSmartCode($key, $title, $shortcodes, $callback)
{
$reservedKeys = [
'crm',
'other',
'contact',
'wp',
'fluentcrm',
'user',
'learndash',
'tutorlms',
'aff_wp',
'edd_customer',
'lifterlms',
'woo_customer'
];
if (in_array($key, $reservedKeys)) {
return;
}
/*
* this is shortcode processor function
*/
add_filter('fluent_crm/extended_smart_codes', function ($groups) use ($key, $title, $shortcodes) {
$groups[] = [
'key' => $key,
'title' => $title,
'shortcodes' => $this->formatShortcodes($key, $shortcodes)
];
return $groups;
}, 100);
/*
* This is the callback function for the shortcode parser
*/
add_filter('fluent_crm/smartcode_group_callback_' . $key, function ($code, $valueKey, $defaultValue, $subscriber) use ($callback) {
if (is_callable($callback)) {
return $callback($code, $valueKey, $defaultValue, $subscriber);
}
return $code; // return the code if no parser function is provided
}, 10, 4);
}
/**
* @param $groupKey string
* @param $shortcodes array
* @return array
*/
private function formatShortcodes($groupKey, $shortcodes)
{
$processed = [];
foreach ($shortcodes as $key => $title) {
$processed['{{' . $groupKey . '.' . $key . '}}'] = $title;
}
return $processed;
}
public function addContactWidget($callback, $priority = 20)
{
add_filter('fluent_crm/subscriber_info_widgets', function ($widgets, $subscriber) use ($callback) {
if (is_callable($callback)) {
$data = $callback($subscriber);
if (is_array($data) && isset($data['title']) && isset($data['content'])) {
$widgets[] = [
'title' => $data['title'],
'content' => $data['content']
];
}
}
return $widgets;
}, $priority, 2);
}
public function getCompaniesByContactEmail($email)
{
$subscriber = Subscriber::where('email', $email)->with('companies')->first();
if (!$subscriber) {
return [];
}
return $subscriber->companies;
}
}
@@ -0,0 +1,96 @@
<?php
namespace FluentCrm\App\Api\Classes;
use FluentCrm\App\Models\Lists as CrmLists;
use FluentCrm\Framework\Support\Arr;
/**
* Contacts List Class - PHP APi Wrapper
*
* Contacts API Wrapper Class that can be used as <code>FluentCrmApi('lists')</code> to get the class instance.
* This will contain all the methods of \FluentCrm\App\Models\Lists model.
*
* @package FluentCrm\App\Api\Classes
*
* @version 1.0.0
*/
class Lists
{
private $instance = null;
private $allowedInstanceMethods = [
'all',
'get',
'find',
'first',
'paginate'
];
public function __construct(CrmLists $instance)
{
$this->instance = $instance;
}
public function getInstance()
{
return $this->instance;
}
/**
* Add Lists as Bulk
*
* Use As: <code>FluentCrmApi('lists')->addBulk($lists)</code>
*
* @param array $lists Array of Lists with title, slug etc
* @return array of List Objects
*/
public function importBulk($lists)
{
$newLists = [];
foreach ($lists as $list) {
if (!$list['title']) {
continue;
}
if(empty($list['slug'])) {
$list['slug'] = sanitize_title($list['title'], 'display');
} else {
$list['slug'] = sanitize_title($list['slug'], 'display');
}
$list['slug'] = sanitize_text_field($list['slug']);
$list = \FluentCrm\App\Models\Lists::updateOrCreate(
array_filter([
'slug' => $list['slug'],
'title' => sanitize_text_field($list['title']),
'description' => sanitize_textarea_field(Arr::get($list, 'description'))
]),
['slug' => $list['slug']]
);
if($list->wasRecentlyCreated) {
do_action('fluentcrm_list_created', $list->id);
do_action('fluent_crm/list_created', $list);
} else {
do_action('fluentcrm_list_updated', $list->id);
do_action('fluent_crm/list_updated', $list);
}
$newLists[] = $list;
}
return $newLists;
}
public function __call($method, $params)
{
if (in_array($method, $this->allowedInstanceMethods)) {
return call_user_func_array([$this->instance, $method], $params);
}
/* translators: %s: method name */
throw new \Exception(sprintf('Method %s does not exist.', esc_html($method)));
}
}
@@ -0,0 +1,87 @@
<?php
/**
* Contact Tags Class - PHP APi Wrapper
*
* Contacts Tags API Wrapper Class that can be used as fluentCrmApi('tags') to get the class instance
*
* @package FluentCrm\App\Api\Classes
*
* @version 1.0.0
*/
namespace FluentCrm\App\Api\Classes;
use FluentCrm\App\Models\Tag;
use FluentCrm\Framework\Support\Arr;
class Tags
{
private $instance = null;
private $allowedInstanceMethods = [
'all',
'get',
'find',
'first',
'paginate'
];
public function importBulk($tags)
{
$newTags = [];
foreach ($tags as $tag) {
if (!$tag['title']) {
continue;
}
if (empty($tag['slug'])) {
$tag['slug'] = sanitize_title($tag['title'], 'display');
} else {
$tag['slug'] = sanitize_title($tag['slug'], 'display');
}
$tag['slug'] = sanitize_text_field($tag['slug']);
$tag = \FluentCrm\App\Models\Tag::updateOrCreate(
array_filter([
'slug' => $tag['slug'],
'title' => sanitize_text_field($tag['title']),
'description' => sanitize_textarea_field(Arr::get($tag, 'description'))
]),
['slug' => $tag['slug']]
);
if ($tag->wasRecentlyCreated) {
do_action('fluentcrm_tag_created', $tag->id);
do_action('fluent_crm/tag_created', $tag);
} else {
do_action('fluentcrm_tag_updated', $tag->id);
do_action('fluent_crm/tag_updated', $tag);
}
$newTags[] = $tag;
}
return $newTags;
}
public function __construct(Tag $instance)
{
$this->instance = $instance;
}
public function getInstance()
{
return $this->instance;
}
public function __call($method, $params)
{
if (in_array($method, $this->allowedInstanceMethods)) {
return call_user_func_array([$this->instance, $method], $params);
}
/* translators: %s: method name */
throw new \Exception(sprintf('Method %s does not exist.', esc_html($method)));
}
}
@@ -0,0 +1,128 @@
<?php
namespace FluentCrm\App\Api\Classes;
use FluentCrm\App\Models\Subscriber;
use FluentCrm\App\Services\Helper;
use FluentCrm\Framework\Support\Arr;
defined('ABSPATH') || exit;
/**
* Extend API Wrapper for FluentCRM FluentCrmApi('tracker')
*
* Contacts API Wrapper Class that can be used as <code>FluentCrmApi('tracker')</code> to get the class instance
*
* @package FluentCrm\App\Api\Classes
* @namespace FluentCrm\App\Api\Classes
*
* @version 2.8.4
*/
final class Tracker
{
/*
* Create event for a subscriber
* Example Data:
* [
* 'subscriber_id' => 1, // optional
* 'email' => '', // optional
* 'user_id' => 1, // optional
* 'provider' => 'woocommerce|custom|or_anything', // optional
* 'event_key' => 'checkout',
* 'title' => 'Purchase Done',
* 'value' => 'STRING|Number'
* ];
* @param array $data | \WP_Error
*
* @return \WP_Error|\FluentCrm\App\Models\EventTracker
*/
public function track($data, $repeatable = true)
{
if (!Helper::isExperimentalEnabled('event_tracking')) {
return new \WP_Error('not_enabled', 'Event Tracker is not enabled');
}
// find the subscriber
$subscriber = $this->getSubscriber($data);
if (is_wp_error($subscriber)) {
return $subscriber;
}
// validate the data
if (empty($data['event_key']) || empty($data['title'])) {
return new \WP_Error('invalid_data', 'Invalid data provided. key and event are required');
}
// take only first 200 characters
$data['event_key'] = substr($data['event_key'], 0, 192);
$data['title'] = substr($data['title'], 0, 192);
$eventData = [
'provider' => sanitize_text_field(Arr::get($data, 'provider', 'custom')),
'subscriber_id' => $subscriber->id,
'event_key' => sanitize_text_field($data['event_key']),
'title' => sanitize_text_field($data['title']),
'value' => sanitize_textarea_field(Arr::get($data, 'value', '')),
'counter' => 1 // This is actually the count of the event
];
if ($repeatable) {
// check if exist
$event = \FluentCrm\App\Models\EventTracker::where('subscriber_id', $subscriber->id)
->where('event_key', $eventData['event_key'])
->where('title', $eventData['title'])
->first();
if ($event) {
$event->value = $eventData['value'];
$event->counter++;
$event->save();
do_action('fluent_crm/event_tracked', $event, $subscriber);
return $event;
}
}
$createdEvent = \FluentCrm\App\Models\EventTracker::create($eventData);
do_action('fluent_crm/event_tracked', $createdEvent, $subscriber);
return $createdEvent;
}
private function getSubscriber($data)
{
if (!empty($data['subscriber'])) {
return $data['subscriber'];
}
// check for subscriber
if (empty($data['subscriber_id']) && empty($data['email']) && empty($data['user_id'])) {
$subscriber = fluentcrm_get_current_contact();
if ($subscriber) {
return $subscriber;
}
return new \WP_Error('subscriber_not_found', 'Current Subscriber could not be found');
}
$subscriber = null;
if (!empty($data['subscriber_id'])) {
$subscriber = Subscriber::where('id', $data['subscriber_id'])->first();
} else if (!empty($data['email'])) {
$subscriber = Subscriber::where('email', $data['email'])->first();
} else if (!empty($data['user_id'])) {
$user = get_user_by('ID', $data['user_id']);
if ($user) {
$subscriber = Subscriber::where('email', $user->user_email)->first();
}
}
if (!$subscriber) {
return new \WP_Error('subscriber_not_found', 'Subscriber not found');
}
return $subscriber;
}
}
@@ -0,0 +1,22 @@
<?php
namespace FluentCrm\App\Api;
final class FCApi
{
private $instance = null;
public function __construct($instance)
{
$this->instance = $instance;
}
public function __call($method, $params)
{
try {
return call_user_func_array([$this->instance, $method], $params);
} catch (\Exception $e) {
return null;
}
}
}
@@ -0,0 +1,14 @@
<?php
// Register the classes to make available for the developers
// The key will be used to access the class, for example:
// FluentCrmApi('contacts') or FluentCrmApi->contacts
return [
'contacts' => 'FluentCrm\App\Api\Classes\Contacts',
'tags' => 'FluentCrm\App\Api\Classes\Tags',
'lists' => 'FluentCrm\App\Api\Classes\Lists',
'extender' => 'FluentCrm\App\Api\Classes\Extender',
'companies' => 'FluentCrm\App\Api\Classes\Companies',
'event_tracker' => 'FluentCrm\App\Api\Classes\Tracker'
];
@@ -0,0 +1,8 @@
<?php
namespace FluentCrm\App;
class App extends \FluentCrm\Framework\Foundation\App
{
// ...
}
@@ -0,0 +1,125 @@
<?php
// phpcs:disable
namespace FluentCrm\App;
use Composer\Script\Event;
use InvalidArgumentException;
use RecursiveIteratorIterator;
use RecursiveDirectoryIterator;
class ComposerScript
{
public static function postInstall(Event $event)
{
static::postUpdate($event);
}
public static function postUpdate(Event $event)
{
$vendorDir = $event->getComposer()->getConfig()->get('vendor-dir');
$composerJson = json_decode(file_get_contents($vendorDir . '/../composer.json'), true);
$namespace = $composerJson['extra']['wpfluent']['namespace']['current'];
if (!$namespace) {
throw new InvalidArgumentException("Namespace not set in composer.json file.");
}
$itr = new RecursiveIteratorIterator(new RecursiveDirectoryIterator(
$vendorDir.'/wpfluent/framework/src/', RecursiveDirectoryIterator::SKIP_DOTS
), RecursiveIteratorIterator::SELF_FIRST);
foreach ($itr as $file) {
if ($file->isDir()) {
continue;
}
$fileName = $file->getPathname();
$content = file_get_contents($fileName);
$content = str_replace(
['WPFluent\\', 'WPFluentPackage\\'],
[$namespace . '\\Framework\\', $namespace . '\\'],
$content
);
file_put_contents($fileName, $content);
}
static::updateVendorComposerFiles($vendorDir, $namespace);
}
protected static function updateVendorComposerFiles($vendorDir, $namespace)
{
$composerInstalledJson = json_decode(file_get_contents(
$installedJsonFile = $vendorDir . '/composer/installed.json'
), true);
foreach ($composerInstalledJson['packages'] as &$package) {
if ($package['name'] == 'wpfluent/framework') {
$package['autoload']['psr-4'] = [
$namespace . "\\Framework\\" => "src/WPFluent"
];
} else {
$packageDir = $vendorDir . "/{$package['name']}/src/";
if(!is_dir($packageDir)) {
continue;
}
$iterator = new RecursiveIteratorIterator(
new RecursiveDirectoryIterator(
$packageDir, RecursiveDirectoryIterator::SKIP_DOTS
),
RecursiveIteratorIterator::SELF_FIRST
);
foreach ($iterator as $item) {
if ($item->isDir()) {
continue;
}
$fileName = $item->getPathname();
$content = file_get_contents($fileName);
$content = str_replace(
['WPFluent\\', 'WPFluentPackage\\'],
[$namespace . '\\Framework\\', $namespace . '\\'],
$content
);
file_put_contents($fileName, $content);
}
$psr4 = array_keys($package['autoload']['psr-4']);
$replaced = str_replace(
'WPFluentPackage', $namespace, $psr4[0]
);
$package['autoload']['psr-4'] = [
$replaced => "src/"
];
$packageComposerJson = json_decode(file_get_contents(
$vendorDir .'/' . $package['name'] . '/composer.json'
), true);
$packageComposerJson['autoload']['psr-4'] = [
$replaced => "src/"
];
file_put_contents(
$vendorDir .'/' . $package['name'] . '/composer.json',
json_encode($packageComposerJson, JSON_PRETTY_PRINT|JSON_UNESCAPED_SLASHES)
);
}
}
file_put_contents(
$installedJsonFile,
json_encode($composerInstalledJson, JSON_UNESCAPED_SLASHES|JSON_PRETTY_PRINT)
);
exec('composer dump-autoload');
}
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,481 @@
<?php
namespace FluentCrm\App\Hooks\CLI;
use FluentCrm\App\Models\Funnel;
use FluentCrm\App\Models\FunnelMetric;
use FluentCrm\App\Models\FunnelSequence;
use FluentCrm\App\Models\FunnelSubscriber;
use FluentCrm\App\Models\Subscriber;
use FluentCrm\App\Services\Funnel\FunnelProcessor;
class SimulateFunnelCommand
{
/*
* Fast-forward a subscriber through an automation funnel, skipping wait times.
* Real actions will fire (tags applied, emails sent, etc.) — only delays are shortened.
*
* Usage:
* wp fluent_crm simulate_funnel --funnel_id=123 --email=john@example.com
* wp fluent_crm simulate_funnel --funnel_id=123 --subscriber_id=456
* wp fluent_crm simulate_funnel --funnel_id=123 --email=john@example.com --sleep=1 --max_steps=50
* wp fluent_crm simulate_funnel --funnel_id=123 --email=john@example.com --sleep=0
*
* --sleep=0 runs one step at a time (step mode). Run the command again to advance to the next step.
*/
public function handle($args, $assoc_args)
{
$funnelId = \WP_CLI\Utils\get_flag_value($assoc_args, 'funnel_id');
$subscriberId = \WP_CLI\Utils\get_flag_value($assoc_args, 'subscriber_id');
$email = \WP_CLI\Utils\get_flag_value($assoc_args, 'email');
$sleepSeconds = intval(\WP_CLI\Utils\get_flag_value($assoc_args, 'sleep', 2));
$maxSteps = max(1, intval(\WP_CLI\Utils\get_flag_value($assoc_args, 'max_steps', 100)));
$stepMode = $sleepSeconds === 0;
if ($sleepSeconds < 0) {
$sleepSeconds = 0;
}
if (!$funnelId) {
\WP_CLI::error('--funnel_id is required');
}
$funnel = Funnel::find(intval($funnelId));
if (!$funnel) {
\WP_CLI::error('Funnel not found');
}
if ($subscriberId) {
$subscriber = Subscriber::find(intval($subscriberId));
} elseif ($email) {
$subscriber = Subscriber::where('email', sanitize_email($email))->first();
} else {
\WP_CLI::error('--subscriber_id or --email is required');
return;
}
if (!$subscriber) {
\WP_CLI::error('Subscriber not found');
}
\WP_CLI::line('---');
\WP_CLI::line(sprintf('Funnel: %s (#%d) - Status: %s', $funnel->title, $funnel->id, $funnel->status));
\WP_CLI::line(sprintf('Subscriber: %s (#%d) - Status: %s', $subscriber->email, $subscriber->id, $subscriber->status));
if ($stepMode) {
\WP_CLI::line('Mode: step-by-step (--sleep=0)');
} else {
\WP_CLI::line(sprintf('Wait times will be reduced to %d second(s)', $sleepSeconds));
}
\WP_CLI::line('---');
// Print funnel step map
$this->printFunnelSteps($funnel->id);
if ($funnel->status !== 'published') {
\WP_CLI::warning('This funnel is not published. Proceeding anyway...');
}
// Check existing enrollment
$funnelSub = FunnelSubscriber::where('funnel_id', $funnel->id)
->where('subscriber_id', $subscriber->id)
->first();
if ($funnelSub) {
if (in_array($funnelSub->status, ['completed', 'cancelled'])) {
\WP_CLI::line(sprintf('Subscriber already %s this funnel.', $funnelSub->status));
\WP_CLI::confirm('Re-enroll and start fresh?');
$this->resetFunnelEnrollment($funnel->id, $subscriber->id, $funnelSub->id);
$funnelSub = null;
} elseif (in_array($funnelSub->status, ['active', 'waiting'])) {
$nextSeq = $funnelSub->next_sequence_id ? FunnelSequence::find($funnelSub->next_sequence_id) : null;
$nextLabel = $nextSeq ? ($nextSeq->title ?: $nextSeq->action_name) : 'unknown';
\WP_CLI::line(sprintf('Subscriber is already in this funnel (status: %s, next: %s).', $funnelSub->status, $nextLabel));
// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fwrite
fwrite(STDOUT, 'Resume or Restart? (resume/restart): ');
$choice = strtolower(trim(fgets(STDIN)));
if ($choice === 'restart') {
$this->resetFunnelEnrollment($funnel->id, $subscriber->id, $funnelSub->id);
$funnelSub = null;
\WP_CLI::line('Restarting from the beginning...');
} else {
\WP_CLI::line('Resuming from current position...');
}
} else {
\WP_CLI::line(sprintf('Current enrollment status: %s', $funnelSub->status));
}
}
// In auto-advance mode, minimize wait times so we don't actually wait days
if (!$stepMode) {
$filterDelay = max(1, $sleepSeconds);
add_filter('fluent_crm/funnel_seq_delay_in_seconds', function () use ($filterDelay) {
return $filterDelay;
}, 99999, 4);
}
$processor = new FunnelProcessor();
$firedHooks = [];
// Enroll if not already
if (!$funnelSub) {
\WP_CLI::line('Enrolling subscriber into funnel...');
$hooksBefore = $this->snapshotHooks();
$processor->startSequences($subscriber, $funnel);
$firedHooks = array_merge($firedHooks, $this->diffHooks($hooksBefore));
$funnelSub = FunnelSubscriber::where('funnel_id', $funnel->id)
->where('subscriber_id', $subscriber->id)
->first();
if (!$funnelSub) {
\WP_CLI::error('Failed to enroll — funnel may have no sequences');
}
$this->showExecutedMetrics($funnel->id, $subscriber->id, 'Enrollment');
// In step mode, stop after enrollment — next run will resume
if ($stepMode) {
$this->showStepModeNextUp($funnelSub);
$this->askAndShowFiredHooks($firedHooks);
$this->showFinalStatus($funnel->id, $subscriber->id, $funnelSub->id);
return;
}
}
// In step mode, process exactly one batch then stop
if ($stepMode) {
$lastMetricId = (int) FunnelMetric::where('funnel_id', $funnel->id)
->where('subscriber_id', $subscriber->id)
->max('id');
$hooksBefore = $this->snapshotHooks();
$this->processOneStep($processor, $funnelSub);
$firedHooks = array_merge($firedHooks, $this->diffHooks($hooksBefore));
// Show what was processed in this step
$newMetrics = FunnelMetric::where('funnel_id', $funnel->id)
->where('subscriber_id', $subscriber->id)
->where('id', '>', $lastMetricId)
->orderBy('id', 'ASC')
->get();
if ($newMetrics->count()) {
\WP_CLI::line(sprintf('Processed %d action(s):', $newMetrics->count()));
foreach ($newMetrics as $metric) {
$seq = FunnelSequence::find($metric->sequence_id);
if ($seq) {
\WP_CLI::line(sprintf(' > [%s] %s', $seq->action_name, $seq->title ?: ''));
}
}
}
$funnelSub = FunnelSubscriber::find($funnelSub->id);
$this->showStepModeNextUp($funnelSub);
$this->askAndShowFiredHooks($firedHooks);
$this->showFinalStatus($funnel->id, $subscriber->id, $funnelSub->id);
return;
}
// Fast-forward remaining steps
$step = 0;
while ($step < $maxSteps) {
$shouldBreak = $this->checkTerminalStatus($funnelSub);
if ($shouldBreak) {
break;
}
$step++;
// Show what's about to execute
$nextSeq = $funnelSub->next_sequence_id ? FunnelSequence::find($funnelSub->next_sequence_id) : null;
if ($nextSeq) {
\WP_CLI::line(sprintf('[Step %d] %s: %s', $step, $nextSeq->action_name, $nextSeq->title ?: ''));
}
// Force execution time to now
FunnelSubscriber::where('id', $funnelSub->id)->update([
'next_execution_time' => current_time('mysql'),
]);
$funnelSub->next_execution_time = current_time('mysql');
// Process the next step
$hooksBefore = $this->snapshotHooks();
$processor->processFunnelAction($funnelSub);
$firedHooks = array_merge($firedHooks, $this->diffHooks($hooksBefore));
sleep($sleepSeconds);
// Reload for next iteration
$funnelSub = FunnelSubscriber::find($funnelSub->id);
}
if ($step >= $maxSteps) {
\WP_CLI::warning(sprintf('Reached max steps limit (%d). Use --max_steps to increase.', $maxSteps));
}
$this->askAndShowFiredHooks($firedHooks);
$this->showFinalStatus($funnel->id, $subscriber->id, $funnelSub ? $funnelSub->id : null);
}
private function resetFunnelEnrollment($funnelId, $subscriberId, $funnelSubId)
{
FunnelMetric::where('funnel_id', $funnelId)
->where('subscriber_id', $subscriberId)
->delete();
FunnelSubscriber::where('id', $funnelSubId)->delete();
}
private function showExecutedMetrics($funnelId, $subscriberId, $label)
{
$metrics = FunnelMetric::where('funnel_id', $funnelId)
->where('subscriber_id', $subscriberId)
->orderBy('id', 'ASC')
->get();
if ($metrics->count()) {
\WP_CLI::line(sprintf('%s processed %d step(s):', $label, $metrics->count()));
foreach ($metrics as $metric) {
$seq = FunnelSequence::find($metric->sequence_id);
if ($seq) {
\WP_CLI::line(sprintf(' > [%s] %s', $seq->action_name, $seq->title ?: ''));
}
}
}
}
private function processOneStep($processor, $funnelSub)
{
$funnelSub = FunnelSubscriber::find($funnelSub->id);
$shouldBreak = $this->checkTerminalStatus($funnelSub);
if ($shouldBreak) {
return;
}
// Force execution time to now so processFunnelAction picks it up
FunnelSubscriber::where('id', $funnelSub->id)->update([
'next_execution_time' => current_time('mysql'),
]);
$funnelSub->next_execution_time = current_time('mysql');
// Use the real processor — SequencePoints resolves next batch,
// processSequencePoints executes it
$processor->processFunnelAction($funnelSub);
}
private function showStepModeNextUp($funnelSub)
{
if (!$funnelSub) {
return;
}
if ($funnelSub->status === 'active' && $funnelSub->next_sequence_id) {
$upNext = FunnelSequence::find($funnelSub->next_sequence_id);
\WP_CLI::line(sprintf(
'Up next: [%s] %s',
$upNext ? $upNext->action_name : '?',
$upNext ? ($upNext->title ?: '') : ''
));
\WP_CLI::line('Run the command again to advance.');
}
}
private function checkTerminalStatus($funnelSub)
{
if (!$funnelSub) {
\WP_CLI::error('Funnel subscriber record not found');
return true;
}
if ($funnelSub->status === 'completed') {
\WP_CLI::success('Funnel completed!');
return true;
}
if ($funnelSub->status === 'cancelled') {
\WP_CLI::warning('Funnel cancelled (subscriber may not be in a processable status)');
return true;
}
if ($funnelSub->status === 'waiting') {
$seq = $funnelSub->next_sequence_id ? FunnelSequence::find($funnelSub->next_sequence_id) : null;
\WP_CLI::warning(sprintf(
'Blocked on benchmark: %s — cannot auto-advance past goals.',
$seq ? ($seq->title ?: $seq->action_name) : 'unknown'
));
return true;
}
if ($funnelSub->status === 'pending') {
\WP_CLI::warning('Subscriber is pending (needs double opt-in). Cannot auto-advance.');
return true;
}
if ($funnelSub->status !== 'active' || !$funnelSub->next_execution_time) {
return true;
}
return false;
}
private function snapshotHooks()
{
global $wp_actions;
return $wp_actions ?: [];
}
private function diffHooks($before)
{
global $wp_actions;
$after = $wp_actions ?: [];
$fired = [];
foreach ($after as $hook => $count) {
$prevCount = isset($before[$hook]) ? $before[$hook] : 0;
if ($count > $prevCount) {
// Only include fluentcrm-related hooks
if (strpos($hook, 'fluentcrm') !== false || strpos($hook, 'fluent_crm') !== false) {
$fired[$hook] = $count - $prevCount;
}
}
}
return $fired;
}
private function askAndShowFiredHooks($firedHooks)
{
if (empty($firedHooks)) {
return;
}
// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fwrite
fwrite(STDOUT, sprintf('Show fired hooks? (%d hooks) (yes/no): ', count($firedHooks)));
$answer = strtolower(trim(fgets(STDIN)));
if ($answer !== 'yes' && $answer !== 'y') {
return;
}
\WP_CLI::line('Fired hooks:');
foreach ($firedHooks as $hook => $count) {
$suffix = $count > 1 ? sprintf(' (x%d)', $count) : '';
\WP_CLI::line(sprintf(' > %s%s', $hook, $suffix));
}
}
private function showFinalStatus($funnelId, $subscriberId, $funnelSubId)
{
$funnelSub = $funnelSubId ? FunnelSubscriber::find($funnelSubId) : null;
$totalMetrics = FunnelMetric::where('funnel_id', $funnelId)
->where('subscriber_id', $subscriberId)
->count();
\WP_CLI::line('---');
\WP_CLI::line(sprintf('Final status: %s', $funnelSub ? $funnelSub->status : 'unknown'));
\WP_CLI::line(sprintf('Total actions executed: %d', $totalMetrics));
}
private function printFunnelSteps($funnelId)
{
$sequences = FunnelSequence::where('funnel_id', $funnelId)
->orderBy('sequence', 'ASC')
->get();
if ($sequences->isEmpty()) {
\WP_CLI::line('No steps in this funnel.');
\WP_CLI::line('---');
return;
}
// Group children by parent_id and condition_type
$topLevel = [];
$children = []; // $children[$parentId][$conditionType][]
foreach ($sequences as $seq) {
if (!$seq->parent_id) {
$topLevel[] = $seq;
} else {
$children[$seq->parent_id][$seq->condition_type][] = $seq;
}
}
\WP_CLI::line('Funnel steps:');
$this->printSequenceList($topLevel, $children, ' ');
\WP_CLI::line('---');
}
private function printSequenceList($sequences, $children, $indent)
{
$count = count($sequences);
foreach ($sequences as $i => $seq) {
$label = $this->formatSequenceLabel($seq);
$isLast = ($i === $count - 1);
$connector = $isLast ? '└─' : '├─';
\WP_CLI::line($indent . $connector . ' ' . $label);
// If conditional/ab-test, print branches
if ($seq->type === 'conditional' && isset($children[$seq->id])) {
$childIndent = $indent . ($isLast ? ' ' : '│ ');
$branches = $children[$seq->id];
if (isset($branches['yes'])) {
\WP_CLI::line($childIndent . '├─ [YES]:');
$this->printSequenceList($branches['yes'], $children, $childIndent . '│ ');
}
if (isset($branches['no'])) {
\WP_CLI::line($childIndent . '└─ [NO]:');
$this->printSequenceList($branches['no'], $children, $childIndent . ' ');
}
}
}
}
private function formatSequenceLabel($seq)
{
$type = $seq->type ?: 'action';
$title = $seq->title ?: $seq->action_name;
if ($seq->action_name === 'fluentcrm_wait_times') {
$wait = $this->formatWaitTime($seq->settings);
return sprintf('(%s) %s — %s', $type, $title, $wait);
}
if ($seq->action_name === 'end_this_funnel') {
return sprintf('(%s) End Funnel', $type);
}
return sprintf('(%s) %s', $type, $title);
}
private function formatWaitTime($settings)
{
if (!is_array($settings)) {
return '';
}
$waitType = $settings['wait_type'] ?? '';
if ($waitType === 'timestamp_wait') {
return 'until ' . ($settings['wait_date_time'] ?? '?');
}
if ($waitType === 'to_day') {
$day = $settings['wait_day_of_week'] ?? '?';
$time = $settings['wait_time_of_day'] ?? '';
return sprintf('next %s%s', $day, $time ? ' at ' . $time : '');
}
if ($waitType === 'by_custom_field') {
return 'until custom field date';
}
$amount = $settings['wait_time_amount'] ?? '?';
$unit = $settings['wait_time_unit'] ?? 'days';
return sprintf('%s %s', $amount, $unit);
}
}
@@ -0,0 +1,87 @@
<?php
namespace FluentCrm\App\Hooks\Handlers;
/**
* ActivationHandler Class
*
*
* @package FluentCrm\App\Hooks
*
* @version 1.0.0
*/
class ActivationHandler
{
public function handle($network_wide = false)
{
// Run DB Migrations
require_once(FLUENTCRM_PLUGIN_PATH . 'database/FluentCRMDBMigrator.php');
// Task scheduler for sending emails
$this->registerWpCron();
// Default global settings/options
$this->addDefaultGlobalSettings();
}
public function registerWpCron()
{
add_filter('cron_schedules', function ($schedules) {
$schedules['fluentcrm_every_minute'] = array(
'interval' => 300,
'display' => esc_html__('Every Minute (FluentCRM)', 'fluent-crm'),
);
$schedules['fluentcrm_scheduled_five_minute_tasks'] = array(
'interval' => 300,
'display' => esc_html__('Every 5 Minutes (FluentCRM)', 'fluent-crm'),
);
return $schedules;
}, 10, 1);
if (function_exists('\as_has_scheduled_action')) {
if (!as_has_scheduled_action('fluentcrm_scheduled_every_minute_tasks')) {
as_schedule_recurring_action(time(), 60, 'fluentcrm_scheduled_every_minute_tasks', [], 'fluent-crm');
}
}
$hookName = 'fluentcrm_scheduled_five_minute_tasks';
if (!wp_next_scheduled($hookName)) {
wp_schedule_event(time(), 'fluentcrm_scheduled_five_minute_tasks', $hookName);
}
$hourlyHook = 'fluentcrm_scheduled_hourly_tasks';
if (!wp_next_scheduled($hourlyHook)) {
wp_schedule_event(time(), 'hourly', $hourlyHook);
}
$weeklyHook = 'fluentcrm_scheduled_weekly_tasks';
if (!wp_next_scheduled($weeklyHook)) {
wp_schedule_event(time(), 'weekly', $weeklyHook);
}
}
public function addDefaultGlobalSettings()
{
$key = 'fluentcrm-global-settings';
$defaults = [
'campaign' => [
'from' => [
'name' => '',
'email' => ''
]
],
'email' => [
'emails_per_second' => 4
]
];
$settings = get_option($key) ?: [];
update_option($key, array_merge($defaults, $settings));
}
}
@@ -0,0 +1,201 @@
<?php
// php
namespace FluentCrm\App\Hooks\Handlers;
use FluentCrm\App\Models\ActivityLog;
use FluentCrm\App\Models\Lists;
use FluentCrm\App\Models\Subscriber;
use FluentCrm\App\Models\Tag;
class ActivityLogHandler
{
protected $objectTypeContact = 'FluentCrm\App\Models\Subscriber';
// Call this once (e.g., on plugins_loaded) to attach the hooks
public function register()
{
return;
// if (!$this->activityLogEnabled()) {
// return;
// }
// Contact created
add_action('fluent_crm/contact_created', [$this, 'onContactCreated'], 10, 2);
// Tags updated
add_action('fluent_crm/contact_added_to_tags', [$this, 'onTagsAdded'], 10, 3);
add_action('fluent_crm/contact_removed_from_tags', [$this, 'onTagsRemoved'], 10, 3);
// Lists updated
add_action('fluent_crm/contact_added_to_lists', [$this, 'onListsAdded'], 10, 3);
add_action('fluent_crm/contact_removed_from_lists', [$this, 'onListsRemoved'], 10, 3);
// Bulk delete subscribers
add_action('fluentcrm_before_subscribers_deleted', [$this, 'onSubscribersDeleted'], 10,2);
}
public function onContactCreated($contact, $source = 'wp-admin')
{
$contactId = $this->contactId($contact);
if (!$contactId) {
return;
}
$email = $this->contactField($contact, 'email');
$name = trim($this->contactField($contact, 'first_name') . ' ' . $this->contactField($contact, 'last_name'));
$description = 'Contact Name: ' . $name . ' | Contact Email: ' . $email;
$this->log([
'object_type' => $this->objectTypeContact,
'object_id' => $contactId,
'action' => 'created contact',
'source' => $source,
'description' => $description
]);
}
public function onSubscribersDeleted($subscriberIds, $source = 'wp-admin')
{
$subscriberIds = array_values(array_filter((array) $subscriberIds));
if (empty($subscriberIds)) {
return;
}
$subscriberIds = array_values(array_filter((array) $subscriberIds));
$emails = Subscriber::whereIn('id', $subscriberIds)->take(10)->pluck('email')->toArray();
$commaSeparatedEmails = implode(',', $emails);
if (count($subscriberIds) > 10) {
$commaSeparatedEmails .= '...' . ' (and ' . (count($subscriberIds) - 10) . ' more)';
}
$description = 'Deleted Contacts Emails: ' . $commaSeparatedEmails;
$this->log([
'object_type' => $this->objectTypeContact,
'object_id' => 0,
'action' => 'deleted contacts',
'source' => $source,
'description' => $description
]);
}
public function onTagsAdded($contact, $tagIds, $source = 'wp-admin')
{
$this->logTags($contact, $tagIds, 'added tag to contact', $source);
}
public function onTagsRemoved($contact, $tagIds, $source = 'wp-admin')
{
$this->logTags($contact, $tagIds, 'removed tag from contact', $source);
}
public function onListsAdded($contact, $listIds, $source = 'wp-admin')
{
$this->logLists($contact, $listIds, 'added list to contact', $source);
}
public function onListsRemoved($contact, $listIds, $source = 'wp-admin')
{
$this->logLists($contact, $listIds, 'removed list from contact', $source);
}
/*
|----------------------------------------------------------------------
| Helpers
|----------------------------------------------------------------------
*/
protected function logTags($contact, $tagIds, $action, $source)
{
$contactId = $this->contactId($contact);
if (!$contactId) {
return;
}
$tagIds = array_values(array_filter((array) $tagIds));
$subscriberEmail = Subscriber::where('id', $contactId)->value('email');
$commaSeparatedTitles = implode(',', Tag::whereIn('id', $tagIds)->pluck('title')->toArray());
$description = 'Tags: ' . $commaSeparatedTitles . ' | Contact: ' . $subscriberEmail;
$this->log([
'object_type' => $this->objectTypeContact,
'object_id' => $contactId,
'action' => $action,
'source' => $source,
'description' => $description
]);
}
protected function logLists($contact, $listIds, $action, $source)
{
$contactId = $this->contactId($contact);
if (!$contactId) {
return;
}
$listIds = array_values(array_filter((array) $listIds));
$subscriberEmail = Subscriber::where('id', $contactId)->value('email');
$commaSeparatedTitles = implode(',', Lists::whereIn('id', $listIds)->pluck('title')->toArray());
$description = 'Lists: ' . $commaSeparatedTitles . ' | Contact: ' . $subscriberEmail;
$this->log([
'object_type' => $this->objectTypeContact,
'object_id' => $contactId,
'action' => $action,
'source' => $source,
'description' => $description
]);
}
protected function log(array $data)
{
ActivityLog::create([
'object_type' => $data['object_type'] ?? 'contact',
'object_id' => $data['object_id'] ?? null,
'action' => $data['action'] ?? 'unknown',
'source' => $data['source'],
'description' => $data['description'] ?? null,
'activity_by' => $this->currentUserId()
]);
}
protected function contactId($contact)
{
if (is_object($contact)) {
// FluentCRM Contact model uses `id`
return isset($contact->id) ? (int) $contact->id : null;
}
if (is_array($contact)) {
return isset($contact['id']) ? (int) $contact['id'] : null;
}
return null;
}
protected function contactField($contact, $key)
{
if (is_object($contact)) {
return isset($contact->{$key}) ? $contact->{$key} : '';
}
if (is_array($contact)) {
return isset($contact[$key]) ? $contact[$key] : '';
}
return '';
}
protected function currentUserId(): int
{
if (function_exists('get_current_user_id')) {
return (int) get_current_user_id();
}
return 0;
}
protected function activityLogEnabled()
{
$settings = get_option('_fluentcrm_experimental_settings', []);
if (isset($settings['activity_log']) && $settings['activity_log'] == 'no') {
return false;
}
return true;
}
}
@@ -0,0 +1,111 @@
<?php
namespace FluentCrm\App\Hooks\Handlers;
use FluentCrm\App\Models\Subscriber;
use FluentCrm\App\Services\PermissionManager;
use FluentCrm\App\Services\Stats;
use FluentCrm\Framework\Support\Arr;
/**
* Admin Bar Class
*
* Used for Quick Access to CRM
*
* @package FluentCrm\App\Hooks
*
* @version 1.0.0
*/
class AdminBar
{
public function init()
{
$contactPermission = PermissionManager::currentUserCan('fcrm_read_contacts');
/**
* Determine whether the FluentCRM admin bar search is enabled or not.
*
* @return bool False Default is false or disabled.
*/
if (!is_admin() || !$contactPermission || apply_filters('fluent_crm/disable_adminbar_search', apply_filters('fluent_crm/disable_global_search', false))) {
return;
}
add_action('admin_bar_menu', [$this, 'addAdminBarSearch'], 999);
}
public function addAdminBarSearch($adminBar)
{
wp_enqueue_script(
'fluentcrm_adminbar_search',
fluentCrmMix('/admin/js/adminbar-search.js'),
['jquery']
);
$urlBase = fluentcrm_menu_url_base();
$currentScreen = get_current_screen();
$editingUserVars = null;
if ($currentScreen && $currentScreen->id == 'user-edit') {
$userId = (int) Arr::get($_REQUEST, 'user_id');
$user = get_user_by('ID', $userId);
if ($userId && $user) {
$crmProfile = Subscriber::where('email', $user->user_email)
->orWhere('user_id', $user->ID)
->first();
if ($crmProfile) {
$crmProfileUrl = $urlBase . 'subscribers/' . $crmProfile->id;
$editingUserVars = [
'user_id' => $userId,
'crm_profile_id' => $crmProfile->id,
'crm_profile_url' => $crmProfileUrl
];
}
}
}
wp_localize_script('fluentcrm_adminbar_search', 'fcrm_adminbar_search_vars', [
'rest' => $this->getRestInfo(),
'links' => (new Stats)->getQuickLinks(),
'subscriber_base' => $urlBase . 'subscribers/',
'edit_user_vars' => $editingUserVars,
'trans' => [
'Search Contacts' => __('Search Contacts', 'fluent-crm'),
'Type and press enter' => __('Type and press enter', 'fluent-crm'),
'Type to search contacts' => __('Type to search contacts', 'fluent-crm'),
'Quick Links' => __('Quick Links', 'fluent-crm'),
'Sorry no contact found' => __('Sorry no contact found', 'fluent-crm'),
'Load More' => __('Load More', 'fluent-crm'),
'Close' => __('Close', 'fluent-crm')
]
]);
$args = [
'parent' => 'top-secondary',
'id' => 'fcrm_adminbar_search',
'title' => __('Search Contacts', 'fluent-crm'),
'href' => '#',
'meta' => false
];
$adminBar->add_node($args);
}
protected function getRestInfo()
{
$app = FluentCrm();
$ns = $app->config->get('app.rest_namespace');
$v = $app->config->get('app.rest_version');
return [
'base_url' => esc_url_raw(rest_url()),
'url' => rest_url($ns . '/' . $v),
'nonce' => wp_create_nonce('wp_rest'),
'namespace' => $ns,
'version' => $v
];
}
}
File diff suppressed because one or more lines are too long
@@ -0,0 +1,381 @@
<?php
namespace FluentCrm\App\Hooks\Handlers;
use FluentCrm\App\Models\Subscriber;
use FluentCrm\App\Services\AutoSubscribe;
use FluentCrm\App\Services\Funnel\FunnelHelper;
use FluentCrm\App\Services\Helper;
use FluentCrm\Framework\Support\Arr;
/**
* AutoSubscribeHandler Class
*
* Used to handle the auto-subscribe functionality for different WordPress Events.
*
* @package FluentCrm\App\Hooks
*
* @version 1.0.0
*/
class AutoSubscribeHandler
{
public function register()
{
add_action('user_register', array($this, 'userRegistrationHandler'), 99, 1);
add_action('comment_post', array($this, 'handleCommentPost'), 99, 3);
add_action('profile_update', array($this, 'syncUserUpdate'), 10, 3);
add_action('delete_user', array($this, 'maybeDeleteContact'), 10, 3);
add_action('woocommerce_customer_save_address', array($this, 'syncWooAddressUpdate'), 10, 2);
add_action('wp_login', array($this, 'maybeAddCountryToProfile'), 99, 2);
}
public function userRegistrationHandler($userId)
{
if (is_multisite()) {
if (is_network_admin()) {
return false;
}
if (function_exists('WP_Ultimo')) {
return false;
}
}
$settings = (new AutoSubscribe())->getRegistrationSettings();
if (Arr::get($settings, 'status') != 'yes') {
$user = get_user_by('ID', $userId);
$contact = Subscriber::where('email', $user->user_email)->first();
if ($contact && $contact->user_id != $user->ID) {
fluentCrmDb()->table('fc_subscribers')
->where('id', $contact->id)
->update([
'user_id' => $user->ID
]);
}
return false;
}
$subscriberData = FunnelHelper::prepareUserData($userId);
if ($listId = Arr::get($settings, 'target_list')) {
$subscriberData['lists'] = [$listId];
}
if ($tags = Arr::get($settings, 'target_tags')) {
$subscriberData['tags'] = $tags;
}
$isDoubleOptin = Arr::get($settings, 'double_optin') == 'yes';
if ($isDoubleOptin) {
$subscriberData['status'] = 'pending';
} else {
$subscriberData['status'] = 'subscribed';
}
$contact = FunnelHelper::createOrUpdateContact($subscriberData);
if (!$contact) {
return false;
}
if ($contact->status == 'pending' && $subscriberData['status'] == 'pending') {
$contact->sendDoubleOptinEmail();
}
add_action('updated_user_meta', function ($meta_id, $userId, $meta_key, $_meta_value) use ($contact) {
if ($userId == $contact->user_id && ($meta_key == 'first_name' || $meta_key == 'last_name') && $_meta_value) {
if ($contact->{$meta_key} != $_meta_value) {
fluentCrmDb()->table('fc_subscribers')
->where('id', $contact->id)
->update([
$meta_key => $_meta_value
]);
}
}
}, 10, 4);
}
public function addSubscribeCheckbox($buttonHtml)
{
$settings = (new AutoSubscribe())->getCommentSettings();
/**
* Determine the settings for the comment form subscribe feature in FluentCRM.
*
* This filter allows modification of the settings used for the comment form subscribe feature in FluentCRM.
*
* @param array $settings The current settings for the comment form subscribe feature.
* @return array The modified settings for the comment form subscribe feature.
* @since 2.7.0
*
*/
$settings = apply_filters('fluent_crm/comment_form_subscribe_settings', $settings);
if (Arr::get($settings, 'status') != 'yes') {
return $buttonHtml;
}
if (Arr::get($settings, 'show_only_new') == 'yes') {
if ($userId = get_current_user_id()) {
$user = get_user_by('ID', $userId);
$contact = Subscriber::where('user_id', $userId)->orWhere('email', $user->user_email)->first();
if ($contact && $contact->status == 'subscribed') {
return $buttonHtml;
}
}
}
$label = Arr::get($settings, 'checkbox_label');
if (!$label) {
$label = __('Subscribe to newsletter', 'fluent-crm');
}
$checkedTag = '';
if (Arr::get($settings, 'auto_checked') == 'yes') {
$checkedTag = 'checked="true"';
}
$html = '<p class="comment-form-fc-consent comment-form-cookies-consent"><input ' . $checkedTag . ' id="wp-comment-fc-consent" name="wp-comment-fc-consent" type="checkbox" value="yes"><label for="wp-comment-fc-consent">' . $label . '</label></p>';
return $html . $buttonHtml;
}
public function handleCommentPost($commentId, $isApproved, $commentData)
{
// is this a spam comment?
if ($isApproved === 'spam') {
return false;
}
if (defined('WC_PLUGIN_FILE') && Arr::get($commentData, 'comment_type') == 'review') {
do_action('fluentcrm_woo_review_comment_post', $commentId, $isApproved, $commentData);
}
$isChecked = Arr::get($_REQUEST, 'wp-comment-fc-consent') == 'yes';
if (!$isChecked) {
return false;
}
$subscriberData = [
'full_name' => Arr::get($commentData, 'comment_author'),
'email' => Arr::get($commentData, 'comment_author_email'),
'ip_address' => Arr::get($commentData, 'comment_author_IP')
];
if ($userId = Arr::get($commentData, 'user_id')) {
$subscriberData['user_id'] = $userId;
}
$subscriberData = array_filter($subscriberData);
$settings = (new AutoSubscribe())->getCommentSettings();
if ($listId = Arr::get($settings, 'target_list')) {
$subscriberData['lists'] = [$listId];
}
if ($tags = Arr::get($settings, 'target_tags')) {
$subscriberData['tags'] = $tags;
}
$isDoubleOptin = Arr::get($settings, 'double_optin') == 'yes';
if ($isDoubleOptin) {
$subscriberData['status'] = 'pending';
}
$contact = FunnelHelper::createOrUpdateContact($subscriberData);
if (!$contact) {
return false;
}
if (!$contact->country) {
// get CF Country from request header: CF-IPCountry
$countryCode = sanitize_text_field($_SERVER['HTTP_CF_IPCOUNTRY'] ?? '');
if ($countryCode && preg_match('/^[A-Z]{2}$/', $countryCode) && $countryCode !== 'XX') {
$contact->country = $countryCode;
$contact->save();
}
}
if ($contact->status == 'pending') {
$contact->sendDoubleOptinEmail();
}
return true;
}
public function syncUserUpdate($userId, $oldData, $newData = [])
{
if (is_multisite() && is_network_admin()) {
return false;
}
if (!empty($newData['user_pass'])) {
$user = get_user_by('ID', $userId);
(new Cleanup())->handleUserPasswordChanged($user);
}
if (!Helper::isUserSyncEnabled()) {
return false;
}
// check if user email has been changed
$user = get_user_by('ID', $userId);
if ($user->user_email != $oldData->user_email) {
// email has been changed
$oldSubscriber = Subscriber::where('email', $oldData->user_email)->first();
// check if a contact is exist with the new email id
$newSubscriber = Subscriber::where('email', $user->user_email)->first();
if ($newSubscriber) {
fluentCrmDb()->table('fc_subscribers')
->where('id', $oldSubscriber->id)
->update([
'user_id' => ''
]);
$oldSubscriber = false;
}
if ($oldSubscriber) {
$updateData = [
'email' => $user->user_email,
'hash' => md5($user->user_email),
'updated_at' => current_time('mysql'),
'user_id' => $user->ID
];
if ($user->first_name) {
$updateData['first_name'] = $user->first_name;
}
if ($user->last_name) {
$updateData['last_name'] = $user->last_name;
}
return fluentCrmDb()->table('fc_subscribers')
->where('id', $oldSubscriber->id)
->update($updateData);
}
}
// we just have to change the first name and lastname
$updateData = Helper::getWPMapUserInfo($user);
unset($updateData['email']);
if (!$updateData) {
return false;
}
$updateData['updated_at'] = current_time('mysql');
return fluentCrmDb()->table('fc_subscribers')
->where('email', $user->user_email)
->update($updateData);
}
public function maybeDeleteContact($userId, $reassignId, $user)
{
if (is_multisite() && is_network_admin()) {
return false;
}
if (!Helper::isContactDeleteOnUserDeleteEnabled()) {
return false;
}
$subscriber = Subscriber::where('user_id', $userId)->first();
if (!$subscriber) {
$subscriber = Subscriber::where('email', $user->user_email)->first();
}
if (!$subscriber) {
return false;
}
return Helper::deleteContacts([$subscriber->id]);
}
public function syncWooAddressUpdate($userId, $addressType)
{
if ($addressType != 'billing') {
return;
}
$customer = new \WC_Customer($userId);
if (!$customer || !$customer->get_id()) {
return;
}
$user = get_user_by('ID', $userId);
$contact = Subscriber::where('email', $user->user_email)->first();
$addressData = $customer->get_billing();
$updateData = [
'user_id' => $userId,
'address_line_1' => $addressData['address_1'],
'address_line_2' => $addressData['address_2'],
'city' => $addressData['city'],
'state' => $addressData['state'],
'country' => $addressData['country'],
'postal_code' => $addressData['postcode']
];
if ($contact) {
$contact->fill($updateData);
$dirty = $contact->getDirty();
if ($dirty) {
fluentCrmDb()->table('fc_subscribers')
->where('id', $contact->id)
->update($dirty);
$contact = Subscriber::find($contact->id);
do_action('fluent_crm/contact_updated', $contact, $dirty);
}
} else {
FluentCrmApi('contacts')->createOrUpdate($updateData);
}
}
public function maybeAddCountryToProfile($userLogin, $wpUser)
{
// get CF Country from request header: CF-IPCountry
$countryCode = sanitize_text_field($_SERVER['HTTP_CF_IPCOUNTRY'] ?? '');
if (!$countryCode || !preg_match('/^[A-Z]{2}$/', $countryCode) || $countryCode === 'XX') {
return;
}
$contact = Subscriber::where('email', $wpUser->user_email)->first();
if (!$contact || $contact->country) {
return;
}
$updateData = [
'country' => $countryCode
];
if (empty($contact->user_id) || (int) $contact->user_id === (int) $wpUser->ID) {
$updateData['user_id'] = $wpUser->ID;
}
fluentCrmDb()->table('fc_subscribers')
->where('id', $contact->id)
->update($updateData);
}
}
@@ -0,0 +1,60 @@
<?php
namespace FluentCrm\App\Hooks\Handlers;
/**
* CampaignGuard Class
*
* Used to handle concurrent requests for the same campaign.
*
* @package FluentCrm\App\Hooks
*
* @version 1.0.0
*/
class CampaignGuard
{
const FORBIDDEN_CODE = 403;
public function checkIsActive($campaign)
{
if (!$campaign) {
$this->send('The campaign is not available anymore.');
}
$status = $campaign->status;
if (!in_array($status, ['draft', 'pending', 'incomplete', 'purged', 'scheduled'])) {
$message = __('The campaign has been locked and not modifiable due to it\'s current status', 'fluent-crm');
$message .= ": <strong>{$status}</strong>.";
$this->send($message);
}
return;
}
public function checkIsWorking($campaign)
{
if (!$campaign) {
$this->send('The campaign is not available anymore.');
}
$status = $campaign->status;
if ($status == 'working') {
$message = __("The campaign has been locked and not deletable due to it's current status", "fluent-crm");
$message .= ": <strong>{$status}</strong>.";
$this->send($message);
}
return;
}
protected function send($message)
{
FluentCrm('response')->sendError([
'status' => self::FORBIDDEN_CODE,
'message' => "<p style='font-weight:500;color:#606266;'>{$message}</p>"
], self::FORBIDDEN_CODE);
}
}
@@ -0,0 +1,373 @@
<?php
namespace FluentCrm\App\Hooks\Handlers;
use FluentCrm\App\Models\CampaignEmail;
use FluentCrm\App\Models\CampaignUrlMetric;
use FluentCrm\App\Models\Company;
use FluentCrm\App\Models\CompanyNote;
use FluentCrm\App\Models\FunnelMetric;
use FluentCrm\App\Models\FunnelSubscriber;
use FluentCrm\App\Models\Subscriber;
use FluentCrm\App\Models\SubscriberMeta;
use FluentCrm\App\Models\SubscriberNote;
use FluentCrm\App\Models\SubscriberPivot;
use FluentCrm\App\Services\BlockParser;
use FluentCrm\App\Services\Helper;
use FluentCrm\App\Models\Meta;
/**
* Cleanup Class
*
* Used to handle cleanup related assets for subscribers, campaigns and automations.
*
* @package FluentCrm\App\Hooks
*
* @version 1.0.0
*/
class Cleanup
{
/**
* Cleanup related data of a subscriber.
*
* @param array $subscriberIds
*/
public function deleteSubscribersAssets($subscriberIds)
{
CampaignEmail::whereIn('subscriber_id', $subscriberIds)->delete();
CampaignUrlMetric::whereIn('subscriber_id', $subscriberIds)->delete();
SubscriberMeta::whereIn('subscriber_id', $subscriberIds)->delete();
SubscriberNote::whereIn('subscriber_id', $subscriberIds)->delete();
SubscriberPivot::whereIn('subscriber_id', $subscriberIds)->delete();
FunnelMetric::whereIn('subscriber_id', $subscriberIds)->delete();
FunnelSubscriber::whereIn('subscriber_id', $subscriberIds)->delete();
if (defined('FLUENTCAMPAIGN_DIR_FILE')) {
\FluentCampaign\App\Models\SequenceTracker::whereIn('subscriber_id', $subscriberIds)->delete();
}
if (Helper::isExperimentalEnabled('company_module')) {
Company::whereIn('owner_id', $subscriberIds)
->update([
'owner_id' => NULL
]);
}
}
/**
* Cleanup related data of a campaign.
*
* @param int $campaignId
*/
public function deleteCampaignAssets($campaignId)
{
// Idempotent backstop — Campaign::deleteCampaignData() already removes
// these in the normal delete flow, but we keep this here so any
// future caller that fires fluent_crm/campaign_deleted without
// running deleteCampaignData() first still gets a clean teardown.
CampaignEmail::where('campaign_id', $campaignId)->delete();
CampaignUrlMetric::where('campaign_id', $campaignId)->delete();
}
/**
* Cleanup related data of a list.
*
* @param int $listId
*/
public function deleteListAssets($listId)
{
SubscriberPivot::where('object_type', 'FluentCrm\App\Models\Lists')->where('object_id', $listId)->delete();
}
/**
* Cleanup related data of a tag.
*
* @param int $listId
*/
public function deleteTagAssets($listId)
{
SubscriberPivot::where('object_type', 'FluentCrm\App\Models\Tag')->where('object_id', $listId)->delete();
}
/**
* Cancel Future Emails.
*
* @param \FluentCrm\App\Models\Subscriber $subscriber
*/
public function handleUnsubscribe($subscriber)
{
// Per-statement try/catch: a row-lock deadlock against the mailer
// workers on the CampaignEmail update should not also block the
// FunnelSubscriber / SequenceTracker cancellations. The next status
// transition (or a manual retry) will reconcile any rows we miss.
try {
CampaignEmail::where('subscriber_id', $subscriber->id)
->whereIn('status', ['pending', 'scheduled', 'draft', 'processing', 'scheduling'])
->update([
'status' => 'cancelled'
]);
} catch (\Exception $e) {
Helper::debugLog('handleUnsubscribe', 'CampaignEmail cancel deferred: ' . $e->getMessage(), 'extended');
}
try {
FunnelSubscriber::where('subscriber_id', $subscriber->id)
->where('status', 'active')
->whereDoesntHave('funnel', function ($query) {
$query->where('trigger_name', 'fluent_crm/subscriber_status_changed');
})
->update([
'status' => 'cancelled'
]);
} catch (\Exception $e) {
Helper::debugLog('handleUnsubscribe', 'FunnelSubscriber cancel deferred: ' . $e->getMessage(), 'extended');
}
if (defined('FLUENTCAMPAIGN')) {
try {
\FluentCampaign\App\Models\SequenceTracker::where('subscriber_id', $subscriber->id)
->where('status', 'active')
->update([
'status' => 'cancelled'
]);
} catch (\Exception $e) {
Helper::debugLog('handleUnsubscribe', 'SequenceTracker cancel deferred: ' . $e->getMessage(), 'extended');
}
}
}
/**
* Change the future emails email_address of a provided contact.
*
* @param \FluentCrm\App\Models\Subscriber $subscriber
*/
public function handleContactEmailChanged($subscriber)
{
CampaignEmail::where('subscriber_id', $subscriber->id)
->whereIn('status', ['draft', 'scheduled'])
->update([
'email_address' => $subscriber->email
]);
}
/**
* @param $userId int
* @param $resign int|null
* @param $deletedUser \WP_User
* @return bool
*/
public function handleUserDelete($userId, $resign, $deletedUser)
{
$settings = Helper::getComplianceSettings();
if ($settings['delete_contact_on_user'] !== 'yes') {
return false;
}
$subscriber = Subscriber::where('user_id', $userId)->first();
if (!$subscriber && $deletedUser) {
$subscriber = Subscriber::where('email', $deletedUser->user_email)->first();
}
if (!$subscriber) {
return false;
}
// delete the subscriber now;
Helper::deleteContacts([$subscriber->id]);
return true;
}
public function attachCrmExporter($exporters)
{
$settings = Helper::getComplianceSettings();
if ($settings['personal_data_export'] !== 'yes') {
return $exporters;
}
$exporters['fluent-crm'] = [
'exporter_friendly_name' => __('FluentCRM Data', 'fluent-crm'),
'callback' => [$this, 'exportPersonalDataWP'],
];
return $exporters;
}
public function exportPersonalDataWP($user_email, $page = 1)
{
$subscriber = Subscriber::where('email', $user_email)->first();
if (!$subscriber) {
return [
'data' => [],
'done' => true
];
}
$customerFields = $subscriber->custom_fields();
$mainFields = $subscriber->toArray();
$data = [
'group_id' => 'fluent-crm-contact',
'group_label' => __('FluentCRM Data', 'fluent-crm'),
'item_id' => 'crm-contact',
'data' => []
];
foreach ($mainFields as $fieldKey => $fieldValue) {
if ($fieldValue) {
$data['data'][] = [
'name' => $fieldKey,
'value' => $fieldValue
];
}
}
foreach ($customerFields as $fieldKey => $customerField) {
$data['data'][] = [
'name' => $fieldKey,
'value' => $customerField
];
}
return [
'data' => [$data],
'done' => true,
];
}
public function handleCompanyDelete($id)
{
/*
* Remove Company ID from all connected subscribers
*/
Subscriber::where('company_id', $id)->update([
'company_id' => NULL
]);
fluentCrmDb()->table('fc_subscriber_pivot')
->where('object_id', $id)
->where('object_type', 'FluentCrm\App\Models\Company')
->delete();
// Delete company notes
CompanyNote::where('subscriber_id', $id)->delete();
}
public function handleUserPasswordChanged($user)
{
$contact = Subscriber::where('email', $user->user_email)
->first();
if (!$contact) {
return false;
}
$exist = SubscriberMeta::where('subscriber_id', $contact->id)
->where('key', '_secure_managed_hash')
->first();
if (!$exist) {
return false;
}
$hash = md5(wp_generate_uuid4() . '_' . $contact->id . '_' . '_' . time() . '__' . $contact->id);
$exist->value = $hash;
$exist->updated_at = current_time('mysql');
$exist->save();
return true;
}
public function archiveCampaignAssets($campaign)
{
if ($campaign->type != 'campaign' || fluentcrm_get_campaign_meta($campaign->id, '_cached_email_body', true)) {
return;
}
// We will create email body and then cache it for future use
$rawTemplates = [
'raw_html',
'visual_builder',
'raw_classic'
];
if (in_array($campaign->design_template, $rawTemplates)) {
$emailBody = $campaign->email_body;
} else {
$emailBody = (new BlockParser())->parse($campaign->email_body);
}
fluentcrm_update_campaign_meta($campaign->id, '_cached_email_body', $emailBody);
return true;
}
public static function maybeRemoveOldScheuledActionLogs()
{
$group_slug = 'fluent-crm';
$days_old = 7;
global $wpdb;
// Get the timestamp for 7 days ago
$cutoff_date = gmdate('Y-m-d H:i:s', strtotime("-{$days_old} days"));
// Get the group ID
$group_id = $wpdb->get_var($wpdb->prepare(
"SELECT group_id FROM {$wpdb->prefix}actionscheduler_groups WHERE slug = %s",
$group_slug
));
if (!$group_id) {
return false; // Group not found
}
// Delete old actions and their associated logs
$deleted = $wpdb->query($wpdb->prepare("
DELETE a, l
FROM {$wpdb->prefix}actionscheduler_actions a
LEFT JOIN {$wpdb->prefix}actionscheduler_logs l ON a.action_id = l.action_id
WHERE a.group_id = %d
AND a.status IN ('complete', 'failed')
AND a.scheduled_date_gmt < %s", $group_id, $cutoff_date));
// Clean up orphaned claims
$wpdb->query("
DELETE c
FROM {$wpdb->prefix}actionscheduler_claims c
LEFT JOIN {$wpdb->prefix}actionscheduler_actions a ON c.claim_id = a.claim_id
WHERE a.action_id IS NULL");
return $deleted;
}
public function SyncSubscriberDeleteSettings($fromKey, $value)
{
if ($fromKey == 'compliance_settings') {
$option = Meta::where('key', 'user_syncing_settings')
->where('object_type', 'option')
->first();
if ($option) {
$settings = $option->value;
if ($settings['delete_contact_on_user_delete'] != $value) {
$settings['delete_contact_on_user_delete'] = $value;
$option->value = $settings;
$option->save();
}
}
} else {
$complianceSettings = get_option('_fluentcrm_compliance_settings');
if ($complianceSettings) {
$complianceSettings['delete_contact_on_user'] = $value;
update_option('_fluentcrm_compliance_settings', $complianceSettings, 'no');
}
}
}
}
@@ -0,0 +1,169 @@
<?php
namespace FluentCrm\App\Hooks\Handlers;
use FluentCrm\App\Models\Meta;
use FluentCrm\App\Models\Subscriber;
use FluentCrm\App\Services\Helper;
use FluentCrm\Framework\Support\Arr;
/**
* ContactActivityLogger Class
*
* Logs Contact's activity based on different WordPress Events.
*
* @package FluentCrm\App\Hooks
*
* @version 1.0.0
*/
class ContactActivityLogger
{
public function register()
{
// Login Tracker
add_action('wp_login', array($this, 'trackLogin'), 10, 2);
// Global Tracker
add_action('fluent_crm/track_activity_by_subscriber', array($this, 'trackActivityBySubscriber'));
add_action('fluent_crm/email_opened_anonymously', [$this, 'trackEmailOpenAnonymously'], 10, 1);
add_action('fluent_crm/anonymous_email_url_clicked', [$this, 'trackEmailClickAnonymously'], 10, 2);
}
public function trackLogin($username, $user)
{
update_user_meta($user->ID, '_last_login', current_time('mysql'));
$this->trackActivityByUser($user, 'login');
}
public function trackActivityByUser($user, $type = '')
{
if (is_numeric($user)) {
$user = get_user_by('ID', $user);
}
if (!$user || empty($user->user_email)) {
return;
}
$subscriber = Subscriber::where('email', $user->user_email)->first();
if (!$subscriber) {
return;
}
$this->trackActivityBySubscriber($subscriber);
if ($type == 'login') {
fluentcrm_update_subscriber_meta($subscriber->id, '_last_login', current_time('mysql'));
}
return true;
}
public function trackActivityBySubscriber($subscriber)
{
if (!$subscriber) {
return;
}
if (is_numeric($subscriber)) {
$subscriber = Subscriber::where('id', $subscriber)->first();
}
if (!$subscriber) {
return;
}
if ($subscriber->last_activity && strtotime($subscriber->last_activity) > (current_time('timestamp') - 3600)) {
return;
}
$data = [
'last_activity' => current_time('mysql')
];
if (!$subscriber->ip && fluentCrmWillTrackIp()) {
$ip = FluentCrm('request')->getIp(fluentCrmWillAnonymizeIp());
if ($ip != '127.0.0.1') {
$data['ip'] = $ip;
}
}
return fluentCrmDb()->table('fc_subscribers')
->where('id', $subscriber->id)
->update($data);
}
public function trackEmailOpenAnonymously($campaignEmaillModel)
{
if (!$campaignEmaillModel->campaign_id) {
return;
}
// check if the campaign exist
global $wpdb;
$exists = $wpdb->get_var(
$wpdb->prepare(
"SELECT 1 FROM {$wpdb->prefix}fc_campaigns WHERE id = %d LIMIT 1",
$campaignEmaillModel->campaign_id
)
);
if (!$exists) {
return;
}
$existingMetaModel = fluentcrm_get_campaign_meta($campaignEmaillModel->campaign_id, '_ano_open_count', false);
if ($existingMetaModel) {
global $wpdb;
$wpdb->query($wpdb->prepare(
"UPDATE {$wpdb->prefix}fc_meta SET value = value + 1 WHERE id = %d",
$existingMetaModel->id
));
} else {
// we creating new one
Meta::create([
'key' => '_ano_open_count',
'value' => 1,
'object_id' => $campaignEmaillModel->campaign_id,
'object_type' => 'FluentCrm\App\Models\Campaign'
]);
}
return true;
}
public function trackEmailClickAnonymously($url, $campaign)
{
$existingMetaModel = fluentcrm_get_campaign_meta($campaign->id, '_ano_url_clicks', false);
$url = (string)$url;
if ($existingMetaModel) {
$urls = is_array($existingMetaModel->value) ? $existingMetaModel->value : [];
if (isset($urls[$url])) {
$urls[$url] = (int)$urls[$url] + 1;
} else {
$urls[$url] = 1;
}
$existingMetaModel->value = $urls;
$existingMetaModel->save();
} else {
Meta::create([
'key' => '_ano_url_clicks',
'value' => [
$url => 1
],
'object_id' => $campaign->id,
'object_type' => 'FluentCrm\App\Models\Campaign'
]);
}
return true;
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,29 @@
<?php
namespace FluentCrm\App\Hooks\Handlers;
/**
* DeactivationHandler Class
*
* FluentCRM Deactivation Handler Class.
*
* @package FluentCrm\App\Hooks
*
* @version 1.0.0
*/
class DeactivationHandler
{
public function handle()
{
if (function_exists('\as_unschedule_all_actions')) {
as_unschedule_all_actions('fluentcrm_scheduled_every_minute_tasks');
as_unschedule_all_actions('fluent_crm_ascheduler_runs_daily');
}
wp_clear_scheduled_hook('fluentcrm_scheduled_minute_tasks');
wp_clear_scheduled_hook('fluentcrm_scheduled_hourly_tasks');
wp_clear_scheduled_hook('fluentcrm_scheduled_weekly_tasks');
wp_clear_scheduled_hook('fluentcrm_scheduled_five_minute_tasks');
wp_clear_scheduled_hook('fluentcrm_scheduled_daily_tasks');
}
}
@@ -0,0 +1,226 @@
<?php
namespace FluentCrm\App\Hooks\Handlers;
use FluentCrm\App\Services\Sanitize;
use FluentCrm\App\Services\Libs\Emogrifier\Emogrifier;
use FluentCrm\Framework\Support\Arr;
/**
* EmailDesignTemplates Class
*
* For handling email design templates
*
* @package FluentCrm\App\Hooks
*
* @version 1.0.0
*/
class EmailDesignTemplates
{
public function register()
{
add_filter('fluent_crm/email-design-template-block_editor', [$this, 'addBlockEditorTemplate'], 10, 3);
add_filter('fluent_crm/email-design-template-simple', [$this, 'addBlockEditorTemplate'], 10, 3);
add_filter('fluent_crm/email-design-template-plain', [$this, 'addBlockEditorTemplate'], 10, 3);
add_filter('fluent_crm/email-design-template-classic', [$this, 'addBlockEditorTemplate'], 10, 3);
add_filter('fluent_crm/email-design-template-raw_classic', [$this, 'addRawClassicTemplate'], 10, 3);
add_filter('fluent_crm/email-design-template-web_preview', [$this, 'addWebPreviewTemplate'], 10, 3);
}
public function addBlockEditorTemplate($emailBody, $templateData, $campaign)
{
$templateData = $this->filterTemplateData($templateData);
$templateData['email_body'] = $emailBody;
$view = FluentCrm('view');
$emailBody = $view->make('emails.block_editor.Template', $templateData);
$emailBody = $emailBody->__toString();
$emogrifier = new Emogrifier($emailBody);
$emogrifier->disableInvisibleNodeRemoval();
return $emogrifier->emogrify();
}
/**
* @param string $emailBody
* @param array $templateData
* @param \FluentCrm\App\Models\Campaign $campaign
* @return string
*/
public function addPlainTemplate($emailBody, $templateData, $campaign)
{
$templateData = $this->filterTemplateData($templateData);
$view = FluentCrm('view');
$emailBody = $view->make('emails.plain.Template', $templateData);
$emailBody = $emailBody->__toString();
$emogrifier = new Emogrifier($emailBody);
$emogrifier->disableInvisibleNodeRemoval();
return $emogrifier->emogrify();
}
/**
* @param string $emailBody
* @param array $templateData
* @param \FluentCrm\App\Models\Campaign $campaign
* @return string
*/
public function addSimpleTemplate($emailBody, $templateData, $campaign)
{
if (empty($templateData['config']['body_bg_color'])) {
$templateData['config']['body_bg_color'] = '#FAFAFA';
}
if (empty($templateData['config']['content_bg_color'])) {
$templateData['config']['content_bg_color'] = '#ffffff';
}
$templateData = $this->filterTemplateData($templateData);
$view = FluentCrm('view');
$emailBody = $view->make('emails.simple.Template', $templateData);
$emailBody = $emailBody->__toString();
$emogrifier = new Emogrifier($emailBody);
$emogrifier->disableInvisibleNodeRemoval();
return $emogrifier->emogrify();
}
/**
* @param string $emailBody
* @param array $templateData
* @param \FluentCrm\App\Models\Campaign $campaign
* @return string
*/
public function addClassicTemplate($emailBody, $templateData, $campaign)
{
if (empty($templateData['config']['content_bg_color'])) {
$templateData['config']['content_bg_color'] = '#ffffff';
}
$templateData = $this->filterTemplateData($templateData);
$view = FluentCrm('view');
$emailBody = $view->make('emails.classic.Template', $templateData);
$emailBody = $emailBody->__toString();
$emogrifier = new Emogrifier($emailBody);
$emogrifier->disableInvisibleNodeRemoval();
return $emogrifier->emogrify();
}
/**
* @param string $emailBody
* @param array $templateData
* @param \FluentCrm\App\Models\Campaign $campaign
* @return string
*/
public function addRawClassicTemplate($emailBody, $templateData, $campaign)
{
$templateData = $this->filterTemplateData($templateData);
$configDefault = [
'content_width' => '',
'content_padding' => '',
'headings_font_family' => '',
'text_color' => '',
'link_color' => '',
'body_bg_color' => '',
'content_bg_color' => '',
'footer_text_color' => '',
'content_font_family' => "-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol'",
'paragraph_color' => '',
'paragraph_font_size' => '',
'paragraph_font_family' => '',
'paragraph_line_height' => '',
'headings_color' => ''
];
$templateData['config'] = wp_parse_args($templateData['config'], $configDefault);
$view = FluentCrm('view');
$emailBody = $view->make('emails.raw_classic.Template', $templateData);
$emailBody = $emailBody->__toString();
$emogrifier = new Emogrifier($emailBody);
$emogrifier->disableInvisibleNodeRemoval();
return $emogrifier->emogrify();
}
public function addWebPreviewTemplate($emailBody, $templateData, $campaign)
{
$templateData = $this->filterTemplateData($templateData);
$configDefault = [
'content_width' => '',
'content_padding' => '',
'headings_font_family' => '',
'text_color' => '',
'link_color' => '',
'body_bg_color' => '',
'content_bg_color' => '',
'footer_text_color' => '',
'content_font_family' => "-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol'",
'paragraph_color' => '',
'paragraph_font_size' => '',
'paragraph_font_family' => '',
'paragraph_line_height' => '',
'headings_color' => ''
];
$templateData['config'] = wp_parse_args($templateData['config'], $configDefault);
$view = FluentCrm('view');
$emailBody = $view->make('emails.web_preview.Template', $templateData);
$emailBody = $emailBody->__toString();
$emogrifier = new Emogrifier($emailBody);
$emogrifier->disableInvisibleNodeRemoval();
return $emogrifier->emogrify();
}
private function filterTemplateData($templateData)
{
$footerConfig = Arr::get($templateData, 'footer_config', []);
$disableFooter = Arr::get($footerConfig, 'disable_footer');
if ($disableFooter !== 'yes' && $disableFooter !== 'no') {
$disableFooter = Arr::get($templateData, 'config.disable_footer');
}
if ($disableFooter == 'yes') {
$templateData['footer_text'] = '';
} else {
$style = 'font-size: 13px; color: #202020;';
if ($footerConfig) {
$fontSize = Arr::get($footerConfig, 'font_size', 13) . 'px';
$color = sanitize_hex_color(Arr::get($footerConfig, 'font_color', '#202020')) ?: '#202020';
$backgroundColor = Arr::get($footerConfig, 'background_color', 'transparent');
$paddingRaw = Arr::get($footerConfig, 'footer_padding');
$safeBackgroundColor = sanitize_hex_color($backgroundColor);
if ($backgroundColor === 'transparent') {
$safeBackgroundColor = 'transparent';
}
$safePadding = 20;
if ($paddingRaw !== null && $paddingRaw !== '') {
$safePadding = min(80, max(0, intval($paddingRaw)));
}
$style = "font-size: {$fontSize}; color: {$color};";
if ($safeBackgroundColor) {
$style .= " background-color: {$safeBackgroundColor};";
}
$style .= " padding: {$safePadding}px;";
$templateData['footer_text'] = Sanitize::sanitizeFooterHtml($footerConfig['footer_content'] ?? '');
}
if($templateData['footer_text']) {
$templateData['footer_text'] = "<div style='{$style}'>{$templateData['footer_text']}</div>";
}
}
return $templateData;
}
}
@@ -0,0 +1,369 @@
<?php
namespace FluentCrm\App\Hooks\Handlers;
use FluentCrm\App\Models\EventTracker;
use FluentCrm\App\Services\Helper;
use FluentCrm\Framework\Support\Arr;
class EventTrackingHandler
{
public function register()
{
add_filter('fluentcrm_ajax_options_event_tracking_keys', [$this, 'getEventTrackingKeyOptions'], 10, 1);
add_action('fluentcrm_contacts_filter_event_tracking', [$this, 'applyEventTrackingFilter'], 10, 2);
add_action('fluent_crm/track_event_activity', [$this, 'trackEventActivity'], 10, 2);
add_filter('fluent_crm/subscriber_info_widgets', [$this, 'addSubscriberInfoWidgets'], 10, 2);
add_filter('fluent_crm/subscriber_info_widget_event_tracking', [$this, 'addSubscriberInfoWidgets'], 10, 2);
add_filter('fluentcrm_advanced_filter_options', [$this, 'addEventTrackingFilterOptions'], 10, 1);
add_filter('fluent_crm/event_tracking_condition_groups', [$this, 'addEventTrackingConditionOptions'], 10, 1);
add_filter('fluentcrm_automation_condition_groups', function ($groups) {
if (!Helper::isExperimentalEnabled('event_tracking')) {
return $groups;
}
$groups['event_tracking'] = [
'label' => __('Event Tracking', 'fluent-crm'),
'value' => 'event_tracking',
'children' => $this->getConditionItems()
];
return $groups;
});
}
public function getEventTrackingKeyOptions($options = [])
{
$items = EventTracker::select(['event_key'])
->groupBy('event_key')
->orderBy('event_key', 'ASC')
->get();
$formattedItems = [];
foreach ($items as $item) {
$formattedItems[] = [
'id' => $item->event_key,
'title' => $item->event_key
];
}
return $formattedItems;
}
public function applyEventTrackingFilter($query, $filters)
{
if (!Helper::isExperimentalEnabled('event_tracking')) {
return $query;
}
foreach ($filters as $filter) {
if (!array_key_exists('value', $filter) || $filter['value'] === '') {
continue;
}
$relation = 'trackingEvents';
$filterProp = $filter['property'];
if ($filterProp == 'event_tracking_key') {
$operator = $filter['operator'];
$values = $filter['value'];
if ($operator == 'not_in') {
$query->whereDoesntHave($relation, function ($q) use ($values) {
$q->whereIn('event_key', $values);
});
} else {
$query->whereHas($relation, function ($q) use ($values) {
$q->whereIn('event_key', $values);
});
}
continue;
}
if ($filterProp == 'event_tracking_title') {
$operator = $filter['operator'];
if ($operator == '=') {
$query->whereHas($relation, function ($q) use ($filter) {
$q->where('title', $filter['value']);
});
} else if ($operator == '!=') {
$query->whereDoesntHave($relation, function ($q) use ($filter) {
$q->where('title', $filter['value']);
});
} else if ($operator == 'contains') {
$query->whereHas($relation, function ($q) use ($filter) {
$q->where('title', 'LIKE', '%' . $filter['value'] . '%');
});
} else if ($operator == 'not_contains') {
$query->whereDoesntHave($relation, function ($q) use ($filter) {
$q->where('title', 'LIKE', '%' . $filter['value'] . '%');
});
}
continue;
}
if ($filterProp == 'event_tracking_value') {
$eventKey = Arr::get($filter, 'extra_value');
if (!$eventKey) {
continue;
}
$operator = $filter['operator'];
if ($operator == '=') {
$query->whereHas($relation, function ($q) use ($filter, $eventKey) {
$q->where('value', $filter['value'])
->where('event_key', $eventKey);
});
} else if ($operator == '!=') {
$query->whereDoesntHave($relation, function ($q) use ($filter, $eventKey) {
$q->where('value', $filter['value'])
->where('event_key', $eventKey);
});
} else if (in_array($operator, ['<', '>'])) {
$query->whereHas($relation, function ($q) use ($filter, $eventKey, $operator) {
$q->where('value', $operator, (int)$filter['value'])
->where('event_key', $eventKey);
});
} else if ($operator == 'contains') {
$query->whereHas($relation, function ($q) use ($filter, $eventKey) {
$q->where('value', 'LIKE', '%' . $filter['value'] . '%')
->where('event_key', $eventKey);
});
} else if ($operator == 'not_contains') {
$query->whereDoesntHave($relation, function ($q) use ($filter, $eventKey) {
$q->where('value', 'LIKE', '%' . $filter['value'] . '%')
->where('event_key', $eventKey);
});
}
continue;
}
if ($filterProp == 'event_tracking_key_count') {
$eventKey = Arr::get($filter, 'extra_value');
if (!$eventKey) {
continue;
}
$operator = $filter['operator'];
if ($operator == '=') {
$query->whereHas($relation, function ($q) use ($filter, $eventKey) {
$q->where('counter', $filter['value'])
->where('event_key', $eventKey);
});
} else if ($operator == '!=') {
$query->whereDoesntHave($relation, function ($q) use ($filter, $eventKey) {
$q->where('counter', $filter['value'])
->where('event_key', $eventKey);
});
} else if (in_array($operator, ['<', '>'])) {
$query->whereHas($relation, function ($q) use ($filter, $eventKey, $operator) {
$q->where('counter', $operator, (int)$filter['value'])
->where('event_key', $eventKey);
});
}
continue;
}
}
return $query;
}
public function trackEventActivity($data, $repeatable = true)
{
return FluentCrmApi('event_tracker')->track($data, $repeatable);
}
public function addSubscriberInfoWidgets($widgets, $subscriber)
{
if (!Helper::isExperimentalEnabled('event_tracking')) {
return $widgets;
}
$events = EventTracker::where('subscriber_id', $subscriber->id)
->orderBy('updated_at', 'DESC')
->paginate();
if ($events->isEmpty()) {
return $widgets;
}
$html = '<div class="fc_scrolled_lists"><ul class="fcrm_event_tracking_lists">';
foreach ($events as $event) {
$html .= '<li>';
$html .= '<p class="fcrm_event_tracking_title">' . esc_html($event->title) . '</p>';
if ($event->value) {
$html .= '<p class="fcrm_event_tracking_value">' . wp_kses_post($event->value) . '</p>';
}
$html .= '<div class="fcrm_event_tracking_footer">';
$html .= '<div class="fcrm_event_tracking_badge">' . esc_attr($event->event_key) . '<span class="fcrm_event_tracking_count">(' . esc_html($event->counter) . ')</span></div>';
$html .= '<span class="fcrm_event_tracking_date">' . $event->updated_at . '</span>';
$html .= '</div>';
$html .= '</li>';
}
$html .= '</ul></div>';
$widgets['event_tracking'] = [
'title' => __('Event Tracking', 'fluent-crm'),
'content' => $html,
'has_pagination' => $events->total() > $events->perPage(),
'total' => $events->total(),
'per_page' => $events->perPage(),
'current_page' => $events->currentPage()
];
return $widgets;
}
public function addEventTrackingFilterOptions($groups)
{
if (!Helper::isExperimentalEnabled('event_tracking')) {
return $groups;
}
$groups['event_tracking'] = [
'label' => __('Event Tracking', 'fluent-crm'),
'value' => 'event_tracking',
'children' => $this->getConditionItems()
];
return $groups;
}
public function addEventTrackingConditionOptions($items)
{
if (!Helper::isExperimentalEnabled('event_tracking')) {
return $items;
}
return [
[
'label' => __('Event Tracking', 'fluent-crm'),
'value' => 'event_tracking',
'children' => $this->getConditionItems()
],
[
'label' => __('Contact Segment', 'fluent-crm'),
'value' => 'segment',
'children' => [
[
'label' => __('Type', 'fluent-crm'),
'value' => 'contact_type',
'type' => 'selections',
'component' => 'options_selector',
'option_key' => 'contact_types',
'is_multiple' => false,
'is_singular_value' => true
],
[
'label' => __('Tags', 'fluent-crm'),
'value' => 'tags',
'type' => 'selections',
'component' => 'options_selector',
'option_key' => 'tags',
'is_multiple' => true,
],
[
'label' => __('Lists', 'fluent-crm'),
'value' => 'lists',
'type' => 'selections',
'component' => 'options_selector',
'option_key' => 'lists',
'is_multiple' => true,
]
],
]
];
}
private function getConditionItems()
{
return [
[
'label' => __('Event Key', 'fluent-crm'),
'value' => 'event_tracking_key',
'type' => 'selections',
'component' => 'ajax_selector',
'option_key' => 'event_tracking_keys',
'is_multiple' => true,
'custom_operators' => [
'in' => 'in',
'not_in' => 'not in'
],
'creatable' => true,
'experimental_cache' => true,
'help' => __('Match one or more tracking events for your contacts.', 'fluent-crm')
],
[
'label' => __('Event Occurrence Count', 'fluent-crm'),
'value' => 'event_tracking_key_count',
'type' => 'composite_optioned_compare',
'help' => __('The provided value for your selected event will be matched with the event occurrence count', 'fluent-crm'),
'ajax_selector' => [
'label' => __('For Event Key', 'fluent-crm'),
'option_key' => 'event_tracking_keys',
'experimental_cache' => true,
'is_multiple' => false,
'placeholder' => __('Select Event Key', 'fluent-crm')
],
'value_config' => [
'label' => __('Event Count', 'fluent-crm'),
'type' => 'input_text',
'data_type' => 'number',
'placeholder' => __('Event Value', 'fluent-crm')
],
'custom_operators' => [
'=' => 'equal',
'!=' => 'not equal',
'>' => 'greater than',
'<' => 'less than'
],
],
[
'label' => __('Event Value', 'fluent-crm'),
'value' => 'event_tracking_value',
'type' => 'composite_optioned_compare',
'help' => __('The compare value will be matched with selected event & last recorded value of the selected event key', 'fluent-crm'),
'ajax_selector' => [
'label' => __('For Event Key', 'fluent-crm'),
'option_key' => 'event_tracking_keys',
'experimental_cache' => true,
'is_multiple' => false,
'placeholder' => __('Select Event Key', 'fluent-crm')
],
'value_config' => [
'label' => __('Compare Value', 'fluent-crm'),
'type' => 'input_text',
'placeholder' => __('Event Value', 'fluent-crm'),
'data_type' => 'number',
],
'custom_operators' => [
'=' => 'equal',
'!=' => 'not equal',
'contains' => 'includes',
'not_contains' => 'does not include',
'>' => 'greater than',
'<' => 'less than'
],
],
[
'label' => __('Event Title', 'fluent-crm'),
'value' => 'event_tracking_title',
'type' => 'text',
'help' => __('Match by tracking event title', 'fluent-crm')
],
];
}
}
File diff suppressed because it is too large Load Diff
File diff suppressed because one or more lines are too long
@@ -0,0 +1,68 @@
<?php
namespace FluentCrm\App\Hooks\Handlers;
class FluentBlockPatternHandler
{
public function shouldUnregisterAllPatterns($shouldUnregister, $context = '', $data = [])
{
return true;
}
public function addCustomPatternCategories($categories)
{
$categories[] = [
'name' => 'fcrm-email',
'label' => __('FluentCRM Email', 'fluent-crm'),
'description' => __('Reusable email sections for FluentCRM editor.', 'fluent-crm')
];
return $categories;
}
public function addCustomPatterns($patterns)
{
$patterns[] = [
'name' => 'fcrm/intro-cta',
'title' => __('Intro + CTA', 'fluent-crm'),
'categories' => ['fcrm-email'],
'keywords' => ['intro', 'cta'],
'content' => '<!-- wp:group {"style":{"spacing":{"padding":{"top":"24px","right":"24px","bottom":"24px","left":"24px"}}},"layout":{"type":"constrained"}} --><div class="wp-block-group has-theme-palette-color-8-background-color has-background" style="padding-top:24px;padding-right:24px;padding-bottom:24px;padding-left:24px"><!-- wp:heading {"level":3} --><h3>' . esc_html__('Welcome to our newsletter', 'fluent-crm') . '</h3><!-- /wp:heading --><!-- wp:paragraph --><p>' . esc_html__('Share your main message here in one short paragraph.', 'fluent-crm') . '</p><!-- /wp:paragraph --><!-- wp:buttons --><div class="wp-block-buttons"><!-- wp:button --><div class="wp-block-button"><a class="wp-block-button__link wp-element-button" href="#">' . esc_html__('Get Started', 'fluent-crm') . '</a></div><!-- /wp:button --></div><!-- /wp:buttons --></div><!-- /wp:group -->'
];
$patterns[] = [
'name' => 'fcrm/two-button-row',
'title' => __('Two Button Row', 'fluent-crm'),
'categories' => ['fcrm-email'],
'keywords' => ['buttons', 'actions'],
'content' => '<!-- wp:paragraph {"align":"center"} --><p class="has-text-align-center">' . esc_html__('Choose an action:', 'fluent-crm') . '</p><!-- /wp:paragraph --><!-- wp:buttons {"layout":{"type":"flex","justifyContent":"center"}} --><div class="wp-block-buttons"><!-- wp:button --><div class="wp-block-button"><a class="wp-block-button__link wp-element-button" href="#">' . esc_html__('Primary Action', 'fluent-crm') . '</a></div><!-- /wp:button --><!-- wp:button {"className":"is-style-outline"} --><div class="wp-block-button is-style-outline"><a class="wp-block-button__link wp-element-button" href="#">' . esc_html__('Secondary Action', 'fluent-crm') . '</a></div><!-- /wp:button --></div><!-- /wp:buttons -->'
];
$patterns[] = [
'name' => 'fcrm/feature-list',
'title' => __('Feature List', 'fluent-crm'),
'categories' => ['fcrm-email'],
'keywords' => ['list', 'features'],
'content' => '<!-- wp:heading {"level":4} --><h4>' . esc_html__('Why people choose us', 'fluent-crm') . '</h4><!-- /wp:heading --><!-- wp:list --><ul class="wp-block-list"><!-- wp:list-item --><li>' . esc_html__('Fast setup', 'fluent-crm') . '</li><!-- /wp:list-item --><!-- wp:list-item --><li>' . esc_html__('Simple workflow', 'fluent-crm') . '</li><!-- /wp:list-item --><!-- wp:list-item --><li>' . esc_html__('Better conversion', 'fluent-crm') . '</li><!-- /wp:list-item --></ul><!-- /wp:list -->'
];
$patterns[] = [
'name' => 'fcrm/event-reminder',
'title' => __('Event Reminder', 'fluent-crm'),
'categories' => ['fcrm-email'],
'keywords' => ['event', 'reminder'],
'content' => '<!-- wp:group {"style":{"spacing":{"padding":{"top":"20px","right":"20px","bottom":"20px","left":"20px"}}},"layout":{"type":"constrained"}} --><div class="wp-block-group" style="padding-top:20px;padding-right:20px;padding-bottom:20px;padding-left:20px"><!-- wp:heading {"level":4} --><h4>' . esc_html__('Reminder: Upcoming Event', 'fluent-crm') . '</h4><!-- /wp:heading --><!-- wp:paragraph --><p>' . esc_html__('Date: Monday, 10:00 AM', 'fluent-crm') . '<br>' . esc_html__('Location: Online', 'fluent-crm') . '</p><!-- /wp:paragraph --><!-- wp:buttons --><div class="wp-block-buttons"><!-- wp:button --><div class="wp-block-button"><a class="wp-block-button__link wp-element-button" href="#">' . esc_html__('Add to Calendar', 'fluent-crm') . '</a></div><!-- /wp:button --></div><!-- /wp:buttons --></div><!-- /wp:group -->'
];
$patterns[] = [
'name' => 'fcrm/simple-footer-note',
'title' => __('Simple Footer Note', 'fluent-crm'),
'categories' => ['fcrm-email'],
'keywords' => ['footer', 'note'],
'content' => '<!-- wp:separator --><hr class="wp-block-separator has-alpha-channel-opacity"/><!-- /wp:separator --><!-- wp:paragraph {"align":"center","fontSize":"small"} --><p class="has-text-align-center has-small-font-size">' . esc_html__('Need help? Reply to this email and our team will assist you.', 'fluent-crm') . '</p><!-- /wp:paragraph -->'
];
return $patterns;
}
}
@@ -0,0 +1,223 @@
<?php
namespace FluentCrm\App\Hooks\Handlers;
use FluentCrm\App\Models\Tag;
class FluentConditionalContentBlockHandler
{
const BLOCK_NAME = 'fluent-crm/conditional-content';
const DEFAULT_CONDITION = 'show_if_tag_exist';
/**
* Legacy condition keys kept for backward compatibility.
*/
private $legacyMap = [
'show_if_logged_in' => 'show_if_user_logged_in',
'show_if_public_users' => 'show_if_user_not_logged_in',
'show_if_tag_exists' => 'show_if_tag_exist',
'show_if_tag_not_exists' => 'show_if_tag_not_exist',
];
public function register()
{
add_action('init', [$this, 'registerBlock']);
add_action('enqueue_block_editor_assets', [$this, 'enqueueEditorAssets']);
}
/**
* Register the block with a render callback.
* The JS save() still writes HTML into post_content for storage,
* but the render_callback fully controls frontend output.
*/
public function registerBlock()
{
if (!function_exists('register_block_type')) {
return;
}
register_block_type(self::BLOCK_NAME, [
'api_version' => 3,
'editor_script' => 'fluent-crm-conditional-content-block',
'attributes' => [
'condition_type' => [
'type' => 'string',
'default' => self::DEFAULT_CONDITION,
],
'tag_ids' => [
'type' => 'array',
'default' => [],
],
],
'supports' => [
'align' => ['wide', 'full'],
'anchor' => true,
'html' => false,
],
'render_callback' => [$this, 'renderBlock'],
]);
}
public function enqueueEditorAssets()
{
// The iframe editor already registers its own conditional block implementation.
if (isset($_REQUEST['fluent_crm_block_editor'])) {
return;
}
$handle = 'fluent-crm-conditional-content-block';
wp_register_script(
$handle,
fluentCrmMix('public/conditional-content-block.js'),
['wp-blocks', 'wp-block-editor', 'wp-components', 'wp-element', 'wp-i18n'],
FLUENTCRM_PLUGIN_VERSION,
true
);
$tags = Tag::select(['id', 'title'])->orderBy('title', 'ASC')->get();
wp_localize_script($handle, 'fcrmConditionalContentConfig', [
'hasPro' => defined('FLUENTCAMPAIGN'),
'tags' => $tags
]);
wp_set_script_translations($handle, 'fluent-crm');
}
/**
* Render callback. Receives block attributes, the rendered inner blocks
* as $content, and the WP_Block instance.
*
* @param array $attributes
* @param string $content Inner blocks already rendered.
* @param \WP_Block $block
* @return string
*/
public function renderBlock($attributes, $content, $block)
{
if (!$this->passesCondition($attributes)) {
return '';
}
// no inner blocks placed at all — nothing to render.
// Checking $block->inner_blocks (parsed block data) is the authoritative source of truth
// and avoids inspecting the rendered HTML string, which loses semantic information.
if (count($block->inner_blocks) === 0) {
return '';
}
// inner blocks exist but all rendered to nothing — for example a Query Loop
// with no results, a dynamic block gated by its own conditions, or a plugin-restricted
// block. Avoids outputting an empty wrapper div in those cases.
// trim() on the raw HTML string is intentional: any real element (iframe, video, image,
// paragraph, etc.) produces a non-empty string. wp_strip_all_tags() is deliberately
// avoided here because it removes HTML tags and would incorrectly treat media-only
// content (iframes, videos, images) as empty.
if (trim($content) === '') {
return '';
}
$wrapperAttributes = get_block_wrapper_attributes([
'class' => 'fc-cond-section',
]);
return sprintf(
'<div %1$s><div class="fc-cond-blocks">%2$s</div></div>',
$wrapperAttributes,
$content
);
}
/**
* Decide whether the current visitor passes the condition.
*/
private function passesCondition($attrs)
{
$condition = $this->normalizeConditionType(
isset($attrs['condition_type']) ? $attrs['condition_type'] : self::DEFAULT_CONDITION
);
$tagIds = isset($attrs['tag_ids']) && is_array($attrs['tag_ids'])
? array_values(array_filter(array_map('intval', $attrs['tag_ids'])))
: [];
switch ($condition) {
case 'show_if_user_logged_in':
return is_user_logged_in();
case 'show_if_user_not_logged_in':
return !is_user_logged_in();
case 'show_if_tag_exist':
if (empty($tagIds)) {
return false;
}
return $this->contactHasAnyTag($tagIds);
case 'show_if_tag_not_exist':
if (empty($tagIds)) {
return true;
}
return !$this->contactHasAnyTag($tagIds);
}
return false;
}
/**
* Check if the current contact has any of the given tag IDs.
*/
private function contactHasAnyTag(array $tagIds)
{
$contact = $this->getCurrentContact();
if (!$contact) {
return false;
}
return $contact->hasAnyTagId($tagIds);
}
/**
* Resolve the current contact once per request.
*/
private function getCurrentContact()
{
static $resolved = null;
static $cached = false;
if ($cached) {
return $resolved;
}
$cached = true;
$contact = fluentcrm_get_current_contact();
if ($contact) {
$resolved = $contact->load('tags');
}
return $resolved;
}
/**
* Map legacy keys to current condition keys, mirroring the JS side.
*/
private function normalizeConditionType($value)
{
$value = trim((string)$value);
if (!$value) {
return self::DEFAULT_CONDITION;
}
return isset($this->legacyMap[$value]) ? $this->legacyMap[$value] : $value;
}
}
@@ -0,0 +1,266 @@
<?php
namespace FluentCrm\App\Hooks\Handlers;
use FluentCrm\App\Models\Subscriber;
use FluentCrm\Framework\Support\Arr;
use FluentForm\App\Models\Submission;
use FluentForm\App\Modules\Acl\Acl;
use FluentForm\App\Services\FormBuilder\ShortCodeParser;
/**
* FormSubmissions Class
*
* Fluent Forms Integration Class
*
* @package FluentCrm\App\Hooks
*
* @version 1.0.0
*/
class FormSubmissions
{
public function register()
{
if (defined('FLUENTFORM')) {
add_filter('fluent_crm/form_submission_providers', [$this, 'pushDefaultFormProviders']);
add_filter('fluentcrm_get_form_submissions_fluentform', [$this, 'getFluentFormSubmissions'], 10, 2);
add_filter('fluent_crm/dynamic_contact_item_view_fluentform', [$this, 'getFluentFormSubmissionDetails'], 10, 2);
// Smartcodes
add_filter('fluentform/editor_shortcodes', function ($smartCodes) {
$smartCodes[0]['shortcodes']['{fluentcrm.CONTACT_DATA_KEY}'] = 'FluentCRM Data';
return $smartCodes;
}, 100, 1);
add_filter('fluentform/editor_shortcode_callback_group_fluentcrm', [$this, 'parseEditorCodes'], 10, 3);
}
}
public function pushDefaultFormProviders($providers)
{
if (defined('FLUENTFORM')) {
$providers['fluentform'] = [
'title' => __('Form Submissions (Fluent Forms)', 'fluent-crm'),
'name' => __('Fluent Forms', 'fluent-crm')
];
}
return $providers;
}
public function getFluentFormSubmissions($data, $subscriber)
{
if (!defined('FLUENTFORM')) {
return $data;
}
$app = fluentCrm();
$page = intval($app->request->get('page', 1));
$per_page = intval($app->request->get('per_page', 10));
$query = fluentCrmDb()->table('fluentform_submissions')
->select([
'fluentform_submissions.id',
'fluentform_submissions.form_id',
'fluentform_forms.title',
'fluentform_submissions.status',
'fluentform_submissions.created_at'
])
->join('fluentform_forms', 'fluentform_forms.id', '=', 'fluentform_submissions.form_id')
->where(function ($query) use ($subscriber) {
$query->where('fluentform_submissions.response', 'LIKE', '%' . $subscriber->email . '%');
if ($subscriber->user_id) {
$query->orWhere('fluentform_submissions.user_id', $subscriber->user_id);
}
});
$total = $query->count();
$submissions = $query
->limit($per_page)
->offset($per_page * ($page - 1))
->orderBy('fluentform_submissions.id', 'desc')
->get();
$formattedSubmissions = [];
foreach ($submissions as $submission) {
$submissionUrl = admin_url('admin.php?page=fluent_forms&route=entries&form_id=' . $submission->form_id . '#/entries/' . $submission->id);
$actionUrl = '<a target="_blank" rel="noopener" href="' . $submissionUrl . '">#' . $submission->id . '</a>';
$badgeClass = 'fcrm_badge';
if ($submission->status === 'read') {
$badgeClass .= ' fcrm_badge_success';
} else if ($submission->status === 'unread') {
$badgeClass .= ' fcrm_badge_warning';
}
$formattedSubmissions[] = [
'__id' => $submission->id,
'id' => $actionUrl,
'title' => $submission->title,
'Status' => '<span class="' . $badgeClass . '">' . $submission->status . '</span>',
'Submitted At' => '<a target="_blank" rel="noopener" href="' . $submissionUrl . '">' . $submission->created_at . '</a>',
'action' => 'view'
];
}
return [
'total' => $total,
'data' => $formattedSubmissions,
'columns_config' => [
'id' => [
'label' => __('ID', 'fluent-crm'),
'width' => '100px'
],
'title' => [
'label' => __('Form Title', 'fluent-crm')
],
'Status' => [
'label' => __('Status', 'fluent-crm'),
'width' => '100px'
],
'Submitted At' => [
'label' => __('Submitted At', 'fluent-crm'),
'width' => '180px'
],
'action' => [
'quick_action' => true,
'label' => __('Action', 'fluent-crm'),
'width' => '100px'
]
]
];
}
public function getFluentFormSubmissionDetails($dataView, $params)
{
$submissionId = (int)Arr::get($params, '__id');
if (!$submissionId) {
$dataView['content_html'] = '<div class="fc-crm-no-data-view"><p>' . __('No submission found', 'fluent-crm') . '</p></div>';
return $dataView;
}
$submission = Submission::with(['form'])->find($submissionId);
if (!$submission || !$submission->form) {
$dataView['content_html'] = '<div class="fc-crm-no-data-view"><p>' . __('No submission found', 'fluent-crm') . '</p></div>';
return $dataView;
}
$form = $submission->form;
if (!Acl::hasPermission('fluentform_entries_viewer', $form->id)) {
$dataView['title'] = __('Permission Denied', 'fluent-crm');
$dataView['content_html'] = '<div class="fc-crm-no-data-view"><p>' . __('You do not have permission to view this submission.', 'fluent-crm') . '</p></div>';
return $dataView;
}
$submittedData = json_decode($submission->response, true);
$html = '<b>Submission Details</b><br/><br/><div>{all_data}</div>';
if ($submission->payment_status) {
$html .= '<h2>Payment Details</h2>';
$html .= '{payment.receipt}';
}
$html .= '<h4>Additional Details:</h4>';
$html .= '<ul>';
$html .= '<li><strong>Source URL:</strong> {submission.source_url}</li>';
$html .= '<li><strong>Serial #:</strong> {submission.serial_number}</li>';
$html .= '<li><strong>Browser:</strong> {submission.browser} / {submission.device}</li>';
$html .= '<li><strong>Date:</strong> {submission.created_at}</li>';
$html .= '</ul>';
$body = ShortCodeParser::parse(
$html,
$submission->id,
$submittedData,
$form,
false,
true
);
$dataView['title'] = sprintf(__('Submission #%d - %s', 'fluent-crm'), $submission->id, $form->title);
$dataView['content_html'] = '<style>.fc-crm-form-submission-view table { width: 100% !important; }</style><div class="fc-crm-form-submission-view">' . $body . '</div>';
$dataView['footer_content'] = '<a class="el-button fcrm_primary_btn" target="_blank" rel="noopener" href="' . admin_url('admin.php?page=fluent_forms&route=entries&form_id=' . $form->id . '#/entries/' . $submission->id) . '">View in FluentForms</a>';
return $dataView;
}
public function parseEditorCodes($code, $form, $keys)
{
$contact = FluentCrmApi('contacts')->getCurrentContact(true, true);
$providedKey = $keys[0];
// maybe has fallback value
$dynamicKey = explode('|', $providedKey);
$fallBack = '';
if (count($dynamicKey) > 1) {
$fallBack = $dynamicKey[1];
}
$ref = $dynamicKey[0];
if (!$contact) {
return $fallBack;
}
$validMainProps = (new Subscriber)->getFillable();
$validMainProps[] = 'id';
if (in_array($ref, $validMainProps)) {
if ($contact->{$ref}) {
return $contact->{$ref};
}
return $fallBack;
}
// Maybe it's a custom field
$customData = $contact->custom_fields();
if ($customData && !empty($customData[$ref])) {
$value = $customData[$ref];
if (is_array($value)) {
return implode(',', $value);
}
return $customData[$ref];
}
$listMaps = [
'list_ids' => 'id',
'list_titles' => 'title',
'list_slugs' => 'slug'
];
$tagMaps = [
'tag_ids' => 'id',
'tag_titles' => 'title',
'tag_slugs' => 'slug'
];
if (isset($listMaps[$ref])) {
$listProps = [];
foreach ($contact->lists as $list) {
$listProps[] = $list->{$listMaps[$ref]};
}
if ($listProps) {
return trim(implode(', ', $listProps));
}
} else if (isset($tagMaps[$ref])) {
$tagProps = [];
foreach ($contact->tags as $tag) {
$tagProps[] = $tag->{$tagMaps[$ref]};
}
if ($tagProps) {
return trim(implode(', ', $tagProps));
}
}
return $fallBack;
}
}
@@ -0,0 +1,530 @@
<?php
namespace FluentCrm\App\Hooks\Handlers;
use FluentCrm\App\Models\Campaign;
use FluentCrm\App\Models\Funnel;
use FluentCrm\App\Models\FunnelCampaign;
use FluentCrm\App\Models\FunnelSequence;
use FluentCrm\App\Models\FunnelSubscriber;
use FluentCrm\App\Services\Funnel\Actions\ApplyCompanyAction;
use FluentCrm\App\Services\Funnel\Actions\ApplyListAction;
use FluentCrm\App\Services\Funnel\Actions\ApplyTagAction;
use FluentCrm\App\Services\Funnel\Actions\DetachCompanyAction;
use FluentCrm\App\Services\Funnel\Actions\DetachListAction;
use FluentCrm\App\Services\Funnel\Actions\DetachTagAction;
use FluentCrm\App\Services\Funnel\Actions\SendEmailAction;
use FluentCrm\App\Services\Funnel\Actions\WaitTimeAction;
use FluentCrm\App\Services\Funnel\Benchmarks\ListAppliedBenchmark;
use FluentCrm\App\Services\Funnel\Benchmarks\RemoveFromListBenchmark;
use FluentCrm\App\Services\Funnel\Benchmarks\RemoveFromTagBenchmark;
use FluentCrm\App\Services\Funnel\Benchmarks\TagAppliedBenchmark;
use FluentCrm\App\Services\Funnel\FunnelHelper;
use FluentCrm\App\Services\Funnel\FunnelProcessor;
use FluentCrm\App\Services\Funnel\SequencePoints;
use FluentCrm\App\Services\Funnel\Triggers\FluentFormSubmissionTrigger;
use FluentCrm\App\Services\Funnel\Triggers\FluentFormSubscriptionCancelledTrigger;
use FluentCrm\App\Services\Funnel\Triggers\FluentFormSubscriptionPaymentReceivedTrigger;
use FluentCrm\App\Services\Funnel\Triggers\UserRegistrationTrigger;
use FluentCrm\App\Services\Helper;
use FluentCrm\App\Services\PermissionManager;
use FluentCrm\App\Services\Sanitize;
use FluentCrm\Framework\Support\Arr;
/**
* FunnelHandler Class - Automation Funnel Handler
*
* Automation Funnel Handler Class
*
* @package FluentCrm\App\Hooks
*
* @version 1.0.0
*/
class FunnelHandler
{
private $settingsKey = 'fluentcrm_funnel_settings';
private $lockKey = '_fc_funnel_processor_lock';
private $lockTimeout = 90;
protected $funnelFired = false;
private $registeredFunnelTriggers = [];
private $registeredTriggerFallbacks = [];
private $funnelItemsRegistered = false;
public function register()
{
/*
* Core funnel items must register before the early active-trigger pass
* so their fluentcrm_funnel_arg_num_* filters are available at init
* priority 2. This lets core events fired by other init priority 10
* callbacks, such as LifterLMS user registration, enter funnels.
*
* Pro integrations can register trigger arg-count filters before init priority 2.
* Register those ready triggers early so events fired during init priority 10,
* such as EDD manual order status updates, are not missed.
*
* The fallback pass at priority 20 preserves the existing behavior for triggers
* whose arg-count filters are not available during the early pass.
*/
add_action('init', [$this, 'registerFunnelItems'], 1);
add_action('init', [$this, 'registerEarlyActiveTriggers'], 2);
add_action('init', [$this, 'handle'], 10);
add_action('init', [$this, 'registerActiveTriggers'], 20);
}
/**
* Register core funnel actions, benchmarks, triggers, and free Pro placeholders once.
*
* This runs before registerEarlyActiveTriggers() so core trigger arg-count filters
* are present when active funnel listeners are attached before other init@10 callbacks.
*
* @return void
*/
public function registerFunnelItems()
{
if ($this->funnelItemsRegistered) {
return;
}
$this->funnelItemsRegistered = true;
$this->initBlockActions();
$this->initBenchMarkBlocks();
$this->initTriggers();
if (!defined('FLUENTCAMPAIGN_DIR_FILE')) {
new \FluentCrm\App\Services\Funnel\ProFunnelItems();
}
}
public function registerEarlyActiveTriggers()
{
$this->registerActiveTriggers(true);
}
public function registerActiveTriggers($onlyRegisteredArgFilters = false)
{
$triggers = get_option($this->settingsKey, []);
$triggers = array_unique($triggers);
if (!$triggers) {
return;
}
foreach ($triggers as $triggerName) {
if ($this->shouldSkipEddTriggerRegistration($triggerName)) {
continue;
}
if (isset($this->registeredFunnelTriggers[$triggerName])) {
continue;
}
/*
* Early registration is only safe when the trigger's arg-count filter is
* already registered. Otherwise the priority 20 pass will register it
* after handle() has initialized the core trigger filters.
*/
$argNumFilterName = 'fluentcrm_funnel_arg_num_' . $triggerName;
if ($onlyRegisteredArgFilters && !has_filter($argNumFilterName)) {
continue;
}
$argNum = apply_filters($argNumFilterName, 1);
add_action($triggerName, function () use ($triggerName, $argNum) {
$this->mapTriggers($triggerName, func_get_args(), $argNum);
}, 10, $argNum);
$this->registeredFunnelTriggers[$triggerName] = true;
}
/*
* EDD also exposes edd_complete_purchase after a successful payment.
* Keep the existing fallback, but attach it only once and only after the
* main EDD payment-status trigger has been registered.
*/
if (
isset($this->registeredFunnelTriggers['edd_update_payment_status']) &&
empty($this->registeredTriggerFallbacks['edd_complete_purchase'])
) {
add_action('edd_complete_purchase', function ($paymentId) {
$this->mapTriggers('edd_update_payment_status', [$paymentId, 'complete', 'pending'], 3);
});
$this->registeredTriggerFallbacks['edd_complete_purchase'] = true;
}
}
/**
* Skip stored EDD automation hooks when the active EDD install is unsupported.
*
* Existing EDD funnel data should remain stored, but EDD runtime dispatch must
* not be registered unless the site is running EDD 3 or newer.
*
* @param string $triggerName
* @return bool
*/
private function shouldSkipEddTriggerRegistration($triggerName)
{
if (Helper::isEdd3()) {
return false;
}
return in_array($triggerName, [
'edd_update_payment_status',
'edd_sl_post_set_status',
'edd_recurring_add_subscription_payment',
'edd_subscription_status_change',
'edd_fc_order_refunded_simulation'
], true);
}
public function handle()
{
$this->registerFunnelItems();
add_action('fluent_crm_process_automation', function () {
if ($this->funnelFired) {
return;
}
$this->funnelFired = true;
if (!$this->acquireFunnelProcessorLock()) {
return;
}
try {
(new FunnelProcessor())->followUpSequenceActions();
} finally {
$this->releaseFunnelProcessorLock();
}
});
}
private function mapTriggers($triggerName, $originalArgs, $argNumber)
{
$triggerNameBase = $triggerName;
$funnels = Funnel::where('status', 'published')
->where('trigger_name', $triggerNameBase)
->get();
foreach ($funnels as $funnel) {
ob_start();
/**
* Automation Funnel Start Trigger from specific action
* @param Funnel $funnel
* @param array $originalArgs Original Arguments from the trigger
*/
do_action("fluentcrm_funnel_start_{$triggerName}", $funnel, $originalArgs);
$maybeErrors = ob_get_clean();
}
$benchMarks = FunnelSequence::where('type', 'benchmark')
->where('action_name', $triggerNameBase)
->whereHas('funnel', function ($q) {
return $q->where('status', 'published');
})
->orderBy('id', 'ASC')
->get();
foreach ($benchMarks as $benchMark) {
ob_start();
/**
* Automation Funnel's Benchmark Start Trigger from specific action trigger
* @param Funnel $funnel
* @param array $originalArgs Original Arguments from the trigger
*/
do_action("fluentcrm_funnel_benchmark_start_{$triggerName}", $benchMark, $originalArgs);
$maybeErrors = ob_get_clean();
}
}
/**
* Claim the funnel-processor lock so two runners can't process the same
* queue concurrently. Backed by an atomic conditional UPDATE on wp_options
* (Helper::acquireDbLock) on every environment — not wp_cache_add(), which
* is not atomic under all object-cache drop-ins (e.g. LiteSpeed) and would
* let concurrent runners all acquire the lock. See Helper::acquireDbLock().
*/
private function acquireFunnelProcessorLock()
{
return Helper::acquireDbLock($this->lockKey, $this->lockTimeout);
}
private function releaseFunnelProcessorLock()
{
Helper::releaseDbLock($this->lockKey);
}
public function resetFunnelIndexes()
{
$funnels = Funnel::select('trigger_name')
->where('status', 'published')
->groupBy('trigger_name')
->get();
$funnelArrays = [];
foreach ($funnels as $funnel) {
$funnelArrays[] = $funnel->trigger_name;
}
$sequenceMetrics = FunnelSequence::select('action_name')
->where('status', 'published')
->where('type', 'benchmark')
->whereHas('funnel', function ($q) {
return $q->where('status', 'published');
})
->groupBy('action_name')
->get();
foreach ($sequenceMetrics as $sequenceMetric) {
$funnelArrays[] = $sequenceMetric->action_name;
}
update_option($this->settingsKey, array_unique($funnelArrays), 'yes');
}
private function initTriggers()
{
new UserRegistrationTrigger();
new FluentFormSubmissionTrigger();
if (defined('FLUENTFORMPRO')) {
new FluentFormSubscriptionPaymentReceivedTrigger();
new FluentFormSubscriptionCancelledTrigger();
}
}
private function initBlockActions()
{
if (Helper::isCompanyEnabled()) {
new ApplyCompanyAction();
new DetachCompanyAction();
}
new ApplyListAction();
new ApplyTagAction();
new DetachListAction();
new DetachTagAction();
new WaitTimeAction();
new SendEmailAction();
}
private function initBenchMarkBlocks()
{
new ListAppliedBenchmark();
new TagAppliedBenchmark();
new RemoveFromListBenchmark();
new RemoveFromTagBenchmark();
}
public function resumeSubscriberFunnels($subscriber, $oldStatus)
{
$funnelSubscribers = FunnelSubscriber::where('status', 'pending')
->with(['funnel'])
->where('subscriber_id', $subscriber->id)
->whereHas('funnel', function ($query) {
return $query->where('status', 'published');
})
->get();
$funnelProcessorClass = new FunnelProcessor();
foreach ($funnelSubscribers as $funnelSubscriber) {
$funnel = $funnelSubscriber->funnel;
if (!$funnel || $funnel->status != 'published') {
continue;
}
$funnelProcessorClass->resumeFunnelSubscriber($funnel, $subscriber, $funnelSubscriber);
}
}
public function saveSequences()
{
check_ajax_referer('fluentcrm_ajax_nonce', '_nonce');
$hasPermission = PermissionManager::currentUserCan('fcrm_write_funnels');
if (!$hasPermission) {
wp_send_json([
'message' => __('Sorry, You do not have permission to do this action', 'fluent-crm')
], 422);
}
$request = FluentCrm('request');
$data = $request->all();
$data['sequences'] = wp_unslash(Arr::get($data, 'sequences'));
$funnel = FunnelHelper::saveFunnelSequence($data['funnel_id'], $data);
wp_send_json([
'sequences' => FunnelHelper::getFunnelSequences($funnel, true),
'message' => __('Sequence successfully updated', 'fluent-crm')
]);
}
public function exportFunnel()
{
check_ajax_referer('fluentcrm_ajax_nonce', '_nonce');
$permission = 'manage_options';
if (!current_user_can($permission)) {
die('You do not have permission');
}
$funnelId = intval($_REQUEST['funnel_id']);
$funnel = Funnel::findOrFail($funnelId);
/**
* Determine the funnel editor details based on the funnel's trigger name.
*
* The dynamic portion of the hook name, `$funnel->trigger_name`, refers to the trigger name of the funnel.
*
* @param object $funnel The funnel object containing the editor details.
* @since 2.0.0
*
*/
$funnel = apply_filters('fluentcrm_funnel_editor_details_' . $funnel->trigger_name, $funnel);
$funnel->labels = $funnel->getFormattedLabels();
$funnel->sequences = FunnelHelper::getFunnelSequences($funnel, true);
$funnel->site_hash = md5(site_url());
$funnel->export_date = gmdate('Y-m-d H:i:s');
header('Content-disposition: attachment; filename=' . sanitize_title($funnel->title, 'funnel', 'display') . '-' . $funnelId . '.json');
header('Content-type: application/json');
echo json_encode($funnel); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
exit();
}
public function saveEmailAction()
{
check_ajax_referer('fluentcrm_ajax_nonce', '_nonce');
$hasPermission = PermissionManager::currentUserCan('fcrm_write_funnels');
if (!$hasPermission) {
wp_send_json([
'message' => __('Sorry, You do not have permission to do this action', 'fluent-crm')
], 422);
}
$request = FluentCrm('request');
$funnelId = $request->get('funnel_id');
$funnel = Funnel::findOrFail($funnelId);
$settings = Helper::parseArrayOrJson($request->get('action_data'));
$settings['action_name'] = 'send_custom_email';
$funnelCampaign = Arr::get($settings, 'campaign', []);
$funnelCampaignId = Arr::get($funnelCampaign, 'id');
$data = Arr::only($funnelCampaign, array_keys(FunnelCampaign::getMock()));
$data['settings']['mailer_settings'] = Arr::get($settings, 'mailer_settings', []);
$type = 'created';
if ($funnelCampaignId && $funnel->id == Arr::get($data, 'parent_id')) {
// We have this campaign
$data['settings'] = \maybe_serialize($data['settings']);
$data['type'] = 'funnel_email_campaign';
$data['title'] = $funnel->title . ' (' . $funnel->id . ')';
FunnelCampaign::where('id', $funnelCampaignId)->update($data);
$type = 'updated';
} else {
$data['parent_id'] = $funnel->id;
$data['type'] = 'funnel_email_campaign';
$data['title'] = $funnel->title . ' (' . $funnel->id . ')';
$campaign = FunnelCampaign::create($data);
$funnelCampaignId = $campaign->id;
}
if (Arr::get($funnelCampaign, 'design_template') == 'visual_builder') {
$design = Arr::get($funnelCampaign, '_visual_builder_design', []);
fluentcrm_update_campaign_meta($funnelCampaignId, '_visual_builder_design', $design);
} else {
fluentcrm_delete_campaign_meta($funnelCampaignId, '_visual_builder_design');
}
$refCampaign = FunnelCampaign::find($funnelCampaignId);
wp_send_json([
'type' => $type,
'reference_campaign' => $funnelCampaignId,
'campaign' => Arr::only($refCampaign->toArray(), array_keys(FunnelCampaign::getMock()))
], 200);
}
public function saveCampaignEmail()
{
check_ajax_referer('fluentcrm_ajax_nonce', '_nonce');
$hasPermission = PermissionManager::currentUserCan('fcrm_manage_emails');
if (!$hasPermission) {
wp_send_json([
'message' => __('Sorry, You do not have permission to do this action', 'fluent-crm')
], 422);
}
$request = FluentCrm('request');
$id = $request->get('campaign_id');
$data = Helper::parseArrayOrJson($request->get('action_data'));
if (empty($data)) {
wp_send_json([
'message' => __('Invalid Data', 'fluent-crm')
], 422);
}
$updateData = Arr::only($data, [
'title',
'slug',
'template_id',
'email_subject',
'email_pre_header',
'email_body',
'utm_status',
'utm_source',
'utm_medium',
'utm_campaign',
'utm_term',
'utm_content',
'scheduled_at',
'design_template'
]);
if (!empty($data['settings'])) {
$updateData['settings'] = $data['settings'];
}
$updateData = Sanitize::campaign($updateData);
$campaign = Campaign::findOrFail($id);
$campaign->fill($updateData)->save();
$nextStep = Arr::get($data, 'next_step');
if ($nextStep) {
do_action('fluent_crm/update_campaign_compose', $data, $campaign);
fluentcrm_update_campaign_meta($id, '_next_config_step', $nextStep);
}
wp_send_json([
'campaign' => $campaign
], 200);
}
}
@@ -0,0 +1,40 @@
<?php
namespace FluentCrm\App\Hooks\Handlers;
use FluentCrm\App\Services\ExternalIntegrations\BricksBuilderIntegration;
/**
* Integrations Class
*
* @package FluentCrm\App\Hooks
*
* @version 1.0.0
*/
class Integrations
{
public function register()
{
// Full-featured integrations with functionality
if (defined('FLUENTFORM')) {
(new \FluentCrm\App\Services\ExternalIntegrations\FluentForm\FluentFormInit())->init();
}
if(defined('FLUENTCART_VERSION')) {
(new \FluentCrm\App\Services\ExternalIntegrations\FluentCart\FluentCart())->init();
}
/*
* Oxygen Editor Integration
*/
if (defined('CT_VERSION')) {
require_once FLUENTCRM_PLUGIN_PATH . 'app/Services/ExternalIntegrations/Oxygen/oxy_init.php';
}
(new EventTrackingHandler())->register();
if(defined('BRICKS_VERSION')) {
(new BricksBuilderIntegration())->register();
}
}
}
@@ -0,0 +1,821 @@
<?php
namespace FluentCrm\App\Hooks\Handlers;
use FluentCrm\App\Services\Helper;
use FluentCrm\Framework\Support\Arr;
use FluentCrm\App\Models\CustomContactField as CustomFields;
/**
* Integrations Class
*
* @package FluentCrm\App\Hooks
*
* @version 2.5.94
*/
class PrefFormHandler
{
public function handleShortCode($atts, $noContactContent = '')
{
if (isset($_REQUEST['_fc_secure_hash'])) {
$hash = sanitize_text_field($_REQUEST['_fc_secure_hash']);
if ($hash) {
$_COOKIE['fc_hash_secure'] = $hash;
}
}
$settings = Helper::getGlobalEmailSettings();
if (Arr::get($settings, 'pref_form') != 'yes' || empty(Arr::get($settings, 'pref_general'))) {
return '';
}
do_action('fluent_crm/rendering_pref_form_shortcode');
$subscriber = FluentCrmApi('contacts')->getCurrentContact(true, true);
if (!$subscriber) {
return $noContactContent;
}
/**
* Determine the preference form labels in FluentCRM.
*
* This filter allows modification of the labels used in the preference form.
*
* @since 2.5.95
*
* @param array {
* An associative array of labels.
*
* @type string $first_name Label for the first name field.
* @type string $last_name Label for the last name field.
* @type string $prefix Label for the title field.
* @type string $email Label for the email field.
* @type string $phone Label for the phone/mobile field.
* @type string $dob Label for the date of birth field.
* @type string $address_line_1 Label for the address line 1 field.
* @type string $address_line_2 Label for the address line 2 field.
* @type string $city Label for the city field.
* @type string $state Label for the state field.
* @type string $postal_code Label for the ZIP code field.
* @type string $country Label for the country field.
* @type string $update Label for the update info button.
* @type string $address_heading Label for the address information section.
* @type string $list_label Label for the mailing list groups section.
* }
*/
$labels = apply_filters('fluent_crm/pref_labels', [
'first_name' => __('First Name', 'fluent-crm'),
'last_name' => __('Last Name', 'fluent-crm'),
'prefix' => __('Title', 'fluent-crm'),
'email' => __('Email', 'fluent-crm'),
'phone' => __('Phone/Mobile', 'fluent-crm'),
'dob' => __('Date of Birth', 'fluent-crm'),
'address_line_1' => __('Address Line 1', 'fluent-crm'),
'address_line_2' => __('Address Line 2', 'fluent-crm'),
'city' => __('City', 'fluent-crm'),
'state' => __('State', 'fluent-crm'),
'postal_code' => __('ZIP Code', 'fluent-crm'),
'country' => __('Country', 'fluent-crm'),
'update' => __('Update info', 'fluent-crm'),
'address_heading' => __('Address Information', 'fluent-crm'),
'list_label' => __('Mailing List Groups', 'fluent-crm'),
'custom_fields' => __('Custom Fields', 'fluent-crm')
]);
$formFields = $this->getFormFields($settings, $subscriber, $labels, false);
$listOptions = [];
$lists = Helper::getPublicLists();
if ($lists) {
foreach ($lists as $list) {
$listOptions[strval($list->id)] = $list->title;
}
$formattedLists = [];
foreach ($subscriber->lists as $list) {
$formattedLists[] = $list->id;
}
$formFields['lists'] = [
'type' => 'checkboxes',
'name' => 'lists',
'container_class' => 'fc_inline_checkboxes',
'options' => $listOptions,
'value' => $formattedLists,
'id' => 'mailing_lists',
'label' => Arr::get($labels, 'list_label', 'Mailing List Groups'),
];
}
/**
* Determine the preference form fields against a subscriber or contact data in FluentCRM.
*
* This filter allows modification of the preference form fields before they are displayed.
*
* @since 2.5.95
*
* @param array $formFields The current form fields.
* @param object $subscriber The subscriber object.
* @return array Modified form fields.
*/
$formFields = apply_filters('fluent_crm/pref_form_fields', $formFields, $subscriber);
$formFields[] = [
'type' => 'hidden',
'atts' => [
'name' => 'action',
'value' => 'fluent_crm_account_form'
]
];
if (isset($_REQUEST['_fc_secure_hash'])) {
$hash = sanitize_text_field($_REQUEST['_fc_secure_hash']);
if($hash) {
$formFields[] = [
'type' => 'hidden',
'atts' => [
'name' => '_fc_hash_secure',
'value' => $hash
]
];
}
}
wp_enqueue_style(
'fluentcrm_public_pref',
fluentCrmMix('public/public_pref.css'),
[],
FLUENTCRM_PLUGIN_VERSION
);
wp_enqueue_script('fluentcrm_public_pref', fluentCrmMix('public/public_pref.js'), ['jquery'], FLUENTCRM_PLUGIN_VERSION, true);
wp_localize_script('fluentcrm_public_pref', 'fluentcrm_sub_pref', [
'ajaxurl' => admin_url('admin-ajax.php')
]);
return (string) fluentCrm('view')->make('external.pref_form', [
'fields' => $formFields,
'submitBtn' => [
'container_class' => 'fc_pref_submit',
'btn_text' => __('Update info', 'fluent-crm'),
'atts' => [
'type' => 'submit',
'id' => 'fluentcrm_preferences_submit',
'class' => 'btn fc_pref_submit'
]
],
'subscriber' => $subscriber
]);
}
public function handleDynamicContentShortCode($atts, $text = '')
{
if(!$text) {
return '';
}
$defaults = [
'hide_for_guest' => 'no'
];
$atts = shortcode_atts($defaults, $atts, 'fluentcrm_content');
$subscriber = FluentCrmApi('contacts')->getCurrentContact(true, true);
if(!$subscriber) {
if($atts['hide_for_guest'] == 'yes') {
return '';
}
return preg_replace_callback('/({{|##)+(.*?)(}}|##)/', function ($matches) {
if(isset($matches[2])) {
$token = $matches[2];
$tokens = explode('|', $token);
if(isset($tokens[1])) {
return $tokens[1];
}
}
return '';
}, $text);
}
return \FluentCrm\App\Services\Libs\Parser\Parser::parse($text, $subscriber);
}
public function handleAjax()
{
if (!isset($_POST['_fc_nonce']) || !wp_verify_nonce($_POST['_fc_nonce'], 'fluent_crm_account_form_fields')) {
wp_send_json_error([
'message' => __('Sorry, your nonce did not verify.', 'fluent-crm')
], 422);
}
$settings = Helper::getGlobalEmailSettings();
if (Arr::get($settings, 'pref_form') != 'yes' || empty(Arr::get($settings, 'pref_general'))) {
wp_send_json_error([
'message' => __('Sorry! You cannot update your profile.', 'fluent-crm')
], 422);
}
if (isset($_REQUEST['_fc_hash_secure']) && !is_user_logged_in()) {
$hash = sanitize_text_field($_REQUEST['_fc_hash_secure']);
if ($hash) {
$_COOKIE['fc_hash_secure'] = $hash;
}
}
$subscriber = FluentCrmApi('contacts')->getCurrentContact(false, true);
if (!$subscriber) {
wp_send_json_error([
'message' => __('Sorry! You cannot update your profile.', 'fluent-crm')
], 422);
}
$validInputs = $this->getFormFields($settings, $subscriber, [], true);
$validKeys = array_keys($validInputs);
$validData = Arr::only($_REQUEST, $validKeys);
if (empty($validData['email'])) {
$validData['email'] = $subscriber->email;
}
$errors = [];
foreach ($validInputs as $key => $input) {
if (Arr::get($input, 'required') && empty($validData[$key])) {
$errors[] = $key . ' is required';
}
// Handle array values for multi-select and checkboxes
if (isset($validData[$key]) && is_array($validData[$key])) {
$validData[$key] = array_map('sanitize_text_field', $validData[$key]);
} else {
$validData[$key] = sanitize_text_field(Arr::get($validData, $key, ''));
}
}
if (!empty($validData['date_of_birth']) && !$this->isValidDate($validData['date_of_birth'])) {
$errors[] = 'date_of_birth';
}
if ($errors) {
wp_send_json_error([
'message' => __('Please fill up all required fields', 'fluent-crm'),
'errors' => $errors,
'inputs' => $validData
], 422);
}
// Handle custom fields
$enabledCustomFieldSlugs = Arr::get($settings, 'pref_custom', []);
$allCustomFields = (new CustomFields)->getGlobalFields()['fields'];
if (!empty($allCustomFields) && !empty($enabledCustomFieldSlugs)) {
foreach ($allCustomFields as $field) {
$fieldKey = $field['slug'];
// Only process fields that are enabled in pref_custom
if (!in_array($fieldKey, $enabledCustomFieldSlugs)) {
continue;
}
if (isset($validData[$fieldKey])) {
$value = $validData[$fieldKey];
// Handle different field types
switch ($field['type']) {
case 'checkbox':
if (is_array($value)) {
$value = array_map('sanitize_text_field', $value);
}
break;
case 'select-multi':
if (is_array($value)) {
$value = array_map('sanitize_text_field', $value);
} else {
$value = [];
}
break;
case 'number':
$value = floatval($value);
break;
case 'textarea':
$value = isset($_POST[$fieldKey]) ? sanitize_textarea_field($_POST[$fieldKey]) : '';
break;
case 'date':
$value = sanitize_text_field($value);
break;
default:
$value = sanitize_text_field($value);
}
// Update the meta with proper type
$subscriber->updateMeta($field['slug'], $value, 'custom_field');
unset($validData[$fieldKey]); // Remove from main data
}
}
}
$subscriber->fill($validData);
$updateData = $subscriber->getDirty();
if($updateData) {
$subscriber->save();
}
if (isset($_REQUEST['lists'])) {
$publicLists = Helper::getPublicLists();
$publicListIds = [];
foreach ($publicLists as $publicList) {
$publicListIds[] = $publicList->id;
}
$selectedListIds = map_deep($_REQUEST['lists'], 'intval');
$attachLists = [];
$detachLists = [];
foreach ($subscriber->lists as $list) {
if (!in_array($list->id, $publicListIds)) {
continue;
}
if (!in_array($list->id, $selectedListIds)) {
$detachLists[] = $list->id;
}
}
foreach ($selectedListIds as $selectedListId) {
if (in_array($selectedListId, $publicListIds)) {
$attachLists[] = $selectedListId;
}
}
if ($attachLists) {
$subscriber->attachLists($attachLists);
}
if ($detachLists) {
$subscriber->detachLists($detachLists);
}
} else {
$listIds = $subscriber->lists()->get()->pluck('id')->toArray();
$subscriber->detachLists($listIds);
}
do_action('fluent_crm/pref_form_self_contact_updated', $subscriber, $_REQUEST);
if ($updateData) {
do_action('fluentcrm_contact_updated', $subscriber, $updateData);
do_action('fluent_crm/contact_updated', $subscriber, $updateData);
}
wp_send_json_success([
'message' => __('Your information has been updated', 'fluent-crm'),
'data' => $validData
], 200);
}
private function getFormFields($settings, $subscriber, $labels, $inputOnly = false)
{
$generalFields = Arr::get($settings, 'pref_general');
$customFields = Arr::get($settings, 'pref_custom');
$formFields = [];
if (array_intersect($generalFields, ['first_name', 'last_name'])) {
$nameFields = [];
if (in_array('prefix', $generalFields)) {
$nameFields['prefix'] = [
'type' => 'select',
'name' => 'prefix',
'container_class' => 'fc_name_prefix',
'id' => 'fc_name_prefix',
'label' => Arr::get($labels, 'prefix', 'Prefix'),
'placeholder' => '--',
'options' => Helper::getContactPrefixes(true),
'value' => $subscriber->prefix
];
}
if (in_array('first_name', $generalFields)) {
$nameFields['first_name'] = [
'type' => 'input',
'name' => 'first_name',
'id' => 'fc_first_name',
'atts' => [
'type' => 'text',
'placeholder' => __('First Name', 'fluent-crm')
],
'required' => true,
'label' => Arr::get($labels, 'first_name', 'First Name'),
'value' => $subscriber->first_name
];
}
if (in_array('last_name', $generalFields)) {
$nameFields['last_name'] = [
'type' => 'input',
'name' => 'last_name',
'id' => 'fc_last_name',
'atts' => [
'type' => 'text',
'placeholder' => __('Last Name', 'fluent-crm')
],
'required' => true,
'label' => Arr::get($labels, 'last_name', 'Last Name'),
'value' => $subscriber->last_name
];
}
$formFields['name'] = [
'type' => 'container',
'container_class' => 'fc_names fc_' . count($nameFields) . '_col',
'fields' => $nameFields
];
}
$formFields[] = [
'type' => 'raw_html',
'html' => '<div class="fc_2_col fc_email_phone_date">'
];
$formFields['email'] = [
'type' => 'input',
'name' => 'email',
'id' => 'fc_email',
'atts' => [
'type' => 'email',
'placeholder' => __('Email', 'fluent-crm'),
'disabled' => true
],
'required' => true,
'label' => Arr::get($labels, 'email', 'Email'),
'value' => $subscriber->email
];
if (in_array('phone', $generalFields)) {
$formFields['phone'] = [
'type' => 'input',
'name' => 'phone',
'id' => 'fc_phone',
'atts' => [
'type' => 'tel',
'placeholder' => __('Phone', 'fluent-crm')
],
'required' => false,
'label' => Arr::get($labels, 'phone', 'Phone/Mobile'),
'value' => $subscriber->phone
];
}
$formFields[] = [
'type' => 'raw_html',
'html' => '</div>'
];
if (in_array('date_of_birth', $generalFields)) {
$formFields['date_of_birth'] = [
'type' => 'date_dropdowns',
'name' => 'date_of_birth',
'id' => 'fc_date_of_birth',
'required' => false,
'label' => Arr::get($labels, 'dob', 'Date of Birth'),
'value' => $subscriber->date_of_birth
];
}
if (in_array('address_fields', $generalFields)) {
$formFields[] = [
'type' => 'raw_html',
'html' => '<h4 class="fc_address_info_heading">' . Arr::get($labels, 'address_heading', 'Address Information') . '</h4>'
];
/**
* Filter to modify the list of country names for the Preference Form Field in FluentCRM.
*
* This filter allows you to modify the list of country names used in FluentCRM.
*
* @since 2.7.0
*
* @param array An array of country names.
*/
$countryNames = apply_filters('fluent_crm/countries', []);
$formattedCountries = [];
foreach ($countryNames as $country) {
$formattedCountries[$country['code']] = $country['title'];
}
$formFields['address'] = [
'type' => 'container',
'container_class' => 'fc_addresses fc_2_col',
'fields' => [
'address_line_1' => [
'type' => 'input',
'name' => 'address_line_1',
'id' => 'fc_address_line_1',
'atts' => [
'type' => 'text',
'placeholder' => __('Address Line 1', 'fluent-crm')
],
'label' => Arr::get($labels, 'address_line_1', 'Address Line 1'),
'value' => $subscriber->address_line_1
],
'address_line_2' => [
'type' => 'input',
'name' => 'address_line_2',
'id' => 'fc_address_line_2',
'atts' => [
'type' => 'text',
'placeholder' => __('Address Line 2', 'fluent-crm')
],
'label' => Arr::get($labels, 'address_line_2', 'Address Line 2'),
'value' => $subscriber->address_line_2
],
'city' => [
'type' => 'input',
'name' => 'city',
'id' => 'fc_address_city',
'atts' => [
'type' => 'text',
'placeholder' => __('City', 'fluent-crm')
],
'label' => Arr::get($labels, 'city', 'City'),
'value' => $subscriber->city
],
'state' => [
'type' => 'input',
'name' => 'state',
'id' => 'fc_address_state',
'atts' => [
'type' => 'text',
'placeholder' => __('State', 'fluent-crm')
],
'label' => Arr::get($labels, 'state', 'State'),
'value' => $subscriber->state
],
'postal_code' => [
'type' => 'input',
'name' => 'postal_code',
'id' => 'fc_address_postal_code',
'atts' => [
'type' => 'text',
'placeholder' => __('Zip Code', 'fluent-crm')
],
'label' => Arr::get($labels, 'postal_code', 'Zip Code'),
'value' => $subscriber->postal_code
],
'country' => [
'type' => 'select',
'name' => 'country',
'id' => 'fc_address_country',
'placeholder' => __('Select Country', 'fluent-crm'),
'label' => Arr::get($labels, 'country', 'Country'),
'value' => $subscriber->country,
'options' => $formattedCountries
],
]
];
}
// Add custom fields section
if (!empty($customFields)) {
$allCustomFields = (new CustomFields)->getGlobalFields()['fields'];
$enabledCustomFields = [];
// Filter custom fields based on pref_custom settings
foreach ($allCustomFields as $field) {
if (in_array($field['slug'], $customFields)) {
$enabledCustomFields[] = $field;
}
}
if (!empty($enabledCustomFields)) {
$formFields[] = [
'type' => 'raw_html',
'html' => '<p class="fc_custom_fields_heading"></p>'
];
// Group fields by their group attribute
$groupedFields = [];
$ungroupedFields = [];
foreach ($enabledCustomFields as $field) {
if (!empty($field['group'])) {
$group = $field['group'];
if (!isset($groupedFields[$group])) {
$groupedFields[$group] = [];
}
$groupedFields[$group][] = $field;
} else {
$ungroupedFields[] = $field;
}
}
// Add ungrouped fields first
if (!empty($ungroupedFields)) {
$ungroupedContainer = [
'type' => 'container',
'container_class' => 'fc_custom_fields fc_2_col',
'fields' => []
];
foreach ($ungroupedFields as $field) {
$fieldType = $field['type'];
$fieldKey = $field['slug'];
$fieldConfig = $this->getCustomFieldConfig($field, $subscriber);
$ungroupedContainer['fields'][$fieldKey] = $fieldConfig;
}
$formFields['custom_fields_ungrouped'] = $ungroupedContainer;
}
// Create containers for each group
foreach ($groupedFields as $groupName => $fields) {
$customFieldsContainer = [
'type' => 'container',
'container_class' => 'fc_custom_fields fc_2_col fc_custom_field_group_box',
'fields' => []
];
$customFieldsContainer['fields']['group_heading'] = [
'type' => 'raw_html',
'html' => '<h5 class="fc_custom_field_group_heading">' . esc_html($groupName) . '</h5>'
];
foreach ($fields as $field) {
$fieldType = $field['type'];
$fieldKey = $field['slug'];
$fieldConfig = $this->getCustomFieldConfig($field, $subscriber);
$customFieldsContainer['fields'][$fieldKey] = $fieldConfig;
}
$formFields['custom_fields_' . sanitize_title($groupName)] = $customFieldsContainer;
}
}
}
if (!$inputOnly) {
return $formFields;
}
return $this->parseInputs($formFields);
}
private function parseInputs($fields)
{
$inputFields = [];
$inputTypes = ['hidden', 'input', 'checkboxes', 'select', 'radio', 'date', 'date_dropdowns', 'textarea', 'select-multi', 'custom_date', 'custom_date_time'];
foreach ($fields as $inputKey => $field) {
$type = Arr::get($field, 'type');
if ($type == 'container') {
$inputFields = array_merge($this->parseInputs($field['fields']), $inputFields);
} else if (in_array($type, $inputTypes)) {
$inputFields[$inputKey] = $field;
}
}
return $inputFields;
}
private function getCustomFieldConfig($field, $subscriber)
{
$fieldType = $field['type'];
$fieldKey = $field['slug'];
$fieldConfig = [
'type' => 'input',
'name' => $fieldKey,
'id' => 'fc_' . $fieldKey,
'label' => $field['label'],
'required' => !empty($field['required']),
'value' => $subscriber->getMeta($field['slug'], 'custom_field')
];
// Add field-specific configurations
switch ($fieldType) {
case 'text':
$fieldConfig['type'] = 'input';
$fieldConfig['atts'] = [
'type' => 'text',
'placeholder' => $field['label'],
'class' => 'fc_input_control'
];
break;
case 'textarea':
$fieldConfig['type'] = 'textarea';
$fieldConfig['atts'] = [
'placeholder' => $field['label'],
'class' => 'fc_input_control',
'name' => $fieldKey
];
break;
case 'number':
$fieldConfig['type'] = 'input';
$fieldConfig['atts'] = [
'type' => 'number',
'placeholder' => $field['label'],
'class' => 'fc_input_control'
];
break;
case 'select-one':
$fieldConfig['type'] = 'select';
$fieldConfig['options'] = array_combine($field['options'], $field['options']);
$fieldConfig['placeholder'] = $field['label'];
$fieldConfig['atts'] = [
'class' => 'fc_input_control select-one'
];
break;
case 'select-multi':
$fieldConfig['type'] = 'select-multi';
$fieldConfig['options'] = array_combine($field['options'], $field['options']);
$fieldConfig['value'] = is_array($fieldConfig['value']) ? $fieldConfig['value'] : [];
$fieldConfig['name'] = $fieldKey . '[]';
$fieldConfig['atts'] = [
'class' => 'fc_input_control select-multi',
'multiple' => 'multiple'
];
break;
case 'radio':
$fieldConfig['type'] = 'radio';
$fieldConfig['options'] = array_combine($field['options'], $field['options']);
$fieldConfig['atts'] = [
'class' => 'fc_input_control'
];
break;
case 'checkbox':
$fieldConfig['type'] = 'checkboxes';
$fieldConfig['options'] = is_array($field['options']) ? $field['options'] : [];
$fieldConfig['value'] = is_array($fieldConfig['value']) ? $fieldConfig['value'] : [];
$fieldConfig['atts'] = [
'class' => 'fc_input_control'
];
break;
case 'date':
$fieldConfig['type'] = 'custom_date';
$fieldConfig['atts'] = [
'type' => 'date',
'class' => 'fc_date_item fc_input_control',
'data-format' => 'YYYY-MM-DD',
'placeholder' => $field['label'],
'data-template' => 'DD - MM - YYYY'
];
break;
case 'date_time':
$fieldConfig['type'] = 'custom_date_time';
$fieldConfig['atts'] = [
'type' => 'text',
'class' => 'fc_date_item fc_input_control',
'data-format' => 'YYYY-MM-DD HH:mm:ss',
'placeholder' => $field['label'],
'data-template' => 'DD - MM - YYYY HH:mm'
];
break;
}
return $fieldConfig;
}
/**
* Check if a string is a valid calendar date in Y-m-d format.
*
* @param string $ymd Date string (e.g. 2024-02-31).
* @return bool
*/
private function isValidDate($ymd)
{
if (!is_string($ymd) || !preg_match('/^(\d{4})-(\d{2})-(\d{2})$/', $ymd, $parts)) {
return false;
}
return checkdate((int) $parts[2], (int) $parts[3], (int) $parts[1]);
}
}
@@ -0,0 +1,795 @@
<?php
namespace FluentCrm\App\Hooks\Handlers;
use FluentCrm\App\Models\Subscriber;
use FluentCrm\App\Services\Helper;
/**
* PurchaseHistory Class
*
* @package FluentCrm\App\Hooks
*
* @version 1.0.0
*/
class PurchaseHistory
{
/**
* Build the commerce summary widget for supported commerce providers.
*/
public function getCommerceStatWidget($subscriber)
{
/**
* Determine the commerce provider for the purchase history in FluentCRM.
*
* This filter allows you to modify the commerce provider used in FluentCRM.
*
* @since 2.8.0
*
* @param string $commerceProvider The current commerce provider.
* @return string The modified commerce provider.
*/
$commerceProvider = apply_filters('fluentcrm_commerce_provider', '');
if ($commerceProvider) {
/**
* Determine the purchase statistics for a specific commerce provider for a specific subscriber in FluentCRM.
*
* The dynamic portion of the hook name, `$commerceProvider`, refers to the specific commerce provider.
*
* @since 2.8.0
*
* @param array $stats An array of purchase statistics.
* @param int $subscriber->id The ID of the subscriber.
*/
$stats = apply_filters('fluent_crm/contact_purchase_stat_' . $commerceProvider, [], $subscriber->id);
if (!$stats) {
return false;
}
$html = '<ul class="fc_full_listed fcrm_customer_summary_list">';
foreach ($stats as $stat) {
$html .= '<li><span class="fc_list_sub">' . $stat['title'] . '</span> <span class="fc_list_value">' . $stat['value'] . '</span></li>';
}
$html .= '</ul>';
return [
'title' => __('Customer Summary', 'fluent-crm'),
'content' => $html
];
}
if (defined('WC_PLUGIN_FILE')) {
$summary = $this->getWooCustomerSummary($subscriber);
if ($summary) {
return [
'title' => __('Customer Summary', 'fluent-crm'),
'content' => $summary
];
}
return false;
}
if (Helper::isEdd3()) {
$customer = fluentCrmDb()->table('edd_customers')
->where('email', $subscriber->email);
if ($subscriber->user_id) {
$customer = $customer->orWhere('user_id', $subscriber->user_id);
}
$customer = $customer->first();
if (!$customer) {
return false;
}
$summaryData = [
'order_count' => $customer->purchase_count,
'lifetime_value' => number_format($customer->purchase_value, 2),
'avg_value' => ($customer->purchase_count) ? round($customer->purchase_value / $customer->purchase_count, 2) : 'n/a',
'stat_avg_count' => 0,
'stat_avg_spend' => 0,
'stat_avg_value' => 0,
'currency_sign' => edd_currency_symbol(),
'first_order_date' => $customer->date_created
];
$html = $this->formatSummaryData($summaryData, true);
return [
'title' => __('Customer Summary', 'fluent-crm'),
'content' => $html
];
}
return false;
}
public function wooOrders($data, $subscriber)
{
if (!defined('WC_PLUGIN_FILE')) {
return $data;
}
$hasRecount = defined('FLUENTCAMPAIGN') && \FluentCampaign\App\Services\Commerce\Commerce::isEnabled('woo');
$app = fluentCrm();
if ($hasRecount && $app->request->get('will_recount') == 'yes') {
(new \FluentCampaign\App\Services\Integrations\WooCommerce\DeepIntegration)->syncCustomerBySubscriber($subscriber);
}
$page = (int)$app->request->get('page', 1);
$per_page = (int)$app->request->get('per_page', 10);
$sort_by = sanitize_sql_orderby($app->request->get('sort_by', 'id'));
$sort_type = sanitize_sql_orderby($app->request->get('sort_type', 'DESC'));
$valid_columns = ['id', 'date_created', 'total_amount'];
$valid_directions = ['ASC', 'DESC'];
if (!in_array($sort_by, $valid_columns)) {
$sort_by = 'id';
}
if (!in_array(strtoupper($sort_type), $valid_directions)) {
$sort_type = 'DESC';
}
$orders = $this->getWooOrders($subscriber, $sort_by, $sort_type);
$totalOrders = count($orders);
$orders = array_slice($orders, ($page - 1) * $per_page, $per_page);
$formattedOrders = [];
foreach ($orders as $order) {
$item_count = $order->get_item_count() - $order->get_item_count_refunded();
$actionsHtml = '<a target="_blank" href="' . $order->get_edit_order_url() . '">
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M8.5 5.5V7H4.75V15.25H13V11.5H14.5V16C14.5 16.1989 14.421 16.3897 14.2803 16.5303C14.1397 16.671 13.9489 16.75 13.75 16.75H4C3.80109 16.75 3.61032 16.671 3.46967 16.5303C3.32902 16.3897 3.25 16.1989 3.25 16V6.25C3.25 6.05109 3.32902 5.86032 3.46967 5.71967C3.61032 5.57902 3.80109 5.5 4 5.5H8.5ZM16.75 3.25V9.25H15.25V5.80975L9.40525 11.6553L8.34475 10.5948L14.1888 4.75H10.75V3.25H16.75Z" fill="#525866"/>
</svg>
</a>';
$date = '<span class="order_id">'.'#' . $order->get_order_number().'</span><span class="order_date">'.wc_format_datetime($order->get_date_created()).'</span>';
$status = '<span class="fcrm_badge fcrm_badge_'.esc_attr($order->get_status()).'">'. Helper::getStatusText($order->get_status()) .'</span>';
$formattedOrders[] = [
'date' => wp_kses_post($date),
'status' => wp_kses_post($status),
/* translators: 1: formatted order total (with currency), 2: number of items */
'total' => wp_kses_post(sprintf(_n('%1$s for %2$s item', '%1$s for %2$s items', $item_count, 'fluent-crm'), $order->get_formatted_order_total(), $item_count)),
'action' => $actionsHtml,
];
}
/**
* Determine the WooCommerce purchase history sidebar HTML in FluentCRM.
*
* This filter allows customization of the HTML content displayed in the WooCommerce purchase sidebar.
*
* @since 2.7.0
*
* @param string The current HTML content of the sidebar.
* @param object $subscriber The subscriber object containing subscriber data.
* @param int $page The current page identifier as an integer.
*/
$sidebarHtml = apply_filters('fluent_crm/woo_purchase_sidebar_html', '', $subscriber, $page);
return [
'data' => $formattedOrders,
'sidebar_html' => $sidebarHtml,
'total' => $totalOrders,
'has_recount' => $hasRecount,
'columns_config' => [
'date' => [
'label' => __('Date', 'fluent-crm'),
'sortable' => true,
'key' => 'date_created_gmt'
],
'status' => [
'label' => __('Status', 'fluent-crm'),
],
'total' => [
'label' => __('Total', 'fluent-crm'),
'width' => '160px',
'sortable' => true,
'key' => 'total_amount'
],
'actions' => [
'label' => __('', 'fluent-crm'),
'width' => '50px'
]
]
];
}
public function getWooCustomerSummary($subscriber)
{
$customerQuery = fluentCrmDb()->table('wc_customer_lookup')
->where('email', $subscriber->email);
if ($subscriber->user_id) {
$customerQuery = $customerQuery->orWhere('user_id', $subscriber->user_id);
}
$customer = $customerQuery->first();
if ($customer) {
$statuses = wc_get_is_paid_statuses();
$statuses = array_map(function ($status) {
return 'wc-' . $status;
}, $statuses);
$orderStats = fluentCrmDb()->table('wc_order_stats')
->where('customer_id', $customer->customer_id)
->whereIn('status', $statuses)
->get();
if ($orderStats->isEmpty()) {
return false;
}
$lifetimeValue = 0;
$orderIds = [];
$firstOrderDate = null;
$lastOrderDate = null;
foreach ($orderStats as $order) {
if (!$firstOrderDate) {
$firstOrderDate = $order->date_created;
}
if (!$lastOrderDate) {
$lastOrderDate = $order->date_created;
}
if (strtotime($order->date_created) < strtotime($firstOrderDate)) {
$firstOrderDate = $order->date_created;
}
if (strtotime($order->date_created) > strtotime($lastOrderDate)) {
$lastOrderDate = $order->date_created;
}
$lifetimeValue += $order->total_sales;
$orderIds[] = $order->order_id;
}
$orderIds = array_unique($orderIds);
$orderCount = count($orderIds);
$data_store = \WC_Data_Store::load('report-customers-stats');
$stat = $data_store->get_data();
$avg_value = $orderCount > 0 ? round($lifetimeValue / $orderCount, 2) : 0;
$summaryData = [
'order_count' => $orderCount,
'lifetime_value' => $lifetimeValue,
'avg_value' => $avg_value,
'stat_avg_count' => $stat->avg_orders_count,
'stat_avg_spend' => $stat->avg_total_spend,
'stat_avg_value' => $stat->avg_avg_order_value,
'currency_sign' => get_woocommerce_currency_symbol(),
'last_order_date' => $lastOrderDate,
'first_order_date' => $firstOrderDate,
];
return $this->formatSummaryData($summaryData, true);
}
return false;
}
/**
* Return EDD 3 order history for the subscriber purchase-history panel.
*/
public function eddOrders($data, $subscriber)
{
if (!Helper::isEdd3()) {
return $data;
}
$app = fluentCrm();
$page = (int)$app->request->get('page', 1);
set_query_var('paged', $page);
$hasRecount = defined('FLUENTCAMPAIGN') && \FluentCampaign\App\Services\Commerce\Commerce::isEnabled('edd');
if ($hasRecount && $app->request->get('will_recount') == 'yes') {
(new \FluentCampaign\App\Services\Integrations\Edd\DeepIntegration)->syncCustomerBySubscriber($subscriber);
}
$sort_by = sanitize_sql_orderby($app->request->get('sort_by', 'id'));
$sort_type = sanitize_sql_orderby($app->request->get('sort_type', 'DESC'));
$per_page = (int)$app->request->get('per_page', 10);
$customer = new \EDD_Customer($subscriber->email);
if (!$customer || !$customer->id) {
return $data;
}
$lasOrderData = '';
/*
* EDD 3 stores orders in the edd_orders table. Legacy edd_payment posts
* are intentionally not queried because EDD 2 is no longer supported.
*/
$totalCount = fluentCrmDb()->table('edd_orders')
->where('customer_id', $customer->id)
->count();
if (!$totalCount) {
return $data;
}
$valid_columns = ['id', 'date_created', 'total'];
$valid_directions = ['ASC', 'DESC'];
if (!in_array($sort_by, $valid_columns)) {
$sort_by = 'id';
}
if (!in_array(strtoupper($sort_type), $valid_directions)) {
$sort_type = 'DESC';
}
$orders = fluentCrmDb()->table('edd_orders')
->where('customer_id', $customer->id)
->orderBy($sort_by, $sort_type)
->limit($per_page)
->offset(($page - 1) * $per_page)
->get();
$formattedOrders = [];
foreach ($orders as $order) {
$orderActionHtml = '<a target="_blank" href="' . add_query_arg('id', $order->id, admin_url('edit.php?post_type=download&page=edd-payment-history&view=view-order-details')) . '">
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M8.5 5.5V7H4.75V15.25H13V11.5H14.5V16C14.5 16.1989 14.421 16.3897 14.2803 16.5303C14.1397 16.671 13.9489 16.75 13.75 16.75H4C3.80109 16.75 3.61032 16.671 3.46967 16.5303C3.32902 16.3897 3.25 16.1989 3.25 16V6.25C3.25 6.05109 3.32902 5.86032 3.46967 5.71967C3.61032 5.57902 3.80109 5.5 4 5.5H8.5ZM16.75 3.25V9.25H15.25V5.80975L9.40525 11.6553L8.34475 10.5948L14.1888 4.75H10.75V3.25H16.75Z" fill="#525866"/>
</svg>
</a>';
$date = '<span class="order_id">'.'#' . $order->id .'</span><span class="order_date">'.date_i18n(get_option('date_format'), strtotime($order->date_created)).'</span>';
$status = '<span class="fcrm_badge fcrm_badge_'.esc_attr($order->status).'">'. Helper::getStatusText($order->status) .'</span>';
$formattedOrders[] = [
'date' => $date,
'status' => $status,
'total' => edd_currency_filter(edd_format_amount($order->total)),
'action' => $orderActionHtml
];
}
if (!$orders->isEmpty()) {
$lasOrderData = date_i18n(get_option('date_format'), strtotime($orders[0]->date_created));
}
/**
* Determine the HTML content displayed in the EDD purchase history sidebar for a subscriber in FluentCRM.
*
* This filter allows customization of the HTML content that appears in the EDD purchase
* history sidebar for a given subscriber on a specific page.
*
* @since 2.7.0
*
* @param string $beforeHtml The HTML content to be displayed before the purchase history.
* @param object $subscriber The subscriber object.
* @param int $page The current page identifier as an integer.
*/
$beforeHtml = apply_filters('fluent_crm/edd_purchase_sidebar_html', '', $subscriber, $page);
// if (!$beforeHtml && $subscriber->user_id && $page == 1 && $formattedOrders) {
// $summaryData = [
// 'order_count' => $customer->purchase_count,
// 'lifetime_value' => $customer->purchase_value,
// 'avg_value' => ($customer->purchase_count) ? round($customer->purchase_value / $customer->purchase_count, 2) : 'n/a',
// 'stat_avg_count' => 0,
// 'stat_avg_spend' => 0,
// 'stat_avg_value' => 0,
// 'currency_sign' => edd_currency_symbol(),
// 'last_order_date' => $lasOrderData
// ];
// $beforeHtml = $this->formatSummaryData($summaryData);
// }
return [
'data' => $formattedOrders,
'total' => $totalCount,
'sidebar_html' => $beforeHtml,
'after_html' => '<p><a target="_blank" rel="noopener" href="'.admin_url('edit.php?post_type=download&page=edd-customers&view=overview&id='.$customer->id).'">' . esc_html__('View Customer Profile', 'fluent-crm') . '</a></p>',
'has_recount' => $hasRecount,
'columns_config' => [
'order' => [
'label' => __('Order', 'fluent-crm'),
'width' => '100px',
'sortable' => true,
'key' => 'id'
],
'date' => [
'label' => __('Date', 'fluent-crm'),
'sortable' => true,
'key' => 'edd_orders'
],
'status' => [
'label' => __('Status', 'fluent-crm'),
'width' => '140px',
'sortable' => false
],
'total' => [
'label' => __('Total', 'fluent-crm'),
'width' => '120px',
'sortable' => true,
'key' => 'total'
],
'action' => [
'label' => __('Actions', 'fluent-crm'),
'width' => '100px',
'sortable' => false
]
]
];
}
public function payformSubmissions($data, $subscriber)
{
if (!defined('WPPAYFORM_VERSION')) {
return $data;
}
$app = fluentCrm();
$page = intval($app->request->get('page', 1));
$per_page = intval($app->request->get('per_page', 10));
$query = fluentCrmDb()->table('wpf_submissions')
->select([
'wpf_submissions.id',
'wpf_submissions.form_id',
'wpf_submissions.currency',
'wpf_submissions.payment_status',
'wpf_submissions.payment_total',
'wpf_submissions.payment_method',
'wpf_submissions.created_at',
'posts.post_title',
'wpf_subscriptions.recurring_amount',
])
->join('posts', 'posts.ID', '=', 'wpf_submissions.form_id')
->leftJoin('wpf_subscriptions', 'wpf_subscriptions.submission_id', '=', 'wpf_submissions.id')
->where(function ($query) use ($subscriber) {
$query->where('wpf_submissions.customer_email', '=', $subscriber->email);
if ($subscriber->user_id) {
$query->orWhere('wpf_submissions.user_id', '=', $subscriber->user_id);
}
})
// ->where('wpf_submissions.payment_total', '>', 0)
->limit($per_page)
->offset($per_page * ($page - 1))
->orderBy('wpf_submissions.id', 'desc');
$total = $query->count();
$submissions = $query->get();
$formattedSubmissions = [];
foreach ($submissions as $submission) {
$submissionUrl = admin_url('admin.php?page=wppayform.php#/edit-form/' . $submission->form_id . '/entries/' . $submission->id . '/view');
$actionUrl = '<a target="_blank" href="' . $submissionUrl . '">
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M8.5 5.5V7H4.75V15.25H13V11.5H14.5V16C14.5 16.1989 14.421 16.3897 14.2803 16.5303C14.1397 16.671 13.9489 16.75 13.75 16.75H4C3.80109 16.75 3.61032 16.671 3.46967 16.5303C3.32902 16.3897 3.25 16.1989 3.25 16V6.25C3.25 6.05109 3.32902 5.86032 3.46967 5.71967C3.61032 5.57902 3.80109 5.5 4 5.5H8.5ZM16.75 3.25V9.25H15.25V5.80975L9.40525 11.6553L8.34475 10.5948L14.1888 4.75H10.75V3.25H16.75Z" fill="#525866"/>
</svg>
</a>';
$paymentStatus = '<span class="fcrm_badge fcrm_badge_'.esc_attr($submission->payment_status).'">'. \FluentCrm\App\Services\Helper::getStatusText($submission->payment_status) .'</span>';
$formattedSubmissions[] = [
'id' => '#' . $submission->id,
'post_title' => $submission->post_title,
'recurring_amount' => $submission->recurring_amount ? wpPayFormFormatMoney($submission->recurring_amount, $subscriber->form_id) : wpPayFormFormatMoney($submission->payment_total, $subscriber->form_id),
'payment_status' => $paymentStatus,
'payment_method' => $submission->payment_method,
'created_at' => $submission->created_at,
'action' => $actionUrl
];
}
return [
'total' => $total,
'data' => $formattedSubmissions,
'columns_config' => [
'id' => [ 'label' => __('ID', 'fluent-crm'), 'width' => '100px', 'sortable' => false, 'key' => 'id'],
'post_title' => [ 'label' => __('Form Title', 'fluent-crm'), 'sortable' => false, 'key' => 'post_title'],
'recurring_amount' => [ 'label' => __('Payment Total', 'fluent-crm'), 'sortable' => false, 'key' => 'recurring_amount'],
'payment_status' => [ 'label' => __('Payment Status', 'fluent-crm'), 'sortable' => false, 'key' => 'payment_status'],
'payment_method' => [ 'label' => __('Payment Method', 'fluent-crm'), 'sortable' => false, 'key' => 'payment_method'],
'created_at' => [ 'label' => __('Submitted At', 'fluent-crm'), 'sortable' => false, 'key' => 'created_at']
]
];
}
public function formatSummaryData($data, $bodyOnly = false)
{
$blocks = [];
if (!empty($data['first_order_date'])) {
$blocks['Customer Since'] = gmdate(get_option('date_format'), strtotime($data['first_order_date']));
}
if (!empty($data['last_order_date'])) {
$blocks['Last Order'] = gmdate(get_option('date_format'), strtotime($data['last_order_date']));
}
$blocks['Order Count (paid)'] = $data['order_count'] . $this->getPercentChangeHtml($data['order_count'], $data['stat_avg_count']);
$blocks['Lifetime Value'] = $data['currency_sign'] . $data['lifetime_value'];
$blocks['AOV'] = $data['currency_sign'] . $data['avg_value'] . $this->getPercentChangeHtml($data['avg_value'], $data['stat_avg_value']);
$html = '<div class="fc_payment_summary"><h3 class="history_title">' . esc_html__("Customer Summary", "fluent-crm") . '</h3><div class="fc_history_widget">';
$body = '';
$body .= '<ul class="fc_full_listed">';
foreach ($blocks as $title => $block) {
$body .= '<li><span class="fc_list_sub">' . $title . '</span><span class="fc_list_value">' . $block . '</span></li>';
}
if (!empty($data['purchased_products'])) {
$body .= '<li><b>' . esc_html__("Purchased Products", "fluent-crm") . '</b><hr /><ul class="fc_list">';
foreach ($data['purchased_products'] as $product) {
$body .= '<li><a target="_blank" rel="nofollow" href="' . esc_url($product->guid) . '">' . esc_html($product->post_title) . '</a></li>';
}
$body .= '</ul></li>';
}
$body .= '</ul>';
if ($bodyOnly) {
return $body;
}
return $body . '</div></div>';
}
private function getPercentChangeHtml($value, $refValue)
{
if (!$refValue || !$value) {
return '';
}
$change = $value - $refValue;
$percentChange = absint(ceil($change / $refValue * 100));
if ($change >= 0) {
return '<span class="el-icon-caret-top fc_positive fc_change_ref">' . $percentChange . '%' . '</span>';
} else {
return '<span class="el-icon-caret-bottom fc_negative fc_change_ref">' . $percentChange . '%' . '</span>';
}
}
private function getWooOrders($subscriber, $sort_by, $sort_type)
{
$email = $subscriber->email;
$user = get_user_by('email', $email);
// check HPOS is enabled or not
if (get_option('woocommerce_custom_orders_table_enabled') === 'yes') {
// high performance order is enabled
if ($user) {
$hposOrders = fluentCrmDb()->table('wc_orders')
->where('status', '!=', 'trash')
->select(['id'])
->where(function ($query) use ($user) {
$query->where('customer_id', $user->ID)
->orWhere(function ($query) use ($user) {
$query->where('billing_email', $user->user_email)
->where('customer_id', 0);
});
})
->orderBy($sort_by, $sort_type)
->get();
} else {
$hposOrders = fluentCrmDb()->table('wc_orders')
->select(['id'])
->where('billing_email', $email)
->where('customer_id', 0)
->orderBy($sort_by, $sort_type)
->get();
}
if ($hposOrders->isEmpty()) {
return [];
}
$orders = [];
foreach ($hposOrders as $hposOrder) {
$order = wc_get_order($hposOrder->id);
if ($order) {
$orders[$hposOrder->id] = $order;
}
}
return array_values($orders);
}
$orders = [];
// Get all orders by user id
$storeUseId = $user ? $user->ID : false;
if ($storeUseId) {
$userOrders = wc_get_orders([
'customer_id' => $storeUseId,
'limit' => -1,
'orderby' => $sort_by,
'order' => $sort_type,
]);
// Sort orders by total amount manually
if ($sort_by === 'total_amount') {
$this->wooSortOrdersByTotalAmount($userOrders, $sort_type);
}
foreach ($userOrders as $order) {
$orders[$order->get_id()] = $order;
}
}
// get orders by billing email
$guestOrders = wc_get_orders([
'customer' => $email,
'limit' => -1,
'orderby' => $sort_by,
'order' => $sort_type,
]);
if ($sort_by === 'total_amount') {
$this->wooSortOrdersByTotalAmount($userOrders, $sort_type);
}
foreach ($guestOrders as $order) {
$userId = $order->get_user_id();
if ($userId && $storeUseId != $userId) {
continue;
}
$orders[$order->get_id()] = $order;
}
return array_values($orders);
}
/**
* Sorts an array of WooCommerce orders by their total amount.
*
* This method sorts an array of WooCommerce order objects based on the total order amount.
* The sorting can be done in either ascending ('ASC') or descending ('DESC') order,
* as specified by the $sort_type parameter.
*
* @param array $orders Array of WooCommerce order objects to be sorted.
* @param string $sort_type Specifies the sorting order.
* Accepts 'ASC' for ascending or 'DESC' for descending.
*
* @return void The $orders array is sorted in place.
*/
public function wooSortOrdersByTotalAmount(&$orders, $sort_type) {
usort($orders, function ($a, $b) use ($sort_type) {
$a_total = (float)$a->get_total();
$b_total = (float)$b->get_total();
return $sort_type === 'ASC' ? $a_total <=> $b_total : $b_total <=> $a_total;
});
}
public function pmproOrders($data, $subscriber)
{
if (!defined('PMPRO_VERSION')) {
return $data;
}
if (!defined('FLUENTCAMPAIGN')) {
return $data;
}
$customer = fluentCrmDb()->table('users')->where('user_email', $subscriber->email)->first();
if (!$customer) {
return false;
}
if (!$subscriber->user_id || $subscriber->user_id != $customer->ID) {
$subscriber->user_id = $customer->ID;
$subscriber->save();
}
$app = fluentCrm();
$page = (int)$app->request->get('page', 1);
$per_page = (int)$app->request->get('per_page', 10);
$sort_by = sanitize_sql_orderby($app->request->get('sort_by', 'ID'));
$sort_type = sanitize_sql_orderby($app->request->get('sort_type', 'DESC'));
$valid_columns = ['ID', 'date', 'modified'];
$valid_directions = ['ASC', 'DESC'];
if (!in_array($sort_by, $valid_columns)) {
$sort_by = 'ID';
}
if (!in_array(strtoupper($sort_type), $valid_directions)) {
$sort_type = 'DESC';
}
// Fetch the array of MemberOrder OBJECTS
$user_order_objects = \MemberOrder::get_orders([
'user_id' => $subscriber->user_id,
'status' => 'success',
'orderby' => $sort_by,
'order' => $sort_type
]);
// Create a new, simple array to hold the data for JSON conversion
$formattedOrders = [];
$totalOrders = count($user_order_objects);
$orders = array_slice($user_order_objects, ($page - 1) * $per_page, $per_page);
if (!empty($orders)) {
// Loop through each PHP object and extract its data into a simple array
foreach ($orders as $order) {
// Construct the URL using WordPress's admin_url() function
$order_page_url = admin_url('admin.php?page=pmpro-orders&order=' . $order->id);
$level = pmpro_getLevel($order->membership_id);
$actionsHtml = '<td><a href="' . esc_url($order_page_url) . '" target="_blank">View Order</a></td>';
$status = '<span class="fcrm_badge fcrm_badge_'.esc_attr($order->status).'">'. \FluentCrm\App\Services\Helper::getStatusText($order->status) .'</span>';
$formattedOrders[] = [
'order_code' => '#' . $order->code,
'membership_level_name' => $level ? $level->name : null,
'status' => $status,
'total' => $order->total,
'date' => gmdate('j F, Y', $order->timestamp),
'gateway' => $order->gateway,
'actions' => $actionsHtml
];
}
}
return [
'data' => $formattedOrders,
'sidebar_html' => '',
'total' => $totalOrders,
'has_recount' => false,
'columns_config' => [
'order_code' => [
'label' => __('Order Code', 'fluent-crm'),
'width' => '120px',
'sortable' => false,
'key' => 'id'
],
'membership_level_name' => [
'label' => __('Membership Level', 'fluent-crm'),
'sortable' => false,
],
'date' => [
'label' => __('Date', 'fluent-crm'),
'sortable' => false,
'key' => 'date_created_gmt'
],
'status' => [
'label' => __('Status', 'fluent-crm'),
'width' => '100px'
],
'total' => [
'label' => __('Total', 'fluent-crm'),
'width' => '130px',
'sortable' => false,
'key' => 'total_amount'
],
'gateway' => [
'label' => __('Gateway', 'fluent-crm'),
'width' => '120px',
'sortable' => false,
],
'actions' => [
'label' => __('Actions', 'fluent-crm'),
'width' => '100px'
]
]
];
}
}
@@ -0,0 +1,184 @@
<?php
namespace FluentCrm\App\Hooks\Handlers;
use FluentCrm\App\Models\Campaign;
use FluentCrm\App\Models\CampaignEmail;
use FluentCrm\App\Models\CampaignUrlMetric;
use FluentCrm\App\Models\UrlStores;
use FluentCrm\Framework\Support\Arr;
/**
* RedirectionHandler Class
*
* @package FluentCrm\App\Hooks
*
* @version 1.0.0
*/
class RedirectionHandler
{
public function redirect($data)
{
nocache_headers();
$mailId = false;
$urlSlug = sanitize_text_field($data['ns_url']);
if (isset($data['mid'])) {
$mailId = intval($data['mid']);
}
$urlData = fluentCrmGetFromCache('url_' . $urlSlug, function () use ($urlSlug) {
return UrlStores::getRowByShort($urlSlug);
});
if (!$urlData) {
return;
}
if (isset($data['fch'])) {
$urlData->url_token = $data['fch'];
}
$isAnonymousClick = isset($data['ano']);
$redirectUrl = trim($this->trackUrlClick($mailId, $urlData, $isAnonymousClick));
$redirectUrl = htmlspecialchars_decode($redirectUrl);
if (!$redirectUrl) {
wp_redirect(home_url(), 307);
exit;
}
// remove zero width space
$redirectUrl = str_replace(["\xE2\x80\x8B", '%E2%80%8B'], '', $redirectUrl);
do_action('fluentcrm_email_url_click', $redirectUrl, $mailId, $urlData);
wp_redirect($redirectUrl, 307);
exit;
}
public function trackUrlClick($mailId, $urlData, $isAnonymousClick = false)
{
if (!$mailId) {
return $urlData->url;
}
$campaignEmail = CampaignEmail::with(['subscriber'])->find($mailId);
if (!$campaignEmail || !$campaignEmail->subscriber) {
return $urlData->url;
}
$campaign = fluentCrmGetFromCache('campaign_' . $campaignEmail->campaign_id, function () use ($campaignEmail) {
return Campaign::withoutGlobalScopes()->find($campaignEmail->campaign_id);
});
if (!$campaign) {
return $urlData->url;
}
// Require valid fch token before recording any tracking data.
// Missing or invalid token = redirect but don't record metrics.
// This prevents analytics poisoning via forged or guessed mid values.
if (empty($urlData->url_token) || substr($campaignEmail->email_hash, 0, 8) !== $urlData->url_token) {
return $urlData->url;
}
if (!$campaignEmail->is_open && !$isAnonymousClick) {
do_action('fluent_crm/email_opened', $campaignEmail);
}
if (!$isAnonymousClick) {
CampaignUrlMetric::maybeInsert([
'url_id' => $urlData->id,
'campaign_id' => $campaignEmail->campaign_id,
'subscriber_id' => $campaignEmail->subscriber_id,
'type' => 'click',
'ip_address' => FluentCrm('request')->getIp(fluentCrmWillAnonymizeIp())
]);
}
$url = $urlData->url;
$url = str_replace('&amp;', '&', $url);
$url = esc_url_raw($url);
$isSmartUrl = strpos($url, 'route=smart_url');
$tokenVerified = false;
/**
* Filter whether to use cookies for FluentCRM redirection.
*
* This filter allows you to control whether cookies should be used for tracking
* FluentCRM redirection. By default, it is set to true.
*
* @param bool Whether to use cookies for redirection. Default true.
* @since 2.8.44
*
*/
if (apply_filters('fluent_crm/will_use_cookie', true) && !empty($urlData->url_token)) {
// validate the URL token here
if (substr($campaignEmail->email_hash, 0, 8) === $urlData->url_token) {
$tokenVerified = true;
$secureHash = fluentCrmGetContactSecureHash($campaignEmail->subscriber_id);
setcookie("fc_hash_secure", $secureHash, time() + 7776000, COOKIEPATH, COOKIE_DOMAIN, is_ssl(), true); /* expire in 90 days */
$_COOKIE['fc_hash_secure'] = $secureHash;
}
if ($campaignEmail->campaign_id) {
setcookie("fc_cid", $campaignEmail->campaign_id, time() + 2419200, COOKIEPATH, COOKIE_DOMAIN, is_ssl(), true); /* expire in 28 days */
}
}
do_action('fluent_crm/email_url_clicked', $campaignEmail, $urlData);
$args = $campaign->getUtmParams();
if (!$isAnonymousClick) {
$campaignEmail->click_counter += 1;
$campaignEmail->is_open = 1;
$campaignEmail->save();
} else {
do_action('fluent_crm/anonymous_email_url_clicked', $url, $campaign, $campaignEmail);
}
do_action('fluent_crm/track_activity_by_subscriber', $campaignEmail->subscriber);
if ($isSmartUrl) {
// this is a smart URL
$url_components = wp_parse_url($url);
parse_str($url_components['query'], $params);
if (!empty($params['slug'])) {
$subscriber = $campaignEmail->subscriber;
$signedHash = Arr::get($_REQUEST, 'signed_hash');
$isSecure = $tokenVerified && $signedHash && \FluentCrm\App\Services\Helper::verifySmartUrlHash($campaignEmail->email_hash, $signedHash);
if ($isSecure) {
do_action('fluent_crm/smart_link_verified', $subscriber);
}
do_action('fluentcrm_smartlink_clicked_direct', sanitize_text_field($params['slug']), $subscriber, $campaignEmail);
}
}
if (strpos($urlData->url, 'route=bnu') !== false) {
$url_components = wp_parse_url($url);
parse_str($url_components['query'], $params);
if (!empty($params['aid'])) {
$benchmarkActionId = intval($params['aid']);
// Note: hook name has a known typo (missing 't' in 'fluent') — kept for backward compatibility with Pro
do_action('fluencrm_benchmark_link_clicked', $benchmarkActionId, $campaignEmail->subscriber);
}
$args['bnu_timer_' . time()] = time();
}
if ($args) {
$url = add_query_arg($args, $url);
}
return $url;
}
}
@@ -0,0 +1,594 @@
<?php
namespace FluentCrm\App\Hooks\Handlers;
use FluentCrm\App\Models\Campaign;
use FluentCrm\App\Models\CampaignEmail;
use FluentCrm\App\Services\CampaignProcessor;
use FluentCrm\App\Services\ExternalIntegrations\Maintenance;
use FluentCrm\App\Services\Helper;
use FluentCrm\App\Services\Libs\FileSystem;
use FluentCrm\App\Services\Libs\Mailer\Handler;
use FluentCrm\App\Services\Libs\Mailer\MultiThreadHandler;
/**
* Scheduler Class
*
* @package FluentCrm\App\Hooks
*
* @version 1.0.0
*/
class Scheduler
{
public static function register()
{
/*
* Migrating from CRON to Action Scheduler for Every Minutes Tasks
*/
add_action('fluentcrm_scheduled_minute_tasks', function () {
// Auto-migration: ensure the Action Scheduler recurring action exists.
if (!as_has_scheduled_action('fluentcrm_scheduled_every_minute_tasks', [], 'fluent-crm')) {
Helper::debugLog('Migrating Every Minute CRON to Action Scheduler for FluentCRM');
as_schedule_recurring_action(time(), 60, 'fluentcrm_scheduled_every_minute_tasks', [], 'fluent-crm');
return;
}
// WP-Cron is a TRUE fallback: only take over when Action Scheduler
// has actually stalled. _fcrm_last_scheduler is written by
// Scheduler::process() on every successful AS-driven minute tick;
// if it's fresh, AS owns this minute and we no-op here.
$lastScheduler = fluentCrmGetOptionCache('_fcrm_last_scheduler');
if ($lastScheduler && (time() - $lastScheduler) <= 70) {
return;
}
// AS appears stalled (or has never run on this site yet) — take
// over via the same locked entry point AS uses. The atomic lock
// inside process() prevents two concurrent WP-Cron runners both
// deciding to take over from racing each other.
self::process();
});
// This is required to instantly send emails for regular email handler.
// The atomic lock inside Handler::isSystemOk() (acquired before any
// expensive work) is the authoritative guard against concurrent and
// duplicate sends, so we invoke the sender directly — a losing racer
// bails cheaply at the lock. No cron-timing pre-check is needed here.
add_action('wp_ajax_nopriv_fluentcrm-post-campaigns-send-now', function () {
(new \FluentCrm\App\Services\Libs\Mailer\Handler())->handle();
nocache_headers();
wp_send_json_success([
'message' => 'success',
'timestamp' => time()
]);
});
// For Multi Threaded Emails Internal Ajax. Same as above — the atomic
// lock inside MultiThreadHandler::isSystemOk() guards against concurrent
// runners, so we call the handler directly and let the loser bail at the
// lock. The experimental-flag check stays here to avoid constructing the
// handler at all when multi-threading is disabled.
add_action('wp_ajax_nopriv_fluentcrm-post-multi-thread-send-now', function () {
if (Helper::isExperimentalEnabled('multi_threading_emails')) {
(new MultiThreadHandler())->handle();
}
nocache_headers();
wp_send_json_success([
'message' => 'success',
'timestamp' => time()
]);
});
add_action('fluentcrm_scheduled_every_minute_tasks', array(__CLASS__, 'process'));
add_action('fluentcrm_scheduled_hourly_tasks', array(__CLASS__, 'processHourly'));
add_action('fluentcrm_scheduled_five_minute_tasks', array(__CLASS__, 'processFiveMinutes'));
add_action('fluentcrm_process_contact_jobs', array(__CLASS__, 'processForSubscriber'), 999, 1);
add_action('fluentcrm_scheduled_weekly_tasks', array(__CLASS__, 'processWeekly'));
add_action('fluent_crm_send_multi_thread_emails', array(__CLASS__, 'processMultiThreadEmails'), 10);
add_action('fluent_crm_cancel_multi_thread_mailing', function () {
as_unschedule_all_actions('fluent_crm_send_multi_thread_emails');
return true;
});
/*
* Clean up schedule that means removing from database- tasks by action scheduler
* Clean up before last 7 days logs generated by action scheduler
* this action will be triggered daily and will remove all the logs generated before 7 days
*/
add_action('fluent_crm_ascheduler_runs_daily', function () {
Cleanup::maybeRemoveOldScheuledActionLogs();
});
}
public static function process()
{
wp_raise_memory_limit('admin');
// In-process re-entrance guard (cheap; complements the cross-process lock below).
if (did_action('fluentcrm_process_scheduled_tasks_init')) {
return false;
}
// Atomic cross-process mutex. Prevents concurrent AS + WP-Cron + AJAX
// runners from all reaching Handler->handle() at the same time. The
// downstream BaseHandler also has its own lock — this outer guard
// avoids wasted PHP bootstraps for the loser of the race.
if (!self::acquireLock('minute_scheduler', 90)) {
return false;
}
try {
// _fcrm_last_scheduler stays as the success-timestamp signal used
// by the WP-Cron fallback in register() to detect a stalled Action
// Scheduler. It is no longer the gate that prevents re-entry —
// that role belongs to the atomic lock above.
fluentCrmSetOptionCache('_fcrm_last_scheduler', time(), 50);
do_action('fluentcrm_process_scheduled_tasks_init');
(new Handler)->handle();
} finally {
self::releaseLock('minute_scheduler');
}
return true;
}
/**
* Browser-ping fallback for the every-minute task.
*
* Triggered from the admin app's periodic ping (ReportingController::ping,
* fired ~every 50s while any CRM page is open). It is a TRUE last-resort
* fallback: it only takes over when Action Scheduler (and the WP-Cron
* fallback) have stalled, detected by the same _fcrm_last_scheduler
* freshness signal the WP-Cron fallback in register() uses. When AS is
* healthy this returns after a single option read, so it is safe to call on
* every ping and for every admin who has the dashboard open — it does NOT
* run cron more often than once per minute on a healthy site.
*
* All concurrency safety lives in process(): its atomic cross-process lock
* means that even with many tabs/users pinging at once, at most one runner
* sends emails, and the _fcrm_last_scheduler stamp written there throttles
* takeovers to roughly once per minute. This only advances the minute task
* (the email-sending pipeline); the heavier hourly/five-minute tasks keep
* their own WP-Cron/AS schedules.
*
* @return bool True if it took over and ran the minute task, false otherwise.
*/
public static function maybeProcessFromBrowserPing()
{
// Action Scheduler owns this task; only step in when it has actually
// stalled. Same 70s threshold as the WP-Cron fallback in register().
$lastScheduler = fluentCrmGetOptionCache('_fcrm_last_scheduler');
if ($lastScheduler && (time() - $lastScheduler) <= 70) {
return false;
}
return self::process();
}
public static function processForSubscriber($subscriber)
{
if (!is_object($subscriber) || empty($subscriber->id)) {
return false;
}
if (!defined('FLUENTCRM_DOING_BULK_IMPORT')) {
// @todo: Implement this immediately
(new Handler)->processSubscriberEmail($subscriber->id);
}
return true;
}
public static function processHourly()
{
// Atomic mutex. Closes the duplicate-event leak in markArchiveCampaigns():
// without this, two concurrent hourly runners both pass the SELECT,
// both UPDATE rows to 'archived' (idempotent), and both fire
// fluent_crm/campaign_archived for the same campaign — causing
// listeners (webhooks, metrics, notifications) to fire twice.
if (!self::acquireLock('hourly_scheduler', 300)) {
return;
}
try {
self::markArchiveCampaigns();
self::maybeCleanupCsvFiles();
do_action('fluent_crm_process_automation');
} finally {
self::releaseLock('hourly_scheduler');
}
}
public static function markArchiveCampaigns()
{
// get the scheduled or working campaigns where scheduled_at is five minutes ago
$campaigns = Campaign::whereIn('status', ['working', 'scheduled'])
->whereDoesntHave('emails', function ($query) {
$query->whereIn('status', ['scheduling', 'pending', 'scheduled', 'processing', 'draft']);
return $query;
})
->withoutGlobalScope('type')
->whereIn('type', fluentCrmAutoProcessCampaignTypes())
->where('scheduled_at', '<', gmdate('Y-m-d H:i:s', current_time('timestamp') - 300))
->get();
if (!$campaigns->isEmpty()) {
Campaign::whereIn('id', array_unique($campaigns->pluck('id')->toArray()))
->withoutGlobalScope('type')
->update([
'status' => 'archived'
]);
foreach ($campaigns as $campaign) {
do_action('fluent_crm/campaign_archived', $campaign);
}
return true;
}
return false;
}
/**
* @return void
*/
public static function processWeekly()
{
(new Maintenance())->maybeProcessData();
// Clear email_body from historical 'sent' rows to reclaim disk space.
// Loop a LIMIT-bounded UPDATE so each statement's row-lock footprint
// stays small (an unbounded UPDATE on a multi-million-row table holds
// locks for minutes and stalls report/dashboard SELECTs) while still
// draining the full backlog in this tick. Going direct to $wpdb skips
// ORM overhead on what is effectively the same repeated statement.
try {
global $wpdb;
$table = $wpdb->prefix . 'fc_campaign_emails';
$chunkSize = 50000;
$maxIterations = 100; // safety cap — up to ~5M rows per weekly tick
for ($i = 0; $i < $maxIterations; $i++) {
$affected = (int) $wpdb->query(
"UPDATE {$table} SET email_body = '' WHERE status = 'sent' AND email_body != '' LIMIT {$chunkSize}"
);
if ($affected < $chunkSize || fluentCrmIsMemoryExceeded()) {
break;
}
}
} catch (\Exception $e) {
Helper::debugLog('processWeekly', 'email_body cleanup deferred: ' . $e->getMessage(), 'extended');
}
}
/**
* Discover and process pending campaigns.
*
* Called by cron/Action Scheduler. Handles housekeeping (stale email reset),
* finds campaigns ready to process, and kicks off processing. For continuous
* processing, use processCampaignById() via the AJAX handler.
*
* @return bool
*/
public static function processFiveMinutes()
{
// Cheap time-based pre-check — skips the lock-acquire round trip when
// the function is called more frequently than the work needs to run.
$lastRun = fluentCrmGetOptionCache('_fcrm_last_five_minutes_run', 30);
if ($lastRun && (time() - $lastRun) < 60) {
return false;
}
// Atomic mutex. The throttle above is non-atomic so two near-simultaneous
// callers can both pass it; the lock guarantees that only one actually
// proceeds into discovery + processing.
if (!self::acquireLock('five_minute_scheduler', 180)) {
return false;
}
try {
fluentCrmSetOptionCache('_fcrm_last_five_minutes_run', time(), 60);
self::resetStaleProcessingEmails(100, 'processFiveMinutes');
$cutOutTime = gmdate('Y-m-d H:i:s', current_time('timestamp') + 360);
$campaigns = Campaign::whereIn('status', ['pending-scheduled', 'processing'])
->withoutGlobalScope('type')
->whereIn('type', fluentCrmAutoProcessCampaignTypes())
->orderBy('scheduled_at', 'ASC')
->where('scheduled_at', '<=', $cutOutTime)
->limit(2)
->get();
if ($campaigns->isEmpty()) {
do_action('fluent_crm_process_automation');
do_action('fluentcrm_scheduled_hourly_tasks');
return false;
}
$firstCampaign = $campaigns->first();
if ($firstCampaign->status == 'pending-scheduled') {
$firstCampaign->status = 'processing';
$firstCampaign->save();
}
$result = self::processCampaignById($firstCampaign->id);
// If first campaign is done and there are more queued, chain the next one.
// Skip if memory is low (aborted) to avoid cascading failures.
if (!$result && count($campaigns) > 1 && !fluentCrmIsMemoryExceeded()) {
// Verify first campaign actually finished (not just aborted)
$firstCampaign = Campaign::withoutGlobalScope('type')->find($firstCampaign->id);
if ($firstCampaign && $firstCampaign->status != 'processing') {
$nextCampaign = $campaigns->last();
if ($nextCampaign->status == 'pending-scheduled') {
$nextCampaign->status = 'processing';
$nextCampaign->save();
}
self::fireCampaignProcessingChain($nextCampaign->id);
}
}
return $result;
} finally {
self::releaseLock('five_minute_scheduler');
}
}
/**
* Reset rows stuck in 'processing' back to 'pending' so they get re-claimed.
*
* An unbounded mass UPDATE on (status='processing' AND updated_at < cutoff)
* locks a wide range and deadlocks against the row-level SELECT ... FOR
* UPDATE claims that the mailer Handler / MultiThreadHandler hold while
* sending. We instead drain in bounded chunks by primary key.
*
* We deliberately do NOT order the SELECT: ORDER BY id would push MySQL
* onto PRIMARY (full id-walk looking for sparse matches on a multi-million
* row table) instead of the (status, scheduled_at) index, which contains
* only the small currently-'processing' slice. Each chunk drains rows out
* of the predicate, so the next iteration naturally finds different rows
* without an explicit order.
*
* Any deadlock that still slips through is harmless — remaining rows will
* be picked up on the next caller's tick.
*
* @param int $maxAgeSeconds Rows older than this (in 'processing') get reset.
* @param string $callerContext Used in the deferred-log message.
* @return int Number of rows reset back to pending.
*/
public static function resetStaleProcessingEmails($maxAgeSeconds = 100, $callerContext = '')
{
try {
// If a sender lock is still fresh, a batch is likely active or just
// yielded. Resetting 'processing' rows during that window risks
// requeueing work owned by the live sender and increases row-lock
// contention with SELECT ... FOR UPDATE / sent-status updates.
if (self::hasFreshEmailSenderLock($maxAgeSeconds)) {
return 0;
}
$staleCutoff = gmdate('Y-m-d H:i:s', current_time('timestamp') - (int) $maxAgeSeconds);
$chunkSize = 200;
$maxChunks = 50; // up to 10k rows per call; subsequent calls drain the rest
$recovered = 0;
for ($i = 0; $i < $maxChunks; $i++) {
$staleIds = CampaignEmail::where('status', 'processing')
->where('updated_at', '<', $staleCutoff)
->limit($chunkSize)
->pluck('id')
->toArray();
if (empty($staleIds)) {
break;
}
$updated = CampaignEmail::whereIn('id', $staleIds)
->where('status', 'processing')
->update([
'status' => 'pending'
]);
if ($updated === false) {
global $wpdb;
Helper::debugLog($callerContext ?: 'resetStaleProcessingEmails', 'Stale email reset deferred: ' . $wpdb->last_error, 'extended');
break;
}
$recovered += (int) $updated;
if (count($staleIds) < $chunkSize || fluentCrmIsMemoryExceeded()) {
break;
}
}
if ($recovered) {
Helper::debugLog($callerContext ?: 'resetStaleProcessingEmails', 'Recovered ' . $recovered . ' stale processing emails older than ' . (int) $maxAgeSeconds . ' seconds', 'extended');
}
return $recovered;
} catch (\Exception $e) {
Helper::debugLog($callerContext ?: 'resetStaleProcessingEmails', 'Stale email reset deferred: ' . $e->getMessage(), 'extended');
return 0;
}
}
/**
* Avoid stale-row recovery while a sender still appears active.
*
* Sender locks are refreshed by BaseHandler::refreshLock() between claimed
* batches. We check all sender lock keys because regular, multi-threaded,
* and CLI senders can all own rows in fc_campaign_emails.
*
* @param int $maxAgeSeconds
* @return bool
*/
private static function hasFreshEmailSenderLock($maxAgeSeconds)
{
// Use at least 60 seconds so a very small caller-provided stale window
// does not make recovery race an otherwise healthy sender.
$freshWindow = max(60, (int) $maxAgeSeconds);
// Compare everything against one timestamp for consistent decisions
// across all sender lock keys checked below.
$now = time();
foreach (['fluentcrm_is_sending_emails', 'fluentcrm_is_sending_multi_emails', 'fluentcrm_is_sending_cli_emails'] as $lockKey) {
// Read the lock straight from its wp_options row. BaseHandler's
// acquireLock()/refreshLock() store the timestamp there via
// Helper::acquireDbLock()/refreshDbLock() on every environment, so we
// must NOT use getInstantOption() here: on object-cache sites it reads
// the fc_instant_options group, which the DB lock never writes to, and
// would miss a live sender — letting recovery reset its rows.
$lockedAt = Helper::getDbLockTimestamp($lockKey);
// A non-empty timestamp inside the freshness window means a sender
// appears active, so stale recovery should defer to the next tick.
if ($lockedAt && ($now - $lockedAt) <= $freshWindow) {
return true;
}
}
// No fresh sender lock was found. Recovery may safely inspect stale rows.
return false;
}
/**
* Process a specific campaign by ID.
*
* Can be called directly from the AJAX handler for continuous chaining
* without re-discovering campaigns or running housekeeping.
*
* @param int $campaignId
* @return bool True if more processing is needed, false if done.
*/
public static function processCampaignById($campaignId)
{
// Per-campaign scheduler lock. processCampaignById has two entry points
// — the AJAX self-trigger fluentcrm-post-campaigns-emails-processing
// (which bypasses processFiveMinutes' scheduler-level lock entirely)
// and processFiveMinutes itself (which holds five_minute_scheduler).
// Without this guard, fireCampaignProcessingChain could pile up
// overlapping AJAX requests for the same campaign that all reach
// CampaignProcessor and bail at its per-campaign lock — wasted PHP
// bootstraps. Lock name is per-campaign so different campaigns still
// process in parallel. TTL matches the set_time_limit(120) below.
$lockName = 'campaign_chain_' . (int)$campaignId;
if (!self::acquireLock($lockName, 120)) {
return false;
}
try {
if (function_exists('set_time_limit')) {
@set_time_limit(120);
}
$campaign = Campaign::withoutGlobalScope('type')->find($campaignId);
if (!$campaign) {
return false;
}
$campaignProcessingChunk = (int)apply_filters('fluent_crm/five_minute_campaign_processing_chunk', 20, $campaign);
if ($campaignProcessingChunk < 1) {
$campaignProcessingChunk = 1;
}
$runTime = fluentCrmMaxRunTime() - 5;
$campaign = (new CampaignProcessor($campaignId))->processEmails($campaignProcessingChunk, $runTime);
if (fluentCrmIsMemoryExceeded()) {
return false;
}
if ($campaign && $campaign->status == 'processing') {
self::fireCampaignProcessingChain($campaignId);
return true;
}
return false;
} finally {
self::releaseLock($lockName);
}
}
/**
* Fire a background AJAX request to continue processing a specific campaign.
*
* @param int $campaignId
*/
private static function fireCampaignProcessingChain($campaignId)
{
$url = add_query_arg([
'action' => 'fluentcrm-post-campaigns-emails-processing',
'campaign_id' => $campaignId,
'time' => time()
], admin_url('admin-ajax.php'));
\FluentCrm\App\Services\Libs\Mailer\Handler::fireNonBlockingRequest($url, [
'retry' => 1
]);
}
public static function maybeCleanupCsvFiles()
{
$dir = FileSystem::getDir();
// loop through files in directory
foreach (glob($dir . '/fluentcrm-*.csv') as $filename) {
// check if file was created before last 30 minutes
if (time() - filectime($filename) >= 1800) {
wp_delete_file($filename); // delete file
}
}
}
public static function processMultiThreadEmails()
{
(new MultiThreadHandler())->handle();
return true;
}
/**
* Atomically claim a scheduler-level lock so two runners can't enter the
* same critical section concurrently (e.g. Action Scheduler + WP-Cron
* minute ticks landing in the same second).
*
* Backed by a conditional UPDATE on wp_options keyed off a timestamp
* (Helper::acquireDbLock). The UPDATE succeeds only if the row is unclaimed
* or its stored timestamp is older than $ttl, so a crashed runner's lock
* self-recovers after the TTL. This is used on every environment — we no
* longer take a wp_cache_add() fast path, because that primitive is not
* atomic under all object-cache drop-ins (e.g. LiteSpeed), which let
* concurrent runners all acquire the same lock. See Helper::acquireDbLock().
*
* @param string $name Lock identifier appended to the option key.
* @param int $ttl Seconds before a held lock is considered abandoned.
* @return bool True if the lock was acquired by this process.
*/
private static function acquireLock($name, $ttl)
{
return Helper::acquireDbLock('_fluentcrm_lock_' . $name, $ttl);
}
/**
* Release a scheduler-level lock previously acquired by acquireLock().
* Safe to call even if the lock was not held by this process — the worst
* case is freeing the slot a tick early.
*/
private static function releaseLock($name)
{
Helper::releaseDbLock('_fluentcrm_lock_' . $name);
}
}
@@ -0,0 +1,161 @@
<?php
/**
* Setup wizard class
*
* Intial Setup Wizard for FluentCRM
*
*/
namespace FluentCrm\App\Hooks\Handlers;
use FluentCrm\App\Services\PermissionManager;
use FluentCrm\App\Services\TransStrings;
use FluentCrm\App\Vite;
use FluentCrm\Framework\Support\Arr;
/**
* SetupWizard Class
*
* @package FluentCrm\App\Hooks
*
* @version 1.0.0
*/
class SetupWizard
{
/**
* Hook in tabs.
*/
public function __construct()
{
/**
* Determine whether to enable the FluentCRM setup wizard.
*
* This filter allows you to enable or disable the setup wizard for FluentCRM.
*
* @since 1.0.0
*
* @param bool Whether to enable the setup wizard. Default true.
*/
if (apply_filters('fluentcrm_setup_wizard', true) && current_user_can('manage_options')) {
if(fluentcrm_get_option('fluentcrm_setup_wizard_ran') == 'yes') {
wp_redirect(admin_url('admin.php?page=fluentcrm-admin&setup_complete=' . time()));
exit();
}
fluentcrm_update_option('fluentcrm_setup_wizard_ran', 'yes');
$this->setup_wizard();
}
}
/**
* Show the setup wizard
*/
public function setup_wizard()
{
add_filter('user_can_richedit', '__return_true');
if (!function_exists('media_handle_upload')) {
require_once(ABSPATH . 'wp-admin/includes/image.php');
require_once(ABSPATH . 'wp-admin/includes/file.php');
require_once(ABSPATH . 'wp-admin/includes/media.php');
}
if (current_user_can('upload_files')) {
wp_enqueue_script('media-upload');
}
add_thickbox();
wp_enqueue_editor();
if (function_exists('wp_enqueue_media')) {
wp_enqueue_media();
}
// Inject Vite HMR client — mirrors AdminMenu::loadCssJs().
// Without this, Vue <style> blocks are not applied in dev mode.
add_action('admin_head', function () {
Vite::injectViteClient();
}, 1);
// style.css is the merged bundle of all Vue component CSS + Element Plus CSS,
// produced by vite.config.mjs's mergeCssChunksPlugin. The manifest is
// stripped of references to the merged files at build time
// (moveManifestPlugin), so Vite::enqueueScript's auto-enqueue won't
// pick it up — load it explicitly here. In dev mode, Vite HMR
// injects the styles via the client above.
if (!Vite::underDevelopment()) {
wp_enqueue_style(
'fluentcrm_vendor',
fluentCrmMix('admin/css/style.css'),
[],
FLUENTCRM_PLUGIN_VERSION
);
}
wp_enqueue_style(
'fluentcrm-setup',
fluentCrmMix('admin/css/setup-wizard.css'),
['dashicons']
);
// Use Vite::enqueueScript so handles are added to $moduleScripts,
// ensuring type="module" is applied correctly in both dev and production.
Vite::enqueueScript('fluentcrm-boot', 'admin/boot.js', ['jquery'], FLUENTCRM_PLUGIN_VERSION, true);
Vite::enqueueScript('fluentcrm-setup', 'admin/setup-wizard.js', ['fluentcrm-boot'], FLUENTCRM_PLUGIN_VERSION, true);
wp_enqueue_script('lodash');
$existingSettings = get_option(FLUENTCRM . '-global-settings');
$businessSettings = Arr::get($existingSettings, 'business_settings', []);
$currentUser = wp_get_current_user();
wp_localize_script('fluentcrm-boot', 'fcAdmin', [
'ajaxurl' => admin_url('admin-ajax.php'),
'slug' => FLUENTCRM,
'rest' => $this->getRestInfo(FluentCrm()),
'trans' => TransStrings::getStrings(),
'dashboard_url' => admin_url('admin.php?page=fluentcrm-admin&setup_complete=' . time()),
'business_settings' => (object) $businessSettings,
'has_fluentform' => defined('FLUENTFORM'),
'has_fluentcart' => defined('FLUENTCART_VERSION'),
'auth' => [
'permissions' => PermissionManager::currentUserPermissions(),
'first_name' => $currentUser->first_name,
'last_name' => $currentUser->last_name,
'email' => $currentUser->user_email,
'avatar' => fluentcrmGetAvatarHtml($currentUser->user_email, $currentUser->display_name, 128),
'user_id' => $currentUser->ID
],
]);
$this->outputHtml();
}
/**
* Setup Wizard HTML
*/
public function outputHtml()
{
ob_start();
fluentCrm('view')->render('admin.setup_wizard');
exit();
}
protected function getRestInfo($app)
{
$ns = $app->config->get('app.rest_namespace');
$v = $app->config->get('app.rest_version');
return [
'base_url' => esc_url_raw(rest_url()),
'url' => rest_url($ns . '/' . $v),
'nonce' => wp_create_nonce('wp_rest'),
'namespace' => $ns,
'version' => $v,
];
}
}
@@ -0,0 +1,27 @@
<?php
namespace FluentCrm\App\Hooks\Handlers;
class UpgradationHandler
{
public static function maybeUpdateDbTables()
{
$currentDbVerson = get_option('_fluentcrm_db_version');
if (!$currentDbVerson || version_compare($currentDbVerson, FLUENTCRM_DB_VERSION, '<')) {
require_once(FLUENTCRM_PLUGIN_PATH . 'database/FluentCRMDBMigrator.php');
// A migration just ran (and an index ALTER may have failed mid-flight).
// Drop the cached index-health snapshot so the next health check hits
// the live DB and the on-load self-heal reflects the true post-migration
// state instead of a pre-migration "ok". Stored as an empty array (not
// deleted) because getIndexHealth() treats an empty cache as stale.
fluentcrm_update_option('_db_index_health', []);
}
}
public static function updateTables()
{
// Run DB Migrations
require_once(FLUENTCRM_PLUGIN_PATH . 'database/FluentCRMDBMigrator.php');
}
}
@@ -0,0 +1,112 @@
<?php
namespace FluentCrm\App\Hooks\Handlers;
/**
* UrlMetrics Class - For Internal Debugging usage only
*
* @package FluentCrm\App\Hooks
*
* @version 1.0.0
*/
class WpQueryLogger
{
static $logInFile = true;
public static function getQueryLog($withStack = true)
{
$trace = debug_backtrace(2, 0);
$trace = reset($trace);
$file = explode('/', $trace['file']);
$caller = end($file);
$caller = substr($caller, 0, strpos($caller, '.'));
$class = explode('\\', __CLASS__);
$class = end($class);
$self = false;
if ($caller == $class) {
$self = true;
}
if (!defined('SAVEQUERIES') || !SAVEQUERIES) {
if ($self) return;
return [
'message' => __('Please enable query logging by calling enableQueryLog() before queries ran.', 'fluent-crm'),
'Total Queries Ran' => null,
'Query Logs' => null
];
}
if (!current_user_can('administrator')) {
return [
'message' => __('Oops! You are not able to see query logs.', 'fluent-crm'),
'Total Queries Ran' => null,
'Query Logs' => null
];
}
if (FluentCrm()->request->get('action') == 'heartbeat') {
return;
}
$result = [];
$queries = (array)$GLOBALS['wpdb']->queries;
foreach ($queries as $key => $query) {
$query = array_slice($query, 0, 3);
if ($withStack) {
$stackArray = [];
$stack = explode(', ', $query[2]);
foreach ($stack as $skey => $sValue) {
$stackArray[++$skey] = $sValue;
}
$query[2] = $stackArray;
$result[++$key] = array_combine([
'query', 'execution_time', 'stack'
], $query);
} else {
$result[++$key] = array_combine([
'query', 'execution_time'
], array_slice($query, 0, 2));
}
}
return [
'Total Queries Ran' => count($queries),
'Query Logs' => array_filter($result)
];
}
public function logQueries()
{
if (!static::$logInFile) return;
$result = static::getQueryLog();
if (!$result) return;
error_log('[' . fluentCrmTimestamp() . ']: ' . json_encode(
$result, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES
) . PHP_EOL, 3, FluentCrm()->path . 'query.log');
}
public static function enableQueryLog($inFile = false)
{
if (!defined('SAVEQUERIES')) {
define('SAVEQUERIES', true);
static::$logInFile = $inFile;
}
}
public static function init()
{
add_action('shutdown', [get_class(), 'logQueries'], 100);
}
}
@@ -0,0 +1,252 @@
<?php
/**
* @var \FluentCrm\Framework\Foundation\Application $app
*/
/*
* Note: Namespace will be added automatically. For example, if you use MyClass
* as the controller name then it will become FluentCrm\App\Hooks\Handlers\MyClass.
*/
// Init scheduled tasks
\FluentCrm\App\Hooks\Handlers\Scheduler::register();
(new \FluentCrm\App\Hooks\Handlers\FluentBlockEditorHandler())->register();
(new \FluentCrm\App\Hooks\Handlers\FluentConditionalContentBlockHandler())->register();
(new \FluentCrm\App\Modules\AbandonCart\AbandonCart())->register();
(new \FluentCrm\App\Hooks\Handlers\AutoSubscribeHandler())->register();
add_action('fluentcrm_contacts_filter_subscriber', function ($query, $filters) {
return (new \FluentCrm\App\Models\Subscriber)->buildGeneralPropertiesFilterQuery($query, $filters);
}, 10, 2);
add_action('fluentcrm_contacts_filter_segment', function ($query, $filters) {
return (new \FluentCrm\App\Models\Subscriber)->buildSegmentFilterQuery($query, $filters);
}, 10, 2);
add_action('fluentcrm_contacts_filter_custom_fields', function ($query, $filters) {
return (new \FluentCrm\App\Models\Subscriber)->buildCustomFieldsFilterQuery($query, $filters);
}, 10, 2);
add_action('fluentcrm_contacts_filter_activities', function ($query, $filters) {
return (new \FluentCrm\App\Models\Subscriber)->buildActivitiesFilterQuery($query, $filters);
}, 10, 2);
// Add admin init
$app->addAction('wp_loaded', 'AdminMenu@init');
$app->addAction('init', 'ExternalPages@route', 99);
$app->addAction('wp_ajax_fluentcrm_unsubscribe_ajax', 'ExternalPages@handleUnsubscribe');
$app->addAction('wp_ajax_nopriv_fluentcrm_unsubscribe_ajax', 'ExternalPages@handleUnsubscribe');
$app->addAction('wp_ajax_fluentcrm_request_unsubscribe_ajax', 'ExternalPages@handleUnsubscribeRequestAjax');
$app->addAction('wp_ajax_nopriv_fluentcrm_request_unsubscribe_ajax', 'ExternalPages@handleUnsubscribeRequestAjax');
$app->addAction('wp_ajax_fluentcrm_manage_preferences_ajax', 'ExternalPages@handleManageSubPref');
$app->addAction('wp_ajax_nopriv_fluentcrm_manage_preferences_ajax', 'ExternalPages@handleManageSubPref');
$app->addAction('wp_ajax_fluentcrm_request_manage_subscription_ajax', 'ExternalPages@handleManageSubRequestAjax');
$app->addAction('wp_ajax_nopriv_fluentcrm_request_manage_subscription_ajax', 'ExternalPages@handleManageSubRequestAjax');
$app->addAction('wp_ajax_fluentcrm_callback_for_background', 'ExternalPages@handleBackgroundProcessCallback');
$app->addAction('wp_ajax_nopriv_fluentcrm_callback_for_background', 'ExternalPages@handleBackgroundProcessCallback');
$app->addAction('wp_ajax_fluent_crm_account_form', 'PrefFormHandler@handleAjax');
$app->addAction('wp_ajax_nopriv_fluent_crm_account_form', 'PrefFormHandler@handleAjax');
// Fallback for funnel sequence save ajax
$app->addAction('wp_ajax_fluentcrm_save_funnel_sequence_ajax', 'FunnelHandler@saveSequences');
$app->addAction('wp_ajax_fluentcrm_export_funnel', 'FunnelHandler@exportFunnel');
$app->addAction('wp_ajax_fluentcrm_save_funnel_email_action', 'FunnelHandler@saveEmailAction');
$app->addAction('wp_ajax_fluentcrm_save_campaign_email_body', 'FunnelHandler@saveCampaignEmail');
/*
* Integrations & Funnels Handler Init
*/
(new \FluentCrm\App\Hooks\Handlers\FunnelHandler())->register();
// FluentCart's modal checkout fires fluent_cart/before_payment_methods and calls die()
// during init at priority 10, before a priority-10 callback can register. Only
// CheckoutSubscription needs to be early — everything else in FluentCart::init() is fine at 10.
add_action('init', function () {
if (defined('FLUENTCART_VERSION')) {
(new \FluentCrm\App\Services\ExternalIntegrations\FluentCart\CheckoutSubscription())->init();
}
}, 1);
// All external integrations (FluentCart::init() runs here too, minus CheckoutSubscription)
add_action('init', function () {
(new \FluentCrm\App\Hooks\Handlers\Integrations())->register();
}, 10);
$app->addAction('fluentcrm_subscriber_status_to_subscribed', 'FunnelHandler@resumeSubscriberFunnels', 1, 2);
/*
* Cleanup Hooks
*/
$app->addAction('fluentcrm_after_subscribers_deleted', 'Cleanup@deleteSubscribersAssets', 10, 1);
$app->addAction('fluent_crm/campaign_deleted', 'Cleanup@deleteCampaignAssets', 10, 1);
$app->addAction('fluent_crm/list_deleted', 'Cleanup@deleteListAssets', 10, 1);
$app->addAction('fluent_crm/tag_deleted', 'Cleanup@deleteTagAssets', 10, 1);
$app->addAction('fluent_crm/campaign_archived', 'Cleanup@archiveCampaignAssets', 10, 1);
$app->addAction('fluent_crm/sync_subscriber_delete_setting', 'Cleanup@SyncSubscriberDeleteSettings', 10, 2);
$app->addAction('fluentcrm_subscriber_status_to_unsubscribed', 'Cleanup@handleUnsubscribe');
$app->addAction('fluentcrm_subscriber_status_to_bounced', 'Cleanup@handleUnsubscribe');
$app->addAction('fluentcrm_subscriber_status_to_complained', 'Cleanup@handleUnsubscribe');
$app->addAction('fluentcrm_subscriber_status_to_spammed', 'Cleanup@handleUnsubscribe');
$app->addAction('fluent_crm/contact_email_changed', 'Cleanup@handleContactEmailChanged');
$app->addAction('delete_user', 'Cleanup@handleUserDelete', 10, 3);
$app->addAction('fluent_crm/company_deleted', 'Cleanup@handleCompanyDelete', 10, 1);
$app->addAction('after_password_reset', 'Cleanup@handleUserPasswordChanged', 10, 1);
add_action('fluent_crm/debug_log', function ($logData) {
if (!is_array($logData) || empty($logData['title'])) {
return;
}
\FluentCrm\App\Services\Helper::debugLog($logData['title'], \FluentCrm\Framework\Support\Arr::get($logData, 'description', ''), \FluentCrm\Framework\Support\Arr::get($logData, 'type', 'info'));
});
/*
* Admin Bar
*/
$app->addAction('admin_bar_menu', 'AdminBar@init');
add_action('wp_ajax_nopriv_fluentcrm-post-campaigns-emails-processing', function () use ($app) {
$campaignId = isset($_REQUEST['campaign_id']) ? intval($_REQUEST['campaign_id']) : 0;
if ($campaignId) {
// Continue processing a specific campaign — skip housekeeping/discovery
\FluentCrm\App\Hooks\Handlers\Scheduler::processCampaignById($campaignId);
} else {
// No campaign ID — run full discovery (backward compat)
\FluentCrm\App\Hooks\Handlers\Scheduler::processFiveMinutes();
}
wp_send_json_success([
'message' => 'success',
'time' => time()
]);
});
/*
* For Short URL Redirect
*/
add_action('wp_loaded', function () use ($app) {
if (isset($_GET['ns_url'])) {
(new \FluentCrm\App\Hooks\Handlers\RedirectionHandler())->redirect($_GET);
}
});
/*
* Contact Activity Logger Class Init
*/
add_action('init', function () {
(new \FluentCrm\App\Hooks\Handlers\ContactActivityLogger())->register();
(new \FluentCrm\App\Hooks\Handlers\ActivityLogHandler())->register();
});
/*
* Setup-wizard
*/
if (!empty($_GET['page']) && 'fluentcrm-setup' == $_GET['page']) {
add_action('admin_menu', function () {
add_dashboard_page('FluentCRM Setup', 'FluentCRM Setup', 'manage_options', 'fluentcrm-setup', function () {
return '';
});
});
add_action('current_screen', function () {
new \FluentCrm\App\Hooks\Handlers\SetupWizard();
}, 999);
}
add_shortcode('fluentcrm_pref', function ($atts, $content) {
return (new \FluentCrm\App\Hooks\Handlers\PrefFormHandler())->handleShortCode($atts, $content);
});
add_shortcode('fluentcrm_content', function ($atts, $content) {
$result = (new \FluentCrm\App\Hooks\Handlers\PrefFormHandler())->handleDynamicContentShortCode($atts, $content);
return wp_kses_post($result);
});
// require the CLI
if (defined('WP_CLI') && WP_CLI) {
\WP_CLI::add_command('fluent_crm', '\FluentCrm\App\Hooks\CLI\Commands');
}
add_action('admin_notices', function () {
if (defined('FLUENTCAMPAIGN_FRAMEWORK_VERSION') && FLUENTCAMPAIGN_FRAMEWORK_VERSION < 3) {
echo '<div class="fc_notice notice notice-error fc_notice_error"><h3>Update FluentCRM Pro Plugin</h3><p>You are using an out-of-date version of FluentCRM Pro. <a href="' . esc_url(admin_url('plugins.php?s=fluentcampaign=pro&plugin_status=all&fluentcrm_pro_check_update=' . time())) . '">' . esc_html__('Please update FluentCRM Pro to latest version', 'fluent-crm') . '</a>.</p></div>';
}
});
/*
* For REST API Nonce Renew
*/
add_action('wp_ajax_fluentcrm_renew_rest_nonce', function () {
if (!\FluentCrm\App\Services\PermissionManager::currentUserPermissions()) {
wp_send_json([
'error' => 'You do not have permission to do this'
], 403);
}
wp_send_json([
'nonce' => wp_create_nonce('wp_rest'),
'time' => time()
], 200);
});
/*
* Add custom CSS for fcrm_notice
*/
add_action('admin_head', function () {
echo '<style>
.fcrm_notice {
background: #ffffff;
border: 1px solid #E1E4EA;
border-left: 3px solid #FB3748;
padding: 10px 12px !important;
border-radius: 8px;
margin-bottom: 5px;
}
</style>';
});
/*
* MCP — Register abilities for the WordPress Abilities API.
*
* Lazy-register guard:
* - On WP < 6.9 (no Abilities API in core) OR sites without the WP MCP Adapter
* plugin active, `wp_register_ability` is undefined — we skip silently.
* - The opt-out option `fluent_crm_mcp_enabled` (default 'yes') lets admins
* disable the entire MCP surface from Settings → MCP without uninstalling
* the adapter.
*
* See `app/Modules/MCP/MCPInit.php` for the registration logic.
*/
add_action('init', function () {
if (!function_exists('wp_register_ability')) {
return;
}
if (fluentcrm_get_option('mcp_enabled', 'yes') !== 'yes') {
return;
}
(new \FluentCrm\App\Modules\MCP\MCPInit())->init();
}, 5);
@@ -0,0 +1,86 @@
<?php
/**
* @var $app \FluentCrm\Framework\Foundation\Application $app
*/
/*
* Note: Namespace will be added automatically. For example, if you use MyClass
* as the controller name then it will become FluentCrm\App\Hooks\Handlers\MyClass.
*/
$app->addFilter('fluent_crm/countries', 'CountryNames@get');
(new \FluentCrm\App\Hooks\Handlers\EmailDesignTemplates())->register();
$app->addFilter('fluent_crm/purchase_history_woocommerce', 'PurchaseHistory@wooOrders', 10, 2);
$app->addFilter('fluent_crm/purchase_history_edd', 'PurchaseHistory@eddOrders', 10, 2);
$app->addFilter('fluent_crm/purchase_history_payform', 'PurchaseHistory@payformSubmissions', 10, 2);
$app->addFilter('fluent_crm/purchase_history_pmpro', 'PurchaseHistory@pmproOrders', 10, 2);
// Fluent Forms Integration
(new \FluentCrm\App\Hooks\Handlers\FormSubmissions())->register();
add_filter('fluent_crm/parse_campaign_email_text', function ($text, $subscriber) {
return \FluentCrm\App\Services\Libs\Parser\Parser::parse($text, $subscriber);
}, 10, 2);
$app->addFilter('fluent_crm/parse_extended_crm_text', function ($text, $subscriber) {
if (!$subscriber) {
return $text;
}
return \FluentCrm\App\Services\Libs\Parser\Parser::parseCrmValue($text, $subscriber);
}, 10, 2);
$app->addFilter('comment_form_submit_field', 'AutoSubscribeHandler@addSubscribeCheckbox', 10, 1);
$app->addFilter('wp_privacy_personal_data_exporters', 'Cleanup@attachCrmExporter');
$app->addFilter('wp_privacy_personal_data_exporters', 'Cleanup@attachCrmExporter');
$app->addFilter('fluent_crm/block_editor_unregister_all_patterns', 'FluentBlockPatternHandler@shouldUnregisterAllPatterns', 10, 3);
$app->addFilter('fluent_crm/block_editor_custom_pattern_categories', 'FluentBlockPatternHandler@addCustomPatternCategories', 10, 1);
$app->addFilter('fluent_crm/block_editor_custom_patterns', 'FluentBlockPatternHandler@addCustomPatterns', 10, 1);
/*
* deprecated Hooks
* @todo: Remove this by January 2023
*/
add_filter('fluentcrm_parse_campaign_email_text', function ($text, $subscriber) {
if (!$subscriber) {
return $text;
}
_deprecated_hook('fluentcrm_parse_campaign_email_text', '2.6.6', 'fluent_crm/parse_campaign_email_text', 'Use fluent_crm/parse_campaign_email_text filter hook instead');
return \FluentCrm\App\Services\Libs\Parser\Parser::parse($text, $subscriber);
}, 10, 2);
$app->addFilter('fluentcrm_email-design-template-plain', function ($emailBody, $templateData, $campaign) {
_deprecated_hook('fluentcrm_email-design-template-plain', '2.6.6', 'fluent_crm/email-design-template-plain', 'Use fluent_crm/email-design-template-plain filter hook instead');
return (new \FluentCrm\App\Hooks\Handlers\EmailDesignTemplates())->addPlainTemplate($emailBody, $templateData, $campaign);
}, 10, 3);
$app->addFilter('fluentcrm_email-design-template-simple', function ($emailBody, $templateData, $campaign) {
_deprecated_hook('fluentcrm_email-design-template-simple', '2.6.6', 'fluent_crm/email-design-template-simple', 'Use fluent_crm/email-design-template-simple filter hook instead');
return (new \FluentCrm\App\Hooks\Handlers\EmailDesignTemplates())->addSimpleTemplate($emailBody, $templateData, $campaign);
}, 10, 3);
$app->addFilter('fluentcrm_email-design-template-classic', function ($emailBody, $templateData, $campaign) {
_deprecated_hook('fluentcrm_email-design-template-classic', '2.6.6', 'fluent_crm/email-design-template-classic', 'Use fluent_crm/email-design-template-classic filter hook instead');
return (new \FluentCrm\App\Hooks\Handlers\EmailDesignTemplates())->addClassicTemplate($emailBody, $templateData, $campaign);
}, 10, 3);
$app->addFilter('fluentcrm_email-design-template-raw_classic', function ($emailBody, $templateData, $campaign) {
_deprecated_hook('fluentcrm_email-design-template-raw_classic', '2.6.6', 'fluent_crm/email-design-template-raw_classic', 'Use fluent_crm/email-design-template-raw_classic filter hook instead');
return (new \FluentCrm\App\Hooks\Handlers\EmailDesignTemplates())->addRawClassicTemplate($emailBody, $templateData, $campaign);
}, 10, 3);
$app->addFilter('fluentcrm_email-design-template-web_preview', function ($emailBody, $templateData, $campaign) {
_deprecated_hook('fluentcrm_email-design-template-web_preview', '2.6.6', 'fluent_crm/email-design-template-web_preview', 'Use fluent_crm/email-design-template-web_preview filter hook instead');
return (new \FluentCrm\App\Hooks\Handlers\EmailDesignTemplates())->addWebPreviewTemplate($emailBody, $templateData, $campaign);
}, 10, 3);
/*
* </deprecated_hooks_end>
*/
@@ -0,0 +1,41 @@
<?php
namespace FluentCrm\App\Http\Controllers;
use FluentCrm\App\Models\ActivityLog;
use FluentCrm\Framework\Http\Request\Request;
class ActivityLogController extends Controller
{
/**
* Get all the System Logs
* @param \FluentCrm\Framework\Http\Request\Request $request
* @return array || \WP_REST_Response
*/
public function index(Request $request)
{
$search = sanitize_text_field($request->get('search'));
$logs = ActivityLog::orderBy('id', 'DESC');
if (!empty($search)) {
$logs = $logs->where('action', 'LIKE', "%{$search}%")
->orWhere('description', 'LIKE', "%{$search}%");
}
$logs = $logs->paginate($request->per_page ?: 20);
return [
'logs' => $logs
];
}
public function deleteAll(Request $request)
{
ActivityLog::where('id', '>', 0)->delete();
return [
'message' => __('All activity logs have been deleted', 'fluent-crm')
];
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,489 @@
<?php
namespace FluentCrm\App\Http\Controllers;
use FluentCrm\App\Models\Campaign;
use FluentCrm\App\Models\CampaignUrlMetric;
use FluentCrm\App\Services\Helper;
use FluentCrm\Framework\Http\Request\Request;
/**
* CampaignAnalyticsController - REST API Handler Class
*
* REST API Handler
*
* @package FluentCrm\App\Http
*
* @version 1.0.0
*/
class CampaignAnalyticsController extends Controller
{
public function getLinksReport(CampaignUrlMetric $campaignUrlMetric, $campaignId)
{
$campaign = Campaign::withoutGlobalScopes()->findOrFail($campaignId);
$clickStatus = $campaign->settings['click_tracker'] ?? '';
$openStatus = $campaign->settings['open_tracker'] ?? '';
if ($clickStatus === '') {
$clickStatus = fluentcrmTrackClicking();
}
if ($openStatus === '') {
$openStatus = fluentcrmTrackEmailOpen();
}
$links = array_values($campaignUrlMetric->getLinksReport($campaign));
return $this->sendSuccess([
'links' => $links,
'click_status' => $clickStatus,
'open_status' => $openStatus
]);
}
public function getRevenueReport(Request $request, $campaignId)
{
$limit = intval($request->get('per_page', 10));
$offset = (intval($request->get('page', 1)) - 1) * $limit;
$sources = $this->getActiveRevenueSources();
$multiSource = count($sources) > 1;
if (empty($sources)) {
return [
'orders' => [],
'labels' => $this->getRevenueLabels(false),
'total' => 0
];
}
// Build a single newest-first index across every active commerce source so
// pagination spans them all. Within each source, ids stay in DB-newest order.
$index = [];
foreach ($sources as $source) {
foreach ($this->getAttributedOrderIds($source, $campaignId) as $orderId) {
$index[] = ['source' => $source, 'order_id' => (int) $orderId];
}
}
$totalOrders = count($index);
$pageEntries = array_slice($index, $offset, $limit);
$orders = [];
foreach ($pageEntries as $entry) {
$row = $this->formatRevenueRow($entry['source'], $entry['order_id'], $multiSource);
if ($row) {
$orders[] = $row;
}
}
return [
'orders' => $orders,
'labels' => $this->getRevenueLabels($multiSource),
'total' => $totalOrders
];
}
public function getRevenueReSyncReport(Request $request, $campaignId)
{
$sources = $this->getActiveRevenueSources();
if (empty($sources)) {
return [
'message' => __('No revenue found for this campaign', 'fluent-crm')
];
}
$revenueData = ['orderIds' => []];
$primaryCurrency = null;
foreach ($sources as $source) {
$sourceData = $this->reSyncSourceRevenue($source, $campaignId);
foreach ($sourceData['orderIds'] as $oid) {
if (!in_array($oid, $revenueData['orderIds'])) {
$revenueData['orderIds'][] = $oid;
}
}
foreach ($sourceData['totals'] as $currency => $cents) {
if (!isset($revenueData[$currency])) {
$revenueData[$currency] = 0;
if ($primaryCurrency === null) {
$primaryCurrency = $currency;
}
}
$revenueData[$currency] += $cents;
}
}
if (empty($revenueData['orderIds'])) {
return [
'message' => __('No order found to re-sync', 'fluent-crm')
];
}
fluentcrm_update_campaign_meta($campaignId, '_campaign_revenue', $revenueData);
$primaryTotal = $primaryCurrency ? $revenueData[$primaryCurrency] : 0;
return [
'message' => __('Revenue has been re-synced successfully', 'fluent-crm'),
'total' => number_format($primaryTotal / 100, 2)
];
}
/**
* Active commerce sources that participate in campaign revenue attribution.
* Order matters: it determines display precedence within the merged report.
*/
protected function getActiveRevenueSources()
{
$sources = [];
if (defined('WC_PLUGIN_FILE')) {
$sources[] = 'woo';
}
if (Helper::isEdd3()) {
$sources[] = 'edd';
}
if (defined('FLUENTCART_VERSION')) {
$sources[] = 'fct';
}
return $sources;
}
/**
* Lightweight index query — returns just order IDs attributed to this campaign,
* newest-first per source. Used both for paginated report rendering and re-sync.
*/
protected function getAttributedOrderIds($source, $campaignId)
{
if ($source === 'woo') {
if (Helper::isWooHposEnabled()) {
return fluentCrmDb()->table('wc_orders_meta')
->where('meta_key', '_fc_cid')
->where('meta_value', $campaignId)
->orderBy('id', 'DESC')
->get()
->pluck('order_id')
->map(function ($orderId) {
return intval($orderId);
})
->all();
}
return fluentCrmDb()->table('postmeta')
->where('meta_key', '_fc_cid')
->where('meta_value', $campaignId)
->orderBy('meta_id', 'DESC')
->get()
->pluck('post_id')
->map(function ($orderId) {
return intval($orderId);
})
->all();
}
if ($source === 'edd') {
/*
* EDD 3 writes order attribution meta to edd_ordermeta via the
* order meta API. Do not read legacy postmeta/edd_payment records.
*/
return fluentCrmDb()->table('edd_ordermeta')
->where('meta_key', '_fc_cid')
->where('meta_value', $campaignId)
->orderBy('meta_id', 'DESC')
->get()
->pluck('edd_order_id')
->map(function ($orderId) {
return intval($orderId);
})
->all();
}
if ($source === 'fct') {
return fluentCrmDb()->table('fct_order_meta')
->where('meta_key', '_fc_cid')
->where('meta_value', $campaignId)
->orderBy('id', 'DESC')
->get()
->pluck('order_id')
->map(function ($orderId) {
return intval($orderId);
})
->all();
}
return [];
}
/**
* Sum NET revenue per currency for one source — i.e. only orders in a successful
* (paid/completed) status, with refunded amounts subtracted. Returns
* `['orderIds' => [int...], 'totals' => ['usd' => cents, ...]]`.
* Orders that net to zero or below (fully refunded, cancelled, pending) are skipped
* so they don't pollute the order list with non-revenue rows.
*/
protected function reSyncSourceRevenue($source, $campaignId)
{
$result = ['orderIds' => [], 'totals' => []];
$orderIds = $this->getAttributedOrderIds($source, $campaignId);
if (!$orderIds) {
return $result;
}
if ($source === 'woo') {
$paidStatuses = function_exists('wc_get_is_paid_statuses') ? wc_get_is_paid_statuses() : ['processing', 'completed'];
$currency = strtolower(get_woocommerce_currency());
foreach ($orderIds as $orderId) {
$order = wc_get_order($orderId);
if (!$order || !$order->get_id()) {
continue;
}
if (!in_array($order->get_status(), $paidStatuses, true)) {
continue;
}
$netCents = intval(((float) $order->get_total() - (float) $order->get_total_refunded()) * 100);
if ($netCents <= 0) {
continue;
}
$result['orderIds'][] = (int) $order->get_id();
$result['totals'][$currency] = ($result['totals'][$currency] ?? 0) + $netCents;
}
return $result;
}
if ($source === 'edd') {
// EDD 3 keeps canonical status and refund data in order tables.
$completeStatuses = ['complete', 'completed', 'partially_refunded'];
foreach ($orderIds as $orderId) {
$payment = new \EDD_Payment($orderId);
if (!$payment || !$payment->ID) {
continue;
}
if (!in_array($payment->status, $completeStatuses, true)) {
continue;
}
$netTotal = function_exists('edd_get_order_total')
? edd_get_order_total($payment->ID)
: $payment->total;
$netCents = intval(((float) $netTotal) * 100);
if ($netCents <= 0) {
continue;
}
$currency = strtolower(edd_get_payment_currency_code($payment->ID) ?: 'usd');
$result['orderIds'][] = (int) $payment->ID;
$result['totals'][$currency] = ($result['totals'][$currency] ?? 0) + $netCents;
}
return $result;
}
if ($source === 'fct') {
// Canonical "successful" set: paid, partially_paid, partially_refunded.
// Net revenue subtracts total_refund below so partial refunds still contribute.
$successStatuses = \FluentCart\App\Helpers\Status::getOrderPaymentSuccessStatuses();
$orders = \FluentCart\App\Models\Order::query()
->whereIn('id', $orderIds)
->whereIn('payment_status', $successStatuses)
->get();
foreach ($orders as $order) {
$netCents = (int) $order->total_amount - (int) ($order->total_refund ?? 0);
if ($netCents <= 0) {
continue;
}
$currency = strtolower($order->currency ?: 'usd');
$result['orderIds'][] = (int) $order->id;
$result['totals'][$currency] = ($result['totals'][$currency] ?? 0) + $netCents;
}
return $result;
}
return $result;
}
/**
* Render a single order row for the merged revenue table. The `source` key
* is added when more than one commerce platform is contributing data.
*/
protected function formatRevenueRow($source, $orderId, $multiSource)
{
$row = null;
if ($source === 'woo') {
$row = $this->formatWooOrderRow($orderId);
} else if ($source === 'edd') {
$row = $this->formatEddOrderRow($orderId);
} else if ($source === 'fct') {
$row = $this->formatFluentCartOrderRow($orderId);
}
if (!$row) {
return null;
}
if ($multiSource) {
$row = ['source' => $this->getSourceLabel($source)] + $row;
}
return $row;
}
protected function getSourceLabel($source)
{
$labels = [
'woo' => 'WooCommerce',
'edd' => 'EDD',
'fct' => 'FluentCart',
];
return $labels[$source] ?? $source;
}
protected function getRevenueLabels($multiSource)
{
$labels = [
'order' => '#',
'title' => __('Customer', 'fluent-crm'),
'status' => __('Status', 'fluent-crm'),
'date' => __('Date', 'fluent-crm'),
'total' => __('Total', 'fluent-crm'),
'action' => __('View', 'fluent-crm'),
];
if ($multiSource) {
$labels = ['source' => __('Source', 'fluent-crm')] + $labels;
}
return $labels;
}
protected function formatWooOrderRow($orderId)
{
$order = wc_get_order($orderId);
if (!$order || !$order->get_id()) {
return null;
}
/* translators: 1: billing first name, 2: billing last name */
$buyer = trim(sprintf(_x('%1$s %2$s', 'full name', 'fluent-crm'), $order->get_billing_first_name(), $order->get_billing_last_name()));
$order_timestamp = $order->get_date_created() ? $order->get_date_created()->getTimestamp() : '';
if (!$order_timestamp) {
$show_date = '&ndash;';
} else if ($order_timestamp > strtotime('-1 day', time()) && $order_timestamp <= time()) {
$show_date = sprintf(
/* translators: %s: human-readable time difference */
_x('%s ago', '%s = human-readable time difference', 'fluent-crm'),
human_time_diff($order->get_date_created()->getTimestamp(), time())
);
} else {
/**
* Determine the date format for displaying the order creation date in the WooCommerce admin in FluentCRM.
*
* @param string The date format to be used. Default is 'M j, Y'.
* @param string The context for the date format. Default is 'woocommerce'.
* @since 2.2.0
*/
$show_date = $order->get_date_created()->date_i18n(apply_filters('woocommerce_admin_order_date_format', __('M j, Y', 'fluent-crm')));
}
$editUrl = admin_url('post.php?post=' . absint($order->get_id()) . '&action=edit');
return [
'order' => '#' . esc_html($order->get_order_number()),
'title' => '<a href="' . esc_url($editUrl) . '" class="order-view"><strong>' . esc_html($buyer) . '</strong></a>',
'status' => wc_get_order_status_name($order->get_status()),
'date' => $show_date,
'total' => $order->get_formatted_order_total(),
'action' => '<a href="' . esc_url($editUrl) . '">' . esc_html__('View', 'fluent-crm') . '</a>',
];
}
protected function formatEddOrderRow($orderId)
{
$payment = new \EDD_Payment($orderId);
if (!$payment || !$payment->ID) {
return null;
}
$orderActionHtml = '<a href="' . add_query_arg('id', $payment->ID, admin_url('edit.php?post_type=download&page=edd-payment-history&view=view-order-details')) . '">' . esc_html__('View', 'fluent-crm') . '</a>';
$amount = !empty($payment->total) ? $payment->total : 0;
$customer_id = edd_get_payment_customer_id($payment->ID);
if (!empty($customer_id)) {
$customer = new \EDD_Customer($customer_id);
$customerName = '<a href="' . esc_url(admin_url("edit.php?post_type=download&page=edd-customers&view=overview&id=$customer_id")) . '">' . esc_html($customer->name) . '</a>';
} else {
$email = edd_get_payment_user_email($payment->ID);
$customerName = '<a href="' . esc_url(admin_url("edit.php?post_type=download&page=edd-payment-history&s=$email")) . '">' . esc_html__('(customer missing)', 'fluent-crm') . '</a>';
}
return [
'order' => '#' . $payment->number,
'title' => $customerName,
'status' => $payment->status_nicename,
'date' => date_i18n(get_option('date_format'), strtotime($payment->date)),
'total' => edd_currency_filter(edd_format_amount($amount), edd_get_payment_currency_code($payment->ID)),
'action' => $orderActionHtml,
];
}
protected function formatFluentCartOrderRow($orderId)
{
$order = \FluentCart\App\Models\Order::with('customer')->find($orderId);
if (!$order) {
return null;
}
$customerName = '';
if ($order->customer) {
$customerName = trim($order->customer->first_name . ' ' . $order->customer->last_name);
if (!$customerName) {
$customerName = $order->customer->email;
}
}
$orderUrl = admin_url('admin.php?page=fluent-cart#/orders/' . $order->id . '/view');
return [
'order' => '#' . ($order->invoice_no ?: $order->id),
'title' => '<a target="_blank" rel="noopener" href="' . esc_url($orderUrl) . '">' . esc_html($customerName) . '</a>',
'status' => esc_html(\FluentCrm\App\Services\Helper::getStatusText($order->status)),
'date' => date_i18n(get_option('date_format'), strtotime($order->created_at)),
'total' => \FluentCart\App\Helpers\Helper::toDecimal($order->total_amount, true, $order->currency),
'action' => '<a target="_blank" rel="noopener" href="' . esc_url($orderUrl) . '">' . esc_html__('View', 'fluent-crm') . '</a>',
];
}
public function getUnsubscribers(Request $request, $campaignId)
{
$unsubscribes = CampaignUrlMetric::with('subscriber')
->where('campaign_id', $campaignId)
->where('type', 'unsubscribe')
->paginate();
foreach ($unsubscribes as $unsubscribe) {
$unsubscribe->subscriber->reason = $unsubscribe->subscriber->unsubscribeReason();
}
return [
'unsubscribes' => $unsubscribes
];
}
public function getSegmentedContacts(Request $request, $campaignId)
{
$campaign = Campaign::findOrFail($campaignId);
$contactsModel = $campaign->getSubscribersModel();
$search = $request->getSafe('search', 'sanitize_text_field');
if ($search) {
$contactsModel->searchBy($search);
}
if ($orderBy = $request->getSafe('sort_by', 'sanitize_sql_orderby', 'id')) {
$orderType = $request->getSafe('sort_type', 'sanitize_sql_orderby', 'desc');
$contactsModel->orderBy($orderBy, $orderType);
}
$contacts = $contactsModel->with(['lists', 'tags'])->paginate();
return [
'subscribers' => $contacts
];
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,924 @@
<?php
namespace FluentCrm\App\Http\Controllers;
use FluentCrm\App\Http\Controllers\Controller;
use FluentCrm\App\Models\Company;
use FluentCrm\App\Models\CompanyNote;
use FluentCrm\App\Models\CustomCompanyField;
use FluentCrm\App\Models\Subscriber;
use FluentCrm\App\Models\SubscriberNote;
use FluentCrm\App\Services\AutoSubscribe;
use FluentCrm\App\Services\Helper;
use FluentCrm\App\Services\Libs\FileSystem;
use FluentCrm\App\Services\Sanitize;
use FluentCrm\Framework\Http\Request\Request;
use FluentCrm\Framework\Support\Arr;
use FluentCrm\Framework\Support\Collection;
class CompanyController extends Controller
{
public function index(Request $request)
{
$order = [
'by' => $request->getSafe('sort_by', 'sanitize_sql_orderby', 'id'),
'order' => $request->getSafe('sort_order', 'sanitize_sql_orderby', 'DESC')
];
$companies = Company::orderBy($order['by'], $order['order'])
->with(['owner'])
->searchBy($request->getSafe('search', 'sanitize_text_field'));
$inlineFilters = $request->get('inline_filters', []);
if ($inlineFilters && is_array($inlineFilters)) {
$inlineFilters = array_filter($inlineFilters);
foreach ($inlineFilters as $key => $values) {
if (!is_array($values)) {
continue;
}
$values = array_map('sanitize_text_field', $values);
if ($key == 'company_categories') {
$companies->whereIn('industry', $values);
} else if ($key == 'company_types') {
$companies->whereIn('type', $values);
}
}
}
$companies = $companies->paginate();
foreach ($companies as $company) {
$company->contacts_count = $company->getContactsCount();
}
return [
'companies' => $companies
];
}
public function searchCompanies(Request $request)
{
$search = $request->getSafe('search', 'sanitize_text_field');
$companies = Company::orderBy('name', 'ASC')
->searchBy($search);
$subscriberId = $request->getSafe('subscriber_id', 'intval');
if ($subscriberId) {
$companies = $companies->doesnthave('subscribers', 'and', function ($query) use ($subscriberId) {
$query->where('fc_subscribers.id', $subscriberId);
});
}
$companies = $companies->limit(50)->get();
$formatted = [];
$values = (array)$request->get('values', []);
$pushedIds = [];
foreach ($companies as $company) {
$pushedIds[] = $company->id;
$formatted[] = [
'id' => $company->id,
'name' => $company->name,
'email' => $company->email,
'logo' => $company->logo,
'phone' => $company->phone,
'website' => $company->website
];
}
if ($values && $newIds = array_diff($values, $pushedIds)) {
$newItems = Company::whereIn('id', $newIds)
->get();
foreach ($newItems as $item) {
$formatted[] = [
'id' => $item->id,
'name' => $item->name,
'email' => $item->email,
'logo' => $item->logo,
'phone' => $item->phone,
'website' => $item->website
];
}
}
return [
'results' => $formatted,
'has_more' => Company::count() >= 50
];
}
public function searchUnattachedContacts(Request $request)
{
$search = $request->getSafe('search', 'sanitize_text_field');
$companyId = $request->getSafe('company_id', 'intval', '');
$contacts = Subscriber::orderBy('id', 'DESC')
->searchBy($search)
->whereDoesntHave('companies', function ($query) use ($companyId) {
$query->where('fc_companies.id', $companyId);
})
->limit($request->getSafe('limit', 'intval', 20))
->get();
return [
'results' => $contacts
];
}
public function attachSubscribers(Request $request)
{
$subscriberIds = $request->get('subscriber_ids');
$companyIds = $request->get('company_ids');
$result = FluentCrmApi('companies')->attachContactsByIds($subscriberIds, $companyIds);
if (!$result) {
return $this->sendError('Invalid data', 422);
}
return [
'message' => __('Selected Companies have been attached successfully', 'fluent-crm'),
'companies' => $result['companies']
];
}
public function detachSubscribers(Request $request)
{
$subscriberIds = $request->get('subscriber_ids');
$companyIds = $request->get('company_ids');
$result = FluentCrmApi('companies')->detachContactsByIds($subscriberIds, $companyIds);
if (!$result) {
return $this->sendError('Invalid data', 422);
}
$result['message'] = __('Company has been successfully detached', 'fluent-crm');
return $result;
}
/**
* Find a company.
*/
public function find(Request $request, $id)
{
$findBy = $request->getSafe('find_by', 'sanitize_text_field', 'id');
$findByValue = $request->getSafe('find_by_value', 'sanitize_text_field');
$customFindBys = ['name', 'email', 'phone'];
if (in_array($findBy, $customFindBys)) {
$company = Company::where($findBy, $findByValue)->first();
if (!$company) {
return $this->sendError('Company not found', 422);
}
} else {
$company = Company::findOrFail($id);
}
$company->load(['owner']);
if ($company->owner) {
$company->owner->stats = $company->owner->stats();
}
$company->contacts_count = $company->getContactsCount();
return [
'company' => $company
];
}
/**
* Store a company.
* @param Request $request
* @return \WP_REST_Response | array
*/
public function create(Request $request)
{
$allData = $request->all();
$allData = $this->validate($allData, [
'name' => 'required|unique:fc_companies,name'
]);
$data = $this->getSanitizedData($allData);
if (empty($data['logo']) && !empty($allData['website']) && Helper::isExperimentalEnabled('company_auto_logo')) {
$data['logo'] = $this->getLogoWebsiteUrl($allData['website']);
}
$company = FluentCrmApi('companies')->createOrUpdate($data);
if ($contactId = $request->getSafe('intended_contact_id', 'intval')) {
$contact = Subscriber::find($contactId);
if ($contact) {
$contact->attachCompanies([$company->id]);
if (!$contact->company_id) {
$contact->company_id = $company->id;
$contact->save();
}
}
}
return [
'message' => __('Company has been created successfully', 'fluent-crm'),
'company' => $company
];
}
public function update(Request $request, $id = 0)
{
if ($id == 0) {
return $this->create($request);
}
$company = Company::findOrFail($id);
$allData = $request->all();
$name = sanitize_text_field($allData['name']);
if (Company::where('id', '!=', $id)->where('name', $name)->first()) {
return $this->sendError([
'message' => __('Company name already exists. Please use a different company name', 'fluent-crm')
], 422);
}
$data = $this->getSanitizedData($allData);
$company = FluentCrmApi('companies')->createOrUpdate($data);
return [
'message' => __('Company has been updated', 'fluent-crm'),
'company' => $company
];
}
public function updateProperty()
{
$column = $this->request->getSafe('property', 'sanitize_text_field');
$value = $this->request->getSafe('value', 'sanitize_text_field');
$companyIds = $this->request->get('companies');
if (!is_array($companyIds)) {
$companyIds = [$companyIds];
}
$companyIds = array_map('intval', $companyIds);
$companyIds = array_filter($companyIds);
$validColumns = ['type', 'logo', 'owner_id', 'refetch_logo'];
$types = Helper::companyTypes();
$statuses = Helper::companyTypes();
$this->validate([
'column' => $column,
'value' => $value,
'company_ids' => $companyIds
], [
'column' => 'required',
'value' => 'required',
'company_ids' => 'required'
]);
if (!in_array($column, $validColumns)) {
return $this->sendError([
'message' => __('Column is not valid', 'fluent-crm')
]);
}
if ($column == 'type' && !in_array($value, $types)) {
return $this->sendError([
'message' => __('Value is not valid', 'fluent-crm')
]);
} else if ($column == 'status' && !in_array($value, $statuses)) {
return $this->sendError([
'message' => __('Value is not valid', 'fluent-crm')
]);
}
$companies = Company::whereIn('id', $companyIds)->get();
foreach ($companies as $company) {
if ($column == 'refetch_logo') {
$newLogo = $this->getLogoWebsiteUrl($company->website);
if ($newLogo) {
$company->logo = $newLogo;
$company->save();
return [
'message' => __('Logo has been updated successfully', 'fluent-crm'),
'updated_logo' => $newLogo
];
}
return $this->sendError([
'message' => __('Sorry, we could not find the logo from website. Please upload manually', 'fluent-crm')
]);
}
$oldValue = $company->{$column};
if ($oldValue != $value) {
$company->{$column} = $value;
$company->save();
if (in_array($column, ['type', 'status', 'owner_id'])) {
do_action('fluent_crm/company_' . $column . '_to_' . $value, $company, $oldValue);
}
}
}
return $this->sendSuccess([
'message' => __('Company successfully updated', 'fluent-crm')
]);
}
public function delete(Request $request, $id)
{
$company = Company::findOrFail($id);
do_action('fluent_crm/before_company_delete', $company);
$company->delete();
do_action('fluent_crm/company_deleted', $id);
return [
'message' => __('Company has been deleted successfully', 'fluent-crm')
];
}
public function handleBulkActions(Request $request)
{
$actionName = sanitize_text_field($request->get('action_name', ''));
$companyIds = array_map('intval', $request->get('company_ids', []));
$companyIds = array_filter($companyIds);
$lastId = $request->get('last_id', 0);
if (!$companyIds) {
$companyQuery = Company::orderBy('id', 'ASC')
->searchBy($request->getSafe('search', 'sanitize_text_field'));
$inlineFilters = $request->get('company_query.inline_filters', []);
if ($inlineFilters && is_array($inlineFilters)) {
$inlineFilters = array_filter($inlineFilters);
foreach ($inlineFilters as $key => $values) {
if (!is_array($values)) {
continue;
}
$values = array_map('sanitize_text_field', $values);
if ($key == 'company_categories') {
$companyQuery->whereIn('industry', $values);
} else if ($key == 'company_types') {
$companyQuery->whereIn('type', $values);
}
}
}
$companyQuery = $companyQuery->limit(50)
->where('id', '>', $lastId);
} else {
$companyQuery = Company::whereIn('id', $companyIds);
}
$companies = $companyQuery->get();
if ($companies->isEmpty()) {
return [
'is_completed' => true,
'completed_companies' => 0,
'message' => __('All companies have been processed', 'fluent-crm')
];
}
$companyIds = $companyQuery->pluck('id')->toArray();
$lastCompanyId = end($companyIds);
if ($actionName == 'delete_companies') {
foreach ($companies as $company) {
$id = $company->id;
do_action('fluent_crm/before_company_delete', $company);
$company->delete();
do_action('fluent_crm/company_deleted', $id);
}
return $this->sendSuccess([
'last_company_id' => $lastCompanyId,
'completed_companies' => count($companyIds),
'message' => __('Selected Companies have been deleted permanently', 'fluent-crm'),
]);
} elseif ($actionName == 'change_company_status') {
$newStatus = sanitize_text_field($request->get('new_status', ''));
if (!$newStatus) {
return $this->sendError([
'message' => __('Please select status', 'fluent-crm')
]);
}
foreach ($companies as $company) {
$oldStatus = $company->status;
if ($oldStatus != $newStatus) {
$company->status = $newStatus;
$company->save();
do_action('fluent_crm/company_status_to_' . $newStatus, $company, $oldStatus);
}
}
return [
'last_company_id' => $lastCompanyId,
'completed_companies' => count($companyIds),
'message' => __('Status has been changed for the selected companies', 'fluent-crm')
];
} else if ($actionName == 'change_company_type') {
$newType = sanitize_text_field($request->get('new_status', ''));
if (!$newType) {
return $this->sendError([
'message' => __('Please select new type', 'fluent-crm')
]);
}
foreach ($companies as $company) {
$oldType = $company->type;
if ($oldType != $newType) {
$company->type = $newType;
$company->save();
do_action('fluent_crm/company_type_to_' . $newType, $company, $oldType);
}
}
return [
'last_company_id' => $lastCompanyId,
'completed_companies' => count($companyIds),
'message' => __('Company Type has been updated for the selected companies', 'fluent-crm')
];
} else if ($actionName == 'change_company_category') {
$newCategory = sanitize_text_field($request->get('new_status', ''));
if (!$newCategory) {
return $this->sendError([
'message' => __('Please select new category', 'fluent-crm')
]);
}
foreach ($companies as $company) {
$oldCategory = $company->industry;
if ($oldCategory != $newCategory) {
$company->industry = $newCategory;
$company->save();
do_action('fluent_crm/company_category_to_' . $newCategory, $company, $oldCategory);
}
}
return [
'last_company_id' => $lastCompanyId,
'completed_companies' => count($companyIds),
'message' => __('Company Category has been updated for the selected companies', 'fluent-crm')
];
}
return [
'last_company_id' => $lastCompanyId,
'completed_companies' => count($companyIds),
'message' => __('Selected bulk action has been successfully completed', 'fluent-crm')
];
}
private function getSanitizedData($allData)
{
$rules = [
'name' => 'required'
];
if (Arr::get($allData, 'website')) {
$allData['website'] = $this->makeHttpUrl($allData['website']);
$rules['website'] = 'url';
}
if (Arr::get($allData, 'linkedin_url')) {
$allData['linkedin_url'] = $this->makeHttpUrl($allData['linkedin_url']);
$rules['linkedin_url'] = 'url';
}
if (Arr::get($allData, 'facebook_url')) {
$allData['facebook_url'] = $this->makeHttpUrl($allData['facebook_url']);
$rules['facebook_url'] = 'url';
}
if (Arr::get($allData, 'twitter_url')) {
$allData['twitter_url'] = $this->makeHttpUrl($allData['twitter_url']);
$rules['twitter_url'] = 'url';
}
$allData = $this->validate($allData, $rules);
$data = Sanitize::company($allData);
return Arr::only($data, array_keys($allData));
}
private function makeHttpUrl($url)
{
if (!$url) {
return $url;
}
$parsed_url = wp_parse_url($url);
if (!$parsed_url || empty($parsed_url['scheme'])) {
$url = 'https://' . $url;
}
return $url;
}
/**
* Returns true only if the URL resolves to a public, routable IP address.
* Blocks private/reserved ranges to prevent SSRF attacks.
*/
private function isSSRFSafeUrl($url)
{
$parsed = wp_parse_url($url);
if (!$parsed || empty($parsed['host'])) {
return false;
}
$scheme = strtolower($parsed['scheme'] ?? '');
if (!in_array($scheme, ['http', 'https'])) {
return false;
}
$host = $parsed['host'];
// Strip IPv6 brackets if present
$host = trim($host, '[]');
// If it looks like a raw IP, validate directly; otherwise resolve the hostname
if (filter_var($host, FILTER_VALIDATE_IP)) {
$ip = $host;
} else {
$ip = gethostbyname($host);
// gethostbyname() returns the original string on failure
if ($ip === $host && !filter_var($ip, FILTER_VALIDATE_IP)) {
return false;
}
}
// Reject private, loopback, link-local, and other reserved ranges
return (bool) filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE);
}
private function getLogoWebsiteUrl($url)
{
if (!$url) {
return NULL;
}
$url = $this->makeHttpUrl($url);
if (!$this->isSSRFSafeUrl($url)) {
return NULL;
}
$response = wp_remote_get($url, [
'sslverify' => false, // Disable SSL verification to avoid 403 Forbidden error
'timeout' => 10, // Set a timeout of 10 seconds
'user-agent' => 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/58.0.3029.110 Safari/537.3' // Set a User-Agent header to avoid 403 Forbidden error
]);
// Check for errors in the response
if (is_wp_error($response)) {
return NULL;
}
// Extract the HTML content from the response
$html = wp_remote_retrieve_body($response);
preg_match('/<link rel="apple-touch-icon"(?:.*?)href="([^"]+)"/i', $html, $matches);
// Use regular expressions to find the logo image URL
if (!isset($matches[1])) {
preg_match('/<link rel="(?:shortcut|icon)"(?:.*?)href="([^"]+)"/i', $html, $matches);
}
// If a logo URL is found, download the image to the uploads directory
if (isset($matches[1])) {
$logoUrl = $matches[1];
// Resolve relative URLs against the base domain
if (!preg_match('/^https?:\/\//i', $logoUrl)) {
$parsedBase = wp_parse_url($url);
$baseOrigin = ($parsedBase['scheme'] ?? 'https') . '://' . ($parsedBase['host'] ?? '');
$logoUrl = $baseOrigin . '/' . ltrim($logoUrl, '/');
}
$extension = strtolower(substr($logoUrl, strrpos($logoUrl, '.') + 1));
if (!in_array($extension, ['png', 'jpg', 'jpeg', 'gif', 'ico'])) {
return NULL;
}
// Block SSRF on the logo URL too (the link tag href may point to a different host)
if (!$this->isSSRFSafeUrl($logoUrl)) {
return NULL;
}
$uploadDir = wp_upload_dir(); // Get the uploads directory
$filename = md5($url . time()) . '-' . basename($logoUrl); // Get the filename from the URL
$filepath = $uploadDir['basedir'] . '/fluentcrm/' . $filename; // Combine the uploads directory path with the filename
// Download the image using wp_remote_get() and save it to the uploads directory
$image = wp_remote_get($logoUrl, [
'timeout' => 10, // Set a timeout of 10 seconds
'sslverify' => false // Disable SSL verification to avoid 403 Forbidden error
]);
if (!is_wp_error($image)) {
// Check if the downloaded file is actually an image
$headers = wp_remote_retrieve_headers($image);
$imageBody = wp_remote_retrieve_body($image);
if (defined('FILEINFO_MIME_TYPE') && class_exists('\finfo')) {
$finfo = new \finfo(FILEINFO_MIME_TYPE);
$content_type = $finfo->buffer($imageBody);
} else {
$content_type = wp_remote_retrieve_header($headers, 'content-type');
if (!$content_type) {
$content_type = Arr::get($headers, 'content-type');
}
if (strpos($content_type, 'image/') !== 0) {
return null;
}
// Temporary file to validate the image
$tmpFilePath = tempnam(sys_get_temp_dir(), 'tmpimg');
file_put_contents($tmpFilePath, $imageBody);
$imgSize = getimagesize($tmpFilePath);
wp_delete_file($tmpFilePath);
if (!$imgSize) {
return null;
}
}
if (strpos($content_type, 'image/') === 0) {
global $wp_filesystem;
if (!$wp_filesystem) {
require_once(ABSPATH . '/wp-admin/includes/file.php');
WP_Filesystem();
}
FileSystem::setCustomUploadDir([
'baseurl' => $uploadDir['baseurl'],
'basedir' => $uploadDir['basedir'],
]);
$wp_filesystem->put_contents($filepath, $imageBody);
// Return the URL of the saved image
return $uploadDir['baseurl'] . FLUENTCRM_UPLOAD_DIR . '/' . $filename;
} else {
// If the downloaded file is not an image, delete the file and return null
wp_delete_file($filepath);
}
}
}
// If no logo URL is found, or if an error occurs, or if the downloaded file is not an image, return null
return NULL;
}
public function getNotes()
{
$companyId = $this->request->get('id');
$search = $this->request->get('search');
$includeId = intval($this->request->get('include_id', 0));
$notes = CompanyNote::where('subscriber_id', $companyId);
if (!empty($search)) {
global $wpdb;
$notes = $notes->where('title', 'LIKE', '%' . $wpdb->esc_like(sanitize_text_field($search)) . '%');
}
$notes = $notes->orderBy('id', 'DESC')
->paginate();
foreach ($notes as $note) {
$note->added_by = $note->createdBy();
}
$fields['fields'] = Helper::getNoteSyncFields();
$response = [
'notes' => $notes,
'fields' => $fields
];
if ($includeId) {
$noteIds = (new Collection($notes->items()))->pluck('id')->toArray();
if (!in_array($includeId, $noteIds)) {
$includedNote = CompanyNote::where('id', $includeId)
->where('subscriber_id', $companyId)
->first();
if ($includedNote) {
$includedNote->added_by = $includedNote->createdBy();
$response['included_note'] = $includedNote;
}
}
}
return $this->sendSuccess($response);
}
public function addNote(Request $request, $id)
{
$company = Company::findOrFail($id);
$note = $this->validate($request->get('note'), [
'title' => 'required',
'description' => 'required',
'type' => 'required',
'created_at' => 'nullable|date'
]);
if (empty($note['created_at'])) {
$note['created_at'] = current_time('mysql');
}
$note['subscriber_id'] = $id;
$note = Sanitize::contactNote($note);
$subscriberNote = CompanyNote::create(wp_unslash($note));
/**
* Subscriber's Note Added
*
* @param SubscriberNote $subscriberNote Note Model.
* @param Subscriber $subscriber Contact Model.
* @param array $note Contact Note Data Array.
* @since 1.0
*/
do_action('fluent_crm/company_note_added', $subscriberNote, $company, $note);
return $this->sendSuccess([
'note' => $subscriberNote,
'message' => __('Note has been successfully added', 'fluent-crm')
]);
}
public function updateNote(Request $request, $id, $noteId)
{
$company = Company::findOrFail($id);
$note = $this->validate($request->get('note'), [
'title' => 'required',
'description' => 'required',
'type' => 'required',
'created_at' => 'sometimes|date'
]);
$note = Arr::only(wp_unslash($note), ['title', 'description', 'type', 'created_at']);
if (empty($note['created_at'])) {
unset($note['created_at']);
}
$note = Sanitize::contactNote($note);
$companyNote = CompanyNote::findOrFail($noteId);
$companyNote->fill($note);
$companyNote->save();
/**
* Subscriber's Note Updated
*
* @param CompanyNote $companyNote Note Model.
* @param Company $company Contact Model.
* @param array $note Contact Note Data Array.
* @since 1.0
*/
do_action('fluent_crm/company_note_updated', $companyNote, $company, $note);
return $this->sendSuccess([
'note' => $companyNote,
'message' => __('Note successfully updated', 'fluent-crm')
]);
}
public function deleteNote($id, $noteId)
{
$company = Company::findOrFail($id);
CompanyNote::where('id', $noteId)->delete();
/**
* Subscriber's Note Delete
*
* @param int $noteId Note ID.
* @param Company $company Company Model.
* @since 1.0
*/
do_action('fluent_crm/company_note_deleted', $noteId, $company);
return $this->sendSuccess([
'message' => __('Note successfully deleted', 'fluent-crm')
]);
}
public function bulkDeleteNotes(Request $request, $id)
{
$company = Company::findOrFail($id);
$noteIds = array_filter(array_map('intval', (array) $request->get('note_ids', [])));
if (empty($noteIds)) {
return $this->sendError([
'message' => __('No note IDs provided', 'fluent-crm')
]);
}
if (count($noteIds) > 200) {
return $this->sendError([
'message' => __('Too many notes selected. Please delete 200 or fewer notes at a time.', 'fluent-crm')
]);
}
// Scope delete to this company so users cannot delete notes belonging to other companies.
$deletableNoteIds = CompanyNote::where('subscriber_id', $company->id)
->whereIn('id', $noteIds)
->pluck('id')
->toArray();
$deletedCount = 0;
if ($deletableNoteIds) {
$deletedCount = CompanyNote::whereIn('id', $deletableNoteIds)->delete();
foreach ($deletableNoteIds as $deletedNoteId) {
do_action('fluent_crm/company_note_deleted', $deletedNoteId, $company);
}
}
return $this->sendSuccess([
'message' => sprintf(
/* translators: %d: number of deleted notes */
_n('%d note deleted', '%d notes deleted', $deletedCount, 'fluent-crm'),
$deletedCount
)
]);
}
public function getCustomGlobalFields(CustomCompanyField $model)
{
return $this->sendSuccess(
$model->getGlobalFields(
$this->request->get('with', [])
)
);
}
public function saveCustomGlobalFields(CustomCompanyField $model)
{
$fields = $model->saveGlobalFields(
Helper::parseArrayOrJson($this->request->get('fields'))
);
return $this->sendSuccess([
'fields' => $fields,
'message' => __('Fields saved successfully!', 'fluent-crm')
]);
}
public function updateCustomFieldGroupName(CustomCompanyField $model)
{
$oldName = sanitize_text_field($this->request->get('old_name'));
$newName = sanitize_text_field($this->request->get('new_name'));
$updatedCustomFields = $model->updateGroupName($oldName, $newName);
return $this->sendSuccess([
'fields' => $updatedCustomFields,
'message' => __('Group name updated successfully!', 'fluent-crm')
]);
}
public function getCompanyExternalView(Request $request, $companyId)
{
$company = Company::findOrFail($companyId);
$sectionId = $request->get('section_provider');
return apply_filters('fluent_crm/company_profile_section_' . $sectionId, [
'heading' => '',
'content_html' => ''
], $company);
}
public function saveExternalViewData(Request $request, $companyId)
{
$company = Company::findOrFail($companyId);
$sectionId = $request->get('section_provider');
$response = apply_filters('fluent_crm/company_profile_section_save_' . $sectionId, '', $request->get('data', []), $company);
if (!$response) {
return $this->sendError([
'message' => __('Handler could not be found.', 'fluent-crm')
]);
}
return $response;
}
}
@@ -0,0 +1,103 @@
<?php
namespace FluentCrm\App\Http\Controllers;
use FluentCrm\App\App;
use FluentCrm\Framework\Validator\ValidationException;
use FluentCrm\Framework\Validator\Validator;
/**
* abstract REST API Controller Class
*
* REST API Handler
*
* @package FluentCrm\App\Http
*
* @version 1.0.0
*/
abstract class Controller
{
/**
* @var \FluentCrm\App\App
*/
protected $app = null;
/**
* @var \FluentCrm\Framework\Http\Request\Request
*/
protected $request = null;
/**
* @var \FluentCrm\Framework\Http\Response\Response
*/
protected $response = null;
public function __construct()
{
$this->app = App::getInstance();
$this->request = $this->app['request'];
$this->response = $this->app['response'];
}
public function validate($data, $rules, $messages = [])
{
$validator = new Validator($data, $rules, $messages);
if ($validator->validate()->fails()) {
// Sanitize validation error messages before returning them
$errors = $validator->errors();
if (is_array($errors)) {
array_walk_recursive($errors, function (&$value) {
if (is_string($value)) {
$value = sanitize_text_field($value);
}
});
}
// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Sanitization is already done above
throw new ValidationException(
esc_html__('Unprocessable Entity!', 'fluent-crm'),
422,
null,
$errors
);
}
return $data;
}
public function send($data = null, $code = 200)
{
return $this->response->send($data, $code);
}
public function sendSuccess($data = null, $code = 200)
{
return $this->response->sendSuccess($data, $code);
}
public function sendError($data = null, $code = 422)
{
return $this->response->sendError($data, $code);
}
public function validationErrors($data = null, $code = 422)
{
if ($data instanceof ValidationException) {
$data = $data->errors();
}
// Sanitize error payload before sending the response to prevent unescaped output
if (is_array($data)) {
array_walk_recursive($data, function (&$value) {
if (is_string($value)) {
$value = sanitize_text_field($value);
}
});
} elseif (is_string($data)) {
$data = sanitize_text_field($data);
}
return $this->sendError($data, $code);
}
}
@@ -0,0 +1,494 @@
<?php
namespace FluentCrm\App\Http\Controllers;
use FluentCrm\App\Models\Company;
use FluentCrm\App\Services\Helper;
use FluentCrm\App\Services\Libs\FileSystem;
use FluentCrm\App\Services\Sanitize;
use FluentCrm\Framework\Support\Arr;
use FluentCrm\Framework\Http\Request\Request;
use FluentCrm\App\Models\Subscriber;
/**
* CsvController - REST API Handler Class
*
* REST API Handler
*
* @package FluentCrm\App\Http
*
* @version 1.0.0
*/
class CsvController extends Controller
{
/**
* @param \FluentCrm\Framework\Http\Request\Request $request
* @return \WP_REST_Response
* @throws \FluentCrm\Framework\Validator\ValidationException
*/
public function upload(Request $request)
{
if (is_multisite()) {
add_filter('upload_mimes', function ($types) {
if (empty($types['csv'])) {
$types['csv'] = 'text/csv';
}
return $types;
});
}
$files = $this->validate($this->request->files(), [
'file' => 'mimetypes:' . implode(',', fluentcrmCsvMimes())
], [
'file.mimetypes' => __('The file must be a valid CSV.', 'fluent-crm')
]);
$delimeter = $request->get('delimiter', 'comma');
if ($delimeter == 'comma') {
$delimeter = ',';
} else {
$delimeter = ';';
}
$uploadedFiles = FileSystem::put($files);
try {
$csv = $this->getCsvReader(FileSystem::get($uploadedFiles[0]['file']));
$csv->setDelimiter($delimeter);
$headers = $csv->fetchOne();
} catch (\Exception $exception) {
return $this->sendError([
'message' => $exception->getMessage()
]);
}
if (count($headers) != count(array_unique($headers))) {
return $this->sendError([
'message' => __('Looks like your csv has same name header multiple times. Please fix your csv first and remove any duplicate header column', 'fluent-crm')
]);
}
if ($request->get('type') == 'company') {
$mappables = Company::mappables();
} else {
$mappables = Subscriber::mappables();
}
$headerItems = array_values(array_filter($headers));
$subscriberColumns = array_keys($mappables);
$maps = [];
$customFields = fluentcrm_get_custom_contact_fields();
$fieldsMap = [];
if ($customFields) {
foreach ($customFields as $field) {
$fieldsMap[$field['slug']] = $field['label'];
}
}
foreach ($headerItems as $headerItem) {
$tableMap = (in_array($headerItem, $subscriberColumns)) ? $headerItem : null;
if (!$tableMap) {
$santizedItem = str_replace(' ', '_', strtolower($headerItem));
if (in_array($santizedItem, $subscriberColumns)) {
$tableMap = $santizedItem;
}
}
if (!empty($fieldsMap) && in_array($headerItem, $fieldsMap)) {
$tableMap = array_search($headerItem, $fieldsMap);
}
$maps[] = [
'csv' => $headerItem,
'table' => $tableMap
];
}
if ($request->get('type') == 'company') {
/**
* Determine the columns of the company table in FluentCRM.
*
* This filter allows you to modify the columns of the company table in the CSV export.
*
* @since 2.8.0
*
* @param array $subscriberColumns An array of default subscriber columns.
*/
$columns = apply_filters(
'fluent_crm/company_table_columns', $subscriberColumns
);
} else {
/**
* Determine the columns of the subscriber table in FluentCRM.
*
* This filter allows you to modify the columns displayed in the subscriber table.
*
* @since 2.8.0
*
* @param array $subscriberColumns An array of default subscriber table columns.
*/
$columns = apply_filters(
'fluent_crm/subscriber_table_columns', $subscriberColumns
);
}
return $this->send([
'file' => $uploadedFiles[0]['file'],
'headers' => $headerItems,
'fields' => $mappables,
'columns' => $columns,
'map' => $maps
]);
}
public function import()
{
$inputs = $this->request->only([
'map', 'tags', 'lists', 'file', 'update', 'new_status', 'double_optin_email', 'import_silently', 'force_update_status'
]);
if (Arr::get($inputs, 'import_silently') == 'yes') {
if (!defined('FLUENTCRM_DISABLE_TAG_LIST_EVENTS')) {
define('FLUENTCRM_DISABLE_TAG_LIST_EVENTS', true);
}
}
$forceStatusChange = Arr::get($inputs, 'force_update_status') == 'yes';
$delimeter = $this->request->get('delimiter', 'comma');
if ($delimeter == 'comma') {
$delimeter = ',';
} else {
$delimeter = ';';
}
$status = $inputs['new_status'];
try {
$reader = $this->getCsvReader(FileSystem::get($inputs['file']));
$reader->setDelimiter($delimeter);
if (method_exists($reader, 'getRecords')) {
$aHeaders = $reader->fetchOne(0);
$allRecords = $reader->getRecords($aHeaders);
if (!is_array($allRecords)) {
$allRecords = iterator_to_array($allRecords, true);
}
unset($allRecords[0]);
$allRecords = array_values($allRecords);
} else {
$aHeaders = $reader->fetchOne(0);
$allRecords = $reader->fetchAssoc($aHeaders);
if (!is_array($allRecords)) {
$allRecords = iterator_to_array($allRecords, true);
}
unset($allRecords[0]);
$allRecords = array_values($allRecords);
}
} catch (\Exception $exception) {
return $this->sendError([
'message' => $exception->getMessage()
]);
}
$page = $this->request->get('importing_page', 1);
$processPerRequest = apply_filters('fluent_crm/csv_import_contact_limit_per_request', 100);
$offset = ($page - 1) * $processPerRequest;
$records = array_slice($allRecords, $offset, $processPerRequest);
$customFieldKeys = $this->customFieldKeys();
$subscribers = [];
$skipped = [];
$isCompanyEnabled = Helper::isCompanyEnabled();
foreach ($records as $record) {
if (!array_filter($record)) {
continue;
}
$subscriber = [
'custom_values' => []
];
foreach ($inputs['map'] as $map) {
if (!$map['table']) {
continue;
}
if (isset($map['csv'], $map['table'])) {
if (in_array($map['table'], ['tags', 'lists'])) {
//if tags or lists are mapped to be imported
if ($map['table'] == 'tags') {
$subscriber['tags'] = !empty($record[$map['csv']]) ? explode(',', $record[$map['csv']]) : [];
} else {
$subscriber['lists'] = !empty($record[$map['csv']]) ? explode(',', $record[$map['csv']]) : [];
}
}
else if (in_array($map['table'], $customFieldKeys)) {
$subscriber['custom_values'][$map['table']] = $record[$map['csv']];
} else {
$subscriber[$map['table']] = $record[$map['csv']];
}
}
}
if (!array_key_exists('email', $subscriber)) {
return $this->sendError(['email' => __('The email field is required.', 'fluent-crm')], 422);
}
$subscriber['email'] = is_string($subscriber['email']) ? trim($subscriber['email']) : $subscriber['email'];
if ($subscriber['email'] && is_email($subscriber['email'])) {
if (isset($subscriber['company_id']) && $subscriber['company_id'] && $isCompanyEnabled) {
$companyNameOrId = $subscriber['company_id'];
if (is_string($companyNameOrId)) {
$company = Company::query()->firstOrCreate([
'name' => $subscriber['company_id']
], [
'name' => $subscriber['company_id']
]);
if ($company) {
$subscriber['company_id'] = $company->id;
} else {
unset($subscriber['company_id']);
}
} else {
$company = Company::find($subscriber['company_id']);
if (!$company) {
unset($subscriber['company_id']);
}
}
}
$subscribers[] = Sanitize::contact($subscriber);
} else {
$skipped[] = $subscriber;
}
}
if (!isset($inputs['tags'])) {
$inputs['tags'] = [];
}
if (!isset($inputs['lists'])) {
$inputs['lists'] = [];
}
$sendDoubleOptin = Arr::get($inputs, 'double_optin_email') == 'yes';
$result = Subscriber::import(
$subscribers, $inputs['tags'], $inputs['lists'], $inputs['update'], $status, $sendDoubleOptin, $forceStatusChange, 'csv'
);
$totalSkipped = count($result['skips']) + count($skipped);
$completed = $offset + count($records);
$totalCount = count($allRecords);
$hasMore = $completed < $totalCount;
if (!$hasMore) {
FileSystem::delete($inputs['file']);
}
return $this->sendSuccess([
'total' => $totalCount,
'completed' => $completed,
'total_page' => ceil($totalCount / $processPerRequest),
'skipped' => $totalSkipped,
'invalid_contacts' => $skipped,
'skipped_contacts' => $result['skips'],
'invalid_email_counts' => count($skipped),
'inserted' => count($result['inserted']),
'updated' => count($result['updated']),
'has_more' => $hasMore,
'last_page' => $page,
'tags' => $inputs['tags'],
'lists' => $inputs['lists'],
'offset' => $offset,
'result' => $result
]);
}
public function importCompanies()
{
$inputs = $this->request->only([
'map', 'file', 'update', 'create_owner'
]);
$delimeter = $this->request->get('delimiter', 'comma');
if ($delimeter == 'comma') {
$delimeter = ',';
} else {
$delimeter = ';';
}
try {
$reader = $this->getCsvReader(FileSystem::get($inputs['file']));
$reader->setDelimiter($delimeter);
if (method_exists($reader, 'getRecords')) {
$aHeaders = $reader->fetchOne(0);
$allRecords = $reader->getRecords($aHeaders);
if (!is_array($allRecords)) {
$allRecords = iterator_to_array($allRecords, true);
}
unset($allRecords[0]);
$allRecords = array_values($allRecords);
} else {
$aHeaders = $reader->fetchOne(0);
$allRecords = $reader->fetchAssoc($aHeaders);
if (!is_array($allRecords)) {
$allRecords = iterator_to_array($allRecords, true);
}
unset($allRecords[0]);
$allRecords = array_values($allRecords);
}
} catch (\Exception $exception) {
return $this->sendError([
'message' => $exception->getMessage()
]);
}
$page = $this->request->get('importing_page', 1);
$processPerRequest = 100;
$offset = ($page - 1) * $processPerRequest;
$records = array_slice($allRecords, $offset, $processPerRequest);
$willCreateOwner = $this->request->get('create_owner') == 'yes';
$willUpdate = $this->request->get('update') == 'yes';
$customFields = fluentcrm_get_custom_company_fields();
$companies = [];
$skipped = [];
foreach ($records as $record) {
if (!array_filter($record)) {
continue;
}
$company = [];
foreach ($inputs['map'] as $map) {
if (!$map['table']) {
continue;
}
if (isset($map['csv'], $map['table'])) {
$company[$map['table']] = trim($record[$map['csv']]);
}
}
if (empty($company['name'])) {
return $this->sendError(['email' => __('The company name field is required.', 'fluent-crm')], 422);
}
if (!$willUpdate) {
// check if exists
if (Company::where('name', $company['name'])->first()) {
$skipped[] = $company;
continue;
}
}
if ($customFields) {
$customValues = [];
foreach ($company as $dataKey => $dataValue) {
if (strpos($dataKey, '_custom_') === 0) {
$customKey = str_replace('_custom_', '', $dataKey);
$customValues[$customKey] = $dataValue;
unset($company[$dataKey]);
}
}
$company['custom_values'] = $customValues;
}
$company = Sanitize::company($company);
if (!empty($company['owner_email']) && is_email($company['owner_email'])) {
$ownerEmail = sanitize_email($company['owner_email']);
} else {
$ownerEmail = null;
}
if ($ownerEmail) {
$owner = FluentCrmApi('contacts')->getContact($ownerEmail);
if ($owner) {
$company['owner_id'] = $owner->id;
} else if ($willCreateOwner) {
$owner = FluentCrmApi('contacts')->createOrUpdate([
'full_name' => sanitize_text_field(Arr::get($company, 'owner_name')),
'email' => $ownerEmail,
'status' => 'subscribed'
]);
if ($owner) {
$company['owner_id'] = $owner->id;
}
}
}
$createdCompany = FluentCrmApi('companies')->createOrUpdate($company);
$companies[] = $createdCompany;
}
$completed = $offset + count($companies);
$totalCount = count($allRecords);
$hasMore = $completed < $totalCount;
if (!$hasMore) {
FileSystem::delete($inputs['file']);
}
return $this->sendSuccess([
'total' => $totalCount,
'completed' => count($companies),
'total_page' => ceil($totalCount / $processPerRequest),
'skipped' => count($skipped),
'has_more' => $hasMore,
'last_page' => $page,
'offset' => $offset
]);
}
protected function customFieldKeys()
{
$fields = fluentcrm_get_option('contact_custom_fields', []);
$keys = [];
foreach ($fields as $field) {
$keys[] = $field['slug'];
}
return $keys;
}
private function getCsvReader($file)
{
if (!class_exists(' \League\Csv\Reader')) {
include FLUENTCRM_PLUGIN_PATH . 'app/Services/Libs/csv/autoload.php';
}
return \League\Csv\Reader::createFromString($file);
}
}
@@ -0,0 +1,51 @@
<?php
namespace FluentCrm\App\Http\Controllers;
use FluentCrm\App\Models\CustomContactField;
use FluentCrm\App\Services\Helper;
/**
* CustomContactFieldsController - REST API Handler Class
*
* REST API Handler
*
* @package FluentCrm\App\Http
*
* @version 1.0.0
*/
class CustomContactFieldsController extends Controller
{
public function getGlobalFields(CustomContactField $model)
{
return $this->sendSuccess(
$model->getGlobalFields(
$this->request->get('with', [])
)
);
}
public function saveGlobalFields(CustomContactField $model)
{
$fields = $model->saveGlobalFields(
Helper::parseArrayOrJson($this->request->get('fields'))
);
return $this->sendSuccess([
'fields' => $fields,
'message' => __('Fields saved successfully!', 'fluent-crm')
]);
}
public function updateGroupName(CustomContactField $model)
{
$oldName = sanitize_text_field($this->request->get('old_name'));
$newName = sanitize_text_field($this->request->get('new_name'));
$updatedCustomFields = $model->updateGroupName($oldName, $newName);
return $this->sendSuccess([
'fields' => $updatedCustomFields,
'message' => __('Group name updated successfully!', 'fluent-crm')
]);
}
}
@@ -0,0 +1,268 @@
<?php
namespace FluentCrm\App\Http\Controllers;
use FluentCrm\App\Models\CampaignEmail;
use FluentCrm\App\Services\Helper;
use FluentCrm\App\Services\Stats;
use FluentCrm\Framework\Support\Arr;
/**
* DashboardController - REST API Handler Class
*
* REST API Handler
*
* @package FluentCrm\App\Http
*
* @version 1.0.0
*/
class DashboardController extends Controller
{
public function getStats(Stats $stats)
{
$overallStats = $stats->getCounts();
$nextMinuteTask = Helper::getNextMinuteTaskTimeStamp();
$notices = [];
if ((time() - $nextMinuteTask) > 120) {
$notices[] = '<div class=""><b>Attention: </b> Looks like the scheduled cron jobs are not running timely. Please consider setup server side cron. <a href="' . admin_url('admin.php?page=fluentcrm-admin#/settings/settings_tools') . '">Click here to check the status</a></div>';
}
$systemTips = '';
$emailsCount = Arr::get($overallStats, 'email_sent.count', 0);
if ($emailsCount > 400000) {
$lastEmail = CampaignEmail::orderBy('id', 'ASC')->first();
if ($lastEmail && strtotime($lastEmail->created_at) < strtotime('-120 days')) {
$emailsCount = number_format($emailsCount, 0);
$sysBody = '<div class="fc_system_tips">';
/* translators: %s: number of emails in the database */
$sysBody .= '<p>' . sprintf(__('You have %s email history in the database. Consider cleaning up old email history to speed up your next email campaign.', 'fluent-crm'), $emailsCount) . '</p>';
$sysBody .= '<a href="' . fluentcrm_menu_url_base('settings/settings_tools') . '" class="el-button fcrm_primary_btn">' . __('View Data Cleanup', 'fluent-crm') . '</a>';
$sysBody .= '</div>';
$systemTips = [
'title' => __('Database Cleanup Suggestion', 'fluent-crm'),
'body' => $sysBody,
];
}
}
/**
* Define the FluentCRM dashboard notices.
*
* This filter allows modification of the notices displayed on the FluentCRM dashboard.
*
* @since 2.8.40
*
* @param array $notices An array of notices to be displayed on the dashboard.
*/
$notices = apply_filters('fluent_crm/dashboard_notices', $notices);
/**
* Define the dashboard data for FluentCRM.
*
* @since 2.9.23
*
* @param array {
* The dashboard data array.
*
* @type array $stats Overall statistics.
* @type array $sales Sales statistics.
* @type array $dashboard_notices Notices to be displayed on the dashboard.
* @type array $onboarding Onboarding statistics.
* @type array $quick_links Quick links for the dashboard.
* @type array $ff_config FluentForm configuration.
* @type array $recommendation Recommendations for the user.
* @type array $system_tips System tips for the user.
* }
*/
return apply_filters('fluent_crm/dashboard_data', [
'stats' => $overallStats,
/**
* Determine the FluentCRMsales statistics data.
*
* This filter allows modification of the sales statistics data before it is used.
*
* @since 2.7.0
*
* @param array An array of sales statistics data.
*/
'sales' => apply_filters('fluent_crm/sales_stats', []),
'dashboard_notices' => $notices,
'onboarding' => $stats->getOnboardingStat(),
'quick_links' => $stats->getQuickLinks(),
'ff_config' => [
'is_installed' => defined('FLUENTFORM'),
'create_form_link' => admin_url('admin.php?page=fluent_forms#add=1')
],
'recommendation' => $this->recommendation(),
'system_tips' => $systemTips,
'recent_contacts' => $stats->getRecentContacts(3),
'active_automations' => $stats->getActiveAutomations(3),
'recent_campaigns' => $stats->getRecentCampaigns(3),
'triggers' => $this->getTriggers()
]);
}
private function recommendation()
{
if (defined('FLUENTCAMPAIGN')) {
return false;
}
$recommendations = [];
if (defined('WC_PLUGIN_FILE')) {
$recommendations[] = [
'provider' => 'WooCommerce',
'title' => __('Do more with WooCommerce + FluentCRM', 'fluent-crm'),
'description' => __('Integrate FluentCRM with WooCommerce and segment your customers by purchase behavior, send super targeted emails, onboarding emails, cross promotions and many more.', 'fluent-crm'),
'btn_text' => __('Upgrade to Pro', 'fluent-crm'),
'learn_more' => 'https://fluentcrm.com/integrations/woocommerce-marketing-automation/',
'base_title' => __('Supercharge your WooCommerce store by upgrading FluentCRM Pro', 'fluent-crm')
];
$recommendations[] = [
'provider' => 'WooCommerce',
'title' => __('Do more with WooCommerce + FluentCRM', 'fluent-crm'),
'description' => __('Integrate FluentCRM with WooCommerce and segment your customers by purchase behavior, send super targeted emails, onboarding emails, cross promotions and many more.', 'fluent-crm'),
'btn_text' => __('Upgrade to Pro', 'fluent-crm'),
'learn_more' => 'https://fluentcrm.com/integrations/woocommerce-marketing-automation/',
'base_title' => __('Supercharge your WooCommerce store by upgrading FluentCRM Pro', 'fluent-crm')
];
}
if (Helper::isEdd3()) {
$recommendations[] = [
'provider' => 'EDD',
'title' => __('Do more with EDD + FluentCRM', 'fluent-crm'),
'description' => __('Integrate FluentCRM with Easy Digital Downloads and segment your customers by purchase behavior, send super targeted emails, onboarding emails, cross promotions and many more.', 'fluent-crm'),
'btn_text' => __('Upgrade to Pro', 'fluent-crm'),
'learn_more' => 'https://fluentcrm.com/integrations/easy-digital-downloads-integration-fluentcrm/',
'base_title' => __('Supercharge your Digital Downloads store by upgrading FluentCRM Pro', 'fluent-crm')
];
}
if (defined('LLMS_PLUGIN_FILE')) {
$recommendations[] = [
'provider' => 'LifterLMS',
'title' => __('Do more with LifterLMS + FluentCRM', 'fluent-crm'),
'description' => __('Integrate LifterLMS with FluentCRM and segment your students by courses, send super targeted emails, onboarding emails, cross promote more courses and many more.', 'fluent-crm'),
'learn_more' => 'https://fluentcrm.com/integrations/lifterlms/',
'btn_text' => __('Upgrade to Pro', 'fluent-crm'),
'base_title' => __('Supercharge your LMS by upgrading FluentCRM Pro', 'fluent-crm')
];
$recommendations[] = [
'provider' => 'LifterLMS',
'title' => __('Do more with LifterLMS + FluentCRM', 'fluent-crm'),
'description' => __('Integrate LifterLMS with FluentCRM and segment your students by courses, send super targeted emails, onboarding emails, cross promote more courses and many more.', 'fluent-crm'),
'learn_more' => 'https://fluentcrm.com/integrations/lifterlms/',
'btn_text' => __('Upgrade to Pro', 'fluent-crm'),
'base_title' => __('Supercharge your LMS by upgrading FluentCRM Pro', 'fluent-crm')
];
} else if (defined('LEARNDASH_VERSION')) {
$recommendations[] = [
'provider' => 'LearnDash',
'title' => __('Do more with LearnDash + FluentCRM', 'fluent-crm'),
'description' => __('Integrate LearnDash with FluentCRM and segment your students by courses, send super targeted emails, onboarding emails, cross promote more courses and many more.', 'fluent-crm'),
'learn_more' => 'https://fluentcrm.com/integrations/learndash-integration-fluentcrm/',
'btn_text' => __('Upgrade to Pro', 'fluent-crm'),
'base_title' => __('Supercharge your LMS by upgrading FluentCRM Pro', 'fluent-crm')
];
$recommendations[] = [
'provider' => 'LearnDash',
'title' => __('Do more with LearnDash + FluentCRM', 'fluent-crm'),
'description' => __('Integrate LearnDash with FluentCRM and segment your students by courses, send super targeted emails, onboarding emails, cross promote more courses and many more.', 'fluent-crm'),
'learn_more' => 'https://fluentcrm.com/integrations/learndash-integration-fluentcrm/',
'btn_text' => __('Upgrade to Pro', 'fluent-crm'),
'base_title' => __('Supercharge your LMS by upgrading FluentCRM Pro', 'fluent-crm')
];
} else if (defined('TUTOR_VERSION')) {
$recommendations[] = [
'provider' => 'TutorLMS',
'title' => __('Do more with TutorLMS + FluentCRM', 'fluent-crm'),
'description' => __('Integrate TutorLMS with FluentCRM and segment your students by courses, send super targeted emails, onboarding emails, cross promote more courses and many more.', 'fluent-crm'),
'btn_text' => __('Upgrade to Pro', 'fluent-crm'),
'learn_more' => 'https://fluentcrm.com/docs/tutorlms-integration-with-fluentcrm/',
'base_title' => __('Supercharge your LMS by upgrading FluentCRM Pro', 'fluent-crm')
];
} else if (defined('LP_PLUGIN_FILE')) {
$recommendations[] = [
'provider' => 'LearnPress',
'title' => __('Do more with LearnPress + FluentCRM', 'fluent-crm'),
'description' => __('Integrate LearnPress with FluentCRM and segment your students by courses, send super targeted emails, onboarding emails, cross promote more courses and many more.', 'fluent-crm'),
'btn_text' => __('Upgrade to Pro', 'fluent-crm'),
'learn_more' => 'https://fluentcrm.com/docs/learpress-integration-with-fluentcrm/',
'base_title' => __('Supercharge your LMS by upgrading FluentCRM Pro', 'fluent-crm')
];
}
if (defined('PMPRO_VERSION')) {
$recommendations[] = [
'provider' => 'PaidMembership Pro',
'title' => __('Do more with PaidMembership Pro + FluentCRM', 'fluent-crm'),
'description' => __('Integrate PaidMembership Pro with FluentCRM and segment your members by membership levels, send super targeted emails, onboarding emails, cross promote more levels and many more.', 'fluent-crm'),
'btn_text' => __('Upgrade to Pro', 'fluent-crm'),
'base_title' => __('Supercharge your Membership Site by upgrading FluentCRM Pro', 'fluent-crm')
];
} else if (defined('WLM3_PLUGIN_VERSION')) {
$recommendations[] = [
'provider' => 'Wishlist Member',
'title' => __('Do more with Wishlist Member + FluentCRM', 'fluent-crm'),
'description' => __('Integrate Wishlist Member with FluentCRM and segment your members by membership levels, send super targeted emails, onboarding emails, cross promote more levels and many more.', 'fluent-crm'),
'btn_text' => __('Upgrade to Pro', 'fluent-crm'),
'base_title' => __('Supercharge your Membership Site by upgrading FluentCRM Pro', 'fluent-crm')
];
} else if (defined('MEPR_PLUGIN_NAME')) {
$recommendations[] = [
'provider' => 'MemberPress',
'title' => __('Do more with MemberPress + FluentCRM', 'fluent-crm'),
'description' => __('Integrate MemberPress with FluentCRM and segment your members by membership levels, send super targeted emails, onboarding emails, cross promote more levels and many more.', 'fluent-crm'),
'btn_text' => __('Upgrade to Pro', 'fluent-crm'),
'base_title' => __('Supercharge your Membership Site by upgrading FluentCRM Pro', 'fluent-crm')
];
} else if (class_exists('\Restrict_Content_Pro')) {
$recommendations[] = [
'provider' => 'Restrict Content Pro',
'title' => __('Do more with Restrict Content Pro + FluentCRM', 'fluent-crm'),
'description' => __('Integrate Restrict Content Pro with FluentCRM and segment your members by membership levels, send super targeted emails, onboarding emails, cross promote more levels and many more.', 'fluent-crm'),
'btn_text' => __('Upgrade to Pro', 'fluent-crm'),
'base_title' => __('Supercharge your Membership Site by upgrading FluentCRM Pro', 'fluent-crm')
];
}
if (defined('BP_REQUIRED_PHP_VERSION') && function_exists('\buddypress')) {
$title = defined('BP_PLATFORM_VERSION') ? 'BuddyBoss' : 'BuddyPress';
$recommendations[] = [
'provider' => $title,
/* translators: %s: plugin name (BuddyBoss or BuddyPress) */
'title' => sprintf(__('Do more with %s + FluentCRM', 'fluent-crm'), $title),
/* translators: %s: plugin name (BuddyBoss or BuddyPress) */
'description' => sprintf(__('Integrate %s with FluentCRM and segment your members by different group, send super targeted emails, onboarding emails, cross promote more groups and many more.', 'fluent-crm'), $title),
'btn_text' => __('Upgrade to Pro', 'fluent-crm'),
'base_title' => __('Supercharge your Community Site by upgrading FluentCRM Pro', 'fluent-crm')
];
}
if (!$recommendations) {
return false;
}
return $recommendations[array_rand($recommendations)];
}
private function getTriggers()
{
/**
* Determine the list of funnel triggers in FluentCRM.
*
* This filter allows you to modify the array of funnel triggers.
*
* @since 1.0.0
*
* @param array An array of funnel triggers.
*/
return apply_filters('fluentcrm_funnel_triggers', []);
}
}
@@ -0,0 +1,195 @@
<?php
namespace FluentCrm\App\Http\Controllers;
use FluentCrm\App\Services\Helper;
use FluentCrm\Framework\Http\Request\Request;
use FluentCrm\Framework\Support\Arr;
/**
* DocsController - REST API Handler Class
*
* REST API Handler
*
* @package FluentCrm\App\Http
*
* @version 1.0.0
*/
class DocsController extends Controller
{
private $restApi = 'https://fluentcrm.com/wp-json/wp/v2/';
public function index()
{
$formattedDocs = $this->getDocsPerChunk($this->restApi . 'docs?per_page=100', 'fluentcrm_all_docs');
$moreDocs = $this->getDocsPerChunk($this->restApi . 'docs?per_page=100&offset=100', 'fluentcrm_all_docs_2');
if ($moreDocs) {
$formattedDocs = array_merge($formattedDocs, $moreDocs);
}
return [
'docs' => $formattedDocs
];
}
public function getDoc($docId)
{
$request = wp_remote_get($this->restApi . 'docs/' . $docId);
if (is_wp_error($request)) {
return [
'content' => 'sorry, we could not fetch the doc at this moment. Please try again',
'is_error' => true
];
}
$doc = json_decode(wp_remote_retrieve_body($request), true);
return [
'title' => sanitize_text_field($doc['title']['rendered']),
'content' => links_add_target(Helper::sanitizeHtml($doc['content']['rendered'])),
'link' => esc_url($doc['link']),
'id' => $doc['id']
];
}
public function getAddons(Request $request)
{
$canAutoInstallToolkit = (bool) apply_filters('fluent_toolkit/can_auto_install', false);
$toolkitPluginFile = 'fluent-toolkit/fluent-toolkit.php';
$toolkitLoaded = defined('FLUENT_TOOLKIT_VERSION');
$toolkitPluginExists = $this->isPluginInstalled($toolkitPluginFile);
$toolkitActionText = __('Get FluentHub from GitHub', 'fluent-crm');
if ($canAutoInstallToolkit) {
$toolkitActionText = $toolkitPluginExists ? __('Activate FluentHub', 'fluent-crm') : __('Install FluentHub', 'fluent-crm');
}
$addOns = [
'fluentform' => [
'title' => __('Fluent Forms', 'fluent-crm'),
'logo' => fluentCrmMix('images/fluentform.png'),
'is_installed' => defined('FLUENTFORM'),
'learn_more_url' => 'https://wordpress.org/plugins/fluentform/',
'settings_url' => admin_url('admin.php?page=fluent_forms'),
'action_text' => $this->isPluginInstalled('fluent-form/fluent-form.php') ? __('Activate Fluent Forms', 'fluent-crm') : __('Install Fluent Forms', 'fluent-crm'),
'description' => __('Collect leads and build any type of forms, accept payments, connect with your CRM with the Fastest Contact Form Builder Plugin for WordPress', 'fluent-crm')
],
'fluentsmtp' => [
'title' => __('Fluent SMTP', 'fluent-crm'),
'logo' => fluentCrmMix('images/fluent-smtp.svg'),
'is_installed' => defined('FLUENTMAIL'),
'learn_more_url' => 'https://wordpress.org/plugins/fluent-smtp/',
'settings_url' => admin_url('options-general.php?page=fluent-mail#/'),
'action_text' => $this->isPluginInstalled('fluent-smtp/fluent-smtp.php') ? __('Activate Fluent SMTP', 'fluent-crm') : __('Install Fluent SMTP', 'fluent-crm'),
'description' => __('The Ultimate SMTP and SES Plugin for WordPress. Connect with any SMTP, SendGrid, Mailgun, SES, Sendinblue, PepiPost, Google, Microsoft and more.', 'fluent-crm')
],
'fluent-support' => [
'title' => __('Fluent Support', 'fluent-crm'),
'logo' => fluentCrmMix('images/fluent-support.svg'),
'is_installed' => defined('FLUENT_SUPPORT_VERSION'),
'learn_more_url' => 'https://wordpress.org/plugins/fluent-support/',
'settings_url' => admin_url('admin.php?page=fluent-support#/'),
'action_text' => $this->isPluginInstalled('fluent-support/fluent-support.php') ? __('Activate Fluent Support', 'fluent-crm') : __('Install Fluent Support', 'fluent-crm'),
'description' => __('WordPress Helpdesk and Customer Support Ticket Plugin. Provide awesome support and manage customer queries right from your WordPress dashboard.', 'fluent-crm')
],
'fluent-cart' => [
'title' => __('Fluent Cart', 'fluent-crm'),
'logo' => fluentCrmMix('images/fluent-cart-dark.svg'),
'is_installed' => defined('FLUENTCART_VERSION'),
'learn_more_url' => 'https://wordpress.org/plugins/fluent-cart/',
'settings_url' => admin_url('admin.php?page=fluent-cart#/'),
'action_text' => $this->isPluginInstalled('fluent-cart/fluent-cart.php') ? __('Activate Fluent Cart', 'fluent-crm') : __('Install Fluent Cart', 'fluent-crm'),
'description' => __('WordPress eCommerce and Shopping Cart Plugin. Build an online store and manage products, orders, and customers right from your WordPress dashboard.', 'fluent-crm')
],
'fluent-boards' => [
'title' => __('Fluent Boards', 'fluent-crm'),
'logo' => fluentCrmMix('images/fluent-boards.svg'),
'is_installed' => defined('FLUENT_BOARDS'),
'learn_more_url' => 'https://wordpress.org/plugins/fluent-boards/',
'settings_url' => admin_url('admin.php?page=fluent-boards#/'),
'action_text' => $this->isPluginInstalled('fluent-boards/fluent-boards.php') ? __('Activate Fluent Boards', 'fluent-crm') : __('Install Fluent Boards', 'fluent-crm'),
'description' => __('WordPress Project Management and Collaboration Plugin. Manage projects, tasks, and team collaboration right from your WordPress dashboard.', 'fluent-crm')
],
'fluent-community' => [
'title' => __('Fluent Community', 'fluent-crm'),
'logo' => fluentCrmMix('images/fluent-community.svg'),
'is_installed' => defined('FLUENT_COMMUNITY_PLUGIN_VERSION'),
'learn_more_url' => 'https://wordpress.org/plugins/fluent-community/',
'settings_url' => admin_url('admin.php?page=fluent-community#/'),
'action_text' => $this->isPluginInstalled('fluent-community/fluent-community.php') ? __('Activate Fluent Community', 'fluent-crm') : __('Install Fluent Community', 'fluent-crm'),
'description' => __('WordPress Forum and Community Plugin. Build a thriving online community and discussion forum right from your WordPress dashboard.', 'fluent-crm')
],
'fluent-booking' => [
'title' => __('Fluent Booking', 'fluent-crm'),
'logo' => fluentCrmMix('images/fluent-booking.svg'),
'is_installed' => defined('FLUENT_BOOKING_VERSION'),
'learn_more_url' => 'https://wordpress.org/plugins/fluent-booking/',
'settings_url' => admin_url('admin.php?page=fluent-booking#/'),
'action_text' => $this->isPluginInstalled('fluent-booking/fluent-booking.php') ? __('Activate Fluent Booking', 'fluent-crm') : __('Install Fluent Booking', 'fluent-crm'),
'description' => __('WordPress Appointment Booking Plugin. Manage appointments, bookings, and customer scheduling right from your WordPress dashboard.', 'fluent-crm')
],
'fluent-toolkit' => [
'title' => __('FluentHub', 'fluent-crm'),
'logo' => fluentCrmMix('images/fluent-toolkit.svg'),
'is_installed' => $toolkitLoaded,
'learn_more_url' => 'https://github.com/WPManageNinja/fluent-toolkit',
'settings_url' => admin_url('admin.php?page=fluent-toolkit'),
'action_text' => $toolkitActionText,
'install_route' => $canAutoInstallToolkit ? 'mcp/install-adapter' : '',
'install_url' => $canAutoInstallToolkit ? '' : 'https://github.com/WPManageNinja/fluent-toolkit',
'description' => __('FluentCRM ships AI agent tools, but they only become available once FluentHub is installed and active.', 'fluent-crm')
]
];
$data = [
'addons' => $addOns
];
if (in_array('experimental_features', $request->get('with', []))) {
$data['experimental_features'] = Helper::getExperimentalSettings();
}
return $data;
}
private function isPluginInstalled($plugin)
{
return file_exists(WP_PLUGIN_DIR . '/' . $plugin);
}
private function getDocsPerChunk($url, $chunkKey)
{
return fluentCrmGetFromCache($chunkKey, function () use ($url) {
$request = wp_remote_get($url);
if (is_wp_error($request)) {
return [];
}
$docs = json_decode(wp_remote_retrieve_body($request), true);
$formattedDocs = [];
foreach ($docs as $doc) {
if (empty($doc['title'])) {
continue;
}
$primaryCategory = Arr::get($doc, 'taxonomy_info.doc_category.0', ['value' => 'none', 'label' => 'Other']);
$formattedDocs[] = [
'title' => sanitize_text_field($doc['title']['rendered']),
'content' => links_add_target(Helper::sanitizeHtml($doc['content']['rendered'])),
'link' => esc_url($doc['link']),
'category' => wp_kses_post_deep($primaryCategory)
];
}
return $formattedDocs;
}, WEEK_IN_SECONDS);
}
}
@@ -0,0 +1,485 @@
<?php
namespace FluentCrm\App\Http\Controllers;
use FluentCrm\App\Models\Meta;
use FluentCrm\Framework\Support\Arr;
use FluentCrm\Framework\Http\Request\Request;
class EmailPatternController extends Controller
{
private $objectType = 'email_pattern';
private $categoryObjectType = 'email_pattern_category';
public function index(Request $request)
{
$query = Meta::where('object_type', $this->objectType)
->orderBy('id', 'desc');
if ($search = $request->getSafe('search', 'sanitize_text_field')) {
$query->where('value', 'LIKE', '%' . $search . '%');
}
$patterns = $query->paginate();
$formattedPatterns = [];
foreach ($patterns as $pattern) {
$formattedPatterns[] = $this->formatPattern($pattern);
}
return $this->sendSuccess([
'patterns' => [
'data' => $formattedPatterns,
'total' => $patterns->total()
]
]);
}
public function show(Request $request, $id)
{
$pattern = Meta::where('object_type', $this->objectType)
->where('id', $id)
->firstOrFail();
return $this->sendSuccess([
'pattern' => $this->formatPattern($pattern)
]);
}
/**
* Return patterns in wp_block REST format for the editor middleware.
*/
public function indexWpFormat(Request $request)
{
$patterns = Meta::where('object_type', $this->objectType)
->orderBy('id', 'desc')
->get();
$categoryMap = $this->getCategoryMap();
$formatted = [];
foreach ($patterns as $pattern) {
$formatted[] = $this->formatAsWpBlock($pattern, $categoryMap);
}
return $formatted;
}
public function store(Request $request)
{
$this->validate($request->all(), [
'title' => 'required|string',
'content' => 'required|string',
]);
$title = sanitize_text_field($request->get('title'));
$content = wp_kses_post($request->get('content'));
$category = sanitize_text_field($request->get('category', ''));
$description = sanitize_text_field($request->get('description', ''));
$syncStatus = sanitize_text_field($request->get('sync_status', 'unsynced'));
$slug = 'fluentcrm/' . sanitize_title($title . '-' . uniqid());
$pattern = Meta::create([
'object_type' => $this->objectType,
'object_id' => get_current_user_id(),
'key' => $slug,
'value' => [
'title' => $title,
'content' => $content,
'category' => $category,
'description' => $description,
'sync_status' => $syncStatus,
],
]);
return $this->sendSuccess([
'message' => __('Pattern saved successfully', 'fluent-crm'),
'pattern' => $this->formatPattern($pattern),
]);
}
/**
* Store a pattern from wp_block format (called by editor middleware).
*/
public function storeWpFormat(Request $request)
{
$title = $request->get('title', '');
if (is_array($title)) {
$title = Arr::get($title, 'raw', '');
}
$title = sanitize_text_field($title);
$content = $request->get('content', '');
if (is_array($content)) {
$content = Arr::get($content, 'raw', '');
}
$content = wp_kses_post($content);
if (!$title && !$content) {
return $this->sendError([
'message' => __('Title or content is required', 'fluent-crm')
]);
}
if (!$title) {
$title = __('Untitled Pattern', 'fluent-crm');
}
$meta = $request->get('meta', []);
$syncStatus = Arr::get($meta, 'wp_pattern_sync_status', '');
$syncStatus = sanitize_text_field($syncStatus);
$categoryIds = (array) $request->get('wp_pattern_category', []);
$categoryName = $this->resolveCategoryName($categoryIds);
$slug = 'fluentcrm/' . sanitize_title($title . '-' . uniqid());
$pattern = Meta::create([
'object_type' => $this->objectType,
'object_id' => get_current_user_id(),
'key' => $slug,
'value' => [
'title' => $title,
'content' => $content,
'category' => $categoryName,
'description' => '',
'sync_status' => $syncStatus,
],
]);
$categoryMap = $this->getCategoryMap();
return $this->formatAsWpBlock($pattern, $categoryMap);
}
public function update(Request $request, $id)
{
$pattern = Meta::where('object_type', $this->objectType)
->where('id', $id)
->firstOrFail();
$value = $pattern->value;
if ($title = $request->get('title')) {
if (is_array($title)) {
$title = Arr::get($title, 'raw', '');
}
$value['title'] = sanitize_text_field($title);
}
if ($request->has('content')) {
$content = $request->get('content');
if (is_array($content)) {
$content = Arr::get($content, 'raw', '');
}
$value['content'] = wp_kses_post($content);
}
$value['category'] = sanitize_text_field($request->get('category', ''));
if ($request->has('wp_pattern_category')) {
$categoryIds = (array) $request->get('wp_pattern_category', []);
$value['category'] = $this->resolveCategoryName($categoryIds);
}
if ($request->has('description')) {
$value['description'] = sanitize_text_field($request->get('description'));
}
if ($request->exists('sync_status')) {
$value['sync_status'] = sanitize_text_field($request->get('sync_status'));
}
$meta = $request->get('meta', []);
if (is_array($meta) && isset($meta['wp_pattern_sync_status'])) {
$value['sync_status'] = sanitize_text_field($meta['wp_pattern_sync_status']);
}
if ($title = $request->get('title')) {
if (is_array($title)) {
$title = Arr::get($title, 'raw', '');
}
if ($title) {
$pattern->key = 'fluentcrm/' . sanitize_title($title . '-' . $pattern->id);
}
}
$pattern->value = $value;
$pattern->save();
return $this->sendSuccess([
'message' => __('Pattern updated successfully', 'fluent-crm'),
'pattern' => $this->formatPattern($pattern),
]);
}
public function delete(Request $request, $id)
{
Meta::where('object_type', $this->objectType)
->where('id', $id)
->firstOrFail()
->delete();
return $this->sendSuccess([
'message' => __('Pattern deleted successfully', 'fluent-crm'),
]);
}
public function handleBulkAction(Request $request)
{
$actionName = sanitize_text_field($request->get('action_name'));
if ($actionName !== 'delete_patterns') {
return $this->sendError([
'message' => __('Invalid action', 'fluent-crm')
]);
}
$query = Meta::where('object_type', $this->objectType);
if (filter_var($request->get('select_all'), FILTER_VALIDATE_BOOLEAN)) {
$search = $request->getSafe('search', 'sanitize_text_field', '');
if ($search !== '') {
$query->where('value', 'LIKE', '%' . $search . '%');
}
} else {
$patternIds = array_map('intval', (array) $request->get('pattern_ids', []));
if (empty($patternIds)) {
return $this->sendError([
'message' => __('No patterns selected', 'fluent-crm')
]);
}
$query->whereIn('id', $patternIds);
}
$count = $query->delete();
return $this->sendSuccess([
'message' => sprintf(__('%d pattern(s) deleted successfully', 'fluent-crm'), $count)
]);
}
/**
* CRUD for pattern categories (stored as fc_meta with separate object_type).
*/
public function getCategories()
{
// Collect unique category names from all patterns
$patterns = Meta::where('object_type', $this->objectType)->get();
$categories = [];
foreach ($patterns as $pattern) {
$cat = Arr::get($pattern->value, 'category', '');
if ($cat && !in_array($cat, $categories)) {
$categories[] = $cat;
}
}
sort($categories);
return $this->sendSuccess([
'categories' => $categories
]);
}
public function storeCategory(Request $request)
{
$name = sanitize_text_field($request->get('name', ''));
if (!$name) {
return $this->sendError(['message' => __('Category name is required', 'fluent-crm')]);
}
$slug = sanitize_title($name);
// Check for existing
$existing = Meta::where('object_type', $this->categoryObjectType)
->where('key', $slug)
->first();
if ($existing) {
return $this->formatCategoryAsWpTerm($existing);
}
$category = Meta::create([
'object_type' => $this->categoryObjectType,
'object_id' => 0,
'key' => $slug,
'value' => ['name' => $name],
]);
return $this->formatCategoryAsWpTerm($category);
}
public function deleteCategory(Request $request, $id)
{
Meta::where('object_type', $this->categoryObjectType)
->where('id', $id)
->firstOrFail()
->delete();
return $this->sendSuccess([
'message' => __('Category deleted successfully', 'fluent-crm'),
]);
}
/**
* Format a pattern Meta record as a wp_block REST response.
*/
private function formatAsWpBlock($meta, $categoryMap = [])
{
$value = $meta->value;
$title = Arr::get($value, 'title', '');
$content = Arr::get($value, 'content', '');
$syncStatus = Arr::get($value, 'sync_status', 'unsynced');
$category = Arr::get($value, 'category', '');
$categoryIds = [];
if ($category) {
$catSlug = sanitize_title($category);
if (isset($categoryMap[$catSlug])) {
$categoryIds[] = (int) $categoryMap[$catSlug];
}
}
return [
'id' => (int) $meta->id,
'date' => $meta->created_at ? $meta->created_at : gmdate('Y-m-d\TH:i:s'),
'date_gmt' => $meta->created_at ? $meta->created_at : gmdate('Y-m-d\TH:i:s'),
'modified' => $meta->updated_at ? $meta->updated_at : gmdate('Y-m-d\TH:i:s'),
'modified_gmt' => $meta->updated_at ? $meta->updated_at : gmdate('Y-m-d\TH:i:s'),
'slug' => $meta->key,
'status' => 'publish',
'type' => 'wp_block',
'link' => '',
'title' => ['raw' => $title],
'content' => ['raw' => $content, 'protected' => false],
'meta' => new \stdClass(),
'wp_pattern_sync_status' => $syncStatus ?: '',
'wp_pattern_category' => $categoryIds,
];
}
private function formatCategoryAsWpTerm($meta)
{
$value = $meta->value;
return [
'id' => (int) $meta->id,
'count' => 0,
'name' => Arr::get($value, 'name', $meta->key),
'slug' => $meta->key,
'parent' => 0,
];
}
private function formatPattern($meta)
{
$value = $meta->value;
return [
'id' => (int) $meta->id,
'slug' => $meta->key,
'title' => Arr::get($value, 'title', ''),
'content' => Arr::get($value, 'content', ''),
'category' => Arr::get($value, 'category', ''),
'description' => Arr::get($value, 'description', ''),
'sync_status' => Arr::get($value, 'sync_status', 'unsynced'),
'created_at' => $meta->created_at ? (string) $meta->created_at : '',
'updated_at' => $meta->updated_at ? (string) $meta->updated_at : '',
];
}
/**
* Build slug → id map for all pattern categories.
*/
private function getCategoryMap()
{
$categories = Meta::where('object_type', $this->categoryObjectType)->get();
$map = [];
foreach ($categories as $cat) {
$map[$cat->key] = $cat->id;
}
return $map;
}
/**
* Resolve category IDs back to a single category name.
*/
private function resolveCategoryName($categoryIds)
{
if (empty($categoryIds)) {
return '';
}
$categoryIds = array_map('intval', $categoryIds);
$category = Meta::where('object_type', $this->categoryObjectType)
->whereIn('id', $categoryIds)
->first();
if ($category) {
return Arr::get($category->value, 'name', $category->key);
}
return '';
}
/**
* Get patterns formatted for the block editor boot data.
*/
public static function getEditorPatterns()
{
$patterns = Meta::where('object_type', 'email_pattern')
->orderBy('id', 'desc')
->get();
$editorPatterns = [];
$categories = [];
$seenCategories = [];
foreach ($patterns as $pattern) {
$value = $pattern->value;
$title = Arr::get($value, 'title', '');
$content = Arr::get($value, 'content', '');
$category = Arr::get($value, 'category', '');
$description = Arr::get($value, 'description', '');
if (!$content) {
continue;
}
$patternCategories = [];
if ($category) {
$catSlug = sanitize_title($category);
$patternCategories[] = $catSlug;
if (!isset($seenCategories[$catSlug])) {
$seenCategories[$catSlug] = true;
$categories[] = [
'name' => $catSlug,
'label' => $category,
];
}
}
// Always include in the general fluentcrm-patterns category
$patternCategories[] = 'fluentcrm-patterns';
$editorPatterns[] = [
'name' => $pattern->key,
'title' => $title,
'content' => $content,
'description' => $description,
'categories' => $patternCategories,
'keywords' => ['fluentcrm', 'email'],
];
}
// Always add the root category
array_unshift($categories, [
'name' => 'fluentcrm-patterns',
'label' => __('My Patterns', 'fluent-crm'),
]);
return [
'patterns' => $editorPatterns,
'categories' => $categories,
];
}
}
@@ -0,0 +1,464 @@
<?php
namespace FluentCrm\App\Http\Controllers;
use FluentCrm\App\Models\Funnel;
use FluentCrm\App\Models\Lists;
use FluentCrm\App\Models\Tag;
use FluentCrm\Framework\Support\Arr;
use FluentCrm\Framework\Http\Request\Request;
use FluentForm\App\Modules\Acl\Acl;
/**
* FormsController - REST API Handler Class
*
* REST API Handler
*
* @package FluentCrm\App\Http
*
* @version 1.0.0
*/
class FormsController extends Controller
{
/**
* Get all of the lists
*
* @param \FluentCrm\Framework\Http\Request\Request $request
* @return \WP_REST_Response|array
* @throws \WpFluent\Exception
*/
public function index(Request $request)
{
if (!defined('FLUENTFORM')) {
return [
'installed' => false,
'forms' => (object)[
'data' => [],
'total' => 0
]
];
}
// Now let's find the forms which are connected with Fluent Forms
$connectFeedForms = fluentCrmDb()->table('fluentform_form_meta')
->where('meta_key', 'fluentcrm_feeds')
->select(['form_id', 'id', 'value'])
->groupBy('form_id')
->get();
$formIds = [];
$connectedFormIds = [];
foreach ($connectFeedForms as $form) {
$formIds[] = $form->form_id;
$settings = json_decode($form->value, true);
$connectedFormIds[$form->form_id] = [
'feed_id' => $form->id,
'settings' => $settings
];
}
// Now let's get forms ids from funnel
$fluentFormFunnels = Funnel::where('trigger_name', 'fluentform_submission_inserted')
->get();
$connectedFunnelIds = [];
foreach ($fluentFormFunnels as $funnel) {
$formId = Arr::get($funnel->settings, 'form_id');
if ($formId) {
$connectedFunnelIds[$formId] = $funnel->id;
$formIds[] = $formId;
}
}
$formIds = array_unique($formIds);
$page = $request->get('page', 1);
$limit = $request->get('per_page', 10);
$offset = ($page - 1) * $limit;
$forms = [];
if ($formIds) {
$crmBaseUrl = fluentcrm_menu_url_base();
$search = sanitize_text_field($request->get('search', ''));
$allFormsQuery = fluentCrmDb()->table('fluentform_forms')
->whereIn('id', $formIds);
if ($search) {
$allFormsQuery->where('title', 'LIKE', '%' . $search . '%');
}
$allForms = $allFormsQuery->orderBy('id', 'DESC')
->limit($limit)
->offset($offset)
->get();
foreach ($allForms as $form) {
$funnelUrl = '';
$feedUrl = '';
$associateTags = [];
$associateList = '';
if (isset($connectedFunnelIds[$form->id])) {
$funnelUrl = $crmBaseUrl . 'funnel/' . $connectedFunnelIds[$form->id] . '/edit';
}
if (isset($connectedFormIds[$form->id])) {
$feedUrl = admin_url('admin.php?page=fluent_forms&form_id=' . $form->id . '&route=settings&sub_route=form_settings#/all-integrations/' . $connectedFormIds[$form->id]['feed_id'] . '/fluentcrm');
$tagIds = Arr::get($connectedFormIds[$form->id], 'settings.tag_ids');
if ($tagIds) {
$tags = Tag::whereIn('id', $tagIds)->get();
foreach ($tags as $tag) {
$associateTags[] = $tag->title;
}
}
$listId = Arr::get($connectedFormIds[$form->id], 'settings.list_id');
if ($listId && $list = Lists::find($listId)) {
$associateList = $list->title;
}
}
$forms[] = [
'id' => $form->id,
'title' => $form->title,
'status' => $form->status,
'created_at' => $form->created_at,
'funnel_url' => $funnelUrl,
'feed_url' => $feedUrl,
'associate_tags' => implode(', ', $associateTags),
'associate_lists' => $associateList,
'shortcode' => '[fluentform id="' . $form->id . '"]',
'edit_url' => admin_url('admin.php?page=fluent_forms&route=editor&form_id=' . $form->id),
'preview_url' => site_url('?fluent_forms_pages=1&design_mode=1&preview_id=' . $form->id)
];
}
}
$total = count($formIds);
return [
'installed' => true,
'forms' => [
'data' => $forms,
'page' => $page,
'per_page' => $limit,
'total' => $total,
'last_page' => ceil($total / $limit)
]
];
}
public function create(Request $request)
{
$form = $this->validate($request->all(), [
'template_id' => 'required',
'title' => 'required|unique:fluentform_forms',
'selected_tags' => 'required',
'selected_list' => 'required'
]);
$template = $this->getSelectedTemplate($form['template_id']);
$now = current_time('mysql');
$formData = [
'title' => $form['title'],
'status' => 'published',
'type' => 'form',
'created_by' => get_current_user_id(),
'created_at' => $now,
'updated_at' => $now,
'form_fields' => $template['form_fields']
];
$formId = fluentCrmDb()->table('fluentform_forms')->insertGetId($formData);
if ($template['custom_css']) {
fluentCrmDb()->table('fluentform_form_meta')
->insert([
'form_id' => $formId,
'meta_key' => '_custom_form_css',
'value' => $template['custom_css']
]);
}
$defaultSettings = (new \FluentForm\App\Modules\Form\Form(wpFluentForm()))->getFormsDefaultSettings();
if ($form['double_optin']) {
$defaultSettings['confirmation']['messageToShow'] = __('Please check your inbox to confirm your subscription', 'fluent-crm');
} else {
$defaultSettings['confirmation']['messageToShow'] = __('You are successfully subscribed to our email list', 'fluent-crm');
}
fluentCrmDb()->table('fluentform_form_meta')
->insert(array(
'form_id' => $formId,
'meta_key' => 'formSettings',
'value' => json_encode($defaultSettings)
));
$feedDefaults = [
'name' => __('FluentCRM Integration Feed', 'fluent-crm'),
'first_name' => '',
'last_name' => '',
'email' => 'email',
'other_fields' => [
[
'item_value' => '',
'label' => ''
]
],
'list_id' => $form['selected_list'],
'tag_ids' => $form['selected_tags'],
'skip_if_exists' => false,
'double_opt_in' => $form['double_optin'],
'conditionals' => [
'conditions' => [],
'status' => false,
'type' => 'all'
],
'enabled' => true,
'status' => true
];
if (is_array($template['map_fields'])) {
$feedDefaults = wp_parse_args($template['map_fields'], $feedDefaults);
}
$feedData = [
'meta_key' => 'fluentcrm_feeds',
'form_id' => $formId,
'value' => \json_encode($feedDefaults)
];
$createdFeedId = fluentCrmDb()->table('fluentform_form_meta')
->insertGetId($feedData);
do_action('fluentform/inserted_new_form', $formId, $formData);
do_action('fluentcrm_created_new_fluentform', $formId, $formData);
$feedUrl = admin_url('admin.php?page=fluent_forms&form_id=' . $formId . '&route=settings&sub_route=form_settings#/all-integrations/' . $createdFeedId . '/fluentcrm');
return [
'message' => __('Form has been created', 'fluent-crm'),
'created_form' => [
'id' => $formId,
'shortcode' => '[fluentform id="' . $formId . '"]',
'feed_url' => $feedUrl,
'edit_url' => admin_url('admin.php?page=fluent_forms&route=editor&form_id=' . $formId),
'preview_url' => site_url('?fluent_forms_pages=1&design_mode=1&preview_id=' . $formId)
]
];
}
public function getTemplates()
{
$templates = [
'inline_subscribe' => [
'label' => __('Inline Opt-in Form', 'fluent-crm'),
'image' => fluentCrmMix('images/forms/form_1.svg'),
'id' => 'inline_subscribe',
'form_fields' => '{"fields":[{"index":1,"element":"input_email","attributes":{"type":"email","name":"email","value":"","id":"","class":"extra_spaced","placeholder":"Email Address"},"settings":{"container_class":"","label":"","label_placement":"","help_message":"","admin_field_label":"Email Address","validation_rules":{"required":{"value":true,"message":"This field is required"},"email":{"value":true,"message":"This field must contain a valid email"}},"conditional_logics":{"type":"any","status":false,"conditions":[{"field":"","value":"","operator":""}]},"is_unique":"no","unique_validation_message":"Email address need to be unique."},"editor_options":{"title":"Email Address","icon_class":"ff-edit-email","template":"inputText"},"uniqElKey":"el_1601142291509"}],"submitButton":{"uniqElKey":"el_1524065200616","element":"button","attributes":{"type":"submit","class":""},"settings":{"align":"left","button_style":"default","container_class":"top_merged","help_message":"","background_color":"#409EFF","button_size":"md","color":"#ffffff","button_ui":{"type":"default","text":"Subscribe","img_url":""},"normal_styles":{"backgroundColor":"#409EFF","borderColor":"#409EFF","color":"#ffffff","borderRadius":"","minWidth":""},"hover_styles":{"backgroundColor":"#ffffff","borderColor":"#409EFF","color":"#409EFF","borderRadius":"","minWidth":""},"current_state":"normal_styles"},"editor_options":{"title":"Submit Button"}}}',
'custom_css' => $this->getFormCss('inline_subscribe'),
'map_fields' => [
'email' => 'email'
]
],
'simple_optin' => [
'label' => __('Simple Opt-in Form', 'fluent-crm'),
'image' => fluentCrm('url.assets') . 'images/forms/form_2.svg',
'id' => 'simple_optin',
'form_fields' => '{"fields":[{"index":1,"element":"input_email","attributes":{"type":"email","name":"email","value":"","id":"","class":"","placeholder":"Your Email Address"},"settings":{"container_class":"","label":"","label_placement":"","help_message":"","admin_field_label":"Email Address","validation_rules":{"required":{"value":true,"message":"This field is required"},"email":{"value":true,"message":"This field must contain a valid email"}},"conditional_logics":[],"is_unique":"no","unique_validation_message":"Email address need to be unique."},"editor_options":{"title":"Email Address","icon_class":"ff-edit-email","template":"inputText"},"uniqElKey":"el_16011431576720.7540920979222681"}],"submitButton":{"uniqElKey":"el_1524065200616","element":"button","attributes":{"type":"submit","class":""},"settings":{"align":"left","button_style":"default","container_class":"","help_message":"","background_color":"#409EFF","button_size":"md","color":"#ffffff","button_ui":{"type":"default","text":"Subscribe To Newsletter","img_url":""},"normal_styles":{"backgroundColor":"#409EFF","borderColor":"#409EFF","color":"#ffffff","borderRadius":"","minWidth":""},"hover_styles":{"backgroundColor":"#ffffff","borderColor":"#409EFF","color":"#409EFF","borderRadius":"","minWidth":""},"current_state":"normal_styles"},"editor_options":{"title":"Submit Button"}}}',
'custom_css' => '',
'map_fields' => [
'email' => 'email'
]
],
'with_name_subscribe' => [
'label' => __('Subscription Form', 'fluent-crm'),
'image' => fluentCrm('url.assets') . 'images/forms/form_3.svg',
'id' => 'with_name_subscribe',
'form_fields' => '{"fields":[{"index":0,"element":"input_name","attributes":{"name":"names","data-type":"name-element"},"settings":{"container_class":"","admin_field_label":"Name","conditional_logics":{"type":"any","status":false,"conditions":[{"field":"","value":"","operator":""}]},"label_placement":""},"fields":{"first_name":{"element":"input_text","attributes":{"type":"text","name":"first_name","value":"","id":"","class":"","placeholder":"First Name"},"settings":{"container_class":"","label":"First Name","help_message":"","visible":true,"validation_rules":{"required":{"value":false,"message":"This field is required"}},"conditional_logics":[]},"editor_options":{"template":"inputText"}},"middle_name":{"element":"input_text","attributes":{"type":"text","name":"middle_name","value":"","id":"","class":"","placeholder":"","required":false},"settings":{"container_class":"","label":"Middle Name","help_message":"","error_message":"","visible":false,"validation_rules":{"required":{"value":false,"message":"This field is required"}},"conditional_logics":[]},"editor_options":{"template":"inputText"}},"last_name":{"element":"input_text","attributes":{"type":"text","name":"last_name","value":"","id":"","class":"","placeholder":"Last Name","required":false},"settings":{"container_class":"","label":"Last Name","help_message":"","error_message":"","visible":true,"validation_rules":{"required":{"value":false,"message":"This field is required"}},"conditional_logics":[]},"editor_options":{"template":"inputText"}}},"editor_options":{"title":"Name Fields","element":"name-fields","icon_class":"ff-edit-name","template":"nameFields"},"uniqElKey":"el_1570866006692"},{"index":1,"element":"input_email","attributes":{"type":"email","name":"email","value":"","id":"","class":"","placeholder":"Email Address"},"settings":{"container_class":"","label":"Email","label_placement":"","help_message":"","admin_field_label":"","validation_rules":{"required":{"value":true,"message":"This field is required"},"email":{"value":true,"message":"This field must contain a valid email"}},"conditional_logics":{"type":"any","status":false,"conditions":[{"field":"","value":"","operator":""}]},"is_unique":"no","unique_validation_message":"Email address need to be unique."},"editor_options":{"title":"Email Address","icon_class":"ff-edit-email","template":"inputText"},"uniqElKey":"el_1570866012914"}],"submitButton":{"uniqElKey":"el_1524065200616","element":"button","attributes":{"type":"submit","class":""},"settings":{"align":"left","button_style":"default","container_class":"","help_message":"","background_color":"#409EFF","button_size":"md","color":"#ffffff","button_ui":{"type":"default","text":"Subscribe","img_url":""},"normal_styles":{"backgroundColor":"#409EFF","borderColor":"#409EFF","color":"#ffffff","borderRadius":"","minWidth":""},"hover_styles":{"backgroundColor":"#ffffff","borderColor":"#409EFF","color":"#409EFF","borderRadius":"","minWidth":""},"current_state":"normal_styles"},"editor_options":{"title":"Submit Button"}}}',
'custom_css' => '',
'map_fields' => [
'email' => 'email',
'first_name' => '{inputs.names.first_name}',
'last_name' => '{inputs.names.last_name}'
]
]
];
/**
* Define the form templates for FluentCRM Forms(Fluent Forms).
*
* This filter allows customization of the Fluent Forms templates used in FluentCRM.
*
* @param array {
* An array of form templates.
*
* @type array $inline_subscribe {
* Inline Opt-in Form template.
* @type string $label The label for the form.
* @type string $image The URL of the form image.
* @type string $id The ID of the form.
* @type string $form_fields The JSON string of form fields.
* @type string $custom_css The custom CSS for the form.
* @type array $map_fields The mapping of form fields.
* }
* @type array $simple_optin {
* Simple Opt-in Form template.
* @type string $label The label for the form.
* @type string $image The URL of the form image.
* @type string $id The ID of the form.
* @type string $form_fields The JSON string of form fields.
* @type string $custom_css The custom CSS for the form.
* @type array $map_fields The mapping of form fields.
* }
* @type array $with_name_subscribe {
* Subscription Form template.
* @type string $label The label for the form.
* @type string $image The URL of the form image.
* @type string $id The ID of the form.
* @type string $form_fields The JSON string of form fields.
* @type string $custom_css The custom CSS for the form.
* @type array $map_fields The mapping of form fields.
* }
* }
* @since 2.7.0
*
*/
return apply_filters('fluent_crm/ff_form_templates', [
'templates' => $templates
]);
}
private function getFormCss($name)
{
$css = '';
if ($name == 'inline_subscribe') {
$css = '.fluent_form_FF_ID {
position: relative;
}
.fluent_form_FF_ID .top_merged.ff_submit_btn_wrapper {
position: absolute;
top: 5px;
right: 5px;
}
.fluent_form_FF_ID .extra_spaced {
padding: 12px 15px !important;
}';
}
return $css;
}
private function getSelectedTemplate($templateId)
{
$templates = $this->getTemplates();
if (isset($templates['templates'][$templateId])) {
return $templates['templates'][$templateId];
}
$templatesArray = array_values($templates['templates']);
return $templatesArray[0];
}
public function getEntries(Request $request, $id)
{
if (!defined('FLUENTFORM')) {
return $this->sendError([
'message' => __('Fluent Forms is not installed', 'fluent-crm'),
'entries' => []
]);
}
if (!Acl::hasPermission('fluentform_entries_viewer', $id)) {
return $this->sendError([
'message' => __('You do not have permission to view these entries', 'fluent-crm'),
'entries' => []
]);
}
// Check if form exists
$form = fluentCrmDb()->table('fluentform_forms')
->where('id', $id)
->first();
if (!$form) {
return $this->sendError([
'message' => __('Form not found', 'fluent-crm'),
'entries' => []
]);
}
$page = $request->get('page', 1);
$limit = $request->get('per_page', 10);
$offset = ($page - 1) * $limit;
$search = sanitize_text_field($request->get('search', ''));
// Get total count
$totalQuery = fluentCrmDb()->table('fluentform_submissions')
->where('form_id', $id);
if ($search) {
$totalQuery->where(function ($query) use ($search) {
$query->where('response', 'LIKE', '%' . $search . '%')
->orWhere('status', 'LIKE', '%' . $search . '%');
});
}
// Get entries
$entriesQuery = fluentCrmDb()->table('fluentform_submissions')
->where('form_id', $id);
if ($search) {
$entriesQuery->where(function ($query) use ($search) {
$query->where('response', 'LIKE', '%' . $search . '%')
->orWhere('status', 'LIKE', '%' . $search . '%');
});
}
$total = $totalQuery->count();
$entries = $entriesQuery->orderBy('id', 'DESC')
->limit($limit)
->offset($offset)
->get();
// Format entries
$formattedEntries = [];
foreach ($entries as $entry) {
$response = json_decode($entry->response, true);
$formattedEntries[] = [
'id' => $entry->id,
'serial_number' => $entry->serial_number,
'status' => $entry->status,
'created_at' => $entry->created_at,
'response' => $response,
'user_id' => $entry->user_id,
'browser' => $entry->browser,
'device' => $entry->device,
'ip' => $entry->ip,
'entry_url' => admin_url('admin.php?page=fluent_forms&form_id=' . $id . '&route=entries#/entries/' . $entry->id)
];
}
return [
'entries' => [
'data' => $formattedEntries,
'page' => $page,
'per_page' => $limit,
'total' => $total,
'last_page' => ceil($total / $limit)
],
'form' => [
'id' => $form->id,
'title' => $form->title
]
];
}
public function getEntry(Request $request, $formId, $id)
{
$dataView = apply_filters('fluent_crm/dynamic_contact_item_view_fluentform', [
'content_html' => 'No data found'
], [
'__id' => $id
]);
return [
'entry' => $dataView
];
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,149 @@
<?php
namespace FluentCrm\App\Http\Controllers;
use FluentCrm\App\Models\Funnel;
use FluentCrm\App\Models\Label;
use FluentCrm\App\Models\TermRelation;
use FluentCrm\Framework\Http\Request\Request;
use FluentCrm\Framework\Support\Arr;
/**
* FunnelLabelController - REST API Handler Class
*
* REST API Handler
*
* @package FluentCrm\App\Http
*
* @version 2.9.25
*/
class GlobalLabelController extends Controller
{
public function getLabels()
{
$labels = Label::orderBy('position', 'ASC')->get();
return [
'labels' => $labels
];
}
public function create(Request $request)
{
$data = Arr::get($request->all(), 'label');
// sanitize the data
$labelData = [
'slug' => sanitize_text_field($data['slug']),
'title' => sanitize_text_field($data['title']),
];
$color = sanitize_hex_color($data['color']);
$labelData['settings'] = [
'color' => $color
];
$label = Label::create($labelData);
return [
'label' => $label,
'message' => __('Label has been created successfully', 'fluent-crm')
];
}
public function update(Request $request, $id)
{
$data = Arr::get($request->all(), 'label');
$label = Label::findOrFail($id);
// sanitize the data
$labelData = [
'slug' => sanitize_text_field($data['slug']),
'title' => sanitize_text_field($data['title']),
];
$color = sanitize_hex_color($data['color']);
$labelData['settings'] = [
'color' => $color
];
$label->update($labelData);
return [
'label' => $label,
'message' => __('Labels have been updated successfully', 'fluent-crm')
];
}
public function delete(Request $request, $id)
{
$label = Label::findOrFail($id);
if ($label) {
$label->delete();
}
return [
'message' => __('Label has been deleted successfully', 'fluent-crm')
];
}
public function deleteLabel(Request $request)
{
$funnelId = $request->getSafe('funnel_id', 'intval');
$labelSlug = $request->getSafe('label_slug');
$action = $request->getSafe('action');
if (!$labelSlug) {
return [
'message' => __('Please provide label slug', 'fluent-crm')
];
}
switch ($action) {
case 'delete_from_funnel':
$this->deleteLabelFromFunnel($funnelId, $labelSlug);
return [
'message' => __('Removed from funnel successfully', 'fluent-crm')
];
case 'delete_from_funnel_label':
$this->deleteLabelFromFunnelLabel($labelSlug);
return [
'message' => __('Label has been deleted successfully', 'fluent-crm')
];
default:
return [
'message' => __('Invalid Action', 'fluent-crm')
];
}
}
protected function deleteLabelFromFunnel($funnelId, $slug)
{
$funnel = Funnel::findOrFail($funnelId);
$label = Label::where('slug', $slug)->first();
if (!$label) {
return;
}
$funnel->detachLabels([$label->id]);
}
protected function deleteLabelFromFunnelLabel($slug)
{
$label = Label::where('slug', $slug)->first();
if (!$label) {
return;
}
TermRelation::where('term_id', $label->id)
->where('object_type', Funnel::class)
->delete();
$label->delete();
}
}
@@ -0,0 +1,386 @@
<?php
namespace FluentCrm\App\Http\Controllers;
use FluentCrm\App\Services\Helper;
use FluentCrm\Framework\Support\Arr;
use FluentCrm\Framework\Http\Request\Request;
use FluentCrm\App\Models\Subscriber;
/**
* ImporterController - REST API Handler Class
*
* REST API Handler
*
* @package FluentCrm\App\Http
*
* @version 1.0.0
*/
class ImporterController extends Controller
{
public function getDrivers()
{
/**
* Determine the list of contact import providers for FluentCRM.
*
* This filter allows you to modify the list of available import providers
* in the FluentCRM plugin. By default, it includes CSV File and WordPress Users.
*
* @since 2.7.0
*
* @param array {
* An associative array of import providers.
*
* @type array csv {
* Details for the CSV File import provider.
*
* @type string $label The label for the provider.
* @type string $logo The URL to the provider's logo.
* @type bool $disabled Whether the provider is disabled.
* }
* @type array users {
* Details for the WordPress Users import provider.
*
* @type string $label The label for the provider.
* @type string $logo The URL to the provider's logo.
* @type bool $disabled Whether the provider is disabled.
* }
* }
*/
$drivers = apply_filters('fluent_crm/import_providers', [
'csv' => [
'label' => __('CSV File', 'fluent-crm'),
'logo' => fluentCrmMix('images/csv.svg'),
'disabled' => false
],
'users' => [
'label' => __('WordPress Users', 'fluent-crm'),
'logo' => fluentCrmMix('images/wordpress.svg'),
'disabled' => false
]
]);
if (defined('FLUENTCART_VERSION')) {
$drivers['fluent_cart'] = [
'label' => __('FluentCart', 'fluent-crm'),
'logo' => fluentCrmMix('images/fluent-cart-dark.svg'),
'disabled' => false
];
}
if ($proDrivers = $this->getProDrivers()) {
$drivers = array_merge($drivers, $proDrivers);
}
return [
'drivers' => $drivers
];
}
public function getDriver(Request $request, $driver)
{
if ($driver == 'users') {
return $this->processUserDriver($request);
}
/**
* Determine the import driver response (CSV).
*
* This filter allows modification of the import driver response based on the specified driver.
*
* @since 2.7.0
*
* @param bool The response to be filtered or not. Default false.
* @param object $request The request object containing import data.
*/
$response = apply_filters('fluent_crm/get_import_driver_' . $driver, false, $request);
if (!$response || is_wp_error($response)) {
$message = __('Sorry no driver found for this import', 'fluent-crm');
if (is_wp_error($response)) {
$message = $response->get_error_message();
}
return $this->sendError([
'message' => $message
]);
}
return $response;
}
public function importData(Request $request, $driver)
{
$config = $request->get('config', []);
$page = $request->getSafe('importing_page', 'intval', 1);
if ($driver == 'users') {
return $this->processUserImport($config, $page);
}
/**
* Determine the response after importing data using a specific driver (CSV).
*
* This filter allows you to modify the response after the import process
* using a specified driver.
*
* @since 2.7.0
*
* @param bool The response to be filtered or not. Default false.
* @param array $config The configuration array for the import process.
* @param int $page The current page number being processed.
*/
$response = apply_filters('fluent_crm/post_import_driver_' . $driver, false, $config, $page);
if (!$response || is_wp_error($response)) {
$message = __('Sorry no driver found for this import', 'fluent-crm');
if (is_wp_error($response)) {
$message = $response->get_error_message();
}
return $this->sendError([
'message' => $message
]);
}
return $response;
}
private function processUserDriver($request)
{
$summary = $request->get('summary');
if ($summary) {
$config = $request->get('config');
$userQuery = new \WP_User_Query([
'role__in' => Arr::get($config, 'roles'),
'number' => 5,
'fields' => ['ID', 'display_name', 'user_email'],
]);
$users = $userQuery->get_results();
$total = $userQuery->get_total();
$formattedUsers = [];
foreach ($users as $user) {
$formattedUsers[] = [
'name' => $user->display_name,
'email' => $user->user_email
];
}
return $this->send([
'import_info' => [
'subscribers' => $formattedUsers,
'total' => $total,
'has_list_config' => true,
'has_tag_config' => true,
'has_status_config' => true,
'has_update_config' => true,
'has_silent_config' => true
]]);
}
if (!function_exists('get_editable_roles')) {
require_once(ABSPATH . '/wp-admin/includes/user.php');
}
$roles = \get_editable_roles();
$formattedRoles = [];
foreach ($roles as $roleKey => $role) {
$formattedRoles[] = [
'id' => $roleKey,
'label' => $role['name']
];
}
$infoSvg = '<span class="fc-inline-help-icon" aria-hidden="true" style="display:inline-flex;vertical-align:middle">'
. '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16" fill="none">'
. '<path d="M8 14C4.6862 14 2 11.3138 2 8C2 4.6862 4.6862 2 8 2C11.3138 2 14 4.6862 14 8C14 11.3138 11.3138 14 8 14ZM7.4 7.4V11H8.6V7.4H7.4ZM7.4 5V6.2H8.6V5H7.4Z" fill="currentColor"/>'
. '</svg></span>';
return [
'config' => [
'roles' => []
],
'fields' => [
'roles' => [
'label' => __('Select User Roles', 'fluent-crm'),
'inline_help' => $infoSvg . ' ' . __('Please check the user roles that you want to import as contact', 'fluent-crm'),
'type' => 'checkbox-group',
'options' => $formattedRoles,
'has_all_selector' => true,
'all_selector_label' => __('All', 'fluent-crm')
]
],
'labels' => [
'step_2' => __('Next [Review Data]', 'fluent-crm'),
'step_3' => __('Import Users Now', 'fluent-crm')
]
];
}
private function processUsers($users, $inputs)
{
$subscribers = [];
foreach ($users as $user) {
$subscriber = Helper::getWPMapUserInfo($user);
$subscriber['source'] = 'wp_users';
if (isset($subscriber['email']) && $subscriber['email']) {
$subscribers[] = $subscriber;
}
}
$sendDoubleOptin = Arr::get($inputs, 'double_optin_email') == 'yes';
return Subscriber::import(
$subscribers,
Arr::get($inputs, 'tags', []),
Arr::get($inputs, 'lists', []),
Arr::get($inputs, 'update', ''),
Arr::get($inputs, 'status', ''),
$sendDoubleOptin
);
}
private function processUserImport($config, $page)
{
$inputs = Arr::only($config, [
'map', 'tags', 'lists', 'roles', 'update', 'status', 'double_optin_email', 'import_silently'
]);
/**
* Determine the number of subscribers to process per request while importing.
*
* This filter allows you to modify the number of subscribers that are processed in each request.
*
* @since 2.7.0
*
* @param int $limit The number of subscribers to process per request. Default is 100.
*/
$limit = apply_filters('fluent_crm/import_users_limit_per_request', 100);
$userQuery = new \WP_User_Query([
'role__in' => $inputs['roles'],
'number' => $limit,
'offset' => ($page - 1) * $limit
]);
if (Arr::get($inputs, 'import_silently') == 'yes') {
if (!defined('FLUENTCRM_DISABLE_TAG_LIST_EVENTS')) {
define('FLUENTCRM_DISABLE_TAG_LIST_EVENTS', true);
}
}
$total = $userQuery->get_total();
$users = $userQuery->get_results();
if ($users) {
$this->processUsers($users, $inputs);
}
$hasRecords = !!count($users);
return $this->sendSuccess([
'page_total' => ceil($total / $limit),
'record_total' => $total,
'has_more' => $hasRecords,
'current_page' => $page,
'next_page' => $page + 1
]);
}
private function getProDrivers()
{
$drivers = [];
if (defined('FLUENTCAMPAIGN')) {
return $drivers;
}
if (defined('LLMS_PLUGIN_FILE')) {
$drivers['lifterlms'] = [
'label' => __('LifterLMS', 'fluent-crm'),
'logo' => fluentCrmMix('images/lifterlms.png'),
'disabled' => true,
'disabled_message' => __('Import LifterLMS students by course and groups then segment by associate tags. This is a pro feature. Please upgrade to activate this feature', 'fluent-crm')
];
}
if (defined('LEARNDASH_VERSION')) {
$drivers['learndash'] = [
'label' => __('LearnDash', 'fluent-crm'),
'logo' => fluentCrmMix('images/learndash.png'),
'disabled' => true,
'disabled_message' => __('Import LearnDash students by course and groups then segment by associate tags. This is a pro feature. Please upgrade to activate this feature', 'fluent-crm')
];
}
if (defined('TUTOR_VERSION')) {
$drivers['tutorlms'] = [
'label' => __('TutorLMS', 'fluent-crm'),
'logo' => fluentCrmMix('images/tutorlms.jpg'),
'disabled' => true,
'disabled_message' => __('Import TutorLMS students by course then segment by associate tags. This is a pro feature. Please upgrade to activate this feature', 'fluent-crm')
];
}
if (defined('PMPRO_VERSION')) {
$drivers['pmpro'] = [
'label' => __('Paid Membership Pro', 'fluent-crm'),
'logo' => fluentCrmMix('images/pmpro.png'),
'disabled' => true,
'disabled_message' => __('Import Paid Membership Pro members by membership levels then segment by associate tags. This is a pro feature. Please upgrade to activate this feature', 'fluent-crm')
];
}
if (defined('WLM3_PLUGIN_VERSION')) {
$drivers['wishlist_member'] = [
'label' => __('Wishlist member', 'fluent-crm'),
'logo' => fluentCrmMix('images/wishlist_member.png'),
'disabled' => true,
'disabled_message' => __('Import Wishlist members by membership levels then segment by associate tags. This is a pro feature. Please upgrade to activate this feature', 'fluent-crm')
];
}
if (class_exists('\Restrict_Content_Pro')) {
$drivers['rcp'] = [
'label' => __('Restrict Content Pro', 'fluent-crm'),
'logo' => fluentCrmMix('images/rcp.png'),
'disabled' => true,
'disabled_message' => __('Import Restrict Content Pro members by membership levels then segment by associate tags. This is a pro feature. Please upgrade to activate this feature', 'fluent-crm')
];
}
if (defined('BP_REQUIRED_PHP_VERSION') && function_exists('\buddypress')) {
$pluginName = 'BuddyPress';
$logo = fluentCrmMix('images/buddypress.png');
if (defined('BP_PLATFORM_VERSION')) {
$pluginName = 'BuddyBoss';
$logo = fluentCrmMix('images/buddyboss.svg');
}
$drivers['buddypress'] = [
'label' => $pluginName,
'logo' => $logo,
'disabled' => true,
/* translators: %s: plugin name */
'disabled_message' => sprintf(__('Import %s members by member groups and member types then segment by associate tags. This is a pro feature. Please upgrade to activate this feature', 'fluent-crm'), $pluginName)
];
}
if (defined('LP_PLUGIN_FILE')) {
$drivers['learnpress'] = [
'label' => __('LearnPress', 'fluent-crm'),
'logo' => fluentCrmMix('images/learnpress.png'),
'disabled' => true,
'disabled_message' => __('Import LearnPress students by course then segment by associate tags. This is a pro feature. Please upgrade to activate this feature', 'fluent-crm')
];
}
return $drivers;
}
}
@@ -0,0 +1,260 @@
<?php
namespace FluentCrm\App\Http\Controllers;
use FluentCrm\App\Models\Lists;
use FluentCrm\App\Models\Subscriber;
use FluentCrm\App\Services\Helper;
use FluentCrm\Framework\Support\Arr;
use FluentCrm\Framework\Http\Request\Request;
/**
* ListsController - REST API Handler Class
*
* REST API Handler
*
* @package FluentCrm\App\Http
*
* @version 1.0.0
*/
class ListsController extends Controller
{
/**
* Get all of the lists
*
* @param \FluentCrm\Framework\Http\Request\Request $request
* @return \WP_REST_Response
*/
public function index(Request $request)
{
$with = $request->get('with', []);
$order = [
'by' => $request->getSafe('sort_by', 'sanitize_sql_orderby', 'id'),
'order' => $request->getSafe('sort_order', 'sanitize_sql_orderby', 'DESC')
];
$paginatedLists = Lists::orderBy($order['by'], $order['order'])
->searchBy($request->getSafe('search'))
->paginate();
$lists = $paginatedLists->items();
if (!$request->get('exclude_counts')) {
foreach ($lists as $list) {
$list->totalCount = $list->totalCount();
$list->subscribersCount = $list->countByStatus('subscribed');
}
}
$data = [
'lists' => $lists,
'pagination' => [
'total' => $paginatedLists->total(),
]
];
if ($request->get('all_lists')) {
$allLists = Lists::get();
$formattedLists = [];
foreach ($allLists as $list) {
$formattedLists[] = [
'id' => strval($list->id),
'title' => $list->title,
'slug' => $list->slug,
'description' => $list->description
];
}
$data['all_lists'] = $formattedLists;
}
return $this->send($data);
}
/**
* Find a list.
*
* @param \FluentCrm\Framework\Http\Request\Request $request
* @param int $id
* @return \WP_REST_Response
*/
public function find(Request $request, $id)
{
return $this->send(Lists::find($id));
}
/**
* Store a list.
*
* @param \FluentCrm\Framework\Http\Request\Request $request
* @return \WP_REST_Response
*/
public function create(Request $request)
{
$allData = $request->all();
if (empty($allData['slug'])) {
if ($allData['title']) {
$allData['slug'] = sanitize_text_field($allData['title']);
}
}
$data = $this->validate($allData, [
'title' => 'required',
'slug' => "required|unique:fc_lists,slug"
]);
$list = Lists::create([
'title' => sanitize_text_field($allData['title']),
'slug' => sanitize_title($data['slug'], 'display'),
'description' => sanitize_textarea_field(Arr::get($allData, 'description'))
]);
do_action('fluentcrm_list_created', $list->id);
do_action('fluent_crm/list_created', $list);
return $this->send([
'lists' => $list,
'item' => $list,
'message' => __('Successfully saved the list.', 'fluent-crm')
]);
}
/**
* Store a list.
*
* @param \FluentCrm\Framework\Http\Request\Request $request
* @param $id int
* @return \WP_REST_Response
*/
public function update(Request $request, $id)
{
$allData = $this->validate($request->all(), [
'title' => 'required'
]);
if(!empty($allData['slug'])) {
$allData['slug'] = Helper::slugify($allData['title']);
}
if ($id == 0 && $request->get('update_by') == 'slug' && !empty($allData['slug'])) {
$list = Lists::where('slug', $allData['slug'])->first();
if (!$list) {
return $this->sendError([
'message' => __('List could not be found', 'fluent-crm')
]);
}
$id = $list->id;
} else {
$list = Lists::findOrFail($id);
if(empty($allData['slug'])) {
$allData['slug'] = $list->slug;
}
}
if (Lists::where('slug', $allData['slug'])->where('id', '!=', $id)->first()) {
return $this->sendError([
'message' => __('Provided slug already exists in another list', 'fluent-crm')
]);
}
$list = Lists::where('id', $id)->update([
'title' => sanitize_text_field($allData['title']),
'slug' => $allData['slug'],
'description' => sanitize_textarea_field(Arr::get($allData, 'description')),
]);
do_action('fluentcrm_list_updated', $id);
do_action('fluent_crm/list_updated', $list);
return $this->send([
'lists' => $list,
'message' => __('Successfully saved the list.', 'fluent-crm'),
]);
}
/**
* Bulk store lists.
*
* @param \FluentCrm\Framework\Http\Request\Request $request
* @return \WP_REST_Response
*/
public function storeBulk(Request $request)
{
$lists = $request->get('lists', []);
if (empty($lists)) {
$lists = $this->request->get('items', []);
}
$createdIds = [];
foreach ($lists as $list) {
if (empty($list['title'])) {
continue;
}
if (empty($list['slug'])) {
$list['slug'] = Helper::slugify($list['title']);
}
$list = Lists::updateOrCreate(
['slug' => sanitize_title($list['slug'], 'display')],
['title' => sanitize_text_field($list['title'])]
);
$createdIds[] = $list->id;
if($list->wasRecentlyCreated) {
do_action('fluentcrm_list_created', $list->id);
do_action('fluent_crm/list_created', $list);
} else {
do_action('fluentcrm_list_updated', $list->id);
do_action('fluent_crm/list_updated', $list);
}
}
return $this->sendSuccess([
'message' => __('Provided Lists have been successfully created', 'fluent-crm'),
'ids' => $createdIds
]);
}
/**
* Delete a list
*
* @param \FluentCrm\Framework\Http\Request\Request $request
* @param int $id
* @return \WP_REST_Response
*/
public function remove(Request $request, $id)
{
Lists::where('id', $id)->delete();
do_action('fluent_crm/list_deleted', $id);
do_action('fluentcrm_list_deleted', $id);
return $this->send([
'message' => __('Successfully removed the list.', 'fluent-crm')
]);
}
public function handleBulkAction(Request $request)
{
$listIds = array_map('intval', (array)$request->get('listIds', []));
$listIds = array_unique(array_filter($listIds));
foreach ($listIds as $listId) {
Lists::where('id', $listId)->delete();
do_action('fluent_crm/list_deleted', $listId);
do_action('fluentcrm_list_deleted', $listId);
}
return $this->sendSuccess([
'message' => __('Selected Lists have been removed permanently', 'fluent-crm'),
]);
}
}
@@ -0,0 +1,431 @@
<?php
namespace FluentCrm\App\Http\Controllers;
use FluentCrm\App\Modules\MCP\AbilitiesRegistrar;
use FluentCrm\App\Modules\MCP\MCPInit;
use FluentCrm\Framework\Http\Request\Request;
/**
* Settings → MCP admin endpoints (MCP_PLAN.md § 13).
*
* Surfaces:
* - status: adapter detected? CRM count? enabled toggle?
* - install-adapter: one-click install of the WP MCP Adapter plugin
* - toggle: enable/disable the entire MCP module without uninstalling
* - config-snippet: pre-filled JSON for Claude Desktop / Claude Code /
* Cursor / generic clients
*/
class MCPSettingsController extends Controller
{
const ADAPTER_PLUGIN_FILE = 'mcp-adapter/mcp-adapter.php';
const TOOLKIT_PLUGIN_FILE = 'fluent-toolkit/fluent-toolkit.php';
/**
* Status block — what the Settings page lights up with on load.
*/
public function status()
{
if (!function_exists('is_plugin_active')) {
require_once ABSPATH . 'wp-admin/includes/plugin.php';
}
$adapterInstalled = $this->isAdapterPresent();
$toolkitInstalled = $this->isToolkitPresent();
$adapterRuntimeAvailable = $this->isAdapterRuntimeAvailable();
$standaloneActive = is_plugin_active(self::ADAPTER_PLUGIN_FILE) && $adapterRuntimeAvailable;
$toolkitActive = $this->isToolkitLoaded();
$toolkitAdapterActive = $toolkitActive && $this->isToolkitAdapterAvailable();
$adapterActive = $standaloneActive || $toolkitAdapterActive;
$adapterProvider = $standaloneActive ? 'plugin' : ($toolkitAdapterActive ? 'toolkit' : '');
$abilitiesAvailable = function_exists('wp_register_ability');
$canAutoInstall = (bool) apply_filters('fluent_toolkit/can_auto_install', false);
$toolsCount = $abilitiesAvailable ? $this->countAbilities() : 0;
$currentUser = wp_get_current_user();
// Detect a local dev environment heuristically. Self-signed/local
// certs trip Node's TLS validation in the npx proxy that Claude
// Desktop uses; if we know the user is on dev, we pre-bake the
// workaround into the generated snippet. The Vue page also exposes
// a manual toggle for edge cases.
$isLocalDev = self::detectLocalDevEnvironment();
return [
'adapter_installed' => $adapterInstalled || $toolkitInstalled,
'adapter_active' => $adapterActive,
'adapter_provider' => $adapterProvider,
'standalone_adapter_installed' => $adapterInstalled,
'toolkit_installed' => $toolkitInstalled,
'toolkit_active' => $toolkitActive,
'toolkit_adapter_available' => $toolkitAdapterActive,
'adapter_runtime_available' => $adapterRuntimeAvailable,
'adapter_version' => $this->detectAdapterVersion(),
'toolkit_version' => $this->detectToolkitVersion(),
'abilities_api_loaded' => $abilitiesAvailable,
'endpoint_url' => MCPInit::getEndpointUrl(),
'tools_count' => $toolsCount,
'mcp_enabled' => fluentcrm_get_option('mcp_enabled', 'yes') === 'yes',
'pro_active' => defined('FLUENTCAMPAIGN'),
'app_passwords_url' => admin_url('profile.php#application-passwords-section'),
'plugins_url' => admin_url('plugins.php'),
'can_auto_install_adapter' => $canAutoInstall,
'toolkit_download_url' => 'https://github.com/WPManageNinja/fluent-toolkit',
'current_user_login' => $currentUser ? $currentUser->user_login : '',
'is_local_dev' => $isLocalDev,
];
}
/**
* Toggle the kill-switch. Stored as a FluentCRM option so the lazy-register
* guard in app/Hooks/actions.php picks it up on the next request.
*/
public function toggle(Request $request)
{
$value = $request->get('mcp_enabled');
$enabled = is_string($value) ? ($value === 'yes' || $value === 'true' || $value === '1') : (bool) $value;
fluentcrm_update_option('mcp_enabled', $enabled ? 'yes' : 'no');
return [
'ok' => true,
'mcp_enabled' => $enabled,
'message' => $enabled
? __('MCP tools enabled. New requests will see the FluentCRM abilities.', 'fluent-crm')
: __('MCP tools disabled. The adapter will no longer report FluentCRM abilities.', 'fluent-crm'),
];
}
/**
* One-click adapter install. Free can only explain the missing dependency;
* Pro may opt in to the FluentHub background installer via hooks.
*/
public function installAdapter()
{
if (!current_user_can('install_plugins')) {
return $this->sendError([
'message' => __('Sorry! you do not have permission to install plugins', 'fluent-crm'),
]);
}
$canAutoInstall = (bool) apply_filters('fluent_toolkit/can_auto_install', false);
if (!$canAutoInstall) {
return $this->sendError([
'message' => __('Please install FluentHub from GitHub, then reload this page to connect FluentCRM with AI agents.', 'fluent-crm'),
'toolkit_download_url' => 'https://github.com/WPManageNinja/fluent-toolkit',
]);
}
do_action('fluent_toolkit/do_auto_install');
wp_clean_plugins_cache();
if (!function_exists('is_plugin_active')) {
require_once ABSPATH . 'wp-admin/includes/plugin.php';
}
$toolkitInstalled = $this->isToolkitPresent();
$toolkitActive = $this->isToolkitLoaded();
$adapterRuntimeAvailable = $this->isAdapterRuntimeAvailable();
$toolkitAdapterAvailable = $toolkitActive && $this->isToolkitAdapterAvailable();
$isInstalled = $this->isAdapterPresent() || $toolkitInstalled;
$isActive = (is_plugin_active(self::ADAPTER_PLUGIN_FILE) && $adapterRuntimeAvailable) || $toolkitAdapterAvailable;
if ($isInstalled && $isActive) {
$message = __('FluentHub installed and activated. Reload the page to register FluentCRM MCP tools.', 'fluent-crm');
} elseif ($toolkitInstalled && $toolkitActive) {
$message = __('FluentHub is installed and active, but this version does not include the bundled MCP adapter yet. Please update FluentHub when the MCP-ready build is available, then reload this page.', 'fluent-crm');
} elseif ($toolkitInstalled) {
$message = __('FluentHub is installed but could not be activated automatically. Please activate FluentHub from the Plugins page, then reload this page.', 'fluent-crm');
} else {
$message = __('Could not install FluentHub automatically. Please install FluentHub manually, then reload this page.', 'fluent-crm');
}
return [
'is_installed' => $isInstalled,
'adapter_active' => $isActive,
'toolkit_active' => $toolkitActive,
'toolkit_adapter_available' => $toolkitAdapterAvailable,
'message' => $message,
];
}
/**
* Generate a copy-paste config snippet for the requested client.
*
* Every client uses WordPress Application Passwords — built into WP 5.6+,
* no extra plugin needed. Direct HTTP clients (Claude Code, Cursor,
* generic) carry credentials via Basic Auth header; the
* @automattic/mcp-wordpress-remote stdio bridge that Claude Desktop uses
* accepts the username/password directly via WP_API_USERNAME and
* WP_API_PASSWORD env vars and handles encoding itself.
*
* Placeholders used here are stable strings the Vue page substitutes via
* regex when the user fills the credentials inputs.
*/
public function getConfigSnippet(Request $request)
{
$client = sanitize_key((string) $request->get('client', 'claude-code'));
$endpoint = MCPInit::getEndpointUrl();
// Optional override from the Settings UI checkbox. When the user
// explicitly says "I'm on local dev" we add the TLS-bypass env var to
// Claude Desktop's snippet; when they say "no" we omit it even if
// auto-detection thinks otherwise.
$forceLocalDev = $request->get('local_dev');
if ($forceLocalDev === 'yes' || $forceLocalDev === '1' || $forceLocalDev === 'true') {
$isLocalDev = true;
} elseif ($forceLocalDev === 'no' || $forceLocalDev === '0' || $forceLocalDev === 'false') {
$isLocalDev = false;
} else {
$isLocalDev = self::detectLocalDevEnvironment();
}
// The Vue page replaces these tokens with the user's real values.
// Keep them stable + distinct so the regex stays simple.
$basicPlaceholder = '<base64(your-username:application-password)>';
$usernamePlaceholder = '<your-username>';
$passwordPlaceholder = '<your-application-password>';
$appPasswordsUrl = admin_url('profile.php#application-passwords-section');
switch ($client) {
case 'codex':
$snippet = sprintf(
"Settings → Connect to a custom MCP\n\nName: fluent-crm\nTransport: Streamable HTTP ← click this tab first\n\nURL: %s\n\nHeader:\n Key: Authorization\n Value: Basic %s\n\nClick Save.",
$endpoint,
$basicPlaceholder
);
$instructions = sprintf(
/* translators: %s: link to WP user profile application passwords section */
__('Open OpenAI Codex → Settings → Connect to a custom MCP. Click the "Streamable HTTP" tab. Generate a WordPress Application Password from %s, then paste username + app password into the inputs above — the Value field will auto-fill with the encoded Basic auth string.', 'fluent-crm'),
$appPasswordsUrl
);
break;
case 'cursor':
$snippet = wp_json_encode([
'mcpServers' => [
'fluent-crm' => [
'url' => $endpoint,
'type' => 'http',
'headers' => [
'Authorization' => 'Basic ' . $basicPlaceholder,
],
],
],
], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
$instructions = __('Cursor speaks HTTP MCP natively. Fill in your username and application password above and the snippet will be ready to paste into Cursor → Settings → MCP. Restart Cursor afterwards.', 'fluent-crm');
break;
case 'generic':
$snippet = sprintf(
"URL: %s\nAuth: Authorization: Basic %s\n\n# Quick test (curl handles the base64 for you)\ncurl -s -u '%s:%s' \\\n -X POST %s \\\n -H 'Content-Type: application/json' \\\n -d '{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"tools/list\"}'",
$endpoint,
$basicPlaceholder,
$usernamePlaceholder,
$passwordPlaceholder,
$endpoint
);
$instructions = __('Use the URL + Basic Auth header with any HTTP MCP client. The endpoint speaks the standard MCP protocol — initialize, tools/list, tools/call — over JSON-RPC.', 'fluent-crm');
break;
case 'claude-desktop':
// Claude Desktop cannot speak HTTP MCP directly yet — it
// routes through @automattic/mcp-wordpress-remote, which
// accepts WP_API_USERNAME / WP_API_PASSWORD plain (proxy
// does the encoding). No JWT plugin needed.
$env = [
'WP_API_URL' => $endpoint,
'WP_API_USERNAME' => $usernamePlaceholder,
'WP_API_PASSWORD' => $passwordPlaceholder,
'OAUTH_ENABLED' => 'false',
];
if ($isLocalDev) {
// Self-signed certs trip Node's bundled CA store. Trust
// the connection wholesale for local dev — the proxy
// only ever talks to one URL the user explicitly chose,
// so the practical risk is bounded.
$env['NODE_TLS_REJECT_UNAUTHORIZED'] = '0';
}
$snippet = wp_json_encode([
'mcpServers' => [
'fluent-crm' => [
'command' => 'npx',
'args' => ['-y', '@automattic/mcp-wordpress-remote@latest'],
'env' => $env,
],
],
], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
$localDevNote = $isLocalDev
? ' ' . __('Local dev mode is on, so NODE_TLS_REJECT_UNAUTHORIZED is included — the npx proxy needs it to talk to self-signed Valet/MAMP/Local SSL.', 'fluent-crm')
: '';
$instructions = __('Paste into ~/Library/Application Support/Claude/claude_desktop_config.json (macOS) or %APPDATA%\\Claude\\claude_desktop_config.json (Windows), then restart Claude Desktop.', 'fluent-crm') . $localDevNote;
break;
case 'claude-code':
default:
$snippet = sprintf(
"claude mcp add \\\n --transport http \\\n fluent-crm %s \\\n --header \"Authorization: Basic %s\"",
$endpoint,
$basicPlaceholder
);
$instructions = __('Fill in your username and application password above, then paste the command into your terminal. Run `claude` and the FluentCRM tools will appear under MCP servers.', 'fluent-crm');
$client = 'claude-code';
break;
}
return [
'client' => $client,
'snippet' => $snippet,
'instructions' => $instructions,
'endpoint' => $endpoint,
'app_passwords_url' => $appPasswordsUrl,
'is_local_dev' => $isLocalDev,
];
}
/**
* Heuristic check for "we're running on a local development install."
*
* Tested in order:
* 1. Hostname ends in a dev TLD (.test, .lab, .local, .localhost)
* 2. Hostname is literally `localhost`
* 3. Host resolves to a private/loopback IP range
*
* Filterable via `fluent_crm/mcp_is_local_dev` so operators can override
* detection on edge cases (a public-facing site on `.local`, an internal
* tool that needs the dev-mode behavior anyway, etc.).
*
* @return bool
*/
private static function detectLocalDevEnvironment()
{
$host = wp_parse_url(home_url(), PHP_URL_HOST);
$host = strtolower((string) $host);
$isDev = false;
$devTlds = ['.test', '.lab', '.local', '.localhost', '.docker', '.dev'];
foreach ($devTlds as $tld) {
$len = strlen($tld);
if ($len > 0 && substr($host, -$len) === $tld) {
$isDev = true;
break;
}
}
if (!$isDev && ($host === 'localhost' || $host === '127.0.0.1' || $host === '::1')) {
$isDev = true;
}
if (!$isDev && filter_var($host, FILTER_VALIDATE_IP)) {
// Private IP ranges per RFC 1918.
$isPrivate = !filter_var($host, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE);
if ($isPrivate) {
$isDev = true;
}
}
/**
* Override the local-dev detection. Useful when the heuristic gets
* it wrong (e.g. a public site on a `.local` mDNS hostname).
*
* @since 2.10.0
*
* @param bool $isDev Whether the install looks like local dev.
* @param string $host The detected hostname.
*/
return (bool) apply_filters('fluent_crm/mcp_is_local_dev', $isDev, $host);
}
// ---------------------------------------------------------------------
// helpers
// ---------------------------------------------------------------------
private function isAdapterPresent()
{
return $this->isPluginPresent(self::ADAPTER_PLUGIN_FILE);
}
private function isToolkitPresent()
{
return $this->isToolkitLoaded() || $this->isPluginPresent(self::TOOLKIT_PLUGIN_FILE);
}
private function detectAdapterVersion()
{
return $this->detectPluginVersion(self::ADAPTER_PLUGIN_FILE);
}
private function detectToolkitVersion()
{
if ($this->isToolkitLoaded()) {
return (string) FLUENT_TOOLKIT_VERSION;
}
return $this->detectPluginVersion(self::TOOLKIT_PLUGIN_FILE);
}
private function isToolkitLoaded()
{
return defined('FLUENT_TOOLKIT_VERSION');
}
private function isPluginPresent($pluginFile)
{
if (!function_exists('get_plugins')) {
require_once ABSPATH . 'wp-admin/includes/plugin.php';
}
$plugins = get_plugins();
return isset($plugins[$pluginFile]);
}
private function detectPluginVersion($pluginFile)
{
if (!function_exists('get_plugins')) {
require_once ABSPATH . 'wp-admin/includes/plugin.php';
}
$plugins = get_plugins();
if (!isset($plugins[$pluginFile])) {
return null;
}
return $plugins[$pluginFile]['Version'] ?? null;
}
private function isToolkitAdapterAvailable()
{
if (!$this->isToolkitLoaded()) {
return false;
}
if (class_exists('\FluentToolkit\Mcp\AdapterBootstrap') && method_exists('\FluentToolkit\Mcp\AdapterBootstrap', 'available')) {
return (bool) \FluentToolkit\Mcp\AdapterBootstrap::available();
}
return $this->isAdapterRuntimeAvailable();
}
private function isAdapterRuntimeAvailable()
{
return defined('WP_MCP_VERSION')
&& class_exists('\WP\MCP\Core\McpAdapter')
&& function_exists('wp_register_ability');
}
private function countAbilities()
{
$count = count(AbilitiesRegistrar::getDefinitions());
// Pro tools are pushed onto the names list via the
// `fluent_crm/mcp_ability_names` filter in MCPInit.
$names = apply_filters('fluent_crm/mcp_ability_names', array_keys(AbilitiesRegistrar::getDefinitions()));
if (is_array($names)) {
$count = count(array_unique($names));
}
return $count;
}
}
@@ -0,0 +1,208 @@
<?php
namespace FluentCrm\App\Http\Controllers;
use FluentCrm\App\Services\CrmMigrator\ActiveCampaignMigrator;
use FluentCrm\App\Services\CrmMigrator\ConvertKitMigrator;
use FluentCrm\App\Services\CrmMigrator\DripMigrator;
use FluentCrm\App\Services\CrmMigrator\MailChimpMigrator;
use FluentCrm\App\Services\CrmMigrator\MailerLiteMigrator;
use FluentCrm\Framework\Http\Request\Request;
/**
* MigratorController - REST API Handler Class
*
* REST API Handler
*
* @package FluentCrm\App\Http
*
* @version 1.0.0
*/
class MigratorController extends Controller
{
public function getDrivers(Request $request)
{
return [
'drivers' => $this->getMigrators()
];
}
public function verifyCredential(Request $request)
{
$driver = $request->get('driver');
$driverClassName = $this->getDriverClass($driver);
if (!$driverClassName) {
return $this->sendError([
'message' => __('Sorry no driver found for the selected CRM', 'fluent-crm')
]);
}
$credential = $request->get('credential', []);
$driverClass = new $driverClassName;
$result = $driverClass->verifyCredentials($credential);
if (is_wp_error($result)) {
return $this->sendError([
'message' => $result->get_error_message(),
], 422);
}
return [
'message' => __('Your provided API key is valid', 'fluent-crm')
];
}
public function getListTagMappings(Request $request)
{
$driver = $request->get('driver');
$driverClassName = $this->getDriverClass($driver);
if (!$driverClassName) {
return $this->sendError([
'message' => __('Sorry no driver found for the selected CRM', 'fluent-crm')
]);
}
$credential = $request->get('credential', []);
$result = (new $driverClassName)->getListTagMappings($request->all());
if (is_wp_error($result)) {
return $this->sendError([
'message' => $result->get_error_message(),
], 422);
}
return [
'options' => $result
];
}
public function getImportSummary(Request $request)
{
$driver = $request->get('driver');
$driverClassName = $this->getDriverClass($driver);
if (!$driverClassName) {
return $this->sendError([
'message' => __('Sorry no driver found for the selected CRM', 'fluent-crm')
]);
}
$credential = $request->get('credential', []);
$mapSettings = $request->get('map_settings', []);
$summary = (new $driverClassName)->getSummary($request->all());
if (is_wp_error($summary)) {
return $this->sendError([
'message' => $summary->get_error_message(),
], 422);
}
return [
'import_summary' => $summary
];
}
public function handleImport(Request $request)
{
if (!defined('FLUENTCRM_DOING_BULK_IMPORT')) {
define('FLUENTCRM_DOING_BULK_IMPORT', true);
}
$driver = $request->get('driver');
$driverClassName = $this->getDriverClass($driver);
if (!$driverClassName) {
return $this->sendError([
'message' => __('Sorry no driver found for the selected CRM', 'fluent-crm')
]);
}
$summary = (new $driverClassName)->runImport($request->all());
if (is_wp_error($summary)) {
return $this->sendError([
'message' => $summary->get_error_message(),
], 422);
}
return [
'import_info' => $summary
];
}
private function getDriverClass($driver)
{
if ($driver == 'mailchimp') {
return MailChimpMigrator::class;
} else if ($driver == 'ConvertKit') {
return ConvertKitMigrator::class;
} else if ($driver == 'MailerLite') {
return MailerLiteMigrator::class;
} else if ($driver == 'Drip') {
return DripMigrator::class;
} else if ($driver == 'ActiveCampaign') {
return ActiveCampaignMigrator::class;
}
/**
* Filter the migrator driver class.
*
* This filter allows you to modify the migrator driver class.
*
* @since 2.7.0
*
* @param mixed $class The current migrator driver class. Default null.
* @param string $driver The driver name.
*/
return apply_filters('fluent_crm/migrator_driver_class', null, $driver);
}
private function getMigrators()
{
/**
* Filter the list of available SaaS migrators.
*
* This filter allows modification of the list of available SaaS migrators
* by adding, removing, or modifying the migrators.
*
* @since 2.7.0
*
* @param array $migrators {
* An associative array of migrators.
*
* @type array $mailchimp {
* Information about the MailChimp migrator.
* }
* @type array $ConvertKit {
* Information about the ConvertKit migrator.
* }
* @type array $MailerLite {
* Information about the MailerLite migrator.
* }
* @type array $Drip {
* Information about the Drip migrator.
* }
* @type array $ActiveCampaign {
* Information about the ActiveCampaign migrator.
* }
* }
*/
return apply_filters('fluent_crm/saas_migrators', [
'mailchimp' => (new MailChimpMigrator())->getInfo(),
'ConvertKit' => (new ConvertKitMigrator())->getInfo(),
'MailerLite' => (new MailerLiteMigrator())->getInfo(),
'Drip' => (new DripMigrator())->getInfo(),
'ActiveCampaign' => (new ActiveCampaignMigrator())->getInfo()
]);
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,57 @@
<?php
namespace FluentCrm\App\Http\Controllers;
use FluentCrm\App\Models\Subscriber;
use FluentCrm\App\Services\Helper;
/**
* PurchaseHistoryController - REST API Handler Class
*
* REST API Handler
*
* @package FluentCrm\App\Http
*
* @version 1.0.0
*/
class PurchaseHistoryController extends Controller
{
public function historyProviders()
{
return $this->sendSuccess([
'providers' => Helper::getPurchaseHistoryProviders()
]);
}
public function getOrders()
{
$provider = $this->request->getSafe('provider');
$subscriberId = $this->request->getSafe('id', 'intval');
$subscriber = Subscriber::findOrFail($subscriberId);
/**
* Determine the purchase history data for a specific provider in FluentCRM.
*
* The dynamic portion of the hook name, `$provider`, refers to the purchase history provider.
*
* @since 1.0.0
*
* @param array {
* The purchase history data.
*
* @type array $orders List of orders.
* @type int $total Total number of orders.
* }
* @param object $subscriber The subscriber object.
*/
$data = apply_filters('fluent_crm/purchase_history_'.$provider, [
'orders' => [],
'total' => 0
], $subscriber);
return $this->sendSuccess([
'orders' => $data
]);
}
}
@@ -0,0 +1,597 @@
<?php
namespace FluentCrm\App\Http\Controllers;
use FluentCrm\App\Models\Campaign;
use FluentCrm\App\Models\CampaignEmail;
use FluentCrm\App\Models\Funnel;
use FluentCrm\App\Models\FunnelSubscriber;
use FluentCrm\App\Models\Lists;
use FluentCrm\App\Models\Subscriber;
use FluentCrm\App\Models\Tag;
use FluentCrm\App\Hooks\Handlers\Scheduler;
use FluentCrm\App\Services\Reporting;
use FluentCrm\Framework\Http\Request\Request;
use FluentCrm\App\Models\CampaignUrlMetric;
use FluentCrm\Framework\Support\Arr;
/**
* ReportingController - REST API Handler Class
*
* REST API Handler
*
* @package FluentCrm\App\Http
*
* @version 1.0.0
*/
class ReportingController extends Controller
{
public function getContactGrowth(Request $request, Reporting $reporting)
{
list($from, $to) = $request->get('date_range') ?: ['', ''];
$tagId = intval($request->get('tag_id', 0));
$listId = intval($request->get('list_id', 0));
$compareType = sanitize_text_field($request->get('compare_type', ''));
$compareRange = $request->get('compare_range', []);
$currentStats = $reporting->getSubscribersGrowth($from, $to, $tagId, $listId);
$currentFrom = $from ?: gmdate('Y-m-d', strtotime('-30 days'));
$currentTo = $to ?: gmdate('Y-m-d', strtotime('+1 day'));
$dataSets = [
[
'label' => __('Current Range', 'fluent-crm'),
'data' => $currentStats,
'range' => [$currentFrom, $currentTo],
'backgroundColor' => '#335CFF',
'borderColor' => '#335CFF',
'fill' => true,
],
];
if ($compareType && $compareType !== 'no_comparison') {
$compRange = $this->resolveCompareRange($compareType, $compareRange, $currentFrom, $currentTo);
if ($compRange) {
$compareStats = $reporting->getSubscribersGrowth($compRange[0], $compRange[1], $tagId, $listId);
$dataSets[] = [
'label' => __('Compare Range', 'fluent-crm'),
'data' => $compareStats,
'range' => $compRange,
'backgroundColor' => '#1FC16B',
'borderColor' => '#1FC16B',
'fill' => true,
];
}
}
return $this->sendSuccess([
'data_sets' => $dataSets,
'current_range' => [$currentFrom, $currentTo],
]);
}
/**
* Calculate comparison date range based on type.
*
* @param string $type
* @param array $compareRange
* @param string $from
* @param string $to
* @return array|false
*/
private function resolveCompareRange($type, $compareRange, $from, $to)
{
$fromTs = strtotime($from);
$toTs = strtotime($to);
$diffDays = (int)(($toTs - $fromTs) / 86400);
switch ($type) {
case 'previous_period':
return [
gmdate('Y-m-d', $fromTs - ($diffDays + 1) * 86400),
gmdate('Y-m-d', $fromTs - 86400),
];
case 'previous_month':
$newFrom = gmdate('Y-m-d', strtotime($from . ' -1 month'));
return [$newFrom, gmdate('Y-m-d', strtotime($newFrom) + $diffDays * 86400)];
case 'previous_quarter':
$newFrom = gmdate('Y-m-d', strtotime($from . ' -3 months'));
return [$newFrom, gmdate('Y-m-d', strtotime($newFrom) + $diffDays * 86400)];
case 'previous_year':
$newFrom = gmdate('Y-m-d', strtotime($from . ' -12 months'));
return [$newFrom, gmdate('Y-m-d', strtotime($newFrom) + $diffDays * 86400)];
case 'custom':
if (is_array($compareRange) && count(array_filter($compareRange)) >= 2) {
return [
sanitize_text_field($compareRange[0]),
sanitize_text_field($compareRange[1]),
];
}
return false;
default:
return false;
}
}
public function getEmailSentStats(Request $request, Reporting $reporting)
{
list($from, $to) = $request->get('date_range') ?: ['', ''];
$campaignId = intval($request->get('campaign_id', 0));
return $this->sendSuccess([
'stats' => $reporting->getEmailStats($from, $to, 'sent', $campaignId)
]);
}
public function getEmailOpenStats(Request $request, Reporting $reporting)
{
list($from, $to) = $request->get('date_range') ?: ['', ''];
$campaignId = intval($request->get('campaign_id', 0));
return $this->sendSuccess([
'stats' => $reporting->getEmailOpenStats($from, $to, $campaignId)
]);
}
public function getEmailClickStats(Request $request, Reporting $reporting)
{
list($from, $to) = $request->get('date_range') ?: ['', ''];
$campaignId = intval($request->get('campaign_id', 0));
return $this->sendSuccess([
'stats' => $reporting->getEmailClickStats($from, $to, $campaignId)
]);
}
public function getEmailUnsubStats(Request $request, Reporting $reporting)
{
list($from, $to) = $request->get('date_range') ?: ['', ''];
return $this->sendSuccess([
'stats' => $reporting->getUnsubscribeStats($from, $to)
]);
}
public function getEmailPerformance(Request $request, Reporting $reporting)
{
$dateRange = Arr::get($request->all(), 'date_range', []);
if (!empty($dateRange[0]) && !empty($dateRange[1])) {
$from = sanitize_text_field($dateRange[0]);
$to = sanitize_text_field($dateRange[1]);
} else {
$days = intval(Arr::get($request->all(), 'days'));
if ($days > 0) {
$from = '-' . $days . ' days';
} elseif ($request->exists('days')) {
// days=0 means "All Time"
$from = '2000-01-01';
} else {
$from = null; // default: -30 days
}
$to = null;
}
return $this->sendSuccess([
'stats' => $reporting->getEmailPerformance($from, $to)
]);
}
public function getEmails(Request $request)
{
$status = sanitize_text_field($request->get('status', ''));
$search = sanitize_text_field($request->get('search', ''));
$selectedTypes = array_values(array_filter(array_map('sanitize_text_field', (array)$request->get('types', []))));
$types = CampaignEmail::expandEmailTypes($selectedTypes);
$emails = CampaignEmail::orderBy('scheduled_at', 'DESC')
->with('subscriber', 'campaign')
->when($search, function ($q) use ($search) {
return $this->applyEmailSearchFilter($q, $search);
})
->when($status, function ($q) use ($status) {
return $q->where('status', $status);
})
->when($types, function ($q) use ($types) {
return $q->whereIn('email_type', $types);
})
->paginate();
$statuses = null;
$emailTypes = null;
if ($request->get('page') == 1 && !$search) {
$statuses = CampaignEmail::select('status')
->selectRaw('count(id) as total')
->when($types, function ($q) use ($types) {
return $q->whereIn('email_type', $types);
})
->groupBy('status')
->get()
->keyBy('status')
->map(function ($status) {
return $status->total;
});
$typeCounts = CampaignEmail::select('email_type')
->selectRaw('count(id) as total')
->whereNotNull('email_type')
->when($status, function ($q) use ($status) {
return $q->where('status', $status);
})
->groupBy('email_type')
->get()
->reduce(function ($carry, $emailType) {
$canonicalType = CampaignEmail::normalizeEmailType($emailType->email_type);
if (!isset($carry[$canonicalType])) {
$carry[$canonicalType] = [
'id' => $canonicalType,
'label' => CampaignEmail::resolveEmailTypeLabel($canonicalType),
'count' => 0,
];
}
$carry[$canonicalType]['count'] += (int)$emailType->total;
return $carry;
}, []);
$orderedTypes = [];
foreach (array_keys(CampaignEmail::getEmailTypeLabels()) as $canonicalType) {
if (!empty($typeCounts[$canonicalType])) {
$orderedTypes[] = $typeCounts[$canonicalType];
}
}
$emailTypes = array_values($orderedTypes);
}
return [
'emails' => $emails,
'statuses' => $statuses,
'types' => $emailTypes
];
}
/**
* Apply the search filter to email activity queries.
*
* Search is scoped to fields the table actually exposes so users can find
* rows by subject, source campaign title, recipient email, or related
* contact email without triggering extra broad scans on large datasets.
*
* @param \FluentCrm\Framework\Database\Orm\Builder $query
* @param string $search
* @return \FluentCrm\Framework\Database\Orm\Builder
*/
private function applyEmailSearchFilter($query, $search)
{
global $wpdb;
$escapedSearch = $wpdb->esc_like($search);
$containsLike = '%' . $escapedSearch . '%';
$emailLike = strpos($search, '@') !== false ? $escapedSearch . '%' : $containsLike;
return $query->where(function ($subQuery) use ($containsLike, $emailLike) {
$subQuery->where('email_subject', 'LIKE', $containsLike)
->orWhere('email_address', 'LIKE', $emailLike)
->orWhereHas('campaign', function ($campaignQuery) use ($containsLike) {
$campaignQuery->where('title', 'LIKE', $containsLike);
})
->orWhereHas('subscriber', function ($subscriberQuery) use ($emailLike) {
$subscriberQuery->where('email', 'LIKE', $emailLike);
});
});
}
public function deleteEmails(Request $request)
{
$emailIds = $request->get('email_ids');
CampaignEmail::whereIn('id', $emailIds)
->delete();
return [
'message' => __('Selected emails have been deleted', 'fluent-crm')
];
}
public function getContactsByStatus()
{
$statuses = fluentCrmDb()->table('fc_subscribers')
->select(fluentCrmDb()->raw('status, COUNT(id) as count'))
->groupBy('status')
->get();
$defaultOrder = [
'subscribed',
'unsubscribed',
'pending',
'bounced',
'complained',
'spammed',
'transactional'
];
$defaultStats = array_fill_keys($defaultOrder, 0);
$total = 0;
foreach ($statuses as $row) {
$count = (int)$row->count;
$status = sanitize_text_field($row->status);
$total += $count;
if (array_key_exists($status, $defaultStats)) {
$defaultStats[$status] = $count;
}
}
$result = [];
foreach ($defaultOrder as $status) {
$result[] = [
'status' => $status,
'count' => $defaultStats[$status],
];
}
return $this->sendSuccess([
'stats' => $result,
'total' => $total,
]);
}
public function getContactsByTags(Request $request)
{
$limit = intval($request->get('per_page', 20));
$tags = Tag::select(['fc_tags.id', 'fc_tags.title'])
->selectRaw('COUNT(subscriber_id) as contact_count')
->leftJoin('fc_subscriber_pivot', function ($join) {
$join->on('fc_tags.id', '=', 'fc_subscriber_pivot.object_id')
->where('fc_subscriber_pivot.object_type', '=', 'FluentCrm\App\Models\Tag');
})
->groupBy('fc_tags.id', 'fc_tags.title')
->orderByDesc('contact_count')
->paginate($limit);
return $this->sendSuccess([
'tags' => $tags,
]);
}
public function getContactsByLists(Request $request)
{
$limit = intval($request->get('per_page', 20));
$lists = Lists::select(['fc_lists.id', 'fc_lists.title'])
->selectRaw('COUNT(subscriber_id) as contact_count')
->leftJoin('fc_subscriber_pivot', function ($join) {
$join->on('fc_lists.id', '=', 'fc_subscriber_pivot.object_id')
->where('fc_subscriber_pivot.object_type', '=', 'FluentCrm\App\Models\Lists');
})
->groupBy('fc_lists.id', 'fc_lists.title')
->orderByDesc('contact_count')
->paginate($limit);
return $this->sendSuccess([
'lists' => $lists,
]);
}
public function getContactsByCountry()
{
$countries = fluentCrmDb()->table('fc_subscribers')
->select(fluentCrmDb()->raw('UPPER(TRIM(country)) as country_code, COUNT(id) as contact_count'))
->whereNotNull('country')
->whereRaw("TRIM(country) != ''")
->groupBy(fluentCrmDb()->raw('UPPER(TRIM(country))'))
->orderByDesc('contact_count')
->get();
$result = [];
foreach ($countries as $row) {
$result[] = [
'country_code' => $row->country_code,
'contact_count' => (int) $row->contact_count,
];
}
return $this->sendSuccess([
'countries' => $result,
]);
}
public function getCampaignsList(Request $request)
{
$limit = intval($request->get('per_page', 15));
$campaigns = Campaign::where('status', 'archived')
->orderBy('updated_at', 'DESC')
->paginate($limit);
foreach ($campaigns as $campaign) {
$campaign->stats = $campaign->stats();
}
return $this->sendSuccess([
'campaigns' => $campaigns,
]);
}
public function getAutomationReports(Request $request)
{
$limit = intval($request->get('per_page', 15));
$funnels = Funnel::where('status', 'published')
->orderBy('created_at', 'DESC')
->paginate($limit);
$totalSubscribers = 0;
$totalCompleted = 0;
$totalInProgress = 0;
foreach ($funnels as $funnel) {
$funnel->total_subscribers = FunnelSubscriber::where('funnel_id', $funnel->id)
->distinct()
->count('subscriber_id');
$funnel->completed_count = FunnelSubscriber::where('funnel_id', $funnel->id)
->where('status', 'completed')
->count();
$funnel->in_progress_count = FunnelSubscriber::where('funnel_id', $funnel->id)
->where('status', 'active')
->count();
// Last run time
$lastRun = FunnelSubscriber::where('funnel_id', $funnel->id)
->whereNotNull('last_executed_time')
->orderByDesc('last_executed_time')
->first();
$funnel->last_run_at = $lastRun ? $lastRun->last_executed_time : null;
// Recent 3 subscribers who entered
$recentEntries = FunnelSubscriber::where('funnel_id', $funnel->id)
->with(['subscriber' => function ($q) {
$q->select(['id', 'first_name', 'last_name', 'email', 'avatar']);
}])
->orderByDesc('created_at')
->limit(3)
->get();
$funnel->recent_subscribers = $recentEntries->map(function ($entry) {
if (!$entry->subscriber) {
return null;
}
return [
'id' => $entry->subscriber->id,
'name' => trim($entry->subscriber->first_name . ' ' . $entry->subscriber->last_name),
'email' => $entry->subscriber->email,
'avatar' => $entry->subscriber->avatar,
'entered_at' => $entry->created_at,
];
})->filter()->values();
$totalSubscribers += $funnel->total_subscribers;
$totalCompleted += $funnel->completed_count;
$totalInProgress += $funnel->in_progress_count;
}
// Top 5 automations by total subscribers (most triggered)
$topAutomations = Funnel::where('status', 'published')
->get()
->map(function ($funnel) {
$funnel->trigger_count = FunnelSubscriber::where('funnel_id', $funnel->id)
->count();
return $funnel;
})
->sortByDesc('trigger_count')
->take(5)
->values()
->map(function ($funnel) {
return [
'id' => $funnel->id,
'title' => $funnel->title,
'trigger_name' => $funnel->trigger_name,
'trigger_count' => $funnel->trigger_count,
];
});
$overview = [
'total' => Funnel::where('status', 'published')->count(),
'subscribers' => $totalSubscribers,
'completed' => $totalCompleted,
'in_progress' => $totalInProgress,
];
return $this->sendSuccess([
'automations' => $funnels,
'overview' => $overview,
'top_automations' => $topAutomations,
]);
}
public function getAutomationStepReport(Request $request, Reporting $reporting, $id)
{
$id = intval($id);
$funnel = Funnel::findOrFail($id);
$stats = $reporting->funnelStat($funnel->id);
return $this->sendSuccess([
'funnel' => $funnel,
'stats' => $stats,
]);
}
public function getCampaignOptions(Request $request)
{
global $wpdb;
$search = sanitize_text_field($request->get('search', ''));
$limit = intval($request->get('per_page', 50));
$query = Campaign::select(['id', 'title'])
->where('status', 'archived');
if ($search) {
$query->where('title', 'LIKE', '%' . $wpdb->esc_like($search) . '%');
}
$options = $query->orderBy('updated_at', 'DESC')
->limit($limit)
->get();
return $this->sendSuccess([
'options' => $options,
]);
}
public function getAdvancedReportProviders()
{
return [
/**
* Determine the advanced report providers for FluentCRM.
*
* This filter allows you to modify the list of advanced report providers.
*
* @since 1.0.0
*
* @param array An array of advanced report providers.
*/
'providers' => apply_filters('fluent_crm/advanced_report_providers', [])
];
}
public function getRecentTags(Request $request)
{
$limit = intval($request->get('per_page', 5));
$tags = Tag::select(['fc_tags.id', 'fc_tags.title', 'fc_tags.created_at'])
->selectRaw('COUNT(subscriber_id) as contact_count')
->leftJoin('fc_subscriber_pivot', function ($join) {
$join->on('fc_tags.id', '=', 'fc_subscriber_pivot.object_id')
->where('fc_subscriber_pivot.object_type', '=', 'FluentCrm\App\Models\Tag');
})
->groupBy('fc_tags.id', 'fc_tags.title', 'fc_tags.created_at')
->orderByDesc('fc_tags.created_at')
->limit($limit)
->get();
return $this->sendSuccess([
'tags' => $tags,
]);
}
public function ping()
{
// Browser-driven cron fallback: while an admin has any CRM page open,
// the app pings this endpoint ~every 50s. If Action Scheduler (and the
// WP-Cron fallback) have stalled, take over the every-minute email task
// here. No-ops in a single option read when scheduling is healthy, and
// is fully locked/throttled internally — safe across tabs and users.
Scheduler::maybeProcessFromBrowserPing();
return [
'message' => 'pong'
];
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,382 @@
<?php
/**
* Setup wizard class
*
* Intial Setup Wizard for FluentCRM
*
*/
namespace FluentCrm\App\Http\Controllers;
use FluentCrm\Framework\Http\Request\Request;
use FluentCrm\Framework\Support\Arr;
/**
* SetupController - REST API Handler Class
*
* REST API Handler
*
* @package FluentCrm\App\Http
*
* @version 1.0.0
*/
class SetupController extends Controller
{
public function CompleteWizard(Request $request)
{
$installFluentForm = $request->get('install_fluentform', 'no');
if ($installFluentForm == 'yes' && !defined('FLUENTFORM')) {
$this->installFluentForm();
}
if ($request->get('install_fluentcart', 'no') === 'yes' && !defined('FLUENTCART_VERSION')) {
$this->installFluentCart();
}
$optinEmail = $request->get('optin_email', 'no');
if ($optinEmail && is_email($optinEmail)) {
$this->shareEmail($optinEmail);
}
$shareEssential = $request->get('share_essentials', 'no');
if ($shareEssential == 'yes') {
fluentcrm_update_option('_fluentcrm_share_essential', $shareEssential);
}
return $this->sendSuccess([
'message' => __('Installation has been completed', 'fluent-crm')
]);
}
public function handleFluentFormInstall()
{
if (!current_user_can('install_plugins')) {
return $this->sendError([
'message' => __('Sorry! you do not have permission to install plugin', 'fluent-crm')
]);
}
$this->installFluentForm();
return [
'ff_config' => [
'is_installed' => defined('FLUENTFORM'),
'create_form_link' => admin_url('admin.php?page=fluent_forms#add=1')
],
'is_installed' => defined('FLUENTFORM'),
'message' => __('Fluent Forms has been installed and activated', 'fluent-crm')
];
}
public function handleFluentBoardsInstall()
{
if (!current_user_can('install_plugins')) {
return $this->sendError([
'message' => __('Sorry! you do not have permission to install plugin', 'fluent-crm')
]);
}
$this->installFluentBoards();
return [
'message' => __('Fluent Boards has been installed and activated', 'fluent-crm'),
'is_installed' => defined('FLUENT_BOARDS'),
];
}
public function handleFluentCommunityInstall()
{
if (!current_user_can('install_plugins')) {
return $this->sendError([
'message' => __('Sorry! you do not have permission to install plugin', 'fluent-crm')
]);
}
$this->installFluentCommunity();
return [
'message' => __('Fluent Community has been installed and activated', 'fluent-crm'),
'is_installed' => defined('FLUENT_COMMUNITY_PLUGIN_VERSION'),
];
}
public function handleFluentBookingInstall()
{
if (!current_user_can('install_plugins')) {
return $this->sendError([
'message' => __('Sorry! you do not have permission to install plugin', 'fluent-crm')
]);
}
$this->installFluentBooking();
return [
'message' => __('Fluent Booking has been installed and activated', 'fluent-crm'),
'is_installed' => defined('FLUENT_BOOKING_VERSION'),
];
}
public function handleFluentCartInstall()
{
if (!current_user_can('install_plugins')) {
return $this->sendError([
'message' => __('Sorry! you do not have permission to install plugin', 'fluent-crm')
]);
}
$this->installFluentCart();
return [
'message' => __('FluentCart has been installed and activated', 'fluent-crm'),
'is_installed' => defined('FLUENTCART_VERSION'),
];
}
public function handleFluentSmtpInstall()
{
if (!current_user_can('install_plugins')) {
return $this->sendError([
'message' => __('Sorry! you do not have permission to install plugin', 'fluent-crm')
]);
}
$this->installFluentSMTP();
return [
'is_installed' => defined('FLUENTMAIL'),
'config_url' => admin_url('options-general.php?page=fluent-mail#/'),
'message' => __('FluentSMTP plugin has been installed and activated successfully', 'fluent-crm')
];
}
public function handleFluentSupportInstall()
{
if (!current_user_can('install_plugins')) {
return $this->sendError([
'message' => __('Sorry! you do not have permission to install plugin', 'fluent-crm')
]);
}
$plugin_id = 'fluent-support';
$plugin = [
'name' => __('Fluent Support', 'fluent-crm'),
'repo-slug' => 'fluent-support',
'file' => 'fluent-support.php',
];
$this->backgroundInstaller($plugin, $plugin_id);
return [
'is_installed' => defined('FLUENT_SUPPORT_VERSION'),
'message' => __('Fluent Support plugin has been installed and activated successfully', 'fluent-crm')
];
}
private function shareEmail($optinEmail)
{
$user = get_user_by('ID', get_current_user_id());
$data = [
'answers' => [
'website' => site_url(),
'email' => $optinEmail,
'first_name' => $user->first_name,
'last_name' => $user->last_name,
'name' => $user->display_name
],
'questions' => [
'website' => 'website',
'first_name' => 'first_name',
'last_name' => 'last_name',
'email' => 'email',
'name' => 'name'
],
'user' => [
'email' => $optinEmail
],
'fb_capture' => 1,
'form_id' => 54
];
$url = add_query_arg($data, 'https://wpmanageninja.com/');
wp_remote_post($url);
}
private function installFluentForm()
{
$plugin_id = 'fluentform';
$plugin = [
'name' => __('Fluent Forms', 'fluent-crm'),
'repo-slug' => 'fluentform',
'file' => 'fluentform.php',
];
$this->backgroundInstaller($plugin, $plugin_id);
}
private function installFluentBoards()
{
$plugin_id = 'fluent-boards';
$plugin = [
'name' => __('Fluent Boards', 'fluent-crm'),
'repo-slug' => 'fluent-boards',
'file' => 'fluent-boards.php',
];
$this->backgroundInstaller($plugin, $plugin_id);
}
private function installFluentCommunity()
{
$plugin_id = 'fluent-community';
$plugin = [
'name' => __('Fluent Community', 'fluent-crm'),
'repo-slug' => 'fluent-community',
'file' => 'fluent-community.php',
];
$this->backgroundInstaller($plugin, $plugin_id);
}
private function installFluentBooking()
{
$plugin_id = 'fluent-booking';
$plugin = [
'name' => __('Fluent Booking', 'fluent-crm'),
'repo-slug' => 'fluent-booking',
'file' => 'fluent-booking.php',
];
$this->backgroundInstaller($plugin, $plugin_id);
}
private function installFluentCart()
{
$plugin_id = 'fluent-cart';
$plugin = [
'name' => __('FluentCart', 'fluent-crm'),
'repo-slug' => 'fluent-cart',
'file' => 'fluent-cart.php',
];
$this->backgroundInstaller($plugin, $plugin_id);
}
private function installFluentSMTP()
{
$plugin_id = 'fluent-smtp';
$plugin = [
'name' => __('FluentSMTP', 'fluent-crm'),
'repo-slug' => 'fluent-smtp',
'file' => 'fluent-smtp.php',
];
$this->backgroundInstaller($plugin, $plugin_id);
}
private function backgroundInstaller($plugin_to_install, $plugin_id)
{
if (!empty($plugin_to_install['repo-slug'])) {
require_once ABSPATH . 'wp-admin/includes/file.php';
require_once ABSPATH . 'wp-admin/includes/plugin-install.php';
require_once ABSPATH . 'wp-admin/includes/class-wp-upgrader.php';
require_once ABSPATH . 'wp-admin/includes/plugin.php';
WP_Filesystem();
$skin = new \Automatic_Upgrader_Skin();
$upgrader = new \WP_Upgrader($skin);
$installed_plugins = array_reduce(array_keys(\get_plugins()), array($this, 'associate_plugin_file'), array());
$plugin_slug = $plugin_to_install['repo-slug'];
$plugin_file = isset($plugin_to_install['file']) ? $plugin_to_install['file'] : $plugin_slug . '.php';
$installed = false;
$activate = false;
// See if the plugin is installed already.
if (isset($installed_plugins[$plugin_file])) {
$installed = true;
$activate = !is_plugin_active($installed_plugins[$plugin_file]);
}
// Install this thing!
if (!$installed) {
// Suppress feedback.
ob_start();
try {
$plugin_information = plugins_api(
'plugin_information',
array(
'slug' => $plugin_slug,
'fields' => array(
'short_description' => false,
'sections' => false,
'requires' => false,
'rating' => false,
'ratings' => false,
'downloaded' => false,
'last_updated' => false,
'added' => false,
'tags' => false,
'homepage' => false,
'donate_link' => false,
'author_profile' => false,
'author' => false,
),
)
);
if (is_wp_error($plugin_information)) {
throw new \Exception($plugin_information->get_error_message());
}
$package = $plugin_information->download_link;
$download = $upgrader->download_package($package);
if (is_wp_error($download)) {
throw new \Exception($download->get_error_message());
}
$working_dir = $upgrader->unpack_package($download, true);
if (is_wp_error($working_dir)) {
throw new \Exception($working_dir->get_error_message());
}
$result = $upgrader->install_package(
array(
'source' => $working_dir,
'destination' => WP_PLUGIN_DIR,
'clear_destination' => false,
'abort_if_destination_exists' => false,
'clear_working' => true,
'hook_extra' => array(
'type' => 'plugin',
'action' => 'install',
),
)
);
if (is_wp_error($result)) {
throw new \Exception($result->get_error_message());
}
$activate = true;
} catch (\Exception $e) {
}
// Discard feedback.
ob_end_clean();
}
wp_clean_plugins_cache();
// Activate this thing.
if ($activate) {
try {
$result = activate_plugin($installed ? $installed_plugins[$plugin_file] : $plugin_slug . '/' . $plugin_file);
if (is_wp_error($result)) {
throw new \Exception($result->get_error_message());
}
} catch (\Exception $e) {
}
}
}
}
private function associate_plugin_file($plugins, $key)
{
$path = explode('/', $key);
$filename = end($path);
$plugins[$filename] = $key;
return $plugins;
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,308 @@
<?php
namespace FluentCrm\App\Http\Controllers;
use FluentCrm\App\Models\SystemLog;
use FluentCrm\Framework\Http\Request\Request;
/**
* SystemLog Controller - REST API Handler Class
*
* REST API Handler
*
* @package FluentCrm\App\Http
*
* @version 2.8.40
*/
class SystemLogController extends Controller
{
/**
* Get all the System Logs
* @param \FluentCrm\Framework\Http\Request\Request $request
* @return array || \WP_REST_Response
*/
public function index(Request $request)
{
$search = $this->getSearchTerm($request);
$logs = $this->getLogsQuery($search);
$logs = $logs->paginate($request->per_page ?: 20);
return [
'logs' => $logs
];
}
/**
* Stream system logs as CSV without loading all rows into memory.
*
* @param \FluentCrm\Framework\Http\Request\Request $request
* @return void
*/
public function export(Request $request)
{
$range = $this->getExportRange($request);
$startDate = $this->getExportStartDate($range);
$search = $this->getSearchTerm($request);
$chunkSize = $this->getExportChunkSize();
$lastId = PHP_INT_MAX;
$this->prepareCsvDownload($this->getExportFilename($range));
$output = fopen('php://output', 'w');
if (!$output) {
exit;
}
fwrite($output, "\xEF\xBB\xBF");
fputcsv($output, $this->getCsvHeaders(), ',', '"', '\\');
do {
$logs = $this->getLogsQuery($search, $startDate)
->where('id', '<', $lastId)
->select(['id', 'created_at', 'title', 'description'])
->limit($chunkSize)
->get();
$count = count($logs);
foreach ($logs as $log) {
$lastId = (int) $log->id;
fputcsv($output, $this->formatCsvLogRow($log), ',', '"', '\\');
}
fflush($output);
if (function_exists('flush')) {
flush();
}
if (connection_aborted()) {
break;
}
} while ($count === $chunkSize);
fclose($output);
exit;
}
public function deleteAll(Request $request)
{
SystemLog::where('id', '>', 0)->delete();
return [
'message' => __('All logs have been deleted', 'fluent-crm')
];
}
/**
* @param string $search
* @param string|null $startDate
* @return mixed
*/
private function getLogsQuery($search = '', $startDate = null)
{
global $wpdb;
$logs = SystemLog::orderBy('id', 'DESC');
if ($startDate) {
$logs = $logs->where('created_at', '>=', $startDate);
}
if ($search !== '') {
$searchLike = '%' . $wpdb->esc_like($search) . '%';
$logs = $logs->where(function ($query) use ($searchLike) {
$query->where('title', 'LIKE', $searchLike)
->orWhere('description', 'LIKE', $searchLike);
});
}
return $logs;
}
/**
* @param \FluentCrm\Framework\Http\Request\Request $request
* @return string
*/
private function getSearchTerm(Request $request)
{
$search = $request->get('search', '');
if (!is_scalar($search)) {
return '';
}
return trim(sanitize_text_field($search));
}
/**
* @param \FluentCrm\Framework\Http\Request\Request $request
* @return int|string
*/
private function getExportRange(Request $request)
{
$range = $request->get('range', 'all');
if (!is_scalar($range)) {
return 'all';
}
return $this->normalizeExportRange(sanitize_text_field($range));
}
/**
* @param mixed $range
* @return int|string
*/
private function normalizeExportRange($range)
{
$range = is_scalar($range) ? (string) $range : 'all';
if ($range === 'all') {
return 'all';
}
$range = intval($range);
$allowedRanges = [7, 15, 30];
return in_array($range, $allowedRanges, true) ? $range : 'all';
}
/**
* @param int|string $range
* @param int|null $currentTimestamp
* @return string|null
*/
private function getExportStartDate($range, $currentTimestamp = null)
{
$range = $this->normalizeExportRange($range);
if ($range === 'all') {
return null;
}
if (!$currentTimestamp) {
$currentTimestamp = current_time('timestamp');
}
return gmdate('Y-m-d H:i:s', $currentTimestamp - ($range * 86400));
}
/**
* @return array
*/
private function getCsvHeaders()
{
return ['ID', 'Date & Time', 'Title', 'Description'];
}
/**
* @param object $log
* @return array
*/
private function formatCsvLogRow($log)
{
return [
(int) $log->id,
$this->sanitizeCsvCell($log->created_at),
$this->sanitizeCsvCell($log->title),
$this->sanitizeCsvCell($this->plainText($log->description))
];
}
/**
* @param int|string $range
* @return string
*/
private function getExportFilename($range)
{
$range = $this->normalizeExportRange($range);
$rangePart = ($range === 'all') ? 'all' : 'last-' . $range . '-days';
return 'fluent-crm-system-logs-' . $rangePart . '-' . gmdate('Y-m-d-His') . '.csv';
}
/**
* Prevent spreadsheet formula execution while preserving visible values.
*
* @param mixed $value
* @return string
*/
private function sanitizeCsvCell($value)
{
if ($value === null) {
return '';
}
if ($value instanceof \DateTimeInterface) {
$value = $value->format('Y-m-d H:i:s');
} else {
$value = is_scalar($value) ? (string) $value : wp_json_encode($value);
}
if ($value !== '' && preg_match('/^[=+\-@\t\r]/', $value)) {
$value = "'" . $value;
}
return $value;
}
/**
* @param mixed $value
* @return string
*/
private function plainText($value)
{
if ($value === null) {
return '';
}
$value = is_scalar($value) ? (string) $value : wp_json_encode($value);
return trim(html_entity_decode(strip_tags($value), ENT_QUOTES, 'UTF-8'));
}
/**
* @return int
*/
private function getExportChunkSize()
{
$chunkSize = (int) apply_filters('fluent_crm/system_logs_export_chunk_size', 1000);
if ($chunkSize < 100) {
return 100;
}
if ($chunkSize > 5000) {
return 5000;
}
return $chunkSize;
}
/**
* @param string $filename
* @return void
*/
private function prepareCsvDownload($filename)
{
if (function_exists('set_time_limit')) {
// Shared hosts may still enforce web server timeouts; this only removes PHP's timer.
@set_time_limit(0);
}
while (ob_get_level()) {
if (!@ob_end_clean()) {
break;
}
}
nocache_headers();
header('Content-Type: text/csv; charset=utf-8');
header('Content-Disposition: attachment; filename="' . sanitize_file_name($filename) . '"');
header('X-Content-Type-Options: nosniff');
}
}
@@ -0,0 +1,258 @@
<?php
namespace FluentCrm\App\Http\Controllers;
use FluentCrm\App\Models\Tag;
use FluentCrm\App\Services\Helper;
use FluentCrm\Framework\Support\Arr;
use FluentCrm\Framework\Http\Request\Request;
/**
* TagsController - REST API Handler Class
*
* REST API Handler
*
* @package FluentCrm\App\Http
*
* @version 1.0.0
*/
class TagsController extends Controller
{
/**
* Get all of the tags
* @param \FluentCrm\Framework\Http\Request\Request $request
* @return \WP_REST_Response | array
*/
public function index(Request $request)
{
$order = [
'by' => $request->getSafe('sort_by', 'sanitize_sql_orderby', 'id'),
'order' => $request->getSafe('sort_order', 'sanitize_sql_orderby', 'DESC')
];
$tags = Tag::orderBy($order['by'], $order['order'])
->searchBy($request->getSafe('search'))
->paginate();
if (!$request->get('exclude_counts')) {
foreach ($tags as $tag) {
$tag->subscribersCount = $tag->countByStatus('subscribed');
}
}
$data = [
'tags' => $tags
];
if ($request->get('all_tags')) {
$allTags = Tag::get();
$formattedTags = [];
foreach ($allTags as $tag) {
$formattedTags[] = [
'id' => strval($tag->id),
'title' => $tag->title,
'slug' => $tag->slug,
'description' => $tag->description
];
}
$data['all_tags'] = $formattedTags;
}
return $data;
}
/**
* Find a tag.
*/
public function find($id)
{
return $this->send([
'tag' => Tag::find($id)
]);
}
/**
* Store a tag.
* @param \FluentCrm\Framework\Http\Request\Request $request
* @return \WP_REST_Response
*/
public function create(Request $request)
{
$allData = $request->all();
if (empty($allData['slug'])) {
$allData['slug'] = Helper::slugify($allData['title']);
} else {
$allData['slug'] = sanitize_text_field($allData['slug']);
}
$allData = $this->validate($allData, [
'title' => 'required',
'slug' => "required|unique:fc_tags,slug"
]);
$tag = Tag::create([
'title' => sanitize_text_field($allData['title']),
'slug' => $allData['slug'],
'description' => sanitize_textarea_field(Arr::get($allData, 'description'))
]);
do_action('fluentcrm_tag_created', $tag->id);
do_action('fluent_crm/tag_created', $tag);
return $this->sendSuccess([
'lists' => $tag,
'item' => $tag,
'message' => __('Successfully saved the tag.', 'fluent-crm')
]);
}
/**
* Store a tag.
* @param \FluentCrm\Framework\Http\Request\Request $request
* @param $id int Tag ID
* @return \WP_REST_Response
*/
public function store(Request $request, $id)
{
$allData = $this->validate($request->all(), [
'title' => 'required'
]);
if (empty($allData['slug'])) {
$allData['slug'] = Helper::slugify($allData['title']);
}
if ($id == 0 && $request->get('update_by') == 'slug' && !empty($allData['slug'])) {
$tag = Tag::where('slug', $allData['slug'])->first();
if (!$tag) {
return $this->sendError([
'message' => __('Tag could not be found', 'fluent-crm')
]);
}
$id = $tag->id;
} else {
$tag = Tag::findOrFail($id);
if (empty($allData['slug'])) {
$allData['slug'] = $tag->slug;
}
}
if (Tag::where('slug', $allData['slug'])->where('id', '!=', $id)->first()) {
return $this->sendError([
'message' => __('Provided slug already exists in another tag', 'fluent-crm')
]);
}
$tag = Tag::where('id', $id)->update([
'title' => sanitize_text_field($allData['title']),
'slug' => $allData['slug'],
'description' => sanitize_textarea_field(Arr::get($allData, 'description')),
]);
do_action('fluentcrm_tag_updated', $id);
do_action('fluent_crm/tag_updated', $tag);
return $this->sendSuccess([
'lists' => $tag,
'message' => __('Successfully saved the tag.', 'fluent-crm')
]);
}
/**
* Store a tag.
*/
public function storeBulk()
{
$tags = $this->request->get('tags', []);
if (!$tags) {
$tags = $this->request->get('items', []);
}
$createdIds = [];
foreach ($tags as $tag) {
if (empty($tag['title'])) {
continue;
}
if (empty($tag['slug'])) {
$tag['slug'] = Helper::slugify($tag['title']);
}
$tag = Tag::updateOrCreate(
['slug' => sanitize_title($tag['slug'], 'display')],
['title' => sanitize_text_field($tag['title'])]
);
$createdIds[] = $tag->id;
if ($tag->wasRecentlyCreated) {
do_action('fluentcrm_tag_created', $tag->id);
do_action('fluent_crm/tag_created', $tag);
} else {
do_action('fluentcrm_tag_updated', $tag->id);
do_action('fluent_crm/tag_updated', $tag);
}
}
return $this->sendSuccess([
'message' => __('Successfully saved the tags.', 'fluent-crm'),
'ids' => $createdIds
]);
}
/**
* Delete a tag by id
*
* @param \FluentCrm\Framework\Http\Request\Request $request
* @param $tagId
* @return \WP_REST_Response $object
*/
public function remove(Request $request, $tagId)
{
$tag = Tag::find($tagId);
if (!$tag) {
return $this->sendError([
'message' => __('Tag not found', 'fluent-crm')
], 404);
}
$tag->delete();
do_action('fluentcrm_tag_deleted', $tagId);
do_action('fluent_crm/tag_deleted', $tagId);
return $this->sendSuccess([
'message' => __('Successfully removed the tag.', 'fluent-crm')
]);
}
public function handleBulkAction(Request $request)
{
$tagIds = array_map('intval', (array)$request->get('tagIds', []));
$tagIds = array_unique(array_filter($tagIds));
if ($tagIds) {
foreach ($tagIds as $tagId) {
Tag::where('id', $tagId)->delete();
do_action('fluentcrm_tag_deleted', $tagId);
do_action('fluent_crm/tag_deleted', $tagId);
}
}
return $this->sendSuccess([
'message' => __('Selected Tags have been removed permanently', 'fluent-crm'),
]);
}
}
@@ -0,0 +1,703 @@
<?php
namespace FluentCrm\App\Http\Controllers;
use FluentCrm\App\Models\Template;
use FluentCrm\App\Services\Helper;
use FluentCrm\App\Services\Sanitize;
use FluentCrm\Framework\Support\Arr;
use FluentCrm\Framework\Http\Request\Request;
/**
* TemplateController - REST API Handler Class
*
* REST API Handler
*
* @package FluentCrm\App\Http
*
* @version 1.0.0
*/
class TemplateController extends Controller
{
public function templates(Request $request)
{
$order = $request->getSafe('order', 'sanitize_sql_orderby', 'desc');
$orderBy = $request->getSafe('orderBy', 'sanitize_sql_orderby', 'ID');
$templatesQuery = Template::emailTemplates(
$request->get('types', ['publish', 'draft'])
);
if ($search = $request->getSafe('search')) {
$templatesQuery->where('post_title', 'LIKE', '%' . $search . '%');
}
// Order the query results and paginate
$templates = $templatesQuery
->orderBy($orderBy, $order)
->paginate();
foreach ($templates as $template) {
$template->design_template = get_post_meta($template->ID, '_design_template', true);
}
return $this->sendSuccess([
'templates' => $templates
]);
}
public function template(Request $request, $templateId = 0)
{
$template = Template::find($templateId);
if ($template) {
$editType = get_post_meta($template->ID, '_edit_type', true);
if (!$editType) {
$editType = 'html';
}
$designTemplate = get_post_meta($template->ID, '_design_template', true);
$templateConfig = get_post_meta($template->ID, '_template_config', true);
if(!$templateConfig || !is_array($templateConfig)) {
$templateConfig = [];
}
$footerSettings = get_post_meta($template->ID, '_footer_settings', true);
$normalizedSettings = $this->normalizeTemplateSettings([
'template_config' => $templateConfig,
'footer_settings' => $footerSettings
], $designTemplate);
$templateData = [
'post_title' => $template->post_title,
'post_content' => $template->post_content,
'post_excerpt' => $template->post_excerpt,
'email_subject' => get_post_meta($template->ID, '_email_subject', true),
'edit_type' => $editType,
'design_template' => $designTemplate,
'settings' => $normalizedSettings
];
/**
* Filter the template data before editing.
*
* @since 2.6.51
*
* @param array $templateData The data of the template being edited.
* @param object $template The template object.
*/
$templateData = apply_filters('fluent_crm/editing_template_data', $templateData, $template);
} else {
$defaultTemplate = Helper::getDefaultEmailTemplate();
$normalizedSettings = $this->normalizeTemplateSettings([
'template_config' => Helper::getTemplateConfig($defaultTemplate),
'footer_settings' => []
], $defaultTemplate);
$templateData = [
'post_title' => '',
'post_content' => '',
'post_excerpt' => '',
'email_subject' => '',
'edit_type' => 'html',
'design_template' => $defaultTemplate,
'settings' => $normalizedSettings
];
}
return $this->sendSuccess([
'template' => $templateData
]);
}
public function create(Request $request)
{
if($templateId = $request->get('template_id')) {
return $this->update($request, $templateId);
}
$templateData = Helper::parseArrayOrJson($this->request->get('template'));
$designTemplate = Arr::get($templateData, 'design_template');
if (!$designTemplate) {
$designTemplate = Helper::getDefaultEmailTemplate();
$templateData['design_template'] = $designTemplate;
}
$templateData['settings'] = $this->normalizeTemplateSettings(Arr::get($templateData, 'settings', []), $designTemplate);
$postData = Arr::only($templateData, [
'post_title',
'post_content',
'post_excerpt'
]);
if(empty($postData['post_title'])) {
$postData['post_title'] = 'Email Template @ '.current_time('mysql');
}
if (empty($templateData['email_subject'])) {
$templateData['email_subject'] = $postData['post_title'];
}
if(empty($postData['post_excerpt'])) {
$postData['post_excerpt'] = '';
}
$postData['post_modified'] = current_time('mysql');
$postData['post_modified_gmt'] = gmdate('Y-m-d H:i:s');
$postData['post_date'] = current_time('mysql');
$postData['post_date_gmt'] = gmdate('Y-m-d H:i:s');
$postData['post_type'] = fluentcrmTemplateCPTSlug();
$templateId = wp_insert_post($postData);
update_post_meta($templateId, '_email_subject', Arr::get($templateData, 'email_subject'));
update_post_meta($templateId, '_edit_type', Arr::get($templateData, 'edit_type'));
update_post_meta($templateId, '_template_config', Arr::get($templateData, 'settings.template_config', []));
update_post_meta($templateId, '_footer_settings', Arr::get($templateData, 'settings.footer_settings', []));
update_post_meta($templateId, '_design_template', $designTemplate);
do_action('fluent_crm/email_template_created', $templateId, $templateData);
return $this->sendSuccess([
'message' => __('Template successfully created', 'fluent-crm'),
'template_id' => $templateId
]);
}
public function duplicate($templateId)
{
$template = Template::findOrFail($templateId);
$postData = [
'post_title' => __('[Duplicate] ', 'fluent-crm') . $template['post_title'],
'post_content' => $template['post_content'],
'post_excerpt' => $template['post_excerpt'],
'post_modified' => current_time('mysql'),
'post_modified_gmt' => gmdate('Y-m-d H:i:s'),
'post_date' => current_time('mysql'),
'post_date_gmt' => gmdate('Y-m-d H:i:s'),
'post_type' => fluentcrmTemplateCPTSlug(),
];
$newTemplateId = wp_insert_post($postData);
// Meta fields to copy over
$metaKeys = [
'_email_subject',
'_edit_type',
'_template_config',
'_design_template',
'_footer_settings'
];
// Update post meta in a loop
$this->copyMetaFields($templateId, $newTemplateId, $metaKeys);
do_action('fluent_crm/email_template_duplicated', $newTemplateId, $template);
return $this->sendSuccess([
'message' => __('Template successfully duplicated', 'fluent-crm'),
'template_id' => $newTemplateId
]);
}
/**
* Helper method to copy meta fields from one post to another
*/
protected function copyMetaFields($oldPostId, $newPostId, $metaKeys)
{
foreach ($metaKeys as $metaKey) {
update_post_meta($newPostId, $metaKey, get_post_meta($oldPostId, $metaKey, true));
}
}
public function update(Request $request, $id)
{
$oldTemplate = Template::findOrFail($id);
$templateData = Helper::parseArrayOrJson($this->request->get('template'));
$designTemplate = Arr::get($templateData, 'design_template');
if (!$designTemplate) {
$designTemplate = get_post_meta($id, '_design_template', true) ?: Helper::getDefaultEmailTemplate();
$templateData['design_template'] = $designTemplate;
}
$templateData['settings'] = $this->normalizeTemplateSettings(Arr::get($templateData, 'settings', []), $designTemplate);
$footerSettings = Arr::get($templateData, 'settings.footer_settings');
if($footerSettings) {
if (($footerSettings['custom_footer'] == 'yes') && !Helper::hasComplianceText($footerSettings['footer_content'])) {
return $this->sendError([
'message' => __('##crm.manage_subscription_url## or ##crm.unsubscribe_url## string is required for compliance. Please include unsubscription or manage subscription link', 'fluent-crm')
]);
}
}
if(empty($templateData['post_title'])) {
$templateData['post_title'] = 'Email template created at '.gmdate('Y-m-d H:i');
}
if(empty($templateData['email_subject'])) {
$templateData['email_subject'] = 'Email template created at '.gmdate('Y-m-d H:i');
}
$postData = Arr::only($templateData, [
'post_title',
'post_content',
'post_excerpt'
]);
$postData['post_modified'] = current_time('mysql');
$postData['post_modified_gmt'] = gmdate('Y-m-d H:i:s');
Template::where('ID', $id)->update($postData);
update_post_meta($id, '_email_subject', Arr::get($templateData, 'email_subject'));
update_post_meta($id, '_edit_type', Arr::get($templateData, 'edit_type'));
update_post_meta($id, '_design_template', Arr::get($templateData, 'design_template'));
update_post_meta($id, '_template_config', Arr::get($templateData, 'settings.template_config', []));
update_post_meta($id, '_footer_settings', Arr::get($templateData, 'settings.footer_settings', []));
$template = Template::findOrFail($id);
do_action('fluent_crm/email_template_updated', $templateData, $template);
return $this->sendSuccess([
'message' => __('Template successfully updated', 'fluent-crm'),
'template_id' => $id
]);
}
public function handleBulkAction(Request $request)
{
$actionName = sanitize_text_field($request->get('action_name', ''));
$templateIds = array_map('intval', (array)$request->get('template_ids', []));
$templateIds = array_unique(array_filter($templateIds));
$selectAllTemplates = filter_var($request->get('select_all'), FILTER_VALIDATE_BOOLEAN);
if ($selectAllTemplates) {
$templateIds = Template::pluck('id')->toArray();
}
$templateIds = array_filter($templateIds);
if ($actionName == 'change_template_status') {
$newStatus = sanitize_text_field($request->get('status', ''));
if (!$newStatus) {
return $this->sendError([
'message' => __('Please select status', 'fluent-crm')
]);
}
$templates = Template::whereIn('ID', $templateIds)->get();
foreach ($templates as $template) {
$oldStatus = $template->post_status;
if ($oldStatus != $newStatus) {
$template->post_status = $newStatus;
$template->save();
}
}
return [
'message' => __('Status has been changed for the selected templates', 'fluent-crm')
];
} else if ($actionName == 'delete_templates') {
$templates = Template::whereIn('id', $templateIds)->get();
foreach ($templates as $template) {
wp_delete_post($template->ID, true);
}
return $this->sendSuccess([
'message' => __('Selected Templates have been deleted permanently', 'fluent-crm'),
]);
}
return [
'message' => __('invalid bulk action', 'fluent-crm')
];
}
public function delete(Request $request, $id)
{
$template = Template::findOrFail($id);
wp_delete_post($template->ID, true);
return $this->sendSuccess([
'message' => __('The template has been deleted successfully.', 'fluent-crm')
]);
}
public function render()
{
$rendered = Template::findOrFail(
$this->request->get('ID')
)->render();
return $this->sendSuccess($rendered);
}
public function allTemplates()
{
return $this->sendSuccess([
'templates' => Template::emailTemplates(['publish'])->orderBy('ID', 'desc')->get(),
'smartcodes' => $this->smartCodes()
]);
}
public function getSmartCodes()
{
return $this->sendSuccess([
'smartcodes' => $this->smartCodes()
]);
}
protected function smartCodes()
{
return Helper::getGlobalSmartCodes();
}
public function setGlobalStyle(Request $request)
{
$settings = $request->get('config', []);
foreach ($settings as $settingKey => $setting) {
$settings[$settingKey] = sanitize_text_field($setting);
}
fluentcrm_update_option('global_email_style_config', $settings);
return [
'message' => __('Global style settings have been updated', 'fluent-crm')
];
}
/**
* Fetches built-in templates from cached locally
* cached for 24 hours, then refreshed
* @return
*/
public function getBuiltInTemplates()
{
$templates = fluentCrmPersistentCache('email_remote_templates', function () {
return $this->loadRemoteTemplates();
}, 60 * 60 * 24); // 24 hours
// Return a success response with the formatted templates
return $this->sendSuccess([
'templates' => $templates
]);
}
/**
* Downloads a single built-in template file and returns it without saving
* it as a local email template.
*
* @param \FluentCrm\Framework\Http\Request\Request $request
* @return \FluentCrm\Framework\Http\Response\Response
*/
public function getBuiltInTemplate(Request $request)
{
$fileUrl = esc_url_raw($request->get('file', ''));
if (!$fileUrl || !$this->isAllowedRemoteTemplateUrl($fileUrl)) {
return $this->sendError([
'message' => __('Invalid template source URL', 'fluent-crm')
]);
}
$response = wp_remote_get($fileUrl, [
'sslverify' => true,
'timeout' => 20,
'redirection' => 0,
'limit_response_size' => 1024 * 1024
]);
if (is_wp_error($response)) {
return $this->sendError([
'message' => __('Unable to download the selected template. Please try again.', 'fluent-crm')
]);
}
$responseCode = wp_remote_retrieve_response_code($response);
if ($responseCode < 200 || $responseCode >= 300) {
return $this->sendError([
'message' => __('Unable to download the selected template. Please try again.', 'fluent-crm')
]);
}
$templateData = Helper::parseArrayOrJson(wp_remote_retrieve_body($response));
if (Arr::get($templateData, 'is_fc_template') !== 'yes') {
return $this->sendError([
'message' => __('The selected file is not a valid FluentCRM template.', 'fluent-crm')
]);
}
$template = $this->formatRemoteTemplateData($templateData);
$hasVisualBuilderDesign = $template['design_template'] === 'visual_builder' && !empty($template['_visual_builder_design']);
if (!$template['post_content'] && !$hasVisualBuilderDesign) {
return $this->sendError([
'message' => __('The selected template does not have any email content.', 'fluent-crm')
]);
}
return $this->sendSuccess([
'message' => __('Template has been inserted', 'fluent-crm'),
'template' => $template
]);
}
/**
* Restricts direct template downloads to trusted FluentCRM template hosts.
*
* @param string $url
* @return bool
*/
protected function isAllowedRemoteTemplateUrl($url)
{
$parsedUrl = wp_parse_url($url);
if (empty($parsedUrl['scheme']) || empty($parsedUrl['host']) || $parsedUrl['scheme'] !== 'https') {
return false;
}
$allowedHosts = [
'fluentcrm.com',
'www.fluentcrm.com',
'wpmanageninja.com',
'www.wpmanageninja.com'
];
if (defined('FC_TEMPLATE_API_DOMAIN')) {
$configuredHost = wp_parse_url(FC_TEMPLATE_API_DOMAIN, PHP_URL_HOST);
if ($configuredHost) {
$allowedHosts[] = strtolower($configuredHost);
}
}
return in_array(strtolower($parsedUrl['host']), array_unique($allowedHosts), true);
}
/**
* Normalizes remote JSON to the local template shape without creating a WP post.
*
* @param array $templateData
* @return array
*/
protected function formatRemoteTemplateData($templateData)
{
$designTemplate = sanitize_text_field(Arr::get($templateData, 'design_template'));
if (!$designTemplate) {
$designTemplate = Helper::getDefaultEmailTemplate();
}
$normalizedSettings = $this->normalizeTemplateSettings(Arr::get($templateData, 'settings', []), $designTemplate);
return [
'post_title' => sanitize_text_field(Arr::get($templateData, 'post_title', '')),
'post_content' => Arr::get($templateData, 'post_content', ''),
'post_excerpt' => sanitize_textarea_field(Arr::get($templateData, 'post_excerpt', '')),
'email_subject' => sanitize_text_field(Arr::get($templateData, 'email_subject', '')),
'edit_type' => sanitize_text_field(Arr::get($templateData, 'edit_type', 'html')),
'design_template' => $designTemplate,
'settings' => $normalizedSettings,
'_visual_builder_design' => Arr::get($templateData, '_visual_builder_design')
];
}
/**
* Normalize template settings with legacy footer disable compatibility.
*
* @param array $settings
* @return array
*/
protected function normalizeTemplateSettings($settings, $designTemplate = '')
{
$templateConfig = Arr::get($settings, 'template_config', []);
if (!is_array($templateConfig)) {
$templateConfig = [];
}
if (!$this->templateSupportsContentPadding($designTemplate)) {
unset($templateConfig['content_padding']);
} elseif (!isset($templateConfig['content_padding'])) {
$templateConfig['content_padding'] = 20;
}
$footerSettings = Arr::get($settings, 'footer_settings', []);
if (!is_array($footerSettings)) {
$footerSettings = [];
}
$hasExplicitDisableFooter = array_key_exists('disable_footer', $footerSettings);
$hasExplicitCustomFooter = array_key_exists('custom_footer', $footerSettings);
$disableFooter = Arr::get($footerSettings, 'disable_footer');
if ($disableFooter !== 'yes' && $disableFooter !== 'no') {
$legacyDisable = Arr::get($templateConfig, 'disable_footer');
$disableFooter = ($legacyDisable === 'yes' || $legacyDisable === 'no') ? $legacyDisable : 'no';
}
$customFooter = Arr::get($footerSettings, 'custom_footer');
if ($customFooter !== 'yes' && $customFooter !== 'no') {
$legacyFooterContent = Arr::get($footerSettings, 'footer_content', '');
$customFooter = (is_string($legacyFooterContent) && trim(wp_strip_all_tags($legacyFooterContent)))
? 'yes'
: 'no';
}
$footerSettings = wp_parse_args($footerSettings, [
'custom_footer' => 'no',
'footer_content' => '',
'disable_footer' => 'no',
'font_size' => 13,
'font_color' => '#202020',
'background_color' => 'transparent',
'footer_padding' => 20
]);
// Footer content is user-editable from a raw text mode; sanitize before persistence.
$footerSettings['footer_content'] = Sanitize::sanitizeFooterHtml(Arr::get($footerSettings, 'footer_content', ''));
$footerSettings['disable_footer'] = $disableFooter;
$footerSettings['custom_footer'] = $customFooter;
// Legacy imported templates may carry disable_footer in template_config without
// explicit footer settings. Treat those as Global Footer instead of hidden footer.
$isLegacyImportedDisabled = (
!$hasExplicitDisableFooter &&
!$hasExplicitCustomFooter &&
$footerSettings['disable_footer'] === 'yes' &&
Arr::get($templateConfig, 'disable_footer') === 'yes' &&
$footerSettings['custom_footer'] !== 'yes' &&
!trim(wp_strip_all_tags(Arr::get($footerSettings, 'footer_content', '')))
);
if ($isLegacyImportedDisabled) {
$footerSettings['disable_footer'] = 'no';
$footerSettings['custom_footer'] = 'no';
}
// Keep legacy key in sync during transition to avoid regressions in old readers.
$templateConfig['disable_footer'] = $footerSettings['disable_footer'];
return [
'template_config' => $templateConfig,
'footer_settings' => $footerSettings
];
}
/**
* Raw classic editor templates only support font family and footer flags.
*
* @param string $designTemplate
* @return bool
*/
protected function templateSupportsContentPadding($designTemplate)
{
if ($designTemplate === 'raw_classic') {
return false;
}
$templates = Helper::getEmailDesignTemplates();
$template = Arr::get($templates, $designTemplate, []);
return Arr::get($template, 'template_type') !== 'classic_editor';
}
/**
* Fetches and formats email templates from a remote FluentCRM API endpoint.
* This method makes an HTTP request to retrieve email templates from FluentCRM's public API.
* It processes the response and formats the templates into a standardized structure.
* @throws \WP_Error Logs error message if the API request fails
* @return array
* @access public
*/
public function loadRemoteTemplates()
{
$restBase = defined('FC_TEMPLATE_API_DOMAIN') ? FC_TEMPLATE_API_DOMAIN : 'https://fluentcrm.com';
$restApi = $restBase.'/wp-json/wp/v2/email-templates?per_page=50';
// Make a GET request to retrieve CRM templates
$response = wp_remote_get($restApi, [
'sslverify' => false,
]);
// Check if the request resulted in an error
if (is_wp_error($response)) {
// Handle error
error_log($response->get_error_message());
return [];
}
// Decode the JSON response from the request
$templateLists = json_decode(wp_remote_retrieve_body($response), true);
if (!is_array($templateLists)) {
return [];
}
$formattedTemplates = [];
foreach ($templateLists as $template) {
if (!$template['template_json']) {
// Skip if no template json
continue;
}
$mediaURL = '';
if ($template['featured_media'] != 0) {
$mediaURL = $this->getMediaURL($template['featured_media'], $restApi);
}
$formattedTemplates[] = [
'id' => $template['id'],
'title' => $template['title']['rendered'],
'content' => $template['template_json'],
'short_description' => $template['short_description'],
'link' => $template['link'],
'media_url' => $mediaURL,
'status' => $template['status'],
'cover_image' => $template['cover_image'],
];
}
return $formattedTemplates;
}
/**
* Retrieves the full source URL of a media item.
*
* @param int $mediaID Media item ID.
* @param string $restAPI The base URL of the REST API.
*
* @return string Full source URL of the media item.
*/
public function getMediaURL($mediaID, $restAPI) {
$request = wp_remote_get($restAPI.'media/'.$mediaID, [
'sslverify' => false,
]);
// Check for request errors
if (is_wp_error($request)) {
return '';
}
$image = json_decode($request['body'], true);
$img = Arr::get($image, 'source_url');
return $img;
}
}
@@ -0,0 +1,131 @@
<?php
namespace FluentCrm\App\Http\Controllers;
use FluentCrm\App\Models\Subscriber;
use FluentCrm\App\Services\Helper;
use FluentCrm\App\Services\Sanitize;
use FluentCrm\Framework\Support\Arr;
use FluentCrm\Framework\Http\Request\Request;
/**
* UsersController - REST API Handler Class
*
* REST API Handler
*
* @package FluentCrm\App\Http
*
* @version 1.0.0
*/
class UsersController extends Controller
{
/**
* Get all the users.
* @param \FluentCrm\Framework\Http\Request\Request $request
* @return \WP_REST_Response
*/
public function index(Request $request)
{
$roles = $request->getSafe('roles', 'sanitize_text_field', []);
$limit = $request->limit ?: 5;
$fields = $request->fields ?: ['ID', 'display_name', 'user_email'];
$userQuery = new \WP_User_Query([
'role__in' => $roles,
'number' => $limit,
'fields' => $fields,
]);
$users = $userQuery->get_results();
$total = $userQuery->get_total();
return $this->send([
'users' => $users,
'total' => $total
]);
}
public function import(Request $request)
{
$inputs = $request->only([
'map', 'tags', 'lists', 'roles', 'update', 'new_status', 'double_optin_email', 'import_silently'
]);
/**
* Filter the number of subscribers to process per request while importing users in FluentCRM.
*
* This filter allows you to modify the number of subscribers that are processed
* in a single request when processing subscribers in FluentCRM.
*
* @param int $limit The number of subscribers to process per request. Default is 100.
*/
$limit = apply_filters('fluent_crm/process_subscribers_per_request', 100);
$page = absint($request->get('page', 1));
$userQuery = new \WP_User_Query([
'role__in' => Arr::get($inputs, 'roles', []),
'number' => $limit,
'offset' => ($page - 1) * $limit
]);
if (Arr::get($inputs, 'import_silently') == 'yes') {
if(!defined('FLUENTCRM_DISABLE_TAG_LIST_EVENTS')) {
define('FLUENTCRM_DISABLE_TAG_LIST_EVENTS', true);
}
}
$total = $userQuery->get_total();
$users = $userQuery->get_results();
if($users) {
$this->processUsers($users, $inputs);
}
$hasRecords = !!count($users);
return $this->sendSuccess([
'message' => __('Processing', 'fluent-crm'),
'page_total' => ceil($total / $limit),
'record_total' => $total,
'has_more' => $hasRecords,
'current_page' => $page,
'next_page' => $page + 1
]);
}
private function processUsers($users, $inputs)
{
$subscribers = [];
foreach ($users as $user) {
$subscriber = Helper::getWPMapUserInfo($user);
$subscriber['source'] = 'wp_users';
if ($subscriber['email']) {
$subscribers[] = Sanitize::contact($subscriber);
}
}
$sendDoubleOptin = Arr::get($inputs, 'double_optin_email') == 'yes';
return Subscriber::import(
$subscribers,
Arr::get($inputs, 'tags', []),
Arr::get($inputs, 'lists', []),
Arr::get($inputs, 'update'),
Arr::get($inputs, 'new_status'),
$sendDoubleOptin
);
}
public function roles()
{
if (!function_exists('get_editable_roles')) {
require_once(ABSPATH . '/wp-admin/includes/user.php');
}
$roles = \get_editable_roles();
return [
'roles' => $roles
];
}
}
@@ -0,0 +1,87 @@
<?php
namespace FluentCrm\App\Http\Controllers;
use FluentCrm\App\Services\ExternalIntegrations\MailComplaince\Webhook;
use FluentCrm\Framework\Http\Request\Request;
/**
* WebhookBounceController - REST API Handler Class
*
* REST API Handler
*
* @package FluentCrm\App\Http
*
* @version 1.0.0
*/
class WebhookBounceController extends Controller
{
private $validServices = ['mailgun', 'pepipost', 'postmark', 'sendgrid', 'sparkpost', 'elasticemail', 'postalserver', 'smtp2go', 'brevo', 'tosend'];
public function handleBounce(Request $request, $serviceName, $securityCode)
{
if (!in_array($serviceName, $this->validServices)) {
/**
* Filter the bounce handling response for a specific service.
*
* The dynamic portion of the hook name, `$serviceName`, refers to the name of the email service. This is a custom bounce handler.
*
* @since 2.5.95
*
* @param array {
* The response data.
*
* @type int $success Indicates if the bounce handling was successful (0 or 1).
* @type string $message The message associated with the bounce handling.
* @type string $service The name of the email service.
* @type string $result The result of the bounce handling.
* @type int $time The timestamp when the bounce was handled.
* }
* @param object $request The request object.
* @param string $securityCode The security code for the request.
*/
return apply_filters('fluent_crm_handle_bounce_' . $serviceName, [
'success' => 0,
'message' => '',
'service' => $serviceName,
'result' => '',
'time' => time()
], $request, $securityCode);
}
if (!hash_equals($this->getSecurityCode(), $securityCode)) {
return $this->getError();
}
$result = (new Webhook())->handle($serviceName, $request);
return [
'success' => 1,
'message' => 'recorded',
'service' => $serviceName,
'result' => $result,
'time' => time()
];
}
private function getSecurityCode()
{
$code = fluentcrm_get_option('_fc_bounce_key');
if (!$code) {
$code = 'fcrm_' . substr(md5(wp_generate_uuid4()), 0, 14);
fluentcrm_update_option('_fc_bounce_key', $code);
}
return $code;
}
private function getError()
{
return [
'status' => false,
'message' => __('Invalid Data or Security Code', 'fluent-crm')
];
}
}
@@ -0,0 +1,113 @@
<?php
namespace FluentCrm\App\Http\Controllers;
use FluentCrm\App\Models\Company;
use FluentCrm\App\Services\Helper;
use FluentCrm\Framework\Http\Request\Request;
use FluentCrm\Framework\Support\Str;
use FluentCrm\App\Models\Webhook;
use FluentCrm\App\Models\Lists;
use FluentCrm\App\Models\Tag;
/**
* WebhookController - REST API Handler Class
*
* REST API Handler
*
* @package FluentCrm\App\Http
*
* @version 1.0.0
*/
class WebhookController extends Controller
{
public function index(Request $request, Webhook $webhook)
{
$fields = $webhook->getFields();
$search = $request->getSafe('search');
$webhooks = $webhook->latest()->get()->toArray();
if (!empty($search)) {
$search = strtolower($search);
$webhooks = array_map(function ($row) use ($search) {
$value = isset($row['value']) && is_array($row['value']) ? $row['value'] : [];
$name = strtolower((string)($value['name'] ?? ''));
if ($name !== '' && Str::contains($name, $search)) {
return $row;
}
return null;
}, $webhooks);
}
$rows = [];
foreach ($webhooks as $row) {
if ($row) {
$rows[] = $row;
}
}
$response = [
'webhooks' => $rows,
'fields' => $fields['fields'],
'custom_fields' => $fields['custom_fields'],
'lists' => Lists::get(),
'tags' => Tag::get()
];
if (Helper::isCompanyEnabled()) {
$response['companies'] = Company::get();
}
return $response;
}
public function create(Request $request, Webhook $webhook)
{
$data = $request->all();
$validatedData = $this->validate($data, [
'name' => 'required',
'status' => 'required'
]);
$webhook = $webhook->store($validatedData);
return [
'id' => $webhook->id,
'webhook' => $webhook->value,
'webhooks' => $webhook->latest()->get(),
'message' => __('Successfully created the WebHook', 'fluent-crm')
];
}
public function update(Request $request, Webhook $webhook, $id)
{
$existingWebhook = $webhook->find($id);
if (!$existingWebhook) {
return $this->sendError([
'message' => __('Webhook not found', 'fluent-crm')
], 404);
}
$existingWebhook->saveChanges($request->all());
return [
'webhooks' => $webhook->latest()->get(),
'message' => __('Successfully updated the webhook', 'fluent-crm')
];
}
public function delete(Webhook $webhook, $id)
{
$webhook->where('id', $id)->delete();
return [
'webhooks' => $webhook->latest()->get(),
'message' => __('Successfully deleted the webhook', 'fluent-crm')
];
}
}
@@ -0,0 +1,38 @@
<?php
namespace FluentCrm\App\Http\Policies;
use FluentCrm\Framework\Http\Request\Request;
class AiPolicy extends BasePolicy
{
public function getSettings(Request $request)
{
return $this->currentUserCan('fcrm_manage_settings');
}
public function saveSettings(Request $request)
{
return $this->currentUserCan('fcrm_manage_settings');
}
public function testConnection(Request $request)
{
return $this->currentUserCan('fcrm_manage_settings');
}
public function generate(Request $request)
{
return $this->currentUserCan('fcrm_manage_emails');
}
public function generateEmailBody(Request $request)
{
return $this->currentUserCan('fcrm_manage_emails');
}
public function contactSummary(Request $request)
{
return $this->currentUserCan('fcrm_read_contacts');
}
}
@@ -0,0 +1,33 @@
<?php
namespace FluentCrm\App\Http\Policies;
use FluentCrm\App\Services\PermissionManager;
use FluentCrm\Framework\Foundation\Policy;
use FluentCrm\Framework\Http\Request\Request;
/**
* BasePolicy - REST API Permission Policy
*
* @package FluentCrm\App\Http
*
* @version 1.0.0
*/
class BasePolicy extends Policy
{
/**
* Check user permission for any method
* @param Request $request
* @return Boolean
*/
public function verifyRequest(Request $request)
{
return $this->currentUserCan('manage_options');
}
public function currentUserCan($permission)
{
return PermissionManager::currentUserCan($permission);
}
}
@@ -0,0 +1,44 @@
<?php
namespace FluentCrm\App\Http\Policies;
use FluentCrm\Framework\Http\Request\Request;
/**
* CampaignPolicy - REST API Permission Policy
*
* @package FluentCrm\App\Http
*
* @version 1.0.0
*/
class CampaignPolicy extends BasePolicy
{
/**
* Check user permission for any method
* @param \FluentCrm\Framework\Http\Request\Request $request
* @return Boolean
*/
public function verifyRequest(Request $request)
{
if ($request->method() == 'GET') {
return $this->currentUserCan('fcrm_read_emails');
}
return $this->currentUserCan('fcrm_manage_emails');
}
public function delete(Request $request)
{
return $this->currentUserCan('fcrm_manage_email_delete');
}
public function deleteCampaignEmails(Request $request)
{
return $this->currentUserCan('fcrm_manage_email_delete');
}
public function handleBulkAction(Request $request)
{
return $this->currentUserCan('fcrm_manage_email_delete');
}
}
@@ -0,0 +1,66 @@
<?php
namespace FluentCrm\App\Http\Policies;
use FluentCrm\App\Http\Policies\BasePolicy;
use FluentCrm\App\Services\Helper;
use FluentCrm\Framework\Http\Request\Request;
class CompanyPolicy extends BasePolicy
{
private function isEnabled()
{
return Helper::isCompanyEnabled();
}
/**
* Check user permission for any method
* @param \FluentCrm\Framework\Http\Request\Request $request
* @return Boolean
*/
public function verifyRequest(Request $request)
{
return $this->isEnabled() && $this->currentUserCan('fcrm_manage_contact_cats');
}
public function delete(Request $request)
{
return $this->isEnabled() && $this->currentUserCan('fcrm_manage_contact_cats_delete');
}
/**
* Check user permission for bulk company actions.
*
* The delete bulk action permanently removes companies, so it must require
* the stronger delete permission while other bulk updates keep the manage
* permission used by the company module.
*
* @param \FluentCrm\Framework\Http\Request\Request $request
* @return Boolean
*/
public function handleBulkActions(Request $request)
{
$actionName = sanitize_text_field($request->get('action_name', ''));
if ($actionName == 'delete_companies') {
return $this->delete($request);
}
return $this->verifyRequest($request);
}
public function detachSubscribers(Request $request)
{
return $this->verifyRequest($request);
}
public function bulkDeleteNotes(Request $request)
{
return $this->verifyRequest($request);
}
public function deleteSubscribes(Request $request)
{
return $this->detachSubscribers($request);
}
}
@@ -0,0 +1,32 @@
<?php
namespace FluentCrm\App\Http\Policies;
use FluentCrm\Framework\Http\Request\Request;
/**
* CustomFieldsPolicy - REST API Permission Policy
*
* @package FluentCrm\App\Http
*
* @version 1.0.0
*/
class CustomFieldsPolicy extends BasePolicy
{
/**
* Check user permission for any method
* @param \FluentCrm\Framework\Http\Request\Request $request
* @return Boolean
*/
public function verifyRequest(Request $request)
{
return $this->currentUserCan('fcrm_manage_settings');
}
//TODO: masiur vai
public function getLabels(Request $request)
{
return true;
}
}
@@ -0,0 +1,27 @@
<?php
namespace FluentCrm\App\Http\Policies;
use FluentCrm\Framework\Http\Request\Request;
class EmailPatternPolicy extends BasePolicy
{
public function verifyRequest(Request $request)
{
if ($request->method() == 'GET') {
return $this->currentUserCan('fcrm_read_emails');
}
return $this->currentUserCan('fcrm_manage_emails');
}
public function delete(Request $request)
{
return $this->currentUserCan('fcrm_manage_email_delete');
}
public function handleBulkAction(Request $request)
{
return $this->currentUserCan('fcrm_manage_email_delete');
}
}
@@ -0,0 +1,27 @@
<?php
namespace FluentCrm\App\Http\Policies;
use FluentCrm\Framework\Http\Request\Request;
/**
* FormsPolicy - REST API Permission Policy
*
* @package FluentCrm\App\Http
*
* @version 1.0.0
*/
class FormsPolicy extends BasePolicy
{
/**
* Check user permission for any method
* @param \FluentCrm\Framework\Http\Request\Request $request
* @return Boolean
*/
public function verifyRequest(Request $request)
{
return $this->currentUserCan('fcrm_manage_forms');
}
}
@@ -0,0 +1,53 @@
<?php
namespace FluentCrm\App\Http\Policies;
use FluentCrm\Framework\Http\Request\Request;
/**
* FunnelPolicy - REST API Permission Policy
*
* @package FluentCrm\App\Http
*
* @version 1.0.0
*/
class FunnelPolicy extends BasePolicy
{
/**
* Check user permission for any method
* @param \FluentCrm\Framework\Http\Request\Request $request
* @return Boolean
*/
public function verifyRequest(Request $request)
{
if ($request->method() == 'GET') {
return $this->currentUserCan('fcrm_read_funnels');
}
return $this->currentUserCan('fcrm_write_funnels');
}
public function delete(Request $request)
{
return $this->currentUserCan('fcrm_delete_funnels');
}
public function handleBulkAction(Request $request)
{
if ($request->get('action_name') == 'delete_funnels') {
return $this->currentUserCan('fcrm_delete_funnels');
}
return $this->currentUserCan('fcrm_write_funnels');
}
public function removeBulkSubscribers(Request $request)
{
return $this->currentUserCan('fcrm_delete_funnels');
}
public function deleteSubscribers(Request $request)
{
return $this->currentUserCan('fcrm_delete_funnels');
}
}
@@ -0,0 +1,24 @@
<?php
namespace FluentCrm\App\Http\Policies;
use FluentCrm\Framework\Http\Request\Request;
/**
* ImportUserPolicy - Import Contact Policy
*
* @package FluentCrm\App\Http
*/
class ImportUserPolicy extends BasePolicy {
/**
* Check user permission for any method
*
* @param \FluentCrm\Framework\Http\Request\Request $request
*
* @return Boolean
*/
public function verifyRequest( Request $request )
{
return $this->currentUserCan('fcrm_manage_contacts');
}
}
@@ -0,0 +1,42 @@
<?php
namespace FluentCrm\App\Http\Policies;
use FluentCrm\Framework\Http\Request\Request;
/**
* ListPolicy - REST API Permission Policy
*
* @package FluentCrm\App\Http
*
* @version 1.0.0
*/
class ListPolicy extends BasePolicy
{
/**
* Check user permission for any method
* @param \FluentCrm\Framework\Http\Request\Request $request
* @return Boolean
*/
public function verifyRequest(Request $request)
{
return $this->currentUserCan('fcrm_manage_contact_cats');
}
/**
* Check user permission for delete lists
* @param \FluentCrm\Framework\Http\Request\Request $request
* @return Boolean
*/
public function remove(Request $request)
{
return $this->currentUserCan('fcrm_manage_contact_cats_delete');
}
public function handleBulkAction(Request $request)
{
return $this->currentUserCan('fcrm_manage_contact_cats_delete');
}
}
@@ -0,0 +1,26 @@
<?php
namespace FluentCrm\App\Http\Policies;
use FluentCrm\Framework\Http\Request\Request;
/**
* PublicPolicy - REST API Permission Policy
*
* @package FluentCrm\App\Http
*
* @version 1.0.0
*/
class PublicPolicy extends BasePolicy
{
/**
* Check user permission for any method
* @param \FluentCrm\Framework\Http\Request\Request $request
* @return Boolean
*/
public function verifyRequest(Request $request)
{
return true;
}
}
@@ -0,0 +1,36 @@
<?php
namespace FluentCrm\App\Http\Policies;
use FluentCrm\Framework\Http\Request\Request;
/**
* ReportPolicy - REST API Permission Policy
*
* @package FluentCrm\App\Http
*
* @version 1.0.0
*/
class ReportPolicy extends BasePolicy
{
/**
* Check user permission for any method
* @param \FluentCrm\Framework\Http\Request\Request $request
* @return Boolean
*/
public function verifyRequest(Request $request)
{
return $this->currentUserCan('fcrm_view_dashboard');
}
public function getEmails(Request $request)
{
return $this->currentUserCan('fcrm_read_emails');
}
public function deleteEmails(Request $request)
{
return $this->currentUserCan('fcrm_manage_email_delete');
}
}
@@ -0,0 +1,12 @@
<?php
namespace FluentCrm\App\Http\Policies;
use FluentCrm\Framework\Http\Request\Request;
class SettingsPolicy extends BasePolicy
{
public function verifyRequest(Request $request)
{
return $this->currentUserCan('fcrm_manage_settings');
}
}
@@ -0,0 +1,76 @@
<?php
namespace FluentCrm\App\Http\Policies;
use FluentCrm\Framework\Http\Request\Request;
/**
* SubscriberPolicy - REST API Permission Policy
*
* @package FluentCrm\App\Http
*
* @version 1.0.0
*/
class SubscriberPolicy extends BasePolicy
{
/**
* Check user permission for any method
* @param \FluentCrm\Framework\Http\Request\Request $request
* @return Boolean
*/
public function verifyRequest(Request $request)
{
if ($request->method() == 'GET') {
return $this->currentUserCan('fcrm_read_contacts');
}
return $this->currentUserCan('fcrm_manage_contacts');
}
public function deleteSubscriber(Request $request)
{
return $this->currentUserCan('fcrm_manage_contacts_delete');
}
public function deleteSubscribers(Request $request)
{
return $this->currentUserCan('fcrm_manage_contacts_delete');
}
public function deleteNote(Request $request)
{
return $this->currentUserCan('fcrm_manage_contacts_delete');
}
public function bulkDeleteNotes(Request $request)
{
return $this->currentUserCan('fcrm_manage_contacts_delete');
}
public function deleteEmails(Request $request)
{
return $this->currentUserCan('fcrm_manage_email_delete');
}
public function handleBulkActions(Request $request)
{
$actionName = $request->get('action_name');
if (!$actionName) {
return $this->currentUserCan('fcrm_manage_contacts');
}
$actionMaps = [
'add_to_email_sequence' => 'fcrm_manage_emails',
'add_to_automation' => 'fcrm_write_funnels',
'delete_contacts' => 'fcrm_manage_contacts_delete'
];
if (isset($actionMaps[$actionName])) {
return $this->currentUserCan($actionMaps[$actionName]);
}
return $this->currentUserCan('fcrm_manage_contacts');
}
}
@@ -0,0 +1,42 @@
<?php
namespace FluentCrm\App\Http\Policies;
use FluentCrm\Framework\Http\Request\Request;
/**
* TagPolicy - REST API Permission Policy
*
* @package FluentCrm\App\Http
*
* @version 1.0.0
*/
class TagPolicy extends BasePolicy
{
/**
* Check user permission for any method
* @param \FluentCrm\Framework\Http\Request\Request $request
* @return Boolean
*/
public function verifyRequest(Request $request)
{
return $this->currentUserCan('fcrm_manage_contact_cats');
}
/**
* Check user permission for delete tags
* @param \FluentCrm\Framework\Http\Request\Request $request
* @return Boolean
*/
public function remove(Request $request)
{
return $this->currentUserCan('fcrm_manage_contact_cats_delete');
}
public function handleBulkAction(Request $request)
{
return $this->currentUserCan('fcrm_manage_contact_cats_delete');
}
}
@@ -0,0 +1,41 @@
<?php
namespace FluentCrm\App\Http\Policies;
use FluentCrm\Framework\Http\Request\Request;
/**
* TemplatePolicy - REST API Permission Policy
*
* @package FluentCrm\App\Http
*
* @version 1.0.0
*/
class TemplatePolicy extends BasePolicy
{
/**
* Check user permission for any method
* @param \FluentCrm\Framework\Http\Request\Request $request
* @return Boolean
*/
public function verifyRequest(Request $request)
{
return $this->currentUserCan('fcrm_manage_email_templates');
}
public function getBuiltInTemplate(Request $request)
{
return $this->currentUserCan('fcrm_manage_email_templates');
}
public function delete(Request $request)
{
return $this->currentUserCan('fcrm_manage_email_delete');
}
public function handleBulkAction(Request $request)
{
return $this->currentUserCan('fcrm_manage_email_delete');
}
}
@@ -0,0 +1,26 @@
<?php
namespace FluentCrm\App\Http\Policies;
use FluentCrm\Framework\Http\Request\Request;
/**
* UsersPolicy - REST API Permission Policy
*
* @package FluentCrm\App\Http
*
* @version 1.0.0
*/
class UsersPolicy extends BasePolicy
{
/**
* Check user permission for any method
* @param \FluentCrm\Framework\Http\Request\Request $request
* @return Boolean
*/
public function verifyRequest(Request $request)
{
return current_user_can('list_users');
}
}
@@ -0,0 +1,26 @@
<?php
namespace FluentCrm\App\Http\Policies;
use FluentCrm\Framework\Http\Request\Request;
/**
* WebhookPolicy - REST API Permission Policy
*
* @package FluentCrm\App\Http
*
* @version 1.0.0
*/
class WebhookPolicy extends BasePolicy
{
/**
* Check user permission for any method
* @param \FluentCrm\Framework\Http\Request\Request $request
* @return Boolean
*/
public function verifyRequest(Request $request)
{
return $this->currentUserCan('fcrm_manage_settings');
}
}
@@ -0,0 +1,515 @@
<?php
/**
* @var $router FluentCrm\Framework\Http\Router
*/
use FluentCrm\App\Http\Controllers\CampaignAnalyticsController;
use FluentCrm\App\Http\Controllers\CsvController;
use FluentCrm\App\Http\Controllers\CustomContactFieldsController;
use FluentCrm\App\Http\Controllers\DashboardController;
use FluentCrm\App\Http\Controllers\GlobalLabelController;
use FluentCrm\App\Http\Controllers\ImporterController;
use FluentCrm\App\Http\Controllers\PurchaseHistoryController;
use FluentCrm\App\Http\Controllers\SetupController;
use FluentCrm\App\Http\Controllers\SubscriberController;
use FluentCrm\App\Http\Controllers\CompanyController;
use FluentCrm\App\Http\Controllers\TagsController;
use FluentCrm\App\Http\Controllers\ListsController;
use FluentCrm\App\Http\Controllers\CampaignController;
use FluentCrm\App\Http\Controllers\FunnelController;
use FluentCrm\App\Http\Controllers\ReportingController;
use FluentCrm\App\Http\Controllers\SettingsController;
use FluentCrm\App\Http\Controllers\TemplateController;
use FluentCrm\App\Http\Controllers\WebhookBounceController;
use FluentCrm\App\Http\Controllers\WebhookController;
use FluentCrm\App\Http\Controllers\UsersController;
use FluentCrm\App\Http\Controllers\FormsController;
use FluentCrm\App\Http\Controllers\DocsController;
use FluentCrm\App\Http\Controllers\OptionsController;
use FluentCrm\App\Http\Controllers\SystemLogController;
use FluentCrm\App\Http\Controllers\MigratorController;
use FluentCrm\App\Modules\AbandonCart\AbandonCartController;
use FluentCrm\App\Modules\AbandonCart\SettingsController as AbandonCartSettingsController;
use FluentCrm\App\Http\Controllers\AiController;
use FluentCrm\App\Http\Controllers\EmailPatternController;
use FluentCrm\App\Http\Controllers\MCPSettingsController;
/*
* /tags endpoints
*/
$router->prefix('tags')->withPolicy('TagPolicy')->group(function ($router) {
$router->get('/', [TagsController::class, 'index']);
$router->post('/', [TagsController::class, 'create']);
$router->get('{id}', [TagsController::class, 'find'])->int('id');
$router->put('{id}', [TagsController::class, 'store'])->int('id');
$router->delete('{id}', [TagsController::class, 'remove'])->int('id');
$router->post('do-bulk-action', [TagsController::class, 'handleBulkAction']);
$router->post('/bulk', [TagsController::class, 'storeBulk']);
});
/*
* /lists endpoints
*/
$router->prefix('lists')->withPolicy('ListPolicy')->group(function ($router) {
$router->get('/', [ListsController::class, 'index']);
$router->post('/', [ListsController::class, 'create']);
$router->get('{id}', [ListsController::class, 'find'])->int('id');
$router->put('{id}', [ListsController::class, 'update'])->int('id');
$router->delete('/{id}', [ListsController::class, 'remove'])->int('id');
$router->post('do-bulk-action', [ListsController::class, 'handleBulkAction']);
$router->post('/bulk', [ListsController::class, 'storeBulk']);
});
/*
* Global search: contacts + email campaigns + automations in one call.
* Each Permission is checked in the controller.
*/
$router->get('global-search', [OptionsController::class, 'search']);
/*
* /subscribers endpoints
*/
$router->prefix('subscribers')->withPolicy('SubscriberPolicy')->group(function ($router) {
$router->get('/', [SubscriberController::class, 'index']);
$router->post('/', [SubscriberController::class, 'store']);
$router->put('subscribers-property', [SubscriberController::class, 'updateProperty']);
$router->delete('/', [SubscriberController::class, 'deleteSubscribers']);
$router->post('sync-segments', [SubscriberController::class, 'tagger']);
$router->post('do-bulk-action', [SubscriberController::class, 'handleBulkActions']);
$router->get('prev-next-ids', [SubscriberController::class, 'getPrevNextIds']);
$router->get('{id}', [SubscriberController::class, 'show'])->int('id');
$router->delete('{id}', [SubscriberController::class, 'deleteSubscriber'])->int('id');
$router->put('{id}', [SubscriberController::class, 'updateSubscriber'])->int('id');
$router->get('{id}/emails', [SubscriberController::class, 'emails'])->int('id');
$router->get('{id}/emails/template-mock', [SubscriberController::class, 'getTemplateMock'])->int('id');
$router->post('{id}/emails/send', [SubscriberController::class, 'sendCustomEmail'])->int('id');
$router->delete('{id}/emails', [SubscriberController::class, 'deleteEmails'])->int('id');
$router->get('{id}/purchase-history', [PurchaseHistoryController::class, 'getOrders'])->int('id');
$router->get('{id}/form-submissions', [SubscriberController::class, 'getFormSubmissions'])->int('id');
$router->get('{id}/support-tickets', [SubscriberController::class, 'getSupportTickets'])->int('id');
$router->post('{id}/send-double-optin', [SubscriberController::class, 'sendDoubleOptinEmail'])->int('id');
$router->get('{id}/notes', [SubscriberController::class, 'getNotes'])->int('id');
$router->post('{id}/notes', [SubscriberController::class, 'addNote'])->int('id');
$router->put('{id}/notes/{note_id}', [SubscriberController::class, 'updateNote'])->int('id')->int('note_id');
$router->delete('{id}/notes/{note_id}', [SubscriberController::class, 'deleteNote'])->int('id')->int('note_id');
$router->post('{id}/notes/bulk-delete', [SubscriberController::class, 'bulkDeleteNotes'])->int('id');
$router->get('{id}/external_view', [SubscriberController::class, 'getExternalView'])->int('id');
$router->post('{id}/external_view', [SubscriberController::class, 'saveExternalViewData'])->int('id');
$router->get('{id}/info-widgets', [SubscriberController::class, 'getInfoWidgets'])->int('id');
$router->get('{id}/dynamic-item-view', [SubscriberController::class, 'getDynamicItemView'])->int('id');
$router->get('search-contacts', [SubscriberController::class, 'searchContacts']);
$router->get('{id}/tracking-events', [SubscriberController::class, 'getTrackingEvents'])->int('id');
$router->post('track-event', [SubscriberController::class, 'trackEvent']);
$router->get('{id}/url-metrics', [SubscriberController::class, 'getUrlMetrics'])->int('id');
$router->post('bulk-add-update', [SubscriberController::class, 'bulkAddUpdate']);
});
$router->prefix('campaigns')->withPolicy('CampaignPolicy')->group(function ($router) {
$router->get('/', [CampaignController::class, 'campaigns']);
$router->post('/', [CampaignController::class, 'create']);
$router->post('/send-test-email', [CampaignController::class, 'sendTestEmail']);
// Editor/draft preview iframe: renders a campaign payload or campaign_id without email-history metadata.
$router->post('/email-preview-html', [CampaignController::class, 'getEmailPreviewBody']);
// Sent/scheduled email history preview: used from contact profile, campaign emails, and all emails.
$router->get('emails/{email_id}/preview', [CampaignController::class, 'previewEmail'])->int('email_id');
$router->post('estimated-contacts', [CampaignController::class, 'getContactEstimation']);
$router->post('update-single-campaign', [CampaignController::class, 'updateSingleCampaignSimulate']);
$router->get('{id}', [CampaignController::class, 'campaign'])->int('id');
$router->put('{id}', [CampaignController::class, 'update'])->int('id');
$router->post('{id}/step', [CampaignController::class, 'updateStep'])->int('id');
$router->post('{id}/pause', [CampaignController::class, 'pauseCampaign'])->int('id');
$router->post('{id}/duplicate', [CampaignController::class, 'duplicateCampaign'])->int('id');
$router->post('{id}/resume', [CampaignController::class, 'resumeCampaign'])->int('id');
$router->put('{id}/title', [CampaignController::class, 'updateCampaignTitle'])->int('id');
$router->delete('{id}', [CampaignController::class, 'delete'])->int('id');
$router->post('do-bulk-action', [CampaignController::class, 'handleBulkAction'])->int('id');
// todo: delete this endpoint '{id}/subscribe' in future since it is not in use anywhere. We will keep it for reference for now. We will remove in the immediate next version
// $router->post('{id}/subscribe', [CampaignController::class, 'subscribe'])->int('id');
$router->post('{id}/draft-recipients', [CampaignController::class, 'draftRecipients'])->int('id');
$router->get('{id}/estimated-recipients-count', [CampaignController::class, 'recipientsCount'])->int('id');
$router->get('{id}/emails', [CampaignController::class, 'campaignEmails'])->int('id');
$router->delete('{id}/emails', [CampaignController::class, 'deleteCampaignEmails'])->int('id');
$router->post('{id}/schedule', [CampaignController::class, 'schedule'])->int('id');
$router->post('{id}/un-schedule', [CampaignController::class, 'unSchedule'])->int('id');
$router->get('{id}/processing-stat', [CampaignController::class, 'processingStat'])->int('id');
$router->get('{id}/share-url', [CampaignController::class, 'getShareUrl'])->int('id');
$router->get('{id}/status', [CampaignController::class, 'getCampaignStatus'])->int('id');
$router->get('{id}/overview_stats', [CampaignController::class, 'getOverviewStats'])->int('id');
$router->get('{id}/link-report', [CampaignAnalyticsController::class, 'getLinksReport'])->int('id');
$router->get('{id}/revenues', [CampaignAnalyticsController::class, 'getRevenueReport'])->int('id');
$router->post('{id}/revenues/resync', [CampaignAnalyticsController::class, 'getRevenueReSyncReport'])->int('id');
$router->get('{id}/unsubscribers', [CampaignAnalyticsController::class, 'getUnsubscribers'])->int('id');
$router->get('{id}/contacts-by-segment', [CampaignAnalyticsController::class, 'getSegmentedContacts'])->int('id');
$router->put('{id}/update-labels', [CampaignController::class, 'updateLabels'])->int('id');
});
$router->prefix('templates')->withPolicy('TemplatePolicy')->group(function ($router) {
$router->get('/', [TemplateController::class, 'templates']);
$router->get('/all', [TemplateController::class, 'allTemplates']);
$router->get('/smartcodes', [TemplateController::class, 'getSmartCodes']);
$router->post('/', [TemplateController::class, 'create']);
$router->get('{id}', [TemplateController::class, 'template'])->int('id');
$router->put('{id}', [TemplateController::class, 'update'])->int('id');
$router->post('/duplicate/{id}', [TemplateController::class, 'duplicate'])->int('id');
$router->delete('{id}', [TemplateController::class, 'delete'])->int('id');
$router->post('do-bulk-action', [TemplateController::class, 'handleBulkAction']);
$router->post('set-global-style', [TemplateController::class, 'setGlobalStyle']);
$router->post('built-in-template', [TemplateController::class, 'getBuiltInTemplate']);
$router->get('/built-in-templates', [TemplateController::class, 'getBuiltInTemplates']);
});
/*
* Email Patterns Route
*/
$router->prefix('email-patterns')->withPolicy('EmailPatternPolicy')->group(function ($router) {
$router->get('/', [EmailPatternController::class, 'index']);
$router->post('/', [EmailPatternController::class, 'store']);
$router->get('{id}', [EmailPatternController::class, 'show'])->int('id');
$router->put('{id}', [EmailPatternController::class, 'update'])->int('id');
$router->delete('{id}', [EmailPatternController::class, 'delete'])->int('id');
$router->post('do-bulk-action', [EmailPatternController::class, 'handleBulkAction']);
// wp_block-compatible endpoints for editor middleware interception
$router->get('/wp-format', [EmailPatternController::class, 'indexWpFormat']);
$router->post('/wp-format', [EmailPatternController::class, 'storeWpFormat']);
// Pattern categories
$router->get('/categories', [EmailPatternController::class, 'getCategories']);
$router->post('/categories', [EmailPatternController::class, 'storeCategory']);
$router->delete('/categories/{id}', [EmailPatternController::class, 'deleteCategory'])->int('id');
});
/*
* Funnels Route
*/
$router->prefix('funnels')->withPolicy('FunnelPolicy')->group(function ($router) {
$router->get('/', [FunnelController::class, 'funnels']);
$router->post('/', [FunnelController::class, 'create']);
$router->get('templates', [FunnelController::class, 'getTemplates']);
$router->post('create-from-template', [FunnelController::class, 'createFromTemplate']);
$router->post('import', [FunnelController::class, 'importFunnel']);
$router->get('all-activities', [FunnelController::class, 'getAllActivities']);
$router->post('remove-bulk-subscribers', [FunnelController::class, 'removeBulkSubscribers']);
$router->get('triggers', [FunnelController::class, 'getTriggersRest']);
$router->get('subscriber/{subscriber_id}/automations', [FunnelController::class, 'subscriberAutomations']);
$router->post('funnel/save-funnel-sequences', [FunnelController::class, 'saveSequencesFallback']);
$router->post('funnel/save-email-action-fallback', [FunnelController::class, 'saveEmailActionFallback']);
$router->get('{id}', [FunnelController::class, 'getFunnel'])->int('id');
$router->post('{id}/clone', [FunnelController::class, 'cloneFunnel'])->int('id');
$router->put('{id}', [FunnelController::class, 'updateFunnelProperty'])->int('id');
$router->put('{id}/change-trigger', [FunnelController::class, 'changeTrigger'])->int('id');
$router->post('{id}/sequences', [FunnelController::class, 'saveSequences'])->int('id');
$router->put('funnel/{id}/title', [FunnelController::class, 'updateFunnelTitle'])->int('id');
$router->post('{id}/sequences/save-email-action', [FunnelController::class, 'saveEmailAction'])->int('id');
$router->get('{id}/subscribers', [FunnelController::class, 'getSubscribers'])->int('id');
$router->get('{id}/subscribers/{contact_id}', [FunnelController::class, 'getSubscriberReporting'])->int('id')->int('contact_id');
$router->delete('{id}/subscribers', [FunnelController::class, 'deleteSubscribers'])->int('id');
$router->delete('{id}', [FunnelController::class, 'delete'])->int('id');
$router->get('{id}/report', [FunnelController::class, 'report'])->int('id');
$router->post('do-bulk-action', [FunnelController::class, 'handleBulkAction']);
$router->get('{id}/email_reports', [FunnelController::class, 'getEmailReports'])->int('id');
$router->put('{id}/subscribers/{subscriber_id}/status', [FunnelController::class, 'updateSubscriptionStatus'])->int('id')->int('subscriber_id');
$router->post('{id}/subscribers/{subscriber_id}/advance', [FunnelController::class, 'forceAdvanceSubscriber'])->int('id')->int('subscriber_id');
$router->get('{id}/syncable-counts', [FunnelController::class, 'getSyncableContactCounts'])->int('id');
$router->post('{id}/sync-new-steps', [FunnelController::class, 'syncNewSteps'])->int('id');
$router->post('send-test-webhook', [FunnelController::class, 'sendTestWebhook']);
$router->put('{id}/update-labels', [FunnelController::class, 'updateLabels'])->int('id');
});
/*
* Reporting Route
*/
$router->prefix('reports')->withPolicy('ReportPolicy')->group(function ($router) {
$router->get('dashboard-stats', [DashboardController::class, 'getStats']);
$router->get('subscribers', [ReportingController::class, 'getContactGrowth']);
$router->get('email-sents', [ReportingController::class, 'getEmailSentStats']);
$router->get('email-opens', [ReportingController::class, 'getEmailOpenStats']);
$router->get('email-clicks', [ReportingController::class, 'getEmailClickStats']);
$router->get('email-unsubs', [ReportingController::class, 'getEmailUnsubStats']);
$router->get('email-performance', [ReportingController::class, 'getEmailPerformance']);
$router->get('options', [OptionsController::class, 'index']);
$router->get('ajax-options', [OptionsController::class, 'getAjaxOptions']);
$router->get('taxonomy-terms', [OptionsController::class, 'getTaxonomyTerms']);
$router->get('cascade_selections', [OptionsController::class, 'getCascadeSelections']);
$router->get('emails', [ReportingController::class, 'getEmails']);
$router->delete('emails', [ReportingController::class, 'deleteEmails']);
$router->get('advanced-providers', [ReportingController::class, 'getAdvancedReportProviders']);
$router->get('contacts-by-status', [ReportingController::class, 'getContactsByStatus']);
$router->get('contacts-by-tags', [ReportingController::class, 'getContactsByTags']);
$router->get('contacts-by-lists', [ReportingController::class, 'getContactsByLists']);
$router->get('contacts-by-country', [ReportingController::class, 'getContactsByCountry']);
$router->get('recent-tags', [ReportingController::class, 'getRecentTags']);
$router->get('campaigns-list', [ReportingController::class, 'getCampaignsList']);
$router->get('campaign-options', [ReportingController::class, 'getCampaignOptions']);
$router->get('automations', [ReportingController::class, 'getAutomationReports']);
$router->get('automations/{id}/steps', [ReportingController::class, 'getAutomationStepReport']);
$router->get('ping', [ReportingController::class, 'ping']);
});
$router->prefix('setting')->withPolicy('SettingsPolicy')->group(function ($router) {
$router->get('/', [SettingsController::class, 'get']);
$router->put('/', [SettingsController::class, 'save']);
$router->post('complete-installation', [SetupController::class, 'CompleteWizard']);
$router->get('double-optin', [SettingsController::class, 'getDoubleOptinSettings']);
$router->put('double-optin', [SettingsController::class, 'saveDoubleOptinSettings']);
$router->post('install-fluentform', [SetupController::class, 'handleFluentFormInstall']);
$router->post('install-fluentsmtp', [SetupController::class, 'handleFluentSmtpInstall']);
$router->post('install-fluent-support', [SetupController::class, 'handleFluentSupportInstall']);
$router->post('install-fluent-boards', [SetupController::class, 'handleFluentBoardsInstall']);
$router->post('install-fluent-community', [SetupController::class, 'handleFluentCommunityInstall']);
$router->post('install-fluent-cart', [SetupController::class, 'handleFluentCartInstall']);
$router->post('install-fluent-booking', [SetupController::class, 'handleFluentBookingInstall']);
$router->get('bounce_configs', [SettingsController::class, 'getBounceConfigs']);
$router->get('auto_subscribe_settings', [SettingsController::class, 'getAutoSubscribeSettings']);
$router->post('auto_subscribe_settings', [SettingsController::class, 'saveAutoSubscribeSettings']);
$router->get('test', [SettingsController::class, 'TestRequestResolver']);
$router->put('test', [SettingsController::class, 'TestRequestResolver']);
$router->post('test', [SettingsController::class, 'TestRequestResolver']);
$router->delete('test', [SettingsController::class, 'TestRequestResolver']);
$router->post('reset_db', [SettingsController::class, 'resetDB']);
$router->get('old_logs', [SettingsController::class, 'getOldLogDetails']);
$router->delete('old_logs', [SettingsController::class, 'removeOldLogs']);
$router->get('cron_status', [SettingsController::class, 'getCronStatus']);
$router->post('run_cron', [SettingsController::class, 'runCron']);
$router->get('db-index-health', [SettingsController::class, 'getDbIndexHealth']);
$router->post('db-index-health/repair', [SettingsController::class, 'repairDbIndexes']);
$router->get('rest-keys', [SettingsController::class, 'getRestKeys']);
$router->post('rest-keys', [SettingsController::class, 'createRestKey']);
$router->delete('rest-keys', [SettingsController::class, 'deleteRestKey']);
$router->get('integrations', [SettingsController::class, 'getIntegrations']);
$router->post('integrations', [SettingsController::class, 'saveIntegration']);
$router->get('compliance', [SettingsController::class, 'getComplianceSettings']);
$router->post('compliance', [SettingsController::class, 'updateComplianceSettings']);
$router->get('experiments', [SettingsController::class, 'getExperimentalSettings']);
$router->post('experiments', [SettingsController::class, 'updateExperimentalSettings']);
$router->get('experiments/campaigns', [SettingsController::class, 'getCampaigns']);
$router->get('system-logs', [SystemLogController::class, 'index']);
$router->get('system-logs/export', [SystemLogController::class, 'export']);
$router->delete('system-logs/reset', [SystemLogController::class, 'deleteAll']);
// will be added in future
// $router->get('activity-logs', [ActivityLogController::class, 'index']);
// $router->get('activity-logs/reset', [ActivityLogController::class, 'deleteAll']);
$router->get('abandon-cart', [AbandonCartSettingsController::class, 'getSettings']);
$router->post('abandon-cart', [AbandonCartSettingsController::class, 'saveSettings']);
});
$router->prefix('ai')->withPolicy('AiPolicy')->group(function ($router) {
$router->get('settings', [AiController::class, 'getSettings']);
$router->post('settings', [AiController::class, 'saveSettings']);
$router->post('models', [AiController::class, 'getModels']);
$router->post('test', [AiController::class, 'testConnection']);
$router->post('generate', [AiController::class, 'generate']);
$router->post('generate-email-body', [AiController::class, 'generateEmailBody']);
$router->post('contact-summary', [AiController::class, 'contactSummary']);
});
/*
* MCP settings endpoints Settings MCP admin page (MCP_PLAN.md § 13).
*/
$router->prefix('mcp')->withPolicy('SettingsPolicy')->group(function ($router) {
$router->get('status', [MCPSettingsController::class, 'status']);
$router->post('toggle', [MCPSettingsController::class, 'toggle']);
$router->post('install-adapter', [MCPSettingsController::class, 'installAdapter']);
$router->get('config-snippet', [MCPSettingsController::class, 'getConfigSnippet']);
});
$router->prefix('abandon-carts')->withPolicy('FunnelPolicy')->group(function ($router) {
$router->get('/', [AbandonCartController::class, 'getCarts']);
$router->post('bulk-delete', [AbandonCartController::class, 'handleBulkDeleteCart']);
$router->get('report-summary', [AbandonCartController::class, 'getReportSummary']);
});
$router->prefix('custom-fields')->withPolicy('CustomFieldsPolicy')->group(function ($router) {
$router->get('contacts', [CustomContactFieldsController::class, 'getGlobalFields']);
$router->put('contacts', [CustomContactFieldsController::class, 'saveGlobalFields']);
$router->put('contacts/update_group_name', [CustomContactFieldsController::class, 'updateGroupName']);
});
$router->prefix('labels')->withPolicy('CustomFieldsPolicy')->group(function ($router) {
$router->get('/', [GlobalLabelController::class, 'getlabels']);
$router->post('/', [GlobalLabelController::class, 'create']);
$router->put('{id}', [GlobalLabelController::class, 'update'])->int('id');
$router->delete('{id}', [GlobalLabelController::class, 'delete'])->int('id');
});
$router->prefix('webhooks')->withPolicy('WebhookPolicy')->group(function ($router) {
$router->get('/', [WebhookController::class, 'index']);
$router->post('/', [WebhookController::class, 'create']);
$router->put('/{id}', [WebhookController::class, 'update'])->int('id');
$router->delete('/{id}', [WebhookController::class, 'delete'])->int('id');
});
/*
* Users
*/
$router->prefix('users')->withPolicy('UsersPolicy')->group(function ($router) {
$router->get('/', [UsersController::class, 'index']);
$router->get('/roles', [UsersController::class, 'roles']);
});
/*
* Import
*/
$router->prefix('import')->withPolicy('ImportUserPolicy')->group(function ($router) {
$router->post('csv-upload', [CsvController::class, 'upload']);
$router->post('csv-import', [CsvController::class, 'import']);
$router->post('users', [UsersController::class, 'import']);
$router->get('drivers', [ImporterController::class, 'getDrivers']);
$router->get('drivers/{driver}', [ImporterController::class, 'getDriver'])->alphaNumDash('driver');
$router->post('drivers/{driver}', [ImporterController::class, 'importData'])->alphaNumDash('driver');
});
/*
* Fluent Forms Wrapper
*/
$router->prefix('forms')->withPolicy('FormsPolicy')->group(function ($router) {
$router->get('/', [FormsController::class, 'index']);
$router->post('/', [FormsController::class, 'create']);
$router->get('templates', [FormsController::class, 'getTemplates']);
$router->get('{id}/entries', [FormsController::class, 'getEntries'])->int('id');
$router->get('{form_id}/entries/{id}', [FormsController::class, 'getEntry'])->int('form_id')->int('id');
});
/*
* Fluent Forms Wrapper
*/
$router->prefix('docs')->withPolicy('ReportPolicy')->group(function ($router) {
$router->get('/', [DocsController::class, 'index']);
$router->get('/{doc_id}', [DocsController::class, 'getDoc'])->int('doc_id');
$router->get('/addons', [DocsController::class, 'getAddons']);
});
/*
* Public EndPoints
*/
$router->prefix('public')->withPolicy('PublicPolicy')->group(function ($router) {
$router->any('bounce_handler/{service_name}/handle/{security_code}', [WebhookBounceController::class, 'handleBounce'])
->alphaNumDash('service_name')
->alphaNumDash('security_code');
$router->any('bounce_handler/{service_name}/{security_code}', [WebhookBounceController::class, 'handleBounce'])
->alphaNumDash('service_name')
->alphaNumDash('security_code');
});
$router->prefix('migrators')->withPolicy('SettingsPolicy')->group(function ($router) {
$router->get('/', [MigratorController::class, 'getDrivers']);
$router->post('/verify-cred', [MigratorController::class, 'verifyCredential']);
$router->get('/list-tag-mappings', [MigratorController::class, 'getListTagMappings']);
$router->post('/summary', [MigratorController::class, 'getImportSummary']);
$router->post('/import', [MigratorController::class, 'handleImport']);
});
$router->prefix('companies')->withPolicy('CompanyPolicy')->group(function ($router) {
$router->get('/', [CompanyController::class, 'index']);
$router->post('/', [CompanyController::class, 'create']);
$router->get('/{id}', [CompanyController::class, 'find'])->int('id');
$router->put('/{id}', [CompanyController::class, 'update'])->int('id');
$router->delete('/{id}', [CompanyController::class, 'delete'])->int('id');
$router->get('/search', [CompanyController::class, 'searchCompanies']);
$router->get('/search-unattached-contacts', [CompanyController::class, 'searchUnattachedContacts']);
$router->put('companies-property', [CompanyController::class, 'updateProperty']);
$router->post('attach-subscribers', [CompanyController::class, 'attachSubscribers']);
$router->post('detach-subscribers', [CompanyController::class, 'detachSubscribers']);
$router->post('do-bulk-action', [CompanyController::class, 'handleBulkActions']);
$router->get('{id}/notes', [CompanyController::class, 'getNotes'])->int('id');
$router->post('{id}/notes', [CompanyController::class, 'addNote'])->int('id');
$router->put('{id}/notes/{note_id}', [CompanyController::class, 'updateNote'])->int('id')->int('note_id');
$router->delete('{id}/notes/{note_id}', [CompanyController::class, 'deleteNote'])->int('id')->int('note_id');
$router->post('{id}/notes/bulk-delete', [CompanyController::class, 'bulkDeleteNotes'])->int('id');
$router->post('csv-import', [CsvController::class, 'importCompanies']);
$router->get('custom-fields', [CompanyController::class, 'getCustomGlobalFields']);
$router->put('custom-fields', [CompanyController::class, 'saveCustomGlobalFields']);
$router->put('custom-fields/update_group_name', [CompanyController::class, 'updateCustomFieldGroupName']);
$router->get('{id}/custom_tab_view', [CompanyController::class, 'getCompanyExternalView'])->int('id');
});
@@ -0,0 +1,8 @@
<?php if ( ! defined( 'ABSPATH' ) ) exit; // Exit if accessed directly
/**
* @var $router FluentCrm\Framework\Http\Router
*/
$router->namespace('FluentCrm\App\Http\Controllers')->group(function($router) {
require_once __DIR__ . '/api.php';
});
@@ -0,0 +1,70 @@
<?php
namespace FluentCrm\App\Models;
/**
* Activity Log Model - DB Model for Activity Logs
*
* Database Model
*
* @package FluentCrm\App\Models
*
* @version 1.0.0
*/
class ActivityLog extends Model
{
protected $table = 'fc_activity_logs';
protected $guarded = ['id'];
protected $fillable = [
'object_type',
'object_id',
'action',
'source',
'description',
'activity_by',
'created_at',
'updated_at'
];
// Ensure the key is added to every serialized row
protected $appends = ['activity_by_email'];
// Cast the description column to an array (or object)
protected $casts = [
'description' => 'array', // Automatically decodes JSON to array
// Use 'object' instead of 'array' if you prefer stdClass objects
];
public static function boot()
{
parent::boot();
static::creating(function ($model) {
if (empty($model->created_at)) {
$model->created_at = fluentCrmTimestamp();
}
if (empty($model->activity_by)) {
$model->activity_by = 0;
}
$model->updated_at = fluentCrmTimestamp();
});
static::updated(function ($model) {
$model->updated_at = fluentCrmTimestamp();
});
}
public function getActivityByEmailAttribute()
{
$user = User::where('ID', $this->activity_by)->first();
if (!$user) {
return null;
}
return $user->display_name . ' (' . $user->user_email . ')';
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,667 @@
<?php
namespace FluentCrm\App\Models;
use FluentCrm\App\Services\BlockParser;
use FluentCrm\App\Services\Helper;
use FluentCrm\Framework\Support\Arr;
use FluentCrm\Framework\Support\Str;
/**
* CampaignEmail Model - DB Model for Campaign Emails
*
* Database Model
*
* @package FluentCrm\App\Models
*
* @version 1.0.0
*/
class CampaignEmail extends Model
{
protected $table = 'fc_campaign_emails';
protected $guarded = ['id'];
protected $appends = ['email_type_label'];
/**
* Define the canonical email types and any legacy or module-specific aliases.
*
* @return array<string, array<int, string>>
*/
public static function getEmailTypeAliases()
{
return [
'funnel_email_campaign' => ['funnel_email_campaign', 'automation'],
'recurring_campaign' => ['recurring_campaign', 'recurring_email_campaign'],
'custom_email_campaign' => ['custom_email_campaign', 'custom_email'],
'campaign' => ['campaign'],
'sequence' => ['sequence', 'email_sequence', 'sequence_email'],
];
}
/**
* Map canonical email types to the user-facing labels used in reporting.
*
* @return array<string, string>
*/
public static function getEmailTypeLabels()
{
return [
'funnel_email_campaign' => __('Automation', 'fluent-crm'),
'recurring_campaign' => __('Recurring Campaign', 'fluent-crm'),
'custom_email_campaign' => __('Custom Email', 'fluent-crm'),
'campaign' => __('Campaign', 'fluent-crm'),
'sequence' => __('Sequence', 'fluent-crm'),
];
}
/**
* Resolve the canonical email type key for filtering and reporting.
*
* @param string|null $emailType
* @return string
*/
public static function normalizeEmailType($emailType)
{
$emailType = sanitize_text_field((string)$emailType);
foreach (static::getEmailTypeAliases() as $canonicalType => $aliases) {
if (in_array($emailType, $aliases, true)) {
return $canonicalType;
}
}
return $emailType ?: 'campaign';
}
/**
* Expand selected canonical types into the raw slugs stored in email rows.
*
* @param array<int, string> $selectedTypes
* @return array<int, string>
*/
public static function expandEmailTypes(array $selectedTypes)
{
$expandedTypes = [];
$aliases = static::getEmailTypeAliases();
foreach ($selectedTypes as $selectedType) {
$canonicalType = static::normalizeEmailType($selectedType);
$expandedTypes = array_merge($expandedTypes, $aliases[$canonicalType] ?? [$canonicalType]);
}
return array_values(array_unique($expandedTypes));
}
/**
* Resolve the human-readable email type label for reports and tables.
*
* @return string
*/
public function getEmailTypeLabelAttribute()
{
return static::resolveEmailTypeLabel($this->email_type);
}
/**
* Convert a stored email type slug into a stable UI label.
*
* @param string|null $emailType
* @return string
*/
public static function resolveEmailTypeLabel($emailType)
{
$emailType = static::normalizeEmailType($emailType);
$labels = static::getEmailTypeLabels();
if (isset($labels[$emailType])) {
return $labels[$emailType];
}
return ucwords(str_replace(['_', '-'], ' ', $emailType ?: 'campaign'));
}
/**
* One2One: CampaignEmail belongs to one Campaign
* @return Model
*/
public function campaign()
{
return $this->belongsTo(
__NAMESPACE__ . '\Campaign', 'campaign_id', 'id'
)->withoutGlobalScope('type');
}
/**
* One2One: CampaignEmail belongs to one Subscriber
* @return Model
*/
public function subscriber()
{
return $this->belongsTo(
__NAMESPACE__ . '\Subscriber', 'subscriber_id', 'id'
);
}
/**
* One2One: CampaignEmail belongs to one Subject
*
* Note: The email_subject_id will be inserted by calculating the prioroty
* from subjects table where the subjects are related to a parent Campaign.
* So, when creating a campaign email, there will be an option to select a
* subject from a list and that list will contain subjects related to the
* parent campaign because a campaign can have many subjects and the campaign
* email will get only one from that list by calculating the priority from subjects.
*
* @return Model
*/
public function subject()
{
return $this->belongsTo(
__NAMESPACE__ . '\Subject', 'email_subject_id', 'id'
);
}
public function markAs($status)
{
$this->status = $status;
$this->save();
return $this;
}
public function markAsSent($status = 'sent')
{
return $this->markAs($status);
}
public function markAsFailed($status = 'failed')
{
return $this->markAs($status);
}
/**
* Data for the email to be sent
* @return array
*/
public function data()
{
$email_subject = $this->getEmailSubject();
$email_body = $this->getEmailBody();
$headers = Helper::getMailHeader($this->email_headers);
return [
'to' => [
'email' => $this->email_address,
'name' => ($this->subscriber) ? $this->subscriber->full_name : ''
],
'headers' => $headers,
'subject' => $email_subject,
'body' => $email_body,
'campaign_id' => $this->campaign_id,
'id' => $this->id,
'subscriber_id' => $this->subscriber_id
];
}
/**
* Build preview data for one queued/sent email row.
*
* Route: GET /campaigns/emails/{email_id}/preview via CampaignController::previewEmail().
* This is the contact/campaign/all-emails history preview, not the draft editor preview
* route (POST /campaigns/email-preview-html). Keep the body rendering rules aligned with
* CampaignController::getEmailPreviewBody(): raw/classic-builder templates must bypass
* BlockParser, while block-editor templates should continue through BlockParser.
*
* @return array
*/
public function previewData()
{
$emailSettings = fluentcrmGetGlobalSettings('email_settings', []);
$campaign = $this->campaign;
$subscriber = $this->subscriber;
$emailBody = ($this->email_body) ? $this->email_body : (($campaign) ? $campaign->email_body : '');
$designTemplate = ($campaign) ? $campaign->design_template : '';
if (!$designTemplate) {
$designTemplate = 'plain';
}
$rawTemplates = [
'raw_html',
'visual_builder',
'raw_classic'
];
if (in_array($designTemplate, $rawTemplates, true) || ($this->is_parsed && $this->email_body)) {
$emailBody = wp_unslash($emailBody);
} else {
$emailBody = (new BlockParser($subscriber))->parse($emailBody);
}
/**
* Determine the campaign email body content text.
*
* This filter allows you to modify the email body content before it is sent to the subscriber.
*
* @param string $emailBody The email body content.
* @param object $this->subscriber The subscriber object.
* @since 2.7.0
*
*/
$emailBody = apply_filters('fluent_crm/parse_campaign_email_text', $emailBody, $subscriber);
$templateConfig = wp_parse_args(
($campaign) ? Arr::get($campaign->settings, 'template_config', []) : [],
Helper::getTemplateConfig($designTemplate)
);
$emailFooterConfig = ($campaign) ? Helper::getFooterConfig($campaign) : [];
$footerText = Arr::get($emailFooterConfig, 'footer_content', '');
if ($subscriber) {
$subscriber->campaign_id = $this->campaign_id;
/**
* Determine the footer text of a campaign email for previewing.
*
* This filter allows you to modify the footer text of a campaign email before it is sent to the subscriber.
*
* @param string $footerText The footer text of the campaign email.
* @param object $subscriber The subscriber object.
* @since 2.7.0
*
*/
$footerText = apply_filters('fluent_crm/parse_campaign_email_text', $footerText, $subscriber);
}
$preHeader = ($campaign) ? $campaign->email_pre_header : '';
if ($preHeader && $subscriber) {
/**
* Filter the pre-header text of a campaign email for previewing.
*
* @param string $preHeader The pre-header text of the campaign email.
* @param object $subscriber The subscriber object.
* @since 2.7.0
*
*/
$preHeader = apply_filters('fluent_crm/parse_campaign_email_text', $preHeader, $subscriber);
}
$emailFooterConfig['footer_content'] = $footerText;
/**
* Filter the email body content using a specific email design template.
*
* This filter allows customization of the email body content by applying a specific design template.
*
* @param string $emailBody The original email body content before applying the design template.
* @param array {
* Contextual information for the email design template.
*
* @type string $preHeader The pre-header text for the email, if available.
* @type string $email_body The original email body content.
* @type string $footer_text The footer text for the email, if any.
* @type array $config Configuration settings for the email template.
* }
* @param object|null $this->campaign The campaign object, if available.
* @param object|null $this->subscriber The subscriber object, if available.
* @since 1.0.0
*
*/
$email_body = apply_filters(
'fluent_crm/email-design-template-' . $designTemplate,
$emailBody,
[
'preHeader' => $preHeader,
'email_body' => $emailBody,
'footer_text' => $footerText,
'footer_config' => $emailFooterConfig,
'config' => $templateConfig
],
$campaign,
$subscriber
);
if (Str::contains($email_body, ['##crm.', '{{crm.'])) {
/**
* Filter the email body content for a campaign email and parse SmartCodes.
*
* This filter allows customization of the email body content before it is sent to the subscriber. There are FluentCRM-specific SmartCodes.
*
* @param string $email_body The email body content to be filtered.
* @param object $this ->subscriber The subscriber object containing subscriber details.
* @since 2.7.0
*
*/
$email_body = apply_filters('fluent_crm/parse_extended_crm_text', $email_body, $subscriber);
}
$preViewUrl = site_url('?fluentcrm=1&route=email_preview&_e_hash=' . $this->email_hash);
$email_body = str_replace(['##web_preview_url##', '{{crm_global_email_footer}}', '{{crm_preheader_text}}'], [$preViewUrl, $footerText, $preHeader], $email_body);
$email_body = str_replace(['https://fonts.googleapis.com/css2', 'https://fonts.googleapis.com/css'], 'https://fonts.bunny.net/css', $email_body);
return [
'to' => [
'email' => $this->email_address,
'name' => ($subscriber) ? $subscriber->full_name : ''
],
'from' => [
'name' => Arr::get($emailSettings, 'from_name'),
'email' => Arr::get($emailSettings, 'from_email')
],
'reply' => null,
'subject' => $this->email_subject,
'body' => $email_body,
'campaign_id' => $this->campaign_id,
'id' => $this->id,
'subscriber_id' => $this->subscriber_id
];
}
public function getEmailSubject()
{
return $this->email_subject;
}
public function getEmailBody()
{
$subscriber = $this->subscriber;
if ($subscriber) {
$subscriber->email_id = $this->id;
}
$designTemplate = 'classic';
$campaign = $this->campaign;
if ($campaign) {
$designTemplate = $campaign->design_template;
}
if ($this->is_parsed && !$this->email_body && $campaign && $campaign->email_body) {
// Recover unsent queue rows that were marked parsed after their body was cleared.
$this->is_parsed = 0;
}
if (!$this->is_parsed) {
$rawTemplates = [
'raw_html',
'visual_builder',
'raw_classic'
];
$emailBody = ($campaign) ? $campaign->email_body : $this->email_body;
// Don't cache URL map if body has conditional blocks or merge tags inside href URLs
$canCache = !Helper::hasConditionOnString($emailBody)
&& !preg_match('/href=["\'][^"\']*\{\{/', $emailBody);
if (in_array($designTemplate, $rawTemplates)) {
$emailBody = $this->campaign->email_body;
} else {
$emailBody = $this->getParsedEmailBody();
}
$emailBody = str_replace(['https://fonts.googleapis.com/css2', 'https://fonts.googleapis.com/css'], 'https://fonts.bunny.net/css', $emailBody);
$emailBody = apply_filters('fluent_crm/parse_campaign_email_text', $emailBody, $subscriber);
$emailBody = apply_filters('fluentcrm_email_body_text', $emailBody, $subscriber, $this);
if ($campaign && $trackingType = $campaign->getClickTrackingStatus()) {
$campaignUrls = $this->getCampaignUrls($emailBody, $canCache);
if ($campaignUrls) {
if ($trackingType === 'anonymous') {
$emailBody = Helper::attachAnonymousUrls($emailBody, $campaignUrls, $this->id, $this->email_hash);
} else {
$emailBody = Helper::attachUrls($emailBody, $campaignUrls, $this->id, $this->email_hash);
}
}
}
$this->email_body = $emailBody;
$this->is_parsed = 1;
// Not saved to DB here — the parsed body is kept in memory for Mailer::send().
// BaseHandler's mark-as-sent UPDATE persists is_parsed=1 and clears email_body.
// On rare retry (process crash), the email re-parses from campaign body which
// is correct. This avoids a ~15ms LONGTEXT write per email during bulk sends.
}
$emailFooterConfig = [];
$footerText = '';
if ($subscriber) {
$subscriber->campaign_id = $this->campaign_id;
static $footerConfigCache = [];
$cacheKey = $this->campaign_id ?: 0;
if (isset($footerConfigCache[$cacheKey])) {
$emailFooterConfig = $footerConfigCache[$cacheKey];
} else {
$emailFooterConfig = Helper::getFooterConfig($campaign);
$footerConfigCache[$cacheKey] = $emailFooterConfig;
}
$footerText = Arr::get($emailFooterConfig, 'footer_content', '');
if ($footerText) {
/**
* Filter the footer text of the campaign email.
*
* This filter allows you to modify the footer text of the campaign email before it is sent to the subscriber.
*
* @param string $footerText The footer text of the campaign email.
* @param object $subscriber The subscriber object.
* @since 2.7.0
*
*/
$footerText = apply_filters('fluent_crm/parse_campaign_email_text', $footerText, $subscriber);
$preViewUrl = site_url('?fluentcrm=1&route=email_preview&_e_hash=' . $this->email_hash);
$footerText = str_replace('##web_preview_url##', $preViewUrl, $footerText);
$emailFooterConfig['footer_content'] = $footerText;
}
}
static $templateConfigCache = [];
$templateCacheKey = $this->campaign_id ?: 0;
if (isset($templateConfigCache[$templateCacheKey])) {
$templateConfig = $templateConfigCache[$templateCacheKey];
} else {
if ($this->campaign && Arr::get($this->campaign->settings, 'template_config')) {
$templateConfig = wp_parse_args($this->campaign->settings['template_config'], Helper::getTemplateConfig($this->campaign->design_template));
} else {
$templateConfig = Helper::getTemplateConfig();
}
$templateConfigCache[$templateCacheKey] = $templateConfig;
}
$preHeader = ($this->campaign) ? $this->campaign->email_pre_header : '';
if ($preHeader && $subscriber) {
/**
* Filter the pre-header text of a campaign email.
*
* This filter allows you to modify the pre-header text of a campaign email before it is sent.
*
* @param string $preHeader The pre-header text of the campaign email.
* @param object $subscriber The subscriber object containing subscriber details.
* @since 2.7.0
*
*/
$preHeader = apply_filters('fluent_crm/parse_campaign_email_text', $preHeader, $subscriber);
}
$footerUrls = $this->getCampaignUrls($footerText, false);
if ($footerUrls) {
$trackingType = $campaign ? $campaign->getClickTrackingStatus() : null;
if ($trackingType === 'anonymous') {
$footerText = Helper::attachAnonymousUrls($footerText, $footerUrls, $this->id, $this->email_hash);
} else {
$footerText = Helper::attachUrls($footerText, $footerUrls, $this->id, $this->email_hash);
}
}
$templateData = [
'preHeader' => $preHeader,
'email_body' => $this->email_body,
'footer_text' => $footerText,
'config' => $templateConfig,
'footer_config' => $emailFooterConfig,
];
/**
* Filter the email design template content.
*
* This filter allows customization of the email design template content based on the template type.
*
* @param string $this ->email_body The original email body content.
* @param array $templateData The data used for the template.
* @param object $this ->campaign The campaign object.
* @param object $this ->subscriber The subscriber object.
* @since 1.0.0
*
*/
$content = apply_filters(
'fluent_crm/email-design-template-' . $designTemplate,
$this->email_body,
$templateData,
$this->campaign,
$this->subscriber
);
$preViewUrl = site_url('?fluentcrm=1&route=email_preview&_e_hash=' . $this->email_hash);
$content = str_replace(['##web_preview_url##', '{{crm_global_email_footer}}', '{{crm_preheader_text}}'], [$preViewUrl, $footerText, $preHeader], $content);
if (Str::contains($content, ['##crm.', '{{crm'])) {
/**
* Filter the content to parse extended CRM text such as SmartCodes.
*
* This filter allows you to modify the content by parsing extended CRM text. There are FluentCRM-specific SmartCodes available.
*
* @param string $content The content to be filtered.
* @param object $subscriber The subscriber object.
* @since 2.7.0
*
*/
$content = apply_filters('fluent_crm/parse_extended_crm_text', $content, $subscriber);
}
return Helper::injectTrackerPixel($content, $this->email_hash, $this->id);
}
private function getParsedEmailBody()
{
if (!$this->campaign_id || !$this->campaign) {
// return (new BlockParser($this->subscriber))->parse($this->email_body);
return (new BlockParser($this->subscriber))->parse($this->email_body);
}
static $parsedEmailBody = [];
$originalBody = $this->campaign->email_body;
$hasConditions = Helper::hasConditionOnString($originalBody);
if (isset($parsedEmailBody[$this->campaign_id]) && !$hasConditions) {
return $parsedEmailBody[$this->campaign_id];
}
if ($this->campaign->status == 'archived' && !$hasConditions) {
$cachedEmailBody = fluentcrm_get_campaign_meta($this->campaign_id, '_cached_email_body', true);
if ($cachedEmailBody) {
$parsedEmailBody[$this->campaign_id] = $cachedEmailBody;
return $parsedEmailBody[$this->campaign_id];
}
}
$rawTemplates = [
'raw_html',
'visual_builder',
'raw_classic'
];
if (in_array($this->campaign->design_template, $rawTemplates)) {
$emailBody = $originalBody;
} else {
// $emailBody = (new BlockParser($this->subscriber))->parse($originalBody);
$emailBody = (new BlockParser($this->subscriber))->parse($originalBody);
}
if ($hasConditions) {
return $emailBody;
}
$parsedEmailBody[$this->campaign_id] = $emailBody;
return $emailBody;
}
public function getCampaignUrls($emailBody, $cached = false)
{
$trackingType = fluentcrmTrackClicking();
if (!$trackingType) {
return [];
}
if (!$cached || !$this->campaign_id) {
return Helper::urlReplaces($emailBody);
}
static $campaignUrls = [];
if (isset($campaignUrls[$this->campaign_id])) {
return $campaignUrls[$this->campaign_id];
}
$campaignUrls[$this->campaign_id] = Helper::urlReplaces($emailBody);
return $campaignUrls[$this->campaign_id];
}
public function getClicks()
{
return fluentCrmDb()->table('fc_campaign_url_metrics')
->select(['fc_campaign_url_metrics.counter', 'fc_url_stores.url', 'fc_campaign_url_metrics.id'])
->where('type', 'click')
->where('fc_campaign_url_metrics.subscriber_id', $this->subscriber_id)
->where('fc_campaign_url_metrics.campaign_id', $this->campaign_id)
->join('fc_url_stores', 'fc_url_stores.id', '=', 'fc_campaign_url_metrics.url_id')
->get();
}
public function getSubjectCount($campaignId)
{
return static::select(
'fc_campaign_emails.email_subject_id',
fluentCrmDb()->raw('count(*) as total'),
'fc_meta.value',
'fc_meta.key'
)
->where('fc_campaign_emails.campaign_id', $campaignId)
->groupBy('fc_campaign_emails.email_subject_id')
->join('fc_meta', 'fc_meta.id', '=', 'fc_campaign_emails.email_subject_id')
->get();
}
public function getOpenCount($subjectId)
{
return static::where('email_subject_id', $subjectId)
->where('is_open', '>', 0)
->count();
}
public function setEmailHeadersAttribute($headers)
{
$this->attributes['email_headers'] = \maybe_serialize($headers);
}
public function getEmailHeadersAttribute($settings)
{
return \maybe_unserialize($settings);
}
}
@@ -0,0 +1,324 @@
<?php
namespace FluentCrm\App\Models;
use FluentCrm\App\Services\Helper;
use FluentCrm\Framework\Support\Arr;
/**
* CampaignUrlMetric Model - DB Model for Email URL Metrics
*
* Database Model
*
* @package FluentCrm\App\Models
*
* @version 1.0.0
*/
class CampaignUrlMetric extends Model
{
protected $table = 'fc_campaign_url_metrics';
protected $guarded = ['id'];
public function campaign()
{
return $this->belongsTo(__NAMESPACE__ . '\Campaign', 'campaign_id', 'id')
->withoutGlobalScope('type');
}
public function subscriber()
{
return $this->belongsTo(__NAMESPACE__ . '\Subscriber', 'subscriber_id', 'id');
}
public function url_stores()
{
return $this->belongsTo(__NAMESPACE__ . '\UrlStores', 'url_id', 'id');
}
public static function maybeInsert($data)
{
$query = static::where([
'campaign_id' => $data['campaign_id'],
'subscriber_id' => $data['subscriber_id'],
'type' => $data['type']
])->when(!empty($data['url_id']), function ($query) use ($data) {
return $query->where('url_id', $data['url_id']);
});
if ($instance = $query->first()) {
$instance->counter += 1;
$instance->save();
return $instance;
}
return static::create($data);
}
public function getLinksReport($campaign)
{
if (is_numeric($campaign)) {
$campaign = Campaign::withoutGlobalScopes()->find($campaign);
}
if (!$campaign) {
return [];
}
$settings = $campaign->settings;
$clickTracker = $settings['click_tracker'] ?? true;
// is anonimous tracking enabled?
if ($clickTracker === 'anonymous') {
// get from meta
$links = fluentcrm_get_campaign_meta($campaign->id, '_ano_url_clicks', true);
$formattedLinks = [];
if ($links && is_array($links)) {
$index = 1;
foreach ($links as $link => $count) {
$formattedLinks[] = [
'id' => $index,
'url' => esc_url_raw($link),
'total' => $count
];
$index++;
}
}
// sort by total desc
usort($formattedLinks, function ($a, $b) {
return $b['total'] <=> $a['total'];
});
return $this->maybeTransformSmartLinks($formattedLinks);
}
if ($clickTracker === false) {
return [];
}
$stats = static::select(
fluentCrmDb()->raw('count(*) as total'),
'fc_url_stores.url',
'fc_url_stores.id'
)
->where('fc_campaign_url_metrics.campaign_id', $campaign->id)
->where('fc_campaign_url_metrics.type', 'click')
->groupBy('fc_campaign_url_metrics.url_id')
->join('fc_url_stores', 'fc_url_stores.id', '=', 'fc_campaign_url_metrics.url_id')
->orderBy('total', 'DESC')
->get()->toArray();
$formatedLinks = [];
foreach ($stats as $stat) {
$url = str_replace(['&amp;'], ['&'], $stat['url']);
$url = esc_url_raw($url);
if (isset($formatedLinks[$url])) {
$formatedLinks[$url]['total'] += $stat['total'];
continue;
}
$formatedLinks[$url] = [
'id' => $stat['id'],
'url' => $url,
'total' => $stat['total']
];
}
$sortedLinks = array_values($formatedLinks);
usort($sortedLinks, function ($a, $b) {
return $b['total'] <=> $a['total'];
});
return $this->maybeTransformSmartLinks($sortedLinks);
}
public function getCampaignAnalytics($campaign)
{
if (is_numeric($campaign)) {
$campaign = Campaign::withoutGlobalScopes()->find($campaign);
}
if (!$campaign) {
return [];
}
$unsubscribeCount = CampaignUrlMetric::where('campaign_id', $campaign->id)
->where('type', 'unsubscribe')
->distinct()
->count('subscriber_id');
$formattedStatus = [];
if ($campaign->getOpenTrackingStatus(false) === 'anonymous') {
$openCount = fluentcrm_get_campaign_meta($campaign->id, '_ano_open_count', true);
if (!$openCount) {
$openCount = 0;
}
} else {
$openCount = fluentCrmDb()->table('fc_campaign_emails')
->where('campaign_id', $campaign->id)
->where(function ($q) {
$q->where('is_open', 1)
->orWhereNotNull('click_counter');
})
->count();
}
if ($campaign->getClickTrackingStatus(false) === 'anonymous') {
$clicks = fluentcrm_get_campaign_meta($campaign->id, '_ano_url_clicks', true);
$clickCount = 0;
if ($clicks && is_array($clicks)) {
$clickCount = array_sum($clicks);
}
} else {
$clickCount = fluentCrmDb()->table('fc_campaign_emails')
->where('campaign_id', $campaign->id)
->whereNotNull('click_counter')
->count();
}
if ($openCount) {
$formattedStatus['open'] = [
'total' => $openCount,
/* translators: %d: number of opens */
'label' => sprintf(__('Open Rate (%d)', 'fluent-crm'), $openCount),
'type' => 'open',
'is_percent' => true,
'icon_class' => 'dashicons dashicons-buddicons-pm'
];
}
if ($clickCount) {
$formattedStatus['click'] = [
'total' => $clickCount,
/* translators: %d: number of clicks */
'label' => sprintf(__('Click Rate (%d)', 'fluent-crm'), $clickCount),
'type' => 'click',
'is_percent' => true,
'icon_class' => 'el-icon el-icon-position'
];
}
if ($openCount && $clickCount) {
$formattedStatus['ctor'] = [
'total' => number_format(($clickCount / $openCount) * 100, 2) . '%',
'label' => __('Click To Open Rate', 'fluent-crm'),
'type' => 'ctor',
'icon_class' => 'el-icon el-icon-chat-dot-square'
];
}
if ($unsubscribeCount) {
$formattedStatus['unsubscribe'] = [
'total' => $unsubscribeCount,
/* translators: %d: number of unsubscribes */
'label' => sprintf(__('Unsubscribe (%d)', 'fluent-crm'), $unsubscribeCount),
'type' => 'unsubscribe',
'is_percent' => true,
'icon_class' => 'el-icon el-icon-warning-outline'
];
}
$revenue = fluentcrm_get_campaign_meta($campaign->id, '_campaign_revenue');
if ($revenue && $revenue->value) {
$data = (array)$revenue->value;
foreach ($data as $currency => $cents) {
if ($cents && $currency !== 'orderIds') {
$formattedStatus['revenue'] = [
'label' => __('Revenue', 'fluent-crm') . ' (' . $currency . ')',
'type' => 'revenue',
'total' => number_format($cents / 100, 2),
'icon_class' => 'el-icon el-icon-money'
];
}
}
}
return $formattedStatus;
}
public function getSubjectStats($campaign)
{
$subjects = $campaign->subjects()->get();
if ($subjects->isEmpty()) {
return [];
}
$subjectCounts = (new CampaignEmail)->getSubjectCount($campaign->id);
$totalClicks = 0;
$totalOpens = 0;
foreach ($subjectCounts as $subjectCount) {
$metric = $this->getSubjectMetric(
$subjectCount->email_subject_id, $campaign->id
);
$totalClicks += $metric['total_clicks'];
$totalOpens += $metric['total_opens'];
$subjectCount->metric = $metric;
}
return [
'subjects' => $subjectCounts,
'total_clicks' => $totalClicks,
'total_opens' => $totalOpens
];
}
private function getSubjectMetric($subjectId, $campaignId)
{
$clickMetrics = $this->getClickMetrics($campaignId, $subjectId);
$openCount = (new CampaignEmail)->getOpenCount($subjectId);
$clickTotal = array_sum($clickMetrics->pluck('total')->toArray());
return [
'clicks' => $clickMetrics,
'total_clicks' => $clickTotal,
'total_opens' => $openCount
];
}
public function getClickMetrics($campaignId, $subjectId)
{
return static::select(
fluentCrmDb()->raw('count(*) as total'),
'fc_url_stores.url'
)
->where('fc_campaign_url_metrics.campaign_id', $campaignId)
->where('fc_campaign_url_metrics.type', 'click')
->where('fc_campaign_emails.email_subject_id', $subjectId)
->groupBy('fc_campaign_url_metrics.url_id')
->join('fc_url_stores', 'fc_url_stores.id', '=', 'fc_campaign_url_metrics.url_id')
->join('fc_campaign_emails', 'fc_campaign_emails.subscriber_id', '=', 'fc_campaign_url_metrics.subscriber_id')
->orderBy('total', 'DESC')
->get();
}
private function maybeTransformSmartLinks($links)
{
if (!apply_filters('fluent_crm/has_smartlink', false)) {
return $links;
}
foreach ($links as $index => $link) {
$url = $link['url'];
if (strpos($url, 'route=smart_url&slug=') !== false) {
// this is a smart-link
$smartLink = apply_filters('fluent_crm/smartlink_by_short_url', null, $url);
if ($smartLink) {
$links[$index]['destination'] = $smartLink->target_url;
$links[$index]['title'] = $smartLink->title;
}
}
}
return $links;
}
}
@@ -0,0 +1,177 @@
<?php
namespace FluentCrm\App\Models;
use FluentCrm\App\Models\Model;
use FluentCrm\App\Models\Subscriber;
use FluentCrm\Framework\Support\Arr;
class Company extends Model
{
protected $table = 'fc_companies';
protected $guarded = ['id'];
protected $fillable = [
'hash',
'name',
'owner_id',
'industry',
'type',
'email',
'phone',
'address_line_1',
'address_line_2',
'postal_code',
'city',
'state',
'country',
'timezone',
'employees_number',
'description',
'logo',
'linkedin_url',
'facebook_url',
'twitter_url',
'meta',
'website',
'date_of_start',
'created_at',
'updated_at'
];
/**
* Get subscriber mappable fields.
*
* @return array
*/
public static function mappables()
{
return [
'name' => __('Company Name *', 'fluent-crm'),
'owner_email' => __('Owner Email', 'fluent-crm'),
'owner_name' => __('Owner Name', 'fluent-crm'),
'industry' => __('Industry', 'fluent-crm'),
'description' => __('Company Description', 'fluent-crm'),
'logo' => __('Company Logo URL', 'fluent-crm'),
'type' => __('Type', 'fluent-crm'),
'email' => __('Company Email', 'fluent-crm'),
'phone' => __('Company Phone', 'fluent-crm'),
'address_line_1' => __('Address Line 1', 'fluent-crm'),
'address_line_2' => __('Address Line 2', 'fluent-crm'),
'postal_code' => __('Postal Code', 'fluent-crm'),
'city' => __('City', 'fluent-crm'),
'state' => __('State', 'fluent-crm'),
'country' => __('Country', 'fluent-crm'),
'employees_number' => __('Employees Number', 'fluent-crm'),
'linkedin_url' => __('LinkedIn URL', 'fluent-crm'),
'facebook_url' => __('Facebook URL', 'fluent-crm'),
'twitter_url' => __('Twitter URL', 'fluent-crm'),
'website' => __('Website URL', 'fluent-crm')
];
}
protected $searchable = [
'name',
'phone',
'description',
'email'
];
public static function boot()
{
parent::boot();
static::creating(function ($model) {
$model->hash = md5(wp_generate_uuid4() . '_' . time() . '_' . wp_rand(1000, 9999));
});
}
/**
* Local scope to filter companies by search/query string
*/
public function scopeSearchBy($query, $search)
{
if ($search) {
$fields = $this->searchable;
$query->where(function ($query) use ($fields, $search) {
$query->where(array_shift($fields), 'LIKE', "%$search%");
foreach ($fields as $field) {
$query->orWhere($field, 'LIKE', "%$search%");
}
});
}
return $query;
}
public function scopeOfType($query, $status)
{
return $query->where('type', $status);
}
public function scopeOfIndustry($query, $status)
{
return $query->where('industry', $status);
}
/**
* Get all of the subscribers that belongs to the company.
*
* @return \FluentCrm\Framework\Database\Orm\Relations\BelongsToMany
*/
public function subscribers()
{
return $this->belongsToMany(
__NAMESPACE__ . '\Subscriber', 'fc_subscriber_pivot', 'object_id', 'subscriber_id'
)->where('object_type', __CLASS__);
}
public function owner()
{
return $this->belongsTo(Subscriber::class, 'owner_id', 'id');
}
public function getContactsCount()
{
return $this->subscribers()->count();
}
/**
* A Company has many notes and activities.
*
* @return \FluentCrm\Framework\Database\Orm\Relations\HasMany
*/
public function notes()
{
return $this->hasMany(CompanyNote::class, 'subscriber_id', 'id');
}
public function setMetaAttribute($meta)
{
$this->attributes['meta'] = \maybe_serialize($meta);
}
public function getMetaAttribute($meta)
{
$metaData = \maybe_unserialize($meta);
if (!$metaData) {
return [
'custom_values' => []
];
}
$metaDefaults = [
'custom_values' => []
];
return array_merge($metaDefaults, $metaData);
}
public function getCustomValues()
{
return Arr::get($this->meta, 'custom_values', []);
}
}
@@ -0,0 +1,91 @@
<?php
namespace FluentCrm\App\Models;
/**
* SubscriberNote Model - DB Model for Contact's notes
*
* Database Model
*
* @package FluentCrm\App\Models
*
* @version 1.0.0
*/
class CompanyNote extends Model
{
protected $table = 'fc_subscriber_notes';
protected $guarded = ['id'];
protected $fillable = [
'subscriber_id',
'parent_id',
'created_by',
'type',
'title',
'description',
'created_at'
];
public static function boot()
{
parent::boot();
static::creating(function ($model) {
if(empty($model->created_at)) {
$model->created_at = fluentCrmTimestamp();
}
$model->status = '_company_note_';
$model->updated_at = fluentCrmTimestamp();
$model->created_by = $model->created_by ?: get_current_user_id();
});
static::updated(function ($model) {
$model->updated_at = fluentCrmTimestamp();
});
static::addGlobalScope('status', function ($builder) {
$builder->where('status', '_company_note_'); // This disguised the Company Note from SubscriberNote
});
}
/**
* One2One: CompanyNote belongs to one Company
* @return \FluentCrm\Framework\Database\Orm\Relations\BelongsTo
*/
public function company()
{
return $this->belongsTo(
__NAMESPACE__.'\Company', 'subscriber_id', 'id'
);
}
public function markAs($status)
{
$this->status = $status;
$this->save();
return $this;
}
public function createdBy()
{
if(!$this->created_by) {
return false;
}
$user = get_user_by('ID', $this->created_by);
if (!$user) {
return false;
}
return [
'ID' => $user->ID,
'first_name' => $user->first_name,
'last_name' => $user->last_name,
'display_name' => $user->display_name
];
}
}
@@ -0,0 +1,36 @@
<?php
namespace FluentCrm\App\Models;
use FluentCrm\Framework\Support\Arr;
/**
* CustomCompanyField Model - DB Model for Company Contact Fields
*
* Database Model
*
* @package FluentCrm\App\Models
*
* @version 2.8.50
*/
class CustomCompanyField extends CustomContactField
{
protected $globalMetaName = 'company_custom_fields';
public function getFieldGroups()
{
$fieldGroups = fluentcrm_get_option('company_field_groups');
if (!$fieldGroups) {
$fieldGroups = [
[
'slug' => 'default',
'title' => __('Custom Company Data', 'fluent-crm')
]
];
}
return $fieldGroups;
}
}
@@ -0,0 +1,269 @@
<?php
namespace FluentCrm\App\Models;
use FluentCrm\Framework\Support\Arr;
/**
* CustomContactField Model - DB Model for Custom Contact Fields
*
* Database Model
*
* @package FluentCrm\App\Models
*
* @version 1.0.0
*/
class CustomContactField
{
protected $globalMetaName = 'contact_custom_fields';
public function getGlobalFields($with = [])
{
$data['fields'] = fluentcrm_get_option($this->globalMetaName, []);
if (in_array('field_types', $with)) {
$data['field_types'] = $this->getFieldTypes();
}
if (in_array('field_groups', $with)) {
$data['field_groups'] = $this->getFieldGroups();
}
return $data;
}
public function getFieldTypes()
{
/**
* Modify the global custom contact field types for FluentCRM custom contact fields.
*
* The default field types are: 'text', 'textarea', 'number', 'single-select', 'multi-select', 'radio', 'checkbox', 'date', 'date_time'.
*
* @since 2.7.0
*
* @param array {
* An associative array of field types.
*
* @type array $text {
* @type string $type The type of the field.
* @type string $label The label for the field.
* @type string $value_type The value type of the field.
* }
* @type array $textarea {
* @type string $type The type of the field.
* @type string $label The label for the field.
* @type string $value_type The value type of the field.
* }
* @type array $number {
* @type string $type The type of the field.
* @type string $label The label for the field.
* @type string $value_type The value type of the field.
* }
* @type array $single-select {
* @type string $type The type of the field.
* @type string $label The label for the field.
* @type string $value_type The value type of the field.
* }
* @type array $multi-select {
* @type string $type The type of the field.
* @type string $label The label for the field.
* @type string $value_type The value type of the field.
* }
* @type array $radio {
* @type string $type The type of the field.
* @type string $label The label for the field.
* @type string $value_type The value type of the field.
* }
* @type array $checkbox {
* @type string $type The type of the field.
* @type string $label The label for the field.
* @type string $value_type The value type of the field.
* }
* @type array $date {
* @type string $type The type of the field.
* @type string $label The label for the field.
* @type string $value_type The value type of the field.
* }
* @type array $date_time {
* @type string $type The type of the field.
* @type string $label The label for the field.
* @type string $value_type The value type of the field.
* }
* }
*/
return apply_filters('fluent_crm/global_field_types', [
'text' => [
'type' => 'text',
'label' => __('Single Line Text', 'fluent-crm'),
'value_type' => 'string'
],
'textarea' => [
'type' => 'textarea',
'label' => __('Multi Line Text', 'fluent-crm'),
'value_type' => 'string'
],
'number' => [
'type' => 'number',
'label' => __('Numeric Field', 'fluent-crm'),
'value_type' => 'numeric'
],
'single-select' => [
'type' => 'select-one',
'label' => __('Select choice', 'fluent-crm'),
'value_type' => 'string'
],
'multi-select' => [
'type' => 'select-multi',
'label' => __('Multiple Select choice', 'fluent-crm'),
'value_type' => 'array'
],
'radio' => [
'type' => 'radio',
'label' => __('Radio Choice', 'fluent-crm'),
'value_type' => 'string'
],
'checkbox' => [
'type' => 'checkbox',
'label' => __('Checkboxes', 'fluent-crm'),
'value_type' => 'array'
],
'date' => [
'type' => 'date',
'label' => __('Date', 'fluent-crm'),
'value_type' => 'date'
],
'date_time' => [
'type' => 'date_time',
'label' => __('Date and Time', 'fluent-crm'),
'value_type' => 'datetime'
]
]);
}
public function saveGlobalFields($fields)
{
$slugs = [];
foreach ($fields as $field) {
if (isset($field['slug'])) {
$slugs[] = $field['slug'];
}
}
$formattedFields = [];
$keys = [];
foreach ($fields as $field) {
if (empty($field['slug'])) {
$field['slug'] = $this->generateSlug($field, $slugs);
}
if (in_array($field['slug'], $keys)) {
continue;
}
$keys[] = $field['slug'];
$formattedFields[] = $field;
}
fluentcrm_update_option($this->globalMetaName, $formattedFields);
return $formattedFields;
}
protected function generateSlug($field, $slugs)
{
$label = str_replace(' ', '_', $field['label']);
$label = sanitize_title($label, 'custom_field', 'view');
$label = substr($label, 0, 25);
$originalLabel = $label;
if (is_numeric($label)) {
$label = 'cf_' . $label;
}
$mainColumns = array_merge(
(new Subscriber)->getFillable(),
['id', 'updated_at']
);
if (in_array($label, $mainColumns)) {
$label = 'cf_' . $label;
}
$index = 1;
while (in_array($label, $slugs)) {
$label = $originalLabel . '_' . $index;
$index++;
}
return $label;
}
public function formatCustomFieldValues($values, $fields = [])
{
if (!$values) {
return $values;
}
if (!$fields) {
$rawFields = fluentcrm_get_option($this->globalMetaName, []);
foreach ($rawFields as $field) {
$fields[$field['slug']] = $field;
}
}
foreach ($values as $valueKey => $value) {
$isArrayType = Arr::get($fields, $valueKey . '.type') == 'checkbox' || Arr::get($fields, $valueKey . '.type') == 'select-multi';
if (!is_array($value) && $isArrayType) {
$itemValues = explode(',', $value);
$trimmedvalues = [];
foreach ($itemValues as $itemValue) {
$trimmedvalues[] = trim($itemValue);
}
if ($itemValue) {
$values[$valueKey] = $trimmedvalues;
}
}
}
return $values;
}
public function getFieldGroups()
{
$fieldGroups = fluentcrm_get_option('contact_field_groups');
if (!$fieldGroups) {
$fieldGroups = [
[
'slug' => 'default',
'title' => __('Custom Profile Data', 'fluent-crm')
]
];
}
return $fieldGroups;
}
public function updateGroupName($oldName, $newName)
{
$currentCustomFields = fluentcrm_get_option($this->globalMetaName);
$updatedCustomFields = [];
foreach ($currentCustomFields as $customField) {
if (isset($customField['group']) && $customField['group'] == $oldName) {
$customField['group'] = $newName;
}
$updatedCustomFields[] = $customField;
}
fluentcrm_update_option($this->globalMetaName, $updatedCustomFields);
return $updatedCustomFields;
}
}

Some files were not shown because too many files have changed in this diff Show More