Records every successful document download and surfaces the counts in the admin area, so the docket owner can see which filings are being pulled and how often. - document_accesses table + DocumentAccess model - DocumentController@download records each successful download with IP and user agent - documents:import-access-log backfills history from an archived nginx log - Dashboard gains a 60-day downloads card and a most-requested list - New Document Access page with a date-range filter and per-day trend Client IPs were only captured from 2026-07-30, when the reverse proxy chain started forwarding X-Forwarded-For correctly. Rows imported from the old nginx log therefore have no IP, and the UI labels those periods as download counts rather than unique visitors instead of showing a misleading number.
120 lines
4 KiB
PHP
120 lines
4 KiB
PHP
<?php
|
|
|
|
namespace App\Http\Controllers\Admin;
|
|
|
|
use App\Http\Controllers\Controller;
|
|
use App\Models\DocumentAccess;
|
|
use Carbon\Carbon;
|
|
use Illuminate\Http\Request;
|
|
use Illuminate\Support\Facades\DB;
|
|
use Inertia\Inertia;
|
|
use Inertia\Response;
|
|
|
|
class DocumentAccessController extends Controller
|
|
{
|
|
/**
|
|
* The date real client IPs started being recorded. Before this, the nginx
|
|
* log stored only the docker gateway address, so unique-visitor counts are
|
|
* not available for earlier periods and must not be implied.
|
|
*/
|
|
private const IP_DATA_STARTS = '2026-07-30';
|
|
|
|
/**
|
|
* Per-document access counts over a selectable date range.
|
|
*/
|
|
public function index(Request $request): Response
|
|
{
|
|
$validated = $request->validate([
|
|
'from' => ['nullable', 'date'],
|
|
'to' => ['nullable', 'date'],
|
|
'include_bots' => ['nullable', 'boolean'],
|
|
]);
|
|
|
|
$from = isset($validated['from'])
|
|
? Carbon::parse($validated['from'])->startOfDay()
|
|
: Carbon::now()->subDays(60)->startOfDay();
|
|
|
|
$to = isset($validated['to'])
|
|
? Carbon::parse($validated['to'])->endOfDay()
|
|
: Carbon::now()->endOfDay();
|
|
|
|
$includeBots = (bool) ($validated['include_bots'] ?? false);
|
|
|
|
$documents = $this->documentCounts($from, $to, $includeBots);
|
|
|
|
return Inertia::render('Admin/DocumentAccess', [
|
|
'documents' => $documents,
|
|
'daily' => $this->dailyCounts($from, $to, $includeBots),
|
|
'filters' => [
|
|
'from' => $from->toDateString(),
|
|
'to' => $to->toDateString(),
|
|
'include_bots' => $includeBots,
|
|
],
|
|
'totals' => [
|
|
'downloads' => $documents->sum('downloads'),
|
|
'documents_touched' => $documents->count(),
|
|
],
|
|
// Drives the caveat shown in the UI.
|
|
'ip_data_starts' => self::IP_DATA_STARTS,
|
|
'range_predates_ip_data' => $from->lt(Carbon::parse(self::IP_DATA_STARTS)),
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* Download counts per document, plus unique visitors where the underlying
|
|
* rows actually carry an IP.
|
|
*/
|
|
private function documentCounts(Carbon $from, Carbon $to, bool $includeBots)
|
|
{
|
|
$query = DocumentAccess::query()
|
|
->join('documents', 'documents.id', '=', 'document_accesses.document_id')
|
|
->whereBetween('document_accesses.accessed_at', [$from, $to]);
|
|
|
|
if (! $includeBots) {
|
|
$query->excludingBots();
|
|
}
|
|
|
|
return $query
|
|
->groupBy('documents.id', 'documents.title')
|
|
->orderByDesc('downloads')
|
|
->get([
|
|
'documents.id',
|
|
'documents.title',
|
|
DB::raw('count(*) as downloads'),
|
|
DB::raw('count(distinct document_accesses.ip_address) as unique_visitors'),
|
|
DB::raw('max(document_accesses.accessed_at) as last_accessed'),
|
|
])
|
|
->map(fn ($row) => [
|
|
'id' => $row->id,
|
|
'title' => $row->title,
|
|
'downloads' => (int) $row->downloads,
|
|
'unique_visitors' => (int) $row->unique_visitors,
|
|
'last_accessed' => Carbon::parse($row->last_accessed)->format('m/d/Y'),
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* Downloads per day, for the trend chart.
|
|
*/
|
|
private function dailyCounts(Carbon $from, Carbon $to, bool $includeBots)
|
|
{
|
|
$query = DocumentAccess::query()
|
|
->whereBetween('accessed_at', [$from, $to]);
|
|
|
|
if (! $includeBots) {
|
|
$query->excludingBots();
|
|
}
|
|
|
|
return $query
|
|
->groupBy('day')
|
|
->orderBy('day')
|
|
->get([
|
|
DB::raw('date(accessed_at) as day'),
|
|
DB::raw('count(*) as downloads'),
|
|
])
|
|
->map(fn ($row) => [
|
|
'day' => Carbon::parse($row->day)->format('m/d'),
|
|
'downloads' => (int) $row->downloads,
|
|
]);
|
|
}
|
|
}
|