mad-lawsuit/app/Console/Commands/ImportDocumentAccessLog.php
friday-bot 04bd71aaaa Add document access statistics to admin dashboard
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.
2026-07-30 13:14:01 -06:00

118 lines
3.9 KiB
PHP

<?php
namespace App\Console\Commands;
use App\Models\Document;
use App\Models\DocumentAccess;
use Carbon\Carbon;
use Illuminate\Console\Command;
class ImportDocumentAccessLog extends Command
{
protected $signature = 'documents:import-access-log
{file : Path to an nginx access log in NCSA combined format}
{--source=nginx-backfill : Value stored in the source column}
{--dry-run : Parse and report without writing anything}';
protected $description = 'Backfill document_accesses from an archived nginx access log';
/**
* Matches e.g.
* 172.18.0.1 - - [23/May/2026:18:50:19 +0000] "GET /api/documents/83/download HTTP/1.1" 200 12345 "-" "Mozilla/5.0 ..."
*
* Only status 200 counts as an access: 499 means the client aborted the
* transfer, and 4xx/5xx never delivered the file.
*/
private const LINE = '#^\S+ \S+ \S+ \[([^\]]+)\] "GET /api/documents/(\d+)/download [^"]*" 200 \S+ "[^"]*" "([^"]*)"#';
public function handle(): int
{
$file = $this->argument('file');
if (! is_readable($file)) {
$this->error("Cannot read file: {$file}");
return self::FAILURE;
}
$source = $this->option('source');
$dryRun = $this->option('dry-run');
// Only import accesses for documents that still exist; the FK would
// reject the rest and a deleted document has no dashboard row anyway.
$knownIds = Document::pluck('id')->flip();
$handle = fopen($file, 'r');
$rows = [];
$parsed = $skippedUnknown = $unmatched = 0;
while (($line = fgets($handle)) !== false) {
if (! preg_match(self::LINE, $line, $m)) {
// Most lines are ordinary page views, not downloads.
if (str_contains($line, '/download')) {
$unmatched++;
}
continue;
}
[, $timestamp, $documentId, $userAgent] = $m;
if (! $knownIds->has((int) $documentId)) {
$skippedUnknown++;
continue;
}
$rows[] = [
'document_id' => (int) $documentId,
'accessed_at' => Carbon::createFromFormat('d/M/Y:H:i:s O', $timestamp)->utc(),
'ip_address' => null, // never recorded by the old log format
'user_agent' => $userAgent,
'source' => $source,
'created_at' => now(),
'updated_at' => now(),
];
$parsed++;
}
fclose($handle);
$this->info("Parsed {$parsed} successful downloads from {$file}");
if ($skippedUnknown > 0) {
$this->warn("Skipped {$skippedUnknown} referring to documents that no longer exist");
}
if ($unmatched > 0) {
$this->warn("Skipped {$unmatched} download lines that were not status 200 (aborted or failed)");
}
if ($dryRun) {
$this->comment('Dry run: nothing written.');
return self::SUCCESS;
}
// Idempotency: a re-run must not double-count. Every row from a given
// log carries the same source, so clearing that source first makes the
// import safely repeatable.
$existing = DocumentAccess::where('source', $source)->count();
if ($existing > 0) {
if (! $this->confirm("{$existing} rows with source '{$source}' already exist. Replace them?", true)) {
$this->comment('Aborted; nothing written.');
return self::SUCCESS;
}
DocumentAccess::where('source', $source)->delete();
}
foreach (array_chunk($rows, 500) as $chunk) {
DocumentAccess::insert($chunk);
}
$this->info('Imported '.count($rows)." rows with source '{$source}'.");
return self::SUCCESS;
}
}