Initial commit
This commit is contained in:
@@ -0,0 +1,597 @@
|
||||
<?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'
|
||||
];
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user