1145 lines
44 KiB
PHP
1145 lines
44 KiB
PHP
<?php
|
|
|
|
namespace FluentCrm\App\Modules\MCP\Helpers;
|
|
|
|
use FluentCrm\App\Models\Lists;
|
|
use FluentCrm\App\Models\Subscriber;
|
|
use FluentCrm\App\Models\SubscriberNote;
|
|
use FluentCrm\App\Models\Tag;
|
|
use FluentCrm\App\Services\ContactsQuery;
|
|
use FluentCrm\App\Services\PermissionManager;
|
|
|
|
/**
|
|
* Shared utilities for FluentCRM MCP tools.
|
|
*
|
|
* Every tool delegates to existing FluentCRM services for business logic. This
|
|
* helper covers concerns *common* to all tools: identifier resolution
|
|
* (id-or-email, id-or-slug), output formatting, universal-filter translation,
|
|
* pagination, content-type sniffing, and structured WP_Error construction.
|
|
*
|
|
* Naming follows MCP_PLAN.md § 7.
|
|
*/
|
|
class MCPHelper
|
|
{
|
|
// ---------------------------------------------------------------------
|
|
// Identifier resolution
|
|
// ---------------------------------------------------------------------
|
|
|
|
/**
|
|
* Resolve a contact from an input array that may carry contact_id or email.
|
|
*
|
|
* @param array $input
|
|
* @return Subscriber|\WP_Error
|
|
*/
|
|
public static function resolveContact($input)
|
|
{
|
|
$contactId = isset($input['contact_id']) ? (int) $input['contact_id'] : 0;
|
|
$email = isset($input['email']) ? sanitize_email($input['email']) : '';
|
|
|
|
if ($contactId) {
|
|
$subscriber = Subscriber::find($contactId);
|
|
if (!$subscriber) {
|
|
return self::error('not_found', __('Contact not found', 'fluent-crm'), ['contact_id' => $contactId]);
|
|
}
|
|
return $subscriber;
|
|
}
|
|
|
|
if ($email) {
|
|
$subscriber = Subscriber::where('email', $email)->first();
|
|
if (!$subscriber) {
|
|
return self::error('not_found', __('Contact not found', 'fluent-crm'), ['email' => $email]);
|
|
}
|
|
return $subscriber;
|
|
}
|
|
|
|
return self::error('invalid_param', __('Provide contact_id or email', 'fluent-crm'));
|
|
}
|
|
|
|
/**
|
|
* Resolve an array of tag identifiers (ids or titles/slugs) to integer IDs.
|
|
* Optionally creates missing tags when $autoCreate is true (caller MUST
|
|
* have re-checked `fcrm_manage_contact_cats` before passing true).
|
|
*
|
|
* @param array $items
|
|
* @param bool $autoCreate
|
|
* @return array{ids: int[], created: array<int, array{id:int,title:string}>}
|
|
*/
|
|
public static function resolveTagIds($items, $autoCreate = false)
|
|
{
|
|
$ids = [];
|
|
$created = [];
|
|
|
|
foreach ((array) $items as $item) {
|
|
if ($item === '' || $item === null) {
|
|
continue;
|
|
}
|
|
|
|
if (is_numeric($item)) {
|
|
$tag = Tag::find((int) $item);
|
|
if ($tag) {
|
|
$ids[] = (int) $tag->id;
|
|
}
|
|
continue;
|
|
}
|
|
|
|
$value = sanitize_text_field((string) $item);
|
|
$tag = Tag::where('title', $value)->orWhere('slug', sanitize_title($value))->first();
|
|
|
|
if ($tag) {
|
|
$ids[] = (int) $tag->id;
|
|
continue;
|
|
}
|
|
|
|
if ($autoCreate) {
|
|
$tag = Tag::create([
|
|
'title' => $value,
|
|
'slug' => sanitize_title($value),
|
|
]);
|
|
$ids[] = (int) $tag->id;
|
|
$created[] = ['id' => (int) $tag->id, 'title' => $tag->title];
|
|
}
|
|
}
|
|
|
|
return ['ids' => array_values(array_unique($ids)), 'created' => $created];
|
|
}
|
|
|
|
/**
|
|
* Same as resolveTagIds() but for lists.
|
|
*
|
|
* @param array $items
|
|
* @param bool $autoCreate
|
|
* @return array{ids: int[], created: array<int, array{id:int,title:string}>}
|
|
*/
|
|
public static function resolveListIds($items, $autoCreate = false)
|
|
{
|
|
$ids = [];
|
|
$created = [];
|
|
|
|
foreach ((array) $items as $item) {
|
|
if ($item === '' || $item === null) {
|
|
continue;
|
|
}
|
|
|
|
if (is_numeric($item)) {
|
|
$list = Lists::find((int) $item);
|
|
if ($list) {
|
|
$ids[] = (int) $list->id;
|
|
}
|
|
continue;
|
|
}
|
|
|
|
$value = sanitize_text_field((string) $item);
|
|
$list = Lists::where('title', $value)->orWhere('slug', sanitize_title($value))->first();
|
|
|
|
if ($list) {
|
|
$ids[] = (int) $list->id;
|
|
continue;
|
|
}
|
|
|
|
if ($autoCreate) {
|
|
$list = Lists::create([
|
|
'title' => $value,
|
|
'slug' => sanitize_title($value),
|
|
]);
|
|
$ids[] = (int) $list->id;
|
|
$created[] = ['id' => (int) $list->id, 'title' => $list->title];
|
|
}
|
|
}
|
|
|
|
return ['ids' => array_values(array_unique($ids)), 'created' => $created];
|
|
}
|
|
|
|
// ---------------------------------------------------------------------
|
|
// Formatting
|
|
// ---------------------------------------------------------------------
|
|
|
|
/**
|
|
* Build the rich contact record consumed by get-contact / upsert-contact.
|
|
*
|
|
* @param Subscriber $subscriber
|
|
* @param array $opts {
|
|
* @type array $include One or more of: notes, email_history, automations,
|
|
* activity, purchase_history, support_tickets,
|
|
* ai_summary, info_widgets.
|
|
* }
|
|
* @return array
|
|
*/
|
|
public static function formatContactForMCP($subscriber, $opts = [])
|
|
{
|
|
$include = (array) ($opts['include'] ?? []);
|
|
|
|
$address = [
|
|
'line_1' => $subscriber->address_line_1,
|
|
'line_2' => $subscriber->address_line_2,
|
|
'city' => $subscriber->city,
|
|
'state' => $subscriber->state,
|
|
'postal_code' => $subscriber->postal_code,
|
|
'country' => $subscriber->country,
|
|
];
|
|
|
|
$data = [
|
|
'id' => (int) $subscriber->id,
|
|
'email' => $subscriber->email,
|
|
'first_name' => $subscriber->first_name,
|
|
'last_name' => $subscriber->last_name,
|
|
'full_name' => trim((string) $subscriber->full_name),
|
|
'prefix' => $subscriber->prefix,
|
|
'status' => $subscriber->status,
|
|
'contact_type' => $subscriber->contact_type,
|
|
'phone' => $subscriber->phone,
|
|
'address' => array_filter($address, function ($v) { return $v !== null && $v !== ''; }),
|
|
'date_of_birth' => $subscriber->date_of_birth,
|
|
'timezone' => $subscriber->timezone,
|
|
'source' => $subscriber->source,
|
|
'avatar' => $subscriber->avatar,
|
|
'life_time_value' => $subscriber->life_time_value,
|
|
'total_points' => isset($subscriber->total_points) ? (int) $subscriber->total_points : 0,
|
|
'last_activity' => self::toIso8601($subscriber->last_activity),
|
|
'created_at' => self::toIso8601($subscriber->created_at),
|
|
];
|
|
|
|
// Eager-loaded relations: tags, lists.
|
|
$data['tags'] = self::formatTagList($subscriber->tags ?? []);
|
|
$data['lists'] = self::formatListList($subscriber->lists ?? []);
|
|
|
|
// Custom fields are inlined for visibility.
|
|
$data['custom_fields'] = (array) $subscriber->custom_fields();
|
|
|
|
if ($subscriber->user_id) {
|
|
$data['wp_user'] = [
|
|
'id' => (int) $subscriber->user_id,
|
|
'edit_url' => admin_url('user-edit.php?user_id=' . (int) $subscriber->user_id),
|
|
];
|
|
$user = get_user_by('ID', $subscriber->user_id);
|
|
if ($user) {
|
|
$data['wp_user']['roles'] = (array) $user->roles;
|
|
}
|
|
} else {
|
|
$data['wp_user'] = null;
|
|
}
|
|
|
|
// Optional includes.
|
|
if (in_array('notes', $include, true)) {
|
|
$data['notes'] = self::formatNotesFor($subscriber);
|
|
}
|
|
if (in_array('email_history', $include, true)) {
|
|
$data['email_history'] = self::formatEmailHistoryFor($subscriber, (int) ($opts['email_history_limit'] ?? 10));
|
|
}
|
|
if (in_array('automations', $include, true)) {
|
|
$data['automations'] = self::formatAutomationsFor($subscriber);
|
|
}
|
|
|
|
return $data;
|
|
}
|
|
|
|
public static function formatContactSummary($subscriber)
|
|
{
|
|
return [
|
|
'id' => (int) $subscriber->id,
|
|
'email' => $subscriber->email,
|
|
'first_name' => $subscriber->first_name,
|
|
'last_name' => $subscriber->last_name,
|
|
'full_name' => trim((string) $subscriber->full_name),
|
|
'status' => $subscriber->status,
|
|
'contact_type' => $subscriber->contact_type,
|
|
'tags' => self::formatTagList($subscriber->tags ?? []),
|
|
'lists' => self::formatListList($subscriber->lists ?? []),
|
|
'country' => $subscriber->country,
|
|
'city' => $subscriber->city,
|
|
'source' => $subscriber->source,
|
|
'last_activity' => self::toIso8601($subscriber->last_activity),
|
|
'created_at' => self::toIso8601($subscriber->created_at),
|
|
];
|
|
}
|
|
|
|
public static function formatContactList($paginated, $includeCustomFields = false)
|
|
{
|
|
$items = [];
|
|
foreach ($paginated->items() as $subscriber) {
|
|
$item = self::formatContactSummary($subscriber);
|
|
if ($includeCustomFields) {
|
|
$item['custom_fields'] = (array) $subscriber->custom_fields();
|
|
}
|
|
$items[] = $item;
|
|
}
|
|
|
|
return [
|
|
'items' => $items,
|
|
'total' => (int) $paginated->total(),
|
|
'page' => (int) $paginated->currentPage(),
|
|
'per_page' => (int) $paginated->perPage(),
|
|
'pages' => (int) $paginated->lastPage(),
|
|
];
|
|
}
|
|
|
|
public static function formatTagList($tags)
|
|
{
|
|
$out = [];
|
|
foreach ($tags as $tag) {
|
|
$out[] = [
|
|
'id' => (int) $tag->id,
|
|
'title' => $tag->title,
|
|
'slug' => $tag->slug,
|
|
];
|
|
}
|
|
return $out;
|
|
}
|
|
|
|
public static function formatListList($lists)
|
|
{
|
|
$out = [];
|
|
foreach ($lists as $list) {
|
|
$out[] = [
|
|
'id' => (int) $list->id,
|
|
'title' => $list->title,
|
|
'slug' => $list->slug,
|
|
];
|
|
}
|
|
return $out;
|
|
}
|
|
|
|
public static function formatNoteForMCP($note)
|
|
{
|
|
$addedBy = null;
|
|
$createdBy = method_exists($note, 'createdBy') ? $note->createdBy() : null;
|
|
if (is_array($createdBy)) {
|
|
$addedBy = [
|
|
'id' => (int) $createdBy['ID'],
|
|
'name' => $createdBy['display_name'],
|
|
];
|
|
}
|
|
|
|
return [
|
|
'id' => (int) $note->id,
|
|
'subscriber_id' => (int) $note->subscriber_id,
|
|
'type' => $note->type,
|
|
'title' => $note->title,
|
|
'description_text' => self::htmlToText((string) $note->description),
|
|
'description_html' => (string) $note->description,
|
|
'added_by' => $addedBy,
|
|
'created_at' => self::toIso8601($note->created_at),
|
|
];
|
|
}
|
|
|
|
/**
|
|
* Return up to $limit recent notes for a subscriber.
|
|
*/
|
|
public static function formatNotesFor($subscriber, $limit = 50)
|
|
{
|
|
// SubscriberNote already excludes _company_note_ / _system_log_ via a
|
|
// global scope (see Models\SubscriberNote::boot()).
|
|
$notes = SubscriberNote::where('subscriber_id', $subscriber->id)
|
|
->orderBy('id', 'DESC')
|
|
->limit($limit)
|
|
->get();
|
|
|
|
$formatted = [];
|
|
foreach ($notes as $note) {
|
|
$formatted[] = self::formatNoteForMCP($note);
|
|
}
|
|
return $formatted;
|
|
}
|
|
|
|
/**
|
|
* Recent campaign emails sent to / on behalf of a subscriber, paginated to
|
|
* a small set so heavy installs don't drown the response (per MCP_PLAN
|
|
* § 10.7).
|
|
*/
|
|
public static function formatEmailHistoryFor($subscriber, $limit = 10)
|
|
{
|
|
$emails = $subscriber->campaignEmails()
|
|
->orderBy('id', 'DESC')
|
|
->limit(max(1, (int) $limit))
|
|
->get();
|
|
|
|
$out = [];
|
|
foreach ($emails as $email) {
|
|
$out[] = [
|
|
'id' => (int) $email->id,
|
|
'subject' => $email->email_subject,
|
|
'campaign_id' => $email->campaign_id ? (int) $email->campaign_id : null,
|
|
'campaign_title' => $email->campaign ? $email->campaign->title : null,
|
|
'status' => $email->status,
|
|
'is_open' => !empty($email->is_open),
|
|
'is_clicked' => isset($email->click_counter) ? ((int) $email->click_counter > 0) : false,
|
|
'sent_at' => self::toIso8601($email->updated_at),
|
|
];
|
|
}
|
|
return $out;
|
|
}
|
|
|
|
public static function formatAutomationsFor($subscriber)
|
|
{
|
|
$automations = $subscriber->funnel_subscribers()->with('funnel')->get();
|
|
|
|
$out = [];
|
|
foreach ($automations as $row) {
|
|
if (!$row->funnel) {
|
|
continue;
|
|
}
|
|
$out[] = [
|
|
'funnel_id' => (int) $row->funnel_id,
|
|
'title' => $row->funnel->title,
|
|
'status' => $row->status,
|
|
'last_executed_at' => self::toIso8601($row->last_executed_time),
|
|
'next_scheduled_at' => self::toIso8601($row->next_execution_time),
|
|
'next_sequence_id' => $row->next_sequence ? (int) $row->next_sequence : null,
|
|
'enrolled_at' => self::toIso8601($row->created_at),
|
|
];
|
|
}
|
|
return $out;
|
|
}
|
|
|
|
public static function formatCampaignSummary($campaign, $includeStats = true)
|
|
{
|
|
// Only fill sent_at when the campaign has actually shipped — drafts
|
|
// and pre-send states leave it null (review #30). updated_at is
|
|
// not a reliable proxy: any settings tweak bumps it.
|
|
$sentStatuses = ['archived', 'working', 'paused'];
|
|
$sentAt = in_array($campaign->status, $sentStatuses, true)
|
|
? self::toIso8601($campaign->updated_at)
|
|
: null;
|
|
|
|
$item = [
|
|
'id' => (int) $campaign->id,
|
|
'title' => $campaign->title,
|
|
'email_subject' => $campaign->email_subject,
|
|
'status' => $campaign->status,
|
|
'design_template' => $campaign->design_template,
|
|
'scheduled_at' => self::toIso8601($campaign->scheduled_at),
|
|
'sent_at' => $sentAt,
|
|
'created_at' => self::toIso8601($campaign->created_at),
|
|
];
|
|
|
|
if ($includeStats) {
|
|
$item['stats'] = self::campaignStatsCompact($campaign);
|
|
}
|
|
|
|
return $item;
|
|
}
|
|
|
|
/**
|
|
* Compact stats for a single campaign. Mirrors the per-campaign columns
|
|
* the admin list does (sent/views/clicks via fc_campaign_emails) and
|
|
* pulls unsubscribers from fc_campaign_url_metrics where type='unsubscribe'
|
|
* — there is no is_unsubscribed column on fc_campaign_emails.
|
|
*
|
|
* Anonymous-tracking aware: when the campaign is configured for
|
|
* anonymous click/open tracking, the per-contact columns will read 0
|
|
* even when there's real engagement (the data goes to campaign meta
|
|
* instead). We surface tracking_mode + an aggregate fallback so the
|
|
* agent doesn't mis-diagnose anonymous campaigns as having zero
|
|
* engagement (round-4 review P1 #5).
|
|
*/
|
|
public static function campaignStatsCompact($campaign)
|
|
{
|
|
$campaignId = (int) $campaign->id;
|
|
$total = (int) $campaign->recipients_count;
|
|
|
|
$clickStatus = method_exists($campaign, 'getClickTrackingStatus') ? $campaign->getClickTrackingStatus(false) : 'yes';
|
|
$openStatus = method_exists($campaign, 'getOpenTrackingStatus') ? $campaign->getOpenTrackingStatus(false) : 'yes';
|
|
|
|
// Single GROUP-BY-style aggregate over the email table.
|
|
$row = fluentCrmDb()->table('fc_campaign_emails')
|
|
->where('campaign_id', $campaignId)
|
|
->selectRaw("SUM(CASE WHEN status = 'sent' THEN 1 ELSE 0 END) as sent")
|
|
->selectRaw("SUM(CASE WHEN is_open = 1 THEN 1 ELSE 0 END) as views")
|
|
->selectRaw("SUM(CASE WHEN click_counter IS NOT NULL THEN 1 ELSE 0 END) as clicks")
|
|
->first();
|
|
|
|
$sent = (int) ($row->sent ?? 0);
|
|
$views = (int) ($row->views ?? 0);
|
|
$clicks = (int) ($row->clicks ?? 0);
|
|
|
|
// For anonymous tracking, per-contact columns are zero — pull the
|
|
// aggregate counts from campaign meta. open_count is a single int;
|
|
// click count is a serialized map of url => clicks.
|
|
if ($openStatus === 'anonymous') {
|
|
$views = (int) fluentcrm_get_campaign_meta($campaignId, '_ano_open_count', true);
|
|
}
|
|
if ($clickStatus === 'anonymous') {
|
|
$rawUrlClicks = fluentcrm_get_campaign_meta($campaignId, '_ano_url_clicks', true);
|
|
if (is_array($rawUrlClicks)) {
|
|
$clicks = (int) array_sum(array_filter($rawUrlClicks, 'is_numeric'));
|
|
}
|
|
}
|
|
|
|
$unsubs = (int) fluentCrmDb()->table('fc_campaign_url_metrics')
|
|
->where('campaign_id', $campaignId)
|
|
->where('type', 'unsubscribe')
|
|
->distinct()
|
|
->count('subscriber_id');
|
|
|
|
return [
|
|
'total' => $total,
|
|
'sent' => $sent,
|
|
'views' => $views,
|
|
'clicks' => $clicks,
|
|
'unsubscribers' => $unsubs,
|
|
'open_rate' => $sent ? round($views / max(1, $sent) * 100, 2) : 0,
|
|
'click_rate' => $sent ? round($clicks / max(1, $sent) * 100, 2) : 0,
|
|
// Anonymous mode aggregates engagement into campaign meta rather
|
|
// than per-contact rows — agents must know which they're seeing.
|
|
'tracking_mode' => [
|
|
'opens' => $openStatus,
|
|
'clicks' => $clickStatus,
|
|
],
|
|
];
|
|
}
|
|
|
|
// ---------------------------------------------------------------------
|
|
// Filter translation
|
|
// ---------------------------------------------------------------------
|
|
|
|
/**
|
|
* Translate the universal MCP filter shape (MCP_PLAN.md § 3.6) into an
|
|
* array of args ContactsQuery accepts.
|
|
*/
|
|
public static function buildContactsQueryArgs($filter)
|
|
{
|
|
$filter = (array) $filter;
|
|
$args = [];
|
|
|
|
if (!empty($filter['search'])) {
|
|
$args['search'] = sanitize_text_field((string) $filter['search']);
|
|
$args['custom_fields'] = true;
|
|
}
|
|
|
|
if (!empty($filter['tags'])) {
|
|
$resolved = self::resolveTagIds((array) $filter['tags']);
|
|
$args['tags'] = $resolved['ids'];
|
|
}
|
|
|
|
if (!empty($filter['lists'])) {
|
|
$resolved = self::resolveListIds((array) $filter['lists']);
|
|
$args['lists'] = $resolved['ids'];
|
|
}
|
|
|
|
if (!empty($filter['statuses'])) {
|
|
$args['statuses'] = array_values(array_filter(
|
|
array_map('sanitize_text_field', (array) $filter['statuses'])
|
|
));
|
|
}
|
|
|
|
if (!empty($filter['sms_statuses'])) {
|
|
$args['sms_statuses'] = array_values(array_filter(
|
|
array_map('sanitize_text_field', (array) $filter['sms_statuses'])
|
|
));
|
|
}
|
|
|
|
if (!empty($filter['contact_ids'])) {
|
|
$args['contact_ids'] = array_values(array_filter(array_map('intval', (array) $filter['contact_ids'])));
|
|
}
|
|
|
|
// contact_type / created_after / created_before all flow through the
|
|
// advanced_filters pipeline as subscriber/<col> filters. Direct args
|
|
// on ContactsQuery would also work but the advanced path is what
|
|
// FluentCRM uses internally for these columns and reuses the same
|
|
// hooks. Use date-aware operators ('after'/'before') instead of
|
|
// '>='/'<=' — applyGeneralFilterQuery's exact-operator list does
|
|
// NOT include those, and falls through to a LIKE that wraps the
|
|
// value in % (round-4 review B/P1 #4).
|
|
$advanced = self::normalizeAdvancedFilters($filter['advanced_filters'] ?? []);
|
|
|
|
if (!empty($filter['contact_type'])) {
|
|
$advanced[] = [[
|
|
'source' => ['subscriber', 'contact_type'],
|
|
'operator' => '=',
|
|
'value' => sanitize_text_field((string) $filter['contact_type']),
|
|
]];
|
|
}
|
|
|
|
// Date range filters are applied separately by applyDateFilters()
|
|
// post-construction. Routing them through advanced_filters hits
|
|
// FluentCRM's broken whereTimestamp() phantom method (round-4
|
|
// review P1 #4) which produces nonsensical SQL like
|
|
// `where 'timestamp' = 'created_at'`.
|
|
|
|
if (!empty($advanced)) {
|
|
$args['filter_type'] = 'advanced';
|
|
$args['filters_groups_raw'] = $advanced;
|
|
}
|
|
|
|
// All fc_subscribers 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', 'user_id', 'hash', 'contact_owner', 'company_id', 'prefix',
|
|
'first_name', 'last_name', 'email', 'timezone', 'address_line_1',
|
|
'address_line_2', 'postal_code', 'city', 'state', 'country', 'ip',
|
|
'latitude', 'longitude', 'total_points', 'life_time_value', 'phone',
|
|
'status', 'contact_type', 'source', 'avatar', 'date_of_birth',
|
|
'created_at', 'last_activity', 'updated_at',
|
|
];
|
|
$sortBy = sanitize_key((string) ($filter['sort_by'] ?? 'id'));
|
|
if (!in_array($sortBy, $allowedSortBy, true)) {
|
|
$sortBy = 'id';
|
|
}
|
|
$args['sort_by'] = $sortBy;
|
|
$sortType = strtoupper(sanitize_text_field((string) ($filter['sort_type'] ?? 'DESC')));
|
|
$args['sort_type'] = in_array($sortType, ['ASC', 'DESC'], true) ? $sortType : 'DESC';
|
|
|
|
if (isset($filter['custom_fields']) && $filter['custom_fields']) {
|
|
$args['custom_fields'] = true;
|
|
}
|
|
|
|
return $args;
|
|
}
|
|
|
|
/**
|
|
* Apply created_after / created_before to a query model directly. Avoids
|
|
* the whereTimestamp phantom-method bug in
|
|
* Subscriber::applyGeneralFilterQuery (round-4 review P1 #4) — using
|
|
* raw `where(... '>=', ...)` SQL instead.
|
|
*
|
|
* Pass either a ContactsQuery instance (we'll grab getModel()) or an
|
|
* Eloquent query directly.
|
|
*/
|
|
public static function applyDateFilters($queryOrCq, $filter)
|
|
{
|
|
if (!is_array($filter)) {
|
|
return $queryOrCq;
|
|
}
|
|
$query = method_exists($queryOrCq, 'getModel') ? $queryOrCq->getModel() : $queryOrCq;
|
|
if (!is_object($query)) {
|
|
return $queryOrCq;
|
|
}
|
|
|
|
if (!empty($filter['created_after'])) {
|
|
$value = sanitize_text_field((string) $filter['created_after']);
|
|
$query->where('created_at', '>=', $value);
|
|
}
|
|
if (!empty($filter['created_before'])) {
|
|
$value = sanitize_text_field((string) $filter['created_before']);
|
|
$query->where('created_at', '<=', $value);
|
|
}
|
|
|
|
return $queryOrCq;
|
|
}
|
|
|
|
/**
|
|
* Build a paginated ContactsQuery directly from the universal filter shape.
|
|
* The MCP layer reads $_REQUEST['page'] and `per_page` to drive the
|
|
* underlying paginator (matches `$model->paginate()` behavior).
|
|
*/
|
|
public static function buildContactsQuery($filter)
|
|
{
|
|
$args = self::buildContactsQueryArgs($filter);
|
|
return new ContactsQuery($args);
|
|
}
|
|
|
|
/**
|
|
* Validate the universal-filter shape before it's used. Returns
|
|
* `true` on success or a WP_Error (`invalid_param`) on failure.
|
|
*
|
|
* Checks enforced (all fail-closed — a bad value never silently widens
|
|
* the result set):
|
|
* 1. `statuses[]` — must be in fluentcrm_subscriber_statuses().
|
|
* 2. `sms_statuses[]` — must be in fluentcrm_subscriber_sms_statuses().
|
|
* 3. `contact_type` — must be a key in fluentcrm_contact_types().
|
|
* 4. `advanced_filters` — items must carry source[provider, property] +
|
|
* operator, and the (provider, property) pair must be registered in
|
|
* Helper::getAdvancedFilterOptions(). Otherwise the matching engine
|
|
* silently falls back to "match everyone".
|
|
*
|
|
* Operator-test report 2026-05-07 #1 — invalid statuses were being
|
|
* silently dropped by buildContactsQueryArgs(), which made the agent
|
|
* think it was targeting a narrow segment while actually hitting all
|
|
* 12,863 contacts. Round-2 review #3 covered the advanced_filters
|
|
* shape; round-4 review P0 #2 covered the (provider, property) pair.
|
|
*/
|
|
public static function validateUniversalFilter($filter)
|
|
{
|
|
if (!is_array($filter) || empty($filter)) {
|
|
return true;
|
|
}
|
|
|
|
// 1. statuses[]
|
|
if (!empty($filter['statuses']) && is_array($filter['statuses'])) {
|
|
$allowed = fluentcrm_subscriber_statuses();
|
|
$bad = array_values(array_filter(
|
|
array_map('sanitize_text_field', $filter['statuses']),
|
|
function ($s) use ($allowed) {
|
|
return $s !== '' && !in_array($s, $allowed, true);
|
|
}
|
|
));
|
|
if (!empty($bad)) {
|
|
return self::error('invalid_param', __('statuses contains values not in the contact-status enum. Refusing — silently ignoring would widen the audience instead of narrowing it.', 'fluent-crm'), [
|
|
'unknown_statuses' => $bad,
|
|
'allowed_statuses' => array_values($allowed),
|
|
]);
|
|
}
|
|
}
|
|
|
|
// 2. sms_statuses[]
|
|
if (!empty($filter['sms_statuses']) && is_array($filter['sms_statuses'])) {
|
|
$allowed = fluentcrm_subscriber_sms_statuses();
|
|
$bad = array_values(array_filter(
|
|
array_map('sanitize_text_field', $filter['sms_statuses']),
|
|
function ($s) use ($allowed) {
|
|
return $s !== '' && !in_array($s, $allowed, true);
|
|
}
|
|
));
|
|
if (!empty($bad)) {
|
|
return self::error('invalid_param', __('sms_statuses contains values not in the SMS-status enum.', 'fluent-crm'), [
|
|
'unknown_sms_statuses' => $bad,
|
|
'allowed_sms_statuses' => array_values($allowed),
|
|
]);
|
|
}
|
|
}
|
|
|
|
// 3. contact_type
|
|
if (!empty($filter['contact_type'])) {
|
|
$allowed = array_keys(fluentcrm_contact_types());
|
|
$value = sanitize_text_field((string) $filter['contact_type']);
|
|
if (!in_array($value, $allowed, true)) {
|
|
return self::error('invalid_param', __('contact_type is not a registered type.', 'fluent-crm'), [
|
|
'unknown_contact_type' => $value,
|
|
'allowed_contact_types' => $allowed,
|
|
]);
|
|
}
|
|
}
|
|
|
|
$original = $filter['advanced_filters'] ?? null;
|
|
if (!empty($original) && is_array($original)) {
|
|
$normalized = self::normalizeAdvancedFilters($original);
|
|
// If the input had any items at all but nothing survived
|
|
// normalization, the agent passed an unsupported shape.
|
|
$hadAnyItems = false;
|
|
foreach ($original as $group) {
|
|
if (is_array($group) && count($group) > 0) {
|
|
$hadAnyItems = true;
|
|
break;
|
|
}
|
|
}
|
|
if ($hadAnyItems && empty($normalized)) {
|
|
return self::error('invalid_param', __('advanced_filters has no valid items. Each item needs source: [provider, property], operator, and value. For most agent use cases, the simple top-level filters are enough: tags, lists, statuses, search, contact_type, created_after, created_before.', 'fluent-crm'), [
|
|
'received_advanced_filters' => $original,
|
|
'expected_item_shape' => ['source' => ['provider', 'property'], 'operator' => 'string', 'value' => 'mixed'],
|
|
'simple_alternatives' => ['tags', 'lists', 'statuses', 'search', 'contact_type', 'created_after', 'created_before'],
|
|
]);
|
|
}
|
|
|
|
// Validate each (provider, property) pair against the FluentCRM
|
|
// registry. Unrecognized pairs would otherwise silently fall
|
|
// back to "match everyone" (round-4 review P0 #2). We surface
|
|
// the valid pairs in the error so the agent can self-correct.
|
|
$known = self::knownAdvancedFilterPairs();
|
|
$unknown = [];
|
|
foreach ($normalized as $group) {
|
|
foreach ($group as $item) {
|
|
$provider = (string) $item['source'][0];
|
|
$property = (string) $item['source'][1];
|
|
$providerProps = $known[$provider] ?? null;
|
|
if ($providerProps === null) {
|
|
$unknown[] = ['source' => [$provider, $property], 'reason' => 'unknown_provider'];
|
|
continue;
|
|
}
|
|
if (!in_array($property, $providerProps, true)) {
|
|
$unknown[] = ['source' => [$provider, $property], 'reason' => 'unknown_property'];
|
|
}
|
|
}
|
|
}
|
|
if (!empty($unknown)) {
|
|
$compactKnown = [];
|
|
foreach ($known as $providerKey => $props) {
|
|
$compactKnown[$providerKey] = $props;
|
|
}
|
|
return self::error('invalid_param', __('advanced_filters references unknown (provider, property) pairs. The matching engine would silently fall back to "match everyone" — refusing.', 'fluent-crm'), [
|
|
'unknown_pairs' => $unknown,
|
|
'known_pairs' => $compactKnown,
|
|
'tip' => 'For status / engagement / contact_type targeting, use the simple top-level filter fields instead — they are pre-validated.',
|
|
]);
|
|
}
|
|
}
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* Drop malformed entries from a caller-provided advanced_filters payload
|
|
* so ContactsQuery::formatAdvancedFilters doesn't fatal on a count(null).
|
|
*
|
|
* Each item must be {source: [provider, property], operator, value[,
|
|
* extra_value]}. Items without a 2-tuple `source` and a non-empty
|
|
* `operator` are silently dropped. Empty groups are removed.
|
|
*
|
|
* @param mixed $groups
|
|
* @return array
|
|
*/
|
|
public static function normalizeAdvancedFilters($groups)
|
|
{
|
|
if (!is_array($groups)) {
|
|
return [];
|
|
}
|
|
|
|
$out = [];
|
|
foreach ($groups as $group) {
|
|
if (!is_array($group)) {
|
|
continue;
|
|
}
|
|
$cleaned = [];
|
|
foreach ($group as $item) {
|
|
if (!is_array($item)) {
|
|
continue;
|
|
}
|
|
$source = $item['source'] ?? null;
|
|
if (!is_array($source) || count($source) !== 2 || empty($source[0]) || empty($source[1])) {
|
|
continue;
|
|
}
|
|
if (empty($item['operator'])) {
|
|
continue;
|
|
}
|
|
$cleaned[] = $item;
|
|
}
|
|
if ($cleaned) {
|
|
$out[] = $cleaned;
|
|
}
|
|
}
|
|
return $out;
|
|
}
|
|
|
|
/**
|
|
* Cached map of registered (provider => [property, ...]) pairs that
|
|
* FluentCRM actually understands. Used to validate caller-supplied
|
|
* advanced_filters before they hit ContactsQuery — without this gate,
|
|
* an unknown (provider, property) pair causes a silent fallback to
|
|
* "match everyone" because the action hook simply doesn't fire and
|
|
* the where-clause never narrows (round-4 review P0 #2).
|
|
*
|
|
* Source of truth: Helper::getAdvancedFilterOptions() — the same
|
|
* registry the admin UI uses.
|
|
*/
|
|
public static function knownAdvancedFilterPairs()
|
|
{
|
|
static $cache = null;
|
|
if ($cache !== null) {
|
|
return $cache;
|
|
}
|
|
|
|
$cache = [];
|
|
if (method_exists(\FluentCrm\App\Services\Helper::class, 'getAdvancedFilterOptions')) {
|
|
$opts = \FluentCrm\App\Services\Helper::getAdvancedFilterOptions();
|
|
foreach ((array) $opts as $providerKey => $providerCfg) {
|
|
$children = $providerCfg['children'] ?? [];
|
|
$cache[$providerKey] = [];
|
|
foreach ((array) $children as $child) {
|
|
if (!empty($child['value'])) {
|
|
$cache[$providerKey][] = (string) $child['value'];
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Subscriber/contact_type isn't always exposed in the admin UI but
|
|
// is a real column we use for the contact_type universal filter.
|
|
if (isset($cache['subscriber']) && !in_array('contact_type', $cache['subscriber'], true)) {
|
|
$cache['subscriber'][] = 'contact_type';
|
|
}
|
|
|
|
return $cache;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------
|
|
// Content handling
|
|
// ---------------------------------------------------------------------
|
|
|
|
/**
|
|
* Strip HTML tags, decode entities, collapse whitespace. Keeps anchor URLs
|
|
* inline as `[text](url)` so plain-text consumers don't lose them.
|
|
*/
|
|
public static function htmlToText($html)
|
|
{
|
|
if (!$html) {
|
|
return '';
|
|
}
|
|
|
|
$text = preg_replace_callback(
|
|
'/<a[^>]*href=[\'"]([^\'"]+)[\'"][^>]*>(.*?)<\/a>/is',
|
|
function ($m) {
|
|
$url = trim($m[1]);
|
|
$label = trim(wp_strip_all_tags($m[2]));
|
|
if ($label === '' || $label === $url) {
|
|
return $url;
|
|
}
|
|
return $label . ' (' . $url . ')';
|
|
},
|
|
(string) $html
|
|
);
|
|
|
|
$text = wp_strip_all_tags($text);
|
|
$text = html_entity_decode($text, ENT_QUOTES, 'UTF-8');
|
|
$text = preg_replace('/\s+/', ' ', $text);
|
|
|
|
return trim($text);
|
|
}
|
|
|
|
public static function detectContentType($body)
|
|
{
|
|
$body = (string) $body;
|
|
// Cheap sniff: an early `<` followed by an ASCII letter signals HTML.
|
|
if (preg_match('/<[a-zA-Z]/', substr($body, 0, 200))) {
|
|
return 'html';
|
|
}
|
|
return 'text';
|
|
}
|
|
|
|
public static function dualBodyShape($html)
|
|
{
|
|
$html = (string) $html;
|
|
return [
|
|
'body_html' => $html,
|
|
'body_text' => self::htmlToText($html),
|
|
];
|
|
}
|
|
|
|
// ---------------------------------------------------------------------
|
|
// Pagination
|
|
// ---------------------------------------------------------------------
|
|
|
|
/**
|
|
* Normalize page/per_page from input. Mutates `$_REQUEST` so the framework
|
|
* paginator picks up the values — that is FluentCRM's existing pattern.
|
|
*/
|
|
public static function paginationFromInput($input, $defaultPerPage = 15, $maxPerPage = 100)
|
|
{
|
|
$page = max(1, (int) ($input['page'] ?? 1));
|
|
$perPage = (int) ($input['per_page'] ?? $defaultPerPage);
|
|
if ($perPage < 1) {
|
|
$perPage = $defaultPerPage;
|
|
}
|
|
$perPage = min($perPage, $maxPerPage);
|
|
|
|
// Match how FluentCRM controllers expect WP_REQUEST to drive paging.
|
|
$_REQUEST['page'] = $page;
|
|
$_REQUEST['per_page'] = $perPage;
|
|
|
|
return ['page' => $page, 'per_page' => $perPage];
|
|
}
|
|
|
|
// ---------------------------------------------------------------------
|
|
// Validation
|
|
// ---------------------------------------------------------------------
|
|
|
|
/**
|
|
* Return the registered custom-field slugs for contacts. Cached for
|
|
* the request lifetime — fluentcrm_get_custom_contact_fields() is
|
|
* already statically cached but we don't want to repeat the array
|
|
* walk for every bulk row.
|
|
*
|
|
* @return string[]
|
|
*/
|
|
public static function knownContactCustomFieldSlugs()
|
|
{
|
|
static $cache = null;
|
|
if ($cache !== null) {
|
|
return $cache;
|
|
}
|
|
$fields = fluentcrm_get_custom_contact_fields();
|
|
$cache = [];
|
|
foreach ((array) $fields as $f) {
|
|
if (!empty($f['slug'])) {
|
|
$cache[] = (string) $f['slug'];
|
|
}
|
|
}
|
|
return $cache;
|
|
}
|
|
|
|
/**
|
|
* Diff caller-supplied custom-field keys against the registered
|
|
* schema. Unknown keys would otherwise be silently dropped by
|
|
* Subscriber::syncCustomFieldValues — the agent thinks the value
|
|
* persisted but nothing was saved (operator-test report 2026-05-07
|
|
* #6).
|
|
*
|
|
* @param array $customFields
|
|
* @return array{known: array<string,mixed>, unknown: string[]}
|
|
*/
|
|
public static function diffCustomFields($customFields)
|
|
{
|
|
$known = [];
|
|
$unknown = [];
|
|
if (!is_array($customFields) || empty($customFields)) {
|
|
return ['known' => $known, 'unknown' => $unknown];
|
|
}
|
|
$allowed = self::knownContactCustomFieldSlugs();
|
|
foreach ($customFields as $key => $value) {
|
|
$slug = sanitize_key((string) $key);
|
|
if ($slug === '') {
|
|
continue;
|
|
}
|
|
if (in_array($slug, $allowed, true)) {
|
|
$known[$slug] = $value;
|
|
} else {
|
|
$unknown[] = (string) $key;
|
|
}
|
|
}
|
|
return ['known' => $known, 'unknown' => array_values(array_unique($unknown))];
|
|
}
|
|
|
|
/**
|
|
* Parse and validate an agent-supplied scheduled_at into a DateTime in
|
|
* the site timezone. Operator-test report 2026-05-07 #3 — previously
|
|
* the validated DateTime was discarded and the raw input string was
|
|
* passed through to MySQL, which silently dropped the offset (a
|
|
* datetime column has no timezone). On read, toIso8601 then re-parsed
|
|
* the naive string in PHP's default timezone (UTC), producing wrong
|
|
* absolute times.
|
|
*
|
|
* Input convention:
|
|
* - ISO-8601 with offset → respected as written.
|
|
* - Bare datetime / date → interpreted as SITE timezone (matches
|
|
* FluentCRM's storage convention).
|
|
*
|
|
* The caller stores `$dt->format('Y-m-d H:i:s')` which is now
|
|
* unambiguous because `$dt` carries the site tz.
|
|
*/
|
|
public static function validateScheduledAt($iso, $minFutureSeconds = 60)
|
|
{
|
|
if (!$iso) {
|
|
return self::error('invalid_param', __('scheduled_at is required', 'fluent-crm'));
|
|
}
|
|
|
|
$siteTz = self::siteTimezoneObject();
|
|
$input = (string) $iso;
|
|
|
|
try {
|
|
// If the string carries an explicit offset / "Z", DateTime keeps
|
|
// it. If it's bare ("2026-05-08 09:00:00"), pass site tz as
|
|
// the second arg so the moment is interpreted correctly.
|
|
if (self::stringHasTimezone($input)) {
|
|
$dt = new \DateTime($input);
|
|
} else {
|
|
$dt = new \DateTime($input, $siteTz);
|
|
}
|
|
} catch (\Exception $e) {
|
|
return self::error('invalid_param', __('scheduled_at must be ISO 8601 (e.g. 2026-05-08T09:00:00+01:00) or a bare datetime in site timezone (2026-05-08 09:00:00).', 'fluent-crm'), [
|
|
'scheduled_at_input' => $input,
|
|
'site_timezone' => $siteTz->getName(),
|
|
]);
|
|
}
|
|
|
|
// Convert to site tz so storage in `Y-m-d H:i:s` is consistent with
|
|
// the rest of FluentCRM (which uses current_time('mysql')).
|
|
$dt->setTimezone($siteTz);
|
|
|
|
if ($dt->getTimestamp() < (time() + $minFutureSeconds)) {
|
|
return self::error('validation_failed', __('scheduled_at must be in the future', 'fluent-crm'), [
|
|
'scheduled_at_input' => $input,
|
|
'parsed_utc' => gmdate('c', $dt->getTimestamp()),
|
|
'parsed_site_local' => $dt->format('Y-m-d H:i:s'),
|
|
'now_utc' => gmdate('c'),
|
|
'site_timezone' => $siteTz->getName(),
|
|
'now_site_local' => wp_date('Y-m-d H:i:s', time()),
|
|
]);
|
|
}
|
|
|
|
return $dt;
|
|
}
|
|
|
|
/**
|
|
* Heuristic: does the string carry timezone info (Z or ±HH:MM / ±HHMM)
|
|
* after the time component? Date-only strings always count as bare.
|
|
*/
|
|
private static function stringHasTimezone($s)
|
|
{
|
|
return (bool) preg_match('/T?\d{2}:\d{2}(?::\d{2})?(?:\.\d+)?(Z|[+-]\d{2}:?\d{2})$/', trim((string) $s));
|
|
}
|
|
|
|
/**
|
|
* Site timezone as a DateTimeZone — the object form callers need for
|
|
* DateTime construction / setTimezone. wp_timezone() ships in WP 5.3+
|
|
* (we target 6.9+).
|
|
*/
|
|
public static function siteTimezoneObject()
|
|
{
|
|
return wp_timezone();
|
|
}
|
|
|
|
/**
|
|
* Format a stored mysql datetime (assumed in site tz) into the dual
|
|
* shape callers expose to agents — get-campaign / actionSchedule
|
|
* surface this so an operator never has to guess which timezone a
|
|
* scheduled_at value is in.
|
|
*
|
|
* @return array{utc:?string, site_local:?string, site_timezone:string}|null
|
|
*/
|
|
public static function formatScheduledAtDual($value)
|
|
{
|
|
if (!$value) {
|
|
return null;
|
|
}
|
|
$siteTz = self::siteTimezoneObject();
|
|
|
|
try {
|
|
// Stored values come from current_time('mysql') / our own
|
|
// $dt->format('Y-m-d H:i:s') — both are site-tz strings.
|
|
// ISO inputs from outside are unlikely here but tolerated.
|
|
if ($value instanceof \DateTimeInterface) {
|
|
$dt = (new \DateTime('@' . $value->getTimestamp()))->setTimezone($siteTz);
|
|
} elseif (self::stringHasTimezone((string) $value)) {
|
|
$dt = (new \DateTime((string) $value))->setTimezone($siteTz);
|
|
} else {
|
|
$dt = new \DateTime((string) $value, $siteTz);
|
|
}
|
|
} catch (\Exception $e) {
|
|
return null;
|
|
}
|
|
|
|
return [
|
|
'utc' => gmdate('c', $dt->getTimestamp()),
|
|
'site_local' => $dt->format('Y-m-d H:i:s'),
|
|
'site_timezone' => self::siteTimezoneName(),
|
|
];
|
|
}
|
|
|
|
/**
|
|
* Friendly site timezone label. wp_timezone() returns a numeric offset
|
|
* like "+00:00" when gmt_offset is 0 and timezone_string is empty;
|
|
* fluentCrmGetTimezoneString() correctly returns "UTC" in that case.
|
|
*/
|
|
public static function siteTimezoneName()
|
|
{
|
|
return (string) fluentCrmGetTimezoneString();
|
|
}
|
|
|
|
public static function permissionGuard($cap)
|
|
{
|
|
if (PermissionManager::currentUserCan($cap)) {
|
|
return true;
|
|
}
|
|
return self::error('forbidden', __('You do not have permission to perform this action', 'fluent-crm'), ['required' => $cap]);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------
|
|
// Errors
|
|
// ---------------------------------------------------------------------
|
|
|
|
public static function error($code, $message, $details = [])
|
|
{
|
|
return new \WP_Error($code, $message, $details);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------
|
|
// Misc
|
|
// ---------------------------------------------------------------------
|
|
|
|
public static function toIso8601($value)
|
|
{
|
|
if (!$value) {
|
|
return null;
|
|
}
|
|
|
|
try {
|
|
if ($value instanceof \DateTimeInterface) {
|
|
return $value->format('c');
|
|
}
|
|
return (new \DateTime((string) $value))->format('c');
|
|
} catch (\Exception $e) {
|
|
return null;
|
|
}
|
|
}
|
|
}
|