From 04bd71aaaa0fb41a9175fd2b5131719e3bdc335f Mon Sep 17 00:00:00 2001 From: friday-bot Date: Thu, 30 Jul 2026 13:14:01 -0600 Subject: [PATCH] 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. --- .../Commands/ImportDocumentAccessLog.php | 118 ++++++++++ .../Controllers/Admin/DashboardController.php | 40 +++- .../Admin/DocumentAccessController.php | 120 ++++++++++ app/Http/Controllers/DocumentController.php | 14 +- app/Models/DocumentAccess.php | 48 ++++ ..._190000_create_document_accesses_table.php | 43 ++++ resources/js/Pages/Admin/Dashboard.vue | 62 +++++- resources/js/Pages/Admin/DocumentAccess.vue | 205 ++++++++++++++++++ routes/web.php | 3 + 9 files changed, 650 insertions(+), 3 deletions(-) create mode 100644 app/Console/Commands/ImportDocumentAccessLog.php create mode 100644 app/Http/Controllers/Admin/DocumentAccessController.php create mode 100644 app/Models/DocumentAccess.php create mode 100644 database/migrations/2026_07_30_190000_create_document_accesses_table.php create mode 100644 resources/js/Pages/Admin/DocumentAccess.vue diff --git a/app/Console/Commands/ImportDocumentAccessLog.php b/app/Console/Commands/ImportDocumentAccessLog.php new file mode 100644 index 00000000..9ab5c5c5 --- /dev/null +++ b/app/Console/Commands/ImportDocumentAccessLog.php @@ -0,0 +1,118 @@ +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; + } +} diff --git a/app/Http/Controllers/Admin/DashboardController.php b/app/Http/Controllers/Admin/DashboardController.php index dbeb8e13..e4a66fd0 100644 --- a/app/Http/Controllers/Admin/DashboardController.php +++ b/app/Http/Controllers/Admin/DashboardController.php @@ -5,7 +5,10 @@ namespace App\Http\Controllers\Admin; use App\Http\Controllers\Controller; use App\Models\DocketEntry; use App\Models\Document; +use App\Models\DocumentAccess; use App\Models\Subscription; +use Illuminate\Support\Carbon; +use Illuminate\Support\Facades\DB; use Inertia\Inertia; use Inertia\Response; @@ -34,7 +37,42 @@ class DashboardController extends Controller ]; }), ]; - + + $stats += $this->documentAccessSummary(); + return Inertia::render('Admin/Dashboard', $stats); } + + /** + * Headline document-access numbers for the last 60 days, plus the most + * requested documents. Crawlers are excluded so the figures reflect people. + */ + private function documentAccessSummary(): array + { + $since = Carbon::now()->subDays(60)->startOfDay(); + + return [ + 'downloads_60d' => DocumentAccess::excludingBots() + ->where('accessed_at', '>=', $since) + ->count(), + + 'top_documents_60d' => DocumentAccess::query() + ->excludingBots() + ->join('documents', 'documents.id', '=', 'document_accesses.document_id') + ->where('document_accesses.accessed_at', '>=', $since) + ->groupBy('documents.id', 'documents.title') + ->orderByDesc('downloads') + ->take(5) + ->get([ + 'documents.id', + 'documents.title', + DB::raw('count(*) as downloads'), + ]) + ->map(fn ($row) => [ + 'id' => $row->id, + 'title' => $row->title, + 'downloads' => (int) $row->downloads, + ]), + ]; + } } diff --git a/app/Http/Controllers/Admin/DocumentAccessController.php b/app/Http/Controllers/Admin/DocumentAccessController.php new file mode 100644 index 00000000..5073a893 --- /dev/null +++ b/app/Http/Controllers/Admin/DocumentAccessController.php @@ -0,0 +1,120 @@ +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, + ]); + } +} diff --git a/app/Http/Controllers/DocumentController.php b/app/Http/Controllers/DocumentController.php index 98dc22b1..7ce6b597 100644 --- a/app/Http/Controllers/DocumentController.php +++ b/app/Http/Controllers/DocumentController.php @@ -3,6 +3,7 @@ namespace App\Http\Controllers; use App\Models\Document; +use App\Models\DocumentAccess; use Illuminate\Http\Request; use Illuminate\Support\Facades\Storage; use Symfony\Component\HttpFoundation\StreamedResponse; @@ -12,7 +13,7 @@ class DocumentController extends Controller /** * Download a document by ID. */ - public function download(int $id): StreamedResponse + public function download(Request $request, int $id): StreamedResponse { $document = Document::findOrFail($id); @@ -22,6 +23,17 @@ class DocumentController extends Controller abort(404, 'Document file not found.'); } + // Record the access before streaming, so the dashboard reflects it. + // Only successful downloads reach this point, which matches how the + // historical nginx rows were filtered (status 200 only). + DocumentAccess::create([ + 'document_id' => $document->id, + 'accessed_at' => now(), + 'ip_address' => $request->ip(), + 'user_agent' => $request->userAgent(), + 'source' => 'live', + ]); + // Stream the file to the browser return Storage::disk('public')->download( $document->file_path, diff --git a/app/Models/DocumentAccess.php b/app/Models/DocumentAccess.php new file mode 100644 index 00000000..cca61f3b --- /dev/null +++ b/app/Models/DocumentAccess.php @@ -0,0 +1,48 @@ + 'datetime', + ]; + + /** + * Get the document that was accessed. + */ + public function document(): BelongsTo + { + return $this->belongsTo(Document::class); + } + + /** + * Requests whose user agent looks like a crawler rather than a person. + * Kept as a query-time filter rather than a write-time one so the raw + * record stays complete and the definition can be changed later. + */ + public function scopeExcludingBots($query) + { + foreach (['%bot%', '%crawl%', '%spider%', '%slurp%'] as $pattern) { + $query->where(function ($q) use ($pattern) { + $q->whereNull('user_agent') + ->orWhere('user_agent', 'not ilike', $pattern); + }); + } + + return $query; + } +} diff --git a/database/migrations/2026_07_30_190000_create_document_accesses_table.php b/database/migrations/2026_07_30_190000_create_document_accesses_table.php new file mode 100644 index 00000000..70689e09 --- /dev/null +++ b/database/migrations/2026_07_30_190000_create_document_accesses_table.php @@ -0,0 +1,43 @@ +id(); + $table->foreignId('document_id')->constrained()->cascadeOnDelete(); + $table->timestamp('accessed_at'); + + // Null for rows backfilled from the old nginx log: that log recorded + // only the docker gateway address, so the real client IP for anything + // before 2026-07-30 was never captured and cannot be recovered. + $table->string('ip_address', 45)->nullable(); + $table->text('user_agent')->nullable(); + + // 'nginx-backfill' = imported from the archived access log (no IP). + // 'live' = recorded by the app at download time (has IP). + $table->string('source', 20)->default('live'); + + $table->timestamps(); + + $table->index(['document_id', 'accessed_at']); + $table->index('accessed_at'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('document_accesses'); + } +}; diff --git a/resources/js/Pages/Admin/Dashboard.vue b/resources/js/Pages/Admin/Dashboard.vue index 7527fe48..77c0cee8 100644 --- a/resources/js/Pages/Admin/Dashboard.vue +++ b/resources/js/Pages/Admin/Dashboard.vue @@ -10,11 +10,19 @@ interface DocketEntry { documents_count: number; } +interface TopDocument { + id: number; + title: string; + downloads: number; +} + interface Props { total_entries: number; total_documents: number; total_subscribers: number; recent_entries: DocketEntry[]; + downloads_60d: number; + top_documents_60d: TopDocument[]; } const props = defineProps(); @@ -61,7 +69,7 @@ const logout = () => {
-
+
@@ -112,6 +120,58 @@ const logout = () => {
+ + + +
+
+

Downloads (60 days)

+

+ {{ props.downloads_60d }} +

+

Excludes crawlers

+
+
+ + + +
+
+ +
+ + +
+
+
+

Most Requested Documents

+

Last 60 days

+
+ + View all & change dates → + +
+
+
+ {{ doc.title }} + + {{ doc.downloads }} download{{ doc.downloads !== 1 ? 's' : '' }} + +
+
+ No document downloads recorded in the last 60 days. +
+
diff --git a/resources/js/Pages/Admin/DocumentAccess.vue b/resources/js/Pages/Admin/DocumentAccess.vue new file mode 100644 index 00000000..f914fc3c --- /dev/null +++ b/resources/js/Pages/Admin/DocumentAccess.vue @@ -0,0 +1,205 @@ + + + diff --git a/routes/web.php b/routes/web.php index 12e33b4b..ba93cdb3 100644 --- a/routes/web.php +++ b/routes/web.php @@ -38,6 +38,9 @@ Route::prefix('admin')->name('admin.')->group(function () { Route::post('/docket-entries/{docketEntry}/documents', [\App\Http\Controllers\Admin\DocumentController::class, 'store'])->name('documents.store'); Route::delete('/documents/{document}', [\App\Http\Controllers\Admin\DocumentController::class, 'destroy'])->name('documents.destroy'); + // Document access statistics + Route::get('/document-access', [\App\Http\Controllers\Admin\DocumentAccessController::class, 'index'])->name('document-access.index'); + // Subscriber routes Route::get('/subscribers', [\App\Http\Controllers\Admin\SubscriberController::class, 'index'])->name('subscribers.index'); Route::delete('/subscribers/{subscription}', [\App\Http\Controllers\Admin\SubscriberController::class, 'destroy'])->name('subscribers.destroy');