mad-lawsuit/app/Http/Controllers/Admin/DocumentAccessController.php
friday-bot 041fb91182 Add unique visitor total and CSV export to document access
Surfaces distinct visitors for the selected range as a headline figure, and
lets the current view be exported for use in filings without retyping.

Unique visitors are counted across the range as a whole rather than summed
per document, so one person pulling five filings counts once. Where visitor
IPs were never recorded the CSV leaves the cell blank instead of writing 0,
which would otherwise read as "nobody downloaded this".
2026-07-30 13:49:10 -06:00

199 lines
6.8 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;
use Symfony\Component\HttpFoundation\StreamedResponse;
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(),
// Distinct across the whole range, not the sum of the per-document
// column: one person pulling five documents is one visitor.
'unique_visitors' => $this->uniqueVisitors($from, $to, $includeBots),
],
// 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)),
]);
}
/**
* Export the current view as CSV, so figures can be cited or filed
* without retyping them.
*/
public function export(Request $request): StreamedResponse
{
$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);
$ipDataStarts = Carbon::parse(self::IP_DATA_STARTS);
$filename = sprintf(
'document-access-%s-to-%s.csv',
$from->toDateString(),
$to->toDateString()
);
return response()->streamDownload(function () use ($documents, $from, $ipDataStarts) {
$out = fopen('php://output', 'w');
fputcsv($out, ['Document', 'Downloads', 'Unique visitors', 'Last accessed']);
foreach ($documents as $row) {
fputcsv($out, [
$row['title'],
$row['downloads'],
// Blank rather than 0 where the figure is unknowable, so the
// spreadsheet cannot be read as "nobody downloaded this".
$row['unique_visitors'] === 0 ? '' : $row['unique_visitors'],
$row['last_accessed'],
]);
}
if ($from->lt($ipDataStarts)) {
fputcsv($out, []);
fputcsv($out, [
'Note: visitor IP addresses were only recorded from '
.$ipDataStarts->toDateString()
.'. Unique visitors are blank for earlier periods.',
]);
}
fclose($out);
}, $filename, ['Content-Type' => 'text/csv']);
}
/**
* Distinct visitors across the range as a whole.
*/
private function uniqueVisitors(Carbon $from, Carbon $to, bool $includeBots): int
{
$query = DocumentAccess::query()
->whereBetween('accessed_at', [$from, $to])
->whereNotNull('ip_address');
if (! $includeBots) {
$query->excludingBots();
}
return (int) $query->distinct()->count('ip_address');
}
/**
* 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,
]);
}
}