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,447 @@
<?php
namespace FluentCrm\App\Modules\MCP\Tools;
use FluentCrm\App\Models\Funnel;
use FluentCrm\App\Models\FunnelSubscriber;
use FluentCrm\App\Modules\MCP\Helpers\MCPHelper;
use FluentCrm\App\Services\Funnel\FunnelHelper;
/**
* Automation (funnel) MCP tools.
*
* Read tools (Phase 2): listAutomations, getAutomation.
* Write tools (Phase 3): updateContactAutomationStatus.
*/
class FunnelTools
{
// -----------------------------------------------------------------
// Read: list-automations
// -----------------------------------------------------------------
public static function listAutomations($params)
{
$params = (array) $params;
MCPHelper::paginationFromInput($params);
$search = sanitize_text_field((string) ($params['search'] ?? ''));
$statuses = (array) ($params['statuses'] ?? []);
$statuses = array_values(array_intersect(
array_map('sanitize_key', $statuses),
['draft', 'published']
));
// All fc_funnels columns. The framework rewrite made orderBy() throw
// LogicException on column names that don't match ^[a-zA-Z0-9_\.]+$
// — empty strings, "id ASC", "DROP TABLE", etc. — so an unguarded
// sort_by would 500 the tool. Schema is stable (migration only adds
// indexes), so hardcoding the column list avoids a per-request
// SHOW COLUMNS without restricting agents to the input_schema enum.
$allowedSortBy = [
'id', 'type', 'title', 'trigger_name', 'status', 'conditions',
'settings', 'created_by', 'created_at', 'updated_at',
];
$sortBy = sanitize_key((string) ($params['sort_by'] ?? 'id'));
if (!in_array($sortBy, $allowedSortBy, true)) {
$sortBy = 'id';
}
$sortType = strtoupper(sanitize_text_field((string) ($params['sort_type'] ?? 'DESC')));
$sortType = in_array($sortType, ['ASC', 'DESC'], true) ? $sortType : 'DESC';
$query = Funnel::withCount('subscribers')->orderBy($sortBy, $sortType);
if ($search !== '') {
global $wpdb;
$like = '%' . $wpdb->esc_like($search) . '%';
$query->where(function ($q) use ($like) {
$q->where('title', 'LIKE', $like)
->orWhere('trigger_name', 'LIKE', $like);
});
}
if (!empty($statuses)) {
$query->whereIn('status', $statuses);
}
$paginated = $query->paginate();
$items = [];
$triggerLabels = self::triggerLabelMap();
foreach ($paginated->items() as $funnel) {
$items[] = [
'id' => (int) $funnel->id,
'title' => $funnel->title,
'status' => $funnel->status,
'trigger_name' => $funnel->trigger_name,
'trigger_label' => $triggerLabels[$funnel->trigger_name] ?? $funnel->trigger_name,
'in_progress_subscribers_count' => self::inProgressCount((int) $funnel->id),
'completed_subscribers_count' => (int) ($funnel->subscribers_count ?? 0),
'created_at' => MCPHelper::toIso8601($funnel->created_at),
'updated_at' => MCPHelper::toIso8601($funnel->updated_at),
];
}
return [
'items' => $items,
'total' => (int) $paginated->total(),
'page' => (int) $paginated->currentPage(),
'per_page' => (int) $paginated->perPage(),
'pages' => (int) $paginated->lastPage(),
];
}
// -----------------------------------------------------------------
// Read: get-automation
// -----------------------------------------------------------------
public static function getAutomation($params)
{
$params = (array) $params;
$funnelId = (int) ($params['funnel_id'] ?? 0);
if (!$funnelId) {
return MCPHelper::error('invalid_param', __('funnel_id is required', 'fluent-crm'));
}
$funnel = Funnel::find($funnelId);
if (!$funnel) {
return MCPHelper::error('not_found', __('Automation not found', 'fluent-crm'), ['funnel_id' => $funnelId]);
}
$defaultIncludes = ['sequences', 'report'];
$include = isset($params['include']) && is_array($params['include']) && $params['include']
? array_values(array_intersect($params['include'], ['sequences', 'report']))
: $defaultIncludes;
$triggerLabels = self::triggerLabelMap();
$data = [
'id' => (int) $funnel->id,
'title' => $funnel->title,
'status' => $funnel->status,
'trigger_name' => $funnel->trigger_name,
'trigger_label' => $triggerLabels[$funnel->trigger_name] ?? $funnel->trigger_name,
'trigger_settings' => is_array($funnel->settings) ? $funnel->settings : [],
'conditions' => is_array($funnel->conditions) ? $funnel->conditions : [],
'in_progress_subscribers_count' => self::inProgressCount((int) $funnel->id),
'completed_subscribers_count' => (int) FunnelSubscriber::where('funnel_id', $funnel->id)
->where('status', 'completed')
->count(),
'created_at' => MCPHelper::toIso8601($funnel->created_at),
'updated_at' => MCPHelper::toIso8601($funnel->updated_at),
];
if (in_array('sequences', $include, true)) {
$sequences = FunnelHelper::getFunnelSequences($funnel, true);
$includeBodies = !empty($params['include_bodies']);
$data['sequences'] = self::formatSequences($sequences, $includeBodies);
}
if (in_array('report', $include, true)) {
$data['report'] = self::buildStepReport($funnel);
}
return $data;
}
/**
* Format funnel sequences for MCP. By default we strip large body
* fields out of `settings` (action_name=send_custom_email embeds an
* entire campaign payload including email_body). Pass include_bodies
* = true to get the full settings tree — review #7 (token bloat).
*/
private static function formatSequences($sequences, $includeBodies = false)
{
$out = [];
foreach ((array) $sequences as $seq) {
$row = is_object($seq) ? get_object_vars($seq) : (array) $seq;
$settings = $row['settings'] ?? [];
if (!$includeBodies) {
$settings = self::stripBodyFields($settings);
}
$out[] = [
'id' => isset($row['id']) ? (int) $row['id'] : null,
'type' => $row['type'] ?? null,
'action_name' => $row['action_name'] ?? null,
'settings' => $settings,
'delay' => $row['delay'] ?? 0,
'delay_unit' => $row['c_delay_unit'] ?? ($row['delay_unit'] ?? null),
'parent_id' => isset($row['parent_id']) ? (int) $row['parent_id'] : null,
];
}
return $out;
}
/**
* Recursively redact body / html fields. Replaces them with a marker so
* the agent knows the field exists and can re-fetch with include_bodies.
*
* Round-3 review B5: dropped the previous "only if >200 chars" gate —
* any stored body field gets stripped now, regardless of size, so the
* include_bodies=false contract is honored consistently. Rare to have a
* truly tiny body field anyway, and the marker is shorter than most
* email bodies.
*/
private static function stripBodyFields($value)
{
$bodyKeys = ['email_body', 'body', 'body_html', 'body_text'];
if (!is_array($value)) {
return $value;
}
foreach ($value as $k => $v) {
if (is_string($k) && in_array($k, $bodyKeys, true) && is_string($v)) {
$len = strlen($v);
$value[$k] = $len > 0
? '[truncated — re-fetch with include_bodies=true; ' . $len . ' chars]'
: '';
} elseif (is_array($v)) {
$value[$k] = self::stripBodyFields($v);
}
}
return $value;
}
private static function buildStepReport($funnel)
{
$stepCounts = FunnelSubscriber::where('funnel_id', $funnel->id)
->select(['last_sequence_id'])
->selectRaw('COUNT(id) as total')
->groupBy('last_sequence_id')
->get();
$steps = [];
foreach ($stepCounts as $row) {
if (!$row->last_sequence_id) {
continue;
}
$steps[] = [
'step_id' => (int) $row->last_sequence_id,
'total' => (int) $row->total,
];
}
return ['steps' => $steps];
}
private static function inProgressCount($funnelId)
{
return (int) FunnelSubscriber::where('funnel_id', $funnelId)
->whereIn('status', ['active', 'waiting'])
->count();
}
private static function triggerLabelMap()
{
$triggers = apply_filters('fluentcrm_funnel_triggers', []);
$map = [];
if (is_array($triggers)) {
foreach ($triggers as $key => $config) {
$map[$key] = $config['label'] ?? $key;
}
}
return $map;
}
// -----------------------------------------------------------------
// Read: list-funnel-subscribers (round-4 review P3 #11)
// -----------------------------------------------------------------
/**
* List the contacts currently in a funnel filtered by subscription
* status. Closes a real workflow gap: "this customer just upgraded —
* pull them out of trial-onboarding" requires knowing who's in the
* funnel first, and there was no way to find that without already
* knowing the contact id.
*/
public static function listFunnelSubscribers($params)
{
$params = (array) $params;
$funnelId = (int) ($params['funnel_id'] ?? 0);
if (!$funnelId) {
return MCPHelper::error('invalid_param', __('funnel_id is required', 'fluent-crm'));
}
$funnel = Funnel::find($funnelId);
if (!$funnel) {
return MCPHelper::error('not_found', __('Automation not found', 'fluent-crm'), ['funnel_id' => $funnelId]);
}
$allowedStatuses = ['active', 'waiting', 'completed', 'cancelled', 'skipped'];
$statuses = (array) ($params['statuses'] ?? ['active']);
$statuses = array_values(array_intersect(array_map('sanitize_key', $statuses), $allowedStatuses));
if (!$statuses) {
$statuses = ['active'];
}
MCPHelper::paginationFromInput($params);
$rows = FunnelSubscriber::with(['subscriber' => function ($q) {
$q->select(['id', 'email', 'first_name', 'last_name', 'status', 'contact_type']);
}])
->where('funnel_id', $funnelId)
->whereIn('status', $statuses)
->orderBy('id', 'DESC')
->paginate();
$items = [];
foreach ($rows->items() as $row) {
$sub = $row->subscriber;
if (!$sub) {
continue;
}
$items[] = [
'funnel_subscriber_id' => (int) $row->id,
'funnel_status' => $row->status,
'next_sequence_id' => $row->next_sequence_id ? (int) $row->next_sequence_id : null,
'last_executed_at' => MCPHelper::toIso8601($row->last_executed_time),
'next_execution_at' => MCPHelper::toIso8601($row->next_execution_time),
'enrolled_at' => MCPHelper::toIso8601($row->created_at),
'contact' => [
'id' => (int) $sub->id,
'email' => $sub->email,
'full_name' => trim((string) ($sub->first_name . ' ' . $sub->last_name)),
'status' => $sub->status,
'contact_type' => $sub->contact_type,
],
];
}
return [
'items' => $items,
'total' => (int) $rows->total(),
'page' => (int) $rows->currentPage(),
'per_page' => (int) $rows->perPage(),
'pages' => (int) $rows->lastPage(),
'funnel' => [
'id' => (int) $funnel->id,
'title' => $funnel->title,
],
'filtered_statuses' => $statuses,
];
}
// -----------------------------------------------------------------
// Write: update-contact-automation-status
// -----------------------------------------------------------------
public static function updateContactAutomationStatus($params)
{
$params = (array) $params;
$funnelId = (int) ($params['funnel_id'] ?? 0);
$action = sanitize_key((string) ($params['action'] ?? ''));
if (!$funnelId) {
return MCPHelper::error('invalid_param', __('funnel_id is required', 'fluent-crm'));
}
if (!in_array($action, ['resume', 'cancel', 'advance_now'], true)) {
// 'pause' was intentionally dropped — FluentCRM has no native
// paused funnel-subscriber state, and the previous mapping
// silently cancelled. Tell the agent what the alternative is.
if ($action === 'pause') {
return MCPHelper::error('not_supported', __('pause is not supported — FluentCRM has no paused state for funnel subscribers. Use cancel to stop processing (reversible from the UI), or wait for a real benchmark.', 'fluent-crm'), [
'allowed_actions' => ['resume', 'cancel', 'advance_now'],
]);
}
return MCPHelper::error('invalid_param', __('Invalid action', 'fluent-crm'), [
'allowed_actions' => ['resume', 'cancel', 'advance_now'],
]);
}
$contact = MCPHelper::resolveContact($params);
if (is_wp_error($contact)) {
return $contact;
}
$funnel = Funnel::find($funnelId);
if (!$funnel) {
return MCPHelper::error('not_found', __('Automation not found', 'fluent-crm'), ['funnel_id' => $funnelId]);
}
$row = FunnelSubscriber::where('funnel_id', $funnelId)
->where('subscriber_id', $contact->id)
->first();
if (!$row) {
return MCPHelper::error('not_found', __('Contact is not enrolled in this automation', 'fluent-crm'));
}
$previousStatus = $row->status;
if ($row->status === 'completed') {
return MCPHelper::error('not_supported', __('Automation is already completed for this contact', 'fluent-crm'), [
'status' => $row->status,
]);
}
if ($action === 'cancel') {
$row->status = 'cancelled';
$row->save();
} elseif ($action === 'resume') {
$row->status = 'active';
if (!$row->next_execution_time) {
$row->next_execution_time = gmdate('Y-m-d H:i:s', current_time('timestamp') + 60);
}
$row->save();
} elseif ($action === 'advance_now') {
$sequenceId = (int) ($params['advance_to_sequence_id'] ?? 0);
if (!$sequenceId) {
return MCPHelper::error('invalid_param', __('advance_to_sequence_id is required for advance_now', 'fluent-crm'));
}
$sequence = \FluentCrm\App\Models\FunnelSequence::where('id', $sequenceId)
->where('funnel_id', $funnelId)
->first();
if (!$sequence) {
return MCPHelper::error('not_found', __('Target sequence not found in this automation', 'fluent-crm'));
}
// If the contact is waiting on a benchmark, mark the benchmark as
// skipped so reports stay accurate (matches the controller's path).
if ($row->status === 'waiting') {
$benchmarkSeq = \FluentCrm\App\Models\FunnelSequence::find($row->next_sequence_id);
if ($benchmarkSeq) {
\FluentCrm\App\Models\FunnelMetric::updateOrCreate(
[
'funnel_id' => $funnelId,
'sequence_id' => $benchmarkSeq->id,
'subscriber_id' => $contact->id,
],
[
'benchmark_value' => 0,
'benchmark_currency' => 'USD',
'status' => 'skipped',
'notes' => __('Skipped via MCP advance_now', 'fluent-crm'),
]
);
\FluentCrm\App\Services\Funnel\FunnelHelper::changeFunnelSubSequenceStatus($row->id, $benchmarkSeq->id, 'skipped');
}
}
$prev = \FluentCrm\App\Models\FunnelSequence::where('funnel_id', $funnelId)
->where('sequence', '<', $sequence->sequence)
->orderBy('sequence', 'DESC')
->first();
$row->last_sequence_id = $prev ? $prev->id : 0;
$row->next_sequence_id = $sequence->id;
$row->next_sequence = $sequence->sequence;
$row->status = 'active';
$row->next_execution_time = current_time('mysql');
$row->save();
}
$row = FunnelSubscriber::find($row->id);
return [
'ok' => true,
'action' => $action,
'previous_status' => $previousStatus,
'current_status' => $row->status,
'funnel_subscriber' => [
'id' => (int) $row->id,
'funnel_id' => (int) $row->funnel_id,
'subscriber_id' => (int) $row->subscriber_id,
'status' => $row->status,
'next_sequence_id' => $row->next_sequence_id ? (int) $row->next_sequence_id : null,
'next_execution_time' => MCPHelper::toIso8601($row->next_execution_time),
],
];
}
}