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, ]); } }