libra/docs/stories/story-6.10-audit-log-viewer.md

16 KiB

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 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

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

use App\Models\AdminLog;
use Livewire\Volt\Component;
use Livewire\WithPagination;
use Symfony\Component\HttpFoundation\StreamedResponse;

new class extends Component {
    use WithPagination;

    public string $actionFilter = '';
    public string $targetFilter = '';
    public string $dateFrom = '';
    public string $dateTo = '';
    public string $search = '';
    public ?int $selectedLogId = null;

    public function updatedActionFilter(): void { $this->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();
    }
}; ?>

<div>
    <!-- Filters Section -->
    <!-- Table Section -->
    <!-- Pagination -->
    <!-- Detail Modal -->
</div>

Template Structure

<div class="space-y-6">
    {{-- Filters --}}
    <div class="flex flex-wrap gap-4">
        <flux:select wire:model.live="actionFilter" placeholder="{{ __('audit.filter_action') }}">
            <flux:select.option value="">{{ __('audit.all_actions') }}</flux:select.option>
            @foreach($actionTypes as $type)
                <flux:select.option value="{{ $type }}">{{ __("audit.action_{$type}") }}</flux:select.option>
            @endforeach
        </flux:select>

        <flux:select wire:model.live="targetFilter" placeholder="{{ __('audit.filter_target') }}">
            <flux:select.option value="">{{ __('audit.all_targets') }}</flux:select.option>
            @foreach($targetTypes as $type)
                <flux:select.option value="{{ $type }}">{{ __("audit.target_{$type}") }}</flux:select.option>
            @endforeach
        </flux:select>

        <flux:input type="date" wire:model.live="dateFrom" />
        <flux:input type="date" wire:model.live="dateTo" />
        <flux:input wire:model.live.debounce.300ms="search" placeholder="{{ __('audit.search_target_id') }}" />

        <flux:button wire:click="resetFilters" variant="ghost">{{ __('audit.reset') }}</flux:button>
        <flux:button wire:click="exportCsv">{{ __('audit.export_csv') }}</flux:button>
    </div>

    {{-- Table --}}
    @if($logs->isEmpty())
        <flux:callout>{{ __('audit.no_logs_found') }}</flux:callout>
    @else
        <table class="min-w-full">
            <thead>
                <tr>
                    <th>{{ __('audit.timestamp') }}</th>
                    <th>{{ __('audit.admin') }}</th>
                    <th>{{ __('audit.action') }}</th>
                    <th>{{ __('audit.target') }}</th>
                    <th>{{ __('audit.ip_address') }}</th>
                    <th></th>
                </tr>
            </thead>
            <tbody>
                @foreach($logs as $log)
                    <tr wire:key="log-{{ $log->id }}">
                        <td>{{ $log->created_at->translatedFormat(app()->getLocale() === 'ar' ? 'd/m/Y H:i' : 'm/d/Y H:i') }}</td>
                        <td>{{ $log->admin?->name ?? __('audit.system') }}</td>
                        <td>
                            <flux:badge
                                color="{{ match($log->action) {
                                    'create' => 'green',
                                    'update' => 'blue',
                                    'delete' => 'red',
                                    default => 'zinc'
                                } }}">
                                {{ __("audit.action_{$log->action}") }}
                            </flux:badge>
                        </td>
                        <td>{{ __("audit.target_{$log->target_type}") }} #{{ $log->target_id }}</td>
                        <td>{{ $log->ip_address }}</td>
                        <td>
                            <flux:button size="sm" wire:click="showDetails({{ $log->id }})">
                                {{ __('audit.details') }}
                            </flux:button>
                        </td>
                    </tr>
                @endforeach
            </tbody>
        </table>

        {{ $logs->links() }}
    @endif

    {{-- Detail Modal --}}
    <flux:modal name="log-details">
        @if($selectedLog)
            <flux:heading>{{ __('audit.log_details') }}</flux:heading>

            @if($selectedLog->old_values)
                <div>
                    <strong>{{ __('audit.old_values') }}:</strong>
                    <pre class="bg-zinc-100 p-2 rounded text-sm overflow-auto">{{ json_encode($selectedLog->old_values, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE) }}</pre>
                </div>
            @endif

            @if($selectedLog->new_values)
                <div>
                    <strong>{{ __('audit.new_values') }}:</strong>
                    <pre class="bg-zinc-100 p-2 rounded text-sm overflow-auto">{{ json_encode($selectedLog->new_values, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE) }}</pre>
                </div>
            @endif
        @endif
    </flux:modal>
</div>

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

// 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