diff --git a/app/Http/Controllers/Admin/DocumentAccessController.php b/app/Http/Controllers/Admin/DocumentAccessController.php index 5073a893..4cc8e6ae 100644 --- a/app/Http/Controllers/Admin/DocumentAccessController.php +++ b/app/Http/Controllers/Admin/DocumentAccessController.php @@ -9,6 +9,7 @@ use Illuminate\Http\Request; use Illuminate\Support\Facades\DB; use Inertia\Inertia; use Inertia\Response; +use Symfony\Component\HttpFoundation\StreamedResponse; class DocumentAccessController extends Controller { @@ -53,6 +54,9 @@ class DocumentAccessController extends Controller '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, @@ -60,6 +64,81 @@ class DocumentAccessController extends Controller ]); } + /** + * 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. diff --git a/resources/js/Pages/Admin/DocumentAccess.vue b/resources/js/Pages/Admin/DocumentAccess.vue index f914fc3c..8f232864 100644 --- a/resources/js/Pages/Admin/DocumentAccess.vue +++ b/resources/js/Pages/Admin/DocumentAccess.vue @@ -19,7 +19,7 @@ interface Props { documents: DocumentRow[]; daily: DailyRow[]; filters: { from: string; to: string; include_bots: boolean }; - totals: { downloads: number; documents_touched: number }; + totals: { downloads: number; documents_touched: number; unique_visitors: number }; ip_data_starts: string; range_predates_ip_data: boolean; } @@ -49,6 +49,17 @@ const preset = (days: number) => { // Scale the inline bar chart to the busiest day in range. const peak = computed(() => Math.max(1, ...props.daily.map((d) => d.downloads))); + +// Keep the CSV in step with whatever range is currently on screen. +const exportUrl = computed(() => { + const params = new URLSearchParams({ + from: from.value, + to: to.value, + include_bots: includeBots.value ? '1' : '0', + }); + + return `${route('admin.document-access.export')}?${params.toString()}`; +});