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