phase 8 and 9

This commit is contained in:
Stefanescu Mihai
2026-03-19 18:42:19 +02:00
parent d75cabfeec
commit 82ebb697f3
94 changed files with 4498 additions and 749 deletions
+116
View File
@@ -0,0 +1,116 @@
<?php
namespace App\Console\Commands;
use App\Mail\AlertTriggered;
use App\Models\Alert;
use App\Services\AnalyticsQueryService;
use Illuminate\Console\Command;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Mail;
class CheckAlerts extends Command
{
protected $signature = 'analytics:check-alerts';
protected $description = 'Evaluate active alerts and trigger notifications';
public function handle(): int
{
$alerts = Alert::with('website.user')
->where('is_active', true)
->get();
$triggered = 0;
foreach ($alerts as $alert) {
$currentValue = $this->getCurrentValue($alert);
$shouldTrigger = $this->evaluate($alert, $currentValue);
if ($shouldTrigger) {
$this->trigger($alert, $currentValue);
$triggered++;
}
}
$this->info("Checked {$alerts->count()} alerts, triggered {$triggered}.");
return self::SUCCESS;
}
private function getCurrentValue(Alert $alert): float
{
$to = Carbon::today();
$from = $alert->period === 'hourly'
? Carbon::now()->subHour()
: Carbon::yesterday();
$service = AnalyticsQueryService::for($alert->website, $from, $to);
$summary = $service->summary();
return match ($alert->metric) {
'page_views' => $summary['page_views'],
'visitors' => $summary['visitors'],
'sessions' => $summary['sessions'],
'bounce_rate' => $summary['bounce_rate'],
default => 0,
};
}
private function evaluate(Alert $alert, float $currentValue): bool
{
return match ($alert->condition) {
'above' => $currentValue > $alert->threshold,
'below' => $currentValue < $alert->threshold,
'change_up' => $this->percentageChange($alert) > $alert->threshold,
'change_down' => $this->percentageChange($alert) < -$alert->threshold,
default => false,
};
}
private function percentageChange(Alert $alert): float
{
$to = Carbon::today();
$from = Carbon::yesterday();
$prevFrom = Carbon::yesterday()->subDay();
$prevTo = Carbon::yesterday();
$current = $this->getValueForPeriod($alert, $from, $to);
$previous = $this->getValueForPeriod($alert, $prevFrom, $prevTo);
if ($previous == 0) {
return $current > 0 ? 100 : 0;
}
return (($current - $previous) / $previous) * 100;
}
private function getValueForPeriod(Alert $alert, Carbon $from, Carbon $to): float
{
$service = AnalyticsQueryService::for($alert->website, $from, $to);
$summary = $service->summary();
return $summary[$alert->metric] ?? 0;
}
private function trigger(Alert $alert, float $currentValue): void
{
$conditionLabel = Alert::$conditions[$alert->condition] ?? $alert->condition;
$metricLabel = Alert::$metrics[$alert->metric] ?? $alert->metric;
$message = "{$metricLabel} {$conditionLabel} {$alert->threshold} (current: {$currentValue}) for {$alert->website->name}";
$notification = $alert->notifications()->create([
'value' => $currentValue,
'message' => $message,
]);
$alert->update(['last_triggered_at' => now()]);
if (in_array($alert->channel, ['email', 'both'])) {
Mail::to($alert->website->user)->send(new AlertTriggered($alert, $notification));
}
$this->line(" → Triggered: {$alert->name} — {$message}");
}
}
+116
View File
@@ -0,0 +1,116 @@
<?php
namespace App\Http\Controllers;
use App\Models\Alert;
use App\Models\Website;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\View\View;
class AlertController extends Controller
{
public function index(Website $website): View
{
$this->authorize('view', $website);
$alerts = $website->alerts()->orderBy('name')->get();
$unreadCount = $website->alerts()
->whereHas('notifications', fn ($q) => $q->where('is_read', false))
->count();
return view('alerts.index', [
'website' => $website,
'alerts' => $alerts,
'unreadCount' => $unreadCount,
]);
}
public function create(Website $website): View
{
$this->authorize('view', $website);
return view('alerts.create', ['website' => $website]);
}
public function store(Request $request, Website $website): RedirectResponse
{
$this->authorize('view', $website);
$validated = $request->validate([
'name' => 'required|string|max:255',
'metric' => 'required|in:' . implode(',', array_keys(Alert::$metrics)),
'condition' => 'required|in:' . implode(',', array_keys(Alert::$conditions)),
'threshold' => 'required|numeric|min:0',
'period' => 'required|in:daily,hourly',
'channel' => 'required|in:email,in_app,both',
]);
$website->alerts()->create($validated);
return redirect()
->route('alerts.index', $website)
->with('success', 'Alert created successfully.');
}
public function edit(Website $website, Alert $alert): View
{
$this->authorize('view', $website);
return view('alerts.edit', [
'website' => $website,
'alert' => $alert,
]);
}
public function update(Request $request, Website $website, Alert $alert): RedirectResponse
{
$this->authorize('view', $website);
$validated = $request->validate([
'name' => 'required|string|max:255',
'metric' => 'required|in:' . implode(',', array_keys(Alert::$metrics)),
'condition' => 'required|in:' . implode(',', array_keys(Alert::$conditions)),
'threshold' => 'required|numeric|min:0',
'period' => 'required|in:daily,hourly',
'channel' => 'required|in:email,in_app,both',
]);
$alert->update($validated);
return redirect()
->route('alerts.index', $website)
->with('success', 'Alert updated successfully.');
}
public function destroy(Website $website, Alert $alert): RedirectResponse
{
$this->authorize('view', $website);
$alert->delete();
return redirect()
->route('alerts.index', $website)
->with('success', 'Alert deleted.');
}
public function notifications(Website $website): View
{
$this->authorize('view', $website);
$notifications = \App\Models\AlertNotification::whereHas('alert', fn ($q) => $q->where('website_id', $website->id))
->with('alert')
->orderByDesc('created_at')
->paginate(20);
// Mark as read
\App\Models\AlertNotification::whereHas('alert', fn ($q) => $q->where('website_id', $website->id))
->where('is_read', false)
->update(['is_read' => true]);
return view('alerts.notifications', [
'website' => $website,
'notifications' => $notifications,
]);
}
}
@@ -0,0 +1,90 @@
<?php
namespace App\Http\Controllers;
use App\Models\Annotation;
use App\Models\Website;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\View\View;
class AnnotationController extends Controller
{
public function index(Website $website): View
{
$this->authorize('view', $website);
return view('annotations.index', [
'website' => $website,
'annotations' => $website->annotations()->with('user')->orderByDesc('date')->paginate(25),
]);
}
public function store(Request $request, Website $website): RedirectResponse|JsonResponse
{
$this->authorize('view', $website);
$validated = $request->validate([
'date' => 'required|date',
'text' => 'required|string|max:500',
'category' => 'required|in:' . implode(',', array_keys(Annotation::$categories)),
]);
$annotation = $website->annotations()->create([
...$validated,
'user_id' => $request->user()->id,
]);
if ($request->expectsJson()) {
return response()->json($annotation->load('user'), 201);
}
return redirect()
->route('annotations.index', $website)
->with('success', 'Annotation added.');
}
public function destroy(Request $request, Website $website, Annotation $annotation): RedirectResponse|JsonResponse
{
$this->authorize('view', $website);
$annotation->delete();
if ($request->expectsJson()) {
return response()->json(['ok' => true]);
}
return redirect()
->route('annotations.index', $website)
->with('success', 'Annotation deleted.');
}
/**
* JSON endpoint for chart overlays — returns annotations within a date range.
*/
public function forRange(Request $request, Website $website): JsonResponse
{
$this->authorize('view', $website);
$request->validate([
'from' => 'required|date',
'to' => 'required|date|after_or_equal:from',
]);
$annotations = $website->annotations()
->with('user:id,name')
->whereBetween('date', [$request->from, $request->to])
->orderBy('date')
->get()
->map(fn (Annotation $a) => [
'id' => $a->id,
'date' => $a->date->toDateString(),
'text' => $a->text,
'category' => $a->category,
'user' => $a->user->name,
]);
return response()->json($annotations);
}
}
@@ -0,0 +1,133 @@
<?php
namespace App\Http\Controllers\Api\V1;
use App\Http\Controllers\Controller;
use App\Http\Resources\V1\EventResource;
use App\Http\Resources\V1\PageResource;
use App\Http\Resources\V1\ReferrerResource;
use App\Http\Resources\V1\StatsResource;
use App\Http\Resources\V1\VisitorBreakdownResource;
use App\Http\Resources\V1\WebsiteResource;
use App\Models\Website;
use App\Services\AnalyticsQueryService;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
use Illuminate\Support\Carbon;
class WebsiteController extends Controller
{
public function index(Request $request): AnonymousResourceCollection
{
$websites = $request->user()->websites()->orderBy('name')->get();
return WebsiteResource::collection($websites);
}
public function stats(Request $request, Website $website): StatsResource
{
$this->authorizeWebsite($request, $website);
[$from, $to] = $this->parseDateRange($request);
$service = AnalyticsQueryService::for($website, $from, $to);
return new StatsResource([
'from' => $from->toDateString(),
'to' => $to->toDateString(),
'metrics' => $service->summary(),
'timeseries' => $service->visitorsOverTime(),
]);
}
public function pages(Request $request, Website $website): AnonymousResourceCollection
{
$this->authorizeWebsite($request, $website);
[$from, $to] = $this->parseDateRange($request);
$limit = $this->parseLimit($request);
$service = AnalyticsQueryService::for($website, $from, $to);
return PageResource::collection($service->topPages($limit));
}
public function referrers(Request $request, Website $website): AnonymousResourceCollection
{
$this->authorizeWebsite($request, $website);
[$from, $to] = $this->parseDateRange($request);
$limit = $this->parseLimit($request);
$service = AnalyticsQueryService::for($website, $from, $to);
return ReferrerResource::collection($service->trafficSources($limit));
}
public function events(Request $request, Website $website): AnonymousResourceCollection
{
$this->authorizeWebsite($request, $website);
[$from, $to] = $this->parseDateRange($request);
$limit = $this->parseLimit($request);
$service = AnalyticsQueryService::for($website, $from, $to);
return EventResource::collection($service->eventsOverview($limit));
}
public function visitors(Request $request, Website $website): VisitorBreakdownResource
{
$this->authorizeWebsite($request, $website);
[$from, $to] = $this->parseDateRange($request);
$service = AnalyticsQueryService::for($website, $from, $to);
return new VisitorBreakdownResource([
'from' => $from->toDateString(),
'to' => $to->toDateString(),
'countries' => $service->visitorsByCountry(20)->map(fn ($r) => [
'country' => $r->country,
'visitors' => $r->visitors,
]),
'browsers' => $service->visitorsByBrowser(10)->map(fn ($r) => [
'browser' => $r->browser,
'visitors' => $r->visitors,
]),
'operating_systems' => $service->visitorsByOS(10)->map(fn ($r) => [
'os' => $r->os,
'visitors' => $r->visitors,
]),
'device_types' => $service->visitorsByDeviceType(5)->map(fn ($r) => [
'device_type' => $r->device_type,
'visitors' => $r->visitors,
]),
'languages' => $service->visitorsByLanguage(10)->map(fn ($r) => [
'language' => $r->language,
'visitors' => $r->visitors,
]),
'new_vs_returning' => $service->newVsReturning(),
]);
}
private function authorizeWebsite(Request $request, Website $website): void
{
if ($website->user_id !== $request->user()->id) {
abort(403, 'You do not own this website.');
}
}
private function parseDateRange(Request $request): array
{
$request->validate([
'from' => 'nullable|date|before_or_equal:to',
'to' => 'nullable|date|after_or_equal:from',
]);
$from = $request->has('from')
? Carbon::parse($request->get('from'))
: Carbon::today()->subDays(6);
$to = $request->has('to')
? Carbon::parse($request->get('to'))
: Carbon::today();
return [$from, $to];
}
private function parseLimit(Request $request): int
{
return min((int) $request->get('limit', 50), 200);
}
}
@@ -0,0 +1,40 @@
<?php
namespace App\Http\Controllers;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\View\View;
class ApiTokenController extends Controller
{
public function index(Request $request): View
{
return view('api-tokens.index', [
'tokens' => $request->user()->tokens()->orderByDesc('created_at')->get(),
]);
}
public function store(Request $request): RedirectResponse
{
$request->validate([
'name' => 'required|string|max:255',
]);
$token = $request->user()->createToken($request->name);
return redirect()
->route('api-tokens.index')
->with('newToken', $token->plainTextToken)
->with('success', 'API token created successfully.');
}
public function destroy(Request $request, string $tokenId): RedirectResponse
{
$request->user()->tokens()->where('id', $tokenId)->delete();
return redirect()
->route('api-tokens.index')
->with('success', 'API token revoked.');
}
}
@@ -31,4 +31,14 @@ trait ParsesDateRange
return [$from, $to];
}
private function previousPeriod(Carbon $from, Carbon $to): array
{
$days = $from->diffInDays($to) + 1;
$prevTo = $from->copy()->subDay();
$prevFrom = $prevTo->copy()->subDays($days - 1);
return [$prevFrom, $prevTo];
}
}
@@ -0,0 +1,37 @@
<?php
namespace App\Http\Controllers\Concerns;
use App\Models\Segment;
use App\Models\Website;
use App\Services\AnalyticsQueryService;
use Illuminate\Http\Request;
use Illuminate\Support\Collection;
trait ParsesSegment
{
private function applySegment(Request $request, Website $website, AnalyticsQueryService $service): AnalyticsQueryService
{
$segmentId = $request->get('segment');
if (! $segmentId) {
return $service;
}
$segment = $website->segments()->find($segmentId);
if (! $segment) {
return $service;
}
return $service->withSegment($segment->matchingVisitorIds());
}
private function activeSegment(Request $request, Website $website): ?Segment
{
$segmentId = $request->get('segment');
if (! $segmentId) {
return null;
}
return $website->segments()->find($segmentId);
}
}
@@ -0,0 +1,122 @@
<?php
namespace App\Http\Controllers;
use App\Http\Controllers\Concerns\ParsesDateRange;
use App\Models\DashboardWidget;
use App\Models\Website;
use App\Services\AnalyticsQueryService;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\View\View;
class CustomDashboardController extends Controller
{
use ParsesDateRange;
public function show(Request $request, Website $website): View
{
$this->authorize('view', $website);
[$from, $to] = $this->parseDateRange($request);
$service = AnalyticsQueryService::for($website, $from, $to);
$widgets = $website->dashboardWidgets()->orderBy('sort_order')->get();
$widgetData = [];
foreach ($widgets as $widget) {
$widgetData[$widget->id] = $this->resolveWidgetData($widget, $service);
}
return view('custom-dashboard.show', [
'website' => $website,
'widgets' => $widgets,
'widgetData' => $widgetData,
'from' => $from,
'to' => $to,
]);
}
public function manage(Website $website): View
{
$this->authorize('view', $website);
return view('custom-dashboard.manage', [
'website' => $website,
'widgets' => $website->dashboardWidgets()->orderBy('sort_order')->get(),
'types' => DashboardWidget::$types,
'metrics' => DashboardWidget::$metrics,
]);
}
public function store(Request $request, Website $website): RedirectResponse
{
$this->authorize('view', $website);
$validated = $request->validate([
'title' => 'required|string|max:255',
'widget_type' => 'required|string|in:' . implode(',', array_keys(DashboardWidget::$types)),
'size' => 'required|in:full,half,third',
'config' => 'nullable|array',
'config.metric' => 'nullable|string',
'config.limit' => 'nullable|integer|min:1|max:50',
]);
$maxSort = $website->dashboardWidgets()->max('sort_order') ?? 0;
$validated['sort_order'] = $maxSort + 1;
$website->dashboardWidgets()->create($validated);
return redirect()
->route('custom-dashboard.manage', $website)
->with('success', 'Widget added.');
}
public function reorder(Request $request, Website $website): RedirectResponse
{
$this->authorize('view', $website);
$request->validate([
'order' => 'required|array',
'order.*' => 'integer|exists:dashboard_widgets,id',
]);
foreach ($request->order as $position => $widgetId) {
$website->dashboardWidgets()->where('id', $widgetId)->update(['sort_order' => $position]);
}
return redirect()
->route('custom-dashboard.manage', $website)
->with('success', 'Widget order updated.');
}
public function destroy(Website $website, DashboardWidget $widget): RedirectResponse
{
$this->authorize('view', $website);
$widget->delete();
return redirect()
->route('custom-dashboard.manage', $website)
->with('success', 'Widget removed.');
}
private function resolveWidgetData(DashboardWidget $widget, AnalyticsQueryService $service): mixed
{
$config = $widget->config ?? [];
$limit = $config['limit'] ?? 5;
return match ($widget->widget_type) {
'metric' => $service->summary()[$config['metric'] ?? 'page_views'] ?? 0,
'chart' => $service->visitorsOverTime(),
'top_pages' => $service->topPages($limit),
'top_sources' => $service->trafficSources($limit),
'events' => $service->eventsOverview($limit),
'conversions' => $service->conversionsReport(),
'new_vs_returning' => $service->newVsReturning(),
'browsers' => $service->visitorsByBrowser($limit),
'countries' => $service->visitorsByCountry($limit),
default => null,
};
}
}
+9 -1
View File
@@ -18,17 +18,25 @@ class DashboardController extends Controller
$this->authorize('view', $website);
[$from, $to] = $this->parseDateRange($request);
[$prevFrom, $prevTo] = $this->previousPeriod($from, $to);
$service = AnalyticsQueryService::for($website, $from, $to);
$prevService = AnalyticsQueryService::for($website, $prevFrom, $prevTo);
$metrics = $service->summary();
$prevMetrics = $prevService->summary();
$chartData = $service->visitorsOverTime();
$activeVisitors = AnalyticsQueryService::activeVisitors($website);
$goalConversions = $service->conversionsReport();
$goalChartData = $service->goalCompletionsOverTime();
$topPages = $service->topPages(5);
$topSources = $service->trafficSources(5);
return view('dashboard.show', compact(
'website', 'metrics', 'chartData', 'activeVisitors',
'website', 'metrics', 'prevMetrics', 'chartData', 'activeVisitors',
'goalConversions', 'goalChartData', 'from', 'to',
'topPages', 'topSources',
));
}
+209
View File
@@ -0,0 +1,209 @@
<?php
namespace App\Http\Controllers;
use App\Http\Controllers\Concerns\ParsesDateRange;
use App\Models\Website;
use App\Services\AnalyticsQueryService;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\StreamedResponse;
class ExportController extends Controller
{
use ParsesDateRange;
public function export(Request $request, Website $website, string $report): StreamedResponse
{
$this->authorize('view', $website);
[$from, $to] = $this->parseDateRange($request);
$service = AnalyticsQueryService::for($website, $from, $to);
[$headers, $rows] = match ($report) {
'pages' => $this->pagesExport($service),
'landing-pages' => $this->landingPagesExport($service),
'exit-pages' => $this->exitPagesExport($service),
'sources' => $this->sourcesExport($service),
'referrers' => $this->referrersExport($service),
'campaigns' => $this->campaignsExport($service),
'events' => $this->eventsExport($service),
'countries' => $this->countriesExport($service),
'browsers' => $this->browsersExport($service),
'languages' => $this->languagesExport($service),
'summary' => $this->summaryExport($service, $from, $to),
default => abort(404, 'Unknown report type.'),
};
$filename = "{$website->domain}_{$report}_{$from->format('Y-m-d')}_to_{$to->format('Y-m-d')}.csv";
return $this->streamCsv($filename, $headers, $rows);
}
private function streamCsv(string $filename, array $headers, iterable $rows): StreamedResponse
{
return response()->streamDownload(function () use ($headers, $rows) {
$handle = fopen('php://output', 'w');
fputcsv($handle, $headers);
foreach ($rows as $row) {
fputcsv($handle, $row);
}
fclose($handle);
}, $filename, [
'Content-Type' => 'text/csv',
]);
}
private function pagesExport(AnalyticsQueryService $service): array
{
$data = $service->topPages(500);
return [
['Path', 'Views', 'Unique Views', 'Avg Time on Page (s)'],
$data->map(fn ($r) => [
$r->path,
$r->views,
$r->unique_views,
round($r->avg_time ?? 0, 1),
]),
];
}
private function landingPagesExport(AnalyticsQueryService $service): array
{
$data = $service->landingPages(500);
return [
['Path', 'Entrances', 'Bounce Rate (%)'],
$data->map(fn ($r) => [
$r->entry_page ?? $r->path ?? '—',
$r->entrances ?? $r->sessions ?? 0,
$r->bounce_rate ?? 0,
]),
];
}
private function exitPagesExport(AnalyticsQueryService $service): array
{
$data = $service->exitPages(500);
return [
['Path', 'Exits'],
$data->map(fn ($r) => [
$r->exit_page ?? $r->path ?? '—',
$r->exits ?? 0,
]),
];
}
private function sourcesExport(AnalyticsQueryService $service): array
{
$data = $service->trafficSources(500);
return [
['Source', 'Sessions', 'Bounces', 'Avg Duration (s)', 'Avg Pages'],
$data->map(fn ($r) => [
$r->source,
$r->sessions,
$r->bounces,
round($r->avg_duration ?? 0, 1),
round($r->avg_pages ?? 0, 1),
]),
];
}
private function referrersExport(AnalyticsQueryService $service): array
{
$data = $service->referrers(500);
return [
['Referrer Domain', 'Sessions', 'Bounces', 'Avg Duration (s)'],
$data->map(fn ($r) => [
$r->referrer_domain,
$r->sessions,
$r->bounces,
round($r->avg_duration ?? 0, 1),
]),
];
}
private function campaignsExport(AnalyticsQueryService $service): array
{
$data = $service->campaigns(500);
return [
['UTM Source', 'UTM Medium', 'UTM Campaign', 'Sessions', 'Bounces', 'Avg Duration (s)'],
$data->map(fn ($r) => [
$r->utm_source,
$r->utm_medium,
$r->utm_campaign,
$r->sessions,
$r->bounces,
round($r->avg_duration ?? 0, 1),
]),
];
}
private function eventsExport(AnalyticsQueryService $service): array
{
$data = $service->eventsOverview(500);
return [
['Event Name', 'Category', 'Total', 'Unique Visitors', 'Total Value'],
$data->map(fn ($r) => [
$r->name,
$r->category,
$r->total,
$r->unique_visitors,
$r->total_value,
]),
];
}
private function countriesExport(AnalyticsQueryService $service): array
{
$data = $service->visitorsByCountry(500);
return [
['Country', 'Visitors'],
$data->map(fn ($r) => [$r->country, $r->visitors]),
];
}
private function browsersExport(AnalyticsQueryService $service): array
{
$data = $service->visitorsByBrowser(100);
return [
['Browser', 'Visitors'],
$data->map(fn ($r) => [$r->browser, $r->visitors]),
];
}
private function languagesExport(AnalyticsQueryService $service): array
{
$data = $service->visitorsByLanguage(100);
return [
['Language', 'Visitors'],
$data->map(fn ($r) => [$r->language, $r->visitors]),
];
}
private function summaryExport(AnalyticsQueryService $service, $from, $to): array
{
$summary = $service->summary();
return [
['Metric', 'Value'],
collect([
['Period', $from->format('Y-m-d') . ' to ' . $to->format('Y-m-d')],
['Page Views', $summary['page_views']],
['Unique Visitors', $summary['visitors']],
['Sessions', $summary['sessions']],
['Bounce Rate (%)', $summary['bounce_rate']],
['Avg Duration (s)', $summary['avg_duration']],
['Pages / Session', $summary['avg_pages']],
]),
];
}
}
+110
View File
@@ -0,0 +1,110 @@
<?php
namespace App\Http\Controllers;
use App\Http\Controllers\Concerns\ParsesDateRange;
use App\Models\Funnel;
use App\Models\Website;
use App\Services\FunnelAnalysisService;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\View\View;
class FunnelController extends Controller
{
use ParsesDateRange;
public function index(Website $website): View
{
$this->authorize('view', $website);
return view('funnels.index', [
'website' => $website,
'funnels' => $website->funnels()->orderBy('name')->get(),
]);
}
public function create(Website $website): View
{
$this->authorize('view', $website);
return view('funnels.create', [
'website' => $website,
]);
}
public function store(Request $request, Website $website): RedirectResponse
{
$this->authorize('view', $website);
$validated = $request->validate([
'name' => 'required|string|max:255',
'steps' => 'required|array|min:2',
'steps.*.type' => 'required|in:pageview,event',
'steps.*.value' => 'required|string|max:255',
'steps.*.label' => 'required|string|max:255',
]);
$website->funnels()->create($validated);
return redirect()
->route('funnels.index', $website)
->with('success', 'Funnel created successfully.');
}
public function show(Request $request, Website $website, Funnel $funnel): View
{
$this->authorize('view', $website);
[$from, $to] = $this->parseDateRange($request);
$service = new FunnelAnalysisService($funnel, $from, $to);
return view('funnels.show', [
'website' => $website,
'funnel' => $funnel,
'steps' => $service->analyze(),
'from' => $from,
'to' => $to,
]);
}
public function edit(Website $website, Funnel $funnel): View
{
$this->authorize('view', $website);
return view('funnels.edit', [
'website' => $website,
'funnel' => $funnel,
]);
}
public function update(Request $request, Website $website, Funnel $funnel): RedirectResponse
{
$this->authorize('view', $website);
$validated = $request->validate([
'name' => 'required|string|max:255',
'steps' => 'required|array|min:2',
'steps.*.type' => 'required|in:pageview,event',
'steps.*.value' => 'required|string|max:255',
'steps.*.label' => 'required|string|max:255',
]);
$funnel->update($validated);
return redirect()
->route('funnels.show', [$website, $funnel])
->with('success', 'Funnel updated successfully.');
}
public function destroy(Website $website, Funnel $funnel): RedirectResponse
{
$this->authorize('view', $website);
$funnel->delete();
return redirect()
->route('funnels.index', $website)
->with('success', 'Funnel deleted.');
}
}
@@ -0,0 +1,88 @@
<?php
namespace App\Http\Controllers;
use App\Models\Segment;
use App\Models\Website;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\View\View;
class SegmentController extends Controller
{
public function index(Website $website): View
{
$this->authorize('view', $website);
return view('segments.index', [
'website' => $website,
'segments' => $website->segments()->orderBy('name')->get(),
]);
}
public function create(Website $website): View
{
$this->authorize('view', $website);
return view('segments.create', ['website' => $website]);
}
public function store(Request $request, Website $website): RedirectResponse
{
$this->authorize('view', $website);
$validated = $request->validate([
'name' => 'required|string|max:255',
'filters' => 'required|array|min:1',
'filters.*.field' => 'required|string|in:country,region,city,browser,os,device_type,language,screen_resolution',
'filters.*.operator' => 'required|string|in:=,!=,contains,starts_with',
'filters.*.value' => 'required|string|max:255',
]);
$website->segments()->create($validated);
return redirect()
->route('segments.index', $website)
->with('success', 'Segment created successfully.');
}
public function edit(Website $website, Segment $segment): View
{
$this->authorize('view', $website);
return view('segments.edit', [
'website' => $website,
'segment' => $segment,
]);
}
public function update(Request $request, Website $website, Segment $segment): RedirectResponse
{
$this->authorize('view', $website);
$validated = $request->validate([
'name' => 'required|string|max:255',
'filters' => 'required|array|min:1',
'filters.*.field' => 'required|string|in:country,region,city,browser,os,device_type,language,screen_resolution',
'filters.*.operator' => 'required|string|in:=,!=,contains,starts_with',
'filters.*.value' => 'required|string|max:255',
]);
$segment->update($validated);
return redirect()
->route('segments.index', $website)
->with('success', 'Segment updated successfully.');
}
public function destroy(Website $website, Segment $segment): RedirectResponse
{
$this->authorize('view', $website);
$segment->delete();
return redirect()
->route('segments.index', $website)
->with('success', 'Segment deleted.');
}
}
@@ -0,0 +1,18 @@
<?php
namespace App\Http\Controllers;
use App\Models\Website;
use Illuminate\View\View;
class UtmBuilderController extends Controller
{
public function show(Website $website): View
{
$this->authorize('view', $website);
return view('utm-builder.show', [
'website' => $website,
]);
}
}
+20
View File
@@ -0,0 +1,20 @@
<?php
namespace App\Http\Resources\V1;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
class EventResource extends JsonResource
{
public function toArray(Request $request): array
{
return [
'name' => $this->name,
'category' => $this->category,
'total' => (int) $this->total,
'unique_visitors' => (int) $this->unique_visitors,
'total_value' => $this->total_value ? round((float) $this->total_value, 4) : null,
];
}
}
+19
View File
@@ -0,0 +1,19 @@
<?php
namespace App\Http\Resources\V1;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
class PageResource extends JsonResource
{
public function toArray(Request $request): array
{
return [
'path' => $this->path,
'views' => (int) $this->views,
'unique_views' => (int) $this->unique_views,
'avg_time_on_page' => round((float) ($this->avg_time ?? 0), 1),
];
}
}
@@ -0,0 +1,20 @@
<?php
namespace App\Http\Resources\V1;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
class ReferrerResource extends JsonResource
{
public function toArray(Request $request): array
{
return [
'source' => $this->source,
'sessions' => (int) $this->sessions,
'bounce_rate' => $this->sessions > 0 ? round(($this->bounces / $this->sessions) * 100, 1) : 0,
'avg_duration' => round((float) ($this->avg_duration ?? 0), 1),
'avg_pages' => round((float) ($this->avg_pages ?? 0), 1),
];
}
}
+30
View File
@@ -0,0 +1,30 @@
<?php
namespace App\Http\Resources\V1;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
class StatsResource extends JsonResource
{
public function toArray(Request $request): array
{
$data = $this->resource;
return [
'period' => [
'from' => $data['from'],
'to' => $data['to'],
],
'metrics' => [
'page_views' => $data['metrics']['page_views'],
'visitors' => $data['metrics']['visitors'],
'sessions' => $data['metrics']['sessions'],
'bounce_rate' => $data['metrics']['bounce_rate'],
'avg_duration' => $data['metrics']['avg_duration'],
'avg_pages_per_session' => $data['metrics']['avg_pages'],
],
'timeseries' => $data['timeseries'],
];
}
}
@@ -0,0 +1,27 @@
<?php
namespace App\Http\Resources\V1;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
class VisitorBreakdownResource extends JsonResource
{
public function toArray(Request $request): array
{
$data = $this->resource;
return [
'period' => [
'from' => $data['from'],
'to' => $data['to'],
],
'countries' => $data['countries'],
'browsers' => $data['browsers'],
'operating_systems' => $data['operating_systems'],
'device_types' => $data['device_types'],
'languages' => $data['languages'],
'new_vs_returning' => $data['new_vs_returning'],
];
}
}
+21
View File
@@ -0,0 +1,21 @@
<?php
namespace App\Http\Resources\V1;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
class WebsiteResource extends JsonResource
{
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'name' => $this->name,
'domain' => $this->domain,
'tracking_id' => $this->tracking_id,
'is_active' => $this->is_active,
'created_at' => $this->created_at->toIso8601String(),
];
}
}
+35
View File
@@ -0,0 +1,35 @@
<?php
namespace App\Mail;
use App\Models\Alert;
use App\Models\AlertNotification;
use Illuminate\Bus\Queueable;
use Illuminate\Mail\Mailable;
use Illuminate\Mail\Mailables\Content;
use Illuminate\Mail\Mailables\Envelope;
use Illuminate\Queue\SerializesModels;
class AlertTriggered extends Mailable
{
use Queueable, SerializesModels;
public function __construct(
public Alert $alert,
public AlertNotification $notification,
) {}
public function envelope(): Envelope
{
return new Envelope(
subject: "[Alert] {$this->alert->name} — {$this->alert->website->name}",
);
}
public function content(): Content
{
return new Content(
markdown: 'emails.alert-triggered',
);
}
}
+58
View File
@@ -0,0 +1,58 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
class Alert extends Model
{
use HasFactory;
protected $fillable = [
'website_id',
'name',
'metric',
'condition',
'threshold',
'period',
'channel',
'is_active',
'last_triggered_at',
];
protected function casts(): array
{
return [
'threshold' => 'decimal:2',
'is_active' => 'boolean',
'last_triggered_at' => 'datetime',
];
}
public function website(): BelongsTo
{
return $this->belongsTo(Website::class);
}
public function notifications(): HasMany
{
return $this->hasMany(AlertNotification::class);
}
public static array $metrics = [
'page_views' => 'Page Views',
'visitors' => 'Unique Visitors',
'sessions' => 'Sessions',
'bounce_rate' => 'Bounce Rate',
];
public static array $conditions = [
'above' => 'goes above',
'below' => 'drops below',
'change_up' => '% increase exceeds',
'change_down' => '% decrease exceeds',
];
}
+29
View File
@@ -0,0 +1,29 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class AlertNotification extends Model
{
protected $fillable = [
'alert_id',
'value',
'message',
'is_read',
];
protected function casts(): array
{
return [
'value' => 'decimal:2',
'is_read' => 'boolean',
];
}
public function alert(): BelongsTo
{
return $this->belongsTo(Alert::class);
}
}
+41
View File
@@ -0,0 +1,41 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class Annotation extends Model
{
protected $fillable = [
'website_id',
'user_id',
'date',
'text',
'category',
];
protected function casts(): array
{
return [
'date' => 'date',
];
}
public function website(): BelongsTo
{
return $this->belongsTo(Website::class);
}
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
public static array $categories = [
'general' => 'General',
'deployment' => 'Deployment',
'campaign' => 'Campaign',
'incident' => 'Incident',
];
}
+55
View File
@@ -0,0 +1,55 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class DashboardWidget extends Model
{
use HasFactory;
protected $fillable = [
'website_id',
'title',
'widget_type',
'config',
'sort_order',
'size',
];
protected function casts(): array
{
return [
'config' => 'array',
'sort_order' => 'integer',
];
}
public function website(): BelongsTo
{
return $this->belongsTo(Website::class);
}
public static array $types = [
'metric' => 'Single Metric',
'chart' => 'Visitors Chart',
'top_pages' => 'Top Pages',
'top_sources' => 'Top Sources',
'events' => 'Events Overview',
'conversions' => 'Goal Conversions',
'new_vs_returning' => 'New vs Returning',
'browsers' => 'Browsers',
'countries' => 'Countries',
];
public static array $metrics = [
'page_views' => 'Page Views',
'visitors' => 'Unique Visitors',
'sessions' => 'Sessions',
'bounce_rate' => 'Bounce Rate',
'avg_duration' => 'Avg. Duration',
'avg_pages' => 'Pages / Session',
];
}
+32
View File
@@ -0,0 +1,32 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class Funnel extends Model
{
use HasFactory;
protected $fillable = [
'website_id',
'name',
'steps',
'is_active',
];
protected function casts(): array
{
return [
'steps' => 'array',
'is_active' => 'boolean',
];
}
public function website(): BelongsTo
{
return $this->belongsTo(Website::class);
}
}
+59
View File
@@ -0,0 +1,59 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class Segment extends Model
{
use HasFactory;
protected $fillable = [
'website_id',
'name',
'filters',
];
protected function casts(): array
{
return [
'filters' => 'array',
];
}
public function website(): BelongsTo
{
return $this->belongsTo(Website::class);
}
/**
* Get the visitor IDs that match this segment's filters.
*/
public function matchingVisitorIds(): \Illuminate\Support\Collection
{
$query = Visitor::where('website_id', $this->website_id);
foreach ($this->filters as $filter) {
$field = $filter['field'];
$operator = $filter['operator'] ?? '=';
$value = $filter['value'];
$allowedFields = ['country', 'region', 'city', 'browser', 'os', 'device_type', 'language', 'screen_resolution'];
if (! in_array($field, $allowedFields)) {
continue;
}
match ($operator) {
'=' => $query->where($field, $value),
'!=' => $query->where($field, '!=', $value),
'contains' => $query->where($field, 'like', "%{$value}%"),
'starts_with' => $query->where($field, 'like', "{$value}%"),
default => null,
};
}
return $query->pluck('id');
}
}
+2 -1
View File
@@ -8,11 +8,12 @@ use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable;
use Laravel\Sanctum\HasApiTokens;
class User extends Authenticatable
{
/** @use HasFactory<UserFactory> */
use HasFactory, Notifiable;
use HasApiTokens, HasFactory, Notifiable;
/**
* The attributes that are mass assignable.
+25
View File
@@ -73,6 +73,31 @@ class Website extends Model
return $this->hasMany(GoalConversion::class);
}
public function funnels(): HasMany
{
return $this->hasMany(Funnel::class);
}
public function segments(): HasMany
{
return $this->hasMany(Segment::class);
}
public function dashboardWidgets(): HasMany
{
return $this->hasMany(DashboardWidget::class);
}
public function alerts(): HasMany
{
return $this->hasMany(Alert::class);
}
public function annotations(): HasMany
{
return $this->hasMany(Annotation::class);
}
public function dailyAggregates(): HasMany
{
return $this->hasMany(DailyAggregate::class);
+6
View File
@@ -19,5 +19,11 @@ class AppServiceProvider extends ServiceProvider
RateLimiter::for('collect', function (Request $request) {
return Limit::perMinute(120)->by($request->ip());
});
RateLimiter::for('api', function (Request $request) {
$key = $request->user()?->id ?: $request->ip();
return Limit::perMinute(60)->by($key);
});
}
}
+44
View File
@@ -16,6 +16,8 @@ class AnalyticsQueryService
{
private readonly Carbon $todayStart;
private ?Collection $segmentVisitorIds = null;
public function __construct(
private readonly Website $website,
private readonly Carbon $from,
@@ -29,6 +31,27 @@ class AnalyticsQueryService
return new self($website, $from->startOfDay(), $to->endOfDay());
}
public function withSegment(?Collection $visitorIds): self
{
$this->segmentVisitorIds = $visitorIds;
return $this;
}
public function isSegmented(): bool
{
return $this->segmentVisitorIds !== null;
}
private function applySegmentFilter($query, string $column = 'visitor_id')
{
if ($this->segmentVisitorIds !== null) {
$query->whereIn($column, $this->segmentVisitorIds);
}
return $query;
}
/**
* Split the requested date range into two parts:
* - "aggregate range": completed days that can be served from aggregate tables
@@ -76,6 +99,13 @@ class AnalyticsQueryService
public function totalPageViews(): int
{
if ($this->isSegmented()) {
return $this->applySegmentFilter(
PageView::where('website_id', $this->website->id)
->whereBetween('viewed_at', [$this->from, $this->to])
)->count();
}
$total = 0;
[$aggFrom, $aggTo, $liveFrom, $liveTo] = $this->splitRange();
@@ -96,6 +126,13 @@ class AnalyticsQueryService
public function uniqueVisitors(): int
{
if ($this->isSegmented()) {
return $this->applySegmentFilter(
PageView::where('website_id', $this->website->id)
->whereBetween('viewed_at', [$this->from, $this->to])
)->distinct('visitor_id')->count('visitor_id');
}
$total = 0;
[$aggFrom, $aggTo, $liveFrom, $liveTo] = $this->splitRange();
@@ -117,6 +154,13 @@ class AnalyticsQueryService
public function totalSessions(): int
{
if ($this->isSegmented()) {
return $this->applySegmentFilter(
AnalyticsSession::where('website_id', $this->website->id)
->whereBetween('started_at', [$this->from, $this->to])
)->count();
}
$total = 0;
[$aggFrom, $aggTo, $liveFrom, $liveTo] = $this->splitRange();
+84
View File
@@ -0,0 +1,84 @@
<?php
namespace App\Services;
use App\Models\Event;
use App\Models\Funnel;
use App\Models\PageView;
use Illuminate\Support\Carbon;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
class FunnelAnalysisService
{
public function __construct(
private readonly Funnel $funnel,
private readonly Carbon $from,
private readonly Carbon $to,
) {}
/**
* Analyze funnel: for each step, count how many unique visitors completed it
* (in order, within the same session or across sessions in the date range).
*
* Returns: [{label, type, value, visitors, drop_off, drop_off_pct, conversion_pct}]
*/
public function analyze(): array
{
$steps = $this->funnel->steps;
if (empty($steps)) {
return [];
}
$websiteId = $this->funnel->website_id;
// Step 1: get all visitor IDs who completed the first step
$currentVisitorIds = $this->visitorIdsForStep($steps[0], $websiteId);
$firstStepCount = $currentVisitorIds->count();
$results = [];
foreach ($steps as $i => $step) {
if ($i === 0) {
$stepVisitors = $firstStepCount;
} else {
$stepVisitorIds = $this->visitorIdsForStep($step, $websiteId);
$currentVisitorIds = $currentVisitorIds->intersect($stepVisitorIds);
$stepVisitors = $currentVisitorIds->count();
}
$prevCount = $i > 0 ? $results[$i - 1]['visitors'] : $stepVisitors;
$dropOff = $prevCount - $stepVisitors;
$dropOffPct = $prevCount > 0 ? round(($dropOff / $prevCount) * 100, 1) : 0;
$conversionPct = $firstStepCount > 0 ? round(($stepVisitors / $firstStepCount) * 100, 1) : 0;
$results[] = [
'label' => $step['label'] ?? $step['value'],
'type' => $step['type'],
'value' => $step['value'],
'visitors' => $stepVisitors,
'drop_off' => $dropOff,
'drop_off_pct' => $dropOffPct,
'conversion_pct' => $conversionPct,
];
}
return $results;
}
private function visitorIdsForStep(array $step, int $websiteId): Collection
{
if ($step['type'] === 'pageview') {
return PageView::where('website_id', $websiteId)
->whereBetween('viewed_at', [$this->from, $this->to])
->where('path', $step['value'])
->distinct()
->pluck('visitor_id');
}
return Event::where('website_id', $websiteId)
->whereBetween('occurred_at', [$this->from, $this->to])
->where('name', $step['value'])
->distinct()
->pluck('visitor_id');
}
}