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');