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,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);
}
}