first commit from hostslim
This commit is contained in:
@@ -0,0 +1,43 @@
|
||||
<?php
|
||||
|
||||
namespace App\Actions\Fortify;
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Support\Facades\Validator;
|
||||
use Illuminate\Validation\Rule;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use Laravel\Fortify\Contracts\CreatesNewUsers;
|
||||
|
||||
class CreateNewUser implements CreatesNewUsers
|
||||
{
|
||||
use PasswordValidationRules;
|
||||
|
||||
/**
|
||||
* Validate and create a newly registered user.
|
||||
*
|
||||
* @param array<string, string> $input
|
||||
*
|
||||
* @throws ValidationException
|
||||
*/
|
||||
public function create(array $input): User
|
||||
{
|
||||
Validator::make($input, [
|
||||
'name' => ['required', 'string', 'max:255'],
|
||||
'email' => [
|
||||
'required',
|
||||
'string',
|
||||
'email',
|
||||
'max:255',
|
||||
Rule::unique(User::class),
|
||||
],
|
||||
'password' => $this->passwordRules(),
|
||||
])->validate();
|
||||
|
||||
return User::create([
|
||||
'name' => $input['name'],
|
||||
'email' => $input['email'],
|
||||
'password' => Hash::make($input['password']),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
<?php
|
||||
|
||||
namespace App\Actions\Fortify;
|
||||
|
||||
use Illuminate\Contracts\Validation\Rule;
|
||||
use Illuminate\Validation\Rules\Password;
|
||||
|
||||
trait PasswordValidationRules
|
||||
{
|
||||
/**
|
||||
* Get the validation rules used to validate passwords.
|
||||
*
|
||||
* @return array<int, Rule|array<mixed>|string>
|
||||
*/
|
||||
protected function passwordRules(): array
|
||||
{
|
||||
return ['required', 'string', Password::default(), 'confirmed'];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
|
||||
namespace App\Actions\Fortify;
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Support\Facades\Validator;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use Laravel\Fortify\Contracts\ResetsUserPasswords;
|
||||
|
||||
class ResetUserPassword implements ResetsUserPasswords
|
||||
{
|
||||
use PasswordValidationRules;
|
||||
|
||||
/**
|
||||
* Validate and reset the user's forgotten password.
|
||||
*
|
||||
* @param array<string, string> $input
|
||||
*
|
||||
* @throws ValidationException
|
||||
*/
|
||||
public function reset(User $user, array $input): void
|
||||
{
|
||||
Validator::make($input, [
|
||||
'password' => $this->passwordRules(),
|
||||
])->validate();
|
||||
|
||||
$user->forceFill([
|
||||
'password' => Hash::make($input['password']),
|
||||
])->save();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
<?php
|
||||
|
||||
namespace App\Actions\Fortify;
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Support\Facades\Validator;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use Laravel\Fortify\Contracts\UpdatesUserPasswords;
|
||||
|
||||
class UpdateUserPassword implements UpdatesUserPasswords
|
||||
{
|
||||
use PasswordValidationRules;
|
||||
|
||||
/**
|
||||
* Validate and update the user's password.
|
||||
*
|
||||
* @param array<string, string> $input
|
||||
*
|
||||
* @throws ValidationException
|
||||
*/
|
||||
public function update(User $user, array $input): void
|
||||
{
|
||||
Validator::make($input, [
|
||||
'current_password' => ['required', 'string', 'current_password:web'],
|
||||
'password' => $this->passwordRules(),
|
||||
], [
|
||||
'current_password.current_password' => __('The provided password does not match your current password.'),
|
||||
])->validateWithBag('updatePassword');
|
||||
|
||||
$user->forceFill([
|
||||
'password' => Hash::make($input['password']),
|
||||
])->save();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
<?php
|
||||
|
||||
namespace App\Actions\Fortify;
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Contracts\Auth\MustVerifyEmail;
|
||||
use Illuminate\Support\Facades\Validator;
|
||||
use Illuminate\Validation\Rule;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use Laravel\Fortify\Contracts\UpdatesUserProfileInformation;
|
||||
|
||||
class UpdateUserProfileInformation implements UpdatesUserProfileInformation
|
||||
{
|
||||
/**
|
||||
* Validate and update the given user's profile information.
|
||||
*
|
||||
* @param array<string, string> $input
|
||||
*
|
||||
* @throws ValidationException
|
||||
*/
|
||||
public function update(User $user, array $input): void
|
||||
{
|
||||
Validator::make($input, [
|
||||
'name' => ['required', 'string', 'max:255'],
|
||||
|
||||
'email' => [
|
||||
'required',
|
||||
'string',
|
||||
'email',
|
||||
'max:255',
|
||||
Rule::unique('users')->ignore($user->id),
|
||||
],
|
||||
])->validateWithBag('updateProfileInformation');
|
||||
|
||||
if ($input['email'] !== $user->email &&
|
||||
$user instanceof MustVerifyEmail) {
|
||||
$this->updateVerifiedUser($user, $input);
|
||||
} else {
|
||||
$user->forceFill([
|
||||
'name' => $input['name'],
|
||||
'email' => $input['email'],
|
||||
])->save();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the given verified user's profile information.
|
||||
*
|
||||
* @param array<string, string> $input
|
||||
*/
|
||||
protected function updateVerifiedUser(User $user, array $input): void
|
||||
{
|
||||
$user->forceFill([
|
||||
'name' => $input['name'],
|
||||
'email' => $input['email'],
|
||||
'email_verified_at' => null,
|
||||
])->save();
|
||||
|
||||
$user->sendEmailVerificationNotification();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\AnalyticsSession;
|
||||
use App\Models\DailyAggregate;
|
||||
use App\Models\PageDailyAggregate;
|
||||
use App\Models\PageView;
|
||||
use App\Models\Website;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class AggregateAnalytics extends Command
|
||||
{
|
||||
protected $signature = 'analytics:aggregate
|
||||
{--date= : The date to aggregate (YYYY-MM-DD). Defaults to yesterday.}
|
||||
{--website= : Aggregate only a specific website ID.}';
|
||||
|
||||
protected $description = 'Roll up raw analytics data into daily aggregate tables';
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$date = $this->option('date')
|
||||
? Carbon::parse($this->option('date'))
|
||||
: Carbon::yesterday();
|
||||
|
||||
if ($date->isToday()) {
|
||||
$this->error('Cannot aggregate today\'s data — it is still being collected.');
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$websites = $this->option('website')
|
||||
? Website::where('id', $this->option('website'))->get()
|
||||
: Website::all();
|
||||
|
||||
if ($websites->isEmpty()) {
|
||||
$this->warn('No websites found.');
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
$this->info("Aggregating data for {$date->toDateString()} across {$websites->count()} website(s)...");
|
||||
|
||||
$bar = $this->output->createProgressBar($websites->count());
|
||||
$bar->start();
|
||||
|
||||
foreach ($websites as $website) {
|
||||
$this->aggregateForWebsite($website, $date);
|
||||
$bar->advance();
|
||||
}
|
||||
|
||||
$bar->finish();
|
||||
$this->newLine();
|
||||
$this->info('Aggregation complete.');
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
private function aggregateForWebsite(Website $website, Carbon $date): void
|
||||
{
|
||||
$dayStart = $date->copy()->startOfDay();
|
||||
$dayEnd = $date->copy()->endOfDay();
|
||||
|
||||
DB::transaction(function () use ($website, $date, $dayStart, $dayEnd) {
|
||||
$this->aggregateDaily($website, $date, $dayStart, $dayEnd);
|
||||
$this->aggregatePageDaily($website, $date, $dayStart, $dayEnd);
|
||||
});
|
||||
}
|
||||
|
||||
private function aggregateDaily(Website $website, Carbon $date, Carbon $dayStart, Carbon $dayEnd): void
|
||||
{
|
||||
$visitors = PageView::where('website_id', $website->id)
|
||||
->whereBetween('viewed_at', [$dayStart, $dayEnd])
|
||||
->distinct('visitor_id')
|
||||
->count('visitor_id');
|
||||
|
||||
$sessions = AnalyticsSession::where('website_id', $website->id)
|
||||
->whereBetween('started_at', [$dayStart, $dayEnd])
|
||||
->count();
|
||||
|
||||
$pageViews = PageView::where('website_id', $website->id)
|
||||
->whereBetween('viewed_at', [$dayStart, $dayEnd])
|
||||
->count();
|
||||
|
||||
$bounceCount = AnalyticsSession::where('website_id', $website->id)
|
||||
->whereBetween('started_at', [$dayStart, $dayEnd])
|
||||
->where('is_bounce', true)
|
||||
->count();
|
||||
|
||||
$totalDuration = (int) AnalyticsSession::where('website_id', $website->id)
|
||||
->whereBetween('started_at', [$dayStart, $dayEnd])
|
||||
->sum('duration');
|
||||
|
||||
DailyAggregate::updateOrCreate(
|
||||
['website_id' => $website->id, 'date' => $date->toDateString()],
|
||||
compact('visitors', 'sessions', 'page_views', 'bounce_count', 'total_duration'),
|
||||
);
|
||||
}
|
||||
|
||||
private function aggregatePageDaily(Website $website, Carbon $date, Carbon $dayStart, Carbon $dayEnd): void
|
||||
{
|
||||
$pages = PageView::where('website_id', $website->id)
|
||||
->whereBetween('viewed_at', [$dayStart, $dayEnd])
|
||||
->select(
|
||||
'path',
|
||||
DB::raw('COUNT(*) as views'),
|
||||
DB::raw('COUNT(DISTINCT visitor_id) as unique_views'),
|
||||
DB::raw('COALESCE(SUM(time_on_page), 0) as total_time_on_page'),
|
||||
)
|
||||
->groupBy('path')
|
||||
->get();
|
||||
|
||||
$bouncePaths = AnalyticsSession::where('website_id', $website->id)
|
||||
->whereBetween('started_at', [$dayStart, $dayEnd])
|
||||
->where('is_bounce', true)
|
||||
->whereNotNull('entry_page')
|
||||
->select('entry_page as path', DB::raw('COUNT(*) as bounces'))
|
||||
->groupBy('entry_page')
|
||||
->pluck('bounces', 'path');
|
||||
|
||||
PageDailyAggregate::where('website_id', $website->id)
|
||||
->where('date', $date->toDateString())
|
||||
->delete();
|
||||
|
||||
$rows = $pages->map(fn ($page) => [
|
||||
'website_id' => $website->id,
|
||||
'date' => $date->toDateString(),
|
||||
'path' => $page->path,
|
||||
'views' => $page->views,
|
||||
'unique_views' => $page->unique_views,
|
||||
'total_time_on_page' => $page->total_time_on_page,
|
||||
'bounces' => $bouncePaths->get($page->path, 0),
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
])->toArray();
|
||||
|
||||
foreach (array_chunk($rows, 500) as $chunk) {
|
||||
PageDailyAggregate::insert($chunk);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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}");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\AnalyticsSession;
|
||||
use App\Models\Event;
|
||||
use App\Models\PageView;
|
||||
use App\Models\Website;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Carbon;
|
||||
|
||||
class PurgeAnalytics extends Command
|
||||
{
|
||||
protected $signature = 'analytics:purge
|
||||
{--website= : Purge only a specific website ID.}
|
||||
{--force : Skip confirmation prompt.}';
|
||||
|
||||
protected $description = 'Delete raw analytics data older than each website\'s retention period';
|
||||
|
||||
private const DEFAULT_RETENTION_DAYS = 365;
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$websites = $this->option('website')
|
||||
? Website::where('id', $this->option('website'))->get()
|
||||
: Website::all();
|
||||
|
||||
if ($websites->isEmpty()) {
|
||||
$this->warn('No websites found.');
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
$summary = $websites->map(fn (Website $w) => [
|
||||
'id' => $w->id,
|
||||
'name' => $w->name,
|
||||
'retention' => $this->getRetentionDays($w) . ' days',
|
||||
'cutoff' => Carbon::today()->subDays($this->getRetentionDays($w))->toDateString(),
|
||||
])->toArray();
|
||||
|
||||
$this->table(['ID', 'Website', 'Retention', 'Delete before'], $summary);
|
||||
|
||||
if (! $this->option('force') && ! $this->confirm('Proceed with purging old raw data?')) {
|
||||
$this->info('Cancelled.');
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
foreach ($websites as $website) {
|
||||
$this->purgeForWebsite($website);
|
||||
}
|
||||
|
||||
$this->info('Purge complete.');
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
private function purgeForWebsite(Website $website): void
|
||||
{
|
||||
$retentionDays = $this->getRetentionDays($website);
|
||||
$cutoff = Carbon::today()->subDays($retentionDays);
|
||||
|
||||
$this->info("Purging {$website->name} (data before {$cutoff->toDateString()})...");
|
||||
|
||||
$pvDeleted = PageView::where('website_id', $website->id)
|
||||
->where('viewed_at', '<', $cutoff)
|
||||
->delete();
|
||||
|
||||
$evDeleted = Event::where('website_id', $website->id)
|
||||
->where('occurred_at', '<', $cutoff)
|
||||
->delete();
|
||||
|
||||
$sessDeleted = AnalyticsSession::where('website_id', $website->id)
|
||||
->where('started_at', '<', $cutoff)
|
||||
->delete();
|
||||
|
||||
$this->line(" Deleted: {$pvDeleted} page views, {$evDeleted} events, {$sessDeleted} sessions");
|
||||
}
|
||||
|
||||
private function getRetentionDays(Website $website): int
|
||||
{
|
||||
return $website->settings['data_retention_days'] ?? self::DEFAULT_RETENTION_DAYS;
|
||||
}
|
||||
}
|
||||
@@ -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.');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Auth;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\Auth\LoginRequest;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class AuthenticatedSessionController extends Controller
|
||||
{
|
||||
/**
|
||||
* Display the login view.
|
||||
*/
|
||||
public function create(): View
|
||||
{
|
||||
return view('auth.login');
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle an incoming authentication request.
|
||||
*/
|
||||
public function store(LoginRequest $request): RedirectResponse
|
||||
{
|
||||
$request->authenticate();
|
||||
|
||||
$request->session()->regenerate();
|
||||
|
||||
return redirect()->intended(route('dashboard', absolute: false));
|
||||
}
|
||||
|
||||
/**
|
||||
* Destroy an authenticated session.
|
||||
*/
|
||||
public function destroy(Request $request): RedirectResponse
|
||||
{
|
||||
Auth::guard('web')->logout();
|
||||
|
||||
$request->session()->invalidate();
|
||||
|
||||
$request->session()->regenerateToken();
|
||||
|
||||
return redirect('/');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Auth;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class ConfirmablePasswordController extends Controller
|
||||
{
|
||||
/**
|
||||
* Show the confirm password view.
|
||||
*/
|
||||
public function show(): View
|
||||
{
|
||||
return view('auth.confirm-password');
|
||||
}
|
||||
|
||||
/**
|
||||
* Confirm the user's password.
|
||||
*/
|
||||
public function store(Request $request): RedirectResponse
|
||||
{
|
||||
if (! Auth::guard('web')->validate([
|
||||
'email' => $request->user()->email,
|
||||
'password' => $request->password,
|
||||
])) {
|
||||
throw ValidationException::withMessages([
|
||||
'password' => __('auth.password'),
|
||||
]);
|
||||
}
|
||||
|
||||
$request->session()->put('auth.password_confirmed_at', time());
|
||||
|
||||
return redirect()->intended(route('dashboard', absolute: false));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Auth;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class EmailVerificationNotificationController extends Controller
|
||||
{
|
||||
/**
|
||||
* Send a new email verification notification.
|
||||
*/
|
||||
public function store(Request $request): RedirectResponse
|
||||
{
|
||||
if ($request->user()->hasVerifiedEmail()) {
|
||||
return redirect()->intended(route('dashboard', absolute: false));
|
||||
}
|
||||
|
||||
$request->user()->sendEmailVerificationNotification();
|
||||
|
||||
return back()->with('status', 'verification-link-sent');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Auth;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class EmailVerificationPromptController extends Controller
|
||||
{
|
||||
/**
|
||||
* Display the email verification prompt.
|
||||
*/
|
||||
public function __invoke(Request $request): RedirectResponse|View
|
||||
{
|
||||
return $request->user()->hasVerifiedEmail()
|
||||
? redirect()->intended(route('dashboard', absolute: false))
|
||||
: view('auth.verify-email');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Auth;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\User;
|
||||
use Illuminate\Auth\Events\PasswordReset;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Support\Facades\Password;
|
||||
use Illuminate\Support\Str;
|
||||
use Illuminate\Validation\Rules;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class NewPasswordController extends Controller
|
||||
{
|
||||
/**
|
||||
* Display the password reset view.
|
||||
*/
|
||||
public function create(Request $request): View
|
||||
{
|
||||
return view('auth.reset-password', ['request' => $request]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle an incoming new password request.
|
||||
*
|
||||
* @throws ValidationException
|
||||
*/
|
||||
public function store(Request $request): RedirectResponse
|
||||
{
|
||||
$request->validate([
|
||||
'token' => ['required'],
|
||||
'email' => ['required', 'email'],
|
||||
'password' => ['required', 'confirmed', Rules\Password::defaults()],
|
||||
]);
|
||||
|
||||
// Here we will attempt to reset the user's password. If it is successful we
|
||||
// will update the password on an actual user model and persist it to the
|
||||
// database. Otherwise we will parse the error and return the response.
|
||||
$status = Password::reset(
|
||||
$request->only('email', 'password', 'password_confirmation', 'token'),
|
||||
function (User $user) use ($request) {
|
||||
$user->forceFill([
|
||||
'password' => Hash::make($request->password),
|
||||
'remember_token' => Str::random(60),
|
||||
])->save();
|
||||
|
||||
event(new PasswordReset($user));
|
||||
}
|
||||
);
|
||||
|
||||
// If the password was successfully reset, we will redirect the user back to
|
||||
// the application's home authenticated view. If there is an error we can
|
||||
// redirect them back to where they came from with their error message.
|
||||
return $status == Password::PASSWORD_RESET
|
||||
? redirect()->route('login')->with('status', __($status))
|
||||
: back()->withInput($request->only('email'))
|
||||
->withErrors(['email' => __($status)]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Auth;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Validation\Rules\Password;
|
||||
|
||||
class PasswordController extends Controller
|
||||
{
|
||||
/**
|
||||
* Update the user's password.
|
||||
*/
|
||||
public function update(Request $request): RedirectResponse
|
||||
{
|
||||
$validated = $request->validateWithBag('updatePassword', [
|
||||
'current_password' => ['required', 'current_password'],
|
||||
'password' => ['required', Password::defaults(), 'confirmed'],
|
||||
]);
|
||||
|
||||
$request->user()->update([
|
||||
'password' => Hash::make($validated['password']),
|
||||
]);
|
||||
|
||||
return back()->with('status', 'password-updated');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Auth;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Password;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class PasswordResetLinkController extends Controller
|
||||
{
|
||||
/**
|
||||
* Display the password reset link request view.
|
||||
*/
|
||||
public function create(): View
|
||||
{
|
||||
return view('auth.forgot-password');
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle an incoming password reset link request.
|
||||
*
|
||||
* @throws ValidationException
|
||||
*/
|
||||
public function store(Request $request): RedirectResponse
|
||||
{
|
||||
$request->validate([
|
||||
'email' => ['required', 'email'],
|
||||
]);
|
||||
|
||||
// We will send the password reset link to this user. Once we have attempted
|
||||
// to send the link, we will examine the response then see the message we
|
||||
// need to show to the user. Finally, we'll send out a proper response.
|
||||
$status = Password::sendResetLink(
|
||||
$request->only('email')
|
||||
);
|
||||
|
||||
return $status == Password::RESET_LINK_SENT
|
||||
? back()->with('status', __($status))
|
||||
: back()->withInput($request->only('email'))
|
||||
->withErrors(['email' => __($status)]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Auth;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\User;
|
||||
use Illuminate\Auth\Events\Registered;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Validation\Rules;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class RegisteredUserController extends Controller
|
||||
{
|
||||
/**
|
||||
* Display the registration view.
|
||||
*/
|
||||
public function create(): View
|
||||
{
|
||||
return view('auth.register');
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle an incoming registration request.
|
||||
*
|
||||
* @throws ValidationException
|
||||
*/
|
||||
public function store(Request $request): RedirectResponse
|
||||
{
|
||||
$request->validate([
|
||||
'name' => ['required', 'string', 'max:255'],
|
||||
'email' => ['required', 'string', 'lowercase', 'email', 'max:255', 'unique:'.User::class],
|
||||
'password' => ['required', 'confirmed', Rules\Password::defaults()],
|
||||
]);
|
||||
|
||||
$user = User::create([
|
||||
'name' => $request->name,
|
||||
'email' => $request->email,
|
||||
'password' => Hash::make($request->password),
|
||||
]);
|
||||
|
||||
event(new Registered($user));
|
||||
|
||||
Auth::login($user);
|
||||
|
||||
return redirect(route('dashboard', absolute: false));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Auth;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Auth\Events\Verified;
|
||||
use Illuminate\Foundation\Auth\EmailVerificationRequest;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
|
||||
class VerifyEmailController extends Controller
|
||||
{
|
||||
/**
|
||||
* Mark the authenticated user's email address as verified.
|
||||
*/
|
||||
public function __invoke(EmailVerificationRequest $request): RedirectResponse
|
||||
{
|
||||
if ($request->user()->hasVerifiedEmail()) {
|
||||
return redirect()->intended(route('dashboard', absolute: false).'?verified=1');
|
||||
}
|
||||
|
||||
if ($request->user()->markEmailAsVerified()) {
|
||||
event(new Verified($request->user()));
|
||||
}
|
||||
|
||||
return redirect()->intended(route('dashboard', absolute: false).'?verified=1');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Jobs\ProcessTrackingPayload;
|
||||
use App\Models\Website;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Response;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Jaybizzle\CrawlerDetect\CrawlerDetect;
|
||||
|
||||
class CollectController extends Controller
|
||||
{
|
||||
public function store(Request $request): JsonResponse
|
||||
{
|
||||
if ($this->isBot($request)) {
|
||||
$this->log("ISBOT");
|
||||
return response()->json(['ok' => false, 'reason' => 'bot'], 200);
|
||||
}
|
||||
|
||||
$payload = $request->validate([
|
||||
'tid' => 'required|string',
|
||||
'vid' => 'required|string|max:64',
|
||||
'sid' => 'required|string|max:64',
|
||||
'type' => 'required|in:pageview,event',
|
||||
|
||||
'url' => 'nullable|string|max:2048',
|
||||
'path' => 'nullable|string|max:2048',
|
||||
'title' => 'nullable|string|max:512',
|
||||
'referrer' => 'nullable|string|max:2048',
|
||||
|
||||
'event_name' => 'nullable|string|max:255',
|
||||
'event_category' => 'nullable|string|max:255',
|
||||
'event_action' => 'nullable|string|max:255',
|
||||
'event_label' => 'nullable|string|max:255',
|
||||
'event_value' => 'nullable|numeric',
|
||||
'event_properties' => 'nullable|array',
|
||||
|
||||
'sr' => 'nullable|string|max:20',
|
||||
'lang' => 'nullable|string|max:10',
|
||||
'ua' => 'nullable|string|max:512',
|
||||
|
||||
'utm_source' => 'nullable|string|max:255',
|
||||
'utm_medium' => 'nullable|string|max:255',
|
||||
'utm_campaign' => 'nullable|string|max:255',
|
||||
'utm_term' => 'nullable|string|max:255',
|
||||
'utm_content' => 'nullable|string|max:255',
|
||||
|
||||
'ref_domain' => 'nullable|string|max:255',
|
||||
'device' => 'nullable|string|max:20',
|
||||
'browser' => 'nullable|string|max:50',
|
||||
'browser_version' => 'nullable|string|max:20',
|
||||
'os' => 'nullable|string|max:50',
|
||||
'os_version' => 'nullable|string|max:20',
|
||||
|
||||
'time_on_page' => 'nullable|integer|min:0',
|
||||
'is_new_session' => 'nullable|boolean',
|
||||
]);
|
||||
|
||||
if (! $this->isDomainValid($payload)) {
|
||||
$this->log("Domain is invalid");
|
||||
return response()->json(['ok' => false, 'reason' => 'domain_mismatch'], 400);
|
||||
}
|
||||
$this->log("dispatch job");
|
||||
ProcessTrackingPayload::dispatch($payload, $request->ip(), Carbon::now());
|
||||
|
||||
return response()->json(['ok' => true]);
|
||||
}
|
||||
|
||||
private function log($message)
|
||||
{
|
||||
$path = '/home/libremet/domains/libremetrics.com/engine/storage/logs/mihai.log';
|
||||
|
||||
$message = date('Y-m-d H:i:s') . "\n" . $message . "\n\n";
|
||||
|
||||
file_put_contents($path, $message);
|
||||
}
|
||||
|
||||
/**
|
||||
* 1x1 transparent GIF tracking pixel for no-JS fallback.
|
||||
* Usage: <img src="/api/pixel?tid=TA-XXX" width="1" height="1" alt="">
|
||||
*/
|
||||
public function pixel(Request $request): Response
|
||||
{
|
||||
if ($this->isBot($request)) {
|
||||
return $this->gifResponse();
|
||||
}
|
||||
|
||||
$tid = $request->get('tid');
|
||||
|
||||
if ($tid) {
|
||||
$payload = [
|
||||
'tid' => $tid,
|
||||
'vid' => $request->get('vid', md5($request->ip() . $request->userAgent())),
|
||||
'sid' => $request->get('sid', md5($request->ip() . $request->userAgent() . date('Y-m-d-H'))),
|
||||
'type' => 'pageview',
|
||||
'url' => $request->get('url', $request->header('Referer')),
|
||||
'path' => $request->get('path', parse_url($request->header('Referer', '/'), PHP_URL_PATH)),
|
||||
'referrer' => $request->header('Referer'),
|
||||
];
|
||||
|
||||
ProcessTrackingPayload::dispatch($payload, $request->ip(), Carbon::now());
|
||||
}
|
||||
|
||||
return $this->gifResponse();
|
||||
}
|
||||
|
||||
private function isBot(Request $request): bool
|
||||
{
|
||||
return (new CrawlerDetect)->isCrawler($request->userAgent());
|
||||
}
|
||||
|
||||
private function isDomainValid(array $payload): bool
|
||||
{
|
||||
$url = $payload['url'] ?? null;
|
||||
if (! $url) {
|
||||
return true;
|
||||
}
|
||||
|
||||
$host = parse_url($url, PHP_URL_HOST);
|
||||
if (! $host) {
|
||||
return true;
|
||||
}
|
||||
|
||||
$website = Website::where('tracking_id', $payload['tid'])->first();
|
||||
if (! $website) {
|
||||
return true; // let the job handle the 404
|
||||
}
|
||||
|
||||
$domain = $website->domain;
|
||||
|
||||
// Allow exact match or subdomain match (e.g. www.example.com matches example.com)
|
||||
return $host === $domain || str_ends_with($host, '.' . $domain);
|
||||
}
|
||||
|
||||
private function gifResponse(): Response
|
||||
{
|
||||
// 1x1 transparent GIF
|
||||
$gif = base64_decode('R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7');
|
||||
|
||||
return response($gif, 200, [
|
||||
'Content-Type' => 'image/gif',
|
||||
'Content-Length' => strlen($gif),
|
||||
'Cache-Control' => 'no-store, no-cache, must-revalidate, max-age=0',
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Concerns;
|
||||
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Carbon;
|
||||
|
||||
trait ParsesDateRange
|
||||
{
|
||||
private function parseDateRange(Request $request): array
|
||||
{
|
||||
$range = $request->get('range', '7d');
|
||||
$to = Carbon::today();
|
||||
|
||||
$from = match ($range) {
|
||||
'today' => Carbon::today(),
|
||||
'7d' => Carbon::today()->subDays(6),
|
||||
'30d' => Carbon::today()->subDays(29),
|
||||
'90d' => Carbon::today()->subDays(89),
|
||||
'custom' => Carbon::parse($request->get('from', Carbon::today()->subDays(6)->toDateString())),
|
||||
default => Carbon::today()->subDays(6),
|
||||
};
|
||||
|
||||
if ($range === 'custom' && $request->has('to')) {
|
||||
$to = Carbon::parse($request->get('to'));
|
||||
}
|
||||
|
||||
if ($from->gt($to)) {
|
||||
[$from, $to] = [$to, $from];
|
||||
}
|
||||
|
||||
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,10 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||
|
||||
abstract class Controller
|
||||
{
|
||||
use AuthorizesRequests;
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Http\Controllers\Concerns\ParsesDateRange;
|
||||
use App\Models\Website;
|
||||
use App\Services\AnalyticsQueryService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class DashboardController extends Controller
|
||||
{
|
||||
use ParsesDateRange;
|
||||
|
||||
public function show(Request $request, Website $website): View
|
||||
{
|
||||
$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', 'prevMetrics', 'chartData', 'activeVisitors',
|
||||
'goalConversions', 'goalChartData', 'from', 'to',
|
||||
'topPages', 'topSources',
|
||||
));
|
||||
}
|
||||
|
||||
public function realtime(Website $website): JsonResponse
|
||||
{
|
||||
$this->authorize('view', $website);
|
||||
|
||||
return response()->json([
|
||||
'active' => AnalyticsQueryService::activeVisitors($website),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -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']],
|
||||
]),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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,78 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Http\Requests\StoreGoalRequest;
|
||||
use App\Http\Requests\UpdateGoalRequest;
|
||||
use App\Models\Goal;
|
||||
use App\Models\Website;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class GoalController extends Controller
|
||||
{
|
||||
public function index(Website $website): View
|
||||
{
|
||||
$this->authorize('view', $website);
|
||||
|
||||
$goals = $website->goals()->latest()->get();
|
||||
|
||||
return view('goals.index', compact('website', 'goals'));
|
||||
}
|
||||
|
||||
public function create(Website $website): View
|
||||
{
|
||||
$this->authorize('view', $website);
|
||||
|
||||
return view('goals.create', compact('website'));
|
||||
}
|
||||
|
||||
public function store(StoreGoalRequest $request, Website $website): RedirectResponse
|
||||
{
|
||||
$this->authorize('view', $website);
|
||||
|
||||
$data = $request->validated();
|
||||
$data['is_active'] = $request->boolean('is_active', true);
|
||||
|
||||
$website->goals()->create($data);
|
||||
|
||||
return redirect()
|
||||
->route('goals.index', $website)
|
||||
->with('success', 'Goal created successfully.');
|
||||
}
|
||||
|
||||
public function edit(Website $website, Goal $goal): View
|
||||
{
|
||||
$this->authorize('view', $website);
|
||||
abort_unless($goal->website_id === $website->id, 404);
|
||||
|
||||
return view('goals.edit', compact('website', 'goal'));
|
||||
}
|
||||
|
||||
public function update(UpdateGoalRequest $request, Website $website, Goal $goal): RedirectResponse
|
||||
{
|
||||
$this->authorize('view', $website);
|
||||
abort_unless($goal->website_id === $website->id, 404);
|
||||
|
||||
$data = $request->validated();
|
||||
$data['is_active'] = $request->boolean('is_active', true);
|
||||
|
||||
$goal->update($data);
|
||||
|
||||
return redirect()
|
||||
->route('goals.index', $website)
|
||||
->with('success', 'Goal updated successfully.');
|
||||
}
|
||||
|
||||
public function destroy(Website $website, Goal $goal): RedirectResponse
|
||||
{
|
||||
$this->authorize('view', $website);
|
||||
abort_unless($goal->website_id === $website->id, 404);
|
||||
|
||||
$goal->delete();
|
||||
|
||||
return redirect()
|
||||
->route('goals.index', $website)
|
||||
->with('success', 'Goal deleted successfully.');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Http\Requests\ProfileUpdateRequest;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\Redirect;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class ProfileController extends Controller
|
||||
{
|
||||
/**
|
||||
* Display the user's profile form.
|
||||
*/
|
||||
public function edit(Request $request): View
|
||||
{
|
||||
return view('profile.edit', [
|
||||
'user' => $request->user(),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the user's profile information.
|
||||
*/
|
||||
public function update(ProfileUpdateRequest $request): RedirectResponse
|
||||
{
|
||||
$request->user()->fill($request->validated());
|
||||
|
||||
if ($request->user()->isDirty('email')) {
|
||||
$request->user()->email_verified_at = null;
|
||||
}
|
||||
|
||||
$request->user()->save();
|
||||
|
||||
return Redirect::route('profile.edit')->with('status', 'profile-updated');
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete the user's account.
|
||||
*/
|
||||
public function destroy(Request $request): RedirectResponse
|
||||
{
|
||||
$request->validateWithBag('userDeletion', [
|
||||
'password' => ['required', 'current_password'],
|
||||
]);
|
||||
|
||||
$user = $request->user();
|
||||
|
||||
Auth::logout();
|
||||
|
||||
$user->delete();
|
||||
|
||||
$request->session()->invalidate();
|
||||
$request->session()->regenerateToken();
|
||||
|
||||
return Redirect::to('/');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,214 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Http\Controllers\Concerns\ParsesDateRange;
|
||||
use App\Models\Website;
|
||||
use App\Services\AnalyticsQueryService;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class ReportController extends Controller
|
||||
{
|
||||
use ParsesDateRange;
|
||||
|
||||
// ---- Audience ----
|
||||
|
||||
public function geographic(Request $request, Website $website): View
|
||||
{
|
||||
$this->authorize('view', $website);
|
||||
[$from, $to] = $this->parseDateRange($request);
|
||||
$service = AnalyticsQueryService::for($website, $from, $to);
|
||||
|
||||
return view('reports.audience.geographic', [
|
||||
'website' => $website,
|
||||
'from' => $from,
|
||||
'to' => $to,
|
||||
'countries' => $service->visitorsByCountry(),
|
||||
'cities' => $service->visitorsByCity(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function technology(Request $request, Website $website): View
|
||||
{
|
||||
$this->authorize('view', $website);
|
||||
[$from, $to] = $this->parseDateRange($request);
|
||||
$service = AnalyticsQueryService::for($website, $from, $to);
|
||||
|
||||
return view('reports.audience.technology', [
|
||||
'website' => $website,
|
||||
'from' => $from,
|
||||
'to' => $to,
|
||||
'browsers' => $service->visitorsByBrowser(),
|
||||
'operatingSystems' => $service->visitorsByOS(),
|
||||
'devices' => $service->visitorsByDeviceType(),
|
||||
'resolutions' => $service->visitorsByScreenResolution(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function language(Request $request, Website $website): View
|
||||
{
|
||||
$this->authorize('view', $website);
|
||||
[$from, $to] = $this->parseDateRange($request);
|
||||
$service = AnalyticsQueryService::for($website, $from, $to);
|
||||
|
||||
return view('reports.audience.language', [
|
||||
'website' => $website,
|
||||
'from' => $from,
|
||||
'to' => $to,
|
||||
'languages' => $service->visitorsByLanguage(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function newVsReturning(Request $request, Website $website): View
|
||||
{
|
||||
$this->authorize('view', $website);
|
||||
[$from, $to] = $this->parseDateRange($request);
|
||||
$service = AnalyticsQueryService::for($website, $from, $to);
|
||||
|
||||
return view('reports.audience.new-vs-returning', [
|
||||
'website' => $website,
|
||||
'from' => $from,
|
||||
'to' => $to,
|
||||
'data' => $service->newVsReturning(),
|
||||
]);
|
||||
}
|
||||
|
||||
// ---- Acquisition ----
|
||||
|
||||
public function sources(Request $request, Website $website): View
|
||||
{
|
||||
$this->authorize('view', $website);
|
||||
[$from, $to] = $this->parseDateRange($request);
|
||||
$service = AnalyticsQueryService::for($website, $from, $to);
|
||||
|
||||
return view('reports.acquisition.sources', [
|
||||
'website' => $website,
|
||||
'from' => $from,
|
||||
'to' => $to,
|
||||
'sources' => $service->trafficSources(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function campaigns(Request $request, Website $website): View
|
||||
{
|
||||
$this->authorize('view', $website);
|
||||
[$from, $to] = $this->parseDateRange($request);
|
||||
$service = AnalyticsQueryService::for($website, $from, $to);
|
||||
|
||||
return view('reports.acquisition.campaigns', [
|
||||
'website' => $website,
|
||||
'from' => $from,
|
||||
'to' => $to,
|
||||
'campaigns' => $service->campaigns(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function referrers(Request $request, Website $website): View
|
||||
{
|
||||
$this->authorize('view', $website);
|
||||
[$from, $to] = $this->parseDateRange($request);
|
||||
$service = AnalyticsQueryService::for($website, $from, $to);
|
||||
|
||||
return view('reports.acquisition.referrers', [
|
||||
'website' => $website,
|
||||
'from' => $from,
|
||||
'to' => $to,
|
||||
'referrers' => $service->referrers(),
|
||||
]);
|
||||
}
|
||||
|
||||
// ---- Behavior ----
|
||||
|
||||
public function pages(Request $request, Website $website): View
|
||||
{
|
||||
$this->authorize('view', $website);
|
||||
[$from, $to] = $this->parseDateRange($request);
|
||||
$service = AnalyticsQueryService::for($website, $from, $to);
|
||||
|
||||
return view('reports.behavior.pages', [
|
||||
'website' => $website,
|
||||
'from' => $from,
|
||||
'to' => $to,
|
||||
'pages' => $service->topPages(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function landingPages(Request $request, Website $website): View
|
||||
{
|
||||
$this->authorize('view', $website);
|
||||
[$from, $to] = $this->parseDateRange($request);
|
||||
$service = AnalyticsQueryService::for($website, $from, $to);
|
||||
|
||||
return view('reports.behavior.landing-pages', [
|
||||
'website' => $website,
|
||||
'from' => $from,
|
||||
'to' => $to,
|
||||
'pages' => $service->landingPages(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function exitPages(Request $request, Website $website): View
|
||||
{
|
||||
$this->authorize('view', $website);
|
||||
[$from, $to] = $this->parseDateRange($request);
|
||||
$service = AnalyticsQueryService::for($website, $from, $to);
|
||||
|
||||
return view('reports.behavior.exit-pages', [
|
||||
'website' => $website,
|
||||
'from' => $from,
|
||||
'to' => $to,
|
||||
'pages' => $service->exitPages(),
|
||||
]);
|
||||
}
|
||||
|
||||
// ---- Events ----
|
||||
|
||||
public function events(Request $request, Website $website): View
|
||||
{
|
||||
$this->authorize('view', $website);
|
||||
[$from, $to] = $this->parseDateRange($request);
|
||||
$service = AnalyticsQueryService::for($website, $from, $to);
|
||||
|
||||
return view('reports.events.overview', [
|
||||
'website' => $website,
|
||||
'from' => $from,
|
||||
'to' => $to,
|
||||
'events' => $service->eventsOverview(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function eventDetail(Request $request, Website $website, string $eventName): View
|
||||
{
|
||||
$this->authorize('view', $website);
|
||||
[$from, $to] = $this->parseDateRange($request);
|
||||
$service = AnalyticsQueryService::for($website, $from, $to);
|
||||
|
||||
return view('reports.events.detail', [
|
||||
'website' => $website,
|
||||
'from' => $from,
|
||||
'to' => $to,
|
||||
'eventName' => $eventName,
|
||||
'details' => $service->eventDetail($eventName),
|
||||
'trending' => $service->eventTrending($eventName),
|
||||
]);
|
||||
}
|
||||
|
||||
// ---- Conversions ----
|
||||
|
||||
public function conversions(Request $request, Website $website): View
|
||||
{
|
||||
$this->authorize('view', $website);
|
||||
[$from, $to] = $this->parseDateRange($request);
|
||||
$service = AnalyticsQueryService::for($website, $from, $to);
|
||||
|
||||
return view('reports.conversions', [
|
||||
'website' => $website,
|
||||
'from' => $from,
|
||||
'to' => $to,
|
||||
'goals' => $service->conversionsReport(),
|
||||
'chartData' => $service->goalCompletionsOverTime(),
|
||||
'totalSessions' => $service->totalSessions(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -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,45 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\Website;
|
||||
use Illuminate\Http\Response;
|
||||
|
||||
class TrackingScriptController extends Controller
|
||||
{
|
||||
public function script(string $trackingId): Response
|
||||
{
|
||||
$this->log("Start ".$trackingId);
|
||||
$website = Website::where('tracking_id', $trackingId)
|
||||
->where('is_active', true)
|
||||
->first();
|
||||
|
||||
if (! $website) {
|
||||
$this->log("no website ".$trackingId);
|
||||
return response('', 404)
|
||||
->header('Content-Type', 'application/javascript');
|
||||
}
|
||||
|
||||
$collectUrl = route('collect.store');
|
||||
|
||||
$js = view('tracking.tracker', [
|
||||
'trackingId' => $trackingId,
|
||||
'collectUrl' => $collectUrl,
|
||||
])->render();
|
||||
|
||||
$this->log("response ".$trackingId);
|
||||
|
||||
return response($js)
|
||||
->header('Content-Type', 'application/javascript')
|
||||
->header('Cache-Control', 'public, max-age=3600');
|
||||
}
|
||||
|
||||
private function log($message)
|
||||
{
|
||||
$path = '/home/libremet/domains/libremetrics.com/engine/storage/logs/mihai.log';
|
||||
|
||||
$message = date('Y-m-d H:i:s') . "\n" . $message . "\n\n";
|
||||
|
||||
file_put_contents($path, $message);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Http\Requests\StoreWebsiteRequest;
|
||||
use App\Http\Requests\UpdateWebsiteRequest;
|
||||
use App\Models\Website;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class WebsiteController extends Controller
|
||||
{
|
||||
public function index(): View
|
||||
{
|
||||
$websites = auth()->user()->websites()->latest()->get();
|
||||
|
||||
return view('websites.index', compact('websites'));
|
||||
}
|
||||
|
||||
public function create(): View
|
||||
{
|
||||
return view('websites.create');
|
||||
}
|
||||
|
||||
public function store(StoreWebsiteRequest $request): RedirectResponse
|
||||
{
|
||||
$request->user()->websites()->create($request->validated());
|
||||
|
||||
return redirect()
|
||||
->route('websites.index')
|
||||
->with('success', 'Website added successfully.');
|
||||
}
|
||||
|
||||
public function show(Website $website): RedirectResponse
|
||||
{
|
||||
$this->authorize('view', $website);
|
||||
|
||||
return redirect()->route('dashboard.show', $website);
|
||||
}
|
||||
|
||||
public function edit(Website $website): View
|
||||
{
|
||||
$this->authorize('update', $website);
|
||||
|
||||
return view('websites.edit', compact('website'));
|
||||
}
|
||||
|
||||
public function update(UpdateWebsiteRequest $request, Website $website): RedirectResponse
|
||||
{
|
||||
$this->authorize('update', $website);
|
||||
|
||||
$data = $request->validated();
|
||||
$data['is_active'] = $request->boolean('is_active');
|
||||
$website->update($data);
|
||||
|
||||
return redirect()
|
||||
->route('dashboard.show', $website)
|
||||
->with('success', 'Website updated successfully.');
|
||||
}
|
||||
|
||||
public function destroy(Website $website): RedirectResponse
|
||||
{
|
||||
$this->authorize('delete', $website);
|
||||
|
||||
$website->delete();
|
||||
|
||||
return redirect()
|
||||
->route('websites.index')
|
||||
->with('success', 'Website deleted successfully.');
|
||||
}
|
||||
|
||||
public function snippet(Website $website): View
|
||||
{
|
||||
$this->authorize('view', $website);
|
||||
|
||||
return view('websites.snippet', compact('website'));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use Closure;
|
||||
use Illuminate\Http\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class CollectCors
|
||||
{
|
||||
public function handle(Request $request, Closure $next): Response
|
||||
{
|
||||
if ($request->isMethod('OPTIONS')) {
|
||||
return response('', 204, $this->corsHeaders($request));
|
||||
}
|
||||
|
||||
$response = $next($request);
|
||||
|
||||
foreach ($this->corsHeaders($request) as $key => $value) {
|
||||
$response->headers->set($key, $value);
|
||||
}
|
||||
|
||||
return $response;
|
||||
}
|
||||
|
||||
private function corsHeaders(Request $request): array
|
||||
{
|
||||
// Get the origin from the request, default to '*' if not present
|
||||
$origin = $request->header('Origin', '*');
|
||||
|
||||
return [
|
||||
'Access-Control-Allow-Origin' => $origin,
|
||||
'Access-Control-Allow-Methods' => 'GET, POST, OPTIONS',
|
||||
'Access-Control-Allow-Headers' => 'Content-Type, X-Requested-With',
|
||||
'Access-Control-Max-Age' => '86400',
|
||||
'Access-Control-Allow-Credentials' => 'true',
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests\Auth;
|
||||
|
||||
use Illuminate\Auth\Events\Lockout;
|
||||
use Illuminate\Contracts\Validation\ValidationRule;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\RateLimiter;
|
||||
use Illuminate\Support\Str;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
|
||||
class LoginRequest extends FormRequest
|
||||
{
|
||||
/**
|
||||
* Determine if the user is authorized to make this request.
|
||||
*/
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the validation rules that apply to the request.
|
||||
*
|
||||
* @return array<string, ValidationRule|array<mixed>|string>
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'email' => ['required', 'string', 'email'],
|
||||
'password' => ['required', 'string'],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Attempt to authenticate the request's credentials.
|
||||
*
|
||||
* @throws ValidationException
|
||||
*/
|
||||
public function authenticate(): void
|
||||
{
|
||||
$this->ensureIsNotRateLimited();
|
||||
|
||||
if (! Auth::attempt($this->only('email', 'password'), $this->boolean('remember'))) {
|
||||
RateLimiter::hit($this->throttleKey());
|
||||
|
||||
throw ValidationException::withMessages([
|
||||
'email' => trans('auth.failed'),
|
||||
]);
|
||||
}
|
||||
|
||||
RateLimiter::clear($this->throttleKey());
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure the login request is not rate limited.
|
||||
*
|
||||
* @throws ValidationException
|
||||
*/
|
||||
public function ensureIsNotRateLimited(): void
|
||||
{
|
||||
if (! RateLimiter::tooManyAttempts($this->throttleKey(), 5)) {
|
||||
return;
|
||||
}
|
||||
|
||||
event(new Lockout($this));
|
||||
|
||||
$seconds = RateLimiter::availableIn($this->throttleKey());
|
||||
|
||||
throw ValidationException::withMessages([
|
||||
'email' => trans('auth.throttle', [
|
||||
'seconds' => $seconds,
|
||||
'minutes' => ceil($seconds / 60),
|
||||
]),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the rate limiting throttle key for the request.
|
||||
*/
|
||||
public function throttleKey(): string
|
||||
{
|
||||
return Str::transliterate(Str::lower($this->string('email')).'|'.$this->ip());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests;
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Contracts\Validation\ValidationRule;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
class ProfileUpdateRequest extends FormRequest
|
||||
{
|
||||
/**
|
||||
* Get the validation rules that apply to the request.
|
||||
*
|
||||
* @return array<string, ValidationRule|array<mixed>|string>
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'name' => ['required', 'string', 'max:255'],
|
||||
'email' => [
|
||||
'required',
|
||||
'string',
|
||||
'lowercase',
|
||||
'email',
|
||||
'max:255',
|
||||
Rule::unique(User::class)->ignore($this->user()->id),
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
class StoreGoalRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'name' => ['required', 'string', 'max:255'],
|
||||
'type' => ['required', Rule::in(['url_destination', 'event_match', 'duration', 'pages_per_session'])],
|
||||
'default_value' => ['nullable', 'numeric', 'min:0'],
|
||||
'is_active' => ['sometimes', 'boolean'],
|
||||
|
||||
'conditions.path' => ['required_if:type,url_destination', 'nullable', 'string', 'max:2048'],
|
||||
'conditions.match_type' => ['required_if:type,url_destination', 'nullable', Rule::in(['exact', 'starts_with', 'contains', 'regex'])],
|
||||
|
||||
'conditions.event_name' => ['required_if:type,event_match', 'nullable', 'string', 'max:255'],
|
||||
'conditions.event_category' => ['nullable', 'string', 'max:255'],
|
||||
|
||||
'conditions.seconds' => ['required_if:type,duration', 'nullable', 'integer', 'min:1'],
|
||||
|
||||
'conditions.page_count' => ['required_if:type,pages_per_session', 'nullable', 'integer', 'min:1'],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class StoreWebsiteRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'name' => ['required', 'string', 'max:255'],
|
||||
'domain' => ['required', 'string', 'max:255', 'regex:/^([a-zA-Z0-9-]+\.)+[a-zA-Z]{2,}$/'],
|
||||
];
|
||||
}
|
||||
|
||||
public function messages(): array
|
||||
{
|
||||
return [
|
||||
'domain.regex' => 'Please enter a valid domain (e.g. example.com).',
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
class UpdateGoalRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'name' => ['required', 'string', 'max:255'],
|
||||
'type' => ['required', Rule::in(['url_destination', 'event_match', 'duration', 'pages_per_session'])],
|
||||
'default_value' => ['nullable', 'numeric', 'min:0'],
|
||||
'is_active' => ['sometimes', 'boolean'],
|
||||
|
||||
'conditions.path' => ['required_if:type,url_destination', 'nullable', 'string', 'max:2048'],
|
||||
'conditions.match_type' => ['required_if:type,url_destination', 'nullable', Rule::in(['exact', 'starts_with', 'contains', 'regex'])],
|
||||
|
||||
'conditions.event_name' => ['required_if:type,event_match', 'nullable', 'string', 'max:255'],
|
||||
'conditions.event_category' => ['nullable', 'string', 'max:255'],
|
||||
|
||||
'conditions.seconds' => ['required_if:type,duration', 'nullable', 'integer', 'min:1'],
|
||||
|
||||
'conditions.page_count' => ['required_if:type,pages_per_session', 'nullable', 'integer', 'min:1'],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class UpdateWebsiteRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'name' => ['required', 'string', 'max:255'],
|
||||
'domain' => ['required', 'string', 'max:255', 'regex:/^([a-zA-Z0-9-]+\.)+[a-zA-Z]{2,}$/'],
|
||||
'is_active' => ['sometimes', 'boolean'],
|
||||
];
|
||||
}
|
||||
|
||||
public function messages(): array
|
||||
{
|
||||
return [
|
||||
'domain.regex' => 'Please enter a valid domain (e.g. example.com).',
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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'],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
<?php
|
||||
|
||||
namespace App\Jobs;
|
||||
|
||||
use App\Models\AnalyticsSession;
|
||||
use App\Models\Event;
|
||||
use App\Models\PageView;
|
||||
use App\Models\Visitor;
|
||||
use App\Models\Website;
|
||||
use App\Services\GoalEvaluationService;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Bus\Dispatchable;
|
||||
use Illuminate\Queue\InteractsWithQueue;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Stevebauman\Location\Facades\Location;
|
||||
|
||||
class ProcessTrackingPayload implements ShouldQueue
|
||||
{
|
||||
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||
|
||||
public int $tries = 3;
|
||||
|
||||
public function __construct(
|
||||
private readonly array $payload,
|
||||
private readonly string $ip,
|
||||
private readonly Carbon $receivedAt,
|
||||
) {}
|
||||
|
||||
public function handle(GoalEvaluationService $goalService): void
|
||||
{
|
||||
$website = Website::where('tracking_id', $this->payload['tid'])
|
||||
->where('is_active', true)
|
||||
->first();
|
||||
|
||||
if (! $website) {
|
||||
return;
|
||||
}
|
||||
|
||||
$now = $this->receivedAt;
|
||||
|
||||
$visitor = Visitor::updateOrCreate(
|
||||
[
|
||||
'website_id' => $website->id,
|
||||
'uid' => $this->payload['vid'],
|
||||
],
|
||||
[
|
||||
'last_seen_at' => $now,
|
||||
'first_seen_at' => $now,
|
||||
'device_type' => $this->payload['device'] ?? null,
|
||||
'browser' => $this->payload['browser'] ?? null,
|
||||
'browser_version' => $this->payload['browser_version'] ?? null,
|
||||
'os' => $this->payload['os'] ?? null,
|
||||
'os_version' => $this->payload['os_version'] ?? null,
|
||||
'screen_resolution' => $this->payload['sr'] ?? null,
|
||||
'language' => $this->payload['lang'] ?? null,
|
||||
],
|
||||
);
|
||||
|
||||
if ($visitor->wasRecentlyCreated) {
|
||||
$visitor->update(['first_seen_at' => $now]);
|
||||
$this->geolocate($visitor);
|
||||
}
|
||||
|
||||
$session = AnalyticsSession::firstOrCreate(
|
||||
['uid' => $this->payload['sid']],
|
||||
[
|
||||
'website_id' => $website->id,
|
||||
'visitor_id' => $visitor->id,
|
||||
'started_at' => $now,
|
||||
'entry_page' => $this->payload['path'] ?? $this->payload['url'] ?? null,
|
||||
'ip_address' => $this->ip,
|
||||
'referrer_url' => $this->payload['referrer'] ?? null,
|
||||
'referrer_domain' => $this->payload['ref_domain'] ?? null,
|
||||
'utm_source' => $this->payload['utm_source'] ?? null,
|
||||
'utm_medium' => $this->payload['utm_medium'] ?? null,
|
||||
'utm_campaign' => $this->payload['utm_campaign'] ?? null,
|
||||
'utm_term' => $this->payload['utm_term'] ?? null,
|
||||
'utm_content' => $this->payload['utm_content'] ?? null,
|
||||
],
|
||||
);
|
||||
|
||||
if ($this->payload['type'] === 'pageview') {
|
||||
PageView::create([
|
||||
'website_id' => $website->id,
|
||||
'visitor_id' => $visitor->id,
|
||||
'analytics_session_id' => $session->id,
|
||||
'url' => $this->payload['url'] ?? '',
|
||||
'path' => $this->payload['path'] ?? '/',
|
||||
'title' => $this->payload['title'] ?? null,
|
||||
'referrer' => $this->payload['referrer'] ?? null,
|
||||
'time_on_page' => $this->payload['time_on_page'] ?? null,
|
||||
'viewed_at' => $now,
|
||||
]);
|
||||
}
|
||||
|
||||
if ($this->payload['type'] === 'event') {
|
||||
Event::create([
|
||||
'website_id' => $website->id,
|
||||
'visitor_id' => $visitor->id,
|
||||
'analytics_session_id' => $session->id,
|
||||
'name' => $this->payload['event_name'] ?? 'unknown',
|
||||
'category' => $this->payload['event_category'] ?? null,
|
||||
'action' => $this->payload['event_action'] ?? null,
|
||||
'label' => $this->payload['event_label'] ?? null,
|
||||
'value' => $this->payload['event_value'] ?? null,
|
||||
'properties' => $this->payload['event_properties'] ?? null,
|
||||
'occurred_at' => $now,
|
||||
]);
|
||||
}
|
||||
|
||||
$session->update([
|
||||
'ended_at' => $now,
|
||||
'duration' => $now->diffInSeconds($session->started_at),
|
||||
'exit_page' => $this->payload['path'] ?? $this->payload['url'] ?? $session->exit_page,
|
||||
'pages_count' => $session->pageViews()->count(),
|
||||
'is_bounce' => $session->pageViews()->count() <= 1,
|
||||
]);
|
||||
|
||||
$session->refresh();
|
||||
|
||||
$goalService->evaluate($website, $visitor, $session, $this->payload, $now);
|
||||
}
|
||||
|
||||
private function geolocate(Visitor $visitor): void
|
||||
{
|
||||
try {
|
||||
$position = Location::get($this->ip);
|
||||
|
||||
if ($position) {
|
||||
$visitor->update(array_filter([
|
||||
'country' => $position->countryCode ?? null,
|
||||
'region' => $position->regionName ?? null,
|
||||
'city' => $position->cityName ?? null,
|
||||
]));
|
||||
}
|
||||
} catch (\Throwable) {
|
||||
// Geolocation is best-effort; never fail the job over it
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
];
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
<?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 AnalyticsSession extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $fillable = [
|
||||
'website_id',
|
||||
'visitor_id',
|
||||
'uid',
|
||||
'started_at',
|
||||
'ended_at',
|
||||
'duration',
|
||||
'is_bounce',
|
||||
'entry_page',
|
||||
'exit_page',
|
||||
'pages_count',
|
||||
'ip_address',
|
||||
'referrer_url',
|
||||
'referrer_domain',
|
||||
'utm_source',
|
||||
'utm_medium',
|
||||
'utm_campaign',
|
||||
'utm_term',
|
||||
'utm_content',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'started_at' => 'datetime',
|
||||
'ended_at' => 'datetime',
|
||||
'is_bounce' => 'boolean',
|
||||
'duration' => 'integer',
|
||||
'pages_count' => 'integer',
|
||||
];
|
||||
}
|
||||
|
||||
public function website(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Website::class);
|
||||
}
|
||||
|
||||
public function visitor(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Visitor::class);
|
||||
}
|
||||
|
||||
public function pageViews(): HasMany
|
||||
{
|
||||
return $this->hasMany(PageView::class);
|
||||
}
|
||||
|
||||
public function events(): HasMany
|
||||
{
|
||||
return $this->hasMany(Event::class);
|
||||
}
|
||||
|
||||
public function goalConversions(): HasMany
|
||||
{
|
||||
return $this->hasMany(GoalConversion::class);
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class DailyAggregate extends Model
|
||||
{
|
||||
protected $fillable = [
|
||||
'website_id',
|
||||
'date',
|
||||
'visitors',
|
||||
'sessions',
|
||||
'page_views',
|
||||
'bounce_count',
|
||||
'total_duration',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'visitors' => 'integer',
|
||||
'sessions' => 'integer',
|
||||
'page_views' => 'integer',
|
||||
'bounce_count' => 'integer',
|
||||
'total_duration' => 'integer',
|
||||
];
|
||||
}
|
||||
|
||||
public function website(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Website::class);
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class Event extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $fillable = [
|
||||
'website_id',
|
||||
'visitor_id',
|
||||
'analytics_session_id',
|
||||
'name',
|
||||
'category',
|
||||
'action',
|
||||
'label',
|
||||
'value',
|
||||
'properties',
|
||||
'occurred_at',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'value' => 'decimal:4',
|
||||
'properties' => 'array',
|
||||
'occurred_at' => 'datetime',
|
||||
];
|
||||
}
|
||||
|
||||
public function website(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Website::class);
|
||||
}
|
||||
|
||||
public function visitor(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Visitor::class);
|
||||
}
|
||||
|
||||
public function session(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(AnalyticsSession::class, 'analytics_session_id');
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
<?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 Goal extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $fillable = [
|
||||
'website_id',
|
||||
'name',
|
||||
'type',
|
||||
'conditions',
|
||||
'default_value',
|
||||
'is_active',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'conditions' => 'array',
|
||||
'default_value' => 'decimal:4',
|
||||
'is_active' => 'boolean',
|
||||
];
|
||||
}
|
||||
|
||||
public function website(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Website::class);
|
||||
}
|
||||
|
||||
public function conversions(): HasMany
|
||||
{
|
||||
return $this->hasMany(GoalConversion::class);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class GoalConversion extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $fillable = [
|
||||
'goal_id',
|
||||
'website_id',
|
||||
'visitor_id',
|
||||
'analytics_session_id',
|
||||
'value',
|
||||
'converted_at',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'value' => 'decimal:4',
|
||||
'converted_at' => 'datetime',
|
||||
];
|
||||
}
|
||||
|
||||
public function goal(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Goal::class);
|
||||
}
|
||||
|
||||
public function website(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Website::class);
|
||||
}
|
||||
|
||||
public function visitor(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Visitor::class);
|
||||
}
|
||||
|
||||
public function session(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(AnalyticsSession::class, 'analytics_session_id');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class PageDailyAggregate extends Model
|
||||
{
|
||||
protected $fillable = [
|
||||
'website_id',
|
||||
'date',
|
||||
'path',
|
||||
'views',
|
||||
'unique_views',
|
||||
'total_time_on_page',
|
||||
'bounces',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'views' => 'integer',
|
||||
'unique_views' => 'integer',
|
||||
'total_time_on_page' => 'integer',
|
||||
'bounces' => 'integer',
|
||||
];
|
||||
}
|
||||
|
||||
public function website(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Website::class);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class PageView extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $fillable = [
|
||||
'website_id',
|
||||
'visitor_id',
|
||||
'analytics_session_id',
|
||||
'url',
|
||||
'path',
|
||||
'title',
|
||||
'referrer',
|
||||
'time_on_page',
|
||||
'viewed_at',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'viewed_at' => 'datetime',
|
||||
'time_on_page' => 'integer',
|
||||
];
|
||||
}
|
||||
|
||||
public function website(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Website::class);
|
||||
}
|
||||
|
||||
public function visitor(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Visitor::class);
|
||||
}
|
||||
|
||||
public function session(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(AnalyticsSession::class, 'analytics_session_id');
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
// use Illuminate\Contracts\Auth\MustVerifyEmail;
|
||||
use Database\Factories\UserFactory;
|
||||
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 HasApiTokens, HasFactory, Notifiable;
|
||||
|
||||
/**
|
||||
* The attributes that are mass assignable.
|
||||
*
|
||||
* @var list<string>
|
||||
*/
|
||||
protected $fillable = [
|
||||
'name',
|
||||
'email',
|
||||
'password',
|
||||
];
|
||||
|
||||
/**
|
||||
* The attributes that should be hidden for serialization.
|
||||
*
|
||||
* @var list<string>
|
||||
*/
|
||||
protected $hidden = [
|
||||
'password',
|
||||
'remember_token',
|
||||
];
|
||||
|
||||
/**
|
||||
* Get the attributes that should be cast.
|
||||
*
|
||||
* @return array<string, string>
|
||||
*/
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'email_verified_at' => 'datetime',
|
||||
'password' => 'hashed',
|
||||
];
|
||||
}
|
||||
|
||||
public function websites(): HasMany
|
||||
{
|
||||
return $this->hasMany(Website::class);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
<?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 Visitor extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $fillable = [
|
||||
'website_id',
|
||||
'uid',
|
||||
'first_seen_at',
|
||||
'last_seen_at',
|
||||
'device_type',
|
||||
'browser',
|
||||
'browser_version',
|
||||
'os',
|
||||
'os_version',
|
||||
'screen_resolution',
|
||||
'language',
|
||||
'country',
|
||||
'region',
|
||||
'city',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'first_seen_at' => 'datetime',
|
||||
'last_seen_at' => 'datetime',
|
||||
];
|
||||
}
|
||||
|
||||
public function website(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Website::class);
|
||||
}
|
||||
|
||||
public function sessions(): HasMany
|
||||
{
|
||||
return $this->hasMany(AnalyticsSession::class);
|
||||
}
|
||||
|
||||
public function pageViews(): HasMany
|
||||
{
|
||||
return $this->hasMany(PageView::class);
|
||||
}
|
||||
|
||||
public function events(): HasMany
|
||||
{
|
||||
return $this->hasMany(Event::class);
|
||||
}
|
||||
|
||||
public function goalConversions(): HasMany
|
||||
{
|
||||
return $this->hasMany(GoalConversion::class);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
<?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;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class Website extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $fillable = [
|
||||
'user_id',
|
||||
'name',
|
||||
'domain',
|
||||
'tracking_id',
|
||||
'is_active',
|
||||
'settings',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'is_active' => 'boolean',
|
||||
'settings' => 'array',
|
||||
];
|
||||
}
|
||||
|
||||
protected static function booted(): void
|
||||
{
|
||||
static::creating(function (Website $website) {
|
||||
if (empty($website->tracking_id)) {
|
||||
$website->tracking_id = 'TA-' . strtoupper(Str::random(10));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public function user(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class);
|
||||
}
|
||||
|
||||
public function visitors(): HasMany
|
||||
{
|
||||
return $this->hasMany(Visitor::class);
|
||||
}
|
||||
|
||||
public function sessions(): HasMany
|
||||
{
|
||||
return $this->hasMany(AnalyticsSession::class);
|
||||
}
|
||||
|
||||
public function pageViews(): HasMany
|
||||
{
|
||||
return $this->hasMany(PageView::class);
|
||||
}
|
||||
|
||||
public function events(): HasMany
|
||||
{
|
||||
return $this->hasMany(Event::class);
|
||||
}
|
||||
|
||||
public function goals(): HasMany
|
||||
{
|
||||
return $this->hasMany(Goal::class);
|
||||
}
|
||||
|
||||
public function goalConversions(): HasMany
|
||||
{
|
||||
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);
|
||||
}
|
||||
|
||||
public function pageDailyAggregates(): HasMany
|
||||
{
|
||||
return $this->hasMany(PageDailyAggregate::class);
|
||||
}
|
||||
|
||||
public function getDataRetentionDaysAttribute(): int
|
||||
{
|
||||
return $this->settings['data_retention_days'] ?? 365;
|
||||
}
|
||||
|
||||
public function embedSnippet(): string
|
||||
{
|
||||
$scriptUrl = route('tracking.script', $this->tracking_id);
|
||||
|
||||
return <<<HTML
|
||||
<!-- Analytics Tracker -->
|
||||
<script defer src="{$scriptUrl}"></script>
|
||||
HTML;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
<?php
|
||||
|
||||
namespace App\Policies;
|
||||
|
||||
use App\Models\User;
|
||||
use App\Models\Website;
|
||||
|
||||
class WebsitePolicy
|
||||
{
|
||||
public function viewAny(User $user): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
public function view(User $user, Website $website): bool
|
||||
{
|
||||
return $user->id === $website->user_id;
|
||||
}
|
||||
|
||||
public function create(User $user): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
public function update(User $user, Website $website): bool
|
||||
{
|
||||
return $user->id === $website->user_id;
|
||||
}
|
||||
|
||||
public function delete(User $user, Website $website): bool
|
||||
{
|
||||
return $user->id === $website->user_id;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
|
||||
namespace App\Providers;
|
||||
|
||||
use Illuminate\Cache\RateLimiting\Limit;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\RateLimiter;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Illuminate\Support\ServiceProvider;
|
||||
|
||||
class AppServiceProvider extends ServiceProvider
|
||||
{
|
||||
public function register(): void
|
||||
{
|
||||
//
|
||||
}
|
||||
|
||||
public function boot(): void
|
||||
{
|
||||
Schema::defaultStringLength(191);
|
||||
|
||||
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);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
<?php
|
||||
|
||||
namespace App\Providers;
|
||||
|
||||
use App\Actions\Fortify\CreateNewUser;
|
||||
use App\Actions\Fortify\ResetUserPassword;
|
||||
use App\Actions\Fortify\UpdateUserPassword;
|
||||
use App\Actions\Fortify\UpdateUserProfileInformation;
|
||||
use Illuminate\Cache\RateLimiting\Limit;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\RateLimiter;
|
||||
use Illuminate\Support\ServiceProvider;
|
||||
use Illuminate\Support\Str;
|
||||
use Laravel\Fortify\Actions\RedirectIfTwoFactorAuthenticatable;
|
||||
use Laravel\Fortify\Fortify;
|
||||
|
||||
class FortifyServiceProvider extends ServiceProvider
|
||||
{
|
||||
/**
|
||||
* Register any application services.
|
||||
*/
|
||||
public function register(): void
|
||||
{
|
||||
//
|
||||
}
|
||||
|
||||
/**
|
||||
* Bootstrap any application services.
|
||||
*/
|
||||
public function boot(): void
|
||||
{
|
||||
Fortify::createUsersUsing(CreateNewUser::class);
|
||||
Fortify::updateUserProfileInformationUsing(UpdateUserProfileInformation::class);
|
||||
Fortify::updateUserPasswordsUsing(UpdateUserPassword::class);
|
||||
Fortify::resetUserPasswordsUsing(ResetUserPassword::class);
|
||||
Fortify::redirectUserForTwoFactorAuthenticationUsing(RedirectIfTwoFactorAuthenticatable::class);
|
||||
|
||||
RateLimiter::for('login', function (Request $request) {
|
||||
$throttleKey = Str::transliterate(Str::lower($request->input(Fortify::username())).'|'.$request->ip());
|
||||
|
||||
return Limit::perMinute(5)->by($throttleKey);
|
||||
});
|
||||
|
||||
RateLimiter::for('two-factor', function (Request $request) {
|
||||
return Limit::perMinute(5)->by($request->session()->get('login.id'));
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,786 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\AnalyticsSession;
|
||||
use App\Models\DailyAggregate;
|
||||
use App\Models\PageDailyAggregate;
|
||||
use App\Models\PageView;
|
||||
use App\Models\Visitor;
|
||||
use App\Models\Website;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class AnalyticsQueryService
|
||||
{
|
||||
private readonly Carbon $todayStart;
|
||||
|
||||
private ?Collection $segmentVisitorIds = null;
|
||||
|
||||
public function __construct(
|
||||
private readonly Website $website,
|
||||
private readonly Carbon $from,
|
||||
private readonly Carbon $to,
|
||||
) {
|
||||
$this->todayStart = Carbon::today();
|
||||
}
|
||||
|
||||
public static function for(Website $website, Carbon $from, Carbon $to): self
|
||||
{
|
||||
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
|
||||
* - "live range": today's partial data that must come from raw tables
|
||||
*
|
||||
* Returns [?Carbon $aggFrom, ?Carbon $aggTo, ?Carbon $liveFrom, ?Carbon $liveTo]
|
||||
*/
|
||||
private function splitRange(): array
|
||||
{
|
||||
$aggFrom = null;
|
||||
$aggTo = null;
|
||||
$liveFrom = null;
|
||||
$liveTo = null;
|
||||
|
||||
$yesterdayEnd = $this->todayStart->copy()->subSecond();
|
||||
|
||||
if ($this->from->lt($this->todayStart)) {
|
||||
$aggFrom = $this->from->copy();
|
||||
$aggTo = $this->to->lt($this->todayStart) ? $this->to->copy() : $yesterdayEnd;
|
||||
}
|
||||
|
||||
if ($this->to->gte($this->todayStart)) {
|
||||
$liveFrom = $this->from->gte($this->todayStart) ? $this->from->copy() : $this->todayStart->copy();
|
||||
$liveTo = $this->to->copy();
|
||||
}
|
||||
|
||||
return [$aggFrom, $aggTo, $liveFrom, $liveTo];
|
||||
}
|
||||
|
||||
private function hasAggregateRange(): bool
|
||||
{
|
||||
[$aggFrom] = $this->splitRange();
|
||||
|
||||
return $aggFrom !== null;
|
||||
}
|
||||
|
||||
private function hasLiveRange(): bool
|
||||
{
|
||||
[,, $liveFrom] = $this->splitRange();
|
||||
|
||||
return $liveFrom !== null;
|
||||
}
|
||||
|
||||
// ---- Summary Metrics ----
|
||||
|
||||
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();
|
||||
|
||||
if ($aggFrom) {
|
||||
$total += (int) DailyAggregate::where('website_id', $this->website->id)
|
||||
->whereBetween('date', [$aggFrom->toDateString(), $aggTo->toDateString()])
|
||||
->sum('page_views');
|
||||
}
|
||||
|
||||
if ($liveFrom) {
|
||||
$total += PageView::where('website_id', $this->website->id)
|
||||
->whereBetween('viewed_at', [$liveFrom, $liveTo])
|
||||
->count();
|
||||
}
|
||||
|
||||
return $total;
|
||||
}
|
||||
|
||||
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();
|
||||
|
||||
if ($aggFrom) {
|
||||
$total += (int) DailyAggregate::where('website_id', $this->website->id)
|
||||
->whereBetween('date', [$aggFrom->toDateString(), $aggTo->toDateString()])
|
||||
->sum('visitors');
|
||||
}
|
||||
|
||||
if ($liveFrom) {
|
||||
$total += PageView::where('website_id', $this->website->id)
|
||||
->whereBetween('viewed_at', [$liveFrom, $liveTo])
|
||||
->distinct('visitor_id')
|
||||
->count('visitor_id');
|
||||
}
|
||||
|
||||
return $total;
|
||||
}
|
||||
|
||||
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();
|
||||
|
||||
if ($aggFrom) {
|
||||
$total += (int) DailyAggregate::where('website_id', $this->website->id)
|
||||
->whereBetween('date', [$aggFrom->toDateString(), $aggTo->toDateString()])
|
||||
->sum('sessions');
|
||||
}
|
||||
|
||||
if ($liveFrom) {
|
||||
$total += AnalyticsSession::where('website_id', $this->website->id)
|
||||
->whereBetween('started_at', [$liveFrom, $liveTo])
|
||||
->count();
|
||||
}
|
||||
|
||||
return $total;
|
||||
}
|
||||
|
||||
public function bounceRate(): float
|
||||
{
|
||||
$totalSessions = $this->totalSessions();
|
||||
if ($totalSessions === 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
$bounced = 0;
|
||||
[$aggFrom, $aggTo, $liveFrom, $liveTo] = $this->splitRange();
|
||||
|
||||
if ($aggFrom) {
|
||||
$bounced += (int) DailyAggregate::where('website_id', $this->website->id)
|
||||
->whereBetween('date', [$aggFrom->toDateString(), $aggTo->toDateString()])
|
||||
->sum('bounce_count');
|
||||
}
|
||||
|
||||
if ($liveFrom) {
|
||||
$bounced += AnalyticsSession::where('website_id', $this->website->id)
|
||||
->whereBetween('started_at', [$liveFrom, $liveTo])
|
||||
->where('is_bounce', true)
|
||||
->count();
|
||||
}
|
||||
|
||||
return round(($bounced / $totalSessions) * 100, 1);
|
||||
}
|
||||
|
||||
public function avgSessionDuration(): float
|
||||
{
|
||||
$totalSessions = $this->totalSessions();
|
||||
if ($totalSessions === 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
$totalDuration = 0;
|
||||
[$aggFrom, $aggTo, $liveFrom, $liveTo] = $this->splitRange();
|
||||
|
||||
if ($aggFrom) {
|
||||
$totalDuration += (int) DailyAggregate::where('website_id', $this->website->id)
|
||||
->whereBetween('date', [$aggFrom->toDateString(), $aggTo->toDateString()])
|
||||
->sum('total_duration');
|
||||
}
|
||||
|
||||
if ($liveFrom) {
|
||||
$totalDuration += (int) AnalyticsSession::where('website_id', $this->website->id)
|
||||
->whereBetween('started_at', [$liveFrom, $liveTo])
|
||||
->sum('duration');
|
||||
}
|
||||
|
||||
return round($totalDuration / $totalSessions);
|
||||
}
|
||||
|
||||
public function avgPagesPerSession(): float
|
||||
{
|
||||
$totalSessions = $this->totalSessions();
|
||||
if ($totalSessions === 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
$totalPageViews = $this->totalPageViews();
|
||||
|
||||
return round($totalPageViews / $totalSessions, 1);
|
||||
}
|
||||
|
||||
public function summary(): array
|
||||
{
|
||||
return [
|
||||
'page_views' => $this->totalPageViews(),
|
||||
'visitors' => $this->uniqueVisitors(),
|
||||
'sessions' => $this->totalSessions(),
|
||||
'bounce_rate' => $this->bounceRate(),
|
||||
'avg_duration' => $this->avgSessionDuration(),
|
||||
'avg_pages' => $this->avgPagesPerSession(),
|
||||
];
|
||||
}
|
||||
|
||||
// ---- Time Series ----
|
||||
|
||||
public function visitorsOverTime(): Collection
|
||||
{
|
||||
$days = $this->from->diffInDays($this->to);
|
||||
$isHourly = $days <= 2;
|
||||
$groupFormat = $isHourly ? '%Y-%m-%d %H:00' : '%Y-%m-%d';
|
||||
$carbonFormat = $isHourly ? 'Y-m-d H:00' : 'Y-m-d';
|
||||
|
||||
[$aggFrom, $aggTo, $liveFrom, $liveTo] = $this->splitRange();
|
||||
$aggregated = collect();
|
||||
$liveData = collect();
|
||||
|
||||
if ($aggFrom && ! $isHourly) {
|
||||
$aggregated = DailyAggregate::where('website_id', $this->website->id)
|
||||
->whereBetween('date', [$aggFrom->toDateString(), $aggTo->toDateString()])
|
||||
->select('date as period', 'page_views', 'visitors', 'sessions')
|
||||
->get()
|
||||
->mapWithKeys(fn ($row) => [
|
||||
Carbon::parse($row->period)->format('Y-m-d') => [
|
||||
'page_views' => $row->page_views,
|
||||
'visitors' => $row->visitors,
|
||||
'sessions' => $row->sessions,
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
$liveQueryFrom = ($aggFrom && ! $isHourly) ? ($liveFrom ?? $this->todayStart) : $this->from;
|
||||
$liveQueryTo = $liveTo ?? $this->to;
|
||||
$needsLive = $isHourly || $liveFrom;
|
||||
|
||||
if ($needsLive || ! $aggFrom) {
|
||||
$rawFrom = $isHourly ? $this->from : $liveQueryFrom;
|
||||
$rawTo = $isHourly ? $this->to : $liveQueryTo;
|
||||
|
||||
$pvRaw = PageView::where('website_id', $this->website->id)
|
||||
->whereBetween('viewed_at', [$rawFrom, $rawTo])
|
||||
->select(
|
||||
DB::raw($this->dateFormat($groupFormat, 'viewed_at').' as period'),
|
||||
DB::raw('COUNT(*) as page_views'),
|
||||
DB::raw('COUNT(DISTINCT visitor_id) as visitors'),
|
||||
)
|
||||
->groupBy('period')
|
||||
->get()
|
||||
->keyBy('period');
|
||||
|
||||
$sessRaw = AnalyticsSession::where('website_id', $this->website->id)
|
||||
->whereBetween('started_at', [$rawFrom, $rawTo])
|
||||
->select(
|
||||
DB::raw($this->dateFormat($groupFormat, 'started_at').' as period'),
|
||||
DB::raw('COUNT(*) as sessions'),
|
||||
)
|
||||
->groupBy('period')
|
||||
->get()
|
||||
->keyBy('period');
|
||||
|
||||
foreach ($pvRaw as $key => $row) {
|
||||
$liveData[$key] = [
|
||||
'page_views' => $row->page_views,
|
||||
'visitors' => $row->visitors,
|
||||
'sessions' => $sessRaw->get($key)?->sessions ?? 0,
|
||||
];
|
||||
}
|
||||
|
||||
foreach ($sessRaw as $key => $row) {
|
||||
if (! isset($liveData[$key])) {
|
||||
$liveData[$key] = [
|
||||
'page_views' => 0,
|
||||
'visitors' => 0,
|
||||
'sessions' => $row->sessions,
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$merged = $aggregated->union($liveData);
|
||||
|
||||
$periods = collect();
|
||||
$cursor = $this->from->copy();
|
||||
$increment = $isHourly ? 'addHour' : 'addDay';
|
||||
|
||||
while ($cursor->lte($this->to)) {
|
||||
$key = $cursor->format($carbonFormat);
|
||||
$data = $merged->get($key);
|
||||
|
||||
$periods->push([
|
||||
'period' => $key,
|
||||
'page_views' => $data['page_views'] ?? 0,
|
||||
'visitors' => $data['visitors'] ?? 0,
|
||||
'sessions' => $data['sessions'] ?? 0,
|
||||
]);
|
||||
|
||||
$cursor->$increment();
|
||||
}
|
||||
|
||||
return $periods;
|
||||
}
|
||||
|
||||
// ---- Audience: Geographic ----
|
||||
|
||||
public function visitorsByCountry(int $limit = 50): Collection
|
||||
{
|
||||
return Visitor::where('website_id', $this->website->id)
|
||||
->whereNotNull('country')
|
||||
->whereHas('pageViews', fn ($q) => $q->whereBetween('viewed_at', [$this->from, $this->to]))
|
||||
->select('country', DB::raw('COUNT(*) as visitors'))
|
||||
->groupBy('country')
|
||||
->orderByDesc('visitors')
|
||||
->limit($limit)
|
||||
->get();
|
||||
}
|
||||
|
||||
public function visitorsByCity(int $limit = 50): Collection
|
||||
{
|
||||
return Visitor::where('website_id', $this->website->id)
|
||||
->whereNotNull('city')
|
||||
->whereHas('pageViews', fn ($q) => $q->whereBetween('viewed_at', [$this->from, $this->to]))
|
||||
->select('country', 'region', 'city', DB::raw('COUNT(*) as visitors'))
|
||||
->groupBy('country', 'region', 'city')
|
||||
->orderByDesc('visitors')
|
||||
->limit($limit)
|
||||
->get();
|
||||
}
|
||||
|
||||
// ---- Audience: Technology ----
|
||||
|
||||
public function visitorsByBrowser(int $limit = 20): Collection
|
||||
{
|
||||
return $this->visitorGroupedBy('browser', $limit);
|
||||
}
|
||||
|
||||
public function visitorsByOS(int $limit = 20): Collection
|
||||
{
|
||||
return $this->visitorGroupedBy('os', $limit);
|
||||
}
|
||||
|
||||
public function visitorsByDeviceType(int $limit = 10): Collection
|
||||
{
|
||||
return $this->visitorGroupedBy('device_type', $limit);
|
||||
}
|
||||
|
||||
public function visitorsByScreenResolution(int $limit = 20): Collection
|
||||
{
|
||||
return $this->visitorGroupedBy('screen_resolution', $limit);
|
||||
}
|
||||
|
||||
// ---- Audience: Language ----
|
||||
|
||||
public function visitorsByLanguage(int $limit = 20): Collection
|
||||
{
|
||||
return $this->visitorGroupedBy('language', $limit);
|
||||
}
|
||||
|
||||
// ---- Audience: New vs Returning ----
|
||||
|
||||
public function newVsReturning(): array
|
||||
{
|
||||
$activeVisitorIds = PageView::where('website_id', $this->website->id)
|
||||
->whereBetween('viewed_at', [$this->from, $this->to])
|
||||
->distinct()
|
||||
->pluck('visitor_id');
|
||||
|
||||
$total = $activeVisitorIds->count();
|
||||
if ($total === 0) {
|
||||
return ['new' => 0, 'returning' => 0, 'new_pct' => 0, 'returning_pct' => 0];
|
||||
}
|
||||
|
||||
$returning = AnalyticsSession::where('website_id', $this->website->id)
|
||||
->whereIn('visitor_id', $activeVisitorIds)
|
||||
->select('visitor_id')
|
||||
->groupBy('visitor_id')
|
||||
->havingRaw('COUNT(*) > 1')
|
||||
->get()
|
||||
->count();
|
||||
|
||||
$new = $total - $returning;
|
||||
|
||||
return [
|
||||
'new' => $new,
|
||||
'returning' => $returning,
|
||||
'new_pct' => round(($new / $total) * 100, 1),
|
||||
'returning_pct' => round(($returning / $total) * 100, 1),
|
||||
];
|
||||
}
|
||||
|
||||
// ---- Acquisition: Traffic Sources ----
|
||||
|
||||
public function trafficSources(int $limit = 50): Collection
|
||||
{
|
||||
return AnalyticsSession::where('website_id', $this->website->id)
|
||||
->whereBetween('started_at', [$this->from, $this->to])
|
||||
->select(
|
||||
DB::raw("COALESCE(NULLIF(referrer_domain, ''), 'Direct') as source"),
|
||||
DB::raw('COUNT(*) as sessions'),
|
||||
DB::raw('SUM(CASE WHEN is_bounce = 1 THEN 1 ELSE 0 END) as bounces'),
|
||||
DB::raw('AVG(duration) as avg_duration'),
|
||||
DB::raw('AVG(pages_count) as avg_pages'),
|
||||
)
|
||||
->groupBy('source')
|
||||
->orderByDesc('sessions')
|
||||
->limit($limit)
|
||||
->get();
|
||||
}
|
||||
|
||||
// ---- Acquisition: Campaigns (UTM) ----
|
||||
|
||||
public function campaigns(int $limit = 50): Collection
|
||||
{
|
||||
return AnalyticsSession::where('website_id', $this->website->id)
|
||||
->whereBetween('started_at', [$this->from, $this->to])
|
||||
->whereNotNull('utm_source')
|
||||
->select(
|
||||
'utm_source', 'utm_medium', 'utm_campaign',
|
||||
DB::raw('COUNT(*) as sessions'),
|
||||
DB::raw('SUM(CASE WHEN is_bounce = 1 THEN 1 ELSE 0 END) as bounces'),
|
||||
DB::raw('AVG(duration) as avg_duration'),
|
||||
)
|
||||
->groupBy('utm_source', 'utm_medium', 'utm_campaign')
|
||||
->orderByDesc('sessions')
|
||||
->limit($limit)
|
||||
->get();
|
||||
}
|
||||
|
||||
// ---- Acquisition: Referrers ----
|
||||
|
||||
public function referrers(int $limit = 50): Collection
|
||||
{
|
||||
return AnalyticsSession::where('website_id', $this->website->id)
|
||||
->whereBetween('started_at', [$this->from, $this->to])
|
||||
->whereNotNull('referrer_domain')
|
||||
->where('referrer_domain', '!=', '')
|
||||
->select(
|
||||
'referrer_domain',
|
||||
DB::raw('COUNT(*) as sessions'),
|
||||
DB::raw('SUM(CASE WHEN is_bounce = 1 THEN 1 ELSE 0 END) as bounces'),
|
||||
DB::raw('AVG(duration) as avg_duration'),
|
||||
)
|
||||
->groupBy('referrer_domain')
|
||||
->orderByDesc('sessions')
|
||||
->limit($limit)
|
||||
->get();
|
||||
}
|
||||
|
||||
// ---- Behavior: Pages ----
|
||||
|
||||
public function topPages(int $limit = 50): Collection
|
||||
{
|
||||
[$aggFrom, $aggTo, $liveFrom, $liveTo] = $this->splitRange();
|
||||
$aggData = collect();
|
||||
$liveData = collect();
|
||||
|
||||
if ($aggFrom) {
|
||||
$aggData = PageDailyAggregate::where('website_id', $this->website->id)
|
||||
->whereBetween('date', [$aggFrom->toDateString(), $aggTo->toDateString()])
|
||||
->select(
|
||||
'path',
|
||||
DB::raw('SUM(views) as views'),
|
||||
DB::raw('SUM(unique_views) as unique_views'),
|
||||
DB::raw('CASE WHEN SUM(views) > 0 THEN SUM(total_time_on_page) * 1.0 / SUM(views) ELSE 0 END as avg_time'),
|
||||
)
|
||||
->groupBy('path')
|
||||
->get()
|
||||
->keyBy('path');
|
||||
}
|
||||
|
||||
if ($liveFrom) {
|
||||
$liveData = PageView::where('website_id', $this->website->id)
|
||||
->whereBetween('viewed_at', [$liveFrom, $liveTo])
|
||||
->select(
|
||||
'path',
|
||||
DB::raw('COUNT(*) as views'),
|
||||
DB::raw('COUNT(DISTINCT visitor_id) as unique_views'),
|
||||
DB::raw('AVG(time_on_page) as avg_time'),
|
||||
)
|
||||
->groupBy('path')
|
||||
->get()
|
||||
->keyBy('path');
|
||||
}
|
||||
|
||||
if ($aggData->isEmpty()) {
|
||||
return $liveData->values()->sortByDesc('views')->take($limit)->values();
|
||||
}
|
||||
|
||||
if ($liveData->isEmpty()) {
|
||||
return $aggData->values()->sortByDesc('views')->take($limit)->values();
|
||||
}
|
||||
|
||||
$allPaths = $aggData->keys()->merge($liveData->keys())->unique();
|
||||
|
||||
return $allPaths->map(function (string $path) use ($aggData, $liveData) {
|
||||
$agg = $aggData->get($path);
|
||||
$live = $liveData->get($path);
|
||||
|
||||
$totalViews = ($agg->views ?? 0) + ($live->views ?? 0);
|
||||
$totalUnique = ($agg->unique_views ?? 0) + ($live->unique_views ?? 0);
|
||||
$avgTime = $totalViews > 0
|
||||
? (($agg ? $agg->avg_time * $agg->views : 0) + ($live ? $live->avg_time * $live->views : 0)) / $totalViews
|
||||
: 0;
|
||||
|
||||
return (object) [
|
||||
'path' => $path,
|
||||
'views' => $totalViews,
|
||||
'unique_views' => $totalUnique,
|
||||
'avg_time' => $avgTime,
|
||||
];
|
||||
})->sortByDesc('views')->take($limit)->values();
|
||||
}
|
||||
|
||||
// ---- Behavior: Landing Pages ----
|
||||
|
||||
public function landingPages(int $limit = 50): Collection
|
||||
{
|
||||
return AnalyticsSession::where('website_id', $this->website->id)
|
||||
->whereBetween('started_at', [$this->from, $this->to])
|
||||
->whereNotNull('entry_page')
|
||||
->select(
|
||||
'entry_page as path',
|
||||
DB::raw('COUNT(*) as sessions'),
|
||||
DB::raw('SUM(CASE WHEN is_bounce = 1 THEN 1 ELSE 0 END) as bounces'),
|
||||
DB::raw('AVG(duration) as avg_duration'),
|
||||
)
|
||||
->groupBy('entry_page')
|
||||
->orderByDesc('sessions')
|
||||
->limit($limit)
|
||||
->get();
|
||||
}
|
||||
|
||||
// ---- Behavior: Exit Pages ----
|
||||
|
||||
public function exitPages(int $limit = 50): Collection
|
||||
{
|
||||
return AnalyticsSession::where('website_id', $this->website->id)
|
||||
->whereBetween('started_at', [$this->from, $this->to])
|
||||
->whereNotNull('exit_page')
|
||||
->select(
|
||||
'exit_page as path',
|
||||
DB::raw('COUNT(*) as exits'),
|
||||
)
|
||||
->groupBy('exit_page')
|
||||
->orderByDesc('exits')
|
||||
->limit($limit)
|
||||
->get();
|
||||
}
|
||||
|
||||
// ---- Events: Overview ----
|
||||
|
||||
public function eventsOverview(int $limit = 50): Collection
|
||||
{
|
||||
$driver = DB::connection()->getDriverName();
|
||||
$castType = $driver === 'sqlite' ? 'REAL' : 'DECIMAL(10,2)';
|
||||
|
||||
return \App\Models\Event::where('website_id', $this->website->id)
|
||||
->whereBetween('occurred_at', [$this->from, $this->to])
|
||||
->select(
|
||||
'name',
|
||||
'category',
|
||||
DB::raw('COUNT(*) as total'),
|
||||
DB::raw('COUNT(DISTINCT visitor_id) as unique_visitors'),
|
||||
DB::raw("SUM(CAST(value AS {$castType})) as total_value"),
|
||||
)
|
||||
->groupBy('name', 'category')
|
||||
->orderByDesc('total')
|
||||
->limit($limit)
|
||||
->get();
|
||||
}
|
||||
|
||||
// ---- Events: Detail ----
|
||||
|
||||
public function eventDetail(string $eventName): Collection
|
||||
{
|
||||
$driver = DB::connection()->getDriverName();
|
||||
$castType = $driver === 'sqlite' ? 'REAL' : 'DECIMAL(10,2)';
|
||||
|
||||
return \App\Models\Event::where('website_id', $this->website->id)
|
||||
->whereBetween('occurred_at', [$this->from, $this->to])
|
||||
->where('name', $eventName)
|
||||
->select(
|
||||
'action', 'label',
|
||||
DB::raw('COUNT(*) as total'),
|
||||
DB::raw("SUM(CAST(value AS {$castType})) as total_value"),
|
||||
)
|
||||
->groupBy('action', 'label')
|
||||
->orderByDesc('total')
|
||||
->get();
|
||||
}
|
||||
|
||||
// ---- Events: Trending ----
|
||||
|
||||
public function eventTrending(string $eventName): Collection
|
||||
{
|
||||
$days = $this->from->diffInDays($this->to);
|
||||
$groupFormat = $days <= 2 ? '%Y-%m-%d %H:00' : '%Y-%m-%d';
|
||||
$carbonFormat = $days <= 2 ? 'Y-m-d H:00' : 'Y-m-d';
|
||||
|
||||
$data = \App\Models\Event::where('website_id', $this->website->id)
|
||||
->whereBetween('occurred_at', [$this->from, $this->to])
|
||||
->where('name', $eventName)
|
||||
->select(
|
||||
DB::raw($this->dateFormat($groupFormat, 'occurred_at').' as period'),
|
||||
DB::raw('COUNT(*) as total'),
|
||||
)
|
||||
->groupBy('period')
|
||||
->orderBy('period')
|
||||
->get()
|
||||
->keyBy('period');
|
||||
|
||||
$periods = collect();
|
||||
$cursor = $this->from->copy();
|
||||
$increment = $days <= 2 ? 'addHour' : 'addDay';
|
||||
|
||||
while ($cursor->lte($this->to)) {
|
||||
$key = $cursor->format($carbonFormat);
|
||||
$periods->push([
|
||||
'period' => $key,
|
||||
'total' => $data->get($key)?->total ?? 0,
|
||||
]);
|
||||
$cursor->$increment();
|
||||
}
|
||||
|
||||
return $periods;
|
||||
}
|
||||
|
||||
// ---- Helpers ----
|
||||
|
||||
private function visitorGroupedBy(string $column, int $limit): Collection
|
||||
{
|
||||
return Visitor::where('website_id', $this->website->id)
|
||||
->whereNotNull($column)
|
||||
->where($column, '!=', '')
|
||||
->whereHas('pageViews', fn ($q) => $q->whereBetween('viewed_at', [$this->from, $this->to]))
|
||||
->select($column, DB::raw('COUNT(*) as visitors'))
|
||||
->groupBy($column)
|
||||
->orderByDesc('visitors')
|
||||
->limit($limit)
|
||||
->get();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the appropriate date formatting function for the current database driver.
|
||||
*/
|
||||
private function dateFormat(string $format, string $column): string
|
||||
{
|
||||
$driver = DB::connection()->getDriverName();
|
||||
|
||||
if ($driver === 'sqlite') {
|
||||
return "strftime('{$format}', {$column})";
|
||||
}
|
||||
|
||||
// MySQL/MariaDB
|
||||
// Convert SQLite strftime format to MySQL DATE_FORMAT
|
||||
$mysqlFormat = str_replace(
|
||||
['%Y', '%m', '%d', '%H', '%M', '%S'],
|
||||
['%Y', '%m', '%d', '%H', '%i', '%s'],
|
||||
$format
|
||||
);
|
||||
|
||||
return "DATE_FORMAT({$column}, '{$mysqlFormat}')";
|
||||
}
|
||||
|
||||
// ---- Conversions ----
|
||||
|
||||
public function conversionsReport(): Collection
|
||||
{
|
||||
$goals = \App\Models\Goal::where('website_id', $this->website->id)->get();
|
||||
$totalSessions = $this->totalSessions();
|
||||
|
||||
return $goals->map(function ($goal) use ($totalSessions) {
|
||||
$completions = \App\Models\GoalConversion::where('goal_id', $goal->id)
|
||||
->whereBetween('converted_at', [$this->from, $this->to])
|
||||
->count();
|
||||
|
||||
$totalValue = \App\Models\GoalConversion::where('goal_id', $goal->id)
|
||||
->whereBetween('converted_at', [$this->from, $this->to])
|
||||
->sum('value');
|
||||
|
||||
return (object) [
|
||||
'id' => $goal->id,
|
||||
'name' => $goal->name,
|
||||
'type' => $goal->type,
|
||||
'is_active' => $goal->is_active,
|
||||
'completions' => $completions,
|
||||
'conversion_rate' => $totalSessions > 0 ? round(($completions / $totalSessions) * 100, 2) : 0,
|
||||
'total_value' => $totalValue,
|
||||
];
|
||||
});
|
||||
}
|
||||
|
||||
public function goalCompletionsOverTime(): Collection
|
||||
{
|
||||
$days = $this->from->diffInDays($this->to);
|
||||
$groupFormat = $days <= 2 ? '%Y-%m-%d %H:00' : '%Y-%m-%d';
|
||||
$carbonFormat = $days <= 2 ? 'Y-m-d H:00' : 'Y-m-d';
|
||||
|
||||
$data = \App\Models\GoalConversion::where('website_id', $this->website->id)
|
||||
->whereBetween('converted_at', [$this->from, $this->to])
|
||||
->select(
|
||||
DB::raw($this->dateFormat($groupFormat, 'converted_at').' as period'),
|
||||
DB::raw('COUNT(*) as completions'),
|
||||
)
|
||||
->groupBy('period')
|
||||
->orderBy('period')
|
||||
->get()
|
||||
->keyBy('period');
|
||||
|
||||
$periods = collect();
|
||||
$cursor = $this->from->copy();
|
||||
$increment = $days <= 2 ? 'addHour' : 'addDay';
|
||||
|
||||
while ($cursor->lte($this->to)) {
|
||||
$key = $cursor->format($carbonFormat);
|
||||
$periods->push([
|
||||
'period' => $key,
|
||||
'completions' => $data->get($key)?->completions ?? 0,
|
||||
]);
|
||||
$cursor->$increment();
|
||||
}
|
||||
|
||||
return $periods;
|
||||
}
|
||||
|
||||
// ---- Real-Time ----
|
||||
|
||||
public static function activeVisitors(Website $website, int $minutes = 5): int
|
||||
{
|
||||
return AnalyticsSession::where('website_id', $website->id)
|
||||
->where('ended_at', '>=', Carbon::now()->subMinutes($minutes))
|
||||
->distinct('visitor_id')
|
||||
->count('visitor_id');
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\AnalyticsSession;
|
||||
use App\Models\Goal;
|
||||
use App\Models\GoalConversion;
|
||||
use App\Models\Visitor;
|
||||
use App\Models\Website;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Collection;
|
||||
|
||||
class GoalEvaluationService
|
||||
{
|
||||
public function evaluate(
|
||||
Website $website,
|
||||
Visitor $visitor,
|
||||
AnalyticsSession $session,
|
||||
array $payload,
|
||||
Carbon $now,
|
||||
): void {
|
||||
$goals = $website->goals()->where('is_active', true)->get();
|
||||
|
||||
foreach ($goals as $goal) {
|
||||
if ($this->alreadyConverted($goal, $session)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($this->matches($goal, $session, $payload)) {
|
||||
GoalConversion::create([
|
||||
'goal_id' => $goal->id,
|
||||
'website_id' => $website->id,
|
||||
'visitor_id' => $visitor->id,
|
||||
'analytics_session_id' => $session->id,
|
||||
'value' => $goal->default_value,
|
||||
'converted_at' => $now,
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private function alreadyConverted(Goal $goal, AnalyticsSession $session): bool
|
||||
{
|
||||
return GoalConversion::where('goal_id', $goal->id)
|
||||
->where('analytics_session_id', $session->id)
|
||||
->exists();
|
||||
}
|
||||
|
||||
private function matches(Goal $goal, AnalyticsSession $session, array $payload): bool
|
||||
{
|
||||
return match ($goal->type) {
|
||||
'url_destination' => $this->matchesUrlDestination($goal, $payload),
|
||||
'event_match' => $this->matchesEvent($goal, $payload),
|
||||
'duration' => $this->matchesDuration($goal, $session),
|
||||
'pages_per_session' => $this->matchesPagesPerSession($goal, $session),
|
||||
default => false,
|
||||
};
|
||||
}
|
||||
|
||||
private function matchesUrlDestination(Goal $goal, array $payload): bool
|
||||
{
|
||||
$conditions = $goal->conditions;
|
||||
$path = $payload['path'] ?? '';
|
||||
$targetPath = $conditions['path'] ?? '';
|
||||
$matchType = $conditions['match_type'] ?? 'exact';
|
||||
|
||||
if (! $path || ! $targetPath) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return match ($matchType) {
|
||||
'exact' => $path === $targetPath,
|
||||
'starts_with' => str_starts_with($path, $targetPath),
|
||||
'contains' => str_contains($path, $targetPath),
|
||||
'regex' => (bool) @preg_match($targetPath, $path),
|
||||
default => false,
|
||||
};
|
||||
}
|
||||
|
||||
private function matchesEvent(Goal $goal, array $payload): bool
|
||||
{
|
||||
if (($payload['type'] ?? '') !== 'event') {
|
||||
return false;
|
||||
}
|
||||
|
||||
$conditions = $goal->conditions;
|
||||
$eventName = $conditions['event_name'] ?? '';
|
||||
|
||||
if (! $eventName || ($payload['event_name'] ?? '') !== $eventName) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$requiredCategory = $conditions['event_category'] ?? null;
|
||||
if ($requiredCategory && ($payload['event_category'] ?? '') !== $requiredCategory) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private function matchesDuration(Goal $goal, AnalyticsSession $session): bool
|
||||
{
|
||||
$requiredSeconds = $goal->conditions['seconds'] ?? 0;
|
||||
|
||||
return $session->duration >= $requiredSeconds;
|
||||
}
|
||||
|
||||
private function matchesPagesPerSession(Goal $goal, AnalyticsSession $session): bool
|
||||
{
|
||||
$requiredPages = $goal->conditions['page_count'] ?? 0;
|
||||
|
||||
return $session->pages_count >= $requiredPages;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
<?php
|
||||
|
||||
namespace App\View\Components;
|
||||
|
||||
use Illuminate\View\Component;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class AppLayout extends Component
|
||||
{
|
||||
/**
|
||||
* Get the view / contents that represents the component.
|
||||
*/
|
||||
public function render(): View
|
||||
{
|
||||
return view('layouts.app');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
<?php
|
||||
|
||||
namespace App\View\Components;
|
||||
|
||||
use Illuminate\View\Component;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class GuestLayout extends Component
|
||||
{
|
||||
/**
|
||||
* Get the view / contents that represents the component.
|
||||
*/
|
||||
public function render(): View
|
||||
{
|
||||
return view('layouts.guest');
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user