598 lines
21 KiB
PHP
598 lines
21 KiB
PHP
<?php
|
|
|
|
namespace FluentCrm\App\Http\Controllers;
|
|
|
|
use FluentCrm\App\Models\Campaign;
|
|
use FluentCrm\App\Models\CampaignEmail;
|
|
use FluentCrm\App\Models\Funnel;
|
|
use FluentCrm\App\Models\FunnelSubscriber;
|
|
use FluentCrm\App\Models\Lists;
|
|
use FluentCrm\App\Models\Subscriber;
|
|
use FluentCrm\App\Models\Tag;
|
|
use FluentCrm\App\Hooks\Handlers\Scheduler;
|
|
use FluentCrm\App\Services\Reporting;
|
|
use FluentCrm\Framework\Http\Request\Request;
|
|
use FluentCrm\App\Models\CampaignUrlMetric;
|
|
use FluentCrm\Framework\Support\Arr;
|
|
|
|
/**
|
|
* ReportingController - REST API Handler Class
|
|
*
|
|
* REST API Handler
|
|
*
|
|
* @package FluentCrm\App\Http
|
|
*
|
|
* @version 1.0.0
|
|
*/
|
|
class ReportingController extends Controller
|
|
{
|
|
public function getContactGrowth(Request $request, Reporting $reporting)
|
|
{
|
|
list($from, $to) = $request->get('date_range') ?: ['', ''];
|
|
$tagId = intval($request->get('tag_id', 0));
|
|
$listId = intval($request->get('list_id', 0));
|
|
$compareType = sanitize_text_field($request->get('compare_type', ''));
|
|
$compareRange = $request->get('compare_range', []);
|
|
|
|
$currentStats = $reporting->getSubscribersGrowth($from, $to, $tagId, $listId);
|
|
|
|
$currentFrom = $from ?: gmdate('Y-m-d', strtotime('-30 days'));
|
|
$currentTo = $to ?: gmdate('Y-m-d', strtotime('+1 day'));
|
|
|
|
$dataSets = [
|
|
[
|
|
'label' => __('Current Range', 'fluent-crm'),
|
|
'data' => $currentStats,
|
|
'range' => [$currentFrom, $currentTo],
|
|
'backgroundColor' => '#335CFF',
|
|
'borderColor' => '#335CFF',
|
|
'fill' => true,
|
|
],
|
|
];
|
|
|
|
if ($compareType && $compareType !== 'no_comparison') {
|
|
$compRange = $this->resolveCompareRange($compareType, $compareRange, $currentFrom, $currentTo);
|
|
if ($compRange) {
|
|
$compareStats = $reporting->getSubscribersGrowth($compRange[0], $compRange[1], $tagId, $listId);
|
|
$dataSets[] = [
|
|
'label' => __('Compare Range', 'fluent-crm'),
|
|
'data' => $compareStats,
|
|
'range' => $compRange,
|
|
'backgroundColor' => '#1FC16B',
|
|
'borderColor' => '#1FC16B',
|
|
'fill' => true,
|
|
];
|
|
}
|
|
}
|
|
|
|
return $this->sendSuccess([
|
|
'data_sets' => $dataSets,
|
|
'current_range' => [$currentFrom, $currentTo],
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* Calculate comparison date range based on type.
|
|
*
|
|
* @param string $type
|
|
* @param array $compareRange
|
|
* @param string $from
|
|
* @param string $to
|
|
* @return array|false
|
|
*/
|
|
private function resolveCompareRange($type, $compareRange, $from, $to)
|
|
{
|
|
$fromTs = strtotime($from);
|
|
$toTs = strtotime($to);
|
|
$diffDays = (int)(($toTs - $fromTs) / 86400);
|
|
|
|
switch ($type) {
|
|
case 'previous_period':
|
|
return [
|
|
gmdate('Y-m-d', $fromTs - ($diffDays + 1) * 86400),
|
|
gmdate('Y-m-d', $fromTs - 86400),
|
|
];
|
|
case 'previous_month':
|
|
$newFrom = gmdate('Y-m-d', strtotime($from . ' -1 month'));
|
|
return [$newFrom, gmdate('Y-m-d', strtotime($newFrom) + $diffDays * 86400)];
|
|
case 'previous_quarter':
|
|
$newFrom = gmdate('Y-m-d', strtotime($from . ' -3 months'));
|
|
return [$newFrom, gmdate('Y-m-d', strtotime($newFrom) + $diffDays * 86400)];
|
|
case 'previous_year':
|
|
$newFrom = gmdate('Y-m-d', strtotime($from . ' -12 months'));
|
|
return [$newFrom, gmdate('Y-m-d', strtotime($newFrom) + $diffDays * 86400)];
|
|
case 'custom':
|
|
if (is_array($compareRange) && count(array_filter($compareRange)) >= 2) {
|
|
return [
|
|
sanitize_text_field($compareRange[0]),
|
|
sanitize_text_field($compareRange[1]),
|
|
];
|
|
}
|
|
return false;
|
|
default:
|
|
return false;
|
|
}
|
|
}
|
|
|
|
public function getEmailSentStats(Request $request, Reporting $reporting)
|
|
{
|
|
list($from, $to) = $request->get('date_range') ?: ['', ''];
|
|
$campaignId = intval($request->get('campaign_id', 0));
|
|
return $this->sendSuccess([
|
|
'stats' => $reporting->getEmailStats($from, $to, 'sent', $campaignId)
|
|
]);
|
|
}
|
|
|
|
public function getEmailOpenStats(Request $request, Reporting $reporting)
|
|
{
|
|
list($from, $to) = $request->get('date_range') ?: ['', ''];
|
|
$campaignId = intval($request->get('campaign_id', 0));
|
|
return $this->sendSuccess([
|
|
'stats' => $reporting->getEmailOpenStats($from, $to, $campaignId)
|
|
]);
|
|
}
|
|
|
|
public function getEmailClickStats(Request $request, Reporting $reporting)
|
|
{
|
|
list($from, $to) = $request->get('date_range') ?: ['', ''];
|
|
$campaignId = intval($request->get('campaign_id', 0));
|
|
return $this->sendSuccess([
|
|
'stats' => $reporting->getEmailClickStats($from, $to, $campaignId)
|
|
]);
|
|
}
|
|
|
|
public function getEmailUnsubStats(Request $request, Reporting $reporting)
|
|
{
|
|
list($from, $to) = $request->get('date_range') ?: ['', ''];
|
|
return $this->sendSuccess([
|
|
'stats' => $reporting->getUnsubscribeStats($from, $to)
|
|
]);
|
|
}
|
|
|
|
public function getEmailPerformance(Request $request, Reporting $reporting)
|
|
{
|
|
$dateRange = Arr::get($request->all(), 'date_range', []);
|
|
|
|
if (!empty($dateRange[0]) && !empty($dateRange[1])) {
|
|
$from = sanitize_text_field($dateRange[0]);
|
|
$to = sanitize_text_field($dateRange[1]);
|
|
} else {
|
|
$days = intval(Arr::get($request->all(), 'days'));
|
|
|
|
if ($days > 0) {
|
|
$from = '-' . $days . ' days';
|
|
} elseif ($request->exists('days')) {
|
|
// days=0 means "All Time"
|
|
$from = '2000-01-01';
|
|
} else {
|
|
$from = null; // default: -30 days
|
|
}
|
|
|
|
$to = null;
|
|
}
|
|
|
|
return $this->sendSuccess([
|
|
'stats' => $reporting->getEmailPerformance($from, $to)
|
|
]);
|
|
}
|
|
|
|
public function getEmails(Request $request)
|
|
{
|
|
$status = sanitize_text_field($request->get('status', ''));
|
|
$search = sanitize_text_field($request->get('search', ''));
|
|
$selectedTypes = array_values(array_filter(array_map('sanitize_text_field', (array)$request->get('types', []))));
|
|
$types = CampaignEmail::expandEmailTypes($selectedTypes);
|
|
|
|
$emails = CampaignEmail::orderBy('scheduled_at', 'DESC')
|
|
->with('subscriber', 'campaign')
|
|
->when($search, function ($q) use ($search) {
|
|
return $this->applyEmailSearchFilter($q, $search);
|
|
})
|
|
->when($status, function ($q) use ($status) {
|
|
return $q->where('status', $status);
|
|
})
|
|
->when($types, function ($q) use ($types) {
|
|
return $q->whereIn('email_type', $types);
|
|
})
|
|
->paginate();
|
|
|
|
$statuses = null;
|
|
$emailTypes = null;
|
|
|
|
if ($request->get('page') == 1 && !$search) {
|
|
$statuses = CampaignEmail::select('status')
|
|
->selectRaw('count(id) as total')
|
|
->when($types, function ($q) use ($types) {
|
|
return $q->whereIn('email_type', $types);
|
|
})
|
|
->groupBy('status')
|
|
->get()
|
|
->keyBy('status')
|
|
->map(function ($status) {
|
|
return $status->total;
|
|
});
|
|
|
|
$typeCounts = CampaignEmail::select('email_type')
|
|
->selectRaw('count(id) as total')
|
|
->whereNotNull('email_type')
|
|
->when($status, function ($q) use ($status) {
|
|
return $q->where('status', $status);
|
|
})
|
|
->groupBy('email_type')
|
|
->get()
|
|
->reduce(function ($carry, $emailType) {
|
|
$canonicalType = CampaignEmail::normalizeEmailType($emailType->email_type);
|
|
|
|
if (!isset($carry[$canonicalType])) {
|
|
$carry[$canonicalType] = [
|
|
'id' => $canonicalType,
|
|
'label' => CampaignEmail::resolveEmailTypeLabel($canonicalType),
|
|
'count' => 0,
|
|
];
|
|
}
|
|
|
|
$carry[$canonicalType]['count'] += (int)$emailType->total;
|
|
|
|
return $carry;
|
|
}, []);
|
|
|
|
$orderedTypes = [];
|
|
foreach (array_keys(CampaignEmail::getEmailTypeLabels()) as $canonicalType) {
|
|
if (!empty($typeCounts[$canonicalType])) {
|
|
$orderedTypes[] = $typeCounts[$canonicalType];
|
|
}
|
|
}
|
|
|
|
$emailTypes = array_values($orderedTypes);
|
|
}
|
|
|
|
return [
|
|
'emails' => $emails,
|
|
'statuses' => $statuses,
|
|
'types' => $emailTypes
|
|
];
|
|
}
|
|
|
|
/**
|
|
* Apply the search filter to email activity queries.
|
|
*
|
|
* Search is scoped to fields the table actually exposes so users can find
|
|
* rows by subject, source campaign title, recipient email, or related
|
|
* contact email without triggering extra broad scans on large datasets.
|
|
*
|
|
* @param \FluentCrm\Framework\Database\Orm\Builder $query
|
|
* @param string $search
|
|
* @return \FluentCrm\Framework\Database\Orm\Builder
|
|
*/
|
|
private function applyEmailSearchFilter($query, $search)
|
|
{
|
|
global $wpdb;
|
|
|
|
$escapedSearch = $wpdb->esc_like($search);
|
|
$containsLike = '%' . $escapedSearch . '%';
|
|
$emailLike = strpos($search, '@') !== false ? $escapedSearch . '%' : $containsLike;
|
|
|
|
return $query->where(function ($subQuery) use ($containsLike, $emailLike) {
|
|
$subQuery->where('email_subject', 'LIKE', $containsLike)
|
|
->orWhere('email_address', 'LIKE', $emailLike)
|
|
->orWhereHas('campaign', function ($campaignQuery) use ($containsLike) {
|
|
$campaignQuery->where('title', 'LIKE', $containsLike);
|
|
})
|
|
->orWhereHas('subscriber', function ($subscriberQuery) use ($emailLike) {
|
|
$subscriberQuery->where('email', 'LIKE', $emailLike);
|
|
});
|
|
});
|
|
}
|
|
|
|
public function deleteEmails(Request $request)
|
|
{
|
|
$emailIds = $request->get('email_ids');
|
|
CampaignEmail::whereIn('id', $emailIds)
|
|
->delete();
|
|
|
|
return [
|
|
'message' => __('Selected emails have been deleted', 'fluent-crm')
|
|
];
|
|
}
|
|
|
|
public function getContactsByStatus()
|
|
{
|
|
$statuses = fluentCrmDb()->table('fc_subscribers')
|
|
->select(fluentCrmDb()->raw('status, COUNT(id) as count'))
|
|
->groupBy('status')
|
|
->get();
|
|
|
|
$defaultOrder = [
|
|
'subscribed',
|
|
'unsubscribed',
|
|
'pending',
|
|
'bounced',
|
|
'complained',
|
|
'spammed',
|
|
'transactional'
|
|
];
|
|
$defaultStats = array_fill_keys($defaultOrder, 0);
|
|
$total = 0;
|
|
|
|
foreach ($statuses as $row) {
|
|
$count = (int)$row->count;
|
|
$status = sanitize_text_field($row->status);
|
|
$total += $count;
|
|
|
|
if (array_key_exists($status, $defaultStats)) {
|
|
$defaultStats[$status] = $count;
|
|
}
|
|
}
|
|
|
|
$result = [];
|
|
foreach ($defaultOrder as $status) {
|
|
$result[] = [
|
|
'status' => $status,
|
|
'count' => $defaultStats[$status],
|
|
];
|
|
}
|
|
|
|
return $this->sendSuccess([
|
|
'stats' => $result,
|
|
'total' => $total,
|
|
]);
|
|
}
|
|
|
|
public function getContactsByTags(Request $request)
|
|
{
|
|
$limit = intval($request->get('per_page', 20));
|
|
|
|
$tags = Tag::select(['fc_tags.id', 'fc_tags.title'])
|
|
->selectRaw('COUNT(subscriber_id) as contact_count')
|
|
->leftJoin('fc_subscriber_pivot', function ($join) {
|
|
$join->on('fc_tags.id', '=', 'fc_subscriber_pivot.object_id')
|
|
->where('fc_subscriber_pivot.object_type', '=', 'FluentCrm\App\Models\Tag');
|
|
})
|
|
->groupBy('fc_tags.id', 'fc_tags.title')
|
|
->orderByDesc('contact_count')
|
|
->paginate($limit);
|
|
|
|
return $this->sendSuccess([
|
|
'tags' => $tags,
|
|
]);
|
|
}
|
|
|
|
public function getContactsByLists(Request $request)
|
|
{
|
|
$limit = intval($request->get('per_page', 20));
|
|
|
|
$lists = Lists::select(['fc_lists.id', 'fc_lists.title'])
|
|
->selectRaw('COUNT(subscriber_id) as contact_count')
|
|
->leftJoin('fc_subscriber_pivot', function ($join) {
|
|
$join->on('fc_lists.id', '=', 'fc_subscriber_pivot.object_id')
|
|
->where('fc_subscriber_pivot.object_type', '=', 'FluentCrm\App\Models\Lists');
|
|
})
|
|
->groupBy('fc_lists.id', 'fc_lists.title')
|
|
->orderByDesc('contact_count')
|
|
->paginate($limit);
|
|
|
|
return $this->sendSuccess([
|
|
'lists' => $lists,
|
|
]);
|
|
}
|
|
|
|
public function getContactsByCountry()
|
|
{
|
|
$countries = fluentCrmDb()->table('fc_subscribers')
|
|
->select(fluentCrmDb()->raw('UPPER(TRIM(country)) as country_code, COUNT(id) as contact_count'))
|
|
->whereNotNull('country')
|
|
->whereRaw("TRIM(country) != ''")
|
|
->groupBy(fluentCrmDb()->raw('UPPER(TRIM(country))'))
|
|
->orderByDesc('contact_count')
|
|
->get();
|
|
|
|
$result = [];
|
|
foreach ($countries as $row) {
|
|
$result[] = [
|
|
'country_code' => $row->country_code,
|
|
'contact_count' => (int) $row->contact_count,
|
|
];
|
|
}
|
|
|
|
return $this->sendSuccess([
|
|
'countries' => $result,
|
|
]);
|
|
}
|
|
|
|
public function getCampaignsList(Request $request)
|
|
{
|
|
$limit = intval($request->get('per_page', 15));
|
|
|
|
$campaigns = Campaign::where('status', 'archived')
|
|
->orderBy('updated_at', 'DESC')
|
|
->paginate($limit);
|
|
|
|
foreach ($campaigns as $campaign) {
|
|
$campaign->stats = $campaign->stats();
|
|
}
|
|
|
|
return $this->sendSuccess([
|
|
'campaigns' => $campaigns,
|
|
]);
|
|
}
|
|
|
|
public function getAutomationReports(Request $request)
|
|
{
|
|
$limit = intval($request->get('per_page', 15));
|
|
|
|
$funnels = Funnel::where('status', 'published')
|
|
->orderBy('created_at', 'DESC')
|
|
->paginate($limit);
|
|
|
|
$totalSubscribers = 0;
|
|
$totalCompleted = 0;
|
|
$totalInProgress = 0;
|
|
|
|
foreach ($funnels as $funnel) {
|
|
$funnel->total_subscribers = FunnelSubscriber::where('funnel_id', $funnel->id)
|
|
->distinct()
|
|
->count('subscriber_id');
|
|
|
|
$funnel->completed_count = FunnelSubscriber::where('funnel_id', $funnel->id)
|
|
->where('status', 'completed')
|
|
->count();
|
|
|
|
$funnel->in_progress_count = FunnelSubscriber::where('funnel_id', $funnel->id)
|
|
->where('status', 'active')
|
|
->count();
|
|
|
|
// Last run time
|
|
$lastRun = FunnelSubscriber::where('funnel_id', $funnel->id)
|
|
->whereNotNull('last_executed_time')
|
|
->orderByDesc('last_executed_time')
|
|
->first();
|
|
$funnel->last_run_at = $lastRun ? $lastRun->last_executed_time : null;
|
|
|
|
// Recent 3 subscribers who entered
|
|
$recentEntries = FunnelSubscriber::where('funnel_id', $funnel->id)
|
|
->with(['subscriber' => function ($q) {
|
|
$q->select(['id', 'first_name', 'last_name', 'email', 'avatar']);
|
|
}])
|
|
->orderByDesc('created_at')
|
|
->limit(3)
|
|
->get();
|
|
|
|
$funnel->recent_subscribers = $recentEntries->map(function ($entry) {
|
|
if (!$entry->subscriber) {
|
|
return null;
|
|
}
|
|
return [
|
|
'id' => $entry->subscriber->id,
|
|
'name' => trim($entry->subscriber->first_name . ' ' . $entry->subscriber->last_name),
|
|
'email' => $entry->subscriber->email,
|
|
'avatar' => $entry->subscriber->avatar,
|
|
'entered_at' => $entry->created_at,
|
|
];
|
|
})->filter()->values();
|
|
|
|
$totalSubscribers += $funnel->total_subscribers;
|
|
$totalCompleted += $funnel->completed_count;
|
|
$totalInProgress += $funnel->in_progress_count;
|
|
}
|
|
|
|
// Top 5 automations by total subscribers (most triggered)
|
|
$topAutomations = Funnel::where('status', 'published')
|
|
->get()
|
|
->map(function ($funnel) {
|
|
$funnel->trigger_count = FunnelSubscriber::where('funnel_id', $funnel->id)
|
|
->count();
|
|
return $funnel;
|
|
})
|
|
->sortByDesc('trigger_count')
|
|
->take(5)
|
|
->values()
|
|
->map(function ($funnel) {
|
|
return [
|
|
'id' => $funnel->id,
|
|
'title' => $funnel->title,
|
|
'trigger_name' => $funnel->trigger_name,
|
|
'trigger_count' => $funnel->trigger_count,
|
|
];
|
|
});
|
|
|
|
$overview = [
|
|
'total' => Funnel::where('status', 'published')->count(),
|
|
'subscribers' => $totalSubscribers,
|
|
'completed' => $totalCompleted,
|
|
'in_progress' => $totalInProgress,
|
|
];
|
|
|
|
return $this->sendSuccess([
|
|
'automations' => $funnels,
|
|
'overview' => $overview,
|
|
'top_automations' => $topAutomations,
|
|
]);
|
|
}
|
|
|
|
public function getAutomationStepReport(Request $request, Reporting $reporting, $id)
|
|
{
|
|
$id = intval($id);
|
|
$funnel = Funnel::findOrFail($id);
|
|
|
|
$stats = $reporting->funnelStat($funnel->id);
|
|
|
|
return $this->sendSuccess([
|
|
'funnel' => $funnel,
|
|
'stats' => $stats,
|
|
]);
|
|
}
|
|
|
|
public function getCampaignOptions(Request $request)
|
|
{
|
|
global $wpdb;
|
|
|
|
$search = sanitize_text_field($request->get('search', ''));
|
|
$limit = intval($request->get('per_page', 50));
|
|
|
|
$query = Campaign::select(['id', 'title'])
|
|
->where('status', 'archived');
|
|
|
|
if ($search) {
|
|
$query->where('title', 'LIKE', '%' . $wpdb->esc_like($search) . '%');
|
|
}
|
|
|
|
$options = $query->orderBy('updated_at', 'DESC')
|
|
->limit($limit)
|
|
->get();
|
|
|
|
return $this->sendSuccess([
|
|
'options' => $options,
|
|
]);
|
|
}
|
|
|
|
public function getAdvancedReportProviders()
|
|
{
|
|
return [
|
|
/**
|
|
* Determine the advanced report providers for FluentCRM.
|
|
*
|
|
* This filter allows you to modify the list of advanced report providers.
|
|
*
|
|
* @since 1.0.0
|
|
*
|
|
* @param array An array of advanced report providers.
|
|
*/
|
|
'providers' => apply_filters('fluent_crm/advanced_report_providers', [])
|
|
];
|
|
}
|
|
|
|
public function getRecentTags(Request $request)
|
|
{
|
|
$limit = intval($request->get('per_page', 5));
|
|
|
|
$tags = Tag::select(['fc_tags.id', 'fc_tags.title', 'fc_tags.created_at'])
|
|
->selectRaw('COUNT(subscriber_id) as contact_count')
|
|
->leftJoin('fc_subscriber_pivot', function ($join) {
|
|
$join->on('fc_tags.id', '=', 'fc_subscriber_pivot.object_id')
|
|
->where('fc_subscriber_pivot.object_type', '=', 'FluentCrm\App\Models\Tag');
|
|
})
|
|
->groupBy('fc_tags.id', 'fc_tags.title', 'fc_tags.created_at')
|
|
->orderByDesc('fc_tags.created_at')
|
|
->limit($limit)
|
|
->get();
|
|
|
|
return $this->sendSuccess([
|
|
'tags' => $tags,
|
|
]);
|
|
}
|
|
|
|
public function ping()
|
|
{
|
|
// Browser-driven cron fallback: while an admin has any CRM page open,
|
|
// the app pings this endpoint ~every 50s. If Action Scheduler (and the
|
|
// WP-Cron fallback) have stalled, take over the every-minute email task
|
|
// here. No-ops in a single option read when scheduling is healthy, and
|
|
// is fully locked/throttled internally — safe across tabs and users.
|
|
Scheduler::maybeProcessFromBrowserPing();
|
|
|
|
return [
|
|
'message' => 'pong'
|
|
];
|
|
}
|
|
}
|