# Story 6.10: Audit Log Viewer ## Epic Reference **Epic 6:** Admin Dashboard ## User Story As an **admin**, I want **to view admin action history**, So that **I can maintain accountability and track changes**. ## Dependencies - **Story 1.1:** Project Setup & Database Schema (admin_logs table and AdminLog model) - **Story 1.2:** Authentication & Role System (admin auth middleware) - **Story 6.1:** Dashboard Overview & Statistics (admin layout, navigation patterns) ## Navigation Context - Accessible from admin dashboard sidebar/navigation - Route: `/admin/audit-logs` - Named route: `admin.audit-logs` ## Background Context ### What Gets Logged The `admin_logs` table captures significant admin actions for accountability: - **User Management:** Create, update, delete, deactivate/reactivate users - **Consultation Management:** Approve, reject, mark complete, mark no-show, cancel bookings - **Timeline Management:** Create, update, archive timelines and timeline entries - **Posts Management:** Create, update, delete posts - **Settings Changes:** System configuration modifications ### AdminLog Model Schema Reference: PRD ยง16.1 Database Schema ``` admin_logs table: - id: bigint (primary key) - admin_id: bigint (nullable, foreign key to users - null for system actions) - action: string (create, update, delete, approve, reject, etc.) - target_type: string (user, consultation, timeline, timeline_update, post, setting) - target_id: bigint (ID of the affected record) - old_values: json (nullable - previous state for updates) - new_values: json (nullable - new state for creates/updates) - ip_address: string (IPv4/IPv6 address of admin) - created_at: timestamp ``` ## Acceptance Criteria ### Display Requirements - [ ] Paginated table of audit log entries (25 per page) - [ ] Each row displays: - Timestamp (formatted per locale: d/m/Y H:i for AR, m/d/Y H:i for EN) - Admin name (or "System" for automated actions) - Action type badge (color-coded: green=create, blue=update, red=delete) - Target type and ID (e.g., "User #123") - IP address - Details button to view old/new values ### Filtering - [ ] Filter by action type (dropdown with available actions) - [ ] Filter by target type (dropdown with available targets) - [ ] Filter by date range (date pickers for from/to) - [ ] Filters apply without page reload (Livewire) - [ ] Reset filters button ### Search - [ ] Search by target ID (exact match) - [ ] Search updates results in real-time with debounce ### Detail Modal - [ ] Modal displays full log entry details - [ ] Shows old values in readable format (JSON pretty-printed) - [ ] Shows new values in readable format - [ ] Highlights differences between old/new for updates - [ ] Bilingual labels ### CSV Export - [ ] Export current filtered results to CSV - [ ] CSV includes all displayed columns - [ ] CSV filename includes export date - [ ] Bilingual column headers based on admin's language preference ### Empty State - [ ] Friendly message when no logs exist - [ ] Friendly message when filters return no results ## Technical Notes ### Files to Create/Modify - `resources/views/livewire/admin/audit-logs.blade.php` - Main Volt component - `app/Models/AdminLog.php` - Eloquent model (if not already created in Story 1.1) - `routes/web.php` - Add admin route (or admin routes file) - `lang/ar/audit.php` - Arabic translations - `lang/en/audit.php` - English translations ### AdminLog Model ```php class AdminLog extends Model { protected $fillable = [ 'admin_id', 'action', 'target_type', 'target_id', 'old_values', 'new_values', 'ip_address', ]; protected function casts(): array { return [ 'old_values' => 'array', 'new_values' => 'array', ]; } public function admin(): BelongsTo { return $this->belongsTo(User::class, 'admin_id'); } } ``` ### Volt Component Structure ```php resetPage(); } public function updatedTargetFilter(): void { $this->resetPage(); } public function updatedDateFrom(): void { $this->resetPage(); } public function updatedDateTo(): void { $this->resetPage(); } public function updatedSearch(): void { $this->resetPage(); } public function resetFilters(): void { $this->reset(['actionFilter', 'targetFilter', 'dateFrom', 'dateTo', 'search']); $this->resetPage(); } public function showDetails(int $logId): void { $this->selectedLogId = $logId; $this->dispatch('open-modal', name: 'log-details'); } public function with(): array { return [ 'logs' => $this->getFilteredQuery()->paginate(25), 'actionTypes' => AdminLog::distinct()->pluck('action'), 'targetTypes' => AdminLog::distinct()->pluck('target_type'), 'selectedLog' => $this->selectedLogId ? AdminLog::with('admin')->find($this->selectedLogId) : null, ]; } public function exportCsv(): StreamedResponse { $logs = $this->getFilteredQuery()->get(); return response()->streamDownload(function () use ($logs) { $handle = fopen('php://output', 'w'); // Header row (bilingual based on locale) fputcsv($handle, [ __('audit.timestamp'), __('audit.admin'), __('audit.action'), __('audit.target_type'), __('audit.target_id'), __('audit.ip_address'), ]); foreach ($logs as $log) { fputcsv($handle, [ $log->created_at->format('Y-m-d H:i:s'), $log->admin?->name ?? __('audit.system'), $log->action, $log->target_type, $log->target_id, $log->ip_address, ]); } fclose($handle); }, 'audit-log-' . now()->format('Y-m-d') . '.csv'); } private function getFilteredQuery() { return AdminLog::query() ->with('admin') ->when($this->actionFilter, fn($q) => $q->where('action', $this->actionFilter)) ->when($this->targetFilter, fn($q) => $q->where('target_type', $this->targetFilter)) ->when($this->dateFrom, fn($q) => $q->whereDate('created_at', '>=', $this->dateFrom)) ->when($this->dateTo, fn($q) => $q->whereDate('created_at', '<=', $this->dateTo)) ->when($this->search, fn($q) => $q->where('target_id', $this->search)) ->latest(); } }; ?>
``` ### Template Structure ```blade
{{-- Filters --}}
{{ __('audit.all_actions') }} @foreach($actionTypes as $type) {{ __("audit.action_{$type}") }} @endforeach {{ __('audit.all_targets') }} @foreach($targetTypes as $type) {{ __("audit.target_{$type}") }} @endforeach {{ __('audit.reset') }} {{ __('audit.export_csv') }}
{{-- Table --}} @if($logs->isEmpty()) {{ __('audit.no_logs_found') }} @else @foreach($logs as $log) @endforeach
{{ __('audit.timestamp') }} {{ __('audit.admin') }} {{ __('audit.action') }} {{ __('audit.target') }} {{ __('audit.ip_address') }}
{{ $log->created_at->translatedFormat(app()->getLocale() === 'ar' ? 'd/m/Y H:i' : 'm/d/Y H:i') }} {{ $log->admin?->name ?? __('audit.system') }} {{ __("audit.action_{$log->action}") }} {{ __("audit.target_{$log->target_type}") }} #{{ $log->target_id }} {{ $log->ip_address }} {{ __('audit.details') }}
{{ $logs->links() }} @endif {{-- Detail Modal --}} @if($selectedLog) {{ __('audit.log_details') }} @if($selectedLog->old_values)
{{ __('audit.old_values') }}:
{{ json_encode($selectedLog->old_values, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE) }}
@endif @if($selectedLog->new_values)
{{ __('audit.new_values') }}:
{{ json_encode($selectedLog->new_values, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE) }}
@endif @endif
``` ### Action Badge Colors | Action | Color | Meaning | |--------|-------|---------| | create | green | New record created | | update | blue | Record modified | | delete | red | Record removed | | approve | green | Booking approved | | reject | red | Booking rejected | | archive | zinc | Timeline archived | ## Testing Requirements ### Test Scenarios ```php // tests/Feature/Admin/AuditLogTest.php test('audit log page requires admin authentication', function () { $this->get(route('admin.audit-logs')) ->assertRedirect(route('login')); }); test('audit log page loads for admin', function () { $admin = User::factory()->admin()->create(); $this->actingAs($admin) ->get(route('admin.audit-logs')) ->assertOk() ->assertSeeLivewire('admin.audit-logs'); }); test('audit logs display in table', function () { $admin = User::factory()->admin()->create(); AdminLog::factory()->count(5)->create(); Volt::test('admin.audit-logs') ->actingAs($admin) ->assertSee('audit.timestamp'); }); test('can filter by action type', function () { $admin = User::factory()->admin()->create(); AdminLog::factory()->create(['action' => 'create']); AdminLog::factory()->create(['action' => 'delete']); Volt::test('admin.audit-logs') ->actingAs($admin) ->set('actionFilter', 'create') ->assertSee('create') ->assertDontSee('delete'); }); test('can filter by target type', function () { $admin = User::factory()->admin()->create(); AdminLog::factory()->create(['target_type' => 'user']); AdminLog::factory()->create(['target_type' => 'consultation']); Volt::test('admin.audit-logs') ->actingAs($admin) ->set('targetFilter', 'user') ->assertSee('user'); }); test('can filter by date range', function () { $admin = User::factory()->admin()->create(); AdminLog::factory()->create(['created_at' => now()->subDays(10)]); AdminLog::factory()->create(['created_at' => now()]); Volt::test('admin.audit-logs') ->actingAs($admin) ->set('dateFrom', now()->subDays(5)->format('Y-m-d')) ->set('dateTo', now()->format('Y-m-d')) ->assertViewHas('logs', fn($logs) => $logs->count() === 1); }); test('can search by target id', function () { $admin = User::factory()->admin()->create(); AdminLog::factory()->create(['target_id' => 123]); AdminLog::factory()->create(['target_id' => 456]); Volt::test('admin.audit-logs') ->actingAs($admin) ->set('search', '123') ->assertViewHas('logs', fn($logs) => $logs->count() === 1); }); test('can view log details in modal', function () { $admin = User::factory()->admin()->create(); $log = AdminLog::factory()->create([ 'old_values' => ['name' => 'Old Name'], 'new_values' => ['name' => 'New Name'], ]); Volt::test('admin.audit-logs') ->actingAs($admin) ->call('showDetails', $log->id) ->assertSet('selectedLogId', $log->id); }); test('can export filtered logs to csv', function () { $admin = User::factory()->admin()->create(); AdminLog::factory()->count(3)->create(); Volt::test('admin.audit-logs') ->actingAs($admin) ->call('exportCsv') ->assertFileDownloaded(); }); test('displays empty state when no logs', function () { $admin = User::factory()->admin()->create(); Volt::test('admin.audit-logs') ->actingAs($admin) ->assertSee(__('audit.no_logs_found')); }); test('pagination works correctly', function () { $admin = User::factory()->admin()->create(); AdminLog::factory()->count(50)->create(); Volt::test('admin.audit-logs') ->actingAs($admin) ->assertViewHas('logs', fn($logs) => $logs->count() === 25); }); test('reset filters clears all filters', function () { $admin = User::factory()->admin()->create(); Volt::test('admin.audit-logs') ->actingAs($admin) ->set('actionFilter', 'create') ->set('targetFilter', 'user') ->set('search', '123') ->call('resetFilters') ->assertSet('actionFilter', '') ->assertSet('targetFilter', '') ->assertSet('search', ''); }); ``` ## Edge Cases & Error Handling - **Empty state:** Display friendly message when no logs exist or filters return nothing - **Deleted admin:** Show "System" or "Deleted User" if admin_id references deleted user - **Large JSON values:** Truncate display in table, show full in modal with scroll - **IPv6 addresses:** Ensure column width accommodates longer IP addresses - **Missing target:** Handle gracefully if target record was deleted (show ID only) - **Pagination reset:** Reset to page 1 when filters change ## Definition of Done - [ ] Audit logs page accessible at `/admin/audit-logs` - [ ] Logs display with all required columns - [ ] All filters work correctly (action, target, date range) - [ ] Search by target ID works - [ ] Pagination displays 25 items per page - [ ] CSV export downloads with filtered data - [ ] Detail modal shows old/new values formatted - [ ] Empty state displays when appropriate - [ ] Bilingual support (AR/EN) complete - [ ] Admin-only access enforced - [ ] All tests pass - [ ] Code formatted with Pint ## Estimation **Complexity:** Medium | **Effort:** 3-4 hours