Files
filament-short-url/src/Console/Commands/AggregateAndPruneVisitsCommand.php

208 lines
9.2 KiB
PHP
Raw Normal View History

<?php
namespace Bjanczak\FilamentShortUrl\Console\Commands;
use Bjanczak\FilamentShortUrl\Models\ShortUrlDailyStats;
use Bjanczak\FilamentShortUrl\Models\ShortUrlVisit;
use Illuminate\Console\Command;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Storage;
class AggregateAndPruneVisitsCommand extends Command
{
/** @var string */
protected $signature = 'short-url:aggregate-and-prune';
/** @var string */
protected $description = 'Aggregate past days visits into daily stats and prune old raw visit logs';
public function handle(): int
{
$today = Carbon::today()->toDateString();
$driver = DB::connection()->getDriverName();
$dateExpression = match ($driver) {
'pgsql' => 'visited_at::date',
'sqlsrv' => 'CAST(visited_at AS DATE)',
default => 'DATE(visited_at)',
};
// 1. Find all unique dates before today that have visits (optimized range and compatible DATE extract)
$dates = ShortUrlVisit::where('visited_at', '<', $today)
->selectRaw("{$dateExpression} as visit_date")
->distinct()
->pluck('visit_date')
->toArray();
if (empty($dates)) {
$this->info('No historical visits to aggregate.');
} else {
$this->info('Found '.count($dates).' days to aggregate.');
foreach ($dates as $date) {
// Wrap date aggregation in a database transaction for data integrity
DB::transaction(function () use ($date): void {
$nextDate = Carbon::parse($date)->addDay()->toDateString();
$start = $date.' 00:00:00';
$end = $nextDate.' 00:00:00';
// Driver-aware boolean count: MySQL/SQLite store booleans as TINYINT (= 1),
// PostgreSQL uses a native boolean type (cast to int for aggregation).
$driver = DB::connection()->getDriverName();
$qrExpr = $driver === 'pgsql'
? 'count(case when is_qr_scan::int = 1 then 1 end) as qr_scans'
: 'count(case when is_qr_scan = 1 then 1 end) as qr_scans';
$totals = DB::table('short_url_visits')
->where('visited_at', '>=', $start)
->where('visited_at', '<', $end)
->select([
'short_url_id',
DB::raw('count(*) as total'),
DB::raw('count(distinct ip_hash) as uniques'),
DB::raw($qrExpr),
])
->groupBy('short_url_id')
->get();
if ($totals->isEmpty()) {
return;
}
$statsByUrl = [];
foreach ($totals as $row) {
$statsByUrl[$row->short_url_id] = [
'total' => (int) $row->total,
'uniques' => (int) $row->uniques,
'qr_scans' => (int) $row->qr_scans,
'device_stats' => [],
'browser_stats' => [],
'os_stats' => [],
'country_stats' => [],
'city_stats' => [],
'referer_stats' => [],
'utm_source_stats' => [],
'utm_medium_stats' => [],
'utm_campaign_stats' => [],
'language_stats' => [],
'variant_stats' => [],
];
}
// Helper to fetch and populate category stats natively in database GROUP BY
$populateStats = function (string $column, string $statsKey) use ($start, $end, &$statsByUrl): void {
$urlIds = array_keys($statsByUrl);
if (empty($urlIds)) {
return;
}
$query = DB::table('short_url_visits')
->where('visited_at', '>=', $start)
->where('visited_at', '<', $end)
->whereIn('short_url_id', $urlIds)
->whereNotNull($column)
->where($column, '<>', '');
if ($statsKey === 'city_stats') {
$query->select(['short_url_id', 'city', 'country_code', DB::raw('count(*) as count')])
->groupBy(['short_url_id', 'city', 'country_code']);
} else {
$query->select(['short_url_id', $column, DB::raw('count(*) as count')])
->groupBy(['short_url_id', $column]);
}
$rows = $query->get();
foreach ($rows as $row) {
$urlId = $row->short_url_id;
if (! isset($statsByUrl[$urlId])) {
continue;
}
if ($statsKey === 'city_stats') {
$cityVal = $row->city;
$countryCode = $row->country_code;
$val = $countryCode ? "{$cityVal} ({$countryCode})" : $cityVal;
} else {
$val = $row->$column;
}
$statsByUrl[$urlId][$statsKey][$val] = (int) $row->count;
}
};
// Populate all categories via 11 quick indexed database aggregations
$populateStats('device_type', 'device_stats');
$populateStats('browser', 'browser_stats');
$populateStats('operating_system', 'os_stats');
$populateStats('country_code', 'country_stats');
$populateStats('city', 'city_stats');
$populateStats('referer_host', 'referer_stats');
$populateStats('utm_source', 'utm_source_stats');
$populateStats('utm_medium', 'utm_medium_stats');
$populateStats('utm_campaign', 'utm_campaign_stats');
$populateStats('browser_language', 'language_stats');
$populateStats('selected_variant', 'variant_stats');
// Write aggregated stats to ShortUrlDailyStats
foreach ($statsByUrl as $urlId => $s) {
ShortUrlDailyStats::updateOrCreate([
'short_url_id' => $urlId,
'date' => $date,
], [
'visits_count' => $s['total'],
'unique_visits_count' => $s['uniques'],
'device_stats' => $s['device_stats'],
'browser_stats' => $s['browser_stats'],
'os_stats' => $s['os_stats'],
'country_stats' => $s['country_stats'],
'city_stats' => $s['city_stats'],
'referer_stats' => $s['referer_stats'],
'utm_source_stats' => $s['utm_source_stats'],
'utm_medium_stats' => $s['utm_medium_stats'],
'utm_campaign_stats' => $s['utm_campaign_stats'],
'qr_visits_count' => $s['qr_scans'],
'language_stats' => $s['language_stats'],
'variant_stats' => $s['variant_stats'],
]);
}
});
$this->info("Aggregated stats for {$date}.");
}
}
// 2. Prune old visits if enabled
if (config('filament-short-url.pruning.enabled', true)) {
$retentionDays = (int) config('filament-short-url.pruning.retention_days', 90);
$cutoff = Carbon::now()->subDays($retentionDays)->toDateTimeString();
$deleted = ShortUrlVisit::where('visited_at', '<', $cutoff)->delete();
$this->info("Successfully pruned {$deleted} raw visit records older than {$retentionDays} days.");
}
// 3. Prune old temporary logo files (older than 24 hours)
$disk = Storage::disk('public');
if ($disk->exists('short-urls/tmp')) {
$files = $disk->files('short-urls/tmp');
$now = time();
$prunedCount = 0;
foreach ($files as $file) {
$lastModified = $disk->lastModified($file);
if (($now - $lastModified) > 86400) { // 86400 seconds = 24 hours
$disk->delete($file);
$prunedCount++;
}
}
if ($prunedCount > 0) {
$this->info("Successfully pruned {$prunedCount} temporary logo files older than 24 hours.");
}
}
return 0;
}
}