Files
fotospiel-app/app/Filament/Widgets/JoinTokenTopTokensWidget.php
Codex Agent 15e19d4e8b
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
Add join token analytics dashboard and align Filament views
2026-01-04 18:21:59 +01:00

192 lines
7.0 KiB
PHP

<?php
namespace App\Filament\Widgets;
use App\Filament\Resources\EventResource;
use App\Models\Event;
use App\Models\EventJoinToken;
use Filament\Tables;
use Filament\Widgets\Concerns\InteractsWithPageFilters;
use Filament\Widgets\TableWidget as BaseWidget;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Carbon;
class JoinTokenTopTokensWidget extends BaseWidget
{
use InteractsWithPageFilters;
protected static ?int $sort = 3;
protected int|string|array $columnSpan = 'full';
protected static ?string $heading = null;
public function getHeading(): ?string
{
return __('admin.join_token_analytics.table.heading');
}
public function table(Tables\Table $table): Tables\Table
{
$filters = $this->resolveFilters();
return $table
->query(fn (): Builder => $this->buildQuery($filters))
->columns([
Tables\Columns\TextColumn::make('token_preview')
->label(__('admin.join_token_analytics.table.token'))
->copyable()
->copyMessage(__('admin.events.messages.join_link_copied')),
Tables\Columns\TextColumn::make('event_label')
->label(__('admin.join_token_analytics.table.event'))
->getStateUsing(fn (EventJoinToken $record) => $this->formatEventLabel($record->event))
->url(fn (EventJoinToken $record) => $record->event
? EventResource::getUrl('view', ['record' => $record->event])
: null)
->openUrlInNewTab(),
Tables\Columns\TextColumn::make('tenant_label')
->label(__('admin.join_token_analytics.table.tenant'))
->getStateUsing(fn (EventJoinToken $record) => $record->event?->tenant?->name ?? __('admin.common.unnamed')),
Tables\Columns\TextColumn::make('success_total')
->label(__('admin.join_token_analytics.table.success'))
->numeric(),
Tables\Columns\TextColumn::make('failure_total')
->label(__('admin.join_token_analytics.table.failures'))
->numeric(),
Tables\Columns\TextColumn::make('rate_limited_total')
->label(__('admin.join_token_analytics.table.rate_limited'))
->numeric(),
Tables\Columns\TextColumn::make('upload_total')
->label(__('admin.join_token_analytics.table.uploads'))
->numeric(),
Tables\Columns\TextColumn::make('last_seen_at')
->label(__('admin.join_token_analytics.table.last_seen'))
->since()
->placeholder('—'),
])
->paginated(false);
}
private function buildQuery(array $filters): Builder
{
$query = EventJoinToken::query()
->with(['event.tenant'])
->when($filters['event_id'], fn (Builder $builder, int $eventId) => $builder->where('event_id', $eventId))
->withCount([
'analytics as success_total' => function (Builder $builder) use ($filters) {
$this->applyAnalyticsFilters($builder, $filters)
->whereIn('event_type', self::SUCCESS_EVENTS);
},
'analytics as failure_total' => function (Builder $builder) use ($filters) {
$this->applyAnalyticsFilters($builder, $filters)
->whereIn('event_type', self::FAILURE_EVENTS);
},
'analytics as rate_limited_total' => function (Builder $builder) use ($filters) {
$this->applyAnalyticsFilters($builder, $filters)
->whereIn('event_type', self::RATE_LIMIT_EVENTS);
},
'analytics as upload_total' => function (Builder $builder) use ($filters) {
$this->applyAnalyticsFilters($builder, $filters)
->whereIn('event_type', self::UPLOAD_EVENTS);
},
])
->withMax([
'analytics as last_seen_at' => function (Builder $builder) use ($filters) {
$this->applyAnalyticsFilters($builder, $filters);
},
], 'occurred_at')
->orderByDesc('failure_total')
->orderByDesc('rate_limited_total')
->orderByDesc('success_total')
->limit(10);
return $query;
}
private function applyAnalyticsFilters(Builder $query, array $filters): Builder
{
return $query->whereBetween('occurred_at', [$filters['start'], $filters['end']]);
}
private function resolveFilters(): array
{
$eventId = $this->pageFilters['event_id'] ?? null;
$eventId = is_numeric($eventId) ? (int) $eventId : null;
$range = is_string($this->pageFilters['range'] ?? null) ? $this->pageFilters['range'] : '24h';
[$start, $end] = $this->resolveWindow($range, $eventId);
return [
'event_id' => $eventId,
'range' => $range,
'start' => $start,
'end' => $end,
];
}
private function resolveWindow(string $range, ?int $eventId): array
{
$now = now();
$start = match ($range) {
'2h' => $now->copy()->subHours(2),
'6h' => $now->copy()->subHours(6),
'12h' => $now->copy()->subHours(12),
'7d' => $now->copy()->subDays(7),
default => $now->copy()->subHours(24),
};
$end = $now;
if ($range === 'event_day' && $eventId) {
$eventDate = Event::query()->whereKey($eventId)->value('date');
if ($eventDate) {
$eventDay = Carbon::parse($eventDate);
$start = $eventDay->copy()->startOfDay();
$end = $eventDay->copy()->endOfDay();
}
}
return [$start, $end];
}
private function formatEventLabel(?Event $event): string
{
if (! $event) {
return __('admin.common.unnamed');
}
$locale = app()->getLocale();
$name = $event->name[$locale] ?? $event->name['de'] ?? $event->name['en'] ?? $event->slug ?? __('admin.common.unnamed');
$tenant = $event->tenant?->name ?? __('admin.common.unnamed');
$date = $event->date?->format('Y-m-d');
return $date ? "{$name} ({$tenant}) {$date}" : "{$name} ({$tenant})";
}
private const SUCCESS_EVENTS = [
'access_granted',
'gallery_access_granted',
];
private const RATE_LIMIT_EVENTS = [
'token_rate_limited',
'access_rate_limited',
'download_rate_limited',
];
private const FAILURE_EVENTS = [
'invalid_token',
'token_expired',
'token_revoked',
'event_not_public',
'gallery_expired',
'token_rate_limited',
'access_rate_limited',
'download_rate_limited',
];
private const UPLOAD_EVENTS = [
'upload_completed',
];
}